Skip to content

fix(tailordb): apply per-migration schema from migration's own snapshot#1207

Draft
toiroakr wants to merge 6 commits into
refactor/deploy-via-snapshotfrom
fix/migration-loop
Draft

fix(tailordb): apply per-migration schema from migration's own snapshot#1207
toiroakr wants to merge 6 commits into
refactor/deploy-via-snapshotfrom
fix/migration-loop

Conversation

@toiroakr
Copy link
Copy Markdown
Contributor

@toiroakr toiroakr commented May 20, 2026

Summary

Stacked on #1184 (refactor/deploy-via-snapshot). Do not merge until #1184 lands; re-target this PR to main after that.

executeSingleMigration{Pre,Post}Phase sent changeSet.type.updates[].request verbatim for any affected type. The changeSet is built from the local source-of-truth (post-all-migrations), so a field that LATER migrations remove was already missing from the request, causing the removal to silently happen during an earlier migration's pre-phase. An intermediate migration's data script reading such a field then failed at runtime with field 'X' not found.

This PR submits the intermediate schema state reconstructed up to migration N (initial 0000/schema.json baseline + diffs through N) via a cached buildSnapshotTypeManifest helper, instead of the FINAL request. Only the initial baseline is stored on disk; later migrations ship diff.json only, so we replay them with the existing reconstructSnapshotFromMigrations helper. The behavior now matches services/tailordb-migration.md §"Per-migration phases", which guarantees field/type deletions happen only in the owning migration's post-phase.

  • index.tsexecuteSingleMigration{Pre,Post}Phase now builds requests from snapshot[N] via buildSnapshotTypeManifest; migrationSnapshotCache derives snapshot[N] by replaying diffs from the initial baseline; planTypes now receives executorUsedTypes so the snapshot manifest can default publishRecordEvents correctly.
  • migration-bulk-schema-bug.test.ts — cherry-picked repro from the bug reporter; extended with reconstructSnapshotFromMigrations fixtures and executorUsedTypes context to exercise the snapshot pipeline.
  • deploy.test.ts — added executorUsedTypes to the mock context to match the new shape.

aganesy and others added 3 commits May 21, 2026 00:04
executeSingleMigrationPrePhase (deploy/tailordb/index.ts) sends
changeSet.type.updates[].request verbatim for any type that is
"affected" by the current migration. Because the changeSet is built
from the local source-of-truth types (post-all-migrations), any field
that LATER migrations remove from the same type is already missing
from the request, so the removal silently happens during an earlier
migration's prePhase.

This contradicts docs/services/tailordb-migration.md §"Per-migration
phases", which guarantees that field/type deletions happen in the
owning migration's postPhase only.

Symptom in real deploys: a data-migration script in an intermediate
migration that reads a field removed by a later migration fails with
`field 'X' not found`, because the field was dropped by the very first
prePhase that touched the affected type.

This commit adds a failing unit test that:
  1. Stages two pending migrations on the same type User
     - #1 adds User.permissions
     - #5 removes User.roles
  2. Asserts that during #1's prePhase, the request sent to the platform
     still contains the roles field (per docs).
  3. Currently fails: the request only contains [id, name, permissions]
     because roles was already dropped by the local source-of-truth.

Run:
  pnpm --filter @tailor-platform/sdk exec vitest run \
    src/cli/commands/deploy/tailordb/migration-bulk-schema-bug.test.ts

Expected output: 1 failed, 1 passed.

Note: lefthook hooks are skipped because (1) example#generate fails on
this Windows clone due to pnpm bin symlink issues unrelated to this
change, and (2) the post-commit GPG signature requirement is not set
up on this machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
executeSingleMigrationPrePhase/PostPhase sent the FINAL changeSet
request for any affected type, so a field that later migrations
remove was already missing during an earlier migration's phases. An
intermediate migration's data script reading such a field then failed
at runtime with `field 'X' not found`.

Load each migration's own schema.json snapshot via a cached
buildSnapshotTypeManifest helper and submit that intermediate shape
instead of the FINAL request. Aligns with services/tailordb-migration.md
§"Per-migration phases", which guarantees field/type deletions happen
only in the owning migration's postPhase.

