@substrat-run/contract-tests 0.126.0 → 0.128.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.
@@ -1516,6 +1516,35 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1516
1516
  // `poison-a` failed and is backed off: an error on the row, but a retry still due.
1517
1517
  expect(page.entries.some((d) => d.consumer === 'executor:flaky-effector')).toBe(false);
1518
1518
  });
1519
+ it('replays a declared lifecycle over the outbox through the platform verb (#1744)', async () => {
1520
+ // `readLifecycleFlow`'s own suite pins the replay over a hand-built outbox; this
1521
+ // proves the wiring — real emits, on both adapters, read back through `lifecycleFlow`.
1522
+ const stub = await host.getScope(alice, t1, s1);
1523
+ const since = new Date(Date.now() - 60_000).toISOString();
1524
+ await stub.invoke('test/move', { entityId: 'l1', state: 'draft' });
1525
+ await stub.invoke('test/move', { entityId: 'l1', state: 'live' });
1526
+ // No state in the payload: the declared edge says where it went.
1527
+ await stub.invoke('test/move', { entityId: 'l1' });
1528
+ const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
1529
+ entityType: 'test-lifecycle',
1530
+ lifecycle: {
1531
+ field: 'state',
1532
+ initial: 'draft',
1533
+ states: {
1534
+ draft: { on: { 'test/move': 'live' } },
1535
+ live: { on: { 'test/move': 'done' } },
1536
+ done: { terminal: true },
1537
+ },
1538
+ },
1539
+ since,
1540
+ until: new Date(Date.now() + 60_000).toISOString(),
1541
+ });
1542
+ expect(flow.edges.find((e) => e.from === 'draft' && e.to === 'live').count).toBe(1);
1543
+ expect(flow.edges.find((e) => e.from === 'live' && e.to === 'done').count).toBe(1);
1544
+ expect(flow.states.find((s) => s.state === 'done').current).toBe(1);
1545
+ expect(flow.observation).toMatchObject({ entities: 1, events: 3, inferred: 1, complete: true });
1546
+ expect(flow.totals.finished).toBe(1);
1547
+ });
1519
1548
  // -- scope data introspection: the §5.4 admin-query RPC --------------------
1520
1549
  //
1521
1550
  // A read-only window into a scope's OWN database (the console/dashboard Data
@@ -2166,10 +2195,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2166
2195
  const dump = await host.admin.exportScope(staff, t1, s1);
2167
2196
  // Doctor the dump into one captured before the `operation` column existed:
2168
2197
  // the DDL loses the column and every row loses that cell — exactly what a
2169
- // dump exported by an older platform carries. The replay's DDL is
2170
- // authoritative, and KERNEL_DDL's IF NOT EXISTS cannot widen a table the
2171
- // dump brought, so without the post-replay re-run of the additive
2172
- // migrations the emit below fails with `no such column: operation`.
2198
+ // dump exported by an older platform carries. The restore builds the outbox
2199
+ // from the kernel's DDL, not the dump's (#1883), and inserts the rows by
2200
+ // column name, so the missing cell reads NULL and the emit below still has
2201
+ // its column.
2173
2202
  const doctored = dump.tables.map((t) => {
2174
2203
  if (t.name !== '_substrat_outbox')
2175
2204
  return t;
@@ -2289,6 +2318,14 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2289
2318
  { name: 'MARKER', ddl: 'CREATE TABLE MARKER (id TEXT)', columns: ['id'], rows: [] },
2290
2319
  ])).rejects.toThrow(/listed twice/);
2291
2320
  });
2321
+ // A column listed twice is the same shape one level down: the row insert names it twice,
2322
+ // and which of the two cells lands is SQLite's choice, not the dump's. Case-folded, as
2323
+ // SQLite resolves column names that way too (#1883 review).
2324
+ it('refuses a column listed twice in one table, including differing only in case', async () => {
2325
+ await expect(importForged([
2326
+ { name: 'marker', ddl: 'CREATE TABLE marker (id TEXT, note TEXT)', columns: ['id', 'note', 'ID'], rows: [] },
2327
+ ])).rejects.toThrow(/column "ID" in table "marker" is listed twice/);
2328
+ });
2292
2329
  // Non-vacuous: proves the checks do not simply reject everything, and that a
2293
2330
  // `;` inside a string literal is not read as a statement boundary — a DEFAULT
2294
2331
  // of `'a;b'` is legal SQL, and a `split(';')` would refuse it.
