@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.
- package/README.md +23 -0
- package/dist/grant-expiry-suite.d.ts +14 -0
- package/dist/grant-expiry-suite.d.ts.map +1 -0
- package/dist/grant-expiry-suite.js +181 -0
- package/dist/grant-expiry-suite.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/modules.d.ts +236 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +32 -3
- package/dist/modules.js.map +1 -1
- package/dist/schedule-suite.d.ts.map +1 -1
- package/dist/schedule-suite.js +9 -1
- package/dist/schedule-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts +7 -0
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +255 -0
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/timeline-suite.d.ts.map +1 -1
- package/dist/timeline-suite.js +6 -0
- package/dist/timeline-suite.js.map +1 -1
- package/package.json +3 -3
package/dist/scope-host-suite.js
CHANGED
|
@@ -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) -----------------
|