Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0802c52
ci(commons): add changeSummary to ModelVersion objects for typecheck
omargfh Aug 12, 2026
d017fb4
feat(commons): switch uuid/cuid to nanoid for business logic
omargfh Aug 12, 2026
b90bdfe
fix(commons): stop guessing tag id vs name by shape
omargfh Aug 12, 2026
cb898f9
feat(commons): generate request and correlation ids as nanoids
omargfh Aug 12, 2026
fb7ed19
refactor(commons): emit nanoid-shaped ids from seedId
omargfh Aug 12, 2026
52daaf6
refactor(commons): switch legacy migration row ids to nanoid
omargfh Aug 12, 2026
ec132fd
feat(commons): stop generating and accepting UUID entity ids
omargfh Aug 12, 2026
017ccf7
fix(commons): stop filenameFromKey mangling nanoid-shaped keys
omargfh Aug 12, 2026
7bd8634
test(commons): match nanoid segments in perf timing collector
omargfh Aug 12, 2026
d291b1f
docs(commons): record NanoID id convention, retire UUID from plan docs
omargfh Aug 12, 2026
eabcd9d
refactor(commons): fix rehearsal dump normalisation for nanoid keys
omargfh Aug 12, 2026
29b7763
refactor(commons): rename uuid-named identifiers to id in legacy migr…
omargfh Aug 12, 2026
4da0814
fix(commons): compare preview keys by position, not shape
omargfh Aug 12, 2026
661ed2f
fix(commons): stop autoload registering spec files as plugins
omargfh Aug 13, 2026
e578e1d
fix(commons): register the nanoid format on every ajv instance
omargfh Aug 13, 2026
6707c46
fix(commons): stop cucumber importing vitest specs as support code
omargfh Aug 13, 2026
b7e2758
fix(commons): stop key normalisation eating real filenames
omargfh Aug 13, 2026
cb24cf0
refactor(commons): share the staging key segment length
omargfh Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/modeling-commons-backend/.claude/DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Decisions

## Entity ids: NanoID instead of UUID

All entity ids switched from `crypto.randomUUID()` to NanoID, generated by `newId()` and validated by `idSchema()` in `src/shared/utils/id.ts`.

- 21 characters, default NanoID alphabet `A-Za-z0-9_-`. Collision probability is negligible at this project's scale, and the alphabet is safe unescaped in URL segments, S3 keys, and JSON.
- Hard cutover, no transitional dual-accept window: generation and validation switched together. The project is pre-beta, so a shock is acceptable and a permissive "accept both shapes" period would only leave a pattern lying around that something eventually starts depending on.
- Two deliberate exceptions remain UUID-shaped, each carrying an explaining comment at the source:
- `src/shared/utils/validate-request-id.ts` still accepts inbound UUID `x-correlation-id` / `request-id` headers, for interop with upstream proxies and tracing systems that generate UUIDs.
- `storagePathHash` in `prisma/legacy-migration/lib/file-keys.ts` is a frozen UUID-shaped hash of a legacy integer id, pinned by test. It is deterministic on purpose; changing its output would orphan existing S3 objects.

