@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.
Files changed (56) hide show
  1. package/README.md +26 -10
  2. package/package.json +3 -3
  3. package/src/blueprints/external-sandbox.d.ts +1 -1
  4. package/src/cli/deploy-runtime.d.ts +25 -1
  5. package/src/cli/deploy-runtime.js +120 -4
  6. package/src/cli/dev-command.d.ts +2 -0
  7. package/src/cli/dev-command.js +8 -0
  8. package/src/cli/main.js +20 -6
  9. package/src/cli/personal-secret-runtime.d.ts +12 -0
  10. package/src/cli/personal-secret-runtime.js +18 -3
  11. package/src/cli/secret-terminal.js +1 -1
  12. package/src/container-images.d.ts +1 -1
  13. package/src/container-images.js +1 -1
  14. package/src/developer-agent/operation-registry.d.ts +10 -0
  15. package/src/developer-agent/operation-registry.js +60 -13
  16. package/src/developer-agent/planning.d.ts +8 -1
  17. package/src/developer-agent/planning.js +23 -3
  18. package/src/executors/local-deploy/local-approval.js +1 -1
  19. package/src/executors/sst-lifecycle/governed-native-operation.js +3 -2
  20. package/src/executors/sst-lifecycle/run-sst-command.d.ts +6 -0
  21. package/src/executors/sst-lifecycle/run-sst-command.js +3 -33
  22. package/src/foundation/cost-estimate.d.ts +170 -0
  23. package/src/foundation/cost-estimate.js +683 -0
  24. package/src/generators/preset/tool-versions.d.ts +0 -2
  25. package/src/generators/preset/tool-versions.js +1 -3
  26. package/src/generators/preset/workspace-files-adopter-skills.js +39 -18
  27. package/src/generators/preset/workspace-files-developer-agent.js +83 -27
  28. package/src/generators/preset/workspace-files-shared-operation-claims.d.ts +10 -0
  29. package/src/generators/preset/workspace-files-shared-operation-claims.js +59 -0
  30. package/src/generators/preset/workspace-files-stage-authority.js +13 -2
  31. package/src/generators/preset/workspace-files-thin-deploy.d.ts +10 -0
  32. package/src/generators/preset/workspace-files-thin-deploy.js +54 -20
  33. package/src/generators/preset/workspace-files-thin-docs.js +32 -14
  34. package/src/generators/preset/workspace-files.js +28 -8
  35. package/src/generators/slice/built-in-plan-api-contract-files.js +7 -4
  36. package/src/generators/slice/built-in-plan-authorization-files.js +10 -5
  37. package/src/generators/slice/built-in-plan-authorization-infrastructure-files.js +933 -249
  38. package/src/generators/slice/built-in-plan-data-files.js +17 -8
  39. package/src/generators/slice/built-in-plan-database-retirement-rehearsal-files.js +5 -1
  40. package/src/generators/slice/built-in-plan-frontend-files.js +7 -0
  41. package/src/generators/slice/built-in-plan-infrastructure-files.js +0 -1
  42. package/src/generators/slice/built-in-plan-infrastructure-safety-files.js +8 -3
  43. package/src/generators/slice/built-in-plan-metadata-files.js +2 -1
  44. package/src/generators/slice/built-in-plan-reliability-files.js +4 -1
  45. package/src/generators/slice/built-in-plan-testing-persistence-event-files.d.ts +7 -0
  46. package/src/generators/slice/built-in-plan-testing-persistence-event-files.js +487 -0
  47. package/src/generators/slice/built-in-plan-testing-persistence-files.js +2 -0
  48. package/src/generators/slice/built-in-plan.js +3 -1
  49. package/src/generators/workload/frozen-container.d.ts +1 -1
  50. package/src/git-policy/github-bootstrap.d.ts +9 -0
  51. package/src/git-policy/github-bootstrap.js +16 -2
  52. package/src/local-dev/fleet-inventory.js +18 -10
  53. package/src/local-dev/fleet-launcher.js +2 -2
  54. package/src/secrets/personal-command.d.ts +18 -2
  55. package/src/secrets/personal-command.js +66 -12
  56. 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
- Only a human at a local interactive terminal may set or remove a
296
- human-supplied operator-declared personal value:
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; remove requires a hidden confirmation.
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. Do not ask or permit an agent or CI job
309
- to invoke either command.
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. Do not delegate that human-supplied ceremony to an agent and do not
361
- pass a value through argv, source, a file, a ticket, a work marker, GitHub,
362
- logs, or evidence. Routine operator authority is write-only for exact approved
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. The deployment role needs
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. It binds
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
- State edit, import operations inside a deploy preview, remove, repair, and
347
- authoritative refresh are governed recovery operations. Retain a state backup,
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, human-only personal-stage value ceremony.
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 approved query window is at most one hour.
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