@substrat-run/contract-tests 0.148.0 → 0.150.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/counter-runner.d.ts +3 -3
  2. package/dist/counter-runner.js +4 -4
  3. package/dist/counter-runner.js.map +1 -1
  4. package/dist/directory-restore-suite.d.ts.map +1 -1
  5. package/dist/directory-restore-suite.js +66 -2
  6. package/dist/directory-restore-suite.js.map +1 -1
  7. package/dist/directory-shapes.d.ts +6 -0
  8. package/dist/directory-shapes.d.ts.map +1 -1
  9. package/dist/directory-shapes.js +42 -0
  10. package/dist/directory-shapes.js.map +1 -1
  11. package/dist/entity-state-module.d.ts.map +1 -1
  12. package/dist/entity-state-module.js +3 -0
  13. package/dist/entity-state-module.js.map +1 -1
  14. package/dist/entity-trash-suite.d.ts.map +1 -1
  15. package/dist/entity-trash-suite.js +20 -0
  16. package/dist/entity-trash-suite.js.map +1 -1
  17. package/dist/held-migrations.d.ts +27 -0
  18. package/dist/held-migrations.d.ts.map +1 -0
  19. package/dist/held-migrations.js +39 -0
  20. package/dist/held-migrations.js.map +1 -0
  21. package/dist/index.d.ts +8 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +9 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/migration-fence-suite.d.ts +3 -0
  26. package/dist/migration-fence-suite.d.ts.map +1 -0
  27. package/dist/migration-fence-suite.js +173 -0
  28. package/dist/migration-fence-suite.js.map +1 -0
  29. package/dist/migration-fence.d.ts +42 -0
  30. package/dist/migration-fence.d.ts.map +1 -0
  31. package/dist/migration-fence.js +66 -0
  32. package/dist/migration-fence.js.map +1 -0
  33. package/dist/modules.d.ts +2 -0
  34. package/dist/modules.d.ts.map +1 -1
  35. package/dist/modules.js +19 -0
  36. package/dist/modules.js.map +1 -1
  37. package/dist/orphan-sweep-suite.d.ts +28 -0
  38. package/dist/orphan-sweep-suite.d.ts.map +1 -0
  39. package/dist/orphan-sweep-suite.js +547 -0
  40. package/dist/orphan-sweep-suite.js.map +1 -0
  41. package/dist/scope-host-suite.d.ts.map +1 -1
  42. package/dist/scope-host-suite.js +357 -20
  43. package/dist/scope-host-suite.js.map +1 -1
  44. package/dist/scope-store-runner-suite.d.ts +7 -7
  45. package/dist/scope-store-runner-suite.d.ts.map +1 -1
  46. package/dist/scope-store-runner-suite.js +7 -7
  47. package/dist/scope-store-runner-suite.js.map +1 -1
  48. package/dist/scope-store-suite.js +11 -11
  49. package/dist/scope-store-suite.js.map +1 -1
  50. package/dist/store-driver-release-suite.d.ts +12 -0
  51. package/dist/store-driver-release-suite.d.ts.map +1 -0
  52. package/dist/store-driver-release-suite.js +561 -0
  53. package/dist/store-driver-release-suite.js.map +1 -0
  54. package/dist/upload-link-module.d.ts +4 -0
  55. package/dist/upload-link-module.d.ts.map +1 -1
  56. package/dist/upload-link-module.js +66 -4
  57. package/dist/upload-link-module.js.map +1 -1
  58. package/dist/upload-link-replay-suite.d.ts +3 -0
  59. package/dist/upload-link-replay-suite.d.ts.map +1 -0
  60. package/dist/upload-link-replay-suite.js +293 -0
  61. package/dist/upload-link-replay-suite.js.map +1 -0
  62. package/dist/upload-link-suite.d.ts.map +1 -1
  63. package/dist/upload-link-suite.js +161 -4
  64. package/dist/upload-link-suite.js.map +1 -1
  65. package/dist/vertical-events-suite.d.ts.map +1 -1
  66. package/dist/vertical-events-suite.js +46 -4
  67. package/dist/vertical-events-suite.js.map +1 -1
  68. package/package.json +3 -3
  69. package/dist/store-kind-release-suite.d.ts +0 -12
  70. package/dist/store-kind-release-suite.d.ts.map +0 -1
  71. package/dist/store-kind-release-suite.js +0 -353
  72. package/dist/store-kind-release-suite.js.map +0 -1