Full rationale and inventory: `doc/nanoid-id-migration-plan.md`.
1 change: 1 addition & 0 deletions apps/modeling-commons-backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.ongoing/
5 changes: 5 additions & 0 deletions apps/modeling-commons-backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ Reference modules: `src/modules/model/` (full shape), `src/modules/event/` (read
- `POST /v1/models/:id/versions` is **multipart/form-data** (required `file` field), not JSON.
- Draft data persists as versioned JSON validated by compiled Typebox `Parse` (`model-draft/schemas/v1.ts`); its `Clean` step strips properties absent from the schema, so add any new draft field to the schema or it's silently dropped on the next read.

## Ids

- All entity ids are NanoIDs generated by `newId()` from `#src/shared/utils/id.ts`. Never generate an id from Node's native crypto random-identifier call directly.
- DTOs validate ids with `idSchema(...)` from the same module, never a hand-written regex or a hardcoded format string for the old identifier shape.

## Routes

- Auth: `preHandler: [requireAuth]` from `#src/shared/hooks/require-auth.ts`.
Expand Down
5 changes: 4 additions & 1 deletion apps/modeling-commons-backend/cucumber.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ if (usingPerfProfile) {
}

const baseConfig = {
import: ['tests/support/**/*.ts', 'tests/**/*.steps.ts'],
// Vitest specs may sit beside their subject in tests/support. Importing one
// as cucumber support code runs describe() outside a test runner and kills
// the whole run before any scenario starts.
import: ['tests/support/**/!(*.spec).ts', 'tests/**/*.steps.ts'],
paths: ['tests/**/*.feature'],
format: [
'json:reports/cucumber-report.json',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ Source table: `postings` in `nlcommons_production` (Rails). Columns confirmed ag

| Legacy column | New column / treatment |
|-------------------|--------------------------------------------------------------------------------|
| `id` | `legacyId` (Int, `@unique`). New row gets a uuid `id`. |
| `id` | `legacyId` (Int, `@unique`). New row gets a NanoID `id`. |
| `person_id` | `userId` resolved via `User.legacyId` map. `null` if author wasn't migrated. |
| `node_id` | `modelId` resolved via `Model.legacyId` map. **Posting skipped if unresolved** (orphaned: model was spam-excluded or never migrated). |
| `parent_id` | `parentLegacyPostingId` resolved via two-pass uuid map. |
| `parent_id` | `parentLegacyPostingId` resolved via two-pass id map. |
| `title` | `title` (nullable; legacy default was `'(No title)'`). |
| `body` | `body`. **Nulled if `deleted_at IS NOT NULL`** — no recoverable body for tombstones. Stored as-is; legacy did `gsub!('<', '&lt;')`, so values may already be HTML-escaped. Frontend treats as preformatted text. |
| `is_question` | `isQuestion` (bool). |
Expand All @@ -41,7 +41,7 @@ One Prisma migration. Adds `LegacyModelPosting` + two back-relations.

```prisma
model LegacyModelPosting {
id String @id @default(uuid())
id String @id @default(nanoid())
legacyId Int @unique
modelId String
userId String?
Expand Down Expand Up @@ -139,16 +139,16 @@ Tombstone-friendly. `body`/`userId`/`author` all nullable so the frontend render

```ts
export const legacyPostingAuthorDtoSchema = Type.Object({
id: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), // null when unmapped
id: Type.Union([idSchema(), Type.Null()]), // null when unmapped
displayName: Type.Union([Type.String(), Type.Null()]), // legacyAuthorName fallback
image: Type.Union([Type.String(), Type.Null()]),
});

export const legacyPostingDtoSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
id: idSchema(),
legacyId: Type.Integer(),
modelId: Type.String({ format: 'uuid' }),
parentLegacyPostingId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]),
modelId: idSchema(),
parentLegacyPostingId: Type.Union([idSchema(), Type.Null()]),

title: Type.Union([Type.String(), Type.Null()]),
body: Type.Union([Type.String(), Type.Null()]), // null on tombstones
Expand All @@ -163,7 +163,7 @@ export const legacyPostingDtoSchema = Type.Object({
});

export const legacyPostingListResponseDtoSchema = Type.Object({
modelId: Type.String({ format: 'uuid' }),
modelId: idSchema(),
postings: Type.Array(legacyPostingDtoSchema),
});
```
Expand Down Expand Up @@ -220,9 +220,9 @@ async function migrateLegacyPostings(

// 3. Two-pass import to resolve parent links.
// Pass 1: insert every posting with parentLegacyPostingId = null.
// Pass 2: update parent links once all uuid mappings exist.
// Pass 2: update parent links once all id mappings exist.

const idMap = new Map<number, string>(seenLegacyId); // legacy posting id → new uuid
const idMap = new Map<number, string>(seenLegacyId); // legacy posting id → new id

await streamRows<OldPosting>(
`SELECT id, person_id, node_id, parent_id, title, body,
Expand All @@ -237,19 +237,19 @@ async function migrateLegacyPostings(
continue;
}
if (!p.node_id) { report.legacyPostings.skipped_orphan_model++; continue; }
const modelUuid = modelIdMap.get(p.node_id);
if (!modelUuid) { report.legacyPostings.skipped_orphan_model++; continue; }
const modelId = modelIdMap.get(p.node_id);
if (!modelId) { report.legacyPostings.skipped_orphan_model++; continue; }

const userUuid = p.person_id ? userIdMap.get(p.person_id) ?? null : null;
const userId = p.person_id ? userIdMap.get(p.person_id) ?? null : null;
const authorName = p.person_id ? legacyAuthorName.get(p.person_id) ?? null : null;