@@ -5316,9 +5353,12 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5316
5353
  await move(folder('f1'), folder('fr'), folder('f3'));
5317
5354
  });
5318
5355
  it('terminates on a graph that is already cyclic', async () => {
5319
- // `link` does not refuse a cycle (a follow-up), so one can exist: c1 ⇄ c2.
5320
- await link(folder('c1'), folder('c2'));
5321
- await link(folder('c2'), folder('c1'));
5356
+ // `link` refuses a cycle too now (#1875), so the only way one can exist is a
5357
+ // restored dump — c1 ⇄ c2, planted the same way an undeclared or expired edge is.
5358
+ await plant([
5359
+ { subject: 'folder:c1', object: 'folder:c2' },
5360
+ { subject: 'folder:c2', object: 'folder:c1' },
5361
+ ]);
5322
5362
  await link(folder('cx'), folder('fr'));
5323
5363
  await move(folder('cx'), folder('fr'), folder('c1'));
5324
5364
  expect(await edges(folder('cx'))).toEqual([
@@ -5329,6 +5369,53 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5329
5369
  await expectRefusal(move(folder('c1'), folder('c2'), folder('cx')), 'validation_failed', /its own ancestor/);
5330
5370
  });
5331
5371
  });
5372
+ /**
5373
+ * #1875: `ctx.link` refuses a cycle the same way `ctx.relink` does, via the same shared
5374
+ * walk (`entity-edges.ts`'s `assertNoCycle`) — a behaviour change to a shipped verb, since
5375
+ * a module that already wrote one (by accident) now fails where it used to succeed.
5376
+ */
5377
+ describe("link refuses a cycle, sharing relink's walk (#1875)", () => {
5378
+ // lr ← l1 ← l2, and a sibling subtree lr ← l3.
5379
+ beforeAll(async () => {
5380
+ await link(folder('l1'), folder('lr'));
5381
+ await link(folder('l2'), folder('l1'));
5382
+ await link(folder('l3'), folder('lr'));
5383
+ });
5384
+ it('refuses linking a parent that is the child itself', async () => {
5385
+ await expectRefusal(link(folder('l1'), folder('l1')), 'validation_failed', /its own ancestor/);
5386
+ expect(await edges(folder('l1'))).toEqual([{ object: 'folder:lr', revoked: false }]);
5387
+ });
5388
+ it('refuses linking into its own descendant', async () => {
5389
+ // lr is an ancestor of l2 (lr ← l1 ← l2); linking lr under l2 would make lr its own
5390
+ // descendant.
5391
+ await expectRefusal(link(folder('lr'), folder('l2')), 'validation_failed', /its own ancestor/);
5392
+ expect(await edges(folder('lr'))).toEqual([]);
5393
+ });
5394
+ it('the twin: a link into a sibling subtree still succeeds', async () => {
5395
+ await link(folder('l2'), folder('l3'));
5396
+ expect(await edges(folder('l2'))).toEqual([
5397
+ { object: 'folder:l1', revoked: false },
5398
+ { object: 'folder:l3', revoked: false },
5399
+ ]);
5400
+ });
5401
+ it('an already-cyclic graph (planted via export/restore) terminates instead of looping', async () => {
5402
+ // `link` refuses a cycle now, so the only way one exists is a restored dump.
5403
+ await plant([
5404
+ { subject: 'folder:lc1', object: 'folder:lc2' },
5405
+ { subject: 'folder:lc2', object: 'folder:lc1' },
5406
+ ]);
5407
+ await link(folder('lc3'), folder('lr'));
5408
+ // lc1's own chain (lc1 → lc2 → lc1 → …) never reaches lc3, so this still succeeds —
5409
+ // the point is that the walk finishes at all instead of hanging on the cycle.
5410
+ await link(folder('lc3'), folder('lc1'));
5411
+ expect(await edges(folder('lc3'))).toEqual([
5412
+ { object: 'folder:lc1', revoked: false },
5413
+ { object: 'folder:lr', revoked: false },
5414
+ ]);
5415
+ // A member of the cycle still refuses becoming its own ancestor.
5416
+ await expectRefusal(link(folder('lc1'), folder('lc1')), 'validation_failed', /its own ancestor/);
5417
+ });
5418
+ });
5332
5419
  it('a tombstone survives export, restore and snapshot — never resurrected', async () => {
5333
5420
  await link(item('rl10'), box('rb1'));
5334
5421
  await move(item('rl10'), box('rb1'), box('rb2'));