@substrat-run/contract-tests 0.98.1 → 0.103.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.
@@ -143,6 +143,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
143
143
  expect(row.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
144
144
  expect(new Date(row.occurred_at).getTime()).not.toBeNaN();
145
145
  expect(row.pii_class).toBe('none');
146
+ // #1231: the signals `operation` dimension — the exact invoke() string,
147
+ // stamped kernel-side so module code can neither forge nor suppress it.
148
+ expect(row.operation).toBe('test/emit-event');
146
149
  });
147
150
  it('ctx.now() is one instant for the whole operation, and the envelope agrees (#812)', async () => {
148
151
  const stub = await host.getScope(alice, t1, s1);
@@ -814,6 +817,38 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
814
817
  const vCol = page.columns.indexOf('v');
815
818
  expect(page.rows.map((r) => r[vCol])).toContain('fork-me');
816
819
  });
820
+ it('replays a LEGACY dump and re-applies the additive spine columns — the next emit still lands (#1231)', async () => {
821
+ const stub = await host.getScope(alice, t1, s1);
822
+ await stub.invoke('test/emit-event');
823
+ const dump = await host.admin.exportScope(staff, t1, s1);
824
+ // Doctor the dump into one captured before the `operation` column existed:
825
+ // the DDL loses the column and every row loses that cell — exactly what a
826
+ // dump exported by an older platform carries. The replay's DDL is
827
+ // authoritative, and KERNEL_DDL's IF NOT EXISTS cannot widen a table the
828
+ // dump brought, so without the post-replay re-run of the additive
829
+ // migrations the emit below fails with `no such column: operation`.
830
+ const doctored = dump.tables.map((t) => {
831
+ if (t.name !== '_substrat_outbox')
832
+ return t;
833
+ const idx = t.columns.indexOf('operation');
834
+ expect(idx).toBeGreaterThanOrEqual(0);
835
+ return {
836
+ ...t,
837
+ ddl: t.ddl.replace(/\s*operation TEXT,/, ''),
838
+ columns: t.columns.filter((c) => c !== 'operation'),
839
+ rows: t.rows.map((r) => r.filter((_, i) => i !== idx)),
840
+ };
841
+ });
842
+ const copy = scopeId.parse(ulid());
843
+ await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, { ...dump, tables: doctored });
844
+ const copyStub = await host.getScope(alice, t1, copy);
845
+ await copyStub.invoke('test/emit-event');
846
+ const rows = await copyStub.invoke('test/read-outbox');
847
+ const last = rows[rows.length - 1];
848
+ // Stamped, not merely tolerated — and the imported legacy rows read NULL.
849
+ expect(last.operation).toBe('test/emit-event');
850
+ expect(rows.filter((r) => r.operation === null).length).toBeGreaterThan(0);
851
+ });
817
852
  it('is an independent copy — a later write to the source does not reach it', async () => {
818
853
  const dump = await host.admin.exportScope(staff, t1, s1);
819
854
  const copy = scopeId.parse(ulid());
@@ -1553,6 +1588,64 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1553
1588
  expect(page2.length).toBe(1);
1554
1589
  expect(page2[0].id).not.toBe(page1[0].id);
1555
1590
  });
