@substrat-run/contract-tests 0.138.0 → 0.140.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/dist/attachment-text-fixtures.d.ts +17 -0
- package/dist/attachment-text-fixtures.d.ts.map +1 -1
- package/dist/attachment-text-fixtures.js +344 -4
- package/dist/attachment-text-fixtures.js.map +1 -1
- package/dist/attachment-text-suite.d.ts +16 -1
- package/dist/attachment-text-suite.d.ts.map +1 -1
- package/dist/attachment-text-suite.js +212 -28
- package/dist/attachment-text-suite.js.map +1 -1
- package/dist/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +5 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/caused-by-module.d.ts +23 -0
- package/dist/caused-by-module.d.ts.map +1 -0
- package/dist/caused-by-module.js +56 -0
- package/dist/caused-by-module.js.map +1 -0
- package/dist/caused-by-suite.d.ts +28 -0
- package/dist/caused-by-suite.d.ts.map +1 -0
- package/dist/caused-by-suite.js +223 -0
- package/dist/caused-by-suite.js.map +1 -0
- package/dist/connect-link-suite.d.ts +19 -0
- package/dist/connect-link-suite.d.ts.map +1 -0
- package/dist/connect-link-suite.js +215 -0
- package/dist/connect-link-suite.js.map +1 -0
- package/dist/entity-state-migration-suite.d.ts +16 -0
- package/dist/entity-state-migration-suite.d.ts.map +1 -0
- package/dist/entity-state-migration-suite.js +305 -0
- package/dist/entity-state-migration-suite.js.map +1 -0
- package/dist/entity-state-module.d.ts +317 -0
- package/dist/entity-state-module.d.ts.map +1 -0
- package/dist/entity-state-module.js +278 -0
- package/dist/entity-state-module.js.map +1 -0
- package/dist/entity-state-suite.d.ts +9 -0
- package/dist/entity-state-suite.d.ts.map +1 -0
- package/dist/entity-state-suite.js +557 -0
- package/dist/entity-state-suite.js.map +1 -0
- package/dist/entity-trash-module.d.ts +439 -0
- package/dist/entity-trash-module.d.ts.map +1 -0
- package/dist/entity-trash-module.js +287 -0
- package/dist/entity-trash-module.js.map +1 -0
- package/dist/entity-trash-suite.d.ts +59 -0
- package/dist/entity-trash-suite.d.ts.map +1 -0
- package/dist/entity-trash-suite.js +568 -0
- package/dist/entity-trash-suite.js.map +1 -0
- package/dist/erasure-module.d.ts +259 -0
- package/dist/erasure-module.d.ts.map +1 -0
- package/dist/erasure-module.js +251 -0
- package/dist/erasure-module.js.map +1 -0
- package/dist/findings-atomic-suite.d.ts +21 -0
- package/dist/findings-atomic-suite.d.ts.map +1 -0
- package/dist/findings-atomic-suite.js +131 -0
- package/dist/findings-atomic-suite.js.map +1 -0
- package/dist/findings-suite.d.ts +11 -0
- package/dist/findings-suite.d.ts.map +1 -0
- package/dist/findings-suite.js +391 -0
- package/dist/findings-suite.js.map +1 -0
- package/dist/index.d.ts +22 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +101 -1
- package/dist/list-suite.js.map +1 -1
- package/dist/membership-executor-suite.d.ts +6 -2
- package/dist/membership-executor-suite.d.ts.map +1 -1
- package/dist/membership-executor-suite.js +318 -21
- package/dist/membership-executor-suite.js.map +1 -1
- package/dist/migration-comments-suite.d.ts +3 -0
- package/dist/migration-comments-suite.d.ts.map +1 -0
- package/dist/migration-comments-suite.js +92 -0
- package/dist/migration-comments-suite.js.map +1 -0
- package/dist/migration-comments.d.ts +3 -0
- package/dist/migration-comments.d.ts.map +1 -0
- package/dist/migration-comments.js +50 -0
- package/dist/migration-comments.js.map +1 -0
- package/dist/migration-digest-suite.d.ts +3 -0
- package/dist/migration-digest-suite.d.ts.map +1 -0
- package/dist/migration-digest-suite.js +175 -0
- package/dist/migration-digest-suite.js.map +1 -0
- package/dist/modules.d.ts +759 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +158 -6
- package/dist/modules.js.map +1 -1
- package/dist/peer-suite.d.ts +2 -1
- package/dist/peer-suite.d.ts.map +1 -1
- package/dist/peer-suite.js +103 -1
- package/dist/peer-suite.js.map +1 -1
- package/dist/permission-suite.d.ts.map +1 -1
- package/dist/permission-suite.js +590 -2
- package/dist/permission-suite.js.map +1 -1
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +102 -1
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/split-cases.d.ts +12 -0
- package/dist/split-cases.d.ts.map +1 -0
- package/dist/split-cases.js +59 -0
- package/dist/split-cases.js.map +1 -0
- package/dist/subject-erasure-suite.d.ts +6 -0
- package/dist/subject-erasure-suite.d.ts.map +1 -0
- package/dist/subject-erasure-suite.js +287 -0
- package/dist/subject-erasure-suite.js.map +1 -0
- package/dist/switch-audit-fault.d.ts +48 -0
- package/dist/switch-audit-fault.d.ts.map +1 -0
- package/dist/switch-audit-fault.js +80 -0
- package/dist/switch-audit-fault.js.map +1 -0
- package/dist/system-switch-suite.d.ts +2 -1
- package/dist/system-switch-suite.d.ts.map +1 -1
- package/dist/system-switch-suite.js +90 -0
- package/dist/system-switch-suite.js.map +1 -1
- package/dist/vertical-events-suite.d.ts +55 -0
- package/dist/vertical-events-suite.d.ts.map +1 -1
- package/dist/vertical-events-suite.js +55 -0
- package/dist/vertical-events-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ScopeHostFixture } from './scope-host-suite.js';
|
|
2
|
+
import type { DirectoryExec } from './findings-atomic-suite.js';
|
|
3
|
+
/**
|
|
4
|
+
* A vertical's mailed connect link (connections.md §3.5.4), held by the directory.
|
|
5
|
+
*
|
|
6
|
+
* What the row is FOR is the whole list of cases: a link a bureau mails to a client company's
|
|
7
|
+
* provider administrator is spent exactly once (two racing callbacks, one connection), can be
|
|
8
|
+
* withdrawn before it is opened, lapses on its own, and belongs to one scope — another scope's
|
|
9
|
+
* id answers as an absent one. Restore is the callback's undo when the credential store
|
|
10
|
+
* fails, and must not be a way to resurrect a revocation or a lapsed link.
|
|
11
|
+
*
|
|
12
|
+
* Expiry is asserted with links minted already lapsed rather than by sleeping: the store takes
|
|
13
|
+
* the expiry it is given, and the hosted adapter has no clock a suite could move.
|
|
14
|
+
*
|
|
15
|
+
* Every move commits with its audit row or not at all. The audit failure is injected as a
|
|
16
|
+
* trigger on the directory's admin log (`exec`), the one fault both stores raise the same way.
|
|
17
|
+
*/
|
|
18
|
+
export declare function connectLinkContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture>, exec: DirectoryExec): void;
|
|
19
|
+
//# sourceMappingURL=connect-link-suite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect-link-suite.d.ts","sourceRoot":"","sources":["../src/connect-link-suite.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,IAAI,EAAE,aAAa,GAClB,IAAI,CAsON"}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { connectLinkId, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
3
|
+
import { ulid } from '@substrat-run/kernel';
|
|
4
|
+
/**
|
|
5
|
+
* A vertical's mailed connect link (connections.md §3.5.4), held by the directory.
|
|
6
|
+
*
|
|
7
|
+
* What the row is FOR is the whole list of cases: a link a bureau mails to a client company's
|
|
8
|
+
* provider administrator is spent exactly once (two racing callbacks, one connection), can be
|
|
9
|
+
* withdrawn before it is opened, lapses on its own, and belongs to one scope — another scope's
|
|
10
|
+
* id answers as an absent one. Restore is the callback's undo when the credential store
|
|
11
|
+
* fails, and must not be a way to resurrect a revocation or a lapsed link.
|
|
12
|
+
*
|
|
13
|
+
* Expiry is asserted with links minted already lapsed rather than by sleeping: the store takes
|
|
14
|
+
* the expiry it is given, and the hosted adapter has no clock a suite could move.
|
|
15
|
+
*
|
|
16
|
+
* Every move commits with its audit row or not at all. The audit failure is injected as a
|
|
17
|
+
* trigger on the directory's admin log (`exec`), the one fault both stores raise the same way.
|
|
18
|
+
*/
|
|
19
|
+
export function connectLinkContractSuite(adapterName, makeFixture, exec) {
|
|
20
|
+
describe(`connect links (connections.md §3.5.4): ${adapterName}`, () => {
|
|
21
|
+
let fixture;
|
|
22
|
+
let host;
|
|
23
|
+
const staff = platformActorId.parse(ulid());
|
|
24
|
+
const admin = principalId.parse(ulid());
|
|
25
|
+
const t1 = tenantId.parse(ulid());
|
|
26
|
+
const t2 = tenantId.parse(ulid());
|
|
27
|
+
const s1 = scopeId.parse(ulid());
|
|
28
|
+
const s2 = scopeId.parse(ulid()); // same tenant, another install
|
|
29
|
+
const s3 = scopeId.parse(ulid()); // another tenant
|
|
30
|
+
const vertical = 'bureau-books';
|
|
31
|
+
const week = () => new Date(Date.now() + 7 * 86_400_000).toISOString();
|
|
32
|
+
const lapsed = () => new Date(Date.now() - 60_000).toISOString();
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
fixture = await makeFixture();
|
|
35
|
+
host = fixture.host;
|
|
36
|
+
await host.admin.createTenant(staff, { id: t1, slug: `cl-${t1.slice(-10).toLowerCase()}`, name: 'Bureau' });
|
|
37
|
+
await host.admin.createTenant(staff, { id: t2, slug: `cl-${t2.slice(-10).toLowerCase()}`, name: 'Other' });
|
|
38
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s1, vertical });
|
|
39
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s2, vertical });
|
|
40
|
+
await host.provisionScope(staff, { tenantId: t2, scopeId: s3, vertical });
|
|
41
|
+
});
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
await fixture.cleanup();
|
|
44
|
+
});
|
|
45
|
+
const mint = (over = {}) => host.admin.mintConnectLink(staff, {
|
|
46
|
+
tenantId: over.tenantId ?? t1,
|
|
47
|
+
scopeId: over.scopeId ?? s1,
|
|
48
|
+
vertical,
|
|
49
|
+
provider: 'fortnox',
|
|
50
|
+
createdBy: admin,
|
|
51
|
+
expiresAt: over.expiresAt ?? week(),
|
|
52
|
+
...(over.subjectRef ? { subjectRef: over.subjectRef } : {}),
|
|
53
|
+
returnUrl: 'https://books.bureau.example/clients/42',
|
|
54
|
+
});
|
|
55
|
+
const keyOf = (l) => ({ tenantId: l.tenantId, scopeId: l.scopeId, id: l.id });
|
|
56
|
+
const consume = (l, accountRef = '123456') => host.admin.consumeConnectLink(staff, { ...keyOf(l), provider: 'fortnox', accountRef, accountLabel: 'Testbolaget AB' });
|
|
57
|
+
it('mints an outstanding row carrying who authorized it and what it is for', async () => {
|
|
58
|
+
const link = await mint({ subjectRef: 'client-42' });
|
|
59
|
+
expect(link).toMatchObject({
|
|
60
|
+
tenantId: t1,
|
|
61
|
+
scopeId: s1,
|
|
62
|
+
vertical,
|
|
63
|
+
provider: 'fortnox',
|
|
64
|
+
status: 'outstanding',
|
|
65
|
+
createdBy: admin,
|
|
66
|
+
subjectRef: 'client-42',
|
|
67
|
+
returnUrl: 'https://books.bureau.example/clients/42',
|
|
68
|
+
usedAt: null,
|
|
69
|
+
accountRef: null,
|
|
70
|
+
accountLabel: null,
|
|
71
|
+
});
|
|
72
|
+
expect(await host.admin.getConnectLink(staff, keyOf(link))).toEqual(link);
|
|
73
|
+
const listed = await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1, outstandingOnly: true });
|
|
74
|
+
expect(listed.map((l) => l.id)).toContain(link.id);
|
|
75
|
+
});
|
|
76
|
+
it('is spent exactly once, recording what the consent attached', async () => {
|
|
77
|
+
const link = await mint();
|
|
78
|
+
const first = await consume(link);
|
|
79
|
+
expect(first.ok).toBe(true);
|
|
80
|
+
if (first.ok) {
|
|
81
|
+
expect(first.link).toMatchObject({ status: 'used', accountRef: '123456', accountLabel: 'Testbolaget AB' });
|
|
82
|
+
expect(first.link.usedAt).not.toBeNull();
|
|
83
|
+
}
|
|
84
|
+
// A forwarded mail, a reloaded callback: the second spend answers `used`, changes nothing.
|
|
85
|
+
expect(await consume(link, '999999')).toEqual({ ok: false, reason: 'used' });
|
|
86
|
+
expect((await host.admin.getConnectLink(staff, keyOf(link)))?.accountRef).toBe('123456');
|
|
87
|
+
});
|
|
88
|
+
it('settles two racing consumes with exactly one winner', async () => {
|
|
89
|
+
const link = await mint();
|
|
90
|
+
const results = await Promise.all([consume(link, 'a'), consume(link, 'b'), consume(link, 'c')]);
|
|
91
|
+
expect(results.filter((r) => r.ok)).toHaveLength(1);
|
|
92
|
+
expect(results.filter((r) => !r.ok)).toEqual([
|
|
93
|
+
{ ok: false, reason: 'used' },
|
|
94
|
+
{ ok: false, reason: 'used' },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
it('refuses a link that has lapsed, and lists it as nothing that still opens', async () => {
|
|
98
|
+
const link = await mint({ expiresAt: lapsed() });
|
|
99
|
+
expect(await consume(link)).toEqual({ ok: false, reason: 'expired' });
|
|
100
|
+
const open = await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1, outstandingOnly: true });
|
|
101
|
+
expect(open.map((l) => l.id)).not.toContain(link.id);
|
|
102
|
+
// The unfiltered list is the history, lapsed rows included.
|
|
103
|
+
const all = await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1 });
|
|
104
|
+
expect(all.map((l) => l.id)).toContain(link.id);
|
|
105
|
+
});
|
|
106
|
+
it('a revoked link refuses, and revoking is idempotent', async () => {
|
|
107
|
+
const link = await mint();
|
|
108
|
+
expect((await host.admin.revokeConnectLink(staff, keyOf(link)))?.status).toBe('revoked');
|
|
109
|
+
expect(await consume(link)).toEqual({ ok: false, reason: 'revoked' });
|
|
110
|
+
expect((await host.admin.revokeConnectLink(staff, keyOf(link)))?.status).toBe('revoked');
|
|
111
|
+
// A spent link has nothing left to revoke — it answers as it stands.
|
|
112
|
+
const spent = await mint();
|
|
113
|
+
await consume(spent);
|
|
114
|
+
expect((await host.admin.revokeConnectLink(staff, keyOf(spent)))?.status).toBe('used');
|
|
115
|
+
});
|
|
116
|
+
it('belongs to one scope: another scope or tenant naming its id finds nothing', async () => {
|
|
117
|
+
const link = await mint();
|
|
118
|
+
for (const key of [
|
|
119
|
+
{ tenantId: t1, scopeId: s2, id: link.id },
|
|
120
|
+
{ tenantId: t2, scopeId: s3, id: link.id },
|
|
121
|
+
{ tenantId: t2, scopeId: s1, id: link.id },
|
|
122
|
+
]) {
|
|
123
|
+
expect(await host.admin.getConnectLink(staff, key)).toBeUndefined();
|
|
124
|
+
expect(await host.admin.revokeConnectLink(staff, key)).toBeUndefined();
|
|
125
|
+
expect(await host.admin.consumeConnectLink(staff, { ...key, provider: 'fortnox', accountRef: 'x' })).toEqual({ ok: false, reason: 'unknown' });
|
|
126
|
+
expect(await host.admin.restoreConnectLink(staff, key)).toBe(false);
|
|
127
|
+
}
|
|
128
|
+
expect((await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s2 })).map((l) => l.id)).not.toContain(link.id);
|
|
129
|
+
expect((await host.admin.listConnectLinks(staff, { tenantId: t2 })).map((l) => l.id)).not.toContain(link.id);
|
|
130
|
+
// A round for another provider is not this link's round either.
|
|
131
|
+
expect(await host.admin.consumeConnectLink(staff, { ...keyOf(link), provider: 'scrive', accountRef: 'x' })).toEqual({ ok: false, reason: 'unknown' });
|
|
132
|
+
// And none of the above touched it.
|
|
133
|
+
expect((await host.admin.getConnectLink(staff, keyOf(link)))?.status).toBe('outstanding');
|
|
134
|
+
expect(await host.admin.getConnectLink(staff, { tenantId: t1, scopeId: s1, id: connectLinkId.parse(ulid()) })).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
it('restore un-spends a used link after a failed store — and only that', async () => {
|
|
137
|
+
const link = await mint();
|
|
138
|
+
expect((await consume(link)).ok).toBe(true);
|
|
139
|
+
expect(await host.admin.restoreConnectLink(staff, keyOf(link))).toBe(true);
|
|
140
|
+
expect(await host.admin.getConnectLink(staff, keyOf(link))).toMatchObject({
|
|
141
|
+
status: 'outstanding',
|
|
142
|
+
usedAt: null,
|
|
143
|
+
accountRef: null,
|
|
144
|
+
accountLabel: null,
|
|
145
|
+
});
|
|
146
|
+
// Outstanding again, so a fresh consent round can spend it.
|
|
147
|
+
expect((await consume(link)).ok).toBe(true);
|
|
148
|
+
// An outstanding link has nothing to restore; a revocation is never undone by it.
|
|
149
|
+
const outstanding = await mint();
|
|
150
|
+
expect(await host.admin.restoreConnectLink(staff, keyOf(outstanding))).toBe(false);
|
|
151
|
+
await host.admin.revokeConnectLink(staff, keyOf(outstanding));
|
|
152
|
+
expect(await host.admin.restoreConnectLink(staff, keyOf(outstanding))).toBe(false);
|
|
153
|
+
expect((await host.admin.getConnectLink(staff, keyOf(outstanding)))?.status).toBe('revoked');
|
|
154
|
+
});
|
|
155
|
+
it('lists only the ids it is given, and only those in the named tenant and scope', async () => {
|
|
156
|
+
const a = await mint();
|
|
157
|
+
const b = await mint();
|
|
158
|
+
const elsewhere = await mint({ scopeId: s2 });
|
|
159
|
+
const other = await mint({ tenantId: t2, scopeId: s3 });
|
|
160
|
+
const ids = [a.id, b.id, elsewhere.id, other.id, connectLinkId.parse(ulid())];
|
|
161
|
+
const named = await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1, ids });
|
|
162
|
+
expect(named.map((l) => l.id).sort()).toEqual([a.id, b.id].sort());
|
|
163
|
+
expect(await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1, ids: [] })).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
describe('a move commits with its audit row, or not at all', () => {
|
|
166
|
+
/** Fail the admin-log insert for `action` in this suite's tenant until `heal`. */
|
|
167
|
+
const failAudit = async (action) => {
|
|
168
|
+
await exec(host, `CREATE TRIGGER fault_connect_link_audit BEFORE INSERT ON _substrat_admin_log
|
|
169
|
+
WHEN NEW.action = '${action}' AND NEW.tenant_id = '${t1}'
|
|
170
|
+
BEGIN SELECT RAISE(ABORT, 'injected fault'); END`);
|
|
171
|
+
return () => exec(host, 'DROP TRIGGER fault_connect_link_audit');
|
|
172
|
+
};
|
|
173
|
+
const statusOf = async (l) => (await host.admin.getConnectLink(staff, keyOf(l)))?.status;
|
|
174
|
+
it('a consume whose audit row fails leaves the link outstanding, and the retry spends it', async () => {
|
|
175
|
+
const link = await mint();
|
|
176
|
+
const heal = await failAudit('consumeConnectLink');
|
|
177
|
+
await expect(consume(link)).rejects.toThrow(/injected fault/);
|
|
178
|
+
expect(await statusOf(link)).toBe('outstanding');
|
|
179
|
+
await heal();
|
|
180
|
+
expect((await consume(link)).ok).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
it('a mint, revoke or restore whose audit row fails changes nothing', async () => {
|
|
183
|
+
const before = (await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1 })).length;
|
|
184
|
+
let heal = await failAudit('mintConnectLink');
|
|
185
|
+
await expect(mint()).rejects.toThrow(/injected fault/);
|
|
186
|
+
await heal();
|
|
187
|
+
expect(await host.admin.listConnectLinks(staff, { tenantId: t1, scopeId: s1 })).toHaveLength(before);
|
|
188
|
+
const open = await mint();
|
|
189
|
+
heal = await failAudit('revokeConnectLink');
|
|
190
|
+
await expect(host.admin.revokeConnectLink(staff, keyOf(open))).rejects.toThrow(/injected fault/);
|
|
191
|
+
await heal();
|
|
192
|
+
expect(await statusOf(open)).toBe('outstanding');
|
|
193
|
+
const spent = await mint();
|
|
194
|
+
expect((await consume(spent)).ok).toBe(true);
|
|
195
|
+
heal = await failAudit('restoreConnectLink');
|
|
196
|
+
await expect(host.admin.restoreConnectLink(staff, keyOf(spent))).rejects.toThrow(/injected fault/);
|
|
197
|
+
await heal();
|
|
198
|
+
expect(await statusOf(spent)).toBe('used');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
it('audits mint, revoke, consume and restore — and not a revoke that changed nothing', async () => {
|
|
202
|
+
const link = await mint();
|
|
203
|
+
await host.admin.revokeConnectLink(staff, keyOf(link));
|
|
204
|
+
await host.admin.revokeConnectLink(staff, keyOf(link));
|
|
205
|
+
const spent = await mint();
|
|
206
|
+
await consume(spent);
|
|
207
|
+
await host.admin.restoreConnectLink(staff, keyOf(spent));
|
|
208
|
+
const ids = [link.id, spent.id];
|
|
209
|
+
const rows = (await host.admin.auditLog(staff, { tenantId: t1 })).filter((r) => ids.includes(r.after?.id ?? ''));
|
|
210
|
+
expect(rows.map((r) => r.action).sort()).toEqual(['consumeConnectLink', 'mintConnectLink', 'mintConnectLink', 'restoreConnectLink', 'revokeConnectLink'].sort());
|
|
211
|
+
expect(rows.every((r) => r.scopeId === s1)).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=connect-link-suite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect-link-suite.js","sourceRoot":"","sources":["../src/connect-link-suite.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ScopeId, type TenantId } from '@substrat-run/contracts';
|
|
2
|
+
import type { ScopeHostFixture } from './scope-host-suite.js';
|
|
3
|
+
type Row = Record<string, unknown>;
|
|
4
|
+
/** The adapter's way past `ctx.sql` and into its migration bookkeeping. */
|
|
5
|
+
export interface RawScopeAccess {
|
|
6
|
+
/** One statement on the scope's own database; a SELECT's rows. */
|
|
7
|
+
sql(tenant: TenantId, scope: ScopeId, sql: string): Promise<Row[]>;
|
|
8
|
+
/**
|
|
9
|
+
* Take `moduleId@version` out of the scope's journal AND out of whatever the host remembers of
|
|
10
|
+
* it, so the next pass runs that migration again.
|
|
11
|
+
*/
|
|
12
|
+
forget(tenant: TenantId, scope: ScopeId, moduleId: string, version: string): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function entityStateMigrationContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture>, raw: RawScopeAccess): void;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=entity-state-migration-suite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entity-state-migration-suite.d.ts","sourceRoot":"","sources":["../src/entity-state-migration-suite.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAkE,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAEtI,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAG9D,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEnC,2EAA2E;AAC3E,MAAM,WAAW,cAAc;IAC7B,kEAAkE;IAClE,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IACnE;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5F;AAKD,wBAAgB,iCAAiC,CAC/C,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,GAAG,EAAE,cAAc,GAClB,IAAI,CAyUN"}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite for an authored migration that rebuilds a table the kernel derived onto (#2090).
|
|
3
|
+
*
|
|
4
|
+
* SQLite's create-copy-rename rebuild drops a table's triggers and indexes with it. On a table
|
|
5
|
+
* that declares archive or trash, those are the kernel's guard triggers and its partial list
|
|
6
|
+
* indexes — and the migrations that made them are journaled, so they never run again. A
|
|
7
|
+
* migration runs on the scope's own handle, so `ctx.sql`'s after-DDL check never sees it
|
|
8
|
+
* either. What this suite holds the migration pass to:
|
|
9
|
+
*
|
|
10
|
+
* - the derived objects come back, and the guard holds after them;
|
|
11
|
+
* - a migration that takes a STATE column away fails the scope closed, rolled back, with the
|
|
12
|
+
* rows keeping their state — a column cannot be re-derived without inventing what it held;
|
|
13
|
+
* - a rebuild split over two migrations still copies its binned rows, so the guard comes back
|
|
14
|
+
* only after the last migration of the pass;
|
|
15
|
+
* - a rebuilt searchable table gets its search triggers back and its index rebuilt, so text
|
|
16
|
+
* rewritten or deleted afterwards — what subject erasure does — leaves the index;
|
|
17
|
+
* - an authored rebuild of a table that declares no state runs as it always did.
|
|
18
|
+
*
|
|
19
|
+
* Each case provisions a scope of its own. Most make it forget ONE rebuild and run the pass
|
|
20
|
+
* again: what a redeploy that added that rebuild to a live scope does. The rest change the
|
|
21
|
+
* schema by hand with every migration journaled — a scope stripped before the pass checked, or
|
|
22
|
+
* left with a derived object that has the right name and the wrong definition — and run
|
|
23
|
+
* `migrateScope`, which repairs with nothing pending.
|
|
24
|
+
*/
|
|
25
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
26
|
+
import { permissionKey, platformActorId, principalId, scopeId, tenantId } from '@substrat-run/contracts';
|
|
27
|
+
import { ulid } from '@substrat-run/kernel';
|
|
28
|
+
import { rebuildMod } from './entity-state-module.js';
|
|
29
|
+
const MODULE = '@test/rebuild';
|
|
30
|
+
const STATEFUL = new Set(['rbnote', 'rbcut', 'rbsplit']);
|
|
31
|
+
export function entityStateMigrationContractSuite(adapterName, makeFixture, raw) {
|
|
32
|
+
describe(`an authored migration rebuilding a table the kernel derived onto (#2090): ${adapterName}`, () => {
|
|
33
|
+
let fixture;
|
|
34
|
+
let host;
|
|
35
|
+
const t = tenantId.parse(ulid());
|
|
36
|
+
const staff = platformActorId.parse(ulid());
|
|
37
|
+
const keeper = principalId.parse(ulid());
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
fixture = await makeFixture();
|
|
40
|
+
host = fixture.host;
|
|
41
|
+
host.registerModule(rebuildMod);
|
|
42
|
+
await host.admin.createTenant(staff, { id: t, slug: `rebuild-${t.toLowerCase()}`, name: 'Rebuild' });
|
|
43
|
+
await host.admin.grantEntitlement(staff, t, 'rebuild');
|
|
44
|
+
await host.admin.defineRole(staff, t, {
|
|
45
|
+
key: 'rb-keeper',
|
|
46
|
+
permissions: ['rb:use', 'rb:archive', 'rb:trash'].map((k) => permissionKey.parse(k)),
|
|
47
|
+
source: 'vertical',
|
|
48
|
+
});
|
|
49
|
+
await host.admin.assignRole(staff, { principalId: keeper, roleKey: 'rb-keeper', node: { tenantId: t, scopeId: null } });
|
|
50
|
+
});
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await fixture.cleanup();
|
|
53
|
+
});
|
|
54
|
+
const freshScope = async () => {
|
|
55
|
+
const s = scopeId.parse(ulid());
|
|
56
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: s, vertical: 'rebuild-vertical' });
|
|
57
|
+
await host.admin.activateScope(staff, t, s);
|
|
58
|
+
return { s, stub: await host.getScope(keeper, t, s) };
|
|
59
|
+
};
|
|
60
|
+
/** A provisioned scope holding one active and one binned row of `entityType`. */
|
|
61
|
+
const scopeWith = async (entityType) => {
|
|
62
|
+
const { s, stub } = await freshScope();
|
|
63
|
+
const kept = ulid();
|
|
64
|
+
const binned = ulid();
|
|
65
|
+
await stub.invoke('rb/add', { entityType, id: kept, title: 'kept' });
|
|
66
|
+
await stub.invoke('rb/add', { entityType, id: binned, title: 'binned' });
|
|
67
|
+
if (STATEFUL.has(entityType))
|
|
68
|
+
await stub.invoke('rb/trash', { entityType, id: binned });
|
|
69
|
+
return { s, stub, kept, binned };
|
|
70
|
+
};
|
|
71
|
+
const objectsOn = async (s, table) => (await raw.sql(t, s, `SELECT name FROM sqlite_master WHERE tbl_name = '${table}' AND name LIKE '\\_substrat\\_%' ESCAPE '\\' ORDER BY name`)).map((r) => String(r['name']));
|
|
72
|
+
const columnsOf = async (s, table) => (await raw.sql(t, s, `SELECT name FROM pragma_table_info('${table}')`)).map((r) => String(r['name']));
|
|
73
|
+
/** Every kernel-prefixed object in the scope, name → its CREATE statement. */
|
|
74
|
+
const definitions = async (s) => Object.fromEntries((await raw.sql(t, s, `SELECT name, sql FROM sqlite_master WHERE name LIKE '\\_substrat\\_%' ESCAPE '\\' AND sql IS NOT NULL`))
|
|
75
|
+
.filter((r) => /^_substrat_(state|list|search)_/.test(String(r['name'])))
|
|
76
|
+
.map((r) => [String(r['name']), String(r['sql'])]));
|
|
77
|
+
/** Statements on the scope's own handle, one at a time — past `ctx.sql`, the journal untouched. */
|
|
78
|
+
const rawAll = async (s, statements) => {
|
|
79
|
+
for (const statement of statements)
|
|
80
|
+
await raw.sql(t, s, statement);
|
|
81
|
+
};
|
|
82
|
+
/** A rebuilt-by-hand table, as a migration journaled before #2090 would have left it. */
|
|
83
|
+
const rebuild = (table, select = '*') => [
|
|
84
|
+
`CREATE TABLE ${table}_new AS SELECT ${select} FROM ${table}`,
|
|
85
|
+
`DROP TABLE ${table}`,
|
|
86
|
+
`ALTER TABLE ${table}_new RENAME TO ${table}`,
|
|
87
|
+
];
|
|
88
|
+
it('puts back the guard triggers and list indexes a rebuild dropped, and the guard holds after it', async () => {
|
|
89
|
+
const { s, stub, binned } = await scopeWith('rbnote');
|
|
90
|
+
const derived = await objectsOn(s, 'rb_notes');
|
|
91
|
+
expect(derived).toEqual([
|
|
92
|
+
'_substrat_list_test_rebuild_rbnote_title',
|
|
93
|
+
'_substrat_list_test_rebuild_rbnote_title_archived',
|
|
94
|
+
'_substrat_list_test_rebuild_rbnote_title_trashed',
|
|
95
|
+
'_substrat_state_rb_notes_born',
|
|
96
|
+
'_substrat_state_rb_notes_moved',
|
|
97
|
+
]);
|
|
98
|
+
await raw.forget(t, s, MODULE, '0002-rebuild-notes');
|
|
99
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'migrated' });
|
|
100
|
+
expect(await objectsOn(s, 'rb_notes')).toEqual(derived);
|
|
101
|
+
// The guard, past `ctx.sql`: a row moves only through a kernel verb, and is never born binned.
|
|
102
|
+
await expect(raw.sql(t, s, `UPDATE rb_notes SET _substrat_trashed_at = NULL WHERE id = '${binned}'`)).rejects.toThrow(/moves only through ctx\.archive/);
|
|
103
|
+
await expect(raw.sql(t, s, `INSERT INTO rb_notes (id, title, _substrat_archived_at) VALUES ('${ulid()}', 'x', '2026')`)).rejects.toThrow(/never inserted archived or trashed/);
|
|
104
|
+
// The rows kept their state through the rebuild, and the kernel's own move still passes.
|
|
105
|
+
expect(await stub.invoke('rb/state', { entityType: 'rbnote', id: binned })).toBe('trashed');
|
|
106
|
+
await stub.invoke('rb/restore', { entityType: 'rbnote', id: binned });
|
|
107
|
+
expect(await stub.invoke('rb/state', { entityType: 'rbnote', id: binned })).toBe('active');
|
|
108
|
+
});
|
|
109
|
+
it('fails the scope closed when a migration drops a state column — rolled back, every row keeping its state', async () => {
|
|
110
|
+
const { s, binned } = await scopeWith('rbcut');
|
|
111
|
+
const before = await objectsOn(s, 'rb_cut');
|
|
112
|
+
// Not the last of its pass, so nothing after it gets the chance to cover for it.
|
|
113
|
+
await raw.forget(t, s, MODULE, '0003-rebuild-cut');
|
|
114
|
+
await raw.forget(t, s, MODULE, '0004-rebuild-plain');
|
|
115
|
+
const outcome = await host.migrateScope(t, s);
|
|
116
|
+
expect(outcome).toMatchObject({ status: 'failed', failure: { version: `${MODULE}@0003-rebuild-cut` } });
|
|
117
|
+
expect(outcome.status === 'failed' && outcome.failure.error).toMatch(/migration @test\/rebuild@0003-rebuild-cut left 'rb_cut' without _substrat_trashed_at/);
|
|
118
|
+
expect((await host.admin.getScopeRecord(staff, t, s))?.migrationFailure?.version).toBe(`${MODULE}@0003-rebuild-cut`);
|
|
119
|
+
// Nothing is served, and a second pass fails the same way: it is the migration, not a moment.
|
|
120
|
+
await expect(host.getScope(keeper, t, s).then((stub) => stub.invoke('rb/state', { entityType: 'rbcut', id: binned }))).rejects.toThrow(/without _substrat_trashed_at/);
|
|
121
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'failed' });
|
|
122
|
+
// Rolled back: the column, the binned row in it, and the guard are all where they were.
|
|
123
|
+
expect(await columnsOf(s, 'rb_cut')).toContain('_substrat_trashed_at');
|
|
124
|
+
expect(await raw.sql(t, s, `SELECT _substrat_trashed_at IS NOT NULL AS binned FROM rb_cut WHERE id = '${binned}'`)).toEqual([
|
|
125
|
+
{ binned: 1 },
|
|
126
|
+
]);
|
|
127
|
+
expect(await objectsOn(s, 'rb_cut')).toEqual(before);
|
|
128
|
+
});
|
|
129
|
+
it('lets a rebuild split over two migrations copy its binned rows, and puts the guard back after the last', async () => {
|
|
130
|
+
const { s, stub, binned } = await scopeWith('rbsplit');
|
|
131
|
+
const before = await objectsOn(s, 'rb_split');
|
|
132
|
+
// A guard put back between the two would refuse the binned row the second one copies.
|
|
133
|
+
await raw.forget(t, s, MODULE, '0005-split-aside');
|
|
134
|
+
await raw.forget(t, s, MODULE, '0006-split-copy');
|
|
135
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'migrated' });
|
|
136
|
+
expect(await objectsOn(s, 'rb_split')).toEqual(before);
|
|
137
|
+
expect(await stub.invoke('rb/state', { entityType: 'rbsplit', id: binned })).toBe('trashed');
|
|
138
|
+
await expect(raw.sql(t, s, `UPDATE rb_split SET _substrat_trashed_at = NULL WHERE id = '${binned}'`)).rejects.toThrow(/moves only through ctx\.archive/);
|
|
139
|
+
});
|
|
140
|
+
it('rebuilds a searchable table’s index and its triggers, so rewritten or deleted text leaves search', async () => {
|
|
141
|
+
const { s, stub } = await freshScope();
|
|
142
|
+
const [rewritten, deleted, untouched] = [ulid(), ulid(), ulid()];
|
|
143
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: rewritten, title: 'alpha secret' });
|
|
144
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: deleted, title: 'beta private' });
|
|
145
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: untouched, title: 'delta plain' });
|
|
146
|
+
const before = await objectsOn(s, 'rb_search');
|
|
147
|
+
expect(before).toEqual(['ad', 'ai', 'au'].map((s) => `_substrat_search_test_rebuild_rbsearch_${s}`));
|
|
148
|
+
// The rebuild copies the rows in reverse, so every rowid the old index points at moves.
|
|
149
|
+
await raw.forget(t, s, MODULE, '0007-rebuild-search');
|
|
150
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'migrated' });
|
|
151
|
+
expect(await objectsOn(s, 'rb_search')).toEqual(before);
|
|
152
|
+
expect(await stub.invoke('rb/search', { term: 'alpha' })).toEqual([rewritten]);
|
|
153
|
+
expect(await stub.invoke('rb/search', { term: 'delta' })).toEqual([untouched]);
|
|
154
|
+
// Erasure's two shapes: the text rewritten in place, and the row removed.
|
|
155
|
+
await stub.invoke('rb/retitle', { id: rewritten, title: 'gamma redacted' });
|
|
156
|
+
await stub.invoke('rb/remove', { id: deleted });
|
|
157
|
+
expect(await stub.invoke('rb/search', { term: 'secret' })).toEqual([]);
|
|
158
|
+
expect(await stub.invoke('rb/search', { term: 'private' })).toEqual([]);
|
|
159
|
+
expect(await stub.invoke('rb/search', { term: 'gamma' })).toEqual([rewritten]);
|
|
160
|
+
});
|
|
161
|
+
// -- scopes already stripped, every migration journaled (Codex r1 on #2091) -----------------
|
|
162
|
+
it('repairs a scope a journaled rebuild already stripped — with nothing pending, on the next pass', async () => {
|
|
163
|
+
const { s, stub, binned } = await scopeWith('rbnote');
|
|
164
|
+
const said = ulid();
|
|
165
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: said, title: 'alpha secret' });
|
|
166
|
+
const before = await definitions(s);
|
|
167
|
+
await rawAll(s, [...rebuild('rb_notes'), ...rebuild('rb_search')]);
|
|
168
|
+
expect(Object.keys(await definitions(s))).not.toContain('_substrat_state_rb_notes_born');
|
|
169
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'noop' });
|
|
170
|
+
expect(await definitions(s)).toEqual(before);
|
|
171
|
+
await expect(raw.sql(t, s, `UPDATE rb_notes SET _substrat_trashed_at = NULL WHERE id = '${binned}'`)).rejects.toThrow(/moves only through ctx\.archive/);
|
|
172
|
+
await stub.invoke('rb/retitle', { id: said, title: 'gamma redacted' });
|
|
173
|
+
expect(await stub.invoke('rb/search', { term: 'secret' })).toEqual([]);
|
|
174
|
+
expect(await stub.invoke('rb/search', { term: 'gamma' })).toEqual([said]);
|
|
175
|
+
});
|
|
176
|
+
it('re-creates a derived object present by name but not as the kernel defines it', async () => {
|
|
177
|
+
const { s, stub, binned } = await scopeWith('rbnote');
|
|
178
|
+
const said = ulid();
|
|
179
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: said, title: 'alpha secret' });
|
|
180
|
+
const before = await definitions(s);
|
|
181
|
+
await rawAll(s, [
|
|
182
|
+
// A search update trigger that keeps nothing in step: erased text would stay found.
|
|
183
|
+
'DROP TRIGGER _substrat_search_test_rebuild_rbsearch_au',
|
|
184
|
+
'CREATE TRIGGER _substrat_search_test_rebuild_rbsearch_au AFTER UPDATE ON rb_search BEGIN SELECT 1; END',
|
|
185
|
+
// A guard that never fires.
|
|
186
|
+
'DROP TRIGGER _substrat_state_rb_notes_moved',
|
|
187
|
+
'CREATE TRIGGER _substrat_state_rb_notes_moved BEFORE UPDATE OF _substrat_trashed_at ON rb_notes WHEN 0 BEGIN SELECT 1; END',
|
|
188
|
+
// A list index on the wrong columns, and one with the wrong partial predicate.
|
|
189
|
+
'DROP INDEX _substrat_list_test_rebuild_rbnote_title',
|
|
190
|
+
'CREATE INDEX _substrat_list_test_rebuild_rbnote_title ON rb_notes (id)',
|
|
191
|
+
'DROP INDEX _substrat_list_test_rebuild_rbnote_title_archived',
|
|
192
|
+
'CREATE INDEX _substrat_list_test_rebuild_rbnote_title_archived ON rb_notes (title, id) WHERE _substrat_archived_at IS NULL',
|
|
193
|
+
]);
|
|
194
|
+
expect(await definitions(s)).not.toEqual(before);
|
|
195
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'noop' });
|
|
196
|
+
expect(await definitions(s)).toEqual(before);
|
|
197
|
+
await expect(raw.sql(t, s, `UPDATE rb_notes SET _substrat_trashed_at = NULL WHERE id = '${binned}'`)).rejects.toThrow(/moves only through ctx\.archive/);
|
|
198
|
+
await stub.invoke('rb/retitle', { id: said, title: 'gamma redacted' });
|
|
199
|
+
expect(await stub.invoke('rb/search', { term: 'secret' })).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
it('fails closed on the next pass when a journaled state column is already gone — nothing is served', async () => {
|
|
202
|
+
const { s, binned } = await scopeWith('rbcut');
|
|
203
|
+
await rawAll(s, rebuild('rb_cut', 'id, title'));
|
|
204
|
+
const outcome = await host.migrateScope(t, s);
|
|
205
|
+
expect(outcome).toMatchObject({ status: 'failed', failure: { version: `${MODULE}@state/rbcut:trash` } });
|
|
206
|
+
expect(outcome.status === 'failed' && outcome.failure.error).toMatch(/an applied migration left 'rb_cut' without _substrat_trashed_at/);
|
|
207
|
+
await expect(host.getScope(keeper, t, s).then((stub) => stub.invoke('rb/state', { entityType: 'rbcut', id: binned }))).rejects.toThrow(/without _substrat_trashed_at/);
|
|
208
|
+
});
|
|
209
|
+
it('refuses a restore whose journal owes a table a state column the dump does not carry — the load rolls back whole', async () => {
|
|
210
|
+
const { s, stub, binned } = await scopeWith('rbcut');
|
|
211
|
+
const before = await definitions(s);
|
|
212
|
+
const dump = await host.admin.exportScope(staff, t, s);
|
|
213
|
+
const malformed = {
|
|
214
|
+
...dump,
|
|
215
|
+
tables: dump.tables.map((table) => {
|
|
216
|
+
if (table.name !== 'rb_cut')
|
|
217
|
+
return table;
|
|
218
|
+
const keep = table.columns.map((c, i) => (c === '_substrat_trashed_at' ? -1 : i)).filter((i) => i >= 0);
|
|
219
|
+
return {
|
|
220
|
+
...table,
|
|
221
|
+
ddl: `CREATE TABLE rb_cut (${keep.map((i) => `${table.columns[i]} TEXT`).join(', ')})`,
|
|
222
|
+
columns: keep.map((i) => table.columns[i]),
|
|
223
|
+
rows: table.rows.map((row) => keep.map((i) => row[i])),
|
|
224
|
+
};
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
await expect(host.restoreScope(staff, t, s, malformed)).rejects.toThrow(/the restored dump left 'rb_cut' without _substrat_trashed_at/);
|
|
228
|
+
// Nothing of the load committed: the column, the binned row and the guard are all as they were.
|
|
229
|
+
expect(await columnsOf(s, 'rb_cut')).toContain('_substrat_trashed_at');
|
|
230
|
+
expect(await definitions(s)).toEqual(before);
|
|
231
|
+
expect(await stub.invoke('rb/state', { entityType: 'rbcut', id: binned })).toBe('trashed');
|
|
232
|
+
// The twin: the same dump, unaltered, restores.
|
|
233
|
+
await host.restoreScope(staff, t, s, dump);
|
|
234
|
+
expect(await stub.invoke('rb/state', { entityType: 'rbcut', id: binned })).toBe('trashed');
|
|
235
|
+
});
|
|
236
|
+
it('repairs a search-only table a module rebuilt through ctx.sql, before its operation commits (Codex r2 on #2091)', async () => {
|
|
237
|
+
const { s, stub } = await freshScope();
|
|
238
|
+
const said = ulid();
|
|
239
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: said, title: 'alpha secret' });
|
|
240
|
+
const before = await definitions(s);
|
|
241
|
+
// Supported runtime DDL: `rb_search` declares no state, so no text guard stands in the way.
|
|
242
|
+
await stub.invoke('rb/ddl', { statements: rebuild('rb_search') });
|
|
243
|
+
expect(await definitions(s)).toEqual(before);
|
|
244
|
+
await stub.invoke('rb/retitle', { id: said, title: 'gamma redacted' });
|
|
245
|
+
expect(await stub.invoke('rb/search', { term: 'secret' })).toEqual([]);
|
|
246
|
+
expect(await stub.invoke('rb/search', { term: 'gamma' })).toEqual([said]);
|
|
247
|
+
await stub.invoke('rb/remove', { id: said });
|
|
248
|
+
expect(await stub.invoke('rb/search', { term: 'gamma' })).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
it('repairs between the statements of ONE ctx.sql call — a rebuild, a write and a read of the index see no stale hit (Codex r3)', async () => {
|
|
251
|
+
const { s, stub } = await freshScope();
|
|
252
|
+
const said = ulid();
|
|
253
|
+
await stub.invoke('rb/add', { entityType: 'rbsearch', id: said, title: 'alpha secret' });
|
|
254
|
+
const idx = '_substrat_search_test_rebuild_rbsearch';
|
|
255
|
+
const rows = await stub.invoke('rb/batch', {
|
|
256
|
+
sql: [
|
|
257
|
+
...rebuild('rb_search'),
|
|
258
|
+
`UPDATE rb_search SET title = 'gamma redacted' WHERE id = '${said}'`,
|
|
259
|
+
`SELECT COUNT(*) AS n FROM ${idx} WHERE ${idx} MATCH 'secret'`,
|
|
260
|
+
].join(';\n'),
|
|
261
|
+
});
|
|
262
|
+
expect(rows).toEqual([{ n: 0 }]);
|
|
263
|
+
expect(await stub.invoke('rb/search', { term: 'gamma' })).toEqual([said]);
|
|
264
|
+
});
|
|
265
|
+
it('refuses a multi-statement schema change that binds parameters, and runs none of it', async () => {
|
|
266
|
+
const { s, stub } = await freshScope();
|
|
267
|
+
const before = await definitions(s);
|
|
268
|
+
await expect(stub.invoke('rb/ddl-bound', {})).rejects.toThrow(/binds parameters runs one statement/);
|
|
269
|
+
expect(await columnsOf(s, 'rb_plain')).toEqual(['id', 'title']);
|
|
270
|
+
expect(await definitions(s)).toEqual(before);
|
|
271
|
+
});
|
|
272
|
+
it('runs runtime DDL comment-blanked: a table created with comments can later drop its last column (#2084 × #2090)', async () => {
|
|
273
|
+
const { s, stub } = await freshScope();
|
|
274
|
+
const t2 = `rt_${ulid().toLowerCase()}`;
|
|
275
|
+
await stub.invoke('rb/ddl', {
|
|
276
|
+
statements: [`CREATE TABLE ${t2} (\n id TEXT PRIMARY KEY, -- the id\n note TEXT -- dropped later\n)`],
|
|
277
|
+
});
|
|
278
|
+
const stored = await raw.sql(t, s, `SELECT sql FROM sqlite_master WHERE name = '${t2}'`);
|
|
279
|
+
expect(String(stored[0]['sql'])).not.toMatch(/--/);
|
|
280
|
+
// workerd's SQLite refuses this rewrite of stored DDL with line comments before the last column.
|
|
281
|
+
await stub.invoke('rb/ddl', { statements: [`ALTER TABLE ${t2} DROP COLUMN note`] });
|
|
282
|
+
expect(await columnsOf(s, t2)).toEqual(['id']);
|
|
283
|
+
});
|
|
284
|
+
it('runs no DDL over a scope that already carries everything as the kernel emits it', async () => {
|
|
285
|
+
const { s } = await scopeWith('rbnote');
|
|
286
|
+
await host.migrateScope(t, s);
|
|
287
|
+
// A re-created object is a new `sqlite_master` row: the rowids are what a DROP-and-CREATE
|
|
288
|
+
// moves. (A Durable Object refuses `pragma_schema_version`.)
|
|
289
|
+
const catalogue = async () => raw.sql(t, s, 'SELECT rowid, name FROM sqlite_master ORDER BY rowid');
|
|
290
|
+
const before = await catalogue();
|
|
291
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'noop' });
|
|
292
|
+
expect(await catalogue()).toEqual(before);
|
|
293
|
+
});
|
|
294
|
+
it('twin: a rebuild of a table that declares no state runs as before, and its list index comes back too', async () => {
|
|
295
|
+
const { s, stub } = await scopeWith('rbplain');
|
|
296
|
+
const before = await objectsOn(s, 'rb_plain');
|
|
297
|
+
expect(before).toEqual(['_substrat_list_test_rebuild_rbplain_title']);
|
|
298
|
+
await raw.forget(t, s, MODULE, '0004-rebuild-plain');
|
|
299
|
+
expect(await host.migrateScope(t, s)).toMatchObject({ status: 'migrated' });
|
|
300
|
+
expect(await stub.invoke('rb/titles', { entityType: 'rbplain' })).toEqual(['binned', 'kept']);
|
|
301
|
+
expect(await objectsOn(s, 'rb_plain')).toEqual(before);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
//# sourceMappingURL=entity-state-migration-suite.js.map
|