Skip to content

Wip/modeling commons/nanoid - #59

Open
omargfh wants to merge 18 commits into
wip/modeling-commons/release-changesfrom
wip/modeling-commons/nanoid
Open

Wip/modeling commons/nanoid#59
omargfh wants to merge 18 commits into
wip/modeling-commons/release-changesfrom
wip/modeling-commons/nanoid

Conversation

@omargfh

@omargfh omargfh commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

omargfh added 18 commits August 12, 2026 14:10
findByIdOrName used a UUID-shaped regex to decide between an id
lookup and a name lookup. A NanoID is an ordinary-looking string
that a tag name could also be, so the heuristic misroutes any
NanoID-shaped tag name to a 404.

Look up by id first and fall back to name on a miss; ids are
unique, so a hit is authoritative. This costs one extra query on
the name path, which is acceptable.
Switch x-correlation-id and Fastify genReqId generation from randomUUID
to newId. Rename validateUUIDv4 to validateRequestId and widen it to
accept either a canonical UUID or a NanoID, since these headers commonly
arrive as UUIDs from upstream proxies and tracing systems. This is the
only place in the codebase that still accepts UUID-shaped ids, and it
is deliberate upstream interop, not backward compatibility.
Hash the namespaced key with SHA-256 and map 21 digest bytes into the
nanoid alphabet via byte & 63, instead of bit-forcing a UUIDv5 shape
from SHA-1. Determinism and idempotency are unchanged; only the output
encoding moves from UUID to nanoid.
Replace randomUUID with newId in initial-import.ts and apply-diff.ts
for every generated row id. Idempotency comes from legacyId columns,
not id determinism, so this is a pure generator swap.

Rename NodeMigrationDeps.newUuid to newId and update all callers and
specs. Rename derivedUuid to storagePathHash and add a pinned-output
test: its uuid-shaped S3 key format is frozen on purpose and must stay
byte-for-byte identical to avoid orphaning existing storage objects.
Swap every remaining randomUUID() entity-id generator for the shared
newId() helper (model, permission, tag, additional-file, interaction,
draft domains, plus the draft-file entries embedded in draft JSON) and
point Better Auth's generateId at the same helper.

Widen storage-key path segments from randomUUID().substring(0, 8) (32
bits of entropy) to nanoid(10), applied consistently across every
storage-key call site since none of them parse the segment back.

Replace every Type.String({ format: 'uuid' }) validator with idSchema()
across the 18 remaining DTO/schema files, drop the now-unused 'uuid'
ajv format, and fix ID_EXAMPLE to a real NanoID literal so Swagger
stops advertising a UUID-shaped example that fails its own pattern.

Tighten existing domain and storage-key specs to assert the NanoID
shape instead of the old UUID/hex regex.
filenameFromKey still sliced a fixed 37 chars off the last path
segment, a leftover assumption from the 36-char UUID prefix the writer
no longer emits. Against the new nanoid(10) staging-key prefix this
either leaked the prefix into the filename (short keys) or truncated
a genuine long filename (keys over 37 chars).

Three shapes actually reach this reader: createStorageKey keys put the
random segment in its own path directory, so the last segment is
already the bare filename; legacy pre-migration keys and the current
stagingKey both join the random segment and filename with a dash in
one segment. Strip a canonical-UUID-shaped prefix for the legacy case,
and a fixed STAGING_KEY_RANDOM_SEGMENT_LENGTH-char prefix only when the
key path is staging-shaped, since the nanoid alphabet includes '-' and
an ordinary filename could otherwise be mistaken for a random segment.
The length is now a named constant shared with stagingKey instead of a
magic number.

Also drop the stale 'Tag UUID or case-insensitive name' description in
tag.schemas.ts; the param is a NanoID now.
Replace the UUID/hex id regexes in the e2e timing collector with a
matcher anchored to the 21-char nanoid alphabet, plus the legacy
dashed 36-char format for older captured reports, so requests to
routes like /v1/models/:id collapse correctly again instead of
fragmenting the perf report. Also swap the nil UUID literal in the
user profile auth feature for a nanoid-shaped placeholder so that
scenario still exercises 401 instead of tripping the id-format 400.
Add the id convention to backend AGENTS.md/CLAUDE.md and record the
NanoID decision plus its two deliberate UUID exceptions in
apps/modeling-commons-backend/.claude/DECISIONS.md. Update the six
plan docs still describing UUID ids as the convention, fixing the
runtime-breaking ::uuid cast in the search spec and rewriting the
discussion-archive importer's id-mapping algorithm for NanoID.
The rehearsal dump's uuid regex only matched the old 36-char shape, so
it silently stopped normalising the nanoid(21) and nanoid(10) segments
that storage keys now contain, making two dumps of identical data diff
on every key.

Extract the normalisation into lib/normalize-key.ts so it covers all
three shapes that can appear in a key: bare nanoid(21)/nanoid(10) path
segments, the nanoid(10)-filename fused segment from stagingKey, and
the hex-hyphenated shape shared by legacy pre-migration rows and the
frozen storagePathHash output. Matches are anchored to segment
boundaries and exact lengths so real path components and filenames are
never mistaken for an id.
…ation

These names and comments claimed UUIDs that no longer exist now that
row ids are NanoIDs: modelUuid, fileUuid, ownerUuid, tagUuid, userUuid
and related test fixtures. Mechanical rename only, no behaviour change.
storagePathHash and its pinned-output test are untouched.
samePreviewObject stripped a 36-char uuid segment to ignore the random
nonce when deciding whether a resync's preview already matches the
database. Since createModelFromNode now mints a nanoid(21) nonce while
the resync still derives the frozen storagePathHash (36 chars), the
strip only removed one of the two, so every previously-imported model
compared as changed on its first resync even when nothing changed.

Both buildPreviewFileKey and the live app's createStorageKey share the
same tail convention, .../{y}/{m}/{d}/{nonce}/{filename}, so the nonce
is reliably the second-to-last path segment regardless of what shape
it is. Compare by that position instead of by shape, and move the
function into lib/preview-key.ts so it is unit tested.
The plugins autoload had no filter, so a spec file placed beside its subject
was imported as a Fastify plugin and ran describe() outside a test runner,
killing the app at boot. The routes autoload already filters this way.
Fastify compiles route schemas with an ajv instance it builds itself, so the
format registered on the exported ajv never reached route validation and every
route carrying an idSchema failed to build. Both server builders now share one
registration function.
The support glob swept in any .ts under tests/support, so a spec beside its
subject ran describe() outside a test runner and aborted the run before any
scenario started. Verified with --dry-run: 158 scenarios, no undefined steps.
The fused-prefix strip fired on any ten characters before a dash, so
wolf-sheep-predation.nlogo normalised to <id>-predation.nlogo and two
different objects could compare identical in a rehearsal dump. Bare ids now
require a following slash, and the fused strip is gated on a staging path.
Both producers of the fused staging key must agree with the reader that
strips it; one still had a bare literal.
@omargfh
omargfh marked this pull request as ready for review August 13, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant