Skip to content

Integration/promote to prod - #62

Merged
omargfh merged 68 commits into
app/modeling-commonsfrom
integration/promote-to-prod
Aug 18, 2026
Merged

Integration/promote to prod#62
omargfh merged 68 commits into
app/modeling-commonsfrom
integration/promote-to-prod

Conversation

@omargfh

@omargfh omargfh commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Closes #53 #54 #55 #59 #61.

omargfh added 30 commits August 10, 2026 13:49
- Move file-key building, .nlogo parsing, legacy row access, dedupe and the
  per-node migration routine out of archive.ts so patch.ts can reuse them.
- Cover the extracted code with unit tests, including the exact write sequence
  createModelFromNode emits against a recording transaction.
- Fix parseNlogox: it called DOMParser, which Node does not have, so every
  .nlogox model silently stored a null netlogoVersion and infoTab. Replaced
  with a dependency-free parser. Already-migrated rows still hold nulls.
- Pin dob to UTC midnight. pg reads a `date` as local midnight while Prisma
  stores @db.Date from the UTC day, so a birthdate never compared equal to
  itself and shifted by a day in positive UTC offsets.
- Replace the shell-based avatar copy with fs.copyFile; it interpolated a
  legacy filename into a command string.
- Type-check prisma/lib and the migration scripts.
archive.ts only skips rows that already carry a legacyId, so it cannot see
edits, deletions or new children of an already-migrated node. patch.ts applies
those from a diffdb.sh diff.

- Materialises the whole change set as a plan before writing, so the dry run
  and the real run compute the same thing. Applying replays it in one
  transaction and writes an expectations manifest for --verify-only.
- Trusts only `side` and `id` from the diff and re-reads rows from the legacy
  snapshot, since diffdb.sh md5s the `contents` columns. Deleted rows are the
  exception; their row_to_json is the only surviving copy.
- Addresses everything by legacyId. No existing row's id column is touched.
- Recomputes previews from the whole attachment set rather than patching them:
  a preview is one column on the latest version and the highest legacy
  attachment id wins it.
- Moves tags and the preview onto an appended version, matching archive.ts,
  which keeps them on the latest version only.
- Leaves updatedAt alone; Prisma's @updatedat overrode it on create, so no
  migrated row ever held the legacy value.
- Refuses to guess on mid-sequence version deletes, out-of-order appends and
  in-place attachment edits, none of which can be mapped back safely.
Builds a legacy database with a migrated baseline and an incoming schema
carrying sixteen change scenarios, archives the baseline, diffs, patches, then
archives the incoming snapshot into a second database and compares canonical
uuid-free dumps of both.

Asserts that archive(baseline) + patch(diff) equals archive(new snapshot),
with the soft-deleted model checked separately since a fresh archive never
sees it.
A node that gained a version and lost its last preview attachment in the same
diff kept the old preview: the resync compared against the database row for the
appended version, which does not exist yet, read null, and concluded a cleared
preview was already applied. The append then carried the dead key forward.

Compare against what the append will carry instead, and clear that carry when a
resync supersedes it. Covered by a rehearsal scenario; the script's own verify
passed this because the manifest was built from the same wrong plan.
From a review pass over the patch script.

- Derive a version's object key from its legacy id instead of a fresh uuid, and
  diff the row before writing. A modified version previously uploaded a
  duplicate object and repointed netlogoFileKey on every run, so re-applying
  the same diff was not the no-op it was documented to be.
- Refuse an ambiguous ModelAdditionalFile match rather than deleting whichever
  row came back first. Two attachments on one model sharing a filename, or a
  null created_at, could otherwise delete the wrong file irrecoverably.
- Drop a tag from the resolution map once it is scheduled for deletion, so a
  tagging in the same diff is not created and then cascade-deleted.
- Assert the appended version's preview in the manifest; only carried tags were
  covered, so a wrong carried preview could pass verification.
- Strip every uuid segment when comparing preview keys, not just the first,
  which was the model uuid and identical on both sides.

Rehearsal now covers an in-place version edit.
…gacy-migration

- rename archive.ts, patch.ts and archive-upload.ts to initial-import.ts,
  apply-diff.ts and upload-files.ts, naming the job rather than the source