1591
+ it('stamps and filters by version — the signals dimension that joins a failure to its push (#1231)', async () => {
1592
+ await host.admin.recordOpsFailure({
1593
+ actor: staff,
1594
+ operation: 'deploy.upload',
1595
+ vertical: 'acme/versioned',
1596
+ version: '01JVERSIONAAAAAAAAAAAAAAAA',
1597
+ message: 'failed under the new version',
1598
+ });
1599
+ await host.admin.recordOpsFailure({
1600
+ actor: staff,
1601
+ operation: 'deploy.upload',
1602
+ vertical: 'acme/versioned',
1603
+ // No version — a writer that cannot say says nothing, and the row reads null.
1604
+ message: 'failed with no version in play',
1605
+ });
1606
+ const stamped = await host.admin.listOpsFailures(staff, {
1607
+ version: '01JVERSIONAAAAAAAAAAAAAAAA',
1608
+ });
1609
+ expect(stamped.length).toBe(1);
1610
+ expect(stamped[0].version).toBe('01JVERSIONAAAAAAAAAAAAAAAA');
1611
+ // The unstamped row round-trips as an explicit null, never undefined: the
1612
+ // entry schema owns the distinction between "predates the stamp" and a typo.
1613
+ const both = await host.admin.listOpsFailures(staff, { vertical: 'acme/versioned' });
1614
+ expect(both.length).toBe(2);
1615
+ expect(both.map((r) => r.version).sort()).toEqual([
1616
+ '01JVERSIONAAAAAAAAAAAAAAAA',
1617
+ null,
1618
+ ].sort());
1619
+ });
1620
+ it('carries the error shape — origin and code round-trip and narrow (#1233)', async () => {
1621
+ await host.admin.recordOpsFailure({
1622
+ actor: staff,
1623
+ operation: 'intent.connector:testprov',
1624
+ stage: 'terminal',
1625
+ vertical: 'acme/shaped',
1626
+ origin: 'platform',
1627
+ code: 'permission_denied',
1628
+ message: 'platform intent 01JTESTINTENT failed: permission denied',
1629
+ });
1630
+ await host.admin.recordOpsFailure({
1631
+ actor: staff,
1632
+ operation: 'intent.connector:testprov',
1633
+ stage: 'terminal',
1634
+ vertical: 'acme/shaped',
1635
+ // Unclassified — a writer that cannot say says nothing, like the version stamp.
1636
+ message: 'platform intent 01JOTHERINTENT failed: socket hang up',
1637
+ });
1638
+ // The fingerprint's narrowing: code is a COLUMN, never a message regex.
1639
+ const denied = await host.admin.listOpsFailures(staff, { code: 'permission_denied' });
1640
+ expect(denied.length).toBe(1);
1641
+ expect(denied[0].origin).toBe('platform');
1642
+ expect(denied[0].code).toBe('permission_denied');
1643
+ // The unclassified row round-trips as explicit nulls, never undefined.
1644
+ const both = await host.admin.listOpsFailures(staff, { vertical: 'acme/shaped' });
1645
+ expect(both.length).toBe(2);
1646
+ expect(both.map((r) => r.code).sort()).toEqual(['permission_denied', null].sort());
1647
+ expect(both.map((r) => r.origin).sort()).toEqual(['platform', null].sort());
1648
+ });
1556
1649
  it('bounds the recorded message — a runaway upstream body never becomes a runaway row', async () => {
1557
1650
  await host.admin.recordOpsFailure({
1558
1651
  actor: staff,
@@ -1564,6 +1657,165 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1564
1657
  expect(rows[0].message.length).toBeLessThanOrEqual(2000);
1565
1658
  });
1566
1659
  });