@@ -1,9 +1,10 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
- import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
- import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
3
+ import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, unheldMigrationDigest, } from '@substrat-run/contracts';
4
+ import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, promoteReviewDigest, promoteReviewRefusalOf, rebindReviewRefusalOf, createUlid, ulid, } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
6
6
  import { testOperations } from './test-operations.js';
7
+ import { derivedDigestOf, heldMigrations } from './held-migrations.js';
7
8
  /**
8
9
  * A journal read whose every row is whole. A test that planted nothing has no business meeting
9
10
  * an `UndecodablePlatformRequest` (#1637), so one fails here, naming why, rather than being
@@ -3853,7 +3854,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3853
3854
  version,
3854
3855
  manifestDigest: `man-${version}`,
3855
3856
  permissionDigest: 'p',
3856
- migrationDigest: mig,
3857
+ ...(await heldMigrations(mig)),
3857
3858
  deploymentRef: null,
3858
3859
  });
3859
3860
  await host.admin.admitVersion(staff, id);
@@ -5299,7 +5300,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5299
5300
  expect(await host.admin.scopeErasureEpoch(staff, t1, s)).toBe(1);
5300
5301
  });
5301
5302
  // #2286, L17: what the lifecycle reaches is every binding any version declared.
5302
- it('keeps every scope-store binding a version declared, refuses a re-kind at publish, and names none it cannot read', async () => {
5303
+ it('keeps every scope-store binding a version declared, refuses a driver change at publish, and names none it cannot read', async () => {
5303
5304
  const slug = `bind-${ulid().toLowerCase().slice(-8)}`;
5304
5305
  await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
5305
5306
  const publish = (scopeStores, raw) => {
@@ -5320,22 +5321,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5320
5321
  }),
5321
5322
  }).then(() => id);
5322
5323
  };
5323
- await publish([{ binding: 'GRAPH', kind: 'graph' }, { binding: 'OLD', kind: 'graph' }]);
5324
- await publish([{ binding: 'GRAPH', kind: 'graph' }]);
5324
+ await publish([{ binding: 'GRAPH', driver: 'graph' }, { binding: 'OLD', driver: 'graph' }]);
5325
+ await publish([{ binding: 'GRAPH', driver: 'graph' }]);
5325
5326
  // A dropped binding is still there.
5326
5327
  expect(await host.admin.verticalScopeStores(staff, slug)).toEqual([
5327
- { binding: 'GRAPH', kind: 'graph' },
5328
- { binding: 'OLD', kind: 'graph' },
5328
+ { binding: 'GRAPH', driver: 'graph' },
5329
+ { binding: 'OLD', driver: 'graph' },
5329
5330
  ]);
5330
- // A re-kind is refused, and the version is not stored.
5331
+ // A driver change is refused, and the version is not stored.
5331
5332
  const before = (await host.admin.listVersions(staff, slug)).length;
5332
- await expectRefusal(publish([{ binding: 'NEW', kind: 'graph' }, { binding: 'OLD', kind: 'counter' }]), 'conflict');
5333
+ await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'OLD', driver: 'counter' }]), 'conflict');
5333
5334
  expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
5334
5335
  // So is a version that declares one binding twice.
5335
- await expectRefusal(publish([{ binding: 'NEW', kind: 'graph' }, { binding: 'NEW', kind: 'graph' }]), 'conflict');
5336
+ await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]), 'conflict');
5336
5337
  expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
5337
- // The twin: the same kind again, with a new binding beside it, is published.
5338
- await publish([{ binding: 'OLD', kind: 'graph' }, { binding: 'NEW', kind: 'graph' }]);
5338
+ // The twin: the same driver again, with a new binding beside it, is published.
5339
+ await publish([{ binding: 'OLD', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]);
5339
5340
  expect((await host.admin.verticalScopeStores(staff, slug)).map((s) => s.binding)).toEqual(['GRAPH', 'NEW', 'OLD']);
5340
5341
  // A version whose manifest cannot be read may declare a store nobody can name.
5341
5342
  await publish(undefined, '{nope');
@@ -5851,7 +5852,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5851
5852
  const id = ulid();
5852
5853
  await host.admin.publishVersion(staff, {
5853
5854
  id, verticalSlug: 'migrationsapart', version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm',
5854
- permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null,
5855
+ permissionDigest: 'p', deploymentRef: null,
5856
+ // #2339: a set it carries is hashed as the host derives it; the others store none to check.
5857
+ migrationDigest: typeof manifest === 'string' || !Array.isArray(manifest.migrations) ? 'g' : await derivedDigestOf(manifest),
5855
5858
  manifestJson: typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
5856
5859
  });
5857
5860
  return id;
@@ -5885,6 +5888,60 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5885
5888
  const junk = await publish('not json');
5886
5889
  expect(await host.admin.versionManifest(staff, 'migrationsapart', junk)).toBe('not json');
5887
5890
  expect(await host.admin.versionMigrations(staff, 'migrationsapart', junk)).toBeNull();
5891
+ // #2322: each migration's declared moves and discards are stored beside its SQL and read
5892
+ // back as published, whatever digest the publisher claimed; one declaring none has neither.
5893
+ const declared = [
5894
+ { ...migrations[0], moves: { legacy_a: 'a' } },
5895
+ { ...migrations[1] },
5896
+ { ...migrations[2], discards: { 'b.c': 'replaced by d; see "0003"' } },
5897
+ ];
5898
+ const withDeclarations = await publish({ ...base, migrations: declared });
5899
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', withDeclarations)).toEqual(declared);
5900
+ // A declaration the push boundary refuses (a discard with no reason) carries no SQL at all.
5901
+ const reasonless = await publish({ ...base, migrations: [{ ...migrations[0], discards: { a: '' } }] });
5902
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', reasonless)).toBeNull();
5903
+ });
5904
+ it("derives a version's migration digest from what it stores, and refuses a publish whose digest is not that (#2339)", async () => {
5905
+ const slug = `acme/digest-${ulid().slice(-8).toLowerCase()}`;
5906
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
5907
+ const A = [{ moduleId: 'helpdesk', version: '0001-init', sql: 'CREATE TABLE a (id TEXT);' }];
5908
+ const B = [...A, { moduleId: 'helpdesk', version: '0002-drop', sql: 'DROP TABLE a;' }];
5909
+ const declaredA = [{ ...A[0], discards: { 'a.id': 'nothing reads it' } }];
5910
+ const publish = async (migrationDigest, migrations, doClasses) => {
5911
+ const id = ulid();
5912
+ await host.admin.publishVersion(staff, {
5913
+ id, verticalSlug: slug, version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
5914
+ migrationDigest, deploymentRef: null,
5915
+ ...(migrations ? { manifestJson: JSON.stringify({ migrations, ...(doClasses ? { doClasses } : {}) }) } : {}),
5916
+ });
5917
+ return id;
5918
+ };
5919
+ const digestOfA = await derivedDigestOf({ migrations: A });
5920
+ const a = await publish(digestOfA, A);
5921
+ expect((await host.admin.getVersion(staff, a, slug))?.migrationDigest).toBe(digestOfA);
5922
+ // The base's digest over other SQL, over the same SQL with a declared loss added, or over other
5923
+ // store classes: refused, and nothing is stored.
5924
+ for (const [migrations, doClasses] of [[B, undefined], [declaredA, undefined], [A, ['OtherDO']]]) {
5925
+ const before = (await host.admin.listVersions(staff, slug)).length;
5926
+ await expectRefusal(publish(digestOfA, [...migrations], doClasses ? [...doClasses] : undefined), 'validation_failed', /is not the digest of the migrations it carries/);
5927
+ expect(await host.admin.listVersions(staff, slug)).toHaveLength(before);
5928
+ }
5929
+ // Its positive twin: the digest B's own migrations give is stored, and a promote over A asks.
5930
+ const b = await publish(await derivedDigestOf({ migrations: B }), B);
5931
+ await host.admin.admitVersion(staff, a);
5932
+ await host.admin.admitVersion(staff, b);
5933
+ await host.admin.promoteVersion(staff, slug, 'prod', a);
5934
+ await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', b), 'conflict', /changes migrations/);
5935
+ // A version that stores none cannot be checked: never refused, and it equals no other version.
5936
+ const bare = await publish(digestOfA);
5937
+ expect((await host.admin.getVersion(staff, bare, slug))?.migrationDigest).toBe(unheldMigrationDigest(bare));
5938
+ // The admin log records the digest the registry holds, not the one the push sent; so does a held one's.
5939
+ const published = async (id) => (await host.admin.auditLog(staff, {})).find((e) => e.action === 'publishVersion' && e.after?.id === id)
5940
+ ?.after?.migrationDigest;
5941
+ expect(await published(bare)).toBe(unheldMigrationDigest(bare));
5942
+ expect(await published(a)).toBe(digestOfA);
5943
+ await host.admin.admitVersion(staff, bare);
5944
+ await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', bare), 'conflict', /holds no migrations the platform can compare/);
5888
5945
  });
5889
5946
  it('an archived scope blocks the delete naming the reap step; a reaped tombstone never blocks', async () => {
5890
5947
  // A deleted app leaves an `archived` row (restorable via unarchive), then a
@@ -5992,12 +6049,24 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5992
6049
  version,
5993
6050
  manifestDigest: `man-${version}`,
5994
6051
  permissionDigest: digests.perm,
5995
- migrationDigest: digests.mig,
6052
+ ...(await heldMigrations(digests.mig)),
5996
6053
  deploymentRef: null,
5997
6054
  });
5998
6055
  await host.admin.admitVersion(staff, id);
5999
6056
  return id;
6000
6057
  };
6058
+ /**
6059
+ * #2333: the binding a review hands out, computed as the migrations read computes it — from
6060
+ * the stored rows of the version prod points at now and of the one being promoted.
6061
+ */
6062
+ const reviewOf = async (slug, versionId, base) => {
6063
+ const prod = base !== undefined ? base : ((await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId ?? null);
6064
+ const side = async (id) => ({
6065
+ ...(await host.admin.getVersion(staff, id, slug)),
6066
+ migrations: await host.admin.versionMigrations(staff, slug, id),
6067
+ });
6068
+ return { base: prod, digest: await promoteReviewDigest(prod === null ? null : await side(prod), await side(versionId)) };
6069
+ };
6001
6070
  it('promotes a first version with nothing to acknowledge', async () => {
6002
6071
  // The gate is about CHANGE, not existence. A first promotion has no
6003
6072
  // predecessor to diff against, so demanding an acknowledgement would be
@@ -6020,7 +6089,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6020
6089
  it('refuses a promotion that changes migrations until it is acknowledged', async () => {
6021
6090
  const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
6022
6091
  await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3), 'conflict', /changes migrations/);
6023
- await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
6092
+ // #2333: the acknowledgement that lets a migration reach live data names the review it answers.
6093
+ await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true }), 'precondition_failed', /needs the review it answers/);
6094
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true, review: await reviewOf('callout', v3) });
6024
6095
  expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