- move the staged output and avatar snapshot under the same directory
- run db:legacy:patch through tsx directly; wrapping it in `sh -c` swallowed
  the --apply / --verify-only / --skip-upload flags it reads from argv
- ModelAuthor.collaboratorType carries legacy collaborator_types.name
- NonMemberContributor archives credited people who never held an account

Both are provenance only; no business logic reads either.
initial-import.ts derived ModelAuthor from version history alone, so a
collaborator who never uploaded a version had no row at all.

- members fold into ModelAuthor as role=contributor, which grants write
  access since canWrite treats any contributor as a writer
- non-members are archived in NonMemberContributor, unread by the app
- idempotent: keyed on the existing (modelId, userId) rows and on
  NonMemberContributor.legacyId, so a second run reports nothing to do
migrateInteractions tracked its per-table count locally and logged it, but
never wrote it back to the report, so report.json always claimed zero. The
docs took that at face value: the phase writes ~6.2M rows on the production
snapshot.

Also records the full-scale rehearsal and the recompute step, which nothing
referenced even though the import leaves the denormalized Model counters at 0.
sessions (1.3M rows) and ip_locations (120k) map to nothing in the new schema,
so apply-diff.ts always discarded their diffs. Scanning them was most of the
script's runtime. Output is byte-identical without them.
- Show a one-time modal on login and sign-up pointing pre-migration
  modelingcommons.org users at password reset
- Suppress it for logged-in users, after dismissal, and past the
  sunset date constant
- Extract getResetPasswordLink so the modal and ReclaimAccount share
  one link builder
- Primary action reads Next and advances until the last step, where
  it publishes
- Move Revert changes to an mdi icon button in the editor header
- Keep edit mode single-shot: its steps are freely navigable sections,
  not a gated wizard
- Drop the assumption that the visitor has an old account
- Name the emailed link instead of telling them to reset a password
- Label the actions Reclaim account and I didn't have an old account
- Add a note for visitors who do not remember having an account
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.
omargfh added 29 commits August 13, 2026 09:43
- Drop and recreate foreign keys around the swap rather than relying on
  ON UPDATE CASCADE, whose firing order is undefined for Model.id
- Resolve id-bearing columns from the catalog to a fixpoint, so columns
  that reach a mapped table indirectly are not missed
- Rewrite soft references by map membership, never by shape, leaving the
  frozen storagePathHash segments intact
- Purge unpublished drafts and their uploads instead of migrating them
- ":memory:" was never in-memory: Nuxt Content rewrites it to
  `{ name: "memory" }` and db0 only takes its in-memory branch when the
  name is still ":memory:", so it fell back to a file under the process
  CWD and every content query threw EACCES in the image
- /tmp is writable both in the container and on a machine running the
  e2e suite in server mode, and matches Nuxt Content's own presets
The schema added ModelVersion.changeSummary but nothing in src ever
referenced it, so every hand-built ModelVersionEntity was missing a
required field and check-types failed across five files.
The tag search sent an `offset` query param, but the API only accepts
`limit` and `page`. Fastify dropped the unknown param and defaulted page
to 0, so every "load more" refetched the first 20 tags and the tag select
menu could never reach the rest.
Lists tags by popularity with their model-version counts, and switches to
prefix search while the search box has a value. Both listings page in on
scroll. Prefix search carries no counts, so TagCard now takes an optional
description.
The home page issued six per-request API calls, all of them public and
identical for every visitor. They now come from a single `/_data/home`
handler cached for ten minutes with stale-while-revalidate, so the page
costs one internal call and the backend sees one set of queries per TTL
rather than one per visit.

Nitro's cached handler strips every header but the declared `varies`
list, so the shared entry is built without a session and cannot leak one.
Recents went stale under the shared feed's 45 minute TTL. They now come
from their own `/_data/home-recent` handler cached for a minute, loaded
lazily so they never block the sections around them, and rendered with
skeletons until they land.

