@empire-builder-kit/nx 1.0.0-rc.40 → 1.0.0-rc.41
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 +26 -10
- package/package.json +3 -3
- package/src/blueprints/external-sandbox.d.ts +1 -1
- package/src/cli/deploy-runtime.d.ts +25 -1
- package/src/cli/deploy-runtime.js +120 -4
- package/src/cli/dev-command.d.ts +2 -0
- package/src/cli/dev-command.js +8 -0
- package/src/cli/main.js +20 -6
- package/src/cli/personal-secret-runtime.d.ts +12 -0
- package/src/cli/personal-secret-runtime.js +18 -3
- package/src/cli/secret-terminal.js +1 -1
- package/src/container-images.d.ts +1 -1
- package/src/container-images.js +1 -1
- package/src/developer-agent/operation-registry.d.ts +10 -0
- package/src/developer-agent/operation-registry.js +60 -13
- package/src/developer-agent/planning.d.ts +8 -1
- package/src/developer-agent/planning.js +23 -3
- package/src/executors/local-deploy/local-approval.js +1 -1
- package/src/executors/sst-lifecycle/governed-native-operation.js +3 -2
- package/src/executors/sst-lifecycle/run-sst-command.d.ts +6 -0
- package/src/executors/sst-lifecycle/run-sst-command.js +3 -33
- package/src/foundation/cost-estimate.d.ts +170 -0
- package/src/foundation/cost-estimate.js +683 -0
- package/src/generators/preset/tool-versions.d.ts +0 -2
- package/src/generators/preset/tool-versions.js +1 -3
- package/src/generators/preset/workspace-files-adopter-skills.js +39 -18
- package/src/generators/preset/workspace-files-developer-agent.js +83 -27
- package/src/generators/preset/workspace-files-shared-operation-claims.d.ts +10 -0
- package/src/generators/preset/workspace-files-shared-operation-claims.js +59 -0
- package/src/generators/preset/workspace-files-stage-authority.js +13 -2
- package/src/generators/preset/workspace-files-thin-deploy.d.ts +10 -0
- package/src/generators/preset/workspace-files-thin-deploy.js +54 -20
- package/src/generators/preset/workspace-files-thin-docs.js +32 -14
- package/src/generators/preset/workspace-files.js +28 -8
- package/src/generators/slice/built-in-plan-api-contract-files.js +7 -4
- package/src/generators/slice/built-in-plan-authorization-files.js +10 -5
- package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js +933 -249
- package/src/generators/slice/built-in-plan-data-files.js +17 -8
- package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js +5 -1
- package/src/generators/slice/built-in-plan-frontend-files.js +7 -0
- package/src/generators/slice/built-in-plan-infrastructure-files.js +0 -1
- package/src/generators/slice/built-in-plan-infrastructure-safety-files.js +8 -3
- package/src/generators/slice/built-in-plan-metadata-files.js +2 -1
- package/src/generators/slice/built-in-plan-reliability-files.js +4 -1
- package/src/generators/slice/built-in-plan-testing-persistence-event-files.d.ts +7 -0
- package/src/generators/slice/built-in-plan-testing-persistence-event-files.js +487 -0
- package/src/generators/slice/built-in-plan-testing-persistence-files.js +2 -0
- package/src/generators/slice/built-in-plan.js +3 -1
- package/src/generators/workload/frozen-container.d.ts +1 -1
- package/src/git-policy/github-bootstrap.d.ts +9 -0
- package/src/git-policy/github-bootstrap.js +16 -2
- package/src/local-dev/fleet-inventory.js +18 -10
- package/src/local-dev/fleet-launcher.js +2 -2
- package/src/secrets/personal-command.d.ts +18 -2
- package/src/secrets/personal-command.js +66 -12
- package/src/secrets/verification.js +6 -1
|
@@ -292,8 +292,8 @@ human-supplied—controls cleanup.
|
|
|
292
292
|
|
|
293
293
|
## Personal human-supplied development value
|
|
294
294
|
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
Setting a human-supplied operator-declared personal value is a human-only
|
|
296
|
+
step. Removing one carries no value and may be started by an agent:
|
|
297
297
|
|
|
298
298
|
\`\`\`bash
|
|
299
299
|
ebk secret set ${name} <logical-name>
|
|
@@ -303,10 +303,15 @@ ebk secret remove ${name} <logical-name>
|
|
|
303
303
|
The CLI derives the personal stage, reads the profile and Region saved by
|
|
304
304
|
\`ebk dev configure\`, proves the current AWS account is the development
|
|
305
305
|
Foundation account, and invokes the workspace-pinned SST CLI. Set accepts the
|
|
306
|
-
value only through a hidden TTY prompt
|
|
306
|
+
value only through a hidden TTY prompt and refuses agent and CI environments,
|
|
307
|
+
because a hidden prompt does not hide the value from the process that owns the
|
|
308
|
+
terminal: a human runs it in a terminal no agent spawned. An agent prepares
|
|
309
|
+
that step by naming the exact logical name and command, then resumes without
|
|
310
|
+
reading the value. Remove may be started by an agent; the human types the
|
|
311
|
+
hidden \`REMOVE <logical-name>\` confirmation in that interactive terminal.
|
|
307
312
|
Neither command accepts a stage, profile, Region, value argument, environment
|
|
308
|
-
value, file, fallback, or placeholder.
|
|
309
|
-
|
|
313
|
+
value, file, fallback, or placeholder. Never ask an agent or CI job to run
|
|
314
|
+
\`ebk secret set\`.
|
|
310
315
|
|
|
311
316
|
## Development metadata
|
|
312
317
|
|
|
@@ -357,9 +362,13 @@ Account-level role materialization remains an adopter responsibility.
|
|
|
357
362
|
|
|
358
363
|
After an empty human-supplied container exists, a human secret operator enters
|
|
359
364
|
or rotates the value through the approved hidden-input or provider-owned
|
|
360
|
-
ceremony.
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
ceremony. Only the value entry is human-only: an agent may confirm the empty
|
|
366
|
+
container through the value-blind \`secret-metadata\` report, tell the operator
|
|
367
|
+
the exact container name and stage, and verify afterwards with the same report
|
|
368
|
+
and the deployment; it never enters, reads, or relays the value. Do not
|
|
369
|
+
delegate that human-supplied ceremony to an agent and do not pass a value
|
|
370
|
+
through argv, source, a file, a ticket, a work marker, GitHub, logs, or
|
|
371
|
+
evidence. Routine operator authority is write-only for exact approved
|
|
363
372
|
paths and has no application or Foundation deployment authority. Value reads
|
|
364
373
|
require a separate audited break-glass path. Machine-generated declarations
|
|
365
374
|
instead use \`secret-initialize\`; they never accept a human value.
|
|
@@ -428,7 +428,11 @@ The command accepts no arguments, so an operator cannot supply a stage, Slice,
|
|
|
428
428
|
database, state machine, task, role, network, image, or command override.
|
|
429
429
|
|
|
430
430
|
Run the generated \`db-retirement-rehearsal\` target only through the protected
|
|
431
|
-
workflow
|
|
431
|
+
workflow: the run binds the reviewed candidate image digest and a deployment role
|
|
432
|
+
scoped to the one registered state machine, and those exist only there. Locally,
|
|
433
|
+
an agent may complete the registration and binding edits above, prepare them
|
|
434
|
+
for review, and propose the workflow run; it does not start the state machine
|
|
435
|
+
from a workstation. The deployment role needs
|
|
432
436
|
\`states:StartExecution\` on the exact registered state machine and
|
|
433
437
|
\`states:DescribeExecution\` on its exact execution namespace. It receives no
|
|
434
438
|
database credential, secret-value, SQL, \`rds-db:connect\`, or arbitrary ECS
|
|
@@ -309,6 +309,13 @@ looks reusable.
|
|
|
309
309
|
|
|
310
310
|
## Manual review
|
|
311
311
|
|
|
312
|
+
An agent driving a real browser may perform and record the keyboard, focus,
|
|
313
|
+
zoom/reflow, reduced-motion, and announcement checks below and attach them to
|
|
314
|
+
the visual report. A human using actual assistive technology (screen reader,
|
|
315
|
+
switch access, or magnifier) completes the assistive-technology pass and is
|
|
316
|
+
recorded as the reviewer for that pass; the quarantine approver must still be
|
|
317
|
+
distinct from the owner.
|
|
318
|
+
|
|
312
319
|
Confirm, at minimum:
|
|
313
320
|
|
|
314
321
|
1. landmark and heading order, names, roles, descriptions, and state;
|
|
@@ -976,7 +976,6 @@ ${sql
|
|
|
976
976
|
},
|
|
977
977
|
handler: 'authorization/machine-client-provisioner.handler',
|
|
978
978
|
link: secretLinksFor('machine-client-provisioner'),
|
|
979
|
-
runtime: 'python3.12',
|
|
980
979
|
timeout: '2 minutes',
|
|
981
980
|
...lambdaLogging(machineClientProvisionerLogs.logGroup.name),
|
|
982
981
|
},
|
|
@@ -332,7 +332,9 @@ apply; it is never promoted to \`no-drift\`. The exact SST/provider call
|
|
|
332
332
|
inventory still requires protected CloudTrail calibration evidence.
|
|
333
333
|
|
|
334
334
|
When a plan requires human review, commit
|
|
335
|
-
the workspace-level \`operations/infrastructure-approval.json\` batch.
|
|
335
|
+
the workspace-level \`operations/infrastructure-approval.json\` batch. An agent
|
|
336
|
+
may draft that batch from the sanitized plan; a CODEOWNER other than its author
|
|
337
|
+
reviews and approves it before it is committed. It binds
|
|
336
338
|
the exact ordered project set, action, stage, and one sanitized planned
|
|
337
339
|
plan/digest-bound decision per project. The resume run permits only that one
|
|
338
340
|
aggregate file to differ from the planned source commit and recomputes every
|
|
@@ -343,8 +345,11 @@ import, and cutover/data/collision/rollback/recovery proof for every
|
|
|
343
345
|
replacement. Their canonical digests are retained in both authorization and
|
|
344
346
|
the immediate pre-mutation binding; changing one after review fails closed.
|
|
345
347
|
|
|
346
|
-
|
|
347
|
-
authoritative refresh are governed recovery
|
|
348
|
+
In staging and production, state edit, import operations inside a deploy
|
|
349
|
+
preview, remove, repair, and authoritative refresh are governed recovery
|
|
350
|
+
operations. In the development stage, refresh, unlock, and remove run locally
|
|
351
|
+
through their explicit Nx targets without non-author approval. For the
|
|
352
|
+
protected stages, retain a state backup,
|
|
348
353
|
exact physical target inventory, non-author approval, command/result evidence,
|
|
349
354
|
and a finalized post-repair convergence record. Remove additionally requires
|
|
350
355
|
the complete deletion inventory, drained dependents, retained Foundation,
|
|
@@ -194,7 +194,8 @@ Service or Job recovery procedures as those capabilities are added.
|
|
|
194
194
|
## Operator secrets
|
|
195
195
|
|
|
196
196
|
Use \`docs/runbooks/operator-secret-management.md\` for value-free metadata
|
|
197
|
-
convergence and the separate
|
|
197
|
+
convergence, which agents may run, and the separate personal-stage value
|
|
198
|
+
ceremony, whose value entry is human-only.
|
|
198
199
|
|
|
199
200
|
## Performance, capacity, and cost
|
|
200
201
|
|
|
@@ -1621,7 +1621,10 @@ binding; callers cannot label it.
|
|
|
1621
1621
|
|
|
1622
1622
|
A bounded log query requires a human approval naming the approver, invoking
|
|
1623
1623
|
human, incident, reason, account, region, stage, Slice, exact log groups, query
|
|
1624
|
-
class, time window, and expiry. The
|
|
1624
|
+
class, time window, and expiry. The invoking human is the person whose session
|
|
1625
|
+
runs the query; an agent may prepare the request and run the query inside that
|
|
1626
|
+
human's session, and the approver must be a different human. Unmasking remains
|
|
1627
|
+
a separate break-glass authority. The approved query window is at most one hour.
|
|
1625
1628
|
The script returns an authorization plan; the existing protected query runner
|
|
1626
1629
|
retains attributable evidence. Empty results never prove recovery.
|
|
1627
1630
|
`,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded behavioral integration specs that drive the seeded event consumer
|
|
3
|
+
* against the disposable worker stores. They never write DynamoDB outbox rows
|
|
4
|
+
* (integration/record.spec.ts owns the exactly-one-outbox assertion on the
|
|
5
|
+
* shared worker table), assert by seed-scoped key, and never wait in real time.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderTestingPersistenceEventFiles(name: string, pascalName: string, sql: boolean): Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderTestingPersistenceEventFiles = renderTestingPersistenceEventFiles;
|
|
4
|
+
/**
|
|
5
|
+
* Seeded behavioral integration specs that drive the seeded event consumer
|
|
6
|
+
* against the disposable worker stores. They never write DynamoDB outbox rows
|
|
7
|
+
* (integration/record.spec.ts owns the exactly-one-outbox assertion on the
|
|
8
|
+
* shared worker table), assert by seed-scoped key, and never wait in real time.
|
|
9
|
+
*/
|
|
10
|
+
function renderTestingPersistenceEventFiles(name, pascalName, sql) {
|
|
11
|
+
const inboxHelpers = `const inboxCount = async (source: string, eventId: string) => {
|
|
12
|
+
// Key-scoped: the inbox sort key is unique to this seed's event, while its
|
|
13
|
+
// partition carries the consumer identity. Never count the shared table.
|
|
14
|
+
const sk = lifecycleIdempotencyKey('event-consumer', [source, eventId]);
|
|
15
|
+
let count = 0;
|
|
16
|
+
let ExclusiveStartKey: Record<string, AttributeValue> | undefined;
|
|
17
|
+
do {
|
|
18
|
+
const page = await localKeyValueClient.send(new ScanCommand({ TableName: localKeyValueTable, ConsistentRead: true,
|
|
19
|
+
FilterExpression: '#sk = :sk AND #kind = :kind', ExpressionAttributeNames: { '#sk': 'sk', '#kind': 'kind' },
|
|
20
|
+
ExpressionAttributeValues: { ':sk': { S: sk }, ':kind': { S: 'inbox' } }, ExclusiveStartKey }));
|
|
21
|
+
count += page.Items?.length ?? 0;
|
|
22
|
+
ExclusiveStartKey = page.LastEvaluatedKey;
|
|
23
|
+
} while (ExclusiveStartKey);
|
|
24
|
+
return count;
|
|
25
|
+
};`;
|
|
26
|
+
const seedHelpers = (spec) => `const seed = process.env.EBK_TEST_SEED;
|
|
27
|
+
if (!seed) throw new Error('EBK_TEST_SEED is required for reproducible integration fixtures.');
|
|
28
|
+
const stage = process.env.EBK_STAGE ?? '';
|
|
29
|
+
const busName = process.env.EVENT_BUS_NAME ?? '';
|
|
30
|
+
// The seeded consumer authorizes its own workload identity on every invocation.
|
|
31
|
+
process.env.EBK_WORKLOAD_IDENTITY = '${name}:event-consumer';
|
|
32
|
+
const scoped = (label: string) =>
|
|
33
|
+
'b4-' + createHash('sha256').update(seed + '/${spec}/' + label).digest('hex').slice(0, 32);`;
|
|
34
|
+
const files = {
|
|
35
|
+
'integration/event-consumer-duplicates.spec.ts': `import { createHash } from 'node:crypto';
|
|
36
|
+
import { ScanCommand, type AttributeValue } from '@aws-sdk/client-dynamodb';
|
|
37
|
+
import { createDataLifecycleChangeEvent, lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
|
|
38
|
+
import { buildEventEntry } from '@empire-builder-kit/runtime/events';
|
|
39
|
+
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
40
|
+
import { handler } from '../api/event-consumer.js';
|
|
41
|
+
import { ${pascalName}Created, ${pascalName}DataLifecycleChanged } from '../contracts/events.js';
|
|
42
|
+
import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
|
|
43
|
+
|
|
44
|
+
const observed = vi.hoisted(() => ({ consumed: [] as string[], refusals: [] as { code: string; eventId: string }[] }));
|
|
45
|
+
vi.mock('../data/key-value.js', async () => {
|
|
46
|
+
const { localKeyValueStore } = await import('../testing/local-key-value.js');
|
|
47
|
+
const store = localKeyValueStore();
|
|
48
|
+
// Observe the inbox boundary without changing what the seeded consumer does.
|
|
49
|
+
const consume: typeof store.consume = async (input) => {
|
|
50
|
+
observed.consumed.push(input.eventId);
|
|
51
|
+
try {
|
|
52
|
+
return await store.consume(input);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
observed.refusals.push({ code: String((error as { code?: unknown }).code), eventId: input.eventId });
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return { dataStore: () => ({ ...store, consume }) };
|
|
59
|
+
});
|
|
60
|
+
${seedHelpers('event-consumer-duplicates')}
|
|
61
|
+
let sequence = 0;
|
|
62
|
+
const messageId = (label: string) => scoped(label) + '-m' + String(++sequence);
|
|
63
|
+
afterAll(() => localKeyValueClient.destroy());
|
|
64
|
+
|
|
65
|
+
function created(eventId: string, occurredAt: string, options: { occurredBy?: string; stage?: string; tenantId?: string } = {}) {
|
|
66
|
+
const built = buildEventEntry(${pascalName}Created, { id: eventId, occurredBy: options.occurredBy ?? 'integration-owner' }, {
|
|
67
|
+
busName, stage: options.stage ?? stage, eventId, occurredAt,
|
|
68
|
+
context: { correlationId: eventId, tenantId: options.tenantId ?? 'application' },
|
|
69
|
+
});
|
|
70
|
+
return JSON.stringify({ source: built.entry.source, 'detail-type': built.entry.detailType, detail: JSON.parse(built.entry.detail) });
|
|
71
|
+
}
|
|
72
|
+
function lifecycle(eventId: string, pseudonymousKey: string, occurredAt: string) {
|
|
73
|
+
const data = createDataLifecycleChangeEvent({ action: 'correction', categoryId: 'product-record', occurredAt,
|
|
74
|
+
policyVersion: '1.0.0', pseudonymousKey, requestRef: lifecycleIdempotencyKey('request', [eventId]), sourceSlice: '${name}' });
|
|
75
|
+
const built = buildEventEntry(${pascalName}DataLifecycleChanged, data, {
|
|
76
|
+
busName, stage, eventId, occurredAt, context: { correlationId: eventId, tenantId: 'application' },
|
|
77
|
+
});
|
|
78
|
+
return JSON.stringify({ source: built.entry.source, 'detail-type': built.entry.detailType, detail: JSON.parse(built.entry.detail) });
|
|
79
|
+
}
|
|
80
|
+
${inboxHelpers}
|
|
81
|
+
const inboxKey = (eventId: string) => lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]);
|
|
82
|
+
|
|
83
|
+
describe('${name} event consumer duplicate consumption', () => {
|
|
84
|
+
it('consumes a created event delivered repeatedly and twice in one batch exactly once', async () => {
|
|
85
|
+
const eventId = scoped('created');
|
|
86
|
+
const body = created(eventId, new Date().toISOString());
|
|
87
|
+
await expect(handler({ Records: [{ messageId: messageId('created'), body }] })).resolves.toEqual({ batchItemFailures: [] });
|
|
88
|
+
await expect(handler({ Records: [{ messageId: messageId('created'), body }] })).resolves.toEqual({ batchItemFailures: [] });
|
|
89
|
+
await expect(handler({ Records: [{ messageId: messageId('created'), body }, { messageId: messageId('created'), body }] }))
|
|
90
|
+
.resolves.toEqual({ batchItemFailures: [] });
|
|
91
|
+
expect(observed.consumed.filter((id) => id === inboxKey(eventId))).toHaveLength(4);
|
|
92
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('records one receipt and keeps the downstream copy unchanged when a lifecycle event is redelivered', async () => {
|
|
96
|
+
const eventId = scoped('lifecycle');
|
|
97
|
+
const pseudonymousKey = lifecycleIdempotencyKey('record', [scoped('lifecycle-record')]);
|
|
98
|
+
const body = lifecycle(eventId, pseudonymousKey, new Date().toISOString());
|
|
99
|
+
const store = localKeyValueStore();
|
|
100
|
+
const copyId = lifecycleIdempotencyKey('lifecycle-copy', ['${name}', pseudonymousKey, '${name}-lifecycle-consumer']);
|
|
101
|
+
await expect(handler({ Records: [{ messageId: messageId('lifecycle'), body }] })).resolves.toEqual({ batchItemFailures: [] });
|
|
102
|
+
const copy = await store.get('application', 'data-lifecycle-downstream-copies', copyId);
|
|
103
|
+
expect(copy).toMatchObject({ version: 1, value: { status: 'reconciled', action: 'correction' } });
|
|
104
|
+
await expect(handler({ Records: [{ messageId: messageId('lifecycle'), body }] })).resolves.toEqual({ batchItemFailures: [] });
|
|
105
|
+
await expect(store.get('application', 'data-lifecycle-receipts', inboxKey(eventId))).resolves.toMatchObject({ version: 1 });
|
|
106
|
+
await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toEqual(copy);
|
|
107
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('fails only the message that reuses an event id with changed data', async () => {
|
|
111
|
+
const eventId = scoped('conflict');
|
|
112
|
+
const independent = scoped('conflict-independent');
|
|
113
|
+
const occurredAt = new Date().toISOString();
|
|
114
|
+
await expect(handler({ Records: [{ messageId: messageId('conflict'), body: created(eventId, occurredAt) }] }))
|
|
115
|
+
.resolves.toEqual({ batchItemFailures: [] });
|
|
116
|
+
const changed = messageId('conflict-changed');
|
|
117
|
+
await expect(handler({ Records: [
|
|
118
|
+
{ messageId: changed, body: created(eventId, occurredAt, { occurredBy: 'integration-other-owner' }) },
|
|
119
|
+
{ messageId: messageId('conflict-independent'), body: created(independent, occurredAt) },
|
|
120
|
+
] })).resolves.toEqual({ batchItemFailures: [{ itemIdentifier: changed }] });
|
|
121
|
+
expect(observed.refusals).toContainEqual({ code: 'idempotency-conflict', eventId: inboxKey(eventId) });
|
|
122
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
123
|
+
await expect(inboxCount('${name}', independent)).resolves.toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('refuses foreign tenant and foreign stage events before any inbox write', async () => {
|
|
127
|
+
const occurredAt = new Date().toISOString();
|
|
128
|
+
const tenant = scoped('foreign-tenant');
|
|
129
|
+
const foreignStage = scoped('foreign-stage');
|
|
130
|
+
const application = scoped('application-tenant');
|
|
131
|
+
const tenantMessage = messageId('foreign-tenant');
|
|
132
|
+
const stageMessage = messageId('foreign-stage');
|
|
133
|
+
await expect(handler({ Records: [
|
|
134
|
+
{ messageId: tenantMessage, body: created(tenant, occurredAt, { tenantId: 'foreign-tenant' }) },
|
|
135
|
+
{ messageId: stageMessage, body: created(foreignStage, occurredAt, { stage: stage === 'production' ? 'staging' : 'production' }) },
|
|
136
|
+
{ messageId: messageId('application-tenant'), body: created(application, occurredAt) },
|
|
137
|
+
] })).resolves.toEqual({ batchItemFailures: [{ itemIdentifier: tenantMessage }, { itemIdentifier: stageMessage }] });
|
|
138
|
+
expect(observed.consumed).not.toContain(inboxKey(tenant));
|
|
139
|
+
expect(observed.consumed).not.toContain(inboxKey(foreignStage));
|
|
140
|
+
await expect(inboxCount('${name}', tenant)).resolves.toBe(0);
|
|
141
|
+
await expect(inboxCount('${name}', foreignStage)).resolves.toBe(0);
|
|
142
|
+
await expect(inboxCount('${name}', application)).resolves.toBe(1);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
`,
|
|
146
|
+
'integration/event-consumer-redrive.spec.ts': `import { createHash } from 'node:crypto';
|
|
147
|
+
import { ScanCommand, type AttributeValue, type DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
148
|
+
import { createDataLifecycleChangeEvent, lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
|
|
149
|
+
import { buildEventEntry } from '@empire-builder-kit/runtime/events';
|
|
150
|
+
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
151
|
+
import { handler } from '../api/event-consumer.js';
|
|
152
|
+
import workContract from '../async/work-contract.json' with { type: 'json' };
|
|
153
|
+
import { ${pascalName}Created, ${pascalName}DataLifecycleChanged } from '../contracts/events.js';
|
|
154
|
+
import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
|
|
155
|
+
|
|
156
|
+
const control = vi.hoisted(() => ({ outage: false }));
|
|
157
|
+
vi.mock('../data/key-value.js', async () => {
|
|
158
|
+
const { createDynamoPersistence } = await import('@empire-builder-kit/runtime/key-value');
|
|
159
|
+
const { localKeyValueClient, localKeyValueTable } = await import('../testing/local-key-value.js');
|
|
160
|
+
const passthrough = localKeyValueClient.send.bind(localKeyValueClient) as (command: unknown) => Promise<unknown>;
|
|
161
|
+
// A transient dependency outage: every store call fails while it lasts.
|
|
162
|
+
const client = { send: (command: unknown) =>
|
|
163
|
+
control.outage ? Promise.reject(new Error('Integration DynamoDB outage.')) : passthrough(command) };
|
|
164
|
+
const store = createDynamoPersistence({ client: client as unknown as DynamoDBClient, tableName: localKeyValueTable,
|
|
165
|
+
stage: process.env.EBK_STAGE ?? '', slice: '${name}', eventBusName: 'local-integration-events' });
|
|
166
|
+
return { dataStore: () => store };
|
|
167
|
+
});
|
|
168
|
+
${seedHelpers('event-consumer-redrive')}
|
|
169
|
+
afterAll(() => localKeyValueClient.destroy());
|
|
170
|
+
const maxReceiveCount = workContract.queue.maxReceiveCount;
|
|
171
|
+
if (!Number.isSafeInteger(maxReceiveCount) || maxReceiveCount < 1)
|
|
172
|
+
throw new Error('The async work contract must declare a positive maxReceiveCount.');
|
|
173
|
+
|
|
174
|
+
interface QueuedMessage { body: string; messageId: string; receiveCount: number }
|
|
175
|
+
/**
|
|
176
|
+
* In-test SQS source queue and DLQ with the declared redrive policy. Visibility
|
|
177
|
+
* timeouts elapse instantly; real SQS movement is not proven locally.
|
|
178
|
+
*/
|
|
179
|
+
function createQueue(label: string) {
|
|
180
|
+
const source: QueuedMessage[] = [];
|
|
181
|
+
const deadLetter: QueuedMessage[] = [];
|
|
182
|
+
const reported: string[] = [];
|
|
183
|
+
let sequence = 0;
|
|
184
|
+
const send = (body: string) => {
|
|
185
|
+
const messageId = scoped(label) + '-m' + String(++sequence);
|
|
186
|
+
source.push({ body, messageId, receiveCount: 0 });
|
|
187
|
+
return messageId;
|
|
188
|
+
};
|
|
189
|
+
async function drain() {
|
|
190
|
+
while (source.length > 0) {
|
|
191
|
+
const batch = source.splice(0, 10);
|
|
192
|
+
for (const message of batch) message.receiveCount += 1;
|
|
193
|
+
const { batchItemFailures } = await handler({ Records: batch.map(({ body, messageId }) => ({ body, messageId })) });
|
|
194
|
+
const failed = new Set(batchItemFailures.map(({ itemIdentifier }) => itemIdentifier));
|
|
195
|
+
reported.push(...failed);
|
|
196
|
+
for (const message of batch) {
|
|
197
|
+
if (!failed.has(message.messageId)) continue;
|
|
198
|
+
(message.receiveCount >= maxReceiveCount ? deadLetter : source).push(message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// An operator redrive returns dead letters as new source messages.
|
|
203
|
+
const redrive = () => { for (const message of deadLetter.splice(0)) send(message.body); };
|
|
204
|
+
return { deadLetter, drain, redrive, reported, send };
|
|
205
|
+
}
|
|
206
|
+
function body(detailType: string, built: { entry: { source: string; detail: string } }) {
|
|
207
|
+
return JSON.stringify({ source: built.entry.source, 'detail-type': detailType, detail: JSON.parse(built.entry.detail) });
|
|
208
|
+
}
|
|
209
|
+
${inboxHelpers}
|
|
210
|
+
|
|
211
|
+
describe('${name} event consumer poison redrive', () => {
|
|
212
|
+
it('dead-letters a transiently failing event at the declared receive limit and redrives it exactly once', async () => {
|
|
213
|
+
const queue = createQueue('transient');
|
|
214
|
+
const eventId = scoped('transient');
|
|
215
|
+
const occurredAt = new Date().toISOString();
|
|
216
|
+
const pseudonymousKey = lifecycleIdempotencyKey('record', [scoped('transient-record')]);
|
|
217
|
+
const message = body(${pascalName}DataLifecycleChanged.detailType, buildEventEntry(${pascalName}DataLifecycleChanged,
|
|
218
|
+
createDataLifecycleChangeEvent({ action: 'correction', categoryId: 'product-record', occurredAt, policyVersion: '1.0.0',
|
|
219
|
+
pseudonymousKey, requestRef: lifecycleIdempotencyKey('request', [eventId]), sourceSlice: '${name}' }),
|
|
220
|
+
{ busName, stage, eventId, occurredAt, context: { correlationId: eventId, tenantId: 'application' } }));
|
|
221
|
+
const store = localKeyValueStore();
|
|
222
|
+
const receiptId = lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]);
|
|
223
|
+
const copyId = lifecycleIdempotencyKey('lifecycle-copy', ['${name}', pseudonymousKey, '${name}-lifecycle-consumer']);
|
|
224
|
+
control.outage = true;
|
|
225
|
+
try {
|
|
226
|
+
queue.send(message);
|
|
227
|
+
await queue.drain();
|
|
228
|
+
} finally {
|
|
229
|
+
control.outage = false;
|
|
230
|
+
}
|
|
231
|
+
expect(queue.deadLetter.map(({ receiveCount }) => receiveCount)).toEqual([maxReceiveCount]);
|
|
232
|
+
await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toBeNull();
|
|
233
|
+
await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toBeNull();
|
|
234
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
|
|
235
|
+
queue.redrive();
|
|
236
|
+
await queue.drain();
|
|
237
|
+
expect(queue.deadLetter).toEqual([]);
|
|
238
|
+
await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toMatchObject({ version: 1 });
|
|
239
|
+
const copy = await store.get('application', 'data-lifecycle-downstream-copies', copyId);
|
|
240
|
+
expect(copy).toMatchObject({ version: 1, value: { status: 'reconciled' } });
|
|
241
|
+
// A repeated operator redrive or archive replay is a duplicate, not a second effect.
|
|
242
|
+
queue.send(message);
|
|
243
|
+
await queue.drain();
|
|
244
|
+
expect(queue.deadLetter).toEqual([]);
|
|
245
|
+
await expect(store.get('application', 'data-lifecycle-receipts', receiptId)).resolves.toMatchObject({ version: 1 });
|
|
246
|
+
await expect(store.get('application', 'data-lifecycle-downstream-copies', copyId)).resolves.toEqual(copy);
|
|
247
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('keeps a conflicting event poisoned across redrive while independent messages proceed', async () => {
|
|
251
|
+
const queue = createQueue('permanent');
|
|
252
|
+
const eventId = scoped('permanent');
|
|
253
|
+
const independent = scoped('permanent-independent');
|
|
254
|
+
const occurredAt = new Date().toISOString();
|
|
255
|
+
const created = (id: string, occurredBy: string) => body(${pascalName}Created.detailType, buildEventEntry(${pascalName}Created,
|
|
256
|
+
{ id, occurredBy }, { busName, stage, eventId: id, occurredAt, context: { correlationId: id, tenantId: 'application' } }));
|
|
257
|
+
queue.send(created(eventId, 'integration-owner'));
|
|
258
|
+
await queue.drain();
|
|
259
|
+
expect(queue.reported).toEqual([]);
|
|
260
|
+
const poison = queue.send(created(eventId, 'integration-other-owner'));
|
|
261
|
+
queue.send(created(independent, 'integration-owner'));
|
|
262
|
+
await queue.drain();
|
|
263
|
+
expect(queue.reported).toEqual(Array.from({ length: maxReceiveCount }, () => poison));
|
|
264
|
+
expect(queue.deadLetter).toHaveLength(1);
|
|
265
|
+
await expect(inboxCount('${name}', independent)).resolves.toBe(1);
|
|
266
|
+
queue.redrive();
|
|
267
|
+
await queue.drain();
|
|
268
|
+
expect(queue.deadLetter.map(({ receiveCount }) => receiveCount)).toEqual([maxReceiveCount]);
|
|
269
|
+
expect(queue.reported).toHaveLength(2 * maxReceiveCount);
|
|
270
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
`,
|
|
274
|
+
};
|
|
275
|
+
if (!sql)
|
|
276
|
+
return files;
|
|
277
|
+
const sqlHelpers = `vi.mock('../data/key-value.js', async () => {
|
|
278
|
+
const { localKeyValueStore } = await import('../testing/local-key-value.js');
|
|
279
|
+
const store = localKeyValueStore();
|
|
280
|
+
// Observe the inbox boundary without changing what the seeded consumer does.
|
|
281
|
+
const consume: typeof store.consume = async (input) => {
|
|
282
|
+
observed.consumed.push(input.eventId);
|
|
283
|
+
return store.consume(input);
|
|
284
|
+
};
|
|
285
|
+
return { dataStore: () => ({ ...store, consume }) };
|
|
286
|
+
});
|
|
287
|
+
// The deployed credential adapter and EventBridge transport never run locally.
|
|
288
|
+
vi.mock('../database/runtime-client', () => ({
|
|
289
|
+
withPrimaryDatabase: () => { throw new Error('Integration must not resolve deployed database credentials.'); },
|
|
290
|
+
}));
|
|
291
|
+
vi.mock('@aws-sdk/client-eventbridge', () => ({
|
|
292
|
+
EventBridgeClient: class { send() { throw new Error('Integration must not publish to a real event bus.'); } },
|
|
293
|
+
PutEventsCommand: class {},
|
|
294
|
+
}));
|
|
295
|
+
`;
|
|
296
|
+
const sqlOutbox = `const published: PutEventsRequestEntry[] = [];
|
|
297
|
+
const outbox = createSqlEventOutbox({ scope: () => ({ stage, busName, enabled: true }), withDatabase: withLocalDatabase,
|
|
298
|
+
publish: async (entry) => { published.push(entry); } });
|
|
299
|
+
const eventIdOf = (entry: PutEventsRequestEntry) =>
|
|
300
|
+
(JSON.parse(entry.Detail ?? '{}') as { meta?: { eventId?: string } }).meta?.eventId;
|
|
301
|
+
const publishedFor = (eventId: string) =>
|
|
302
|
+
published.filter((entry) => entry.DetailType === ${pascalName}Created.detailType && eventIdOf(entry) === eventId);
|
|
303
|
+
const record = (entry: PutEventsRequestEntry, messageId: string) => ({ messageId,
|
|
304
|
+
body: JSON.stringify({ source: entry.Source, 'detail-type': entry.DetailType, detail: JSON.parse(entry.Detail ?? 'null') }) });
|
|
305
|
+
const intents = (eventId: string) => withLocalDatabase({ correlationId: eventId }, 'api-database-records', async (client) =>
|
|
306
|
+
(await client.query<{ attempts: number; last_error_code: string | null; published: boolean }>(
|
|
307
|
+
"SELECT attempts, last_error_code, published_at IS NOT NULL AS published FROM data_lifecycle_outbox WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = $1",
|
|
308
|
+
[eventId])).rows);
|
|
309
|
+
const owned = new Set<string>();
|
|
310
|
+
afterAll(async () => {
|
|
311
|
+
// Test-owned intents leave the shared worker database so later relays see none of them.
|
|
312
|
+
await withLocalDatabase({ correlationId: 'integration-cleanup' }, 'api-database-records', async (client) => {
|
|
313
|
+
await client.query("DELETE FROM data_lifecycle_outbox WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = ANY($1)", [[...owned]]);
|
|
314
|
+
});
|
|
315
|
+
localKeyValueClient.destroy();
|
|
316
|
+
});`;
|
|
317
|
+
const sqlImports = `import { createHash } from 'node:crypto';
|
|
318
|
+
import type { PutEventsRequestEntry } from '@aws-sdk/client-eventbridge';
|
|
319
|
+
import { ScanCommand, type AttributeValue } from '@aws-sdk/client-dynamodb';
|
|
320
|
+
import { lifecycleIdempotencyKey } from '@empire-builder-kit/runtime/data-lifecycle';
|
|
321
|
+
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
322
|
+
import { handler } from '../api/event-consumer.js';
|
|
323
|
+
import { ${pascalName}Created } from '../contracts/events.js';
|
|
324
|
+
import { createSqlEventOutbox } from '../database/sql-events';`;
|
|
325
|
+
return {
|
|
326
|
+
...files,
|
|
327
|
+
'testing/local-sql.ts': `import pg, { type Client as PgClient } from 'pg';
|
|
328
|
+
|
|
329
|
+
const databaseUrl = new URL(process.env.DATABASE_URL ?? 'postgresql://invalid');
|
|
330
|
+
if (!['postgres:', 'postgresql:'].includes(databaseUrl.protocol) || databaseUrl.hostname !== '127.0.0.1' ||
|
|
331
|
+
!databaseUrl.port || databaseUrl.search || databaseUrl.hash ||
|
|
332
|
+
!/^\\/ebk_test_[0-9a-f]{24}$/.test(databaseUrl.pathname)) {
|
|
333
|
+
throw new Error('Integration requires the runner-owned loopback PostgreSQL worker database.');
|
|
334
|
+
}
|
|
335
|
+
const connectionString = databaseUrl.href;
|
|
336
|
+
|
|
337
|
+
/** Same call shape as withPrimaryDatabase, bound to the disposable worker database. */
|
|
338
|
+
export async function withLocalDatabase<Result>(
|
|
339
|
+
_context: { correlationId: string },
|
|
340
|
+
_workload: string,
|
|
341
|
+
operation: (client: PgClient) => Promise<Result>,
|
|
342
|
+
): Promise<Result> {
|
|
343
|
+
const client = new pg.Client({ connectionString });
|
|
344
|
+
await client.connect();
|
|
345
|
+
try {
|
|
346
|
+
return await operation(client);
|
|
347
|
+
} finally {
|
|
348
|
+
await client.end();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
`,
|
|
352
|
+
'integration/dual-store.spec.ts': `${sqlImports}
|
|
353
|
+
import { localKeyValueClient, localKeyValueStore, localKeyValueTable } from '../testing/local-key-value.js';
|
|
354
|
+
import { withLocalDatabase } from '../testing/local-sql.js';
|
|
355
|
+
|
|
356
|
+
const observed = vi.hoisted(() => ({ consumed: [] as string[] }));
|
|
357
|
+
${sqlHelpers}${seedHelpers('dual-store')}
|
|
358
|
+
${sqlOutbox}
|
|
359
|
+
${inboxHelpers}
|
|
360
|
+
const ledger = (id: string) => withLocalDatabase({ correlationId: id }, 'api-database-records', async (client) => {
|
|
361
|
+
await client.query('CREATE TABLE IF NOT EXISTS integration_dual_store_ledger (id text PRIMARY KEY, amount integer NOT NULL)');
|
|
362
|
+
return (await client.query<{ amount: number }>('SELECT amount FROM integration_dual_store_ledger WHERE id = $1', [id])).rows;
|
|
363
|
+
});
|
|
364
|
+
const recordCreated = (eventId: string, fail = false) =>
|
|
365
|
+
outbox.transaction({ correlationId: eventId }, 'api-database-records', async (client, emit) => {
|
|
366
|
+
owned.add(eventId);
|
|
367
|
+
await client.query('CREATE TABLE IF NOT EXISTS integration_dual_store_ledger (id text PRIMARY KEY, amount integer NOT NULL)');
|
|
368
|
+
await client.query('INSERT INTO integration_dual_store_ledger (id, amount) VALUES ($1, $2)', [eventId, 100]);
|
|
369
|
+
await emit(${pascalName}Created, { id: eventId, occurredBy: 'integration-owner' }, { eventId, occurredAt: new Date().toISOString() });
|
|
370
|
+
if (fail) throw new Error('Integration domain refusal.');
|
|
371
|
+
return eventId;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('${name} dual-store DynamoDB and SQL event delivery', () => {
|
|
375
|
+
it('commits a relational row with its intent, relays it and consumes it once into the DynamoDB inbox', async () => {
|
|
376
|
+
const eventId = scoped('committed');
|
|
377
|
+
await expect(recordCreated(eventId)).resolves.toEqual({ value: eventId, queued: 1, notification: 'notified' });
|
|
378
|
+
expect(publishedFor(eventId)).toEqual([]);
|
|
379
|
+
await expect(ledger(eventId)).resolves.toEqual([{ amount: 100 }]);
|
|
380
|
+
await expect(intents(eventId)).resolves.toEqual([{ attempts: 0, last_error_code: null, published: false }]);
|
|
381
|
+
await outbox.reconcile({ correlationId: eventId });
|
|
382
|
+
const [entry] = publishedFor(eventId);
|
|
383
|
+
expect(publishedFor(eventId)).toHaveLength(1);
|
|
384
|
+
await expect(intents(eventId)).resolves.toEqual([{ attempts: 1, last_error_code: null, published: true }]);
|
|
385
|
+
await expect(handler({ Records: [record(entry, scoped('delivery'))] })).resolves.toEqual({ batchItemFailures: [] });
|
|
386
|
+
// A publish/acknowledgement interruption redelivers the same event: a duplicate.
|
|
387
|
+
await expect(handler({ Records: [record(entry, scoped('redelivery'))] })).resolves.toEqual({ batchItemFailures: [] });
|
|
388
|
+
await outbox.reconcile({ correlationId: eventId });
|
|
389
|
+
expect(publishedFor(eventId)).toHaveLength(1);
|
|
390
|
+
expect(observed.consumed).toContain(lifecycleIdempotencyKey('event-consumer', ['${name}', eventId]));
|
|
391
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('rolls back the relational row and its intent together without reaching DynamoDB', async () => {
|
|
395
|
+
const eventId = scoped('rolled-back');
|
|
396
|
+
await expect(recordCreated(eventId, true)).rejects.toThrow('Integration domain refusal.');
|
|
397
|
+
await expect(ledger(eventId)).resolves.toEqual([]);
|
|
398
|
+
await expect(intents(eventId)).resolves.toEqual([]);
|
|
399
|
+
await outbox.reconcile({ correlationId: eventId });
|
|
400
|
+
expect(publishedFor(eventId)).toEqual([]);
|
|
401
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('commits each store in its own transaction without a cross-store guarantee', async () => {
|
|
405
|
+
const id = scoped('independent');
|
|
406
|
+
const store = localKeyValueStore();
|
|
407
|
+
await store.commit({ tenantId: 'application', changes: [{ action: 'put', collection: 'integration-dual-store', id,
|
|
408
|
+
expectedVersion: 0, value: { amount: 100 } }] });
|
|
409
|
+
await expect(recordCreated(id, true)).rejects.toThrow('Integration domain refusal.');
|
|
410
|
+
// The SQL rollback cannot undo the committed DynamoDB write, and vice versa.
|
|
411
|
+
await expect(store.get('application', 'integration-dual-store', id)).resolves.toEqual({ value: { amount: 100 }, version: 1 });
|
|
412
|
+
await expect(ledger(id)).resolves.toEqual([]);
|
|
413
|
+
await expect(intents(id)).resolves.toEqual([]);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
`,
|
|
417
|
+
'integration/sql-tenant-denial.spec.ts': `${sqlImports}
|
|
418
|
+
import { localKeyValueClient, localKeyValueTable } from '../testing/local-key-value.js';
|
|
419
|
+
import { withLocalDatabase } from '../testing/local-sql.js';
|
|
420
|
+
|
|
421
|
+
const observed = vi.hoisted(() => ({ consumed: [] as string[] }));
|
|
422
|
+
${sqlHelpers}${seedHelpers('sql-tenant-denial')}
|
|
423
|
+
${sqlOutbox}
|
|
424
|
+
${inboxHelpers}
|
|
425
|
+
const emitCreated = (eventId: string, context: { correlationId: string }) =>
|
|
426
|
+
outbox.transaction(context, 'api-database-records', async (_client, emit) => {
|
|
427
|
+
owned.add(eventId);
|
|
428
|
+
await emit(${pascalName}Created, { id: eventId, occurredBy: 'integration-owner' }, { eventId, occurredAt: new Date().toISOString() });
|
|
429
|
+
});
|
|
430
|
+
const rewrite = (eventId: string, sql: string, values: unknown[]) =>
|
|
431
|
+
withLocalDatabase({ correlationId: eventId }, 'api-database-records', async (client) => {
|
|
432
|
+
const result = await client.query(sql + " WHERE (event_json->>'Detail')::jsonb->'meta'->>'eventId' = $1", [eventId, ...values]);
|
|
433
|
+
if (result.rowCount !== 1) throw new Error('Expected exactly one stored intent to rewrite.');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('${name} SQL-originated tenant and scope denial', () => {
|
|
437
|
+
it('refuses stored intents rewritten to a foreign stage, bus or Slice at the relay', async () => {
|
|
438
|
+
const ids = { bus: scoped('foreign-bus'), slice: scoped('foreign-slice'), stage: scoped('foreign-stage') };
|
|
439
|
+
for (const eventId of Object.values(ids)) await emitCreated(eventId, { correlationId: eventId });
|
|
440
|
+
// Simulate stored intents that no longer match this relay's exact scope.
|
|
441
|
+
await rewrite(ids.stage, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(event_json, '{Detail}', to_jsonb(replace(event_json->>'Detail', $2, $3)))",
|
|
442
|
+
['"stage":"' + stage + '"', '"stage":"' + (stage === 'production' ? 'staging' : 'production') + '"']);
|
|
443
|
+
await rewrite(ids.bus, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(event_json, '{EventBusName}', to_jsonb($2::text))", ['foreign-bus']);
|
|
444
|
+
await rewrite(ids.slice, "UPDATE data_lifecycle_outbox SET event_json = jsonb_set(jsonb_set(event_json, '{Source}', to_jsonb($2::text)), '{Detail}', to_jsonb(replace(event_json->>'Detail', $3, $4)))",
|
|
445
|
+
['foreign-slice', '"slice":"${name}"', '"slice":"foreign-slice"']);
|
|
446
|
+
const result = await outbox.reconcile({ correlationId: ids.stage });
|
|
447
|
+
expect(result.failed).toBeGreaterThanOrEqual(3);
|
|
448
|
+
for (const eventId of Object.values(ids)) {
|
|
449
|
+
expect(publishedFor(eventId)).toEqual([]);
|
|
450
|
+
await expect(intents(eventId)).resolves.toEqual([{ attempts: 1, last_error_code: 'event-publication-failed', published: false }]);
|
|
451
|
+
await expect(inboxCount('${name}', eventId)).resolves.toBe(0);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('refuses a foreign-Slice event at emission and rolls back its transaction', async () => {
|
|
456
|
+
const eventId = scoped('foreign-emission');
|
|
457
|
+
await expect(outbox.transaction({ correlationId: eventId }, 'api-database-records', async (_client, emit) => {
|
|
458
|
+
owned.add(eventId);
|
|
459
|
+
await emit({ ...${pascalName}Created, source: 'foreign-slice' }, { id: eventId, occurredBy: 'integration-owner' },
|
|
460
|
+
{ eventId, occurredAt: new Date().toISOString() });
|
|
461
|
+
})).rejects.toThrow('SQL event differs from its Slice/bus scope.');
|
|
462
|
+
await expect(intents(eventId)).resolves.toEqual([]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('refuses a foreign-tenant SQL event at the seeded consumer before any inbox write', async () => {
|
|
466
|
+
const foreign = scoped('foreign-tenant');
|
|
467
|
+
const application = scoped('application-tenant');
|
|
468
|
+
// The outbox context is typed { correlationId }; a widened runtime context can still carry tenant metadata.
|
|
469
|
+
const foreignContext = { correlationId: foreign, tenantId: 'foreign-tenant' };
|
|
470
|
+
await emitCreated(foreign, foreignContext);
|
|
471
|
+
await emitCreated(application, { correlationId: application });
|
|
472
|
+
await outbox.reconcile({ correlationId: foreign });
|
|
473
|
+
const [foreignEntry] = publishedFor(foreign);
|
|
474
|
+
const [applicationEntry] = publishedFor(application);
|
|
475
|
+
expect(JSON.parse(foreignEntry.Detail ?? '{}').meta.tenantId).toBe('foreign-tenant');
|
|
476
|
+
const refused = scoped('foreign-tenant-delivery');
|
|
477
|
+
await expect(handler({ Records: [record(foreignEntry, refused), record(applicationEntry, scoped('application-delivery'))] }))
|
|
478
|
+
.resolves.toEqual({ batchItemFailures: [{ itemIdentifier: refused }] });
|
|
479
|
+
expect(observed.consumed).not.toContain(lifecycleIdempotencyKey('event-consumer', ['${name}', foreign]));
|
|
480
|
+
await expect(inboxCount('${name}', foreign)).resolves.toBe(0);
|
|
481
|
+
await expect(inboxCount('${name}', application)).resolves.toBe(1);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
`,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
//# sourceMappingURL=built-in-plan-testing-persistence-event-files.js.map
|