The repro test cherry-picked from the bug reporter now passes.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 20, 2026

🦋 Changeset detected

Latest commit: e697f2c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@tailor-platform/sdk Patch
@tailor-platform/create-sdk Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 20, 2026

⚡ pkg.pr.new

@tailor-platform/sdk

pnpm add https://pkg.pr.new/@tailor-platform/sdk@e697f2c
pnpm dlx https://pkg.pr.new/@tailor-platform/sdk@e697f2c --help

@tailor-platform/create-sdk

pnpm add https://pkg.pr.new/@tailor-platform/create-sdk@e697f2c
pnpm dlx https://pkg.pr.new/@tailor-platform/create-sdk@e697f2c my-app

commit: e697f2c

Per-migration pre/post phase was loading <N>/schema.json directly, but
the migrate generator only writes the initial 0000/schema.json plus
per-migration diff.json. Every real deploy (and CI e2e) therefore hit
ENOENT on migrations/0001/schema.json.

Use the existing reconstructSnapshotFromMigrations(migrationsDir, N)
helper to derive the state AFTER migration N by replaying the initial
baseline through all diffs up to N. Cache the result per (namespace, N)
for the apply run, as before.

Update the bulk-schema-bug reproduction test mock from loadSnapshot to
reconstructSnapshotFromMigrations to match.

This comment was marked as outdated.

toiroakr added 2 commits May 21, 2026 01:19
Drop the long bug-narrative header and the inline FAILS/'current
implementation' wording. The test now asserts the spec directly without
referring to the pre-fix state.
Trim background-story JSDoc and inline comments left over from the bug
investigation: shorten buildSnapshotTypeManifest's doc to its contract,
drop the "future migrations' removals" inline narration, and remove
restated-assertion comments and trivial fixture descriptions in the
migration-bulk-schema-bug test.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment on lines +745 to +797
/**
* Snapshot cache for per-migration schema lookups during a single apply run.
*
* Only the initial baseline `0000/schema.json` is stored on disk; later migrations
* ship `diff.json` only. To get the schema state AFTER migration N we replay the
* initial snapshot through all diffs up to N via `reconstructSnapshotFromMigrations`.
* Results are memoized per (namespace, migration number) for the apply run.
*/
const migrationSnapshotCache = {
cache: new Map<string, SchemaSnapshot>(),
reset() {
this.cache.clear();
},
load(migration: PendingMigration): SchemaSnapshot {
const key = `${migration.namespace}/${migration.number}`;
let snapshot = this.cache.get(key);
if (!snapshot) {
const reconstructed = reconstructSnapshotFromMigrations(
migration.migrationsDir,
migration.number,
);
if (!reconstructed) {
throw new Error(
`Cannot reconstruct snapshot for ${migration.namespace} migration ${migration.number}: no migrations found in ${migration.migrationsDir}`,
);
}
snapshot = reconstructed;
this.cache.set(key, snapshot);
}
return snapshot;
},
};

/**
* Build the TailorDBType manifest for `typeName` from migration N's snapshot.
* @param migration - The pending migration whose snapshot to consult
* @param typeName - The type name to look up in the snapshot
* @param tailorDBInputs - Deploy inputs, used to resolve namespace gqlOperations
* @param executorUsedTypes - Types used by executors (drives publishRecordEvents default)
* @returns The manifest, or undefined if `typeName` is not in that snapshot.
*/
function buildSnapshotTypeManifest(
migration: PendingMigration,
typeName: string,
tailorDBInputs: ReadonlyArray<TailorDBDeployInput>,
executorUsedTypes: ReadonlySet<string>,
): MessageInitShape<typeof TailorDBTypeSchema> | undefined {
const snapshot = migrationSnapshotCache.load(migration);
const snapshotType = snapshot.types[typeName];
if (!snapshotType) return undefined;
const input = tailorDBInputs.find((i) => i.namespace === migration.namespace);
return generateTailorDBTypeManifest(snapshotType, executorUsedTypes, input?.config.gqlOperations);
}

expect(fieldNames).toContain("permissions");
expect(fieldNames).toContain("name");
expect(fieldNames).toContain("roles");
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.

3 participants