const isDeleted = p.deleted_at !== null;
const id = randomUUID();
const id = newId();
rows.push({
id,
legacyId: p.id,
modelId: modelUuid,
userId: userUuid,
modelId,
userId,
legacyAuthorName: authorName,
parentLegacyPostingId: null, // pass 2 sets this
title: p.title,
Expand All @@ -274,23 +274,23 @@ async function migrateLegacyPostings(
`SELECT id, parent_id FROM postings WHERE parent_id IS NOT NULL`,
);
for (const p of parented) {
const childUuid = idMap.get(p.id);
const parentUuid = p.parent_id ? idMap.get(p.parent_id) ?? null : null;
if (!childUuid || !parentUuid) {
const childId = idMap.get(p.id);
const parentId = p.parent_id ? idMap.get(p.parent_id) ?? null : null;
if (!childId || !parentId) {
report.legacyPostings.skipped_orphan_parent++;
continue;
}
await prisma.legacyModelPosting.update({
where: { id: childUuid },
data: { parentLegacyPostingId: parentUuid },
where: { id: childId },
data: { parentLegacyPostingId: parentId },
});
}
}
```

Idempotency:

- Re-running the seed picks up new legacy rows (none expectedlegacy DB is frozenbut the script supports it). Existing rows are skipped via the `legacyId` probe in pass 1; pass 2's update is no-op-safe (same parent uuid).
- Re-running the seed picks up new legacy rows (none expected: legacy DB is frozen, but the script supports it). Existing rows are skipped via the `legacyId` probe in pass 1; pass 2's update is no-op-safe (same parent id).
- `WIPE_TARGET=true` already truncates the right cascade; add `"LegacyModelPosting"` to the truncate list in `wipeTarget`.

Report counters to add to the existing `report` object in `initial-import.ts`:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ One Prisma migration: add the `ModelComment` table, plus the back-relations on

```prisma
model ModelComment {
id String @id @default(uuid())
id String @id @default(nanoid())
modelId String
userId String? // null when author was hard-deleted (FK SetNull)
parentCommentId String? // null = top-level
Expand Down Expand Up @@ -113,7 +113,7 @@ which the service handles directly. Add `patches/` only if a future feature
(same pattern as `model-author`). The domain factory exposes:

- `createComment({ modelId, userId, parentCommentId?, body }): ModelCommentEntity`
assigns `id` (uuid), `createdAt = updatedAt = now`, `deletedAt = null`.
assigns `id` (`newId()`), `createdAt = updatedAt = now`, `deletedAt = null`.
Validates `body` length (1..10_000, trimmed) and throws
`CommentBodyInvalidError` on failure. **Does not** sanitize or escape — body
is stored as raw markdown.
Expand Down Expand Up @@ -201,7 +201,7 @@ mock pattern verbatim.
import { Type, type Static } from 'typebox';

export const createCommentRequestDtoSchema = Type.Object({
parentCommentId: Type.Optional(Type.String({ format: 'uuid' })),
parentCommentId: Type.Optional(idSchema()),
body: Type.String({ minLength: 1, maxLength: 10_000 }),
});
export type CreateCommentRequestDto = Static<typeof createCommentRequestDtoSchema>;
Expand All @@ -223,16 +223,16 @@ tree can keep deleted nodes as structural placeholders.

```ts
export const commentAuthorDtoSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
id: idSchema(),
name: Type.Union([Type.String(), Type.Null()]),
image: Type.Union([Type.String(), Type.Null()]),
});

export const commentResponseDtoSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
modelId: Type.String({ format: 'uuid' }),
userId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]),
parentCommentId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]),
id: idSchema(),
modelId: idSchema(),
userId: Type.Union([idSchema(), Type.Null()]),
parentCommentId: Type.Union([idSchema(), Type.Null()]),
body: Type.Union([Type.String(), Type.Null()]),
deletedAt: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String(),
Expand Down Expand Up @@ -566,7 +566,7 @@ mapped user id. That's a separate import script, out of scope here.

### Integration (`tests/integration/comment.test.ts`)

- POST `/v1/models/:id/comments` returns 201 + valid uuid; unauthenticated
- POST `/v1/models/:id/comments` returns 201 + valid id; unauthenticated
returns 401; reading a private model the caller can't see returns 404 (from
`resolveModel`).
- POST a reply with `parentCommentId` from a different model returns 400.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ enum ReportStatus {
}

model Report {
id String @id @default(uuid())
id String @id @default(nanoid())
resourceType ReportResourceType
resourceId String
reporterUserId String
Expand Down Expand Up @@ -216,7 +216,7 @@ export default function reportDomain() {
throw new EmptyReasonError();
}
return {
id: crypto.randomUUID(),
id: newId(),
...props,
status: 'open',
resolverUserId: null,
Expand Down Expand Up @@ -438,7 +438,7 @@ export const submitReportRequestDtoSchema = Type.Object({
Type.Literal('user'),
Type.Literal('comment'),
]),
resourceId: Type.String({ format: 'uuid' }),
resourceId: idSchema(),
kind: Type.Union([
Type.Literal('spam'),
Type.Literal('abuse'),
Expand Down Expand Up @@ -484,14 +484,14 @@ export type ListReportsQueryDto = Static<typeof listReportsQuerySchema>;

```ts
export const reportResponseDtoSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
id: idSchema(),
resourceType: Type.String(), // enum string
resourceId: Type.String({ format: 'uuid' }),
reporterUserId: Type.String({ format: 'uuid' }),
resourceId: idSchema(),
reporterUserId: idSchema(),
kind: Type.String(),
reason: Type.String(),
status: Type.String(),
resolverUserId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]),
resolverUserId: Type.Union([idSchema(), Type.Null()]),
resolverNote: Type.Union([Type.String(), Type.Null()]),
createdAt: Type.String({ format: 'date-time' }),
resolvedAt: Type.Union([Type.String({ format: 'date-time' }), Type.Null()]),
Expand Down Expand Up @@ -625,7 +625,7 @@ following `model-author.feature` style:
- `POST /v1/reports` requires auth (401 anonymous).
- `POST /v1/reports` against a real model returns 201 + id.
- Second `POST` same reporter+resource returns 409.
- `POST` against a non-existent UUID returns 404.
- `POST` against a non-existent id returns 404.
- `GET /v1/admin/reports` returns 403 for non-admin user, 200 for admin,
paginated.
- Filter combinations: `?status=open`, `?resourceType=model&kind=spam`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ WHERE mv."searchVector" @@ q
AND m."deletedAt" IS NULL
AND (
m.visibility = 'public'
OR ($2::uuid IS NOT NULL AND EXISTS (
OR ($2::text IS NOT NULL AND EXISTS (
SELECT 1 FROM "ModelAuthor" a
WHERE a."modelId" = m.id AND a."userId" = $2
))
OR ($2::uuid IS NOT NULL AND EXISTS (
OR ($2::text IS NOT NULL AND EXISTS (
SELECT 1 FROM "ModelPermission" p
WHERE p."modelId" = m.id AND p."granteeUserId" = $2
))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export type RevertVersionRequestDto = Static<typeof revertVersionRequestDtoSchem

// dtos/revert-version.response.dto.ts
export const revertVersionResponseDtoSchema = Type.Object({
modelId: Type.String({ format: 'uuid' }),
modelId: idSchema(),
versionNumber: Type.Integer({ minimum: 1 }),
});
export type RevertVersionResponseDto = Static<typeof revertVersionResponseDtoSchema>;
Expand Down
6 changes: 3 additions & 3 deletions apps/modeling-commons-backend/doc/model-fork-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ forkModel({ sourceModelId, sourceVersionNumber, callerId, body }):
If missing -> VersionNotFoundError(sourceModelId, sourceVersionNumber)
If source.finalizedAt is null -> CannotForkDraftVersionError

3. Generate newModelId = randomUUID()
3. Generate newModelId = newId()
Generate newFileKey via the same util used by ModelVersionService.create
(today that's fileService.upload which delegates to createStorageKey under
`uploads/models/${newModelId}/versions`). For the copy path we want a key
Expand Down Expand Up @@ -251,7 +251,7 @@ createForkedModel(props: {
}
```

Note that the patch passes a pre-generated `id` so the same UUID can be used for the S3 path prefix *before* the row exists. `createModel` generates internally; `createForkedModel` accepts externally. Keep both; they have different contracts on purpose.
Note that the patch passes a pre-generated `id` so the same NanoID can be used for the S3 path prefix *before* the row exists. `createModel` generates internally; `createForkedModel` accepts externally. Keep both; they have different contracts on purpose.

### `createVersion` extension

Expand Down Expand Up @@ -327,7 +327,7 @@ End-to-end against a real DB and the test S3 stub (whatever the existing integra
1. Seed a model owned by user A with one finalized version (title `'Original'`, description `'...'`, an info tab, a netlogo version, a stored `.nlogox`).
2. Sign in as user B.
3. `POST /v1/models/:id/versions/1/fork` with empty body.
4. Assert: 201 and body `{ id: <uuid> }`.
4. Assert: 201 and body `{ id: <nanoid> }`.
5. Fetch the new model: `parentModelId = A's model id`, `parentVersionNumber = 1`, `visibility = 'private'`, `latestVersionNumber = 1`.
6. Fetch the new version: `title = 'Original (fork)'`, `description = 'same'`, `infoTab = same`, `netlogoVersion = same`, `previewImage` bytes equal, `netlogoFileKey != source key` and resolves via S3 (HEAD ok).
7. Query `ModelAuthor`: exactly one row, `userId = B`, `role = 'owner'`.
Expand Down
Loading