6025
6096
  ?.versionId).toBe(v3);
6026
6097
  });
@@ -6057,9 +6128,259 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6057
6128
  await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
6058
6129
  // Positive twin: through its own vertical the same version promotes (the earlier tests
6059
6130
  // moved prod, so the digest checkpoints are acknowledged — they are not under test here).
6060
- await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
6131
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true, review: await reviewOf('callout', v) });
6061
6132
  expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
6062
6133
  });
6134
+ // -- the acknowledgement names the review it answers (#2333) ---------------
6135
+ // A review is computed over the channel's CURRENT version. A promote between showing it and
6136
+ // acknowledging it moves that base, and a bare flag would then acknowledge a diff that is not
6137
+ // the one applied. So the acknowledgement carries the review's binding, and the host refuses
6138
+ // one that no longer holds.
6139
+ describe('an acknowledgement bound to the review it answers (#2333)', () => {
6140
+ const MIGRATION = (discards, moves = {}) => ({
6141
+ moduleId: 'callout', version: '0002-drop', sql: 'ALTER TABLE t DROP COLUMN c;', discards, ...(Object.keys(moves).length ? { moves } : {}),
6142
+ });
6143
+ /** A fresh vertical with three admitted versions; `a` serves. */
6144
+ const fresh = async () => {
6145
+ const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
6146
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
6147
+ const put = async (perm, mig, migrations) => {
6148
+ const id = ulid();
6149
+ await host.admin.publishVersion(staff, {
6150
+ id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
6151
+ // #2339: the digest is the one the stored migrations give; `mig` names the set without them.
6152
+ permissionDigest: perm, deploymentRef: null,
6153
+ ...(migrations
6154
+ ? { migrationDigest: await derivedDigestOf({ migrations }), manifestJson: JSON.stringify({ migrations }) }
6155
+ : { migrationDigest: mig }),
6156
+ });
6157
+ await host.admin.admitVersion(staff, id);
6158
+ return id;
6159
+ };
6160
+ const a = await put('pA', 'gA', []);
6161
+ await host.admin.promoteVersion(staff, slug, 'prod', a);
6162
+ return { slug, put, a };
6163
+ };
6164
+ const prodOf = async (slug) => (await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId;
6165
+ const refusal = async (p) => {
6166
+ const err = await p.then(() => undefined, (e) => e);
6167
+ return { kind: promoteReviewRefusalOf(err), message: err?.message };
6168
+ };
6169
+ it('a promote between show and acknowledge moves the base: the acknowledgement is refused, and a fresh review passes', async () => {
6170
+ const { slug, put, a } = await fresh();
6171
+ const c = await put('pC', 'gC', [MIGRATION({ c: 'the column is no longer read' })]);
6172
+ const shown = await reviewOf(slug, c);
6173
+ expect(shown.base).toBe(a);
6174
+ // Someone else promotes b in between: same migrations as a, a different permission surface.
6175
+ const b = await put('pB', 'gA', []);
6176
+ await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true, review: await reviewOf(slug, b) });
6177
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: shown }));
6178
+ expect(stale.kind).toBe('stale');
6179
+ expect(stale.message).toContain(`read against version ${a}`);
6180
+ expect(await prodOf(slug)).toBe(b);
6181
+ // The review read again names b, and is what an acknowledgement answers now.
6182
+ const again = await reviewOf(slug, c);
6183
+ expect(again).toEqual({ base: b, digest: expect.stringMatching(/^[0-9a-f]{64}$/) });
6184
+ expect(again.digest).not.toBe(shown.digest);
6185
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: again });
6186
+ expect(await prodOf(slug)).toBe(c);
6187
+ });
6188
+ it('two promotes bound to the same base, racing: one moves the channel, the other is refused as stale', async () => {
6189
+ const { slug, put, a } = await fresh();
6190
+ const b = await put('pB', 'gB', [MIGRATION({ c: 'gone' })]);
6191
+ const c = await put('pC', 'gC', [MIGRATION({ c: 'gone too' })]);
6192
+ const [forB, forC] = [await reviewOf(slug, b), await reviewOf(slug, c)];
6193
+ expect([forB.base, forC.base]).toEqual([a, a]);
6194
+ const ACK = { permissionChange: true, migrationChange: true };
6195
+ // Neither awaited before the other starts: each judges its gates against `a`.
6196
+ const settled = await Promise.allSettled([
6197
+ host.admin.promoteVersion(staff, slug, 'prod', b, { ...ACK, review: forB }),
6198
+ host.admin.promoteVersion(staff, slug, 'prod', c, { ...ACK, review: forC }),
6199
+ ]);
6200
+ const won = settled.flatMap((s, i) => (s.status === 'fulfilled' ? [[b, c][i]] : []));
6201
+ const lost = settled.flatMap((s) => (s.status === 'rejected' ? [s.reason] : []));
6202
+ expect(won).toHaveLength(1);
6203
+ expect(lost.map((e) => promoteReviewRefusalOf(e))).toEqual(['stale']);
6204
+ expect(await prodOf(slug)).toBe(won[0]);
6205
+ // One move away from `a`, by the winner; the loser recorded nothing.
6206
+ const history = await host.admin.listChannelHistory(staff, slug, 'prod');
6207
+ expect(history.filter((h) => h.fromVersionId === a).map((h) => h.versionId)).toEqual(won);
6208
+ expect(history.some((h) => h.versionId === (won[0] === b ? c : b))).toBe(false);
6209
+ });
6210
+ it('a binding is checked whatever it acknowledges: a moved base refuses even a promote that needs no acknowledgement', async () => {
6211
+ const { slug, put, a } = await fresh();
6212
+ const same = await put('pA', 'gA', []);
6213
+ const shown = await reviewOf(slug, same);
6214
+ const b = await put('pB', 'gA', []);
6215
+ await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true });
6216
+ expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', same, { review: shown }))).kind).toBe('stale');
6217
+ expect(await prodOf(slug)).toBe(b);
6218
+ // Twin: bound to the base that still serves, it promotes.
6219
+ await host.admin.promoteVersion(staff, slug, 'prod', a, { permissionChange: true, review: await reviewOf(slug, a) });
6220
+ expect(await prodOf(slug)).toBe(a);
6221
+ });
6222
+ it('a review read before anything served is stale once something does', async () => {
6223
+ const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
6224
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
6225
+ const put = async () => {
6226
+ const id = ulid();
6227
+ await host.admin.publishVersion(staff, { id, verticalSlug: slug, version: id, manifestDigest: 'm', permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null });
6228
+ await host.admin.admitVersion(staff, id);
6229
+ return id;
6230
+ };
6231
+ const first = await put();
6232
+ const shown = await reviewOf(slug, first, null);
6233
+ expect(shown.base).toBeNull();
6234
+ const other = await put();
6235
+ await host.admin.promoteVersion(staff, slug, 'prod', other);
6236
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', first, { review: shown }));
6237
+ expect(stale.kind).toBe('stale');
6238
+ expect(stale.message).toContain('read against no serving version');
6239
+ });
6240
+ it('a digest that is not the one the stored rows give is refused, with the base right', async () => {
6241
+ const { slug, put, a } = await fresh();
6242
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
6243
+ const shown = await reviewOf(slug, c);
6244
+ // Another review's digest, as a client that bound the wrong answer would send.
6245
+ const other = await reviewOf(slug, await put('pA', 'gD', [MIGRATION({ c: 'gone, differently' })]));
6246
+ expect(other.base).toBe(a);
6247
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: other.digest } }));
6248
+ expect(stale.kind).toBe('stale');
6249
+ expect(stale.message).toMatch(/no longer give the digest/);
6250
+ // Never handed back: a refusal is not something to retry with.
6251
+ expect(stale.message).not.toContain(shown.digest);
6252
+ expect(await prodOf(slug)).toBe(a);
6253
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
6254
+ expect(await prodOf(slug)).toBe(c);
6255
+ });
6256
+ it('a migration acknowledgement without a binding is refused; a permission-only one is still honoured', async () => {
6257
+ const { slug, put } = await fresh();
6258
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
6259
+ const unbound = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true }));
6260
+ expect(unbound.kind).toBe('unbound');
6261
+ // It names the fix: the client release that binds, and how to read the review.
6262
+ // By what the release does, never a version number the release tooling has not cut yet.
6263
+ expect(unbound.message).toContain("a CLI release whose 'substrat promote' supports --review");
6264
+ expect(unbound.message).toContain('/guide/deploying#acknowledgement-answers-one-review');
6265
+ expect(unbound.message).not.toMatch(/\d+\.\d+\.\d+/);
6266
+ // The compatibility rule: no migration moves, so an older client's bare flag still promotes.
6267
+ const p = await put('pP', 'gA', []);
6268
+ await host.admin.promoteVersion(staff, slug, 'prod', p, { permissionChange: true });
6269
+ expect(await prodOf(slug)).toBe(p);
6270
+ // And a migrationChange the gate does not consume needs no binding either.
6271
+ const q = await put('pP', 'gA', []);
6272
+ await host.admin.promoteVersion(staff, slug, 'prod', q, { migrationChange: true });
6273
+ expect(await prodOf(slug)).toBe(q);
6274
+ });
6275
+ it('the digest is over the stored declarations, and the binding is recorded', async () => {
6276
+ const { slug, put, a } = await fresh();
6277
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
6278
+ const shown = await reviewOf(slug, c);
6279
+ // What the host digests is what it stored: the declarations come back with the SQL…
6280
+ const stored = await host.admin.versionMigrations(staff, slug, c);
6281
+ expect(stored).toEqual([MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
6282
+ // …and a review of the same version showing a reworded reason binds another digest.
6283
+ const side = async (id, migrations) => ({ ...(await host.admin.getVersion(staff, id, slug)), migrations: migrations });
6284
+ const reworded = await promoteReviewDigest(await side(a, await host.admin.versionMigrations(staff, slug, a)), await side(c, [MIGRATION({ c: 'gone, differently' }, { 't.a': 't.b' })]));
6285
+ expect(reworded).not.toBe(shown.digest);
6286
+ expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: reworded } }))).kind).toBe('stale');
6287
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
6288
+ const row = (await host.admin.auditLog(staff, {})).find((e) => e.action === 'promoteVersion' && JSON.stringify(e.after).includes(c));
6289
+ expect(row?.after?.acknowledged).toEqual({ migrationChange: true, review: shown });
6290
+ });
6291
+ });
6292
+ // -- a rebind's route and pointer move in one write (#2347) ----------------
6293
+ // A rebind onto another lineage is judged against the version the scope is bound to and the
6294
+ // version the target serves. Flipping the route and crossing the pointer used to be two
6295
+ // writes, so a promote on the target between them left the route on the new script and the
6296
+ // pointer on the old lineage. Now they are one write, conditional on both sides.
6297
+ describe("a rebind's route and pointer move in one write, conditional on both sides (#2347)", () => {
6298
+ const lineages = async () => {
6299
+ const tag = ulid().slice(-8).toLowerCase();
6300
+ const [source, target] = [`acme/src-${tag}`, `acme/dst-${tag}`];
6301
+ const put = async (slug) => {
6302
+ const id = ulid();
6303
+ await host.admin.publishVersion(staff, {
6304
+ id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
6305
+ migrationDigest: 'g', deploymentRef: null,
6306
+ });
6307
+ await host.admin.admitVersion(staff, id);
6308
+ return id;
6309
+ };
6310
+ for (const slug of [source, target])
6311
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli', ownerTenant: t1 });
6312
+ const [s1, s2, t1v, t2v] = [await put(source), await put(source), await put(target), await put(target)];
6313
+ const ref = `serving-${tag}`;
6314
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
6315
+ const install = async () => {
6316
+ const sc = scopeId.parse(ulid());
6317
+ await host.provisionScope(staff, { tenantId: t1, scopeId: sc, vertical: source });
6318
+ await host.admin.activateScope(staff, t1, sc);
6319
+ await host.admin.bindScopeVersion(staff, t1, sc, s1);
6320
+ return sc;
6321
+ };
6322
+ const judged = { vertical: target, ref, versionId: t1v };
6323
+ return { source, target, s1, s2, t1v, t2v, ref, install, judged };
6324
+ };
6325
+ const where = async (sc) => {
6326
+ const r = (await host.admin.getScopeRecord(staff, t1, sc));
6327
+ return { vertical: r.vertical, versionId: r.verticalVersionId, servingRef: r.servingRef ?? null };
6328
+ };
6329
+ const refusal = (p) => p.then(() => undefined, (e) => e);
6330
+ it('lands route and pointer together when both sides still hold, and records both', async () => {
6331
+ const { source, target, s1, t1v, ref, install, judged } = await lineages();
6332
+ const sc = await install();
6333
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6334
+ await host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged });
6335
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
6336
+ const row = (await host.admin.auditLog(staff, { action: 'bindScopeVersion', scopeId: sc }))
6337
+ .find((e) => e.after?.versionId === t1v);
6338
+ expect(row?.before).toEqual({ servingRef: null });
6339
+ expect(row?.after).toMatchObject({ versionId: t1v, servingRef: ref, expectedVersionId: s1, expectedServing: judged });
6340
+ });
6341
+ it('a promote on the TARGET since the rebind was judged refuses it as stale, and moves neither route nor pointer', async () => {
6342
+ const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
6343
+ const sc = await install();
6344
+ // What the target serves moves on after the rebind read it.
6345
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' });
6346
+ const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
6347
+ expect(rebindReviewRefusalOf(err)).toBe('stale');
6348
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6349
+ // The twin: judged against what serves now, it lands.
6350
+ await host.admin.bindScopeVersion(staff, t1, sc, t2v, { servingRef: ref, expectedVersionId: s1, expectedServing: { ...judged, versionId: t2v } });
6351
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t2v, servingRef: ref });
6352
+ });
6353
+ it('a move of the SOURCE binding since the rebind was judged refuses it as stale; a plain conditional bind keeps its own refusal', async () => {
6354
+ const { source, s1, s2, t1v, ref, install, judged } = await lineages();
6355
+ const sc = await install();
6356
+ await host.admin.bindScopeVersion(staff, t1, sc, s2);
6357
+ const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
6358
+ expect(rebindReviewRefusalOf(err)).toBe('stale');
6359
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s2, servingRef: null });
6360
+ // Without `expectedServing` the bind is the #1722 compare-and-set it always was.
6361
+ const plain = await refusal(host.admin.bindScopeVersion(staff, t1, sc, s1, { expectedVersionId: s1 }));
6362
+ expect(rebindReviewRefusalOf(plain)).toBeNull();
6363
+ expect(String(plain)).toMatch(/scope binding changed/);
6364
+ });
6365
+ it('racing a promote on the target: either the rebind lands whole, or it is refused as stale and nothing moved', async () => {
6366
+ const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
6367
+ for (let round = 0; round < 4; round++) {
6368
+ const sc = await install();
6369
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
6370
+ const [rebind] = await Promise.allSettled([
6371
+ host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }),
6372
+ host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' }),
6373
+ ]);
6374
+ if (rebind.status === 'fulfilled') {
6375
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
6376
+ }
6377
+ else {
6378
+ expect(rebindReviewRefusalOf(rebind.reason)).toBe('stale');
6379
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6380
+ }
6381
+ }
6382
+ });
6383
+ });
6063
6384
  // -- a version that strands people a link bound (#2178) -------------------
