From 0802c5233515f4dd1609b843c103fcab43d9ecd1 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 14:10:54 -0500 Subject: [PATCH 01/18] ci(commons): add changeSummary to ModelVersion objects for typecheck --- .../model-additional-file/model-additional-file.service.spec.ts | 1 + .../modules/model-version-tag/model-version-tag.service.spec.ts | 1 + .../modules/model-version/domain/model-version.domain.spec.ts | 2 ++ .../src/modules/model-version/domain/model-version.domain.ts | 1 + .../src/modules/model-version/model-version.service.spec.ts | 1 + 5 files changed, 6 insertions(+) diff --git a/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.service.spec.ts b/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.service.spec.ts index 99066b4d..d64bfd62 100644 --- a/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.service.spec.ts @@ -18,6 +18,7 @@ function makeVersion(overrides: Partial = {}): ModelVersionE previewImageFileKey: null, netlogoFileKey: '2026/04/17/abcd-model.nlogox', netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: null, diff --git a/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.service.spec.ts b/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.service.spec.ts index 4ce7025c..7c40d31e 100644 --- a/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.service.spec.ts @@ -18,6 +18,7 @@ function makeVersion(overrides: Partial = {}): ModelVersionE previewImageFileKey: null, netlogoFileKey: 'files/key-1', netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: null, diff --git a/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.spec.ts b/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.spec.ts index 31362af7..4d4c61eb 100644 --- a/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.spec.ts @@ -30,6 +30,7 @@ describe('modelVersionDomain', () => { previewImageFileKey: null, netlogoFileKey: 'f1', netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: null, @@ -46,6 +47,7 @@ describe('modelVersionDomain', () => { previewImageFileKey: null, netlogoFileKey: 'f1', netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: new Date(), diff --git a/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.ts b/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.ts index 78869c50..2a4360b4 100644 --- a/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model-version/domain/model-version.domain.ts @@ -19,6 +19,7 @@ export default function modelVersionDomain() { previewImageFileKey: props.previewImageFileKey ?? null, netlogoFileKey: props.netlogoFileKey, netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: null, diff --git a/apps/modeling-commons-backend/src/modules/model-version/model-version.service.spec.ts b/apps/modeling-commons-backend/src/modules/model-version/model-version.service.spec.ts index 3b1fb6b2..4b67d226 100644 --- a/apps/modeling-commons-backend/src/modules/model-version/model-version.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-version/model-version.service.spec.ts @@ -20,6 +20,7 @@ function makeVersion(overrides: Partial = {}): ModelVersionE previewImageFileKey: null, netlogoFileKey: '2026/04/17/abcd-model.nlogox', netlogoVersion: null, + changeSummary: null, infoTab: null, createdAt: new Date(), finalizedAt: null, From d017fb41a2384bba6d2f68079535e6aa67302fcf Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 14:11:31 -0500 Subject: [PATCH 02/18] feat(commons): switch uuid/cuid to nanoid for business logic --- apps/modeling-commons-backend/.gitignore | 1 + .../doc/nanoid-id-migration-plan.md | 290 ++++++++++++++++++ .../generated/prisma/edge.js | 2 +- .../generated/prisma/index.js | 2 +- .../generated/prisma/package.json | 2 +- .../generated/prisma/schema.prisma | 26 +- apps/modeling-commons-backend/package.json | 3 +- .../prisma/schema.prisma | 26 +- .../src/shared/api/id.response.dto.ts | 7 +- .../src/shared/utils/id.spec.ts | 58 ++++ .../src/shared/utils/id.ts | 16 + .../src/shared/utils/validator.util.ts | 3 + yarn.lock | 140 +++++++-- 13 files changed, 515 insertions(+), 61 deletions(-) create mode 100644 apps/modeling-commons-backend/.gitignore create mode 100644 apps/modeling-commons-backend/doc/nanoid-id-migration-plan.md create mode 100644 apps/modeling-commons-backend/src/shared/utils/id.spec.ts create mode 100644 apps/modeling-commons-backend/src/shared/utils/id.ts diff --git a/apps/modeling-commons-backend/.gitignore b/apps/modeling-commons-backend/.gitignore new file mode 100644 index 00000000..db290220 --- /dev/null +++ b/apps/modeling-commons-backend/.gitignore @@ -0,0 +1 @@ +.ongoing/ \ No newline at end of file diff --git a/apps/modeling-commons-backend/doc/nanoid-id-migration-plan.md b/apps/modeling-commons-backend/doc/nanoid-id-migration-plan.md new file mode 100644 index 00000000..cef9cdff --- /dev/null +++ b/apps/modeling-commons-backend/doc/nanoid-id-migration-plan.md @@ -0,0 +1,290 @@ +# NanoID identifier migration + +Status: proposed - not approved + +Replace every UUID identifier in the Modeling Commons stack with NanoID: entity primary keys, +API contract validation, storage-key path segments, request/correlation ids, seed ids, and the +legacy-import scripts. + +## Why this is cheaper than it looks + +Every id column in the database is already `TEXT`. There is no Postgres `uuid` column type, no +`gen_random_uuid()`/`uuid_generate_v4()` default, and no `pgcrypto`/`uuid-ossp` extension anywhere +in `prisma/migrations/` (verified: grepping the whole migration tree for those tokens returns +nothing; `prisma/migrations/20260413204914_init/migration.sql:17` and every sibling declare +`"id" TEXT NOT NULL` with no `DEFAULT`). + +UUID generation is entirely application-side. So this is a generator swap plus a validation-contract +change, not a column-type migration. + +Prisma 7.6 supports `@default(nanoid())` natively (confirmed by running `prisma validate` against a +patched schema). Because Prisma scalar defaults are applied client-side in the query engine, changing +`uuid()` to `nanoid()` produces no SQL diff at all. + +## Inventory + +### Database (`prisma/schema.prisma`) + +12 primary keys use `@default(uuid())`: + +| Model | Line | +| --- | --- | +| User | 57 | +| Account | 106 | +| Session | 126 | +| Verification | 144 | +| Passkey | 158 | +| Model | 176 | +| ModelAdditionalFile | 255 | +| Tag | 270 | +| NonMemberContributor | 298 | +| ModelPermission | 314 | +| ModelInteraction | 342 | +| Event | 384 | + +`ModelDraft.id` (366) is already `@default(cuid())` - the one outlier, folded into this migration for +consistency. + +Four join tables carry composite `@@id` over UUID-typed FK columns and have no id of their own: +`ModelVersion` (237), `ModelVersionTag` (250), `ModelAuthor` (290), `ModelLike` (336). They migrate +implicitly with their parents. + +These DB defaults are close to dead code: application code supplies an explicit `id` on every create +(`src/modules/model/database/model.repository.ts:69`, `.../model-interaction.repository.ts:15`, and +peers). The defaults matter only for Better Auth's own writes and direct SQL inserts. + +### Generation sites (`src/`) + +Entity ids via `crypto.randomUUID()`: + +- `src/modules/model/domain/model.domain.ts:11` +- `src/modules/model-permission/domain/permission.domain.ts:15` +- `src/modules/tag/domain/tag.domain.ts:43` +- `src/modules/model-additional-file/domain/model-additional-file.domain.ts:11` +- `src/modules/model-interaction/domain/model-interaction.domain.ts:17` +- `src/modules/model-draft/domain/model-draft.domain.ts:15` +- `src/modules/model-draft/model-draft.service.ts:241,371` (draft file entries inside the JSON blob) + +Storage-key path segments: + +- `src/shared/storage/utils.ts:46` - `randomUUID().substring(0, 8)`, i.e. **32 bits of entropy** per + key inside a `{path}/{YYYY}/{MM}/{DD}/` prefix. This is the weakest identifier in the codebase and + the migration is a good moment to widen it. +- `src/modules/model-draft/model-draft.storage.ts:30` and the duplicate at + `src/modules/model-draft/model-draft.service.ts:36,91` +- `src/modules/file/file.route.ts:50` - `` `${randomUUID()}-ua` `` + +Request-scoped ids: + +- `src/server/plugins/correlation-id.ts:11` +- `src/index.ts:15-22` (Fastify `genReqId`) + +The `uuid` npm package is not a dependency anywhere in the monorepo; generation is exclusively +Node's built-in `crypto.randomUUID`. Neither `nanoid` nor `uuid` is a declared direct dependency in +any workspace `package.json`. + +### Validation sites + +- `src/shared/utils/validator.util.ts:18` registers Ajv `addFormats`, which is what actually enforces + every `Type.String({ format: 'uuid' })` in the route schemas. +- `src/shared/api/id.response.dto.ts:5` - the shared `idDtoSchema` reused across modules. +- `src/shared/utils/validateUUIDv4.ts:1-5` - hand-rolled regex, used only for the correlation-id and + request-id headers, never for entity ids. +- 19 DTO/schema files declare `format: 'uuid'` fields, producing 59 `Format: uuid` annotations in the + generated OpenAPI client. + +### Consumers outside the backend + +- `client/rest.d.ts` and `apps/modeling-commons-frontend/shared/types/api.d.ts` are byte-identical + generated artifacts from `yarn generate:types` (`scripts/generate-types.sh:30`, which boots the + server and runs `openapi-typescript` against its live OpenAPI JSON, then copies to + `CLIENT_TYPES_OUTPUT_DIR`). Both are committed. `format: uuid` appears there only as a JSDoc + comment - the TS type is plain `string`, so nothing downstream breaks at compile time. +- The frontend contains **zero** uuid references outside that generated file. No route-param + validation, no regexes, no generation. +- No other app or package in the monorepo touches uuid. + +## Decisions + +### Alphabet and length + +Use `nanoid` v5 (ESM-only, matching `"type": "module"`) with the default 21-character URL-safe +alphabet `A-Za-z0-9_-`. + +At 21 characters the collision probability is negligible at any volume this project will see, and the +alphabet is safe in URL path segments, S3 keys, and JSON without escaping. + +Confirmed: use the library default rather than a custom base62 alphabet. + +### Central id module + +All generation and all validation route through one module, `src/shared/utils/id.ts`: + +```ts +export const ID_LENGTH = 21; +export const newId: () => string; +export const ID_PATTERN: string; // '^[A-Za-z0-9_-]{21}$' +``` + +DTOs consume a Typebox helper built from these constants rather than repeating `format: 'uuid'`. +This is what keeps the contract from drifting again. + +### Hard cutover, not a phased rollout + +The project is in beta and a shock is acceptable, so there is no transitional window. Generation and +validation switch together in PR 2, and no UUID is ever accepted again. + +The alternative - widen `idSchema` to accept both shapes, switch generation, then narrow again once +the data is clean - exists to keep a live system serving traffic mid-migration. That is not worth +buying here: it costs two extra PRs and leaves a permissive pattern lying around that something will +eventually start using. + +What this does mean is that generation and validation **cannot be split**. Switching generation while +routes still demand `format: 'uuid'` makes every route reject the ids it just created; narrowing +validation first rejects everything the running code produces. PR 2 is therefore larger than the +others by design. + +It also means PRs 2 through 4 ship as **one release**. Between PR 2 and the backfill in PR 4, the +schemas reject the UUIDs already in the beta database, so the intermediate state is not deployable. +Each PR still stands on its own against a fresh database, which is what the test suite runs against. + +## Hazards + +### Tag lookup discriminator (correctness, must fix) + +`src/modules/tag/tag.service.ts:30` disambiguates "is this an id or a tag name?" by regex: + +```ts +const isUuid = /^[\da-f]{8}-[\da-f]{4}-.../i.test(idOrName); +``` + +This is safe today only because no plausible tag name is UUID-shaped. A NanoID pattern +(`^[A-Za-z0-9_-]{21}$`) **collides with ordinary tag names** - any 21-character word-ish tag would be +misrouted to `findOneById` and 404. A shape test cannot survive this migration. + +The fix is to stop guessing: look up by id, fall back to name on miss. This is PR 3, kept out of the +sweep because it is the one change here that a reviewer has to actually think about. + +### Storage keys derived from legacy ids + +`prisma/legacy-migration/lib/file-keys.ts:6-14` exposes `derivedUuid(namespace, id)` - a SHA-256 hash +of the legacy integer id, bit-forced into UUIDv4 shape. It is used at `apply-diff.ts:777` and +`apply-diff.ts:948` to build **S3 object keys** for version files and previews. + +This is deterministic on purpose: re-running the incremental sync must land on the same key. If the +legacy import has already run against a real bucket, changing this function changes every computed +key and orphans the existing objects. + +**Decided: freeze it.** Asset URLs are not user-facing and already look arbitrary, so there is +nothing to gain from NanoID-ifying a storage-path hash and a real risk in doing so. `derivedUuid` +keeps its exact output under a name that says what it is (`storagePathHash`), with a comment and a +pinned-output test so a later cleanup sweep cannot silently break it. This is the one deliberate +exception to "no UUID-shaped strings remain". + +Ordinary storage keys *are* migrated: `createStorageKey`/`stagingKey` generate a fresh random segment +at write time and store the resulting key as an opaque string in `fileKey`/`s3Key`/`key` columns. +Nothing ever parses that segment back, so existing objects stay reachable regardless of what +generator produces new keys. + +### Legacy import determinism + +`prisma/legacy-migration/initial-import.ts` uses non-deterministic `randomUUID()` for every row id +(lines 175, 193, 261, 336, 406) and achieves idempotency through the `legacyId Int? @unique` columns +instead (`prisma/migrations/20260427185014_legacy_id/migration.sql:9-25`). There is no deterministic +legacy-int-to-uuid mapping to preserve. `node-migration.ts:41-47` already injects the generator as +`newUuid: () => string`, which is a clean seam. + +### Request-id interop + +`x-correlation-id` and the inbound `request-id` header are frequently UUIDs when they originate from +an upstream proxy or tracing system. Switching generation to NanoID is fine; the header *validator* +should keep accepting UUID-shaped inbound values rather than discarding a perfectly good trace id. +The `request-ids` commit accepts both and generates NanoID. + +### Seed determinism + +`prisma/seed/id.ts:15-24` derives a UUIDv5-shaped id from a SHA-1 of a natural key so the seed is +idempotent under upsert-by-id. The determinism must survive; only the output encoding changes (hash +bytes mapped into the NanoID alphabet, 21 chars). + +### Test-harness silent degradation + +`tests/support/timing-collector.ts:23,33` normalizes URL path segments to `:id` using a UUID regex, +with a `HEX_ID_RE = /^[0-9a-f]{24,36}$/i` fallback that a NanoID will not match either. Perf reports +would silently stop collapsing ids and fragment into thousands of distinct routes. Not a production +bug, but it makes the perf profile useless, so it is in scope. + +`tests/api/user.feature:26` hardcodes the nil UUID as a throwaway path param. + +## Existing data: backfill required + +Confirmed: existing data must survive, so there is no reset path. This makes the backfill the +largest and only genuinely risky part of the plan, and it is PR 4, split across two commits. + +Its first commit builds a durable `_id_migration_map(table_name, old_id, new_id)` table, then +rewrites all 12 UUID primary keys plus `ModelDraft`'s cuid and every declared FK edge in one +transaction with deferred constraints. Four join tables carry those FK columns *inside* a composite +primary key +(`ModelVersion`, `ModelVersionTag`, `ModelAuthor`, `ModelLike`), which is the sharpest edge in the +migration. + +Its second commit then uses that map for the two references no foreign key protects: + +- `Event.resourceId` - a plain column holding model and user ids for the audit trail. It is remapped + by `resourceType`, not by guessing which table an id came from. Note the repo convention that child + aggregates record the *parent* resource type. +- `ModelDraft.data` - a versioned JSON blob whose `DraftFileV1` entries carry their own ids. Its + compiled Typebox `Parse` runs a `Clean` step that strips properties absent from the schema, so a + read-mutate-write through the schema silently drops data. The pass must operate on raw JSON. + +Splitting on the mapping table is what makes that second commit reviewable on its own instead of one +enormous transaction, and it leaves an audit trail and a rollback path. + +Rewriting `User.id` invalidates every `Session` row, so all users are logged out on deploy. +Externally shared model URLs also break. Both are inherent to the request and belong in the release +notes. + +## PR breakdown + +Four PRs, in `.ongoing/nanoid-ids/`. Merge order is the numeric suffix. The per-commit briefs live in +`.ongoing/nanoid-ids/commits/` and carry the file-by-file detail. + +| # | File | Commits | Depends on | +| --- | --- | --- | --- | +| 1 | `foundation-1.md` | `id-module`, `prisma-defaults` | none | +| 2 | `sweep-2.md` | `switch-ids`, `request-ids`, `seed`, `legacy-migration`, `tests`, `docs` | 1 | +| 3 | `tag-lookup-3.md` | `tag-lookup` | 1 | +| 4 | `backfill-4.md` | `backfill-map-and-swap`, `backfill-soft-refs` | 2 | + +Only three boundaries here earn a separation, and each is a different kind of review: + +- **Inert vs. breaking.** PR 1 changes no behaviour, so PR 2's diff contains only the sweep. +- **Mechanical vs. behavioural.** PR 2 is verifiable by grep. PR 3 is the one place a reviewer has to + think, and burying it in a 30-file find-and-replace is how it gets rubber-stamped. +- **Code vs. data.** PR 4 runs against the beta database and is the only step that can lose + something. It needs a backup, a rehearsal against a restored dump, and verification queries rather + than a test suite. + +Everything else is one logical change and is packaged as commits, not PRs. + +PRs 2 and 3 have disjoint file sets and can run in parallel. PR 4 is serialized after PR 2. + +PR 2 is unavoidably large - roughly 30 files - because generation and validation cannot be split +without a transitional window. See "Hard cutover" above. + +All of it ships as a single release: between PR 2 and PR 4 the schemas reject the UUIDs still in the +beta database, so intermediate states are testable against a fresh database but not deployable. + +## Verification + +Per commit: `yarn lint`, `yarn check-types`, `yarn deps:validate`, `yarn test:unit`. +Per PR additionally: `yarn test:e2e` (needs `yarn svc` and `yarn db:migrate:dev`). +Within PR 2: `yarn generate:types` and commit both regenerated artifacts. + +Two intentional exceptions survive, each carrying a comment saying why: the frozen `storagePathHash` +function (and its pinned-output test) and the inbound request-header validator from the `request-ids` commit. Beyond +those, a final grep must return nothing outside `generated/`, `yarn.lock`, and this document: + +``` +grep -rin "uuid" src prisma tests client --include="*.ts" --include="*.prisma" --include="*.feature" +``` diff --git a/apps/modeling-commons-backend/generated/prisma/edge.js b/apps/modeling-commons-backend/generated/prisma/edge.js index a96d6d99..c3b60147 100644 --- a/apps/modeling-commons-backend/generated/prisma/edge.js +++ b/apps/modeling-commons-backend/generated/prisma/edge.js @@ -392,7 +392,7 @@ const config = { "clientVersion": "7.8.0", "engineVersion": "3c6e192761c0362d496ed980de936e2f3cebcd3a", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Enums\n\nenum ModelVisibility {\n public\n private\n unlisted\n}\n\nenum SystemRole {\n admin\n moderator\n user\n}\n\nenum UserKind {\n student\n teacher\n researcher\n other\n}\n\nenum AuthorRole {\n owner\n contributor\n}\n\nenum PermissionLevel {\n read\n write\n admin\n}\n\nenum ModelInteractionKind {\n view\n run\n download\n share\n}\n\nenum ModelFileKind {\n model\n additional\n}\n\n// Better Auth core tables\n\nmodel User {\n id String @id @default(uuid())\n name String?\n email String? @unique\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n // Extended fields\n systemRole SystemRole @default(user)\n userKind UserKind @default(other)\n isProfilePublic Boolean @default(false)\n deletedAt DateTime?\n\n // User profile fields\n bio String?\n country String?\n socialLinks Json? // e.g. [{ platform: 'twitter', url: '...' }]\n dob DateTime? @db.Date\n affiliation String?\n\n // Better Auth relations\n accounts Account[]\n sessions Session[]\n verifications Verification[]\n\n // Domain relations\n authoredModels ModelAuthor[]\n grantedPermissions ModelPermission[]\n events Event[]\n modelLikes ModelLike[]\n modelInteractions ModelInteraction[]\n modelDrafts ModelDraft[]\n\n // Better Auth Admin plugin\n role String?\n banned Boolean?\n banReason String?\n banExpires DateTime? @db.Timestamptz(3)\n\n // Application behavior\n onboardedAt DateTime? @db.Timestamptz(3)\n legacyId Int? @unique\n\n // Passkey relations\n passkeys Passkey[]\n}\n\nmodel Account {\n id String @id @default(uuid())\n userId String\n accountId String\n providerId String\n accessToken String?\n refreshToken String?\n accessTokenExpiresAt DateTime? @db.Timestamptz(3)\n refreshTokenExpiresAt DateTime? @db.Timestamptz(3)\n scope String?\n idToken String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n}\n\nmodel Session {\n id String @id @default(uuid())\n userId String\n expiresAt DateTime\n token String @unique\n ipAddress String?\n userAgent String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Better Auth Admin plugin fields\n impersonatedBy String?\n\n @@index([userId])\n}\n\nmodel Verification {\n id String @id @default(uuid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n updatedAt DateTime? @updatedAt @db.Timestamptz(3)\n\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n userId String?\n\n @@index([userId])\n}\n\nmodel Passkey {\n id String @id @default(uuid())\n name String?\n publicKey String\n userId String\n credentialID String\n counter Int\n deviceType String\n backedUp Boolean\n transports String?\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n aaguid String?\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\n// Domain models\n\nmodel Model {\n id String @id @default(uuid())\n legacyId Int? @unique\n latestVersionNumber Int?\n parentModelId String?\n parentVersionNumber Int?\n visibility ModelVisibility @default(public)\n isEndorsed Boolean @default(false)\n isLibraryModel Boolean @default(false)\n viewCount Int @default(0)\n runCount Int @default(0)\n downloadCount Int @default(0)\n shareCount Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n deletedAt DateTime?\n\n latestVersion ModelVersion? @relation(\"LatestVersion\", fields: [id, latestVersionNumber], references: [modelId, versionNumber])\n parentModel Model? @relation(\"ModelParent\", fields: [parentModelId], references: [id])\n childModels Model[] @relation(\"ModelParent\")\n parentVersion ModelVersion? @relation(\"ParentVersion\", fields: [parentModelId, parentVersionNumber], references: [modelId, versionNumber])\n\n versions ModelVersion[] @relation(\"ModelVersions\")\n authors ModelAuthor[]\n nonMemberContributors NonMemberContributor[]\n permissions ModelPermission[]\n additionalFiles ModelAdditionalFile[]\n likes ModelLike[]\n interactions ModelInteraction[]\n drafts ModelDraft[]\n\n @@unique([id, latestVersionNumber])\n @@index([parentModelId])\n @@index([parentModelId, parentVersionNumber])\n @@index([viewCount])\n @@index([runCount])\n @@index([downloadCount])\n}\n\nmodel ModelVersion {\n modelId String\n versionNumber Int\n title String\n description String?\n changeSummary String?\n previewImageFileKey String?\n netlogoFileKey String\n netlogoVersion String?\n infoTab String?\n createdAt DateTime @default(now())\n finalizedAt DateTime?\n\n model Model @relation(\"ModelVersions\", fields: [modelId], references: [id], onDelete: Cascade)\n\n // Reverse relations\n latestOfModel Model? @relation(\"LatestVersion\")\n parentOfModels Model[] @relation(\"ParentVersion\")\n\n tags ModelVersionTag[]\n taggedAdditionalFiles ModelAdditionalFile[]\n\n @@id([modelId, versionNumber])\n @@index([modelId])\n}\n\nmodel ModelVersionTag {\n modelId String\n versionNumber Int\n tagId String\n createdAt DateTime @default(now())\n\n modelVersion ModelVersion @relation(fields: [modelId, versionNumber], references: [modelId, versionNumber], onDelete: Cascade)\n tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)\n\n @@id([modelId, versionNumber, tagId])\n @@index([tagId])\n}\n\nmodel ModelAdditionalFile {\n id String @id @default(uuid())\n modelId String\n taggedVersionNumber Int\n fileKey String\n kind ModelFileKind @default(additional)\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n taggedVersion ModelVersion @relation(fields: [modelId, taggedVersionNumber], references: [modelId, versionNumber])\n\n @@index([modelId])\n @@index([modelId, taggedVersionNumber])\n}\n\nmodel Tag {\n id String @id @default(uuid())\n legacyId Int? @unique\n name String @unique\n displayName String?\n createdAt DateTime @default(now())\n\n modelVersions ModelVersionTag[]\n}\n\nmodel ModelAuthor {\n modelId String\n userId String\n role AuthorRole\n // Legacy collaborator_types.name, kept for provenance. Nothing reads it.\n collaboratorType String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n}\n\n// Legacy non_member_collaborators joined onto non_member_collaborations: people\n// credited on a model who never held an account. Archival only, no business logic\n// reads this table.\nmodel NonMemberContributor {\n id String @id @default(uuid())\n legacyId Int @unique\n modelId String\n email String?\n name String?\n collaboratorType String?\n addedByUserId String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n @@index([modelId])\n @@index([email])\n}\n\nmodel ModelPermission {\n id String @id @default(uuid())\n modelId String\n granteeUserId String?\n permissionLevel PermissionLevel\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n granteeUser User? @relation(fields: [granteeUserId], references: [id], onDelete: Cascade)\n\n @@unique([modelId, granteeUserId])\n @@index([modelId])\n @@index([granteeUserId])\n}\n\nmodel ModelLike {\n modelId String\n userId String\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n @@index([modelId, createdAt])\n}\n\nmodel ModelInteraction {\n id String @id @default(uuid())\n modelId String\n versionNumber Int?\n kind ModelInteractionKind\n userId String?\n sessionId String?\n ipHash String?\n userAgent String?\n referer String?\n geo Json?\n cookie String?\n\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n @@index([modelId, kind, createdAt])\n @@index([modelId, kind, userId])\n @@index([userId, createdAt])\n @@index([createdAt])\n}\n\nmodel ModelDraft {\n id String @id @default(cuid())\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n modelId String?\n model Model? @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n schemaVersion Int\n data Json\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([modelId])\n}\n\nmodel Event {\n id String @id @default(uuid())\n type String\n actorId String\n resourceType String\n resourceId String\n payload Json\n createdAt DateTime @default(now())\n processedAt DateTime?\n\n actor User @relation(fields: [actorId], references: [id])\n\n @@index([actorId])\n @@index([resourceType, resourceId])\n @@index([type])\n @@index([processedAt])\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Enums\n\nenum ModelVisibility {\n public\n private\n unlisted\n}\n\nenum SystemRole {\n admin\n moderator\n user\n}\n\nenum UserKind {\n student\n teacher\n researcher\n other\n}\n\nenum AuthorRole {\n owner\n contributor\n}\n\nenum PermissionLevel {\n read\n write\n admin\n}\n\nenum ModelInteractionKind {\n view\n run\n download\n share\n}\n\nenum ModelFileKind {\n model\n additional\n}\n\n// Better Auth core tables\n\nmodel User {\n id String @id @default(nanoid())\n name String?\n email String? @unique\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n // Extended fields\n systemRole SystemRole @default(user)\n userKind UserKind @default(other)\n isProfilePublic Boolean @default(false)\n deletedAt DateTime?\n\n // User profile fields\n bio String?\n country String?\n socialLinks Json? // e.g. [{ platform: 'twitter', url: '...' }]\n dob DateTime? @db.Date\n affiliation String?\n\n // Better Auth relations\n accounts Account[]\n sessions Session[]\n verifications Verification[]\n\n // Domain relations\n authoredModels ModelAuthor[]\n grantedPermissions ModelPermission[]\n events Event[]\n modelLikes ModelLike[]\n modelInteractions ModelInteraction[]\n modelDrafts ModelDraft[]\n\n // Better Auth Admin plugin\n role String?\n banned Boolean?\n banReason String?\n banExpires DateTime? @db.Timestamptz(3)\n\n // Application behavior\n onboardedAt DateTime? @db.Timestamptz(3)\n legacyId Int? @unique\n\n // Passkey relations\n passkeys Passkey[]\n}\n\nmodel Account {\n id String @id @default(nanoid())\n userId String\n accountId String\n providerId String\n accessToken String?\n refreshToken String?\n accessTokenExpiresAt DateTime? @db.Timestamptz(3)\n refreshTokenExpiresAt DateTime? @db.Timestamptz(3)\n scope String?\n idToken String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n}\n\nmodel Session {\n id String @id @default(nanoid())\n userId String\n expiresAt DateTime\n token String @unique\n ipAddress String?\n userAgent String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Better Auth Admin plugin fields\n impersonatedBy String?\n\n @@index([userId])\n}\n\nmodel Verification {\n id String @id @default(nanoid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n updatedAt DateTime? @updatedAt @db.Timestamptz(3)\n\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n userId String?\n\n @@index([userId])\n}\n\nmodel Passkey {\n id String @id @default(nanoid())\n name String?\n publicKey String\n userId String\n credentialID String\n counter Int\n deviceType String\n backedUp Boolean\n transports String?\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n aaguid String?\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\n// Domain models\n\nmodel Model {\n id String @id @default(nanoid())\n legacyId Int? @unique\n latestVersionNumber Int?\n parentModelId String?\n parentVersionNumber Int?\n visibility ModelVisibility @default(public)\n isEndorsed Boolean @default(false)\n isLibraryModel Boolean @default(false)\n viewCount Int @default(0)\n runCount Int @default(0)\n downloadCount Int @default(0)\n shareCount Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n deletedAt DateTime?\n\n latestVersion ModelVersion? @relation(\"LatestVersion\", fields: [id, latestVersionNumber], references: [modelId, versionNumber])\n parentModel Model? @relation(\"ModelParent\", fields: [parentModelId], references: [id])\n childModels Model[] @relation(\"ModelParent\")\n parentVersion ModelVersion? @relation(\"ParentVersion\", fields: [parentModelId, parentVersionNumber], references: [modelId, versionNumber])\n\n versions ModelVersion[] @relation(\"ModelVersions\")\n authors ModelAuthor[]\n nonMemberContributors NonMemberContributor[]\n permissions ModelPermission[]\n additionalFiles ModelAdditionalFile[]\n likes ModelLike[]\n interactions ModelInteraction[]\n drafts ModelDraft[]\n\n @@unique([id, latestVersionNumber])\n @@index([parentModelId])\n @@index([parentModelId, parentVersionNumber])\n @@index([viewCount])\n @@index([runCount])\n @@index([downloadCount])\n}\n\nmodel ModelVersion {\n modelId String\n versionNumber Int\n title String\n description String?\n changeSummary String?\n previewImageFileKey String?\n netlogoFileKey String\n netlogoVersion String?\n infoTab String?\n createdAt DateTime @default(now())\n finalizedAt DateTime?\n\n model Model @relation(\"ModelVersions\", fields: [modelId], references: [id], onDelete: Cascade)\n\n // Reverse relations\n latestOfModel Model? @relation(\"LatestVersion\")\n parentOfModels Model[] @relation(\"ParentVersion\")\n\n tags ModelVersionTag[]\n taggedAdditionalFiles ModelAdditionalFile[]\n\n @@id([modelId, versionNumber])\n @@index([modelId])\n}\n\nmodel ModelVersionTag {\n modelId String\n versionNumber Int\n tagId String\n createdAt DateTime @default(now())\n\n modelVersion ModelVersion @relation(fields: [modelId, versionNumber], references: [modelId, versionNumber], onDelete: Cascade)\n tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)\n\n @@id([modelId, versionNumber, tagId])\n @@index([tagId])\n}\n\nmodel ModelAdditionalFile {\n id String @id @default(nanoid())\n modelId String\n taggedVersionNumber Int\n fileKey String\n kind ModelFileKind @default(additional)\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n taggedVersion ModelVersion @relation(fields: [modelId, taggedVersionNumber], references: [modelId, versionNumber])\n\n @@index([modelId])\n @@index([modelId, taggedVersionNumber])\n}\n\nmodel Tag {\n id String @id @default(nanoid())\n legacyId Int? @unique\n name String @unique\n displayName String?\n createdAt DateTime @default(now())\n\n modelVersions ModelVersionTag[]\n}\n\nmodel ModelAuthor {\n modelId String\n userId String\n role AuthorRole\n // Legacy collaborator_types.name, kept for provenance. Nothing reads it.\n collaboratorType String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n}\n\n// Legacy non_member_collaborators joined onto non_member_collaborations: people\n// credited on a model who never held an account. Archival only, no business logic\n// reads this table.\nmodel NonMemberContributor {\n id String @id @default(nanoid())\n legacyId Int @unique\n modelId String\n email String?\n name String?\n collaboratorType String?\n addedByUserId String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n @@index([modelId])\n @@index([email])\n}\n\nmodel ModelPermission {\n id String @id @default(nanoid())\n modelId String\n granteeUserId String?\n permissionLevel PermissionLevel\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n granteeUser User? @relation(fields: [granteeUserId], references: [id], onDelete: Cascade)\n\n @@unique([modelId, granteeUserId])\n @@index([modelId])\n @@index([granteeUserId])\n}\n\nmodel ModelLike {\n modelId String\n userId String\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n @@index([modelId, createdAt])\n}\n\nmodel ModelInteraction {\n id String @id @default(nanoid())\n modelId String\n versionNumber Int?\n kind ModelInteractionKind\n userId String?\n sessionId String?\n ipHash String?\n userAgent String?\n referer String?\n geo Json?\n cookie String?\n\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n @@index([modelId, kind, createdAt])\n @@index([modelId, kind, userId])\n @@index([userId, createdAt])\n @@index([createdAt])\n}\n\nmodel ModelDraft {\n id String @id @default(nanoid())\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n modelId String?\n model Model? @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n schemaVersion Int\n data Json\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([modelId])\n}\n\nmodel Event {\n id String @id @default(nanoid())\n type String\n actorId String\n resourceType String\n resourceId String\n payload Json\n createdAt DateTime @default(now())\n processedAt DateTime?\n\n actor User @relation(fields: [actorId], references: [id])\n\n @@index([actorId])\n @@index([resourceType, resourceId])\n @@index([type])\n @@index([processedAt])\n}\n" } config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"systemRole\",\"kind\":\"enum\",\"type\":\"SystemRole\"},{\"name\":\"userKind\",\"kind\":\"enum\",\"type\":\"UserKind\"},{\"name\":\"isProfilePublic\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"bio\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"country\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"socialLinks\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"dob\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"affiliation\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"verifications\",\"kind\":\"object\",\"type\":\"Verification\",\"relationName\":\"UserToVerification\"},{\"name\":\"authoredModels\",\"kind\":\"object\",\"type\":\"ModelAuthor\",\"relationName\":\"ModelAuthorToUser\"},{\"name\":\"grantedPermissions\",\"kind\":\"object\",\"type\":\"ModelPermission\",\"relationName\":\"ModelPermissionToUser\"},{\"name\":\"events\",\"kind\":\"object\",\"type\":\"Event\",\"relationName\":\"EventToUser\"},{\"name\":\"modelLikes\",\"kind\":\"object\",\"type\":\"ModelLike\",\"relationName\":\"ModelLikeToUser\"},{\"name\":\"modelInteractions\",\"kind\":\"object\",\"type\":\"ModelInteraction\",\"relationName\":\"ModelInteractionToUser\"},{\"name\":\"modelDrafts\",\"kind\":\"object\",\"type\":\"ModelDraft\",\"relationName\":\"ModelDraftToUser\"},{\"name\":\"role\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"banned\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"banReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"banExpires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"onboardedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"passkeys\",\"kind\":\"object\",\"type\":\"Passkey\",\"relationName\":\"PasskeyToUser\"}],\"dbName\":null},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"}],\"dbName\":null},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"},{\"name\":\"impersonatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToVerification\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Passkey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"publicKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialID\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"counter\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"deviceType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backedUp\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"transports\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"aaguid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"PasskeyToUser\"}],\"dbName\":null},\"Model\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"latestVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"parentModelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"visibility\",\"kind\":\"enum\",\"type\":\"ModelVisibility\"},{\"name\":\"isEndorsed\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isLibraryModel\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"viewCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"runCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"downloadCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"shareCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"latestVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"LatestVersion\"},{\"name\":\"parentModel\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelParent\"},{\"name\":\"childModels\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelParent\"},{\"name\":\"parentVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ParentVersion\"},{\"name\":\"versions\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelVersions\"},{\"name\":\"authors\",\"kind\":\"object\",\"type\":\"ModelAuthor\",\"relationName\":\"ModelToModelAuthor\"},{\"name\":\"nonMemberContributors\",\"kind\":\"object\",\"type\":\"NonMemberContributor\",\"relationName\":\"ModelToNonMemberContributor\"},{\"name\":\"permissions\",\"kind\":\"object\",\"type\":\"ModelPermission\",\"relationName\":\"ModelToModelPermission\"},{\"name\":\"additionalFiles\",\"kind\":\"object\",\"type\":\"ModelAdditionalFile\",\"relationName\":\"ModelToModelAdditionalFile\"},{\"name\":\"likes\",\"kind\":\"object\",\"type\":\"ModelLike\",\"relationName\":\"ModelToModelLike\"},{\"name\":\"interactions\",\"kind\":\"object\",\"type\":\"ModelInteraction\",\"relationName\":\"ModelToModelInteraction\"},{\"name\":\"drafts\",\"kind\":\"object\",\"type\":\"ModelDraft\",\"relationName\":\"ModelToModelDraft\"}],\"dbName\":null},\"ModelVersion\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changeSummary\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"previewImageFileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"netlogoFileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"netlogoVersion\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"infoTab\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finalizedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelVersions\"},{\"name\":\"latestOfModel\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"LatestVersion\"},{\"name\":\"parentOfModels\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ParentVersion\"},{\"name\":\"tags\",\"kind\":\"object\",\"type\":\"ModelVersionTag\",\"relationName\":\"ModelVersionToModelVersionTag\"},{\"name\":\"taggedAdditionalFiles\",\"kind\":\"object\",\"type\":\"ModelAdditionalFile\",\"relationName\":\"ModelAdditionalFileToModelVersion\"}],\"dbName\":null},\"ModelVersionTag\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"tagId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"modelVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelVersionToModelVersionTag\"},{\"name\":\"tag\",\"kind\":\"object\",\"type\":\"Tag\",\"relationName\":\"ModelVersionTagToTag\"}],\"dbName\":null},\"ModelAdditionalFile\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"taggedVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"fileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"kind\",\"kind\":\"enum\",\"type\":\"ModelFileKind\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelAdditionalFile\"},{\"name\":\"taggedVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelAdditionalFileToModelVersion\"}],\"dbName\":null},\"Tag\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"modelVersions\",\"kind\":\"object\",\"type\":\"ModelVersionTag\",\"relationName\":\"ModelVersionTagToTag\"}],\"dbName\":null},\"ModelAuthor\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"AuthorRole\"},{\"name\":\"collaboratorType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelAuthor\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelAuthorToUser\"}],\"dbName\":null},\"NonMemberContributor\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"collaboratorType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"addedByUserId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToNonMemberContributor\"}],\"dbName\":null},\"ModelPermission\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"granteeUserId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionLevel\",\"kind\":\"enum\",\"type\":\"PermissionLevel\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelPermission\"},{\"name\":\"granteeUser\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelPermissionToUser\"}],\"dbName\":null},\"ModelLike\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelLike\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelLikeToUser\"}],\"dbName\":null},\"ModelInteraction\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"kind\",\"kind\":\"enum\",\"type\":\"ModelInteractionKind\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"referer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"geo\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"cookie\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelInteraction\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelInteractionToUser\"}],\"dbName\":null},\"ModelDraft\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelDraftToUser\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelDraft\"},{\"name\":\"schemaVersion\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"data\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Event\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"actorId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resourceType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resourceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"payload\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"processedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"actor\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"EventToUser\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") diff --git a/apps/modeling-commons-backend/generated/prisma/index.js b/apps/modeling-commons-backend/generated/prisma/index.js index 19f6bd39..9d8b2b1f 100644 --- a/apps/modeling-commons-backend/generated/prisma/index.js +++ b/apps/modeling-commons-backend/generated/prisma/index.js @@ -393,7 +393,7 @@ const config = { "clientVersion": "7.8.0", "engineVersion": "3c6e192761c0362d496ed980de936e2f3cebcd3a", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Enums\n\nenum ModelVisibility {\n public\n private\n unlisted\n}\n\nenum SystemRole {\n admin\n moderator\n user\n}\n\nenum UserKind {\n student\n teacher\n researcher\n other\n}\n\nenum AuthorRole {\n owner\n contributor\n}\n\nenum PermissionLevel {\n read\n write\n admin\n}\n\nenum ModelInteractionKind {\n view\n run\n download\n share\n}\n\nenum ModelFileKind {\n model\n additional\n}\n\n// Better Auth core tables\n\nmodel User {\n id String @id @default(uuid())\n name String?\n email String? @unique\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n // Extended fields\n systemRole SystemRole @default(user)\n userKind UserKind @default(other)\n isProfilePublic Boolean @default(false)\n deletedAt DateTime?\n\n // User profile fields\n bio String?\n country String?\n socialLinks Json? // e.g. [{ platform: 'twitter', url: '...' }]\n dob DateTime? @db.Date\n affiliation String?\n\n // Better Auth relations\n accounts Account[]\n sessions Session[]\n verifications Verification[]\n\n // Domain relations\n authoredModels ModelAuthor[]\n grantedPermissions ModelPermission[]\n events Event[]\n modelLikes ModelLike[]\n modelInteractions ModelInteraction[]\n modelDrafts ModelDraft[]\n\n // Better Auth Admin plugin\n role String?\n banned Boolean?\n banReason String?\n banExpires DateTime? @db.Timestamptz(3)\n\n // Application behavior\n onboardedAt DateTime? @db.Timestamptz(3)\n legacyId Int? @unique\n\n // Passkey relations\n passkeys Passkey[]\n}\n\nmodel Account {\n id String @id @default(uuid())\n userId String\n accountId String\n providerId String\n accessToken String?\n refreshToken String?\n accessTokenExpiresAt DateTime? @db.Timestamptz(3)\n refreshTokenExpiresAt DateTime? @db.Timestamptz(3)\n scope String?\n idToken String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n}\n\nmodel Session {\n id String @id @default(uuid())\n userId String\n expiresAt DateTime\n token String @unique\n ipAddress String?\n userAgent String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Better Auth Admin plugin fields\n impersonatedBy String?\n\n @@index([userId])\n}\n\nmodel Verification {\n id String @id @default(uuid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n updatedAt DateTime? @updatedAt @db.Timestamptz(3)\n\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n userId String?\n\n @@index([userId])\n}\n\nmodel Passkey {\n id String @id @default(uuid())\n name String?\n publicKey String\n userId String\n credentialID String\n counter Int\n deviceType String\n backedUp Boolean\n transports String?\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n aaguid String?\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\n// Domain models\n\nmodel Model {\n id String @id @default(uuid())\n legacyId Int? @unique\n latestVersionNumber Int?\n parentModelId String?\n parentVersionNumber Int?\n visibility ModelVisibility @default(public)\n isEndorsed Boolean @default(false)\n isLibraryModel Boolean @default(false)\n viewCount Int @default(0)\n runCount Int @default(0)\n downloadCount Int @default(0)\n shareCount Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n deletedAt DateTime?\n\n latestVersion ModelVersion? @relation(\"LatestVersion\", fields: [id, latestVersionNumber], references: [modelId, versionNumber])\n parentModel Model? @relation(\"ModelParent\", fields: [parentModelId], references: [id])\n childModels Model[] @relation(\"ModelParent\")\n parentVersion ModelVersion? @relation(\"ParentVersion\", fields: [parentModelId, parentVersionNumber], references: [modelId, versionNumber])\n\n versions ModelVersion[] @relation(\"ModelVersions\")\n authors ModelAuthor[]\n nonMemberContributors NonMemberContributor[]\n permissions ModelPermission[]\n additionalFiles ModelAdditionalFile[]\n likes ModelLike[]\n interactions ModelInteraction[]\n drafts ModelDraft[]\n\n @@unique([id, latestVersionNumber])\n @@index([parentModelId])\n @@index([parentModelId, parentVersionNumber])\n @@index([viewCount])\n @@index([runCount])\n @@index([downloadCount])\n}\n\nmodel ModelVersion {\n modelId String\n versionNumber Int\n title String\n description String?\n changeSummary String?\n previewImageFileKey String?\n netlogoFileKey String\n netlogoVersion String?\n infoTab String?\n createdAt DateTime @default(now())\n finalizedAt DateTime?\n\n model Model @relation(\"ModelVersions\", fields: [modelId], references: [id], onDelete: Cascade)\n\n // Reverse relations\n latestOfModel Model? @relation(\"LatestVersion\")\n parentOfModels Model[] @relation(\"ParentVersion\")\n\n tags ModelVersionTag[]\n taggedAdditionalFiles ModelAdditionalFile[]\n\n @@id([modelId, versionNumber])\n @@index([modelId])\n}\n\nmodel ModelVersionTag {\n modelId String\n versionNumber Int\n tagId String\n createdAt DateTime @default(now())\n\n modelVersion ModelVersion @relation(fields: [modelId, versionNumber], references: [modelId, versionNumber], onDelete: Cascade)\n tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)\n\n @@id([modelId, versionNumber, tagId])\n @@index([tagId])\n}\n\nmodel ModelAdditionalFile {\n id String @id @default(uuid())\n modelId String\n taggedVersionNumber Int\n fileKey String\n kind ModelFileKind @default(additional)\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n taggedVersion ModelVersion @relation(fields: [modelId, taggedVersionNumber], references: [modelId, versionNumber])\n\n @@index([modelId])\n @@index([modelId, taggedVersionNumber])\n}\n\nmodel Tag {\n id String @id @default(uuid())\n legacyId Int? @unique\n name String @unique\n displayName String?\n createdAt DateTime @default(now())\n\n modelVersions ModelVersionTag[]\n}\n\nmodel ModelAuthor {\n modelId String\n userId String\n role AuthorRole\n // Legacy collaborator_types.name, kept for provenance. Nothing reads it.\n collaboratorType String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n}\n\n// Legacy non_member_collaborators joined onto non_member_collaborations: people\n// credited on a model who never held an account. Archival only, no business logic\n// reads this table.\nmodel NonMemberContributor {\n id String @id @default(uuid())\n legacyId Int @unique\n modelId String\n email String?\n name String?\n collaboratorType String?\n addedByUserId String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n @@index([modelId])\n @@index([email])\n}\n\nmodel ModelPermission {\n id String @id @default(uuid())\n modelId String\n granteeUserId String?\n permissionLevel PermissionLevel\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n granteeUser User? @relation(fields: [granteeUserId], references: [id], onDelete: Cascade)\n\n @@unique([modelId, granteeUserId])\n @@index([modelId])\n @@index([granteeUserId])\n}\n\nmodel ModelLike {\n modelId String\n userId String\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n @@index([modelId, createdAt])\n}\n\nmodel ModelInteraction {\n id String @id @default(uuid())\n modelId String\n versionNumber Int?\n kind ModelInteractionKind\n userId String?\n sessionId String?\n ipHash String?\n userAgent String?\n referer String?\n geo Json?\n cookie String?\n\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n @@index([modelId, kind, createdAt])\n @@index([modelId, kind, userId])\n @@index([userId, createdAt])\n @@index([createdAt])\n}\n\nmodel ModelDraft {\n id String @id @default(cuid())\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n modelId String?\n model Model? @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n schemaVersion Int\n data Json\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([modelId])\n}\n\nmodel Event {\n id String @id @default(uuid())\n type String\n actorId String\n resourceType String\n resourceId String\n payload Json\n createdAt DateTime @default(now())\n processedAt DateTime?\n\n actor User @relation(fields: [actorId], references: [id])\n\n @@index([actorId])\n @@index([resourceType, resourceId])\n @@index([type])\n @@index([processedAt])\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"../generated/prisma\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// Enums\n\nenum ModelVisibility {\n public\n private\n unlisted\n}\n\nenum SystemRole {\n admin\n moderator\n user\n}\n\nenum UserKind {\n student\n teacher\n researcher\n other\n}\n\nenum AuthorRole {\n owner\n contributor\n}\n\nenum PermissionLevel {\n read\n write\n admin\n}\n\nenum ModelInteractionKind {\n view\n run\n download\n share\n}\n\nenum ModelFileKind {\n model\n additional\n}\n\n// Better Auth core tables\n\nmodel User {\n id String @id @default(nanoid())\n name String?\n email String? @unique\n emailVerified Boolean @default(false)\n image String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n // Extended fields\n systemRole SystemRole @default(user)\n userKind UserKind @default(other)\n isProfilePublic Boolean @default(false)\n deletedAt DateTime?\n\n // User profile fields\n bio String?\n country String?\n socialLinks Json? // e.g. [{ platform: 'twitter', url: '...' }]\n dob DateTime? @db.Date\n affiliation String?\n\n // Better Auth relations\n accounts Account[]\n sessions Session[]\n verifications Verification[]\n\n // Domain relations\n authoredModels ModelAuthor[]\n grantedPermissions ModelPermission[]\n events Event[]\n modelLikes ModelLike[]\n modelInteractions ModelInteraction[]\n modelDrafts ModelDraft[]\n\n // Better Auth Admin plugin\n role String?\n banned Boolean?\n banReason String?\n banExpires DateTime? @db.Timestamptz(3)\n\n // Application behavior\n onboardedAt DateTime? @db.Timestamptz(3)\n legacyId Int? @unique\n\n // Passkey relations\n passkeys Passkey[]\n}\n\nmodel Account {\n id String @id @default(nanoid())\n userId String\n accountId String\n providerId String\n accessToken String?\n refreshToken String?\n accessTokenExpiresAt DateTime? @db.Timestamptz(3)\n refreshTokenExpiresAt DateTime? @db.Timestamptz(3)\n scope String?\n idToken String?\n password String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@index([userId])\n}\n\nmodel Session {\n id String @id @default(nanoid())\n userId String\n expiresAt DateTime\n token String @unique\n ipAddress String?\n userAgent String?\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n updatedAt DateTime @updatedAt @db.Timestamptz(3)\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n // Better Auth Admin plugin fields\n impersonatedBy String?\n\n @@index([userId])\n}\n\nmodel Verification {\n id String @id @default(nanoid())\n identifier String\n value String\n expiresAt DateTime\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n updatedAt DateTime? @updatedAt @db.Timestamptz(3)\n\n user User? @relation(fields: [userId], references: [id], onDelete: Cascade)\n userId String?\n\n @@index([userId])\n}\n\nmodel Passkey {\n id String @id @default(nanoid())\n name String?\n publicKey String\n userId String\n credentialID String\n counter Int\n deviceType String\n backedUp Boolean\n transports String?\n createdAt DateTime? @default(now()) @db.Timestamptz(3)\n aaguid String?\n\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n}\n\n// Domain models\n\nmodel Model {\n id String @id @default(nanoid())\n legacyId Int? @unique\n latestVersionNumber Int?\n parentModelId String?\n parentVersionNumber Int?\n visibility ModelVisibility @default(public)\n isEndorsed Boolean @default(false)\n isLibraryModel Boolean @default(false)\n viewCount Int @default(0)\n runCount Int @default(0)\n downloadCount Int @default(0)\n shareCount Int @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n deletedAt DateTime?\n\n latestVersion ModelVersion? @relation(\"LatestVersion\", fields: [id, latestVersionNumber], references: [modelId, versionNumber])\n parentModel Model? @relation(\"ModelParent\", fields: [parentModelId], references: [id])\n childModels Model[] @relation(\"ModelParent\")\n parentVersion ModelVersion? @relation(\"ParentVersion\", fields: [parentModelId, parentVersionNumber], references: [modelId, versionNumber])\n\n versions ModelVersion[] @relation(\"ModelVersions\")\n authors ModelAuthor[]\n nonMemberContributors NonMemberContributor[]\n permissions ModelPermission[]\n additionalFiles ModelAdditionalFile[]\n likes ModelLike[]\n interactions ModelInteraction[]\n drafts ModelDraft[]\n\n @@unique([id, latestVersionNumber])\n @@index([parentModelId])\n @@index([parentModelId, parentVersionNumber])\n @@index([viewCount])\n @@index([runCount])\n @@index([downloadCount])\n}\n\nmodel ModelVersion {\n modelId String\n versionNumber Int\n title String\n description String?\n changeSummary String?\n previewImageFileKey String?\n netlogoFileKey String\n netlogoVersion String?\n infoTab String?\n createdAt DateTime @default(now())\n finalizedAt DateTime?\n\n model Model @relation(\"ModelVersions\", fields: [modelId], references: [id], onDelete: Cascade)\n\n // Reverse relations\n latestOfModel Model? @relation(\"LatestVersion\")\n parentOfModels Model[] @relation(\"ParentVersion\")\n\n tags ModelVersionTag[]\n taggedAdditionalFiles ModelAdditionalFile[]\n\n @@id([modelId, versionNumber])\n @@index([modelId])\n}\n\nmodel ModelVersionTag {\n modelId String\n versionNumber Int\n tagId String\n createdAt DateTime @default(now())\n\n modelVersion ModelVersion @relation(fields: [modelId, versionNumber], references: [modelId, versionNumber], onDelete: Cascade)\n tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)\n\n @@id([modelId, versionNumber, tagId])\n @@index([tagId])\n}\n\nmodel ModelAdditionalFile {\n id String @id @default(nanoid())\n modelId String\n taggedVersionNumber Int\n fileKey String\n kind ModelFileKind @default(additional)\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n taggedVersion ModelVersion @relation(fields: [modelId, taggedVersionNumber], references: [modelId, versionNumber])\n\n @@index([modelId])\n @@index([modelId, taggedVersionNumber])\n}\n\nmodel Tag {\n id String @id @default(nanoid())\n legacyId Int? @unique\n name String @unique\n displayName String?\n createdAt DateTime @default(now())\n\n modelVersions ModelVersionTag[]\n}\n\nmodel ModelAuthor {\n modelId String\n userId String\n role AuthorRole\n // Legacy collaborator_types.name, kept for provenance. Nothing reads it.\n collaboratorType String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n}\n\n// Legacy non_member_collaborators joined onto non_member_collaborations: people\n// credited on a model who never held an account. Archival only, no business logic\n// reads this table.\nmodel NonMemberContributor {\n id String @id @default(nanoid())\n legacyId Int @unique\n modelId String\n email String?\n name String?\n collaboratorType String?\n addedByUserId String?\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n @@index([modelId])\n @@index([email])\n}\n\nmodel ModelPermission {\n id String @id @default(nanoid())\n modelId String\n granteeUserId String?\n permissionLevel PermissionLevel\n createdAt DateTime @default(now())\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n granteeUser User? @relation(fields: [granteeUserId], references: [id], onDelete: Cascade)\n\n @@unique([modelId, granteeUserId])\n @@index([modelId])\n @@index([granteeUserId])\n}\n\nmodel ModelLike {\n modelId String\n userId String\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n @@id([modelId, userId])\n @@index([userId])\n @@index([modelId, createdAt])\n}\n\nmodel ModelInteraction {\n id String @id @default(nanoid())\n modelId String\n versionNumber Int?\n kind ModelInteractionKind\n userId String?\n sessionId String?\n ipHash String?\n userAgent String?\n referer String?\n geo Json?\n cookie String?\n\n createdAt DateTime @default(now()) @db.Timestamptz(3)\n\n model Model @relation(fields: [modelId], references: [id], onDelete: Cascade)\n user User? @relation(fields: [userId], references: [id], onDelete: SetNull)\n\n @@index([modelId, kind, createdAt])\n @@index([modelId, kind, userId])\n @@index([userId, createdAt])\n @@index([createdAt])\n}\n\nmodel ModelDraft {\n id String @id @default(nanoid())\n userId String\n user User @relation(fields: [userId], references: [id], onDelete: Cascade)\n\n modelId String?\n model Model? @relation(fields: [modelId], references: [id], onDelete: Cascade)\n\n schemaVersion Int\n data Json\n\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userId])\n @@index([modelId])\n}\n\nmodel Event {\n id String @id @default(nanoid())\n type String\n actorId String\n resourceType String\n resourceId String\n payload Json\n createdAt DateTime @default(now())\n processedAt DateTime?\n\n actor User @relation(fields: [actorId], references: [id])\n\n @@index([actorId])\n @@index([resourceType, resourceId])\n @@index([type])\n @@index([processedAt])\n}\n" } config.runtimeDataModel = JSON.parse("{\"models\":{\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailVerified\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"image\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"systemRole\",\"kind\":\"enum\",\"type\":\"SystemRole\"},{\"name\":\"userKind\",\"kind\":\"enum\",\"type\":\"UserKind\"},{\"name\":\"isProfilePublic\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"bio\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"country\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"socialLinks\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"dob\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"affiliation\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accounts\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToUser\"},{\"name\":\"sessions\",\"kind\":\"object\",\"type\":\"Session\",\"relationName\":\"SessionToUser\"},{\"name\":\"verifications\",\"kind\":\"object\",\"type\":\"Verification\",\"relationName\":\"UserToVerification\"},{\"name\":\"authoredModels\",\"kind\":\"object\",\"type\":\"ModelAuthor\",\"relationName\":\"ModelAuthorToUser\"},{\"name\":\"grantedPermissions\",\"kind\":\"object\",\"type\":\"ModelPermission\",\"relationName\":\"ModelPermissionToUser\"},{\"name\":\"events\",\"kind\":\"object\",\"type\":\"Event\",\"relationName\":\"EventToUser\"},{\"name\":\"modelLikes\",\"kind\":\"object\",\"type\":\"ModelLike\",\"relationName\":\"ModelLikeToUser\"},{\"name\":\"modelInteractions\",\"kind\":\"object\",\"type\":\"ModelInteraction\",\"relationName\":\"ModelInteractionToUser\"},{\"name\":\"modelDrafts\",\"kind\":\"object\",\"type\":\"ModelDraft\",\"relationName\":\"ModelDraftToUser\"},{\"name\":\"role\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"banned\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"banReason\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"banExpires\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"onboardedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"passkeys\",\"kind\":\"object\",\"type\":\"Passkey\",\"relationName\":\"PasskeyToUser\"}],\"dbName\":null},\"Account\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"providerId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"refreshToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accessTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"refreshTokenExpiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"scope\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"idToken\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"password\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"AccountToUser\"}],\"dbName\":null},\"Session\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"token\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"SessionToUser\"},{\"name\":\"impersonatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Verification\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"identifier\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"expiresAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"UserToVerification\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Passkey\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"publicKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"credentialID\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"counter\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"deviceType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"backedUp\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"transports\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"aaguid\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"PasskeyToUser\"}],\"dbName\":null},\"Model\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"latestVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"parentModelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"visibility\",\"kind\":\"enum\",\"type\":\"ModelVisibility\"},{\"name\":\"isEndorsed\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"isLibraryModel\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"viewCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"runCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"downloadCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"shareCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"latestVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"LatestVersion\"},{\"name\":\"parentModel\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelParent\"},{\"name\":\"childModels\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelParent\"},{\"name\":\"parentVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ParentVersion\"},{\"name\":\"versions\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelVersions\"},{\"name\":\"authors\",\"kind\":\"object\",\"type\":\"ModelAuthor\",\"relationName\":\"ModelToModelAuthor\"},{\"name\":\"nonMemberContributors\",\"kind\":\"object\",\"type\":\"NonMemberContributor\",\"relationName\":\"ModelToNonMemberContributor\"},{\"name\":\"permissions\",\"kind\":\"object\",\"type\":\"ModelPermission\",\"relationName\":\"ModelToModelPermission\"},{\"name\":\"additionalFiles\",\"kind\":\"object\",\"type\":\"ModelAdditionalFile\",\"relationName\":\"ModelToModelAdditionalFile\"},{\"name\":\"likes\",\"kind\":\"object\",\"type\":\"ModelLike\",\"relationName\":\"ModelToModelLike\"},{\"name\":\"interactions\",\"kind\":\"object\",\"type\":\"ModelInteraction\",\"relationName\":\"ModelToModelInteraction\"},{\"name\":\"drafts\",\"kind\":\"object\",\"type\":\"ModelDraft\",\"relationName\":\"ModelToModelDraft\"}],\"dbName\":null},\"ModelVersion\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"changeSummary\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"previewImageFileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"netlogoFileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"netlogoVersion\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"infoTab\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finalizedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelVersions\"},{\"name\":\"latestOfModel\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"LatestVersion\"},{\"name\":\"parentOfModels\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ParentVersion\"},{\"name\":\"tags\",\"kind\":\"object\",\"type\":\"ModelVersionTag\",\"relationName\":\"ModelVersionToModelVersionTag\"},{\"name\":\"taggedAdditionalFiles\",\"kind\":\"object\",\"type\":\"ModelAdditionalFile\",\"relationName\":\"ModelAdditionalFileToModelVersion\"}],\"dbName\":null},\"ModelVersionTag\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"tagId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"modelVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelVersionToModelVersionTag\"},{\"name\":\"tag\",\"kind\":\"object\",\"type\":\"Tag\",\"relationName\":\"ModelVersionTagToTag\"}],\"dbName\":null},\"ModelAdditionalFile\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"taggedVersionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"fileKey\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"kind\",\"kind\":\"enum\",\"type\":\"ModelFileKind\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelAdditionalFile\"},{\"name\":\"taggedVersion\",\"kind\":\"object\",\"type\":\"ModelVersion\",\"relationName\":\"ModelAdditionalFileToModelVersion\"}],\"dbName\":null},\"Tag\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"displayName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"modelVersions\",\"kind\":\"object\",\"type\":\"ModelVersionTag\",\"relationName\":\"ModelVersionTagToTag\"}],\"dbName\":null},\"ModelAuthor\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"role\",\"kind\":\"enum\",\"type\":\"AuthorRole\"},{\"name\":\"collaboratorType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelAuthor\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelAuthorToUser\"}],\"dbName\":null},\"NonMemberContributor\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"legacyId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"collaboratorType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"addedByUserId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToNonMemberContributor\"}],\"dbName\":null},\"ModelPermission\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"granteeUserId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"permissionLevel\",\"kind\":\"enum\",\"type\":\"PermissionLevel\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelPermission\"},{\"name\":\"granteeUser\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelPermissionToUser\"}],\"dbName\":null},\"ModelLike\":{\"fields\":[{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelLike\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelLikeToUser\"}],\"dbName\":null},\"ModelInteraction\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"versionNumber\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"kind\",\"kind\":\"enum\",\"type\":\"ModelInteractionKind\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sessionId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userAgent\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"referer\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"geo\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"cookie\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelInteraction\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelInteractionToUser\"}],\"dbName\":null},\"ModelDraft\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"userId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"user\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"ModelDraftToUser\"},{\"name\":\"modelId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"model\",\"kind\":\"object\",\"type\":\"Model\",\"relationName\":\"ModelToModelDraft\"},{\"name\":\"schemaVersion\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"data\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"}],\"dbName\":null},\"Event\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"type\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"actorId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resourceType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"resourceId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"payload\",\"kind\":\"scalar\",\"type\":\"Json\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"processedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"actor\",\"kind\":\"object\",\"type\":\"User\",\"relationName\":\"EventToUser\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") diff --git a/apps/modeling-commons-backend/generated/prisma/package.json b/apps/modeling-commons-backend/generated/prisma/package.json index 60dc004a..37982991 100644 --- a/apps/modeling-commons-backend/generated/prisma/package.json +++ b/apps/modeling-commons-backend/generated/prisma/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-af8d9cd952af75c255e648d4376319ab8c412ef134d4d3c4cb42d336ce682951", + "name": "prisma-client-2b65646b7f5e094a2235bdabb05f0e278c5d9356b633aff4e9fe60bcd978ab60", "main": "index.js", "types": "index.d.ts", "browser": "default.js", diff --git a/apps/modeling-commons-backend/generated/prisma/schema.prisma b/apps/modeling-commons-backend/generated/prisma/schema.prisma index 6315ce73..127ff746 100644 --- a/apps/modeling-commons-backend/generated/prisma/schema.prisma +++ b/apps/modeling-commons-backend/generated/prisma/schema.prisma @@ -54,7 +54,7 @@ enum ModelFileKind { // Better Auth core tables model User { - id String @id @default(uuid()) + id String @id @default(nanoid()) name String? email String? @unique emailVerified Boolean @default(false) @@ -103,7 +103,7 @@ model User { } model Account { - id String @id @default(uuid()) + id String @id @default(nanoid()) userId String accountId String providerId String @@ -123,7 +123,7 @@ model Account { } model Session { - id String @id @default(uuid()) + id String @id @default(nanoid()) userId String expiresAt DateTime token String @unique @@ -141,7 +141,7 @@ model Session { } model Verification { - id String @id @default(uuid()) + id String @id @default(nanoid()) identifier String value String expiresAt DateTime @@ -155,7 +155,7 @@ model Verification { } model Passkey { - id String @id @default(uuid()) + id String @id @default(nanoid()) name String? publicKey String userId String @@ -173,7 +173,7 @@ model Passkey { // Domain models model Model { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int? @unique latestVersionNumber Int? parentModelId String? @@ -251,7 +251,7 @@ model ModelVersionTag { } model ModelAdditionalFile { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String taggedVersionNumber Int fileKey String @@ -266,7 +266,7 @@ model ModelAdditionalFile { } model Tag { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int? @unique name String @unique displayName String? @@ -294,7 +294,7 @@ model ModelAuthor { // credited on a model who never held an account. Archival only, no business logic // reads this table. model NonMemberContributor { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int @unique modelId String email String? @@ -310,7 +310,7 @@ model NonMemberContributor { } model ModelPermission { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String granteeUserId String? permissionLevel PermissionLevel @@ -338,7 +338,7 @@ model ModelLike { } model ModelInteraction { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String versionNumber Int? kind ModelInteractionKind @@ -362,7 +362,7 @@ model ModelInteraction { } model ModelDraft { - id String @id @default(cuid()) + id String @id @default(nanoid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -380,7 +380,7 @@ model ModelDraft { } model Event { - id String @id @default(uuid()) + id String @id @default(nanoid()) type String actorId String resourceType String diff --git a/apps/modeling-commons-backend/package.json b/apps/modeling-commons-backend/package.json index 697a3a7a..58c99533 100644 --- a/apps/modeling-commons-backend/package.json +++ b/apps/modeling-commons-backend/package.json @@ -53,7 +53,6 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1031.0", "@aws-sdk/s3-request-presigner": "^3.1032.0", - "better-auth": "^1.5.6", "@better-auth/passkey": "^1.5.6", "@better-auth/utils": "^0.3.1", "@fastify/autoload": "6.3.1", @@ -81,10 +80,12 @@ "ajv": "8.18.0", "ajv-formats": "3.0.1", "awilix": "13.0.3", + "better-auth": "^1.5.6", "env-schema": "7.0.0", "fastify": "5.8.4", "fastify-plugin": "5.1.0", "file-type": "^22.0.0", + "nanoid": "^6.0.1", "nodemailer": "^8.0.7", "pg": "^8.20.0", "pg-boss": "^12.14.0", diff --git a/apps/modeling-commons-backend/prisma/schema.prisma b/apps/modeling-commons-backend/prisma/schema.prisma index dafa8f9d..e7cb6c52 100644 --- a/apps/modeling-commons-backend/prisma/schema.prisma +++ b/apps/modeling-commons-backend/prisma/schema.prisma @@ -54,7 +54,7 @@ enum ModelFileKind { // Better Auth core tables model User { - id String @id @default(uuid()) + id String @id @default(nanoid()) name String? email String? @unique emailVerified Boolean @default(false) @@ -103,7 +103,7 @@ model User { } model Account { - id String @id @default(uuid()) + id String @id @default(nanoid()) userId String accountId String providerId String @@ -123,7 +123,7 @@ model Account { } model Session { - id String @id @default(uuid()) + id String @id @default(nanoid()) userId String expiresAt DateTime token String @unique @@ -141,7 +141,7 @@ model Session { } model Verification { - id String @id @default(uuid()) + id String @id @default(nanoid()) identifier String value String expiresAt DateTime @@ -155,7 +155,7 @@ model Verification { } model Passkey { - id String @id @default(uuid()) + id String @id @default(nanoid()) name String? publicKey String userId String @@ -173,7 +173,7 @@ model Passkey { // Domain models model Model { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int? @unique latestVersionNumber Int? parentModelId String? @@ -252,7 +252,7 @@ model ModelVersionTag { } model ModelAdditionalFile { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String taggedVersionNumber Int fileKey String @@ -267,7 +267,7 @@ model ModelAdditionalFile { } model Tag { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int? @unique name String @unique displayName String? @@ -295,7 +295,7 @@ model ModelAuthor { // credited on a model who never held an account. Archival only, no business logic // reads this table. model NonMemberContributor { - id String @id @default(uuid()) + id String @id @default(nanoid()) legacyId Int @unique modelId String email String? @@ -311,7 +311,7 @@ model NonMemberContributor { } model ModelPermission { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String granteeUserId String? permissionLevel PermissionLevel @@ -339,7 +339,7 @@ model ModelLike { } model ModelInteraction { - id String @id @default(uuid()) + id String @id @default(nanoid()) modelId String versionNumber Int? kind ModelInteractionKind @@ -363,7 +363,7 @@ model ModelInteraction { } model ModelDraft { - id String @id @default(cuid()) + id String @id @default(nanoid()) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -381,7 +381,7 @@ model ModelDraft { } model Event { - id String @id @default(uuid()) + id String @id @default(nanoid()) type String actorId String resourceType String diff --git a/apps/modeling-commons-backend/src/shared/api/id.response.dto.ts b/apps/modeling-commons-backend/src/shared/api/id.response.dto.ts index 059e448d..4e7d81d4 100644 --- a/apps/modeling-commons-backend/src/shared/api/id.response.dto.ts +++ b/apps/modeling-commons-backend/src/shared/api/id.response.dto.ts @@ -1,11 +1,8 @@ import { Type } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const idDtoSchema = Type.Object({ - id: Type.String({ - format: 'uuid', - example: '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231', - description: "Entity's id", - }), + id: idSchema("Entity's id"), }); export const versionNumberDtoSchema = Type.Object({ diff --git a/apps/modeling-commons-backend/src/shared/utils/id.spec.ts b/apps/modeling-commons-backend/src/shared/utils/id.spec.ts new file mode 100644 index 00000000..6fe3967d --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/id.spec.ts @@ -0,0 +1,58 @@ +import { Type } from 'typebox'; +import { describe, it, expect } from 'vitest'; + +import { ajv } from '#src/shared/utils/validator.util.ts'; + +import { ID_LENGTH, ID_PATTERN, newId, idSchema } from '#src/shared/utils/id.ts'; + +describe('id format and schema', () => { + it('ID_PATTERN generates a valid regex pattern', () => { + const regex = new RegExp(ID_PATTERN); + expect(regex.test('A1b2C3d4E5f6G7h8I9j0K')).toBe(true); // valid ID + expect(regex.test('invalid-id!')).toBe(false); // invalid ID + }); + + it('newId generates a valid ID', () => { + const id = newId(); + expect(id).toMatch(new RegExp(ID_PATTERN)); + expect(id.length).toBe(ID_LENGTH); + }); + + it('newId generates 10_000 unique IDs', () => { + const ids = new Set(); + for (let i = 0; i < 10_000; i++) { + ids.add(newId()); + } + expect(ids.size).toBe(10_000); + }); + + it('idSchema returns a valid schema object', () => { + const schema = idSchema('Test ID'); + expect(schema.type).toBe('string'); + }); + + it('ajv validates the schema correctly', () => { + const validIds = ['A1b2C3d4E5f6G7h8I9j0K', 'Z9y8X7w6V5u4T3s2R1q0P']; + const invalidIds = ['invalid-id!', '123', 'A1b2C3d4E5f6G7h8I9j0K!', '', undefined]; + + const objectSchema = Type.Object({ + id: idSchema('Test ID'), + }); + + ajv.addKeyword('example'); + const validate = (data: unknown): boolean => { + const validateFn = ajv.compile(objectSchema); + return validateFn(data) as boolean; + }; + + for (const id of validIds) { + const fakeValidData = { id }; + expect(validate(fakeValidData)).toBe(true); + } + + for (const id of invalidIds) { + const fakeInvalidData = { id }; + expect(validate(fakeInvalidData)).toBe(false); + } + }); +}); diff --git a/apps/modeling-commons-backend/src/shared/utils/id.ts b/apps/modeling-commons-backend/src/shared/utils/id.ts new file mode 100644 index 00000000..2003a4a2 --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/id.ts @@ -0,0 +1,16 @@ +import { nanoid } from 'nanoid'; +import { Type, type TString } from 'typebox'; + +export const ID_LENGTH = 21; +// drives ajv.addFormat('nanoid', ...); +// --Omar Ibrahim, Aug 12 26 +export const ID_PATTERN = `^[A-Za-z0-9_-]{${ID_LENGTH}}$`; +export const ID_EXAMPLE = '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231'; +export const newId = (): string => nanoid(ID_LENGTH); +export const idSchema: (description?: string) => TString = (description?: string) => { + return Type.String({ + format: 'nanoid', + example: ID_EXAMPLE, + description: description ?? "Entity's id", + }); +}; diff --git a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts index 4d148d4f..8b4977bc 100644 --- a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts +++ b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts @@ -1,5 +1,6 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; +import { ID_PATTERN } from './id.ts'; // `.default` is needed because Ajv and ajv-formats are CJS packages. // Under `"module": "NodeNext"`, TypeScript resolves the default import as @@ -21,3 +22,5 @@ export const ajv = addFormats.default(new Ajv.default({}), [ 'relative-json-pointer', 'regex', ]); + +ajv.addFormat('nanoid', new RegExp(ID_PATTERN)); diff --git a/yarn.lock b/yarn.lock index e3043544..d0b4421a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3624,10 +3624,10 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== -"@nodable/entities@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" - integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA== +"@nodable/entities@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-3.0.0.tgz#694703bc864d30eaed55c2e3def00dbd61493670" + integrity sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -8006,6 +8006,18 @@ test-exclude "^7.0.1" tinyrainbow "^1.2.0" +"@vitest/expect@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.10.tgz#799c06fc44bb0cf7e2784137b627c5cc173285d4" + integrity sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA== + dependencies: + "@standard-schema/spec" "^1.1.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.1.10" + "@vitest/utils" "4.1.10" + chai "^6.2.2" + tinyrainbow "^3.1.0" + "@vitest/expect@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.6.tgz#b50c9390aae6957ab4d9e20722cebb17d5bf169a" @@ -8018,6 +8030,15 @@ chai "^6.2.2" tinyrainbow "^3.1.0" +"@vitest/mocker@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.10.tgz#2413987ab4cd7fa1c2b614b404c407bf6ad1ead1" + integrity sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow== + dependencies: + "@vitest/spy" "4.1.10" + estree-walker "^3.0.3" + magic-string "^0.30.21" + "@vitest/mocker@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.6.tgz#6b624045745236b02aca879a02aef68b72d9d4cd" @@ -8027,6 +8048,13 @@ estree-walker "^3.0.3" magic-string "^0.30.21" +"@vitest/pretty-format@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz#75542e7273a08cc10fd4d8dad4e3eb1f16cd958c" + integrity sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q== + dependencies: + tinyrainbow "^3.1.0" + "@vitest/pretty-format@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.6.tgz#24a1c03a6b68a8775f8ddfec51d3636315edc3f5" @@ -8034,6 +8062,14 @@ dependencies: tinyrainbow "^3.1.0" +"@vitest/runner@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.10.tgz#febf0a21a9168421422d1955370e606feab60355" + integrity sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg== + dependencies: + "@vitest/utils" "4.1.10" + pathe "^2.0.3" + "@vitest/runner@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.6.tgz#b6d189e68bd9927c4f111ad089ff96e4757591b1" @@ -8042,6 +8078,16 @@ "@vitest/utils" "4.1.6" pathe "^2.0.3" +"@vitest/snapshot@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.10.tgz#7e3e9fec7d4d47232e493cfdcbd2170de4371c04" + integrity sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw== + dependencies: + "@vitest/pretty-format" "4.1.10" + "@vitest/utils" "4.1.10" + magic-string "^0.30.21" + pathe "^2.0.3" + "@vitest/snapshot@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.6.tgz#14fdfc8baf6b4b3e4e35763431dbea3aaa8aa0eb" @@ -8052,11 +8098,25 @@ magic-string "^0.30.21" pathe "^2.0.3" +"@vitest/spy@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65" + integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw== + "@vitest/spy@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.6.tgz#0a316893630f47fa545e33026cfc91575070d165" integrity sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg== +"@vitest/utils@4.1.10": + version "4.1.10" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.10.tgz#ffc71055f18bfccb1fd0586365ebc2824892e403" + integrity sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA== + dependencies: + "@vitest/pretty-format" "4.1.10" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + "@vitest/utils@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.6.tgz#3f4acf1f60e135ec1ce896f10baa4cd6466d0d38" @@ -8614,6 +8674,11 @@ anymatch@^3.0.3, anymatch@^3.1.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +anynum@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/anynum/-/anynum-1.0.1.tgz#2aac00e08dfad3726c1d462e60dbc2f831659a44" + integrity sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A== + archiver-utils@^5.0.0, archiver-utils@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" @@ -12049,15 +12114,16 @@ fast-xml-builder@^1.2.0: xml-naming "^0.1.0" fast-xml-parser@5.5.8, fast-xml-parser@^5.2.5, fast-xml-parser@^5.5.6: - version "5.8.0" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz#64d71f0f8d4bf23621dffd762aef7e98c1884fc1" - integrity sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg== + version "5.10.1" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz#19313f7c9386c47fa4a1de527547b73ed2cfcede" + integrity sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw== dependencies: - "@nodable/entities" "^2.1.0" + "@nodable/entities" "^3.0.0" fast-xml-builder "^1.2.0" - path-expression-matcher "^1.5.0" - strnum "^2.3.0" - xml-naming "^0.1.0" + is-unsafe "^2.0.0" + path-expression-matcher "^1.6.2" + strnum "^2.4.1" + xml-naming "^0.3.0" fastify-plugin@5.1.0, fastify-plugin@^5.0.0, fastify-plugin@^5.1.0: version "5.1.0" @@ -13973,6 +14039,11 @@ is-unicode-supported@^2.0.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== +is-unsafe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-unsafe/-/is-unsafe-2.0.0.tgz#c0dce4e06742662dde26360160e414ea487da2e9" + integrity sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA== + is-utf8@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" @@ -16327,6 +16398,11 @@ nanoid@^5.1.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.7.tgz#a9f09a4ce73ba0b88830af36ee49666bad7827b6" integrity sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ== +nanoid@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-6.0.1.tgz#a04a2304f05b620c600ae8464d94c325ceeebd35" + integrity sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw== + nanostores@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/nanostores/-/nanostores-1.2.0.tgz#df60c32f9b79af668f01d0e917639a36c0655ffe" @@ -17612,6 +17688,11 @@ path-expression-matcher@^1.5.0: resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== +path-expression-matcher@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz#567c73c07197e9dcef24e90edcdc571056599168" + integrity sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -20441,10 +20522,12 @@ strip-literal@^3.0.0, strip-literal@^3.1.0: dependencies: js-tokens "^9.0.1" -strnum@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.3.0.tgz#81bfbfef53db8c3217ea62a98c026886ec4a2761" - integrity sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q== +strnum@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.4.1.tgz#85417f683113badea0fe7e17227676f889ff7e58" + integrity sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg== + dependencies: + anynum "^1.0.1" strtok3@^10.3.5: version "10.3.5" @@ -21982,17 +22065,17 @@ vitest-environment-nuxt@^1.0.1: "@nuxt/test-utils" ">=3.13.1" vitest@^4.0.0, vitest@^4.0.16, vitest@^4.1.2: - version "4.1.6" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.6.tgz#754875c9a09c5a3e8ca7d07d440659d92c19787f" - integrity sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ== - dependencies: - "@vitest/expect" "4.1.6" - "@vitest/mocker" "4.1.6" - "@vitest/pretty-format" "4.1.6" - "@vitest/runner" "4.1.6" - "@vitest/snapshot" "4.1.6" - "@vitest/spy" "4.1.6" - "@vitest/utils" "4.1.6" + version "4.1.10" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.10.tgz#7e9285efe264b1167050b7a3a7ff34788e1b7afc" + integrity sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw== + dependencies: + "@vitest/expect" "4.1.10" + "@vitest/mocker" "4.1.10" + "@vitest/pretty-format" "4.1.10" + "@vitest/runner" "4.1.10" + "@vitest/snapshot" "4.1.10" + "@vitest/spy" "4.1.10" + "@vitest/utils" "4.1.10" es-module-lexer "^2.0.0" expect-type "^1.3.0" magic-string "^0.30.21" @@ -22378,6 +22461,11 @@ xml-naming@^0.1.0: resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" integrity sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw== +xml-naming@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2" + integrity sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ== + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" From b90bdfe3304955454deaaf547a1ea197f6a6c840 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:21:18 -0500 Subject: [PATCH 03/18] fix(commons): stop guessing tag id vs name by shape findByIdOrName used a UUID-shaped regex to decide between an id lookup and a name lookup. A NanoID is an ordinary-looking string that a tag name could also be, so the heuristic misroutes any NanoID-shaped tag name to a 404. Look up by id first and fall back to name on a miss; ids are unique, so a hit is authoritative. This costs one extra query on the name path, which is acceptable. --- .../src/modules/tag/tag.service.spec.ts | 32 +++++++++++++++---- .../src/modules/tag/tag.service.ts | 11 +++---- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/modeling-commons-backend/src/modules/tag/tag.service.spec.ts b/apps/modeling-commons-backend/src/modules/tag/tag.service.spec.ts index 97c76975..f7e74af0 100644 --- a/apps/modeling-commons-backend/src/modules/tag/tag.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/tag/tag.service.spec.ts @@ -33,30 +33,50 @@ describe('tagService', () => { }); describe('findByIdOrName', () => { - it('finds by UUID', async () => { - const tag = { id: '550e8400-e29b-41d4-a716-446655440000', name: 'test' }; + const nanoId = 'V1StGXR8Z5jdHi6BmyT8C'; + + it('finds by id', async () => { + const tag = { id: nanoId, name: 'test' }; tagRepository.findOneById.mockResolvedValue(tag); - const result = await service.findByIdOrName('550e8400-e29b-41d4-a716-446655440000'); + const result = await service.findByIdOrName(nanoId); expect(result).toBe(tag); - expect(tagRepository.findOneById).toHaveBeenCalled(); + expect(tagRepository.findOneById).toHaveBeenCalledWith(nanoId); + expect(tagRepository.findByNameInsensitive).not.toHaveBeenCalled(); }); - it('finds by name when not UUID', async () => { + it('finds by name when id lookup misses', async () => { const tag = { id: 't1', name: 'ecology' }; + tagRepository.findOneById.mockResolvedValue(undefined); tagRepository.findByNameInsensitive.mockResolvedValue(tag); const result = await service.findByIdOrName('ecology'); expect(result).toBe(tag); + expect(tagRepository.findOneById).toHaveBeenCalledWith('ecology'); expect(tagRepository.findByNameInsensitive).toHaveBeenCalledWith('ecology'); }); - it('throws TagNotFoundError when not found', async () => { + it('finds a tag whose name is NanoID-shaped, by name', async () => { + const tag = { id: 't2', name: nanoId }; + tagRepository.findOneById.mockResolvedValue(undefined); + tagRepository.findByNameInsensitive.mockResolvedValue(tag); + + const result = await service.findByIdOrName(nanoId); + + expect(result).toBe(tag); + expect(tagRepository.findOneById).toHaveBeenCalledWith(nanoId); + expect(tagRepository.findByNameInsensitive).toHaveBeenCalledWith(nanoId); + }); + + it('throws TagNotFoundError when neither lookup matches, querying at most twice', async () => { + tagRepository.findOneById.mockResolvedValue(undefined); tagRepository.findByNameInsensitive.mockResolvedValue(undefined); await expect(service.findByIdOrName('missing')).rejects.toThrow(TagNotFoundError); + expect(tagRepository.findOneById).toHaveBeenCalledTimes(1); + expect(tagRepository.findByNameInsensitive).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/modeling-commons-backend/src/modules/tag/tag.service.ts b/apps/modeling-commons-backend/src/modules/tag/tag.service.ts index c7657645..57bfe271 100644 --- a/apps/modeling-commons-backend/src/modules/tag/tag.service.ts +++ b/apps/modeling-commons-backend/src/modules/tag/tag.service.ts @@ -27,14 +27,13 @@ export default function makeTagService({ tagRepository, tagDomain }: Dependencie }, async findByIdOrName(idOrName: string): Promise { - const isUuid = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(idOrName); + const byId = await tagRepository.findOneById(idOrName); + if (byId) return byId; - const tag = isUuid - ? await tagRepository.findOneById(idOrName) - : await tagRepository.findByNameInsensitive(idOrName); + const byName = await tagRepository.findByNameInsensitive(idOrName); + if (byName) return byName; - if (!tag) throw new TagNotFoundError(idOrName); - return tag; + throw new TagNotFoundError(idOrName); }, async upsertByName(name: string): Promise { From cb898f9b48b508816d303a160ab1e4c79be73421 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:22:19 -0500 Subject: [PATCH 04/18] feat(commons): generate request and correlation ids as nanoids Switch x-correlation-id and Fastify genReqId generation from randomUUID to newId. Rename validateUUIDv4 to validateRequestId and widen it to accept either a canonical UUID or a NanoID, since these headers commonly arrive as UUIDs from upstream proxies and tracing systems. This is the only place in the codebase that still accepts UUID-shaped ids, and it is deliberate upstream interop, not backward compatibility. --- apps/modeling-commons-backend/src/index.ts | 8 +-- .../src/server/plugins/correlation-id.spec.ts | 63 +++++++++++++++++++ .../src/server/plugins/correlation-id.ts | 6 +- .../shared/utils/validate-request-id.spec.ts | 34 ++++++++++ .../src/shared/utils/validate-request-id.ts | 15 +++++ .../src/shared/utils/validateUUIDv4.ts | 5 -- 6 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 apps/modeling-commons-backend/src/server/plugins/correlation-id.spec.ts create mode 100644 apps/modeling-commons-backend/src/shared/utils/validate-request-id.spec.ts create mode 100644 apps/modeling-commons-backend/src/shared/utils/validate-request-id.ts delete mode 100644 apps/modeling-commons-backend/src/shared/utils/validateUUIDv4.ts diff --git a/apps/modeling-commons-backend/src/index.ts b/apps/modeling-commons-backend/src/index.ts index 0e080d71..2c407ded 100644 --- a/apps/modeling-commons-backend/src/index.ts +++ b/apps/modeling-commons-backend/src/index.ts @@ -2,9 +2,9 @@ import { env } from '#src/config/index.ts'; import { checkServicesHealth } from '#src/server/healthcheck.ts'; import server from '#src/server/index.ts'; import { prisma } from '#src/shared/db/prisma.client.ts'; +import { newId } from '#src/shared/utils/id.ts'; +import { validateRequestId } from '#src/shared/utils/validate-request-id.ts'; import Fastify from 'fastify'; -import { randomUUID } from 'node:crypto'; -import { validateUUIDv4 } from './shared/utils/validateUUIDv4.ts'; async function init(): Promise { const fastify = Fastify({ @@ -15,10 +15,10 @@ async function init(): Promise { genReqId: (req) => { // header best practice: don't use "x-" // https://www.rfc-editor.org/info/rfc6648 and keep it lowercase - if (validateUUIDv4(req.headers['request-id'] as string)) { + if (validateRequestId(req.headers['request-id'] as string)) { return req.headers['request-id'] as string; } else { - return randomUUID(); + return newId(); } }, routerOptions: { diff --git a/apps/modeling-commons-backend/src/server/plugins/correlation-id.spec.ts b/apps/modeling-commons-backend/src/server/plugins/correlation-id.spec.ts new file mode 100644 index 00000000..0504cd56 --- /dev/null +++ b/apps/modeling-commons-backend/src/server/plugins/correlation-id.spec.ts @@ -0,0 +1,63 @@ +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { FastifyInstance } from 'fastify'; + +import { ID_PATTERN } from '#src/shared/utils/id.ts'; + +import correlationIdPlugin from '#src/server/plugins/correlation-id.ts'; + +describe('correlationIdPlugin', () => { + let fastify: FastifyInstance; + + beforeEach(async () => { + fastify = Fastify(); + await fastify.register(correlationIdPlugin); + fastify.get('/', async (request) => ({ correlationId: request.correlationId })); + await fastify.ready(); + }); + + afterEach(async () => { + await fastify.close(); + }); + + it('generates a NanoID when no header is sent', async () => { + const response = await fastify.inject({ method: 'GET', url: '/' }); + + expect(response.headers['x-correlation-id']).toMatch(new RegExp(ID_PATTERN)); + expect(response.json().correlationId).toBe(response.headers['x-correlation-id']); + }); + + it('echoes back a valid UUID header unchanged', async () => { + const uuid = '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231'; + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'x-correlation-id': uuid } }); + + expect(response.headers['x-correlation-id']).toBe(uuid); + }); + + it('echoes back a valid NanoID header unchanged', async () => { + const nanoid = 'A1b2C3d4E5f6G7h8I9j0K'; + const response = await fastify.inject({ method: 'GET', url: '/', headers: { 'x-correlation-id': nanoid } }); + + expect(response.headers['x-correlation-id']).toBe(nanoid); + }); + + it('generates a fresh id when the header is malformed', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { 'x-correlation-id': 'not-a-real-id' }, + }); + + expect(response.headers['x-correlation-id']).toMatch(new RegExp(ID_PATTERN)); + }); + + it('generates a fresh id when the header is over-long', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/', + headers: { 'x-correlation-id': 'a'.repeat(1000) }, + }); + + expect(response.headers['x-correlation-id']).toMatch(new RegExp(ID_PATTERN)); + }); +}); diff --git a/apps/modeling-commons-backend/src/server/plugins/correlation-id.ts b/apps/modeling-commons-backend/src/server/plugins/correlation-id.ts index 6bb27755..970f4e88 100644 --- a/apps/modeling-commons-backend/src/server/plugins/correlation-id.ts +++ b/apps/modeling-commons-backend/src/server/plugins/correlation-id.ts @@ -1,14 +1,14 @@ import type { FastifyInstance } from 'fastify'; -import { randomUUID } from 'node:crypto'; import fp from 'fastify-plugin'; -import { validateUUIDv4 } from '#src/shared/utils/validateUUIDv4.ts'; +import { newId } from '#src/shared/utils/id.ts'; +import { validateRequestId } from '#src/shared/utils/validate-request-id.ts'; async function correlationIdPlugin(fastify: FastifyInstance) { fastify.decorateRequest('correlationId', ''); fastify.addHook('onRequest', async (request, reply) => { const raw = request.headers['x-correlation-id'] as string | undefined; - const id = raw && validateUUIDv4(raw) ? raw : randomUUID(); + const id = raw && validateRequestId(raw) ? raw : newId(); request.correlationId = id; reply.header('x-correlation-id', id); }); diff --git a/apps/modeling-commons-backend/src/shared/utils/validate-request-id.spec.ts b/apps/modeling-commons-backend/src/shared/utils/validate-request-id.spec.ts new file mode 100644 index 00000000..cff77182 --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/validate-request-id.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; + +import { newId } from '#src/shared/utils/id.ts'; +import { validateRequestId } from '#src/shared/utils/validate-request-id.ts'; + +describe('validateRequestId', () => { + it('accepts a NanoID', () => { + expect(validateRequestId(newId())).toBe(true); + }); + + it('accepts a canonical UUID', () => { + expect(validateRequestId('2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231')).toBe(true); + }); + + it('accepts an upper-case UUID', () => { + expect(validateRequestId('2CDC8AB1-6D50-49CC-BA14-54E4AC7EC231')).toBe(true); + }); + + it('rejects undefined', () => { + expect(validateRequestId(undefined)).toBe(false); + }); + + it('rejects an empty string', () => { + expect(validateRequestId('')).toBe(false); + }); + + it('rejects a malformed value', () => { + expect(validateRequestId('not-a-real-id')).toBe(false); + }); + + it('rejects an over-long value', () => { + expect(validateRequestId('a'.repeat(1000))).toBe(false); + }); +}); diff --git a/apps/modeling-commons-backend/src/shared/utils/validate-request-id.ts b/apps/modeling-commons-backend/src/shared/utils/validate-request-id.ts new file mode 100644 index 00000000..2d627a8f --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/validate-request-id.ts @@ -0,0 +1,15 @@ +import { ID_PATTERN } from '#src/shared/utils/id.ts'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const NANOID_RE = new RegExp(ID_PATTERN); +const MAX_LENGTH = 36; + +// This is the only place in the codebase that still accepts a UUID after the +// NanoID migration, and it stays that way on purpose. x-correlation-id and +// request-id headers routinely arrive as UUIDs from upstream proxies and +// tracing systems, so rejecting them would discard a usable trace id and +// break the correlation chain. This is deliberate upstream interop, not +// leftover backward compatibility, so a later UUID sweep should skip it. +export function validateRequestId(value: string | undefined): boolean { + return typeof value === 'string' && value.length <= MAX_LENGTH && (UUID_RE.test(value) || NANOID_RE.test(value)); +} diff --git a/apps/modeling-commons-backend/src/shared/utils/validateUUIDv4.ts b/apps/modeling-commons-backend/src/shared/utils/validateUUIDv4.ts deleted file mode 100644 index 9f4c0579..00000000 --- a/apps/modeling-commons-backend/src/shared/utils/validateUUIDv4.ts +++ /dev/null @@ -1,5 +0,0 @@ -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function validateUUIDv4(uuid: string | undefined): boolean { - return typeof uuid === 'string' && UUID_RE.test(uuid); -} From fb7ed1945a14cecad7e49d58ba431ed2853268bd Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:23:23 -0500 Subject: [PATCH 05/18] refactor(commons): emit nanoid-shaped ids from seedId Hash the namespaced key with SHA-256 and map 21 digest bytes into the nanoid alphabet via byte & 63, instead of bit-forcing a UUIDv5 shape from SHA-1. Determinism and idempotency are unchanged; only the output encoding moves from UUID to nanoid. --- .../prisma/seed/id.spec.ts | 24 ++++++++++++++++++ .../prisma/seed/id.ts | 25 +++++++++++-------- 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 apps/modeling-commons-backend/prisma/seed/id.spec.ts diff --git a/apps/modeling-commons-backend/prisma/seed/id.spec.ts b/apps/modeling-commons-backend/prisma/seed/id.spec.ts new file mode 100644 index 00000000..2cbb2a79 --- /dev/null +++ b/apps/modeling-commons-backend/prisma/seed/id.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; +import { seedId } from './id.ts'; + +describe('seedId', () => { + test('is deterministic for the same parts', () => { + expect(seedId('user', 'alice')).toBe(seedId('user', 'alice')); + expect(seedId('model', 'wolf-sheep', 1)).toBe(seedId('model', 'wolf-sheep', 1)); + }); + + test('matches the NanoID pattern', () => { + const pattern = new RegExp(ID_PATTERN); + expect(seedId('user', 'alice')).toMatch(pattern); + expect(seedId('event', 'model.created', 'abc123')).toMatch(pattern); + }); + + test('produces distinct ids across a large sample of distinct inputs', () => { + const ids = new Set(); + for (let i = 0; i < 10000; i++) { + ids.add(seedId('key', i)); + } + expect(ids.size).toBe(10000); + }); +}); diff --git a/apps/modeling-commons-backend/prisma/seed/id.ts b/apps/modeling-commons-backend/prisma/seed/id.ts index 2e3cf609..e83bba97 100644 --- a/apps/modeling-commons-backend/prisma/seed/id.ts +++ b/apps/modeling-commons-backend/prisma/seed/id.ts @@ -1,26 +1,31 @@ import { createHash } from 'node:crypto'; +import { ID_LENGTH } from '#src/shared/utils/id.ts'; const SEED_NAMESPACE = 'modeling-commons:seed'; +const NANOID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; /** - * Deterministic, UUID-shaped id derived from a stable natural key. + * Deterministic, NanoID-shaped id derived from a stable natural key. * * The same parts always produce the same id, so every record can be upserted * by id and the whole seed is idempotent across runs - no fragile call-order - * counters. Output is a valid v5-style UUID (version + variant bits set). + * counters. The natural key is hashed with SHA-256, then each of the first + * ID_LENGTH digest bytes is mapped into the 64-character NanoID alphabet via + * `byte & 63`; 64 divides 256 evenly, so the mapping is uniform with no + * modulo bias. * - * seedId('user', 'alice') => 'a1b2...' - * seedId('model', 'wolf-sheep') => 'c3d4...' + * seedId('user', 'alice') => '23KwZwy1vmaMPfUEY8US9' + * seedId('model', 'wolf-sheep') => 'UpmQcV8NL5fWuiU_FxxHE' */ export function seedId(...parts: Array): string { const name = `${SEED_NAMESPACE}:${parts.join(':')}`; - const bytes = createHash('sha1').update(name).digest().subarray(0, 16); + const bytes = createHash('sha256').update(name).digest().subarray(0, ID_LENGTH); - bytes[6] = (bytes[6]! & 0x0f) | 0x50; // version 5 - bytes[8] = (bytes[8]! & 0x3f) | 0x80; // RFC 4122 variant - - const hex = Buffer.from(bytes).toString('hex'); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + let id = ''; + for (const byte of bytes) { + id += NANOID_ALPHABET[byte & 63]; + } + return id; } /** From 52daaf6697e429024427ad7af9f22b45866a56f8 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:23:54 -0500 Subject: [PATCH 06/18] refactor(commons): switch legacy migration row ids to nanoid Replace randomUUID with newId in initial-import.ts and apply-diff.ts for every generated row id. Idempotency comes from legacyId columns, not id determinism, so this is a pure generator swap. Rename NodeMigrationDeps.newUuid to newId and update all callers and specs. Rename derivedUuid to storagePathHash and add a pinned-output test: its uuid-shaped S3 key format is frozen on purpose and must stay byte-for-byte identical to avoid orphaning existing storage objects. --- .../prisma/legacy-migration/apply-diff.ts | 20 +++++++++---------- .../prisma/legacy-migration/initial-import.ts | 14 ++++++------- .../legacy-migration/lib/file-keys.spec.ts | 19 +++++++++++------- .../prisma/legacy-migration/lib/file-keys.ts | 8 +++++--- .../lib/node-migration.spec.ts | 2 +- .../legacy-migration/lib/node-migration.ts | 6 +++--- 6 files changed, 38 insertions(+), 31 deletions(-) diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts index 7be4045c..cbd312e0 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts @@ -23,11 +23,11 @@ import { HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { PrismaPg } from '@prisma/adapter-pg'; import 'dotenv/config'; -import { randomUUID } from 'node:crypto'; import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { newId } from '#src/shared/utils/id.ts'; import { Prisma, PrismaClient } from '../../generated/prisma/client.js'; import { isPatchableTable, @@ -45,7 +45,7 @@ import { buildAvatarFileKey, buildPreviewFileKey, buildVersionFileKey, - derivedUuid, + storagePathHash, sanitizeFilename, } from './lib/file-keys.ts'; import { @@ -373,7 +373,7 @@ async function planTags(diffs: Map, plan: Plan, ctx: ctx.tagIdByLegacyId.set(t.id, existingId); continue; } - const id = randomUUID(); + const id = newId(); plan.tags.create.push({ id, legacyId: t.id, @@ -442,7 +442,7 @@ async function planUsers(diffs: Map, plan: Plan, ctx: plan.notes.push(`person ${p.id} already present in target; nothing to do`); continue; } - const id = randomUUID(); + const id = newId(); plan.users.create.push({ id, legacyId: p.id, @@ -538,7 +538,7 @@ async function claimEmail( async function stageAvatar(p: LegacyPerson, userId: string, plan: Plan): Promise { if (!p.avatar_file_name) return null; - const key = buildAvatarFileKey(userId, p.avatar_updated_at ?? new Date(), randomUUID(), 'avatar'); + const key = buildAvatarFileKey(userId, p.avatar_updated_at ?? new Date(), newId(), 'avatar'); const source = path.join(AVATARS_DIR, `${p.id}`, 'original', p.avatar_file_name); try { plan.files.push({ key, body: await readFile(source) }); @@ -575,7 +575,7 @@ async function planNewModels(diffs: Map, plan: Plan, continue; } - const modelId = randomUUID(); + const modelId = newId(); const tree: NodeTree = { node, versions, attachments, taggings }; const ops: RecordedOp[] = []; @@ -585,7 +585,7 @@ async function planNewModels(diffs: Map, plan: Plan, writeFile: async (key, body) => { plan.files.push({ key, body }); }, - newUuid: randomUUID, + newId, now: () => new Date(), userIdByLegacyId: ctx.userIdByLegacyId, tagIdByLegacyId: ctx.tagIdByLegacyId, @@ -774,7 +774,7 @@ function buildVersionCreate( const key = buildVersionFileKey( modelId, v.created_at ?? node.created_at ?? new Date(), - derivedUuid('version', v.id), + storagePathHash('version', v.id), `${node.name}.${format}`, ); @@ -868,7 +868,7 @@ async function planAdditionalFilesAndPreviews( continue; } - const fileUuid = randomUUID(); + const fileUuid = newId(); const key = buildAttachmentFileKey(modelId, a.created_at ?? new Date(), fileUuid, a.filename); plan.files.push({ key, body: a.contents }); plan.files.push({ @@ -945,7 +945,7 @@ async function planAdditionalFilesAndPreviews( ? buildPreviewFileKey( modelId, winner.created_at ?? new Date(), - derivedUuid('preview', winner.id), + storagePathHash('preview', winner.id), winner.filename, ) : null; diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts b/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts index f0467818..a67f8fb9 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts @@ -26,10 +26,10 @@ */ import { PrismaPg } from '@prisma/adapter-pg'; -import { randomUUID } from 'node:crypto'; import { copyFile as fsCopyFile, mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; +import { newId } from '#src/shared/utils/id.ts'; import { Prisma, PrismaClient } from '../../generated/prisma/client.js'; import { buildAvatarFileKey } from './lib/file-keys.ts'; import { @@ -172,7 +172,7 @@ async function migrateUsers(): Promise> { continue; } - const id = randomUUID(); + const id = newId(); const rawEmail = normalizeEmail(p.email_address); let email: string | null = null; if (rawEmail) { @@ -190,7 +190,7 @@ async function migrateUsers(): Promise> { const avatarKey = buildAvatarFileKey( id, p.avatar_updated_at ?? new Date(), - randomUUID(), + newId(), 'avatar', ); imageUrl = `cdn.modelingcommons.org/modeling-commons/${avatarKey}`; @@ -258,7 +258,7 @@ async function migrateTags(): Promise> { if (!n) continue; if (nameWinner.get(n)?.id !== t.id) continue; - const id = randomUUID(); + const id = newId(); toCreate.push({ id, legacyId: t.id, @@ -333,7 +333,7 @@ async function migrateNodes( legacy.taggingsForNode(node.id), ]); - const modelUuid = randomUUID(); + const modelUuid = newId(); modelIdMap.set(node.id, modelUuid); await prisma.$transaction( @@ -344,7 +344,7 @@ async function migrateNodes( { node, versions, attachments, taggings }, { writeFile: writeLocalFile, - newUuid: randomUUID, + newId, now: () => new Date(), userIdByLegacyId: userIdMap, tagIdByLegacyId: tagIdMap, @@ -403,7 +403,7 @@ async function migrateInteractions( const modelUuid = modelIdMap.get(e.node_id); if (!modelUuid) continue; // node was spam-skipped or dropped rows.push({ - id: randomUUID(), + id: newId(), modelId: modelUuid, kind, userId: e.person_id ? (userIdMap.get(e.person_id) ?? null) : null, diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts index 84109e16..4f9af7d0 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { buildAttachmentFileKey, - derivedUuid, + storagePathHash, buildAvatarFileKey, buildPreviewFileKey, buildVersionFileKey, @@ -38,29 +38,34 @@ describe('sanitizeFilename', () => { }); }); -describe('derivedUuid', () => { +describe('storagePathHash', () => { test('is stable for the same namespace and id', () => { - expect(derivedUuid('preview', 4986)).toBe(derivedUuid('preview', 4986)); + expect(storagePathHash('preview', 4986)).toBe(storagePathHash('preview', 4986)); }); test('differs by id and by namespace', () => { - expect(derivedUuid('preview', 4986)).not.toBe(derivedUuid('preview', 4987)); - expect(derivedUuid('preview', 4986)).not.toBe(derivedUuid('attachment', 4986)); + expect(storagePathHash('preview', 4986)).not.toBe(storagePathHash('preview', 4987)); + expect(storagePathHash('preview', 4986)).not.toBe(storagePathHash('attachment', 4986)); }); test('is a well-formed v4-shaped uuid', () => { - expect(derivedUuid('preview', 1)).toMatch( + expect(storagePathHash('preview', 1)).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); }); test('holds the shape across many ids', () => { for (let i = 0; i < 200; i++) { - expect(derivedUuid('preview', i)).toMatch( + expect(storagePathHash('preview', i)).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); } }); + + test('pins the exact output for known inputs, so a future sweep cannot silently change it', () => { + expect(storagePathHash('version', 1)).toBe('fb60fd65-f24f-42c7-bb9f-9c794f021ae4'); + expect(storagePathHash('preview', 4986)).toBe('6817b950-1dca-4c97-aee6-5fafbb910a24'); + }); }); describe('getAccessPrefix', () => { diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts index 3f9c9e4f..ad17c19d 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts @@ -3,10 +3,12 @@ import { createHash } from 'node:crypto'; export type AccessPolicy = 'public-read' | 'private'; /** - * A stable stand-in for randomUUID, so re-deriving a key for the same legacy row - * yields the same object rather than a fresh copy on every run. + * Frozen storage-path format, not an identifier: the incremental sync must + * re-derive the same S3 object key for the same legacy row on every run, and + * that determinism predates the NanoID migration. This is a deliberate + * exception to the repo-wide NanoID rule; do not change its output. */ -export function derivedUuid(namespace: string, id: number | string): string { +export function storagePathHash(namespace: string, id: number | string): string { const hex = createHash('sha256').update(`${namespace}:${id}`).digest('hex'); const version = `4${hex.slice(13, 16)}`; const variant = ((parseInt(hex.slice(16, 17), 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20); diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts index be35372f..ca889387 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts @@ -44,7 +44,7 @@ function deps(overrides: Partial[3]> = {} writeFile: async (relKey: string, contents: Buffer) => { written.set(relKey, contents); }, - newUuid: () => `uuid-${++counter}`, + newId: () => `uuid-${++counter}`, now: () => NOW, userIdByLegacyId: new Map([ [10, 'user-10'], diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts index 18284379..1bd8650e 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts @@ -40,7 +40,7 @@ export type NodeTree = { export type NodeMigrationDeps = { writeFile: (relKey: string, contents: Buffer) => Promise; - newUuid: () => string; + newId: () => string; now: () => Date; userIdByLegacyId: ReadonlyMap; tagIdByLegacyId: ReadonlyMap; @@ -162,7 +162,7 @@ export async function createModelFromNode( } for (const a of attachments) { - const fileUuid = deps.newUuid(); + const fileUuid = deps.newId(); const dateForPath = a.created_at ?? node.created_at ?? deps.now(); if (a.content_type === 'preview') { @@ -234,7 +234,7 @@ async function writeVersion( const relKey = buildVersionFileKey( modelUuid, dateForPath, - deps.newUuid(), + deps.newId(), `${node.name}.${format}`, ); From ec132fdd85e0a28259ec915b713da4d1215de32f Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:28:16 -0500 Subject: [PATCH 07/18] feat(commons): stop generating and accepting UUID entity ids Swap every remaining randomUUID() entity-id generator for the shared newId() helper (model, permission, tag, additional-file, interaction, draft domains, plus the draft-file entries embedded in draft JSON) and point Better Auth's generateId at the same helper. Widen storage-key path segments from randomUUID().substring(0, 8) (32 bits of entropy) to nanoid(10), applied consistently across every storage-key call site since none of them parse the segment back. Replace every Type.String({ format: 'uuid' }) validator with idSchema() across the 18 remaining DTO/schema files, drop the now-unused 'uuid' ajv format, and fix ID_EXAMPLE to a real NanoID literal so Swagger stops advertising a UUID-shaped example that fails its own pattern. Tighten existing domain and storage-key specs to assert the NanoID shape instead of the old UUID/hex regex. --- apps/modeling-commons-backend/src/lib/auth.ts | 3 ++- .../modules/event/dtos/event.response.dto.ts | 3 ++- .../src/modules/file/domain/file.types.ts | 3 ++- .../src/modules/file/file.route.ts | 4 ++-- .../domain/model-additional-file.domain.ts | 4 ++-- .../model-additional-file.response.dto.ts | 5 ++-- .../model-additional-file.schemas.ts | 5 ++-- .../dtos/model-author.response.dto.ts | 5 ++-- .../model-author/model-author.schemas.ts | 13 +++++----- .../domain/model-draft.domain.spec.ts | 4 ++-- .../model-draft/domain/model-draft.domain.ts | 4 ++-- .../model-draft/dtos/model-draft.dto.ts | 9 +++---- .../model-draft/model-draft.service.spec.ts | 3 ++- .../model-draft/model-draft.service.ts | 9 +++---- .../model-draft/model-draft.storage.ts | 4 ++-- .../src/modules/model-draft/schemas/v1.ts | 3 ++- .../domain/model-interaction.domain.spec.ts | 5 ++-- .../domain/model-interaction.domain.ts | 4 ++-- .../domain/permission.domain.spec.ts | 3 ++- .../domain/permission.domain.ts | 4 ++-- .../dtos/permission.response.dto.ts | 7 +++--- .../model-permission/permission.schemas.ts | 5 ++-- .../dtos/model-version-tag.response.dto.ts | 5 ++-- .../model-version-tag.schemas.ts | 7 +++--- .../dtos/model-version.response.dto.ts | 3 ++- .../model-version/model-version.schemas.ts | 5 ++-- .../modules/model/domain/model.domain.spec.ts | 3 ++- .../src/modules/model/domain/model.domain.ts | 4 ++-- .../src/modules/model/dtos/model.dto.ts | 13 +++++----- .../model/dtos/model.family-card.dto.ts | 5 ++-- .../src/modules/tag/domain/tag.domain.spec.ts | 3 ++- .../src/modules/tag/domain/tag.domain.ts | 4 ++-- .../src/modules/tag/dtos/tag.response.dto.ts | 7 ++---- .../src/modules/user/user.schemas.ts | 3 ++- .../src/shared/storage/utils.spec.ts | 24 ++++++++++++------- .../src/shared/storage/utils.ts | 8 +++---- .../src/shared/utils/id.spec.ts | 6 ++++- .../src/shared/utils/id.ts | 2 +- .../src/shared/utils/validator.util.ts | 1 - 39 files changed, 122 insertions(+), 90 deletions(-) diff --git a/apps/modeling-commons-backend/src/lib/auth.ts b/apps/modeling-commons-backend/src/lib/auth.ts index 5599292b..122518bb 100644 --- a/apps/modeling-commons-backend/src/lib/auth.ts +++ b/apps/modeling-commons-backend/src/lib/auth.ts @@ -5,6 +5,7 @@ import { prismaAdapter } from 'better-auth/adapters/prisma'; import { admin, openAPI } from 'better-auth/plugins'; import rules from '#src/config/rules.ts'; +import { newId } from '#src/shared/utils/id.ts'; import transporter, { mailDomain } from './mail.ts'; import { prisma } from './prisma.ts'; @@ -117,7 +118,7 @@ export const auth = betterAuth({ ipAddressHeaders: env.server.ipAddressHeaders, }, database: { - generateId: 'uuid', + generateId: () => newId(), }, }, diff --git a/apps/modeling-commons-backend/src/modules/event/dtos/event.response.dto.ts b/apps/modeling-commons-backend/src/modules/event/dtos/event.response.dto.ts index 5de8c9b1..f9f36f68 100644 --- a/apps/modeling-commons-backend/src/modules/event/dtos/event.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/event/dtos/event.response.dto.ts @@ -1,7 +1,8 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const eventResponseDtoSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), type: Type.String(), actorId: Type.String(), resourceType: Type.String(), diff --git a/apps/modeling-commons-backend/src/modules/file/domain/file.types.ts b/apps/modeling-commons-backend/src/modules/file/domain/file.types.ts index 568063b9..b3cc6370 100644 --- a/apps/modeling-commons-backend/src/modules/file/domain/file.types.ts +++ b/apps/modeling-commons-backend/src/modules/file/domain/file.types.ts @@ -1,5 +1,6 @@ import Schema from 'typebox/schema'; import Type, { type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { FileNotFoundError } from './file.errors.ts'; export const PUBLIC_PREFIX = 'files/public'; @@ -8,7 +9,7 @@ export type FileAccess = 'public-read' | 'private'; export const fileMetadata = Type.Object({ filename: Type.String(), - userId: Type.Optional(Type.String({ format: 'uuid' })), + userId: Type.Optional(idSchema()), createdAt: Type.String({ format: 'date-time', description: 'ISO string of the file creation date', diff --git a/apps/modeling-commons-backend/src/modules/file/file.route.ts b/apps/modeling-commons-backend/src/modules/file/file.route.ts index d8d9e7bb..8a14e873 100644 --- a/apps/modeling-commons-backend/src/modules/file/file.route.ts +++ b/apps/modeling-commons-backend/src/modules/file/file.route.ts @@ -4,7 +4,7 @@ import { resolveFile } from '#src/shared/hooks/resolve-file.ts'; import type { FastifyInstance } from 'fastify'; import { Type } from 'typebox'; import { FileTooLargeError } from './domain/file.errors.ts'; -import { randomUUID } from 'node:crypto'; +import { nanoid } from 'nanoid'; const MAX_AVATAR_BYTES = rules.avatar.maxFileSize; @@ -47,7 +47,7 @@ export default async function fileRoutes(fastify: FastifyInstance) { throw new FileTooLargeError(buffer.length, MAX_AVATAR_BYTES); } - const filename = `${randomUUID()}-ua`; + const filename = `${nanoid(10)}-ua`; const key = await fileService.upload({ filename, diff --git a/apps/modeling-commons-backend/src/modules/model-additional-file/domain/model-additional-file.domain.ts b/apps/modeling-commons-backend/src/modules/model-additional-file/domain/model-additional-file.domain.ts index 60d4ab8c..1c2e385f 100644 --- a/apps/modeling-commons-backend/src/modules/model-additional-file/domain/model-additional-file.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model-additional-file/domain/model-additional-file.domain.ts @@ -1,14 +1,14 @@ -import { randomUUID } from 'node:crypto'; import type { AddAdditionalFileProps, ModelAdditionalFileEntity, } from '#src/modules/model-additional-file/domain/model-additional-file.types.ts'; +import { newId } from '#src/shared/utils/id.ts'; export default function modelAdditionalFileDomain() { return { createAdditionalFile(props: AddAdditionalFileProps): ModelAdditionalFileEntity { return { - id: randomUUID(), + id: newId(), modelId: props.modelId, taggedVersionNumber: props.taggedVersionNumber, fileKey: props.fileKey, diff --git a/apps/modeling-commons-backend/src/modules/model-additional-file/dtos/model-additional-file.response.dto.ts b/apps/modeling-commons-backend/src/modules/model-additional-file/dtos/model-additional-file.response.dto.ts index 44fe9f70..96a293fa 100644 --- a/apps/modeling-commons-backend/src/modules/model-additional-file/dtos/model-additional-file.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-additional-file/dtos/model-additional-file.response.dto.ts @@ -1,11 +1,12 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { idDtoSchema } from '#src/shared/api/id.response.dto.ts'; export const modelAdditionalFileResponseDtoSchema = Type.Intersect([ idDtoSchema, Type.Object({ - modelId: Type.String({ format: 'uuid' }), - userId: Type.Optional(Type.String({ format: 'uuid' })), + modelId: idSchema(), + userId: Type.Optional(idSchema()), taggedVersionNumber: Type.Integer(), fileKey: Type.String(), kind: Type.Union([Type.Literal('model'), Type.Literal('additional')]), diff --git a/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.schemas.ts b/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.schemas.ts index 7318a00f..1fe7abbc 100644 --- a/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/model-additional-file/model-additional-file.schemas.ts @@ -1,8 +1,9 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const additionalFileParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), - fileId: Type.String({ format: 'uuid' }), + id: idSchema(), + fileId: idSchema(), }); export type AdditionalFileParams = Static; diff --git a/apps/modeling-commons-backend/src/modules/model-author/dtos/model-author.response.dto.ts b/apps/modeling-commons-backend/src/modules/model-author/dtos/model-author.response.dto.ts index 651a4af3..761eb886 100644 --- a/apps/modeling-commons-backend/src/modules/model-author/dtos/model-author.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-author/dtos/model-author.response.dto.ts @@ -1,8 +1,9 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const modelAuthorResponseDtoSchema = Type.Object({ - modelId: Type.String({ format: 'uuid' }), - userId: Type.String({ format: 'uuid' }), + modelId: idSchema(), + userId: idSchema(), role: Type.Enum(['owner', 'contributor']), createdAt: Type.String({ example: '2020-11-24T17:43:15.970Z', diff --git a/apps/modeling-commons-backend/src/modules/model-author/model-author.schemas.ts b/apps/modeling-commons-backend/src/modules/model-author/model-author.schemas.ts index 106201f4..9095eadc 100644 --- a/apps/modeling-commons-backend/src/modules/model-author/model-author.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/model-author/model-author.schemas.ts @@ -1,29 +1,30 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { paginatedQueryRequestDtoSchema } from '#src/shared/api/paginated-query.request.dto.ts'; export const modelAuthorParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), }); export type ModelAuthorParams = Static; export const modelAuthorUserParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), - userId: Type.String({ format: 'uuid' }), + id: idSchema(), + userId: idSchema(), }); export type ModelAuthorUserParams = Static; export const addContributorRequestDtoSchema = Type.Object({ - userId: Type.String({ format: 'uuid', description: 'User to add as contributor' }), + userId: idSchema('User to add as contributor'), }); export type AddContributorRequestDto = Static; export const transferOwnershipRequestDtoSchema = Type.Object({ - newOwnerId: Type.String({ format: 'uuid', description: 'New owner user id' }), + newOwnerId: idSchema('New owner user id'), }); export type TransferOwnershipRequestDto = Static; export const userIdParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), }); export type UserIdParams = Static; diff --git a/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.spec.ts b/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.spec.ts index d47287a4..03e7e26c 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import modelDraftDomain from '#src/modules/model-draft/domain/model-draft.domain.ts'; import { ModelDraftAccessDeniedError } from '#src/modules/model-draft/domain/model-draft.errors.ts'; import type { ModelDraftEntity } from '#src/modules/model-draft/domain/model-draft.types.ts'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; const domain = modelDraftDomain(); @@ -27,8 +28,7 @@ describe('modelDraftDomain', () => { data: {}, }); - expect(draft.id).toBeTypeOf('string'); - expect(draft.id.length).toBeGreaterThan(0); + expect(draft.id).toMatch(new RegExp(ID_PATTERN)); expect(draft.userId).toBe('user-1'); expect(draft.modelId).toBeNull(); expect(draft.schemaVersion).toBe(1); diff --git a/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.ts b/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.ts index 8d4ed940..0cf757bc 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/domain/model-draft.domain.ts @@ -1,6 +1,6 @@ -import { randomUUID } from 'node:crypto'; import type { ModelDraftEntity } from '#src/modules/model-draft/domain/model-draft.types.ts'; import { ModelDraftAccessDeniedError } from '#src/modules/model-draft/domain/model-draft.errors.ts'; +import { newId } from '#src/shared/utils/id.ts'; export default function modelDraftDomain() { return { @@ -12,7 +12,7 @@ export default function modelDraftDomain() { }): ModelDraftEntity { const now = new Date(); return { - id: randomUUID(), + id: newId(), userId: props.userId, modelId: props.modelId ?? null, schemaVersion: props.schemaVersion, diff --git a/apps/modeling-commons-backend/src/modules/model-draft/dtos/model-draft.dto.ts b/apps/modeling-commons-backend/src/modules/model-draft/dtos/model-draft.dto.ts index eeeee43c..4a49a2d2 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/dtos/model-draft.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/dtos/model-draft.dto.ts @@ -1,10 +1,11 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { paginatedResponseBaseSchema } from '#src/shared/api/paginated.response.base.ts'; import { draftDataV1Schema } from '#src/modules/model-draft/schemas/v1.ts'; import { visibilitySchema } from '#src/modules/model/shared/enums.ts'; export const createDraftRequestDtoSchema = Type.Object({ - modelId: Type.Optional(Type.String({ format: 'uuid' })), + modelId: Type.Optional(idSchema()), }); export const patchDraftRequestDtoSchema = Type.Partial( @@ -17,7 +18,7 @@ export const patchDraftRequestDtoSchema = Type.Partial( ); export const draftIdParamsSchema = Type.Object({ - id: Type.String(), + id: idSchema(), }); export const draftFileParamsSchema = Type.Object({ @@ -33,7 +34,7 @@ export const draftFileRoleSchema = Type.Union([ ]); export const modelDraftResponseDtoSchema = Type.Object({ - id: Type.String(), + id: idSchema(), userId: Type.String(), modelId: Type.Union([Type.String(), Type.Null()]), schemaVersion: Type.Integer(), @@ -55,7 +56,7 @@ export const draftFileUploadFieldsSchema = Type.Object({ }); export const draftFileUploadResponseSchema = Type.Object({ - id: Type.Optional(Type.String({ format: 'uuid' })), + id: Type.Optional(idSchema()), role: draftFileRoleSchema, s3Key: Type.String(), filename: Type.String(), diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts index b6b1ae81..f8ac9c45 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts @@ -18,6 +18,7 @@ import { mockModelVersionTagRepository } from '#src/modules/model-version-tag/da import type { ModelDraftEntity } from '#src/modules/model-draft/domain/model-draft.types.ts'; import type { DraftDataV1 } from '#src/modules/model-draft/schemas/v1.ts'; import type { Model } from '#prisma/index'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; function makeDraft(data: DraftDataV1 = {}, overrides: Partial = {}): ModelDraftEntity { return { @@ -289,7 +290,7 @@ describe('modelDraftService', () => { }); expect(result.role).toBe('attachment'); - expect(result.id).toMatch(/^[0-9a-f-]{36}$/); + expect(result.id).toMatch(new RegExp(ID_PATTERN)); const next = modelDraftRepository.updateDataTx.mock.calls[0]![3] as DraftDataV1; expect(next.attachments).toHaveLength(1); expect(next.attachments![0]!.s3Key).toBe('staging/user-1/draft-1/att.csv'); diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts index dfe97023..d121ce22 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts @@ -33,7 +33,8 @@ import { canWrite } from '#src/shared/permissions/model-access.policy.ts'; import { loadModelAccessContext } from '#src/shared/permissions/model-access.viewer.ts'; import { CopyObjectCommand, HeadObjectCommand } from '#src/shared/storage/index.ts'; import { sanitizeFilename } from '#src/shared/storage/utils.ts'; -import { randomUUID } from 'node:crypto'; +import { newId } from '#src/shared/utils/id.ts'; +import { nanoid } from 'nanoid'; import { ModelNotFoundError } from '../model/domain/model.errors.ts'; import { UserNotFoundError } from '../user/domain/user.errors.ts'; import { isValidNetlogoFilename } from '#src/shared/utils/netlogo.ts'; @@ -88,7 +89,7 @@ export default function makeModelDraftService({ const prefix = isPublic ? `${PUBLIC_PREFIX}/staging/${userId}/${draftId}/` : `staging/${userId}/${draftId}/`; - return `${prefix}${randomUUID()}-${sanitizeFilename(filename)}`; + return `${prefix}${nanoid(10)}-${sanitizeFilename(filename)}`; } function filenameFromKey(key: string): string { @@ -238,7 +239,7 @@ export default function makeModelDraftService({ filename, }); return { - id: randomUUID(), + id: newId(), s3Key: copy.s3Key, filename, sizeBytes: copy.sizeBytes, @@ -368,7 +369,7 @@ export default function makeModelDraftService({ } const kind: ModelFileKind = role === 'model-file' ? 'model' : 'additional'; - const attachment: DraftFileV1 = { id: randomUUID(), ...meta, kind }; + const attachment: DraftFileV1 = { id: newId(), ...meta, kind }; next.attachments = [...(data.attachments ?? []), attachment]; await persistData(draft, next); return { id: attachment.id, role, ...meta }; diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts index d24480c0..b6d416d3 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts @@ -7,7 +7,7 @@ import { PutObjectCommand, } from '#src/shared/storage/index.ts'; import { sanitizeFilename } from '#src/shared/storage/utils.ts'; -import { randomUUID } from 'node:crypto'; +import { nanoid } from 'nanoid'; export default function makeModelDraftStorage({ storage, bucket, fileDomain }: Dependencies) { function stagingPrefix(userId: string, draftId: string): string { @@ -27,7 +27,7 @@ export default function makeModelDraftStorage({ storage, bucket, fileDomain }: D const prefix = isPublic ? publicStagingPrefix(userId, draftId) : stagingPrefix(userId, draftId); - return `${prefix}${randomUUID()}-${sanitizeFilename(filename)}`; + return `${prefix}${nanoid(10)}-${sanitizeFilename(filename)}`; } async function deletePrefix(prefix: string): Promise { diff --git a/apps/modeling-commons-backend/src/modules/model-draft/schemas/v1.ts b/apps/modeling-commons-backend/src/modules/model-draft/schemas/v1.ts index da232e68..6d219862 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/schemas/v1.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/schemas/v1.ts @@ -1,4 +1,5 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { visibilitySchema } from '#src/modules/model/shared/enums.ts'; export const DRAFT_SCHEMA_VERSION_V1 = 1 as const; @@ -9,7 +10,7 @@ export const modelFileKindSchema = Type.Union([ ]); export const draftFileV1Schema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), s3Key: Type.String(), filename: Type.String(), sizeBytes: Type.Integer({ minimum: 0 }), diff --git a/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.spec.ts b/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.spec.ts index cef6936a..97f643b6 100644 --- a/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import modelInteractionDomain from '#src/modules/model-interaction/domain/model-interaction.domain.ts'; import { ModelInteractionKind } from '#src/modules/model-interaction/domain/model-interaction.types.ts'; import type { ClientContext } from '#src/shared/http/client-context.ts'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; const domain = modelInteractionDomain(); @@ -19,7 +20,7 @@ function ctx(overrides: Partial = {}): ClientContext { describe('modelInteractionDomain', () => { describe('create', () => { - it('builds an entity with a uuid and copies the client context', () => { + it('builds an entity with an id and copies the client context', () => { const entity = domain.create( 'model-1', ModelInteractionKind.view, @@ -34,7 +35,7 @@ describe('modelInteractionDomain', () => { 5, ); - expect(entity.id).toMatch(/^[0-9a-f-]{36}$/); + expect(entity.id).toMatch(new RegExp(ID_PATTERN)); expect(entity.modelId).toBe('model-1'); expect(entity.kind).toBe(ModelInteractionKind.view); expect(entity.versionNumber).toBe(5); diff --git a/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.ts b/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.ts index 19c79339..98ec187e 100644 --- a/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model-interaction/domain/model-interaction.domain.ts @@ -1,9 +1,9 @@ -import crypto from 'node:crypto'; import type { ModelInteractionEntity, ModelInteractionKind, } from '#src/modules/model-interaction/domain/model-interaction.types.ts'; import type { ClientContext } from '#src/shared/http/client-context.ts'; +import { newId } from '#src/shared/utils/id.ts'; export default function modelInteractionDomain() { return { @@ -14,7 +14,7 @@ export default function modelInteractionDomain() { versionNumber: number | null, ): ModelInteractionEntity { return { - id: crypto.randomUUID(), + id: newId(), modelId, versionNumber, kind, diff --git a/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.spec.ts b/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.spec.ts index 350c23dc..5631f408 100644 --- a/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import permissionDomain from '#src/modules/model-permission/domain/permission.domain.ts'; import { meetsLevel } from '#src/modules/model-permission/domain/permission.types.ts'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; const domain = permissionDomain(); @@ -9,7 +10,7 @@ describe('permissionDomain', () => { it('creates permission entity', () => { const perm = domain.createPermission('model-1', 'user-2', 'write'); - expect(perm.id).toBeTypeOf('string'); + expect(perm.id).toMatch(new RegExp(ID_PATTERN)); expect(perm.modelId).toBe('model-1'); expect(perm.granteeUserId).toBe('user-2'); expect(perm.permissionLevel).toBe('write'); diff --git a/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.ts b/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.ts index 806866ca..5945f0c5 100644 --- a/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model-permission/domain/permission.domain.ts @@ -1,8 +1,8 @@ -import { randomUUID } from 'node:crypto'; import type { ModelPermissionEntity, PermissionLevel, } from '#src/modules/model-permission/domain/permission.types.ts'; +import { newId } from '#src/shared/utils/id.ts'; export default function permissionDomain() { return { @@ -12,7 +12,7 @@ export default function permissionDomain() { permissionLevel: PermissionLevel, ): ModelPermissionEntity { return { - id: randomUUID(), + id: newId(), modelId, granteeUserId, permissionLevel, diff --git a/apps/modeling-commons-backend/src/modules/model-permission/dtos/permission.response.dto.ts b/apps/modeling-commons-backend/src/modules/model-permission/dtos/permission.response.dto.ts index f377e68d..fa66394f 100644 --- a/apps/modeling-commons-backend/src/modules/model-permission/dtos/permission.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-permission/dtos/permission.response.dto.ts @@ -1,9 +1,10 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const permissionResponseDtoSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), - modelId: Type.String({ format: 'uuid' }), - granteeUserId: Type.String({ format: 'uuid' }), + id: idSchema(), + modelId: idSchema(), + granteeUserId: idSchema(), permissionLevel: Type.String({ description: 'read | write | admin' }), createdAt: Type.String({ example: '2020-11-24T17:43:15.970Z', diff --git a/apps/modeling-commons-backend/src/modules/model-permission/permission.schemas.ts b/apps/modeling-commons-backend/src/modules/model-permission/permission.schemas.ts index be356e74..53e96fde 100644 --- a/apps/modeling-commons-backend/src/modules/model-permission/permission.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/model-permission/permission.schemas.ts @@ -1,5 +1,6 @@ import { idDtoSchema } from '#src/shared/api/id.response.dto.ts'; import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const permissionParamsSchema = idDtoSchema; export type PermissionParams = Static; @@ -7,13 +8,13 @@ export type PermissionParams = Static; export const permissionGranteeParamsSchema = Type.Intersect([ permissionParamsSchema, Type.Object({ - granteeUserId: Type.String({ format: 'uuid' }), + granteeUserId: idSchema(), }), ]); export type PermissionGranteeParams = Static; export const grantPermissionRequestDtoSchema = Type.Object({ - granteeUserId: Type.String({ format: 'uuid', description: 'User to grant permission to' }), + granteeUserId: idSchema('User to grant permission to'), permissionLevel: Type.Union( [Type.Literal('read'), Type.Literal('write'), Type.Literal('admin')], { description: 'Permission level to grant' }, diff --git a/apps/modeling-commons-backend/src/modules/model-version-tag/dtos/model-version-tag.response.dto.ts b/apps/modeling-commons-backend/src/modules/model-version-tag/dtos/model-version-tag.response.dto.ts index 48d139bb..d65e35d7 100644 --- a/apps/modeling-commons-backend/src/modules/model-version-tag/dtos/model-version-tag.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-version-tag/dtos/model-version-tag.response.dto.ts @@ -1,9 +1,10 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const modelVersionTagResponseDtoSchema = Type.Object({ - modelId: Type.String({ format: 'uuid' }), + modelId: idSchema(), versionNumber: Type.Integer(), - tagId: Type.String({ format: 'uuid' }), + tagId: idSchema(), tagName: Type.String(), createdAt: Type.String({ format: 'date-time' }), }); diff --git a/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.schemas.ts b/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.schemas.ts index e99b7990..223c37ff 100644 --- a/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/model-version-tag/model-version-tag.schemas.ts @@ -1,4 +1,5 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const addTagRequestDtoSchema = Type.Object({ name: Type.String({ @@ -10,13 +11,13 @@ export const addTagRequestDtoSchema = Type.Object({ export type AddTagRequestDto = Static; export const removeTagParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), - tagId: Type.String({ format: 'uuid' }), + id: idSchema(), + tagId: idSchema(), }); export type RemoveTagParams = Static; export const versionTagsParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), version: Type.Integer({ minimum: 1 }), }); export type VersionTagsParams = Static; diff --git a/apps/modeling-commons-backend/src/modules/model-version/dtos/model-version.response.dto.ts b/apps/modeling-commons-backend/src/modules/model-version/dtos/model-version.response.dto.ts index b28472bc..ac7e2d0a 100644 --- a/apps/modeling-commons-backend/src/modules/model-version/dtos/model-version.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model-version/dtos/model-version.response.dto.ts @@ -1,7 +1,8 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const modelVersionResponseDtoSchema = Type.Object({ - modelId: Type.String({ format: 'uuid' }), + modelId: idSchema(), versionNumber: Type.Integer({ minimum: 1, description: diff --git a/apps/modeling-commons-backend/src/modules/model-version/model-version.schemas.ts b/apps/modeling-commons-backend/src/modules/model-version/model-version.schemas.ts index 93c120ae..e095196b 100644 --- a/apps/modeling-commons-backend/src/modules/model-version/model-version.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/model-version/model-version.schemas.ts @@ -1,4 +1,5 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { paginatedQueryRequestDtoSchema } from '#src/shared/api/paginated-query.request.dto.ts'; export const createVersionRequestDtoSchema = Type.Object({ @@ -14,7 +15,7 @@ export const updateCurrentVersionRequestDtoSchema = Type.Object({ export type UpdateCurrentVersionRequestDto = Static; export const versionParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), version: Type.Integer({ minimum: 1 }), }); export type VersionParams = Static; @@ -22,6 +23,6 @@ export type VersionParams = Static; export const versionListQuerySchema = Type.Intersect([ paginatedQueryRequestDtoSchema, Type.Object({ - id: Type.Optional(Type.String({ format: 'uuid' })), + id: Type.Optional(idSchema()), }), ]); diff --git a/apps/modeling-commons-backend/src/modules/model/domain/model.domain.spec.ts b/apps/modeling-commons-backend/src/modules/model/domain/model.domain.spec.ts index b129266c..ff8b5642 100644 --- a/apps/modeling-commons-backend/src/modules/model/domain/model.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model/domain/model.domain.spec.ts @@ -1,6 +1,7 @@ import type { Model } from '#prisma/index'; import modelDomain from '#src/modules/model/domain/model.domain.ts'; import { ModelAlreadyDeletedError } from '#src/modules/model/domain/model.errors.ts'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; import { describe, expect, it } from 'vitest'; const domain = modelDomain(); @@ -31,7 +32,7 @@ describe('modelDomain', () => { it('creates model with defaults', () => { const model = domain.createModel({ title: 'Test' }); - expect(model.id).toBeTypeOf('string'); + expect(model.id).toMatch(new RegExp(ID_PATTERN)); expect(model.visibility).toBe('public'); expect(model.isEndorsed).toBe(false); expect(model.deletedAt).toBeNull(); diff --git a/apps/modeling-commons-backend/src/modules/model/domain/model.domain.ts b/apps/modeling-commons-backend/src/modules/model/domain/model.domain.ts index 6a0fc7d2..7929c22b 100644 --- a/apps/modeling-commons-backend/src/modules/model/domain/model.domain.ts +++ b/apps/modeling-commons-backend/src/modules/model/domain/model.domain.ts @@ -1,14 +1,14 @@ -import { randomUUID } from 'node:crypto'; import type { Model } from '#prisma/index'; import type { CreateModelProps } from '#src/modules/model/dtos/model.dto.ts'; import { ModelAlreadyDeletedError } from '#src/modules/model/domain/model.errors.ts'; +import { newId } from '#src/shared/utils/id.ts'; export default function modelDomain() { return { createModel(props: CreateModelProps): Model { const now = new Date(); return { - id: randomUUID(), + id: newId(), latestVersionNumber: null, parentModelId: props.parentModelId ?? null, parentVersionNumber: props.parentVersionNumber ?? null, diff --git a/apps/modeling-commons-backend/src/modules/model/dtos/model.dto.ts b/apps/modeling-commons-backend/src/modules/model/dtos/model.dto.ts index 66527b51..7f27f8a3 100644 --- a/apps/modeling-commons-backend/src/modules/model/dtos/model.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model/dtos/model.dto.ts @@ -1,4 +1,5 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { baseResponseDtoSchema } from '#src/shared/api/response.base.ts'; import { paginatedResponseBaseSchema } from '#src/shared/api/paginated.response.base.ts'; import { paginatedQueryRequestDtoSchema } from '#src/shared/api/paginated-query.request.dto.ts'; @@ -15,7 +16,7 @@ export const createModelRequestDtoSchema = Type.Object({ }), description: Type.Optional(Type.String({ description: 'Model description', maxLength: 10000 })), visibility: Type.Optional(visibilitySchema), - parentModelId: Type.Optional(Type.String({ format: 'uuid' })), + parentModelId: Type.Optional(idSchema()), parentVersionNumber: Type.Optional(Type.Integer({ minimum: 1 })), }); @@ -24,7 +25,7 @@ export const updateModelRequestDtoSchema = Type.Object({ }); export const modelIdParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), }); export const modelLegacyIdParamsSchema = Type.Object({ @@ -32,7 +33,7 @@ export const modelLegacyIdParamsSchema = Type.Object({ }); export const modelVersionParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), version: Type.Integer({ minimum: 1 }), }); @@ -65,9 +66,9 @@ export const modelSearchQuerySchema = Type.Intersect([ tags: Type.Optional( Type.Array(Type.String(), { description: 'Filter models by tag', default: [] }), ), - authorId: Type.Optional(Type.String({ format: 'uuid' })), + authorId: Type.Optional(idSchema()), authorRoles: Type.Optional(Type.Array(Type.Enum(['owner', 'contributor']))), - parentModelId: Type.Optional(Type.String({ format: 'uuid' })), + parentModelId: Type.Optional(idSchema()), isEndorsed: Type.Optional(Type.Boolean()), isLibraryModel: Type.Optional(Type.Boolean()), keyword: Type.Optional(Type.String()), @@ -82,7 +83,7 @@ export const modelResponseDtoSchema = Type.Intersect([ baseResponseDtoSchema, Type.Object({ latestVersionNumber: Type.Union([Type.Integer(), Type.Null()]), - parentModelId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), + parentModelId: Type.Union([idSchema(), Type.Null()]), parentVersionNumber: Type.Union([Type.Integer(), Type.Null()]), visibility: visibilitySchema, isEndorsed: Type.Boolean(), diff --git a/apps/modeling-commons-backend/src/modules/model/dtos/model.family-card.dto.ts b/apps/modeling-commons-backend/src/modules/model/dtos/model.family-card.dto.ts index dab2d4b9..31ef5980 100644 --- a/apps/modeling-commons-backend/src/modules/model/dtos/model.family-card.dto.ts +++ b/apps/modeling-commons-backend/src/modules/model/dtos/model.family-card.dto.ts @@ -1,14 +1,15 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const modelFamilySummarySchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), title: Type.String(), description: Type.Union([Type.String(), Type.Null()]), visibility: Type.String(), isEndorsed: Type.Boolean(), createdAt: Type.String({ format: 'date-time' }), latestVersionNumber: Type.Union([Type.Integer(), Type.Null()]), - parentModelId: Type.Union([Type.String({ format: 'uuid' }), Type.Null()]), + parentModelId: Type.Union([idSchema(), Type.Null()]), parentVersionNumber: Type.Union([Type.Integer(), Type.Null()]), authorName: Type.Union([Type.String(), Type.Null()]), versionCount: Type.Integer(), diff --git a/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.spec.ts b/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.spec.ts index d72fc30d..546bd1af 100644 --- a/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.spec.ts +++ b/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import tagDomain from '#src/modules/tag/domain/tag.domain.ts'; import { InvalidTagNameError } from '#src/modules/tag/domain/tag.errors.ts'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; const domain = tagDomain(); @@ -27,7 +28,7 @@ describe('tagDomain', () => { it('creates tag entity', () => { const tag = domain.createTag({ name: 'ecology' }); - expect(tag.id).toBeTypeOf('string'); + expect(tag.id).toMatch(new RegExp(ID_PATTERN)); expect(tag.name).toBe('ecology'); }); }); diff --git a/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.ts b/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.ts index 720b8cf3..082f5a43 100644 --- a/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.ts +++ b/apps/modeling-commons-backend/src/modules/tag/domain/tag.domain.ts @@ -1,6 +1,6 @@ import { InvalidTagNameError } from '#src/modules/tag/domain/tag.errors.ts'; import type { CreateTagProps, TagEntity } from '#src/modules/tag/domain/tag.types.ts'; -import { randomUUID } from 'node:crypto'; +import { newId } from '#src/shared/utils/id.ts'; const TAG_NAME_PATTERN = /^([\w\-]+:)?[\w\- ]+$/; const TAG_NAME_MAX_LENGTH = 100; @@ -40,7 +40,7 @@ export default function tagDomain() { createTag(props: CreateTagProps): TagEntity { const name = this.validateName(props.name); return { - id: randomUUID(), + id: newId(), name: name.toLowerCase(), createdAt: new Date(), displayName: this.getDisplayName(name, props.displayName), diff --git a/apps/modeling-commons-backend/src/modules/tag/dtos/tag.response.dto.ts b/apps/modeling-commons-backend/src/modules/tag/dtos/tag.response.dto.ts index 88c40ff7..8c2cdf26 100644 --- a/apps/modeling-commons-backend/src/modules/tag/dtos/tag.response.dto.ts +++ b/apps/modeling-commons-backend/src/modules/tag/dtos/tag.response.dto.ts @@ -1,11 +1,8 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; export const tagResponseDtoSchema = Type.Object({ - id: Type.String({ - format: 'uuid', - example: '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231', - description: 'Tag id', - }), + id: idSchema('Tag id'), name: Type.String({ example: 'climate', description: 'Tag name' }), displayName: Type.String({ example: 'Climate', diff --git a/apps/modeling-commons-backend/src/modules/user/user.schemas.ts b/apps/modeling-commons-backend/src/modules/user/user.schemas.ts index e08dafc9..fa29d73c 100644 --- a/apps/modeling-commons-backend/src/modules/user/user.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/user/user.schemas.ts @@ -1,8 +1,9 @@ import { Type, type Static } from 'typebox'; +import { idSchema } from '#src/shared/utils/id.ts'; import { paginatedQueryRequestDtoSchema } from '#src/shared/api/paginated-query.request.dto.ts'; export const userIdParamsSchema = Type.Object({ - id: Type.String({ format: 'uuid' }), + id: idSchema(), }); export type UserIdParams = Static; diff --git a/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts b/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts index f4e0335f..389da562 100644 --- a/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts +++ b/apps/modeling-commons-backend/src/shared/storage/utils.spec.ts @@ -89,15 +89,15 @@ describe('createStorageKey', () => { test('includes normalized path, UTC date segments, id prefix, and sanitized filename', () => { const key = createStorageKey('my model.png', 'uploads/models'); - expect(key).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/my_model\.png$/); + expect(key).toMatch(/^uploads\/models\/2026\/04\/17\/[A-Za-z0-9_-]{10}\/my_model\.png$/); }); test('normalizes leading and trailing slashes in path', () => { const key1 = createStorageKey('file.txt', '/uploads/models/'); const key2 = createStorageKey('file.txt', '///uploads/models///'); - expect(key1).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/); - expect(key2).toMatch(/^uploads\/models\/2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/); + expect(key1).toMatch(/^uploads\/models\/2026\/04\/17\/[A-Za-z0-9_-]{10}\/file\.txt$/); + expect(key2).toMatch(/^uploads\/models\/2026\/04\/17\/[A-Za-z0-9_-]{10}\/file\.txt$/); expect(key1).not.toContain('//'); expect(key2).not.toContain('//'); }); @@ -105,15 +105,15 @@ describe('createStorageKey', () => { test('omits the leading path segment when path is empty', () => { const key = createStorageKey('file.txt', ''); - expect(key).toMatch(/^2026\/04\/17\/[a-f0-9]{8}\/file\.txt$/); + expect(key).toMatch(/^2026\/04\/17\/[A-Za-z0-9_-]{10}\/file\.txt$/); }); test('sanitizes the filename portion', () => { const key = createStorageKey('../../etc/passwd', 'uploads'); - expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//); + expect(key).toMatch(/^uploads\/2026\/04\/17\/[A-Za-z0-9_-]{10}\//); - const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//, ''); + const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[A-Za-z0-9_-]{10}\//, ''); expect(filenamePart).toBe('__.._etc_passwd'); expect(filenamePart).toMatch(/^[A-Za-z0-9._-]+$/); }); @@ -125,14 +125,22 @@ describe('createStorageKey', () => { expect(first).not.toBe(second); }); + test('produces 10_000 unique keys within the same day-prefix', () => { + const keys = new Set(); + for (let i = 0; i < 10_000; i++) { + keys.add(createStorageKey('file.txt', 'uploads')); + } + expect(keys.size).toBe(10_000); + }); + test('produces a valid key shape even with malformed inputs', () => { const key = createStorageKey('..\n..\npasswd', '///uploads//'); - expect(key).toMatch(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//); + expect(key).toMatch(/^uploads\/2026\/04\/17\/[A-Za-z0-9_-]{10}\//); expect(key).not.toContain('//'); expect(key).not.toContain('\\'); - const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[a-f0-9]{8}\//, ''); + const filenamePart = key.replace(/^uploads\/2026\/04\/17\/[A-Za-z0-9_-]{10}\//, ''); expect(filenamePart).toMatch(/^[A-Za-z0-9._-]+$/); }); }); diff --git a/apps/modeling-commons-backend/src/shared/storage/utils.ts b/apps/modeling-commons-backend/src/shared/storage/utils.ts index 6cafde40..d07a8934 100644 --- a/apps/modeling-commons-backend/src/shared/storage/utils.ts +++ b/apps/modeling-commons-backend/src/shared/storage/utils.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'crypto'; +import { nanoid } from 'nanoid'; /** * @param filename The original filename to be sanitized. @@ -23,11 +23,11 @@ export function sanitizeFilename(filename: string): string { /** * @param filename The original filename to be sanitized and included in the storage key. * @param path The path prefix for organizing files (e.g. `uploads/models`) - * @return A storage key in the format: `{path}/{YYYY}/{MM}/{DD}/{randomId}-{sanitizedFilename}` + * @return A storage key in the format: `{path}/{YYYY}/{MM}/{DD}/{randomId}/{sanitizedFilename}` * * @example * createStorageKey('my model.png', 'uploads/models') - * // returns: 'uploads/models/2024/06/25/abc12345-my_model.png' + * // returns: 'uploads/models/2024/06/25/AbCdEfGhIj/my_model.png' */ export function createStorageKey(filename: string, path: string): string { const sanitizedFilename = sanitizeFilename(filename); @@ -43,7 +43,7 @@ export function createStorageKey(filename: string, path: string): string { const normalizedPath = path.replace(/^\/+|\/+$/g, ''); const timepath = `${year}/${month}/${day}`; - const id = randomUUID().substring(0, 8); + const id = nanoid(10); if (normalizedPath.length === 0) { return `${timepath}/${id}/${sanitizedFilename}`; diff --git a/apps/modeling-commons-backend/src/shared/utils/id.spec.ts b/apps/modeling-commons-backend/src/shared/utils/id.spec.ts index 6fe3967d..1d26b5e7 100644 --- a/apps/modeling-commons-backend/src/shared/utils/id.spec.ts +++ b/apps/modeling-commons-backend/src/shared/utils/id.spec.ts @@ -3,9 +3,13 @@ import { describe, it, expect } from 'vitest'; import { ajv } from '#src/shared/utils/validator.util.ts'; -import { ID_LENGTH, ID_PATTERN, newId, idSchema } from '#src/shared/utils/id.ts'; +import { ID_LENGTH, ID_PATTERN, ID_EXAMPLE, newId, idSchema } from '#src/shared/utils/id.ts'; describe('id format and schema', () => { + it('ID_EXAMPLE matches ID_PATTERN', () => { + expect(ID_EXAMPLE).toMatch(new RegExp(ID_PATTERN)); + }); + it('ID_PATTERN generates a valid regex pattern', () => { const regex = new RegExp(ID_PATTERN); expect(regex.test('A1b2C3d4E5f6G7h8I9j0K')).toBe(true); // valid ID diff --git a/apps/modeling-commons-backend/src/shared/utils/id.ts b/apps/modeling-commons-backend/src/shared/utils/id.ts index 2003a4a2..60fcd5e4 100644 --- a/apps/modeling-commons-backend/src/shared/utils/id.ts +++ b/apps/modeling-commons-backend/src/shared/utils/id.ts @@ -5,7 +5,7 @@ export const ID_LENGTH = 21; // drives ajv.addFormat('nanoid', ...); // --Omar Ibrahim, Aug 12 26 export const ID_PATTERN = `^[A-Za-z0-9_-]{${ID_LENGTH}}$`; -export const ID_EXAMPLE = '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231'; +export const ID_EXAMPLE = 'uAce-eANwFXi-tACAe9w1'; export const newId = (): string => nanoid(ID_LENGTH); export const idSchema: (description?: string) => TString = (description?: string) => { return Type.String({ diff --git a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts index 8b4977bc..0c3d78d9 100644 --- a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts +++ b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts @@ -16,7 +16,6 @@ export const ajv = addFormats.default(new Ajv.default({}), [ 'ipv6', 'uri', 'uri-reference', - 'uuid', 'uri-template', 'json-pointer', 'relative-json-pointer', From 017ccf7838067bb1651a11f6947cca31c2229d15 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:39:32 -0500 Subject: [PATCH 08/18] fix(commons): stop filenameFromKey mangling nanoid-shaped keys filenameFromKey still sliced a fixed 37 chars off the last path segment, a leftover assumption from the 36-char UUID prefix the writer no longer emits. Against the new nanoid(10) staging-key prefix this either leaked the prefix into the filename (short keys) or truncated a genuine long filename (keys over 37 chars). Three shapes actually reach this reader: createStorageKey keys put the random segment in its own path directory, so the last segment is already the bare filename; legacy pre-migration keys and the current stagingKey both join the random segment and filename with a dash in one segment. Strip a canonical-UUID-shaped prefix for the legacy case, and a fixed STAGING_KEY_RANDOM_SEGMENT_LENGTH-char prefix only when the key path is staging-shaped, since the nanoid alphabet includes '-' and an ordinary filename could otherwise be mistaken for a random segment. The length is now a named constant shared with stagingKey instead of a magic number. Also drop the stale 'Tag UUID or case-insensitive name' description in tag.schemas.ts; the param is a NanoID now. --- .../model-draft/model-draft.service.spec.ts | 53 ++++++++++++++++++- .../model-draft/model-draft.service.ts | 31 +++++++++-- .../src/modules/tag/tag.schemas.ts | 2 +- 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts index f8ac9c45..dc2682f5 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.spec.ts @@ -614,7 +614,10 @@ describe('modelDraftService', () => { }); describe('create (seedDraftDataFromModel)', () => { - function buildSeedService(additionalFiles: Array<{ fileKey: string; kind: 'model' | 'additional' }>) { + function buildSeedService( + additionalFiles: Array<{ fileKey: string; kind: 'model' | 'additional' }>, + versionOverrides: Record = {}, + ) { const modelRepository = mockModelRepository(); modelRepository.findOneById.mockResolvedValue( makeModel({ id: 'model-1', visibility: 'public', latestVersionNumber: 1, deletedAt: null }), @@ -629,6 +632,7 @@ describe('modelDraftService', () => { netlogoFileKey: 'uploads/models/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-primary.nlogo', previewImageFileKey: null, finalizedAt: null, + ...versionOverrides, } as never); const modelVersionTagRepository = mockModelVersionTagRepository(); @@ -716,6 +720,53 @@ describe('modelDraftService', () => { expect(data.seededFrom!.additionalFileS3Keys).toEqual([]); expect(data.seededFrom!.modelFileS3Keys).toEqual([]); }); + + describe('filenameFromKey shapes', () => { + it('strips a legacy UUID-dash prefix from a single-segment key', async () => { + const { service, modelDraftRepository } = buildSeedService([], { + netlogoFileKey: 'uploads/models/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-primary.nlogo', + }); + + await service.create('user-1', { modelId: 'model-1' }); + + const entity = modelDraftRepository.insertTx.mock.calls[0]![1] as { data: DraftDataV1 }; + expect(entity.data.primaryFile!.filename).toBe('primary.nlogo'); + }); + + it('strips a nanoid-dash prefix from a staging-shaped key', async () => { + const { service, modelDraftRepository } = buildSeedService([], { + netlogoFileKey: 'staging/user-1/draft-0/AbCdEfGhIj-wolf-sheep.nlogox', + }); + + await service.create('user-1', { modelId: 'model-1' }); + + const entity = modelDraftRepository.insertTx.mock.calls[0]![1] as { data: DraftDataV1 }; + expect(entity.data.primaryFile!.filename).toBe('wolf-sheep.nlogox'); + }); + + it('leaves a createStorageKey-style key untouched since the filename is its own segment', async () => { + const { service, modelDraftRepository } = buildSeedService([], { + netlogoFileKey: 'uploads/models/2026/04/17/AbCdEfGhIj/my_model.png', + }); + + await service.create('user-1', { modelId: 'model-1' }); + + const entity = modelDraftRepository.insertTx.mock.calls[0]![1] as { data: DraftDataV1 }; + expect(entity.data.primaryFile!.filename).toBe('my_model.png'); + }); + + it('does not truncate a long filename that has no dash-joined id prefix', async () => { + const longFilename = 'a-very-long-model-filename-that-exceeds-thirty-seven-characters.nlogox'; + const { service, modelDraftRepository } = buildSeedService([], { + netlogoFileKey: `uploads/models/2026/04/17/AbCdEfGhIj/${longFilename}`, + }); + + await service.create('user-1', { modelId: 'model-1' }); + + const entity = modelDraftRepository.insertTx.mock.calls[0]![1] as { data: DraftDataV1 }; + expect(entity.data.primaryFile!.filename).toBe(longFilename); + }); + }); }); describe('purgeStale', () => { diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts index d121ce22..c07b5b61 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts @@ -39,6 +39,10 @@ import { ModelNotFoundError } from '../model/domain/model.errors.ts'; import { UserNotFoundError } from '../user/domain/user.errors.ts'; import { isValidNetlogoFilename } from '#src/shared/utils/netlogo.ts'; +const STAGING_KEY_RANDOM_SEGMENT_LENGTH = 10; +const LEGACY_UUID_PREFIX_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/i; + export default function makeModelDraftService({ transactionManager, modelDraftRepository, @@ -89,13 +93,32 @@ export default function makeModelDraftService({ const prefix = isPublic ? `${PUBLIC_PREFIX}/staging/${userId}/${draftId}/` : `staging/${userId}/${draftId}/`; - return `${prefix}${nanoid(10)}-${sanitizeFilename(filename)}`; + return `${prefix}${nanoid(STAGING_KEY_RANDOM_SEGMENT_LENGTH)}-${sanitizeFilename(filename)}`; } + // Three key shapes can reach here. createStorageKey puts the random segment + // in its own path directory, so the last segment is already the bare + // filename - no prefix to strip. Legacy keys (pre-nanoid-migration) and + // stagingKey both join the random segment and the filename with a dash in + // one segment, so we strip a matching fixed-length prefix: the canonical + // UUID shape for legacy keys, or STAGING_KEY_RANDOM_SEGMENT_LENGTH chars for + // staging keys. The nanoid alphabet includes '-', so we only attempt the + // staging-key strip when the path is actually staging-shaped; otherwise a + // dash inside an ordinary filename could be mistaken for the boundary. function filenameFromKey(key: string): string { - const last = key.split('/').pop() ?? key; - // S3 keys we emit are `${uuid}-${sanitizedFilename}`. Slice past the 36-char uuid + '-'. - return last.length > 37 ? last.slice(37) : last; + const segments = key.split('/'); + const last = segments.pop() ?? key; + + const uuidMatch = last.match(LEGACY_UUID_PREFIX_PATTERN); + if (uuidMatch) return last.slice(uuidMatch[0].length); + + const isStagingKey = segments.includes('staging'); + const stagingPrefixLength = STAGING_KEY_RANDOM_SEGMENT_LENGTH + 1; + if (isStagingKey && last[STAGING_KEY_RANDOM_SEGMENT_LENGTH] === '-') { + return last.slice(stagingPrefixLength); + } + + return last; } function setEqual(a: Array, b: Array): boolean { diff --git a/apps/modeling-commons-backend/src/modules/tag/tag.schemas.ts b/apps/modeling-commons-backend/src/modules/tag/tag.schemas.ts index f629fd33..0be97616 100644 --- a/apps/modeling-commons-backend/src/modules/tag/tag.schemas.ts +++ b/apps/modeling-commons-backend/src/modules/tag/tag.schemas.ts @@ -24,7 +24,7 @@ export type PopularTagsQuery = Static; export const tagIdOrNameParamsSchema = Type.Object({ idOrName: Type.String({ - description: 'Tag UUID or case-insensitive name', + description: 'Tag id or case-insensitive name', minLength: 1, }), }); From 7bd8634a6e6795d04c99455c1ec6f01ee85d5886 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:44:34 -0500 Subject: [PATCH 09/18] test(commons): match nanoid segments in perf timing collector Replace the UUID/hex id regexes in the e2e timing collector with a matcher anchored to the 21-char nanoid alphabet, plus the legacy dashed 36-char format for older captured reports, so requests to routes like /v1/models/:id collapse correctly again instead of fragmenting the perf report. Also swap the nil UUID literal in the user profile auth feature for a nanoid-shaped placeholder so that scenario still exercises 401 instead of tripping the id-format 400. --- .../tests/api/user.feature | 2 +- .../tests/support/timing-collector.spec.ts | 28 +++++++++++++++++++ .../tests/support/timing-collector.ts | 11 ++++---- 3 files changed, 35 insertions(+), 6 deletions(-) create mode 100644 apps/modeling-commons-backend/tests/support/timing-collector.spec.ts diff --git a/apps/modeling-commons-backend/tests/api/user.feature b/apps/modeling-commons-backend/tests/api/user.feature index 3d645593..e2239a30 100644 --- a/apps/modeling-commons-backend/tests/api/user.feature +++ b/apps/modeling-commons-backend/tests/api/user.feature @@ -23,7 +23,7 @@ Feature: User Management Then the response status should be 204 Scenario: Update profile requires authentication - When I send a PATCH request to "/api/v1/users/00000000-0000-0000-0000-000000000000" with body: + When I send a PATCH request to "/api/v1/users/AAAAAAAAAAAAAAAAAAAAA" with body: | userKind | student | Then the response status should be 401 diff --git a/apps/modeling-commons-backend/tests/support/timing-collector.spec.ts b/apps/modeling-commons-backend/tests/support/timing-collector.spec.ts new file mode 100644 index 00000000..fa884bf2 --- /dev/null +++ b/apps/modeling-commons-backend/tests/support/timing-collector.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; + +import { newId } from '#src/shared/utils/id.ts'; + +import { normaliseUrl } from '#tests/support/timing-collector.ts'; + +describe('normaliseUrl', () => { + it('collapses a NanoID path segment to :id', () => { + const id = newId(); + expect(normaliseUrl(`/v1/models/${id}`)).toBe('/v1/models/:id'); + }); + + it('collapses a legacy dashed identifier path segment to :id', () => { + expect(normaliseUrl('/v1/models/123e4567-e89b-12d3-a456-426614174000')).toBe('/v1/models/:id'); + }); + + it('collapses a numeric path segment to :id', () => { + expect(normaliseUrl('/v1/models/42')).toBe('/v1/models/:id'); + }); + + it('does not collapse ordinary literal segments', () => { + expect(normaliseUrl('/v1/models/versions/drafts')).toBe('/v1/models/versions/drafts'); + }); + + it('does not collapse a NanoID-shaped path with the wrong length', () => { + expect(normaliseUrl('/v1/models/AAAAAAAAAAAAAAAAAAAA')).toBe('/v1/models/AAAAAAAAAAAAAAAAAAAA'); + }); +}); diff --git a/apps/modeling-commons-backend/tests/support/timing-collector.ts b/apps/modeling-commons-backend/tests/support/timing-collector.ts index 0f332d83..f19cff48 100644 --- a/apps/modeling-commons-backend/tests/support/timing-collector.ts +++ b/apps/modeling-commons-backend/tests/support/timing-collector.ts @@ -1,5 +1,6 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; +import { ID_PATTERN } from '#src/shared/utils/id.ts'; export interface TimingRecord { method: string; @@ -20,18 +21,18 @@ const records: TimingRecord[] = []; const scenarios: ScenarioTag[] = []; let activeScenario: string | undefined; -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const HEX_ID_RE = /^[0-9a-f]{24,36}$/i; +// Legacy captured runs may still contain the old dashed 36-char identifier +// format, so a NanoID-only matcher would fragment those older reports. +const ID_RE = new RegExp(`${ID_PATTERN}|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, 'i'); const NUMERIC_RE = /^\d+$/; -const normaliseUrl = (rawUrl: string): string => { +export const normaliseUrl = (rawUrl: string): string => { const [pathOnly] = rawUrl.split('?'); const path = pathOnly ?? rawUrl; const segments = path.split('/'); const normalised = segments.map((seg) => { if (!seg) return seg; - if (UUID_RE.test(seg)) return ':id'; - if (HEX_ID_RE.test(seg)) return ':id'; + if (ID_RE.test(seg)) return ':id'; if (NUMERIC_RE.test(seg)) return ':id'; return seg; }); From d291b1f6a635c672d24ff012c94b53a760fce2ec Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:46:00 -0500 Subject: [PATCH 10/18] docs(commons): record NanoID id convention, retire UUID from plan docs Add the id convention to backend AGENTS.md/CLAUDE.md and record the NanoID decision plus its two deliberate UUID exceptions in apps/modeling-commons-backend/.claude/DECISIONS.md. Update the six plan docs still describing UUID ids as the convention, fixing the runtime-breaking ::uuid cast in the search spec and rewriting the discussion-archive importer's id-mapping algorithm for NanoID. --- .../.claude/DECISIONS.md | 13 ++++++ apps/modeling-commons-backend/AGENTS.md | 5 +++ ...egacy-migration-discussion-archive-plan.md | 44 +++++++++---------- .../doc/legacy-migration-discussion-plan.md | 18 ++++---- .../doc/legacy-migration-reporting-plan.md | 16 +++---- .../doc/legacy-migration-search-spec.md | 4 +- .../legacy-migration-version-history-plan.md | 2 +- .../doc/model-fork-plan.md | 6 +-- 8 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 apps/modeling-commons-backend/.claude/DECISIONS.md diff --git a/apps/modeling-commons-backend/.claude/DECISIONS.md b/apps/modeling-commons-backend/.claude/DECISIONS.md new file mode 100644 index 00000000..9c4aeed2 --- /dev/null +++ b/apps/modeling-commons-backend/.claude/DECISIONS.md @@ -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`. diff --git a/apps/modeling-commons-backend/AGENTS.md b/apps/modeling-commons-backend/AGENTS.md index 53fe54e6..d3410576 100644 --- a/apps/modeling-commons-backend/AGENTS.md +++ b/apps/modeling-commons-backend/AGENTS.md @@ -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`. diff --git a/apps/modeling-commons-backend/doc/legacy-migration-discussion-archive-plan.md b/apps/modeling-commons-backend/doc/legacy-migration-discussion-archive-plan.md index 55d3eed7..77d1ff37 100644 --- a/apps/modeling-commons-backend/doc/legacy-migration-discussion-archive-plan.md +++ b/apps/modeling-commons-backend/doc/legacy-migration-discussion-archive-plan.md @@ -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!('<', '<')`, so values may already be HTML-escaped. Frontend treats as preformatted text. | | `is_question` | `isQuestion` (bool). | @@ -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? @@ -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 @@ -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), }); ``` @@ -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(seenLegacyId); // legacy posting id → new uuid + const idMap = new Map(seenLegacyId); // legacy posting id → new id await streamRows( `SELECT id, person_id, node_id, parent_id, title, body, @@ -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, @@ -274,15 +274,15 @@ 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 }, }); } } @@ -290,7 +290,7 @@ async function migrateLegacyPostings( Idempotency: -- 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 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`: diff --git a/apps/modeling-commons-backend/doc/legacy-migration-discussion-plan.md b/apps/modeling-commons-backend/doc/legacy-migration-discussion-plan.md index 6f309bd8..0afa7cae 100644 --- a/apps/modeling-commons-backend/doc/legacy-migration-discussion-plan.md +++ b/apps/modeling-commons-backend/doc/legacy-migration-discussion-plan.md @@ -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 @@ -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. @@ -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; @@ -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(), @@ -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. diff --git a/apps/modeling-commons-backend/doc/legacy-migration-reporting-plan.md b/apps/modeling-commons-backend/doc/legacy-migration-reporting-plan.md index 90bee639..44177398 100644 --- a/apps/modeling-commons-backend/doc/legacy-migration-reporting-plan.md +++ b/apps/modeling-commons-backend/doc/legacy-migration-reporting-plan.md @@ -72,7 +72,7 @@ enum ReportStatus { } model Report { - id String @id @default(uuid()) + id String @id @default(nanoid()) resourceType ReportResourceType resourceId String reporterUserId String @@ -216,7 +216,7 @@ export default function reportDomain() { throw new EmptyReasonError(); } return { - id: crypto.randomUUID(), + id: newId(), ...props, status: 'open', resolverUserId: null, @@ -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'), @@ -484,14 +484,14 @@ export type ListReportsQueryDto = Static; ```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()]), @@ -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`, diff --git a/apps/modeling-commons-backend/doc/legacy-migration-search-spec.md b/apps/modeling-commons-backend/doc/legacy-migration-search-spec.md index 535dc3f6..fa17461a 100644 --- a/apps/modeling-commons-backend/doc/legacy-migration-search-spec.md +++ b/apps/modeling-commons-backend/doc/legacy-migration-search-spec.md @@ -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 )) diff --git a/apps/modeling-commons-backend/doc/legacy-migration-version-history-plan.md b/apps/modeling-commons-backend/doc/legacy-migration-version-history-plan.md index 4c110182..df31dda2 100644 --- a/apps/modeling-commons-backend/doc/legacy-migration-version-history-plan.md +++ b/apps/modeling-commons-backend/doc/legacy-migration-version-history-plan.md @@ -326,7 +326,7 @@ export type RevertVersionRequestDto = Static; diff --git a/apps/modeling-commons-backend/doc/model-fork-plan.md b/apps/modeling-commons-backend/doc/model-fork-plan.md index e6771606..b81ca83b 100644 --- a/apps/modeling-commons-backend/doc/model-fork-plan.md +++ b/apps/modeling-commons-backend/doc/model-fork-plan.md @@ -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 @@ -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 @@ -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: }`. +4. Assert: 201 and body `{ id: }`. 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'`. From eabcd9dfa0e6a5e4fd8f514707ad8e99a7689c49 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:57:04 -0500 Subject: [PATCH 11/18] refactor(commons): fix rehearsal dump normalisation for nanoid keys The rehearsal dump's uuid regex only matched the old 36-char shape, so it silently stopped normalising the nanoid(21) and nanoid(10) segments that storage keys now contain, making two dumps of identical data diff on every key. Extract the normalisation into lib/normalize-key.ts so it covers all three shapes that can appear in a key: bare nanoid(21)/nanoid(10) path segments, the nanoid(10)-filename fused segment from stagingKey, and the hex-hyphenated shape shared by legacy pre-migration rows and the frozen storagePathHash output. Matches are anchored to segment boundaries and exact lengths so real path components and filenames are never mistaken for an id. --- .../lib/normalize-key.spec.ts | 60 +++++++++++++++++++ .../legacy-migration/lib/normalize-key.ts | 28 +++++++++ .../prisma/legacy-migration/rehearsal/dump.ts | 10 +++- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts create mode 100644 apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts new file mode 100644 index 00000000..b2215651 --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest'; +import { normalizeKey } from './normalize-key.ts'; + +const MODEL_ID_A = 'abcdefghij0123456789A'; // 21 chars +const MODEL_ID_B = 'ZYXWVUTSRQ9876543210z'; // 21 chars +const FILE_ID = 'fileZfileZfileZfileZf'; // 21 chars, held constant across a/b +const NANOID10_A = 'AbCdEfGhIj'; +const NANOID10_B = '9zY8xW7vU6'; +const USER_ID = 'userU1serU1serU1serU1'; // 21 chars +const DRAFT_ID = 'draftDRAFTdraftDRAFTd'; // 21 chars + +describe('normalizeKey', () => { + test('collapses a bare nanoid(21) model id segment', () => { + const a = `uploads/models/${MODEL_ID_A}/versions/2020/05/04/${FILE_ID}/x.nlogo`; + const b = `uploads/models/${MODEL_ID_B}/versions/2020/05/04/${FILE_ID}/x.nlogo`; + expect(normalizeKey(a)).toBe(normalizeKey(b)); + }); + + test('collapses a bare nanoid(10) createStorageKey segment', () => { + const a = `uploads/models/2024/06/25/${NANOID10_A}/my_model.png`; + const b = `uploads/models/2024/06/25/${NANOID10_B}/my_model.png`; + expect(normalizeKey(a)).toBe(normalizeKey(b)); + }); + + test('collapses the id prefix of a stagingKey fused nanoid(10)-filename segment', () => { + const a = `staging/${USER_ID}/${DRAFT_ID}/${NANOID10_A}-my_model.png`; + const b = `staging/${USER_ID}/${DRAFT_ID}/${NANOID10_B}-my_model.png`; + expect(normalizeKey(a)).toBe(normalizeKey(b)); + }); + + test('collapses a legacy pre-migration hex-hyphenated id segment', () => { + const a = 'uploads/models/11111111-1111-4111-8111-111111111111/versions/2020/05/04/f/x.nlogo'; + const b = 'uploads/models/22222222-2222-4222-8222-222222222222/versions/2020/05/04/f/x.nlogo'; + expect(normalizeKey(a)).toBe(normalizeKey(b)); + }); + + test('collapses the frozen storagePathHash segment', () => { + const a = 'uploads/models/m/versions/2020/05/04/fb60fd65-f24f-42c7-bb9f-9c794f021ae4/x.nlogo'; + const b = 'uploads/models/m/versions/2020/05/04/6817b950-1dca-4c97-aee6-5fafbb910a24/x.nlogo'; + expect(normalizeKey(a)).toBe(normalizeKey(b)); + }); + + test('does not collapse a real path component that differs', () => { + const a = `uploads/models/${MODEL_ID_A}/versions/2020/05/04/f/x.nlogo`; + const b = `uploads/models/${MODEL_ID_A}/preview-images/2020/05/04/f/x.nlogo`; + expect(normalizeKey(a)).not.toBe(normalizeKey(b)); + }); + + test('does not collapse a filename that differs', () => { + const a = `uploads/models/${MODEL_ID_A}/versions/2020/05/04/${NANOID10_A}/a.nlogo`; + const b = `uploads/models/${MODEL_ID_A}/versions/2020/05/04/${NANOID10_A}/b.nlogo`; + expect(normalizeKey(a)).not.toBe(normalizeKey(b)); + }); + + test('does not collapse a stagingKey filename that differs, only its id prefix', () => { + const a = `staging/${USER_ID}/${DRAFT_ID}/${NANOID10_A}-first.png`; + const b = `staging/${USER_ID}/${DRAFT_ID}/${NANOID10_A}-second.png`; + expect(normalizeKey(a)).not.toBe(normalizeKey(b)); + }); +}); diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts new file mode 100644 index 00000000..718373a4 --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts @@ -0,0 +1,28 @@ +/** + * Normalises the random segments of a storage key so two dumps of logically + * identical data diff cleanly, no matter which generator produced the key: + * - a bare nanoid(21) or nanoid(10) path segment, as emitted by row ids + * and `createStorageKey` + * - a `nanoid(10)-filename` fused segment, as emitted by `stagingKey` + * - a hyphenated 36-character hex segment: either a legacy pre-migration + * row id, or the frozen `storagePathHash` output, which deliberately + * keeps that same shape + * + * Known path words (`models`, `versions`, `preview-images`, ...) and date + * segments never share these exact lengths, so they pass through untouched. + * Matches are anchored to segment boundaries and exact lengths so a + * legitimate path component or filename is never mistaken for an id. + */ +const HYPHENATED_HEX_SEGMENT = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; +const NANOID21_SEGMENT = '[A-Za-z0-9_-]{21}'; +const NANOID10_SEGMENT = '[A-Za-z0-9_-]{10}'; + +const BARE_ID = new RegExp( + `(^|/)(?:${HYPHENATED_HEX_SEGMENT}|${NANOID21_SEGMENT}|${NANOID10_SEGMENT})(?=/|$)`, + 'gi', +); +const FUSED_ID_PREFIX = new RegExp(`(^|/)${NANOID10_SEGMENT}(?=-)`, 'g'); + +export function normalizeKey(key: string): string { + return key.replace(BARE_ID, '$1').replace(FUSED_ID_PREFIX, '$1'); +} diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/rehearsal/dump.ts b/apps/modeling-commons-backend/prisma/legacy-migration/rehearsal/dump.ts index bcb8270d..4846e8fc 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/rehearsal/dump.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/rehearsal/dump.ts @@ -1,13 +1,17 @@ -/** Canonical, uuid-free dump of a target DB so two of them can be compared. */ +/** + * Canonical dump of a target DB, with the random id segments of every storage + * key normalised out, so two dumps of logically identical data diff cleanly. + * See lib/normalize-key.ts for exactly what gets collapsed. + */ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '../../../generated/prisma/client.js'; +import { normalizeKey } from '../lib/normalize-key.ts'; const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env['DATABASE_URL']! }), }); -const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; -const norm = (key: string | null) => (key === null ? null : key.replace(UUID, '')); +const norm = (key: string | null) => (key === null ? null : normalizeKey(key)); // A version file keeps the node name it was uploaded under; renaming a node in // the legacy app does not move objects in storage, so the trailing filename can // legitimately differ from a from-scratch archive. Compare the path only. From 29b7763ccd4c68472c89bd72fcbbf5ff20b0ace2 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 16:57:09 -0500 Subject: [PATCH 12/18] refactor(commons): rename uuid-named identifiers to id in legacy migration These names and comments claimed UUIDs that no longer exist now that row ids are NanoIDs: modelUuid, fileUuid, ownerUuid, tagUuid, userUuid and related test fixtures. Mechanical rename only, no behaviour change. storagePathHash and its pinned-output test are untouched. --- .../prisma/legacy-migration/apply-diff.ts | 14 ++--- .../prisma/legacy-migration/initial-import.ts | 20 +++---- .../lib/collaborators.spec.ts | 4 +- .../legacy-migration/lib/collaborators.ts | 2 +- .../legacy-migration/lib/file-keys.spec.ts | 2 +- .../prisma/legacy-migration/lib/file-keys.ts | 24 ++++---- .../lib/node-migration.spec.ts | 58 +++++++++---------- .../legacy-migration/lib/node-migration.ts | 58 +++++++++---------- 8 files changed, 91 insertions(+), 91 deletions(-) diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts index cbd312e0..f4536daa 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts @@ -868,8 +868,8 @@ async function planAdditionalFilesAndPreviews( continue; } - const fileUuid = newId(); - const key = buildAttachmentFileKey(modelId, a.created_at ?? new Date(), fileUuid, a.filename); + const fileId = newId(); + const key = buildAttachmentFileKey(modelId, a.created_at ?? new Date(), fileId, a.filename); plan.files.push({ key, body: a.contents }); plan.files.push({ key: `${key}.metadata.json`, @@ -880,7 +880,7 @@ async function planAdditionalFilesAndPreviews( legacyAttachmentId: a.id, nodeLegacyId: a.node_id, data: { - id: fileUuid, + id: fileId, modelId, taggedVersionNumber: await latestVersionNumberAfterAppends(plan, modelId), fileKey: key, @@ -988,10 +988,10 @@ async function planAdditionalFilesAndPreviews( } /** - * The uuid segment of a file key is a nonce; what identifies a preview object is - * its model, date partition and filename. createModelFromNode mints a random - * nonce while the resync derives one from the legacy attachment id, so compare - * the two without it. + * The random id segment of a file key is a nonce; what identifies a preview + * object is its model, date partition and filename. createModelFromNode mints + * a random nonce while the resync derives one from the legacy attachment id, + * so compare the two without it. */ function samePreviewObject(a: string | null, b: string | null): boolean { if (a === null || b === null) return a === b; diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts b/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts index a67f8fb9..092de992 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts @@ -4,8 +4,8 @@ * Source: nlcommons_production (Rails app, integer ids, tables: people, nodes, * versions, tags, tagged_nodes, attachments, spam_warnings, * permission_settings). - * Target: new Prisma schema (UUIDs, Model/ModelVersion split, Better Auth users, - * file storage by key). + * Target: new Prisma schema (NanoID ids, Model/ModelVersion split, Better Auth + * users, file storage by key). * * Idempotent via legacyId columns on User/Model/Tag — with one exception: * migrateInteractions has no dedupe key (ModelInteraction carries no legacyId @@ -287,8 +287,8 @@ function aliasDuplicateTagNames( if (map.has(t.id)) continue; const n = normalizeTagName(t.name); if (!n) continue; - const winnerUuid = map.get(nameWinner.get(n)?.id ?? -1); - if (winnerUuid) map.set(t.id, winnerUuid); + const winnerId = map.get(nameWinner.get(n)?.id ?? -1); + if (winnerId) map.set(t.id, winnerId); } } @@ -333,14 +333,14 @@ async function migrateNodes( legacy.taggingsForNode(node.id), ]); - const modelUuid = newId(); - modelIdMap.set(node.id, modelUuid); + const modelId = newId(); + modelIdMap.set(node.id, modelId); await prisma.$transaction( async (tx) => { const counts = await createModelFromNode( tx, - modelUuid, + modelId, { node, versions, attachments, taggings }, { writeFile: writeLocalFile, @@ -400,11 +400,11 @@ async function migrateInteractions( const rows: Prisma.ModelInteractionCreateManyInput[] = []; for (const e of batch) { if (!e.node_id) continue; - const modelUuid = modelIdMap.get(e.node_id); - if (!modelUuid) continue; // node was spam-skipped or dropped + const modelId = modelIdMap.get(e.node_id); + if (!modelId) continue; // node was spam-skipped or dropped rows.push({ id: newId(), - modelId: modelUuid, + modelId, kind, userId: e.person_id ? (userIdMap.get(e.person_id) ?? null) : null, ipHash: hashIp(e.ip_address, IP_HASH_SALT), diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.spec.ts index d47dd5d4..f28ee8d4 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.spec.ts @@ -8,8 +8,8 @@ import { } from './collaborators.ts'; import type { LegacyCollaboration, LegacyNonMemberCollaboration } from './legacy.ts'; -const MODEL = 'model-uuid-1'; -const USER = 'user-uuid-1'; +const MODEL = 'model-id-1'; +const USER = 'user-id-1'; const CREATED = new Date('2013-05-01T00:00:00Z'); const TYPES = buildTypeNameById([ diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.ts index 36f6c36b..ef498861 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/collaborators.ts @@ -10,7 +10,7 @@ export type CollaboratorContext = { userIdByLegacyId: ReadonlyMap; modelIdByLegacyId: ReadonlyMap; typeNameById: ReadonlyMap; - /** Keyed `${modelUuid}\0${userUuid}` — the ModelAuthor rows already in the target. */ + /** Keyed `${modelId}\0${userId}` — the ModelAuthor rows already in the target. */ existingAuthors: ReadonlyMap; existingNonMemberLegacyIds: ReadonlySet; }; diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts index 4f9af7d0..513cdeb4 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.spec.ts @@ -103,7 +103,7 @@ describe('key builders', () => { ); }); - test('avatar keys are public-read and keyed by user uuid', () => { + test('avatar keys are public-read and keyed by user id', () => { expect(buildAvatarFileKey(MODEL, DATE, FILE, 'avatar')).toBe( `files/public/uploads/avatars/${MODEL}/2011/02/18/${FILE}/avatar`, ); diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts index ad17c19d..9a8169c2 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/file-keys.ts @@ -36,42 +36,42 @@ export function getAccessPrefix(access: AccessPolicy): string { } export function buildVersionFileKey( - modelUuid: string, + modelId: string, d: Date, - fileUuid: string, + fileId: string, filename: string = 'file', accessPolicy: AccessPolicy = 'private', ): string { const { y, m, day } = dateParts(d); - return `${getAccessPrefix(accessPolicy)}/models/${modelUuid}/versions/${y}/${m}/${day}/${fileUuid}/${sanitizeFilename(filename)}`; + return `${getAccessPrefix(accessPolicy)}/models/${modelId}/versions/${y}/${m}/${day}/${fileId}/${sanitizeFilename(filename)}`; } export function buildPreviewFileKey( - modelUuid: string, + modelId: string, d: Date, - fileUuid: string, + fileId: string, filename: string = 'file', ): string { const { y, m, day } = dateParts(d); - return `${getAccessPrefix('public-read')}/models/${modelUuid}/preview-images/${y}/${m}/${day}/${fileUuid}/${sanitizeFilename(filename)}`; + return `${getAccessPrefix('public-read')}/models/${modelId}/preview-images/${y}/${m}/${day}/${fileId}/${sanitizeFilename(filename)}`; } export function buildAttachmentFileKey( - modelUuid: string, + modelId: string, d: Date, - fileUuid: string, + fileId: string, filename: string = 'file', ): string { const { y, m, day } = dateParts(d); - return `${getAccessPrefix('private')}/models/${modelUuid}/additionalFiles/${y}/${m}/${day}/${fileUuid}/${sanitizeFilename(filename)}`; + return `${getAccessPrefix('private')}/models/${modelId}/additionalFiles/${y}/${m}/${day}/${fileId}/${sanitizeFilename(filename)}`; } export function buildAvatarFileKey( - userUuid: string, + userId: string, d: Date, - fileUuid: string, + fileId: string, filename: string = 'file', ): string { const { y, m, day } = dateParts(d); - return `${getAccessPrefix('public-read')}/avatars/${userUuid}/${y}/${m}/${day}/${fileUuid}/${sanitizeFilename(filename)}`; + return `${getAccessPrefix('public-read')}/avatars/${userId}/${y}/${m}/${day}/${fileId}/${sanitizeFilename(filename)}`; } diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts index ca889387..55c12553 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.spec.ts @@ -7,7 +7,7 @@ import { } from './node-migration.ts'; import type { LegacyAttachment, LegacyNode, LegacyTagging, LegacyVersion } from './legacy.ts'; -const MODEL_UUID = '00000000-0000-4000-8000-000000000000'; +const MODEL_ID = '00000000-0000-4000-8000-000000000000'; const NOW = new Date('2030-01-01T00:00:00.000Z'); const SEP = '@#$#@#$#@'; @@ -44,7 +44,7 @@ function deps(overrides: Partial[3]> = {} writeFile: async (relKey: string, contents: Buffer) => { written.set(relKey, contents); }, - newId: () => `uuid-${++counter}`, + newId: () => `id-${++counter}`, now: () => NOW, userIdByLegacyId: new Map([ [10, 'user-10'], @@ -123,16 +123,16 @@ describe('createModelFromNode', () => { test('refuses a node with no versions', async () => { const { tx } = recordingWriter(); await expect( - createModelFromNode(tx, MODEL_UUID, tree({ versions: [] }), deps().value), + createModelFromNode(tx, MODEL_ID, tree({ versions: [] }), deps().value), ).rejects.toThrow(/no versions/i); }); - test('creates the model keyed on the legacy node id, never touching the uuid of anything else', async () => { + test('creates the model keyed on the legacy node id, never touching the id of anything else', async () => { const { tx, dataFor } = recordingWriter(); - await createModelFromNode(tx, MODEL_UUID, tree(), deps().value); + await createModelFromNode(tx, MODEL_ID, tree(), deps().value); expect(dataFor('model.create')[0]).toEqual({ - id: MODEL_UUID, + id: MODEL_ID, legacyId: 7921, visibility: 'public', isEndorsed: false, @@ -144,7 +144,7 @@ describe('createModelFromNode', () => { test('falls back through created_at then now for missing timestamps', async () => { const { tx, dataFor } = recordingWriter(); const bare = { ...node, created_at: null, updated_at: null }; - await createModelFromNode(tx, MODEL_UUID, tree({ node: bare }), deps().value); + await createModelFromNode(tx, MODEL_ID, tree({ node: bare }), deps().value); expect(dataFor('model.create')[0]).toMatchObject({ createdAt: NOW, updatedAt: NOW }); }); @@ -156,7 +156,7 @@ describe('createModelFromNode', () => { version({ id: 2, created_at: new Date('2021-01-01T00:00:00.000Z') }), version({ id: 3, created_at: new Date('2022-01-01T00:00:00.000Z') }), ]; - const result = await createModelFromNode(tx, MODEL_UUID, tree({ versions }), deps().value); + const result = await createModelFromNode(tx, MODEL_ID, tree({ versions }), deps().value); expect( dataFor('modelVersion.create').map((d) => (d as { versionNumber: number }).versionNumber), @@ -168,10 +168,10 @@ describe('createModelFromNode', () => { test('titles every version with the node name and parses metadata out of the contents', async () => { const { tx, dataFor } = recordingWriter(); - await createModelFromNode(tx, MODEL_UUID, tree(), deps().value); + await createModelFromNode(tx, MODEL_ID, tree(), deps().value); expect(dataFor('modelVersion.create')[0]).toMatchObject({ - modelId: MODEL_UUID, + modelId: MODEL_ID, versionNumber: 1, title: 'Wolf Sheep', description: 'Initial upload', @@ -185,7 +185,7 @@ describe('createModelFromNode', () => { const { tx, dataFor } = recordingWriter(); await createModelFromNode( tx, - MODEL_UUID, + MODEL_ID, tree({ versions: [version({ description: '' })] }), deps().value, ); @@ -196,10 +196,10 @@ describe('createModelFromNode', () => { test('writes the version file under a key derived from the node name and detected format', async () => { const { tx, dataFor } = recordingWriter(); const d = deps(); - await createModelFromNode(tx, MODEL_UUID, tree(), d.value); + await createModelFromNode(tx, MODEL_ID, tree(), d.value); const key = (dataFor('modelVersion.create')[0] as { netlogoFileKey: string }).netlogoFileKey; - expect(key).toBe(`uploads/models/${MODEL_UUID}/versions/2020/05/04/uuid-1/Wolf Sheep.nlogo`); + expect(key).toBe(`uploads/models/${MODEL_ID}/versions/2020/05/04/id-1/Wolf Sheep.nlogo`); expect(d.written.get(key)?.toString('utf8')).toBe(nlogo()); }); @@ -210,12 +210,12 @@ describe('createModelFromNode', () => { version({ id: 2, person_id: 11 }), version({ id: 3, person_id: 10 }), ]; - const result = await createModelFromNode(tx, MODEL_UUID, tree({ versions }), deps().value); + const result = await createModelFromNode(tx, MODEL_ID, tree({ versions }), deps().value); expect(dataFor('modelAuthor.create')).toEqual([ - { modelId: MODEL_UUID, userId: 'user-10', role: 'owner', createdAt: versions[0]!.created_at }, + { modelId: MODEL_ID, userId: 'user-10', role: 'owner', createdAt: versions[0]!.created_at }, { - modelId: MODEL_UUID, + modelId: MODEL_ID, userId: 'user-11', role: 'contributor', createdAt: versions[1]!.created_at, @@ -228,12 +228,12 @@ describe('createModelFromNode', () => { test('an unmapped author is dropped rather than failing the node', async () => { const { tx, dataFor } = recordingWriter(); const versions = [version({ id: 1, person_id: 999 }), version({ id: 2, person_id: 11 })]; - const result = await createModelFromNode(tx, MODEL_UUID, tree({ versions }), deps().value); + const result = await createModelFromNode(tx, MODEL_ID, tree({ versions }), deps().value); expect(result.owners).toBe(0); expect(dataFor('modelAuthor.create')).toEqual([ { - modelId: MODEL_UUID, + modelId: MODEL_ID, userId: 'user-11', role: 'contributor', createdAt: versions[1]!.created_at, @@ -253,7 +253,7 @@ describe('createModelFromNode', () => { ]; const result = await createModelFromNode( tx, - MODEL_UUID, + MODEL_ID, tree({ versions, attachments }), deps().value, ); @@ -261,7 +261,7 @@ describe('createModelFromNode', () => { const updates = calls.filter((c) => c.method === 'modelVersion.update'); expect(updates).toHaveLength(2); expect(updates.at(-1)!.args).toMatchObject({ - where: { modelId_versionNumber: { modelId: MODEL_UUID, versionNumber: 2 } }, + where: { modelId_versionNumber: { modelId: MODEL_ID, versionNumber: 2 } }, }); expect( (updates.at(-1)!.args as { data: { previewImageFileKey: string } }).data.previewImageFileKey, @@ -273,19 +273,19 @@ describe('createModelFromNode', () => { test('preview keys are public-read', async () => { const { tx, calls } = recordingWriter(); const attachments = [attachment({ id: 5, content_type: 'preview', filename: 'p.png' })]; - await createModelFromNode(tx, MODEL_UUID, tree({ attachments }), deps().value); + await createModelFromNode(tx, MODEL_ID, tree({ attachments }), deps().value); const update = calls.find((c) => c.method === 'modelVersion.update')!; expect( (update.args as { data: { previewImageFileKey: string } }).data.previewImageFileKey, - ).toBe(`files/public/uploads/models/${MODEL_UUID}/preview-images/2020/07/01/uuid-2/p.png`); + ).toBe(`files/public/uploads/models/${MODEL_ID}/preview-images/2020/07/01/id-2/p.png`); }); test('non-preview attachments become additional files tagged to the latest version, with a metadata sidecar', async () => { const { tx, dataFor } = recordingWriter(); const d = deps(); const attachments = [attachment({ id: 5, filename: 'CTRNN.nls', content_type: 'extension' })]; - const result = await createModelFromNode(tx, MODEL_UUID, tree({ attachments }), d.value); + const result = await createModelFromNode(tx, MODEL_ID, tree({ attachments }), d.value); const file = dataFor('modelAdditionalFile.create')[0] as { id: string; @@ -293,14 +293,14 @@ describe('createModelFromNode', () => { taggedVersionNumber: number; }; expect(file).toMatchObject({ - id: 'uuid-2', - modelId: MODEL_UUID, + id: 'id-2', + modelId: MODEL_ID, taggedVersionNumber: 1, kind: 'additional', createdAt: attachments[0]!.created_at, }); expect(file.fileKey).toBe( - `uploads/models/${MODEL_UUID}/additionalFiles/2020/07/01/uuid-2/CTRNN.nls`, + `uploads/models/${MODEL_ID}/additionalFiles/2020/07/01/id-2/CTRNN.nls`, ); expect(d.written.get(file.fileKey)?.toString()).toBe('hello'); expect(JSON.parse(d.written.get(`${file.fileKey}.metadata.json`)!.toString())).toEqual({ @@ -326,20 +326,20 @@ describe('createModelFromNode', () => { ]; const result = await createModelFromNode( tx, - MODEL_UUID, + MODEL_ID, tree({ versions, taggings }), deps().value, ); expect(dataFor('modelVersionTag.create')).toEqual([ { - modelId: MODEL_UUID, + modelId: MODEL_ID, versionNumber: 2, tagId: 'tag-100', createdAt: taggings[0]!.created_at, }, { - modelId: MODEL_UUID, + modelId: MODEL_ID, versionNumber: 2, tagId: 'tag-101', createdAt: taggings[2]!.created_at, diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts index 1bd8650e..b7bbcc77 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/node-migration.ts @@ -89,7 +89,7 @@ export function buildAttachmentMetadata( export async function createModelFromNode( tx: ModelWriter, - modelUuid: string, + modelId: string, tree: NodeTree, deps: NodeMigrationDeps, ): Promise { @@ -112,7 +112,7 @@ export async function createModelFromNode( await tx.model.create({ data: { - id: modelUuid, + id: modelId, legacyId: node.id, visibility: mapVisibility(node.visibility_id), isEndorsed: false, @@ -124,53 +124,53 @@ export async function createModelFromNode( let versionNumber = 0; for (const v of versions) { versionNumber++; - await writeVersion(tx, modelUuid, node, v, versionNumber, deps); + await writeVersion(tx, modelId, node, v, versionNumber, deps); result.versions++; } const latestVersionNumber = versionNumber; result.latestVersionNumber = latestVersionNumber; - await tx.model.update({ where: { id: modelUuid }, data: { latestVersionNumber } }); + await tx.model.update({ where: { id: modelId }, data: { latestVersionNumber } }); const seenAuthors = new Set(); - const ownerUuid = deps.userIdByLegacyId.get(firstVersion.person_id); - if (ownerUuid) { + const ownerId = deps.userIdByLegacyId.get(firstVersion.person_id); + if (ownerId) { await tx.modelAuthor.create({ data: { - modelId: modelUuid, - userId: ownerUuid, + modelId, + userId: ownerId, role: 'owner', createdAt: firstVersion.created_at ?? deps.now(), }, }); - seenAuthors.add(ownerUuid); + seenAuthors.add(ownerId); result.owners++; } for (const v of versions.slice(1)) { - const uuid = deps.userIdByLegacyId.get(v.person_id); - if (!uuid || seenAuthors.has(uuid)) continue; + const contributorId = deps.userIdByLegacyId.get(v.person_id); + if (!contributorId || seenAuthors.has(contributorId)) continue; await tx.modelAuthor.create({ data: { - modelId: modelUuid, - userId: uuid, + modelId, + userId: contributorId, role: 'contributor', createdAt: v.created_at ?? deps.now(), }, }); - seenAuthors.add(uuid); + seenAuthors.add(contributorId); result.contributors++; } for (const a of attachments) { - const fileUuid = deps.newId(); + const fileId = deps.newId(); const dateForPath = a.created_at ?? node.created_at ?? deps.now(); if (a.content_type === 'preview') { - const relKey = buildPreviewFileKey(modelUuid, dateForPath, fileUuid, a.filename); + const relKey = buildPreviewFileKey(modelId, dateForPath, fileId, a.filename); await deps.writeFile(relKey, a.contents); await tx.modelVersion.update({ where: { - modelId_versionNumber: { modelId: modelUuid, versionNumber: latestVersionNumber }, + modelId_versionNumber: { modelId, versionNumber: latestVersionNumber }, }, data: { previewImageFileKey: relKey }, }); @@ -178,7 +178,7 @@ export async function createModelFromNode( continue; } - const relKey = buildAttachmentFileKey(modelUuid, dateForPath, fileUuid, a.filename); + const relKey = buildAttachmentFileKey(modelId, dateForPath, fileId, a.filename); await deps.writeFile(relKey, a.contents); await deps.writeFile( `${relKey}.metadata.json`, @@ -186,8 +186,8 @@ export async function createModelFromNode( ); await tx.modelAdditionalFile.create({ data: { - id: fileUuid, - modelId: modelUuid, + id: fileId, + modelId, taggedVersionNumber: latestVersionNumber, fileKey: relKey, kind: 'additional', @@ -199,19 +199,19 @@ export async function createModelFromNode( const seenTags = new Set(); for (const tg of taggings) { - const tagUuid = deps.tagIdByLegacyId.get(tg.tag_id); - if (!tagUuid) { + const tagId = deps.tagIdByLegacyId.get(tg.tag_id); + if (!tagId) { result.skippedOrphanTaggings++; continue; } - if (seenTags.has(tagUuid)) continue; - seenTags.add(tagUuid); + if (seenTags.has(tagId)) continue; + seenTags.add(tagId); await tx.modelVersionTag.create({ data: { - modelId: modelUuid, + modelId, versionNumber: latestVersionNumber, - tagId: tagUuid, + tagId, createdAt: tg.created_at ?? deps.now(), }, }); @@ -223,7 +223,7 @@ export async function createModelFromNode( async function writeVersion( tx: ModelWriter, - modelUuid: string, + modelId: string, node: LegacyNode, v: LegacyVersion, versionNumber: number, @@ -232,7 +232,7 @@ async function writeVersion( const dateForPath = v.created_at ?? node.created_at ?? deps.now(); const format = getNlogoFileExtension(v.contents); const relKey = buildVersionFileKey( - modelUuid, + modelId, dateForPath, deps.newId(), `${node.name}.${format}`, @@ -244,7 +244,7 @@ async function writeVersion( await tx.modelVersion.create({ data: { - modelId: modelUuid, + modelId, versionNumber, title: node.name, description: v.description || null, From 4da0814dd8adc1b540df6b86a4c3f7120e987711 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 17:01:18 -0500 Subject: [PATCH 13/18] fix(commons): compare preview keys by position, not shape samePreviewObject stripped a 36-char uuid segment to ignore the random nonce when deciding whether a resync's preview already matches the database. Since createModelFromNode now mints a nanoid(21) nonce while the resync still derives the frozen storagePathHash (36 chars), the strip only removed one of the two, so every previously-imported model compared as changed on its first resync even when nothing changed. Both buildPreviewFileKey and the live app's createStorageKey share the same tail convention, .../{y}/{m}/{d}/{nonce}/{filename}, so the nonce is reliably the second-to-last path segment regardless of what shape it is. Compare by that position instead of by shape, and move the function into lib/preview-key.ts so it is unit tested. --- .../prisma/legacy-migration/apply-diff.ts | 13 +---- .../legacy-migration/lib/preview-key.spec.ts | 49 +++++++++++++++++++ .../legacy-migration/lib/preview-key.ts | 24 +++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.spec.ts create mode 100644 apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.ts diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts index f4536daa..32b36017 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts @@ -48,6 +48,7 @@ import { storagePathHash, sanitizeFilename, } from './lib/file-keys.ts'; +import { samePreviewObject } from './lib/preview-key.ts'; import { LegacyDatabase, type LegacyNode, @@ -987,18 +988,6 @@ async function planAdditionalFilesAndPreviews( } } -/** - * The random id segment of a file key is a nonce; what identifies a preview - * object is its model, date partition and filename. createModelFromNode mints - * a random nonce while the resync derives one from the legacy attachment id, - * so compare the two without it. - */ -function samePreviewObject(a: string | null, b: string | null): boolean { - if (a === null || b === null) return a === b; - const strip = (key: string) => key.replace(/\/[0-9a-f-]{36}\//gi, '/'); - return strip(a) === strip(b); -} - /** * ModelAdditionalFile carries no legacy id, so a row is located by its filename * and creation time. Returns 'ambiguous' rather than picking one when that pair diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.spec.ts new file mode 100644 index 00000000..14234104 --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'vitest'; +import { samePreviewObject } from './preview-key.ts'; + +const NANOID21_NONCE = 'abcdefghij0123456789A'; // createModelFromNode's newId() +const HASH_NONCE = 'fb60fd65-f24f-42c7-bb9f-9c794f021ae4'; // frozen storagePathHash output +const MODEL_A = 'model1model1model1mod'; +const MODEL_B = 'model2model2model2mod'; + +function previewKey(modelId: string, nonce: string, filename = 'p.png') { + return `files/public/uploads/models/${modelId}/preview-images/2020/07/01/${nonce}/${filename}`; +} + +describe('samePreviewObject', () => { + test('treats keys whose nonces differ only in shape as the same object', () => { + const a = previewKey(MODEL_A, NANOID21_NONCE); + const b = previewKey(MODEL_A, HASH_NONCE); + expect(samePreviewObject(a, b)).toBe(true); + }); + + test('treats a live-app createStorageKey nonce (nanoid10) as the same object too', () => { + const a = previewKey(MODEL_A, HASH_NONCE); + const b = previewKey(MODEL_A, 'AbCdEfGhIj'); + expect(samePreviewObject(a, b)).toBe(true); + }); + + test('does not collapse a difference in model id', () => { + const a = previewKey(MODEL_A, NANOID21_NONCE); + const b = previewKey(MODEL_B, NANOID21_NONCE); + expect(samePreviewObject(a, b)).toBe(false); + }); + + test('does not collapse a difference in the date partition', () => { + const a = 'files/public/uploads/models/m/preview-images/2020/07/01/AbCdEfGhIj/p.png'; + const b = 'files/public/uploads/models/m/preview-images/2020/07/02/AbCdEfGhIj/p.png'; + expect(samePreviewObject(a, b)).toBe(false); + }); + + test('does not collapse a difference in filename', () => { + const a = previewKey(MODEL_A, NANOID21_NONCE, 'first.png'); + const b = previewKey(MODEL_A, NANOID21_NONCE, 'second.png'); + expect(samePreviewObject(a, b)).toBe(false); + }); + + test('null on either side compares equal only when both are null', () => { + expect(samePreviewObject(null, null)).toBe(true); + expect(samePreviewObject(previewKey(MODEL_A, NANOID21_NONCE), null)).toBe(false); + expect(samePreviewObject(null, previewKey(MODEL_A, NANOID21_NONCE))).toBe(false); + }); +}); diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.ts new file mode 100644 index 00000000..b684643d --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/preview-key.ts @@ -0,0 +1,24 @@ +/** + * Compares two preview image keys ignoring their random nonce segment. + * + * Both `buildPreviewFileKey` (legacy migration) and the live app's + * `createStorageKey` share the same tail convention: + * `.../{y}/{m}/{d}/{nonce}/{filename}`. The nonce is therefore always the + * second-to-last path segment, no matter what shape it is: a nanoid(21) from + * `createModelFromNode`, the frozen `storagePathHash` from the resync, or a + * nanoid(10) from a live-app edit. Comparing by position rather than by + * shape means this keeps working as id formats change; what identifies a + * preview object is its model, date partition and filename, not the nonce. + */ +export function samePreviewObject(a: string | null, b: string | null): boolean { + if (a === null || b === null) return a === b; + return stripNonce(a) === stripNonce(b); +} + +function stripNonce(key: string): string { + const segments = key.split('/'); + const nonceIndex = segments.length - 2; + if (nonceIndex < 0) return key; + segments[nonceIndex] = ''; + return segments.join('/'); +} From 661ed2f0b0644b8f7e70a42c9d4925af84de854e Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 20:35:23 -0500 Subject: [PATCH 14/18] fix(commons): stop autoload registering spec files as plugins The plugins autoload had no filter, so a spec file placed beside its subject was imported as a Fastify plugin and ran describe() outside a test runner, killing the app at boot. The routes autoload already filters this way. --- apps/modeling-commons-backend/src/server/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/modeling-commons-backend/src/server/index.ts b/apps/modeling-commons-backend/src/server/index.ts index 1a4dbb5a..566fde8c 100644 --- a/apps/modeling-commons-backend/src/server/index.ts +++ b/apps/modeling-commons-backend/src/server/index.ts @@ -74,6 +74,7 @@ export default async function createServer(fastify: FastifyInstance): Promise Date: Wed, 12 Aug 2026 20:35:27 -0500 Subject: [PATCH 15/18] fix(commons): register the nanoid format on every ajv instance Fastify compiles route schemas with an ajv instance it builds itself, so the format registered on the exported ajv never reached route validation and every route carrying an idSchema failed to build. Both server builders now share one registration function. --- apps/modeling-commons-backend/src/index.ts | 2 + .../src/shared/utils/validator.util.spec.ts | 47 +++++++++++++++++++ .../src/shared/utils/validator.util.ts | 9 +++- .../tests/support/server.ts | 2 + 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 apps/modeling-commons-backend/src/shared/utils/validator.util.spec.ts diff --git a/apps/modeling-commons-backend/src/index.ts b/apps/modeling-commons-backend/src/index.ts index 2c407ded..5f8f563b 100644 --- a/apps/modeling-commons-backend/src/index.ts +++ b/apps/modeling-commons-backend/src/index.ts @@ -4,6 +4,7 @@ import server from '#src/server/index.ts'; import { prisma } from '#src/shared/db/prisma.client.ts'; import { newId } from '#src/shared/utils/id.ts'; import { validateRequestId } from '#src/shared/utils/validate-request-id.ts'; +import { addIdFormat } from '#src/shared/utils/validator.util.ts'; import Fastify from 'fastify'; async function init(): Promise { @@ -28,6 +29,7 @@ async function init(): Promise { customOptions: { keywords: ['example'], }, + onCreate: addIdFormat, }, trustProxy: env.trustProxy.maxHops, }); diff --git a/apps/modeling-commons-backend/src/shared/utils/validator.util.spec.ts b/apps/modeling-commons-backend/src/shared/utils/validator.util.spec.ts new file mode 100644 index 00000000..c56c942a --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/validator.util.spec.ts @@ -0,0 +1,47 @@ +import { idSchema } from '#src/shared/utils/id.ts'; +import { addIdFormat } from '#src/shared/utils/validator.util.ts'; +import Fastify from 'fastify'; +import { Type } from 'typebox'; +import { describe, expect, it } from 'vitest'; + +// Fastify compiles route schemas with an Ajv instance it builds itself, so a +// format registered only on the exported `ajv` never reaches route validation. +// Without addIdFormat wired into the server's ajv options every route carrying +// an idSchema fails to build and the app dies at boot. +describe('addIdFormat', () => { + const buildWithRoute = (onCreate?: typeof addIdFormat) => { + const fastify = Fastify({ ajv: { customOptions: { keywords: ['example'] }, onCreate } }); + fastify.route({ + method: 'PATCH', + url: '/models/:id', + schema: { params: Type.Object({ id: idSchema() }) }, + handler: async () => ({ ok: true }), + }); + return fastify; + }; + + it('lets Fastify compile a route schema that uses idSchema', async () => { + const fastify = buildWithRoute(addIdFormat); + await expect(fastify.ready()).resolves.toBeDefined(); + await fastify.close(); + }); + + it('fails to build the same route when the format is not registered', async () => { + const fastify = buildWithRoute(); + await expect(fastify.ready()).rejects.toThrow(/unknown format "nanoid"/); + await fastify.close(); + }); + + it('accepts a nanoid and rejects a uuid through the compiled route', async () => { + const fastify = buildWithRoute(addIdFormat); + await fastify.ready(); + + const ok = await fastify.inject({ method: 'PATCH', url: '/models/V1StGXR8Z5jdHi6BmyT8C' }); + expect(ok.statusCode).toBe(200); + + const bad = await fastify.inject({ method: 'PATCH', url: '/models/2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231' }); + expect(bad.statusCode).toBe(400); + + await fastify.close(); + }); +}); diff --git a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts index 0c3d78d9..8eee2881 100644 --- a/apps/modeling-commons-backend/src/shared/utils/validator.util.ts +++ b/apps/modeling-commons-backend/src/shared/utils/validator.util.ts @@ -22,4 +22,11 @@ export const ajv = addFormats.default(new Ajv.default({}), [ 'regex', ]); -ajv.addFormat('nanoid', new RegExp(ID_PATTERN)); +// Fastify compiles route schemas with its own Ajv instance, not the one above, +// so both must be given the format. Anything that validates an idSchema has to +// call this or it fails at boot with `unknown format "nanoid"`. +export function addIdFormat(instance: InstanceType): void { + instance.addFormat('nanoid', new RegExp(ID_PATTERN)); +} + +addIdFormat(ajv); diff --git a/apps/modeling-commons-backend/tests/support/server.ts b/apps/modeling-commons-backend/tests/support/server.ts index cc703beb..fdbd564f 100644 --- a/apps/modeling-commons-backend/tests/support/server.ts +++ b/apps/modeling-commons-backend/tests/support/server.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import server from '../../src/server/index.ts'; +import { addIdFormat } from '../../src/shared/utils/validator.util.ts'; import { isTimingEnabled, recordRequest } from './timing-collector.ts'; export const buildApp = async () => { @@ -15,6 +16,7 @@ export const buildApp = async () => { customOptions: { keywords: ['example'], }, + onCreate: addIdFormat, }, }); From 6707c463c5c58d4320216788f3e3703a365e70c1 Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 20:57:01 -0500 Subject: [PATCH 16/18] fix(commons): stop cucumber importing vitest specs as support code The support glob swept in any .ts under tests/support, so a spec beside its subject ran describe() outside a test runner and aborted the run before any scenario started. Verified with --dry-run: 158 scenarios, no undefined steps. --- apps/modeling-commons-backend/cucumber.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/modeling-commons-backend/cucumber.mjs b/apps/modeling-commons-backend/cucumber.mjs index 98a1ae9e..850c240d 100644 --- a/apps/modeling-commons-backend/cucumber.mjs +++ b/apps/modeling-commons-backend/cucumber.mjs @@ -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', From b7e27583722bc1161c51684078fee6e0bec27fdb Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 23:31:18 -0500 Subject: [PATCH 17/18] fix(commons): stop key normalisation eating real filenames The fused-prefix strip fired on any ten characters before a dash, so wolf-sheep-predation.nlogo normalised to -predation.nlogo and two different objects could compare identical in a rehearsal dump. Bare ids now require a following slash, and the fused strip is gated on a staging path. --- .../lib/normalize-key.spec.ts | 24 +++++++++++++++++++ .../legacy-migration/lib/normalize-key.ts | 18 +++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts index b2215651..722da449 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts @@ -58,3 +58,27 @@ describe('normalizeKey', () => { expect(normalizeKey(a)).not.toBe(normalizeKey(b)); }); }); + +describe('normalizeKey does not eat real filenames', () => { + it('leaves a hyphenated filename whose first ten characters precede a dash', () => { + const key = 'uploads/models/m/preview-images/2020/07/01/AbCdEfGhIj/wolf-sheep-predation.nlogo'; + expect(normalizeKey(key)).toBe('uploads/models/m/preview-images/2020/07/01//wolf-sheep-predation.nlogo'); + }); + + it('keeps two different filenames distinguishable under the same id segment', () => { + const base = 'uploads/models/m/additionalFiles/2020/07/01/AbCdEfGhIj/'; + expect(normalizeKey(`${base}wolf-sheep-predation.nlogo`)).not.toBe( + normalizeKey(`${base}Xy1Z_aBcDe-predation.nlogo`), + ); + }); + + it('leaves an extensionless final segment of id length alone', () => { + expect(normalizeKey('uploads/models/m/files/2020/07/01/AbCdEfGhIj/abcdefghij')).toBe( + 'uploads/models/m/files/2020/07/01//abcdefghij', + ); + }); + + it('still strips the fused prefix inside a staging key', () => { + expect(normalizeKey('staging/u/d/AbCdEfGhIj-wolf-sheep.nlogox')).toBe('staging/u/d/-wolf-sheep.nlogox'); + }); +}); diff --git a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts index 718373a4..37a6f54f 100644 --- a/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts @@ -8,21 +8,27 @@ * row id, or the frozen `storagePathHash` output, which deliberately * keeps that same shape * - * Known path words (`models`, `versions`, `preview-images`, ...) and date - * segments never share these exact lengths, so they pass through untouched. - * Matches are anchored to segment boundaries and exact lengths so a - * legitimate path component or filename is never mistaken for an id. + * Two restrictions keep real filenames out of this. Ids only ever appear as + * non-final path segments, so the bare match requires a following slash: a + * 10- or 21-character extensionless filename is not an id. And the fused + * prefix only exists in staging keys, so it is only stripped when the path is + * actually staging-shaped. Without that gate a filename like + * `wolf-sheep-predation.nlogo` normalises to `-predation.nlogo`, because + * `wolf-sheep` is ten characters followed by a hyphen. */ const HYPHENATED_HEX_SEGMENT = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; const NANOID21_SEGMENT = '[A-Za-z0-9_-]{21}'; const NANOID10_SEGMENT = '[A-Za-z0-9_-]{10}'; const BARE_ID = new RegExp( - `(^|/)(?:${HYPHENATED_HEX_SEGMENT}|${NANOID21_SEGMENT}|${NANOID10_SEGMENT})(?=/|$)`, + `(^|/)(?:${HYPHENATED_HEX_SEGMENT}|${NANOID21_SEGMENT}|${NANOID10_SEGMENT})(?=/)`, 'gi', ); const FUSED_ID_PREFIX = new RegExp(`(^|/)${NANOID10_SEGMENT}(?=-)`, 'g'); export function normalizeKey(key: string): string { - return key.replace(BARE_ID, '$1').replace(FUSED_ID_PREFIX, '$1'); + const normalized = key.replace(BARE_ID, '$1'); + + const isStagingKey = key.split('/').slice(0, -1).includes('staging'); + return isStagingKey ? normalized.replace(FUSED_ID_PREFIX, '$1') : normalized; } From cb24cf065ae39c7514db9cb7db156ac4f462c44e Mon Sep 17 00:00:00 2001 From: Omar Ibrahim Date: Wed, 12 Aug 2026 23:31:19 -0500 Subject: [PATCH 18/18] refactor(commons): share the staging key segment length Both producers of the fused staging key must agree with the reader that strips it; one still had a bare literal. --- .../src/modules/model-draft/model-draft.service.ts | 2 +- .../src/modules/model-draft/model-draft.storage.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts index c07b5b61..c27a4124 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.service.ts @@ -35,11 +35,11 @@ import { CopyObjectCommand, HeadObjectCommand } from '#src/shared/storage/index. import { sanitizeFilename } from '#src/shared/storage/utils.ts'; import { newId } from '#src/shared/utils/id.ts'; import { nanoid } from 'nanoid'; +import { STAGING_KEY_RANDOM_SEGMENT_LENGTH } from './model-draft.storage.ts'; import { ModelNotFoundError } from '../model/domain/model.errors.ts'; import { UserNotFoundError } from '../user/domain/user.errors.ts'; import { isValidNetlogoFilename } from '#src/shared/utils/netlogo.ts'; -const STAGING_KEY_RANDOM_SEGMENT_LENGTH = 10; const LEGACY_UUID_PREFIX_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/i; diff --git a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts index b6d416d3..68351237 100644 --- a/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts +++ b/apps/modeling-commons-backend/src/modules/model-draft/model-draft.storage.ts @@ -9,6 +9,11 @@ import { import { sanitizeFilename } from '#src/shared/storage/utils.ts'; import { nanoid } from 'nanoid'; +// Staging keys fuse this random prefix with the filename in one segment, so +// filenameFromKey has to strip exactly this many characters plus the dash. +// Both producers of the shape must use it or the reader mangles filenames. +export const STAGING_KEY_RANDOM_SEGMENT_LENGTH = 10; + export default function makeModelDraftStorage({ storage, bucket, fileDomain }: Dependencies) { function stagingPrefix(userId: string, draftId: string): string { return `staging/${userId}/${draftId}/`; @@ -27,7 +32,7 @@ export default function makeModelDraftStorage({ storage, bucket, fileDomain }: D const prefix = isPublic ? publicStagingPrefix(userId, draftId) : stagingPrefix(userId, draftId); - return `${prefix}${nanoid(10)}-${sanitizeFilename(filename)}`; + return `${prefix}${nanoid(STAGING_KEY_RANDOM_SEGMENT_LENGTH)}-${sanitizeFilename(filename)}`; } async function deletePrefix(prefix: string): Promise {