The Trending Tags sidebar was anchored to the section's array index,
which no longer holds once recents resolve on their own, so it keys off
the section instead. Recents are also kept out of the marquee, which
would otherwise reshuffle every column when they arrive.
Which section loads separately was re-derived in three places by comparing
against the recent section's key, and the feed's section list was hand
maintained alongside the render order. A section now declares `deferred`,
the feed list is derived from it, and a test asserts every rendered
section has exactly one fetcher.
On a checkout with no .nuxt, `nuxt build` exits 0 but emits a
client.manifest chunk containing `default: default`, which is a syntax
error. Nitro loads that chunk lazily, so the server starts, /health and
every server route answer 200, and only page renders fail. `nuxt:generate`
already guarded against this; `nuxt:build` did not.
Runbook, decisions, refusal cases and known divergences from a from-scratch
archive.
The macOS periodic cleaner deletes files under /tmp by access time,
which silently emptied template0/template1 and removed PG_VERSION and
every .conf from the postgres cluster while leaving directories behind.
Point the bind mounts at XDG_DATA_HOME instead.
Enumerates every row through Prisma and asserts the API endpoints, stored
objects and legacy URLs each row implies. Route coverage is derived from the
OpenAPI document, so a GET route that is neither expanded nor listed in SKIP
aborts the run.

Expected status is carried per row rather than assumed to be 200, so a private
model answering anything but 403 and a soft-deleted one answering anything but
404 are both failures.
…n CI

The Dockerfile ARG defaults are what ships when a build forgets to pass the
GitHub environment variables, so leaving them on the beta host meant a silent
wrong-host image. The CI step fails the build if the host reappears anywhere
outside doc/ and .ongoing/.
The paginated response field is 'count' (shared/api/paginated.response.base.ts).
Reading 'total' yielded undefined, so every walk collapsed to page 1 and the
sweep never requested a second page.
Scopes the truncating Before hook to 'not @data-integrity' so cleanDatabase is
unreachable from the tagged path, and adds a dataIntegrity profile that runs
read-only invariant checks against any populated environment.

A row-count snapshot around the run proves at runtime that the cohort writes
nothing, and an empty database fails loudly rather than passing vacuously.
Wires verify-inputs, restore, migrate, diff, patch, collaborators, recompute,
id-migration, verify and dump into one checkpointed entry point. A failed step
names itself and leaves no checkpoint, so a bare re-run resumes there.

Loading the beta snapshot over the target is opt-in behind --restore-target and
requires --yes. On cutover the target is the production beta database, where
restoring a snapshot would discard every write since it was taken.
Sourcing .env overwrote already-exported values, so an operator who exported a
production DATABASE_URL would have it silently replaced by the development one.
In testing this retargeted a --restore-target run onto the local dev database.
Only unset names are filled in now.

Also adds --page-size to the sweep so the multi-page walk is exercisable
against collections smaller than one page.
GET /v1/users/:id/models returned every authorship row for a user with no
visibility or deletion filter, so an anonymous caller learned the ids of that
user's private and soft-deleted models.

Adds readableModelFilter, canRead expressed as a Prisma where clause, and
applies it to both the page and the count. A listing cannot call the policy per
row because the paginated count has to reflect the same predicate as the page.
The filter shares viewerIsActive and viewerIsGlobalAdmin with the policy rather
than restating them, and a spec pins the two together across every viewer,
visibility, role, grant and deletion state.
buildMap emitted one INSERT for the whole map and spread its parameters into
the call. The production map holds 6190632 rows, so that is 18.5 million spread
arguments: it dies with RangeError: Maximum call stack size exceeded, and would
have exceeded the 65535 bind-parameter limit had it got that far.

Only the persist path was affected, so --dry-run reported success immediately
before --yes crashed, and no development-sized database reaches either limit.

Statements are now capped at 5000 rows. Placeholder numbering restarts per
statement, which the spec pins: carrying a running index across chunks fails
only on datasets large enough to need a second statement.
The production map serialises to 415MB against a V8 maximum string length near
512MB, so JSON.stringify was running at ~80% of a hard ceiling and the cutover
uses a larger, fresher dump.

Exceeding it throws RangeError: Invalid string length from writeMapFile, which
runs after the id swap has committed and after superseded storage objects have
been deleted, leaving a migrated database with no checkpoint and no map.

Output is byte-identical to the previous format; the spec pins it to
JSON.stringify so the artifact a human opens is unchanged.
@omargfh
omargfh merged commit 772a95e into app/modeling-commons Aug 18, 2026
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