6064
6385
  // A binding shape's people keep it until they are released, and the levers need the shape:
6065
6386
  // an unbind needs it to bind, a retirement needs it to be a bootstrap shape, an erasure finds
@@ -6086,8 +6407,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6086
6407
  const id = ulid();
6087
6408
  await host.admin.publishVersion(staff, {
6088
6409
  id, verticalSlug: slug, version: `3.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
6089
- permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null,
6090
- manifestJson: JSON.stringify({ registry: { permissions: [], roles: [], entityGrants } }),
6410
+ permissionDigest: 'p', deploymentRef: null,
6411
+ ...(await heldMigrations('g', { registry: { permissions: [], roles: [], entityGrants } })),
6091
6412
  });
6092
6413
  if (admit)
6093
6414
  await host.admin.admitVersion(staff, id);
@@ -6185,6 +6506,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6185
6506
  await host.admin.setScopeServingRef(staff, t1, sc, ref);
6186
6507
  expect(await routeOf()).toBe(ref);
6187
6508
  });
6509
+ it('a bind that routes the scope in the same write (#2347) is judged on the script it routes to', async () => {
6510
+ const { slug, v } = await listed();
6511
+ const sc = await install(slug, v.binds);
6512
+ const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
6513
+ await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.dropped, doClasses: [], migrationTag: 'g' });
6514
+ // The pointer stays on the binding version; the route moves onto a script that runs one
6515
+ // that drops the shape. Judged without the route, this bind would pass.
6516
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref }), 'precondition_failed', /drops the shape/);
6517
+ const r = await host.admin.getScopeRecord(staff, t1, sc);
6518
+ expect([r?.verticalVersionId, r?.servingRef ?? null]).toEqual([v.binds, null]);
6519
+ // The twin: the script serves a version that retires the keys, and both move in one write.
6520
+ await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.retired, doClasses: [], migrationTag: 'g' });
6521
+ await host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref });
6522
+ const after = await host.admin.getScopeRecord(staff, t1, sc);
6523
+ expect([after?.verticalVersionId, after?.servingRef]).toEqual([v.binds, ref]);
6524
+ });
6188
6525
  it('a promote is judged from the version served in place too, and replacing what serves is judged like a promote (review of #2181)', async () => {
6189
6526
  const { slug, v } = await listed();
6190
6527
  const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
@@ -6574,7 +6911,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6574
6911
  version,
6575
6912
  manifestDigest: `man-${version}`,
6576
6913
  permissionDigest: digests?.perm ?? 'pp',
6577
- migrationDigest: digests?.mig ?? 'gg',
6914
+ ...(await heldMigrations(digests?.mig ?? 'gg')),
6578
6915
  deploymentRef: null,
6579
6916
  });
6580
6917
  return id;