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/.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/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/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', 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'`. 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/legacy-migration/apply-diff.ts b/apps/modeling-commons-backend/prisma/legacy-migration/apply-diff.ts index 7be4045c..32b36017 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,9 +45,10 @@ import { buildAvatarFileKey, buildPreviewFileKey, buildVersionFileKey, - derivedUuid, + storagePathHash, sanitizeFilename, } from './lib/file-keys.ts'; +import { samePreviewObject } from './lib/preview-key.ts'; import { LegacyDatabase, type LegacyNode, @@ -373,7 +374,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 +443,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 +539,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 +576,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 +586,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 +775,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,8 +869,8 @@ async function planAdditionalFilesAndPreviews( continue; } - const fileUuid = randomUUID(); - 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 +881,7 @@ async function planAdditionalFilesAndPreviews( legacyAttachmentId: a.id, nodeLegacyId: a.node_id, data: { - id: fileUuid, + id: fileId, modelId, taggedVersionNumber: await latestVersionNumberAfterAppends(plan, modelId), fileKey: key, @@ -945,7 +946,7 @@ async function planAdditionalFilesAndPreviews( ? buildPreviewFileKey( modelId, winner.created_at ?? new Date(), - derivedUuid('preview', winner.id), + storagePathHash('preview', winner.id), winner.filename, ) : null; @@ -987,18 +988,6 @@ 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. - */ -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/initial-import.ts b/apps/modeling-commons-backend/prisma/legacy-migration/initial-import.ts index f0467818..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 @@ -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, @@ -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,18 +333,18 @@ async function migrateNodes( legacy.taggingsForNode(node.id), ]); - const modelUuid = randomUUID(); - 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, - newUuid: randomUUID, + newId, now: () => new Date(), userIdByLegacyId: userIdMap, tagIdByLegacyId: tagIdMap, @@ -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: randomUUID(), - modelId: modelUuid, + id: newId(), + 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 84109e16..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 @@ -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', () => { @@ -98,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 3f9c9e4f..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 @@ -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); @@ -34,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 be35372f..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); }, - newUuid: () => `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 18284379..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 @@ -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; @@ -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.newUuid(); + 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,9 +232,9 @@ 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.newUuid(), + 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, 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..722da449 --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.spec.ts @@ -0,0 +1,84 @@ +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)); + }); +}); + +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 new file mode 100644 index 00000000..37a6f54f --- /dev/null +++ b/apps/modeling-commons-backend/prisma/legacy-migration/lib/normalize-key.ts @@ -0,0 +1,34 @@ +/** + * 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 + * + * 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})(?=/)`, + 'gi', +); +const FUSED_ID_PREFIX = new RegExp(`(^|/)${NANOID10_SEGMENT}(?=-)`, 'g'); + +export function normalizeKey(key: string): string { + 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; +} 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('/'); +} 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. 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/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; } /** diff --git a/apps/modeling-commons-backend/src/index.ts b/apps/modeling-commons-backend/src/index.ts index 0e080d71..5f8f563b 100644 --- a/apps/modeling-commons-backend/src/index.ts +++ b/apps/modeling-commons-backend/src/index.ts @@ -2,9 +2,10 @@ 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 { addIdFormat } from '#src/shared/utils/validator.util.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 +16,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: { @@ -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/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-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-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..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 @@ -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'); @@ -613,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 }), @@ -628,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(); @@ -715,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 dfe97023..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 @@ -33,11 +33,16 @@ 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 { 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 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, @@ -88,13 +93,32 @@ export default function makeModelDraftService({ const prefix = isPublic ? `${PUBLIC_PREFIX}/staging/${userId}/${draftId}/` : `staging/${userId}/${draftId}/`; - return `${prefix}${randomUUID()}-${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 { @@ -238,7 +262,7 @@ export default function makeModelDraftService({ filename, }); return { - id: randomUUID(), + id: newId(), s3Key: copy.s3Key, filename, sizeBytes: copy.sizeBytes, @@ -368,7 +392,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..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 @@ -7,7 +7,12 @@ 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'; + +// 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 { @@ -27,7 +32,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(STAGING_KEY_RANDOM_SEGMENT_LENGTH)}-${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-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/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-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, 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/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, }), }); 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 { 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/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 { + 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/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/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 new file mode 100644 index 00000000..1d26b5e7 --- /dev/null +++ b/apps/modeling-commons-backend/src/shared/utils/id.spec.ts @@ -0,0 +1,62 @@ +import { Type } from 'typebox'; +import { describe, it, expect } from 'vitest'; + +import { ajv } from '#src/shared/utils/validator.util.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 + 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..60fcd5e4 --- /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 = 'uAce-eANwFXi-tACAe9w1'; +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/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); -} 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 4d148d4f..8eee2881 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 @@ -15,9 +16,17 @@ export const ajv = addFormats.default(new Ajv.default({}), [ 'ipv6', 'uri', 'uri-reference', - 'uuid', 'uri-template', 'json-pointer', 'relative-json-pointer', 'regex', ]); + +// 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/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/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, }, }); 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; }); 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"