1660
+ // -- issues (#1233): failures grouped by fingerprint, with a lifecycle --
1661
+ describe('issues (#1233)', () => {
1662
+ it('groups recurring failures into one counted issue, and links the exemplars', async () => {
1663
+ for (let i = 0; i < 3; i++) {
1664
+ await host.admin.recordOpsFailure({
1665
+ actor: staff,
1666
+ operation: 'intent.connector:groupprov',
1667
+ stage: 'terminal',
1668
+ vertical: 'acme/grouped',
1669
+ origin: 'provider',
1670
+ message: `platform intent 01JATTEMPT${i} failed: provider said no`,
1671
+ });
1672
+ }
1673
+ await host.admin.recordOpsFailure({
1674
+ actor: staff,
1675
+ operation: 'intent.connector:groupprov',
1676
+ stage: 'terminal',
1677
+ vertical: 'acme/grouped',
1678
+ origin: 'platform',
1679
+ code: 'permission_denied',
1680
+ message: 'platform intent 01JDENIED failed: permission denied',
1681
+ });
1682
+ // Same operation+stage, different code -> a different defect, a different issue.
1683
+ const issues = await host.admin.listIssues(staff, { operation: 'intent.connector:groupprov' });
1684
+ expect(issues.length).toBe(2);
1685
+ const recurring = issues.find((i) => i.code === null);
1686
+ expect(recurring.count).toBe(3);
1687
+ expect(recurring.status).toBe('new');
1688
+ expect(recurring.origin).toBe('provider');
1689
+ expect(recurring.lastVertical).toBe('acme/grouped');
1690
+ expect(recurring.firstSeen <= recurring.lastSeen).toBe(true);
1691
+ // The exemplar walk: the issue's fingerprint narrows the failure list to its rows.
1692
+ const exemplars = await host.admin.listOpsFailures(staff, { fingerprint: recurring.fingerprint });
1693
+ expect(exemplars.length).toBe(3);
1694
+ expect(new Set(exemplars.map((r) => r.fingerprint)).size).toBe(1);
1695
+ });
1696
+ it('walks the lifecycle: resolved regresses on a fresh arrival, ignored stays ignored', async () => {
1697
+ const record = () => host.admin.recordOpsFailure({
1698
+ actor: staff,
1699
+ operation: 'deploy.upload',
1700
+ stage: 'lifecycle-check',
1701
+ origin: 'platform',
1702
+ code: 'unavailable',
1703
+ message: 'upstream unavailable',
1704
+ });
1705
+ await record();
1706
+ const [issue] = await host.admin.listIssues(staff, { operation: 'deploy.upload', code: 'unavailable' });
1707
+ const resolved = await host.admin.setIssueStatus(staff, issue.fingerprint, 'resolved');
1708
+ expect(resolved?.status).toBe('resolved');
1709
+ expect(resolved?.resolvedAt).not.toBeNull();
1710
+ // Sentry's best retention trick, minus the release half: seen again after
1711
+ // resolved = regressed, back into attention without anyone re-filing it.
1712
+ await record();
1713
+ const [regressed] = await host.admin.listIssues(staff, { operation: 'deploy.upload', code: 'unavailable' });
1714
+ expect(regressed.status).toBe('regressed');
1715
+ expect(regressed.count).toBe(2);
1716
+ // The resolution timestamp survives the regression: "when somebody last
1717
+ // thought this was over" is part of the regression's story.
1718
+ expect(regressed.resolvedAt).not.toBeNull();
1719
+ // Ignored is a verdict about the future, and a fresh arrival does not overturn it.
1720
+ await host.admin.setIssueStatus(staff, issue.fingerprint, 'ignored');
1721
+ await record();
1722
+ const [ignored] = await host.admin.listIssues(staff, { operation: 'deploy.upload', code: 'unavailable' });
1723
+ expect(ignored.status).toBe('ignored');
1724
+ expect(ignored.count).toBe(3);
1725
+ // Reopening clears the resolution; an unknown fingerprint is undefined, never an invented row.
1726
+ const reopened = await host.admin.setIssueStatus(staff, issue.fingerprint, 'new');
1727
+ expect(reopened?.status).toBe('new');
1728
+ expect(reopened?.resolvedAt).toBeNull();
1729
+ expect(await host.admin.setIssueStatus(staff, 'no-such-fingerprint', 'resolved')).toBeUndefined();
1730
+ });
1731
+ });
1732
+ // -- the durable sweep record (#1232): what a pass touched, and how it went --
1733
+ describe('sweep runs (#1232)', () => {
1734
+ it('records unit outcomes and walks them newest-first per unit, with a cursor', async () => {
1735
+ const conn = ulid();
1736
+ for (const outcome of ['ok', 'ok', 'failed']) {
1737
+ await host.admin.recordSweepRun({
1738
+ kind: 'connector',
1739
+ unit: conn,
1740
+ outcome,
1741
+ tenantId: t1,
1742
+ vertical: 'acme/crm',
1743
+ operation: 'sweep.connector:testprov',
1744
+ connectionId: conn,
1745
+ ...(outcome === 'failed' ? { error: 'provider said no' } : { elapsedMs: 12 }),
1746
+ });
1747
+ }
1748
+ // Newest-first by default: "how did the LAST sweep go" is the first row.
1749
+ const strip = await host.admin.listSweepRuns(staff, { kind: 'connector', unit: conn });
1750
+ expect(strip.length).toBe(3);
1751
+ expect(strip[0].outcome).toBe('failed');
1752
+ expect(strip[0].error).toBe('provider said no');
1753
+ expect(strip[2].outcome).toBe('ok');
1754
+ expect(strip[2].elapsedMs).toBe(12);
1755
+ // The cursor pages exactly like ops failures: the row id IS the cursor.
1756
+ const page1 = await host.admin.listSweepRuns(staff, { unit: conn, limit: 2 });
1757
+ const page2 = await host.admin.listSweepRuns(staff, { unit: conn, limit: 2, cursor: page1[1].id });
1758
+ expect(page1.length).toBe(2);
1759
+ expect(page2.length).toBe(1);
1760
+ expect(page2[0].id).not.toBe(page1[0].id);
1761
+ });
1762
+ it('round-trips the dimensions, and a structurally absent one reads an explicit null', async () => {
1763
+ const sched = `${s1}:acme/tick`;
1764
+ await host.admin.recordSweepRun({
1765
+ kind: 'schedule',
1766
+ unit: sched,
1767
+ outcome: 'skipped',
1768
+ tenantId: t1,
1769
+ scopeId: s1,
1770
+ vertical: 'acme/crm',
1771
+ version: '01JSWEEPVERSIONAAAAAAAAAAA',
1772
+ operation: 'acme/tick',
1773
+ });
1774
+ const [row] = await host.admin.listSweepRuns(staff, { kind: 'schedule', unit: sched });
1775
+ expect(row.scopeId).toBe(s1);
1776
+ expect(row.version).toBe('01JSWEEPVERSIONAAAAAAAAAAA');
1777
+ expect(row.operation).toBe('acme/tick');
1778
+ // A schedule row names no connection, and the null is a fact, not a gap —
1779
+ // exactly as a connector row's scope/version nulls are.
1780
+ expect(row.connectionId).toBeNull();
1781
+ expect(row.error).toBeNull();
1782
+ expect(row.elapsedMs).toBeNull();
1783
+ });
1784
+ it('dedupes on (requestId, unit) — a replayed drained batch writes nothing twice (#1232)', async () => {
1785
+ const unit = `${ulid()}:sched/tick`;
1786
+ const write = () => host.admin.recordSweepRun({
1787
+ kind: 'schedule',
1788
+ unit,
1789
+ outcome: 'ok',
1790
+ tenantId: t1,
1791
+ at: '2026-09-07T10:00:00.000Z',
1792
+ requestId: '01JDEDUPEINTENTAAAAAAAAAAA',
1793
+ });
1794
+ await write();
1795
+ await write(); // the replay — a settle lost in transport re-runs the batch
1796
+ const rows = await host.admin.listSweepRuns(staff, { unit });
1797
+ expect(rows).toHaveLength(1);
1798
+ // The carried pass time survives, never overwritten by write time.
1799
+ expect(rows[0].at).toBe('2026-09-07T10:00:00.000Z');
1800
+ // The DIRECT path (no requestId) never dedupes: NULLs are distinct, and two
1801
+ // real passes over one unit are two facts.
1802
+ const direct = `${ulid()}:direct`;
1803
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: direct, outcome: 'ok' });
1804
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: direct, outcome: 'ok' });
1805
+ expect(await host.admin.listSweepRuns(staff, { unit: direct })).toHaveLength(2);
1806
+ });
1807
+ it('bounds the recorded error — one runaway provider body never becomes a runaway row', async () => {
1808
+ const unit = ulid();
1809
+ await host.admin.recordSweepRun({
1810
+ kind: 'connector',
1811
+ unit,
1812
+ outcome: 'failed',
1813
+ error: 'x'.repeat(10_000),
1814
+ });
1815
+ const [row] = await host.admin.listSweepRuns(staff, { unit });
1816
+ expect(row.error.length).toBeLessThanOrEqual(2000);
1817
+ });
1818
+ });
1567
1819
  //
1568
1820
  // The store exists so a vertical's connector can reach a tenant's provider
1569
1821
  // without any module ever holding a credential. So the properties that
@@ -3097,6 +3349,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3097
3349
  expect(actors.length).toBeGreaterThan(0);
3098
3350
  for (const row of actors) {
3099
3351
  expect(JSON.parse(row.actor)).toEqual({ system: '@test/flow' });
3352
+ // #1231: a consumer runs on behalf of NO operation, and the row says so —
3353
+ // NULL, never a synthesized pseudo-name that would pollute the dimension.
3354
+ expect(row.operation).toBeNull();
3100
3355
  }
3101
3356
  });
3102
3357
  // -- tenant registry + lifecycle (control-plane.md §4.1) -----------------