@learncard/network-brain-service 3.17.0 → 3.19.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/.env.example +4 -0
- package/AGENTS.md +177 -159
- package/CHANGELOG.md +148 -0
- package/README.md +26 -8
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.test.d.ts +2 -0
- package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
- package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
- package/dist/cache/atomic-counter.test.d.ts +2 -0
- package/dist/cache/atomic-counter.test.d.ts.map +1 -0
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/config/environment.d.ts +46 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
- package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +3 -7
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
- package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential.helpers.d.ts +4 -2
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
- package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
- package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
- package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts +9 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
- package/dist/helpers/shim.d.ts +1 -1
- package/dist/helpers/shim.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
- package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/helpers/status-list.helpers.test.d.ts +2 -0
- package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
- package/dist/inbox-queue-local.d.ts +2 -0
- package/dist/inbox-queue-local.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AuthGrant.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +1 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialActivity.d.ts +1 -0
- package/dist/models/CredentialActivity.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -2
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/InboxCredential.d.ts +1 -0
- package/dist/models/InboxCredential.d.ts.map +1 -1
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/routes/act-as.test.d.ts +2 -0
- package/dist/routes/act-as.test.d.ts.map +1 -0
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +506 -12
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +48 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts +4 -4
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +8 -2
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +8 -8
- package/dist/routes/federation.d.ts +2 -2
- package/dist/routes/inbox.d.ts +973 -31
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +47 -127
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +6 -6
- package/dist/routes/storage.d.ts +63 -63
- package/dist/routes/test.d.ts +6 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +14 -14
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
- package/dist/services/registry/registry.factory.d.ts.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +22 -8
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +7 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/did-metadata.d.ts +2 -2
- package/dist/types/inbox-batch.d.ts +38 -0
- package/dist/types/inbox-batch.d.ts.map +1 -0
- package/inbox-e2e-setup.ts +53 -1
- package/inbox-elasticmq.conf +10 -0
- package/lambda.ts +61 -16
- package/package.json +18 -16
- package/scripts/setup-inbox-queues.ts +60 -0
- package/serverless-local.yml +2 -0
- package/serverless.yml +75 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
- package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
- package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
- package/src/accesslayer/credential/create.test.ts +141 -0
- package/src/accesslayer/credential/create.ts +27 -3
- package/src/accesslayer/credential-activity/create.ts +1 -0
- package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
- package/src/accesslayer/credential-refresh/index.ts +1 -0
- package/src/accesslayer/credential-refresh/read.ts +8 -2
- package/src/accesslayer/credential-refresh/update.ts +4 -0
- package/src/accesslayer/inbox-batch/store.ts +469 -0
- package/src/accesslayer/inbox-credential/create.ts +51 -19
- package/src/accesslayer/inbox-credential/refresh.ts +221 -0
- package/src/accesslayer/inbox-credential/update.ts +1 -1
- package/src/cache/atomic-counter.test-helpers.ts +27 -0
- package/src/cache/atomic-counter.test.ts +6 -0
- package/src/cache/index.ts +25 -13
- package/src/config/environment.ts +26 -0
- package/src/credential-refresh.ts +5 -1
- package/src/docker-entry.ts +8 -0
- package/src/helpers/activity.helpers.ts +4 -0
- package/src/helpers/app-counter.helpers.ts +1 -1
- package/src/helpers/boost-storage.helpers.test.ts +242 -0
- package/src/helpers/boost.helpers.ts +58 -437
- package/src/helpers/contact-method.helpers.test.ts +43 -0
- package/src/helpers/contact-method.helpers.ts +3 -4
- package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
- package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
- package/src/helpers/credential-refresh-email.helpers.ts +201 -0
- package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
- package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
- package/src/helpers/credential-refresh.helpers.ts +385 -107
- package/src/helpers/credential.helpers.ts +6 -2
- package/src/helpers/finalize-inbox.helpers.ts +39 -22
- package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
- package/src/helpers/inbox-batch-http.helpers.ts +150 -0
- package/src/helpers/inbox-batch-status.helpers.ts +11 -0
- package/src/helpers/inbox-batch.helpers.test.ts +668 -0
- package/src/helpers/inbox-batch.helpers.ts +374 -0
- package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
- package/src/helpers/inbox-queue.helpers.test.ts +253 -0
- package/src/helpers/inbox-queue.helpers.ts +372 -0
- package/src/helpers/inbox-refresh.helpers.ts +432 -0
- package/src/helpers/inbox.helpers.ts +301 -50
- package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
- package/src/helpers/notifications.helpers.ts +4 -2
- package/src/helpers/rateLimit.helpers.ts +2 -5
- package/src/helpers/refresh-send-intent.helpers.ts +273 -0
- package/src/helpers/shim.ts +32 -31
- package/src/helpers/signingAuthority.helpers.test.ts +198 -0
- package/src/helpers/signingAuthority.helpers.ts +41 -18
- package/src/helpers/status-list.helpers.test.ts +77 -0
- package/src/helpers/status-list.helpers.ts +31 -8
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/ConsentFlowTransaction.ts +4 -0
- package/src/models/Credential.ts +1 -0
- package/src/models/CredentialActivity.ts +3 -5
- package/src/models/CredentialRefresh.ts +4 -2
- package/src/models/InboxCredential.ts +2 -0
- package/src/models/credential-refresh-constraints.test.ts +8 -3
- package/src/models/credential-refresh-constraints.ts +5 -0
- package/src/openapi.ts +8 -0
- package/src/routes/act-as.test.ts +239 -0
- package/src/routes/app-store.ts +69 -52
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +943 -120
- package/src/routes/contracts.ts +55 -7
- package/src/routes/credential-refreshes.test.ts +8 -2
- package/src/routes/credential-refreshes.ts +4 -1
- package/src/routes/credentials.ts +2 -1
- package/src/routes/inbox.ts +95 -49
- package/src/routes/index.ts +164 -88
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +80 -31
- package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
- package/src/services/registry/registry.factory.ts +2 -1
- package/src/types/activity.ts +2 -0
- package/src/types/consentflowcontract.ts +21 -3
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/credential.ts +13 -1
- package/src/types/inbox-batch.ts +46 -0
- package/test/app-store-listing.spec.ts +66 -30
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/bitstring-status-list.spec.ts +12 -9
- package/test/consentflow.spec.ts +453 -26
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +13 -5
- package/test/guardian-gated-routes.spec.ts +337 -180
- package/test/helpers/inbox-e2e-environment.ts +6 -0
- package/test/inbox-batch.spec.ts +1338 -0
- package/test/inbox-refresh.spec.ts +534 -0
- package/test/inbox-security.e2e.ts +48 -3
- package/test/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/test/revoke-suspend-per-instance.spec.ts +9 -1
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { neogma } from '@instance';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persisted, unique claim per (refreshId, delivery window) for the managed
|
|
5
|
+
* credential-refresh update email (LC-2198).
|
|
6
|
+
*
|
|
7
|
+
* Why a dedicated node instead of a conditional property write: Neo4j's
|
|
8
|
+
* read-committed isolation does not re-evaluate a `WHERE` after a write lock is
|
|
9
|
+
* acquired, so two concurrent `SET ... WHERE windowKey <> $key` statements can
|
|
10
|
+
* both "win" and send two emails. A unique constraint on `deliveryKey` makes the
|
|
11
|
+
* claim a hard single-writer: exactly one `CREATE` succeeds, every loser observes
|
|
12
|
+
* a constraint violation and skips.
|
|
13
|
+
*
|
|
14
|
+
* This gives at-most-once email delivery per configured window. A send that fails
|
|
15
|
+
* after the claim is recorded as `failed` and is NOT retried inside the same
|
|
16
|
+
* window (see `credential-refresh-email.helpers.ts` for the explicit policy).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type CredentialRefreshEmailDeliveryState = 'claimed' | 'delivered' | 'failed';
|
|
20
|
+
|
|
21
|
+
const deliveryKeyFor = (refreshId: string, windowKey: string): string =>
|
|
22
|
+
`${refreshId}:${windowKey}`;
|
|
23
|
+
|
|
24
|
+
const isUniqueConstraintViolation = (error: unknown): boolean => {
|
|
25
|
+
const code = (error as { code?: string })?.code;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
code === 'Neo.ClientError.Schema.ConstraintValidationFailed' ||
|
|
29
|
+
(error instanceof Error && error.message.includes('already exists'))
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attempts to claim the email delivery window. Returns `'claimed'` for the single
|
|
35
|
+
* winner and `'duplicate'` for every concurrent or retried caller.
|
|
36
|
+
*/
|
|
37
|
+
export const claimCredentialRefreshEmailDelivery = async (params: {
|
|
38
|
+
refreshId: string;
|
|
39
|
+
windowKey: string;
|
|
40
|
+
}): Promise<'claimed' | 'duplicate'> => {
|
|
41
|
+
const { refreshId, windowKey } = params;
|
|
42
|
+
const now = new Date().toISOString();
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await neogma.queryRunner.run(
|
|
46
|
+
`CREATE (delivery:CredentialRefreshEmailDelivery {
|
|
47
|
+
deliveryKey: $deliveryKey,
|
|
48
|
+
refreshId: $refreshId,
|
|
49
|
+
windowKey: $windowKey,
|
|
50
|
+
state: 'claimed',
|
|
51
|
+
attempts: 1,
|
|
52
|
+
createdAt: $now,
|
|
53
|
+
updatedAt: $now
|
|
54
|
+
})`,
|
|
55
|
+
{
|
|
56
|
+
deliveryKey: deliveryKeyFor(refreshId, windowKey),
|
|
57
|
+
refreshId,
|
|
58
|
+
windowKey,
|
|
59
|
+
now,
|
|
60
|
+
}
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return 'claimed';
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (isUniqueConstraintViolation(error)) return 'duplicate';
|
|
66
|
+
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Records the terminal outcome of a claimed delivery attempt. */
|
|
72
|
+
export const finalizeCredentialRefreshEmailDelivery = async (params: {
|
|
73
|
+
refreshId: string;
|
|
74
|
+
windowKey: string;
|
|
75
|
+
state: Extract<CredentialRefreshEmailDeliveryState, 'delivered' | 'failed'>;
|
|
76
|
+
}): Promise<void> => {
|
|
77
|
+
const { refreshId, windowKey, state } = params;
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
|
|
80
|
+
await neogma.queryRunner.run(
|
|
81
|
+
`MATCH (delivery:CredentialRefreshEmailDelivery {deliveryKey: $deliveryKey})
|
|
82
|
+
SET delivery.state = $state, delivery.updatedAt = $now`,
|
|
83
|
+
{ deliveryKey: deliveryKeyFor(refreshId, windowKey), state, now }
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Reads a delivery record for observability and tests. */
|
|
88
|
+
export const getCredentialRefreshEmailDelivery = async (
|
|
89
|
+
refreshId: string,
|
|
90
|
+
windowKey: string
|
|
91
|
+
): Promise<{
|
|
92
|
+
deliveryKey: string;
|
|
93
|
+
refreshId: string;
|
|
94
|
+
windowKey: string;
|
|
95
|
+
state: CredentialRefreshEmailDeliveryState;
|
|
96
|
+
attempts: number;
|
|
97
|
+
} | null> => {
|
|
98
|
+
const result = await neogma.queryRunner.run(
|
|
99
|
+
`MATCH (delivery:CredentialRefreshEmailDelivery {deliveryKey: $deliveryKey})
|
|
100
|
+
RETURN delivery LIMIT 1`,
|
|
101
|
+
{ deliveryKey: deliveryKeyFor(refreshId, windowKey) }
|
|
102
|
+
);
|
|
103
|
+
const node = result.records[0]?.get('delivery');
|
|
104
|
+
|
|
105
|
+
if (!node) return null;
|
|
106
|
+
|
|
107
|
+
const props = node.properties;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
deliveryKey: props.deliveryKey,
|
|
111
|
+
refreshId: props.refreshId,
|
|
112
|
+
windowKey: props.windowKey,
|
|
113
|
+
state: props.state,
|
|
114
|
+
attempts: Number(props.attempts),
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -262,8 +262,14 @@ export const getCredentialRefreshVersions = async (
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
const result = await neogma.queryRunner.run(
|
|
265
|
-
`
|
|
266
|
-
|
|
265
|
+
`CALL {
|
|
266
|
+
MATCH (version:Credential {refreshId: $refreshId}) RETURN version
|
|
267
|
+
UNION ALL
|
|
268
|
+
MATCH (version:InboxRefreshPublication {refreshId: $refreshId})
|
|
269
|
+
WHERE NOT EXISTS { MATCH (:Credential {refreshVersionKey: version.refreshVersionKey}) }
|
|
270
|
+
RETURN version
|
|
271
|
+
}
|
|
272
|
+
WITH version WHERE $beforeVersion IS NULL OR version.version < $beforeVersion
|
|
267
273
|
RETURN {
|
|
268
274
|
id: version.id,
|
|
269
275
|
refreshId: version.refreshId,
|
|
@@ -78,6 +78,7 @@ export const advanceCredentialRefreshHead = async (
|
|
|
78
78
|
etag,
|
|
79
79
|
materialDigest,
|
|
80
80
|
updateSummary,
|
|
81
|
+
credentialDisplayName,
|
|
81
82
|
effectiveAt,
|
|
82
83
|
notificationOutcome,
|
|
83
84
|
notificationId,
|
|
@@ -103,6 +104,7 @@ export const advanceCredentialRefreshHead = async (
|
|
|
103
104
|
etag,
|
|
104
105
|
signingMode,
|
|
105
106
|
updateSummary,
|
|
107
|
+
credentialDisplayName,
|
|
106
108
|
idempotencyKey,
|
|
107
109
|
refreshIdempotencyKey,
|
|
108
110
|
notificationOutcome,
|
|
@@ -160,6 +162,7 @@ export const advanceCredentialRefreshHead = async (
|
|
|
160
162
|
refresh.etag = $etag,
|
|
161
163
|
refresh.materialDigest = $materialDigest,
|
|
162
164
|
refresh.updateSummary = $updateSummary,
|
|
165
|
+
refresh.credentialDisplayName = coalesce($credentialDisplayName, refresh.credentialDisplayName),
|
|
163
166
|
refresh.signingMode = $signingMode,
|
|
164
167
|
refresh.lastPublishedAt = $publishedAt,
|
|
165
168
|
refresh.idempotencyKey = $idempotencyKey,
|
|
@@ -176,6 +179,7 @@ export const advanceCredentialRefreshHead = async (
|
|
|
176
179
|
etag: etag ?? null,
|
|
177
180
|
materialDigest: materialDigest ?? null,
|
|
178
181
|
updateSummary: updateSummary ?? null,
|
|
182
|
+
credentialDisplayName: credentialDisplayName ?? null,
|
|
179
183
|
signingMode,
|
|
180
184
|
publishedAt,
|
|
181
185
|
now,
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import type { ManagedTransaction } from 'neo4j-driver';
|
|
4
|
+
import type { InboxBatchReceipt, IssueInboxCredentialBatchItemResult } from '@learncard/types';
|
|
5
|
+
import { neogma } from '@instance';
|
|
6
|
+
import { encryptInboxCredential, decryptInboxCredential } from '@helpers/inbox-encryption.helpers';
|
|
7
|
+
import type { BatchItem, BatchJob, BatchReplayStore, InboxDispatchLease } from 'types/inbox-batch';
|
|
8
|
+
import { getInboxBatchState } from '@helpers/inbox-batch-status.helpers';
|
|
9
|
+
|
|
10
|
+
export const DAY = 86_400_000;
|
|
11
|
+
export const LEASE_MS = 360_000;
|
|
12
|
+
const CLEANUP_MARGIN_MS = 5_000;
|
|
13
|
+
|
|
14
|
+
let readiness: Promise<void> | undefined;
|
|
15
|
+
export const ensureInboxBatchConstraints = (): Promise<void> => {
|
|
16
|
+
if (!readiness) {
|
|
17
|
+
readiness = (async () => {
|
|
18
|
+
for (const label of [
|
|
19
|
+
'InboxBatch',
|
|
20
|
+
'InboxBatchItem',
|
|
21
|
+
'InboxBatchIssuer',
|
|
22
|
+
'InboxBatchReplay',
|
|
23
|
+
]) {
|
|
24
|
+
try {
|
|
25
|
+
await neogma.queryRunner.run(
|
|
26
|
+
`CREATE CONSTRAINT ${label.toLowerCase()}_id IF NOT EXISTS FOR (n:${label}) REQUIRE n.id IS UNIQUE`
|
|
27
|
+
);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (
|
|
30
|
+
(error as { code?: string }).code !==
|
|
31
|
+
'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists'
|
|
32
|
+
)
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const query of [
|
|
37
|
+
'CREATE INDEX inbox_batch_request IF NOT EXISTS FOR (b:InboxBatch) ON (b.issuer, b.requestId)',
|
|
38
|
+
'CREATE INDEX inbox_batch_dispatch IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.dispatchAt)',
|
|
39
|
+
'CREATE INDEX inbox_batch_recovery IF NOT EXISTS FOR (i:InboxBatchItem) ON (i.state, i.leaseUntil)',
|
|
40
|
+
'CREATE INDEX inbox_batch_replay_owner IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.itemId)',
|
|
41
|
+
'CREATE INDEX inbox_batch_completed IF NOT EXISTS FOR (b:InboxBatch) ON (b.completedAt)',
|
|
42
|
+
'CREATE INDEX inbox_batch_replay_expiry IF NOT EXISTS FOR (r:InboxBatchReplay) ON (r.expiresAt)',
|
|
43
|
+
])
|
|
44
|
+
await neogma.queryRunner.run(query);
|
|
45
|
+
})();
|
|
46
|
+
void readiness.catch(() => {
|
|
47
|
+
readiness = undefined;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return readiness;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const transaction = async <T>(fn: (tx: ManagedTransaction) => Promise<T>): Promise<T> => {
|
|
54
|
+
await ensureInboxBatchConstraints();
|
|
55
|
+
const session = neogma.driver.session();
|
|
56
|
+
try {
|
|
57
|
+
return await session.executeWrite(fn);
|
|
58
|
+
} finally {
|
|
59
|
+
await session.close();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The issuer node serializes submission replay and quota admission in the same transaction. */
|
|
64
|
+
export const createBatchJob = async (input: {
|
|
65
|
+
issuer: string;
|
|
66
|
+
requestId?: string;
|
|
67
|
+
requestHash: string;
|
|
68
|
+
payload: string;
|
|
69
|
+
items: { replayKey: string; duplicate: boolean; correlation?: string }[];
|
|
70
|
+
limit: number;
|
|
71
|
+
}): Promise<InboxBatchReceipt> => {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const id = randomUUID();
|
|
74
|
+
return transaction(async tx => {
|
|
75
|
+
// The property write acquires Neo4j's exclusive node lock until commit. Concurrent
|
|
76
|
+
// admissions therefore cannot read quota between this statement and the update below.
|
|
77
|
+
const locked = await tx.run(
|
|
78
|
+
`MERGE (q:InboxBatchIssuer {id: $issuer})
|
|
79
|
+
ON CREATE SET q.used = 0, q.window = $now
|
|
80
|
+
SET q.lock = coalesce(q.lock, 0) + 1 RETURN q`,
|
|
81
|
+
{ issuer: input.issuer, now }
|
|
82
|
+
);
|
|
83
|
+
if (input.requestId) {
|
|
84
|
+
// Creation atomically creates at least one HAS_ITEM relationship. Requiring it here
|
|
85
|
+
// prevents a structurally incomplete batch from being returned as a valid replay.
|
|
86
|
+
const prior = await tx.run(
|
|
87
|
+
`MATCH (b:InboxBatch {issuer: $issuer, requestId: $requestId})
|
|
88
|
+
WHERE b.createdAt > $cutoff
|
|
89
|
+
MATCH (b)-[:HAS_ITEM]->(i:InboxBatchItem) RETURN b, collect(i.state) AS states`,
|
|
90
|
+
{ issuer: input.issuer, requestId: input.requestId, cutoff: now - DAY }
|
|
91
|
+
);
|
|
92
|
+
const batch = prior.records[0]?.get('b').properties;
|
|
93
|
+
if (batch) {
|
|
94
|
+
if (batch.requestHash !== input.requestHash)
|
|
95
|
+
throw new TRPCError({
|
|
96
|
+
code: 'CONFLICT',
|
|
97
|
+
message: 'requestId was used for a different batch.',
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
batchId: batch.id,
|
|
101
|
+
status: getInboxBatchState(prior.records[0]!.get('states')),
|
|
102
|
+
createdAt: new Date(Number(batch.createdAt)).toISOString(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const quota = locked.records[0]!.get('q').properties;
|
|
107
|
+
const expired = now - Number(quota.window) >= 3_600_000;
|
|
108
|
+
const used = expired ? 0 : Number(quota.used);
|
|
109
|
+
if (used + input.items.length > input.limit)
|
|
110
|
+
throw new TRPCError({
|
|
111
|
+
code: 'TOO_MANY_REQUESTS',
|
|
112
|
+
message: `${input.limit} inbox items per hour; retry after the current window expires (at most 3600 seconds).`,
|
|
113
|
+
});
|
|
114
|
+
await tx.run(
|
|
115
|
+
`MATCH (q:InboxBatchIssuer {id: $issuer}) SET q.used = $used, q.window = $window
|
|
116
|
+
CREATE (b:InboxBatch {id: $id, issuer: $issuer, createdAt: $now, requestHash: $hash, payload: $payload})
|
|
117
|
+
SET b.requestId = $requestId
|
|
118
|
+
WITH b UNWIND $items AS item
|
|
119
|
+
CREATE (b)-[:HAS_ITEM]->(:InboxBatchItem {id: item.id, batchId: $id,
|
|
120
|
+
index: item.index, replayKey: item.replayKey, duplicate: item.duplicate,
|
|
121
|
+
correlation: item.correlation,
|
|
122
|
+
state: 'QUEUED', attempts: 0, dispatchAt: 0})`,
|
|
123
|
+
{
|
|
124
|
+
issuer: input.issuer,
|
|
125
|
+
id,
|
|
126
|
+
now,
|
|
127
|
+
used: used + input.items.length,
|
|
128
|
+
window: expired ? now : Number(quota.window),
|
|
129
|
+
hash: input.requestHash,
|
|
130
|
+
payload: input.payload,
|
|
131
|
+
requestId: input.requestId ?? null,
|
|
132
|
+
items: input.items.map((item, index) => ({ ...item, index, id: `${id}:${index}` })),
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
return { batchId: id, status: 'QUEUED', createdAt: new Date(now).toISOString() };
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const readBatchJob = async (
|
|
140
|
+
id: string,
|
|
141
|
+
issuer: string
|
|
142
|
+
): Promise<{ job: BatchJob; items: BatchItem[] }> => {
|
|
143
|
+
const result = await neogma.queryRunner.run(
|
|
144
|
+
`MATCH (b:InboxBatch {id: $id, issuer: $issuer})-[:HAS_ITEM]->(i)
|
|
145
|
+
RETURN b, i ORDER BY i.index`,
|
|
146
|
+
{ id, issuer }
|
|
147
|
+
);
|
|
148
|
+
if (!result.records.length) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
149
|
+
return {
|
|
150
|
+
job: result.records[0]!.get('b').properties,
|
|
151
|
+
items: result.records.map(r => r.get('i').properties),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Lock before testing ownership: concurrent SQS deliveries must not both enter issuance. */
|
|
156
|
+
export const claimBatchItem = async (
|
|
157
|
+
id: string,
|
|
158
|
+
owner: string
|
|
159
|
+
): Promise<{ job: BatchJob; item: BatchItem } | undefined> =>
|
|
160
|
+
transaction(async tx => {
|
|
161
|
+
const result = await tx.run(
|
|
162
|
+
`MATCH (b:InboxBatch)-[:HAS_ITEM]->(i:InboxBatchItem {id: $id})
|
|
163
|
+
SET i.lock = coalesce(i.lock, 0) + 1 RETURN b, i`,
|
|
164
|
+
{ id }
|
|
165
|
+
);
|
|
166
|
+
if (!result.records.length) return;
|
|
167
|
+
const item = result.records[0]!.get('i').properties as BatchItem;
|
|
168
|
+
if (item.state !== 'QUEUED') return;
|
|
169
|
+
await tx.run(
|
|
170
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.state = 'PROCESSING', i.phase = 'PREPARING',
|
|
171
|
+
i.owner = $owner, i.leaseUntil = $lease, i.attempts = i.attempts + 1`,
|
|
172
|
+
{ id, owner, lease: Date.now() + LEASE_MS }
|
|
173
|
+
);
|
|
174
|
+
return { job: result.records[0]!.get('b').properties, item };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const lockOwnedItem = async (
|
|
178
|
+
tx: ManagedTransaction,
|
|
179
|
+
id: string,
|
|
180
|
+
owner: string
|
|
181
|
+
): Promise<BatchItem> => {
|
|
182
|
+
const result = await tx.run(
|
|
183
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.lock = coalesce(i.lock, 0) + 1 RETURN i`,
|
|
184
|
+
{ id }
|
|
185
|
+
);
|
|
186
|
+
const item = result.records[0]?.get('i').properties as BatchItem | undefined;
|
|
187
|
+
if (
|
|
188
|
+
!item ||
|
|
189
|
+
item.owner !== owner ||
|
|
190
|
+
item.state !== 'PROCESSING' ||
|
|
191
|
+
Number(item.leaseUntil) <= Date.now()
|
|
192
|
+
)
|
|
193
|
+
throw new Error('Inbox item lease lost');
|
|
194
|
+
return item;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export const markBatchIssuanceStarted = async (id: string, owner: string): Promise<void> =>
|
|
198
|
+
transaction(async tx => {
|
|
199
|
+
await lockOwnedItem(tx, id, owner);
|
|
200
|
+
await tx.run(`MATCH (i:InboxBatchItem {id: $id}) SET i.phase = 'ISSUING'`, { id });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/** Adapter retains the existing request fingerprint/CAS protocol with durable, encrypted records.
|
|
204
|
+
* Processing markers have no expiry. Orphaned internal markers are removed after job retention;
|
|
205
|
+
* client-keyed markers remain blocked. Only confirmed successes get the 24-hour replay expiry.
|
|
206
|
+
*/
|
|
207
|
+
export const batchReplayStore = (
|
|
208
|
+
itemId: string,
|
|
209
|
+
owner: string,
|
|
210
|
+
replayKey: string
|
|
211
|
+
): BatchReplayStore => ({
|
|
212
|
+
get: async (_key: string): Promise<string | null> => {
|
|
213
|
+
const result = await neogma.queryRunner.run(
|
|
214
|
+
`MATCH (r:InboxBatchReplay {id: $id})
|
|
215
|
+
WHERE r.expiresAt IS NULL OR r.expiresAt > $now RETURN r.value AS value`,
|
|
216
|
+
{ id: replayKey, now: Date.now() }
|
|
217
|
+
);
|
|
218
|
+
const value = result.records[0]?.get('value');
|
|
219
|
+
return value ? decryptInboxCredential(value) : null;
|
|
220
|
+
},
|
|
221
|
+
setIfAbsent: async (_key: string, value: string, _ttl: number): Promise<'OK' | null> => {
|
|
222
|
+
const encrypted = await encryptInboxCredential(value);
|
|
223
|
+
return transaction(async tx => {
|
|
224
|
+
await lockOwnedItem(tx, itemId, owner);
|
|
225
|
+
const rows = await tx.run(
|
|
226
|
+
`MERGE (r:InboxBatchReplay {id: $id}) SET r.lock = coalesce(r.lock, 0) + 1 RETURN r`,
|
|
227
|
+
{ id: replayKey }
|
|
228
|
+
);
|
|
229
|
+
const replay = rows.records[0]!.get('r').properties;
|
|
230
|
+
if (replay.value && (replay.expiresAt == null || Number(replay.expiresAt) > Date.now()))
|
|
231
|
+
return null;
|
|
232
|
+
await tx.run(
|
|
233
|
+
`MATCH (r:InboxBatchReplay {id: $id}) SET r.value = $value, r.marker = $marker,
|
|
234
|
+
r.itemId = $itemId, r.expiresAt = null`,
|
|
235
|
+
{ id: replayKey, value: encrypted, marker: value, itemId }
|
|
236
|
+
);
|
|
237
|
+
return 'OK';
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
compareAndSet: async (
|
|
241
|
+
_key: string,
|
|
242
|
+
expected: string,
|
|
243
|
+
value: string | null,
|
|
244
|
+
ttl: number
|
|
245
|
+
): Promise<boolean> => {
|
|
246
|
+
const encrypted = value === null ? null : await encryptInboxCredential(value);
|
|
247
|
+
return transaction(async tx => {
|
|
248
|
+
await lockOwnedItem(tx, itemId, owner);
|
|
249
|
+
const rows = await tx.run(
|
|
250
|
+
`MATCH (r:InboxBatchReplay {id: $id}) SET r.lock = coalesce(r.lock, 0) + 1 RETURN r.marker AS marker`,
|
|
251
|
+
{ id: replayKey }
|
|
252
|
+
);
|
|
253
|
+
if (rows.records[0]?.get('marker') !== expected) return false;
|
|
254
|
+
if (value === null) {
|
|
255
|
+
await tx.run(`MATCH (r:InboxBatchReplay {id: $id}) DELETE r`, { id: replayKey });
|
|
256
|
+
// The shared helper only releases after an explicitly safe preflight failure.
|
|
257
|
+
await tx.run(`MATCH (i:InboxBatchItem {id: $itemId}) SET i.phase = 'PREPARING'`, {
|
|
258
|
+
itemId,
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
await tx.run(
|
|
262
|
+
`MATCH (r:InboxBatchReplay {id: $id}) SET r.value = $value,
|
|
263
|
+
r.marker = null, r.expiresAt = $expiry`,
|
|
264
|
+
{ id: replayKey, value: encrypted, expiry: Date.now() + ttl * 1000 }
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
/** Terminal items no longer need the original credential payload, including uncertain outcomes.
|
|
273
|
+
* Client-keyed replay reservations are separate nodes and survive the 30-day job retention period.
|
|
274
|
+
*/
|
|
275
|
+
const finalizeSettledBatch = async (tx: ManagedTransaction, id: string): Promise<void> => {
|
|
276
|
+
await tx.run(
|
|
277
|
+
`MATCH (b:InboxBatch {id: $id}) SET b.lock = coalesce(b.lock, 0) + 1
|
|
278
|
+
WITH b WHERE NOT EXISTS {
|
|
279
|
+
MATCH (b)-[:HAS_ITEM]->(i) WHERE i.state IN ['QUEUED', 'PROCESSING']
|
|
280
|
+
}
|
|
281
|
+
SET b.completedAt = coalesce(b.completedAt, $now) REMOVE b.payload`,
|
|
282
|
+
{ id, now: Date.now() }
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const finishBatchItem = async (
|
|
287
|
+
id: string,
|
|
288
|
+
owner: string,
|
|
289
|
+
result: IssueInboxCredentialBatchItemResult
|
|
290
|
+
): Promise<void> => {
|
|
291
|
+
const encrypted = await encryptInboxCredential(JSON.stringify(result));
|
|
292
|
+
await transaction(async tx => {
|
|
293
|
+
const item = await lockOwnedItem(tx, id, owner);
|
|
294
|
+
const unconfirmed = !result.success && item.phase === 'ISSUING';
|
|
295
|
+
const retry =
|
|
296
|
+
!result.success &&
|
|
297
|
+
(result.error.code === 'INTERNAL_SERVER_ERROR' ||
|
|
298
|
+
result.error.reason === 'IN_PROGRESS') &&
|
|
299
|
+
!unconfirmed &&
|
|
300
|
+
Number(item.attempts) < 5;
|
|
301
|
+
if (retry)
|
|
302
|
+
await tx.run(
|
|
303
|
+
`MATCH (r:InboxBatchReplay {itemId: $id}) WHERE r.marker IS NOT NULL DELETE r`,
|
|
304
|
+
{ id }
|
|
305
|
+
);
|
|
306
|
+
await tx.run(
|
|
307
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.state = $state, i.result = $result,
|
|
308
|
+
i.owner = null, i.leaseUntil = null, i.dispatchAt = $dispatchAt`,
|
|
309
|
+
{
|
|
310
|
+
id,
|
|
311
|
+
state: retry ? 'QUEUED' : unconfirmed ? 'NEEDS_RECONCILIATION' : 'COMPLETED',
|
|
312
|
+
result: retry ? null : encrypted,
|
|
313
|
+
dispatchAt:
|
|
314
|
+
retry && !result.success && result.error.reason === 'IN_PROGRESS'
|
|
315
|
+
? Date.now() + Math.min(60_000, 2_000 * 2 ** (Number(item.attempts) - 1))
|
|
316
|
+
: 0,
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
await finalizeSettledBatch(tx, item.batchId);
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/** Claim dispatch leases before publishing. A lost publish acknowledgement causes a safe duplicate. */
|
|
324
|
+
export const takeInboxDispatches = async (): Promise<InboxDispatchLease[]> =>
|
|
325
|
+
transaction(async tx => {
|
|
326
|
+
const result = await tx.run(
|
|
327
|
+
`MATCH (i:InboxBatchItem) WHERE i.state = 'QUEUED' AND i.dispatchAt < $now
|
|
328
|
+
WITH i LIMIT 1000 SET i.lock = coalesce(i.lock, 0) + 1
|
|
329
|
+
WITH i WHERE i.state = 'QUEUED' AND i.dispatchAt < $now
|
|
330
|
+
SET i.dispatchAt = $next RETURN i.id AS id, i.dispatchAt AS dispatchAt`,
|
|
331
|
+
{ now: Date.now(), next: Date.now() + 60_000 }
|
|
332
|
+
);
|
|
333
|
+
return result.records.map(r => ({
|
|
334
|
+
id: r.get('id'),
|
|
335
|
+
dispatchAt: Number(r.get('dispatchAt')),
|
|
336
|
+
}));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const acknowledgeInboxDispatches = async (leases: InboxDispatchLease[]): Promise<void> => {
|
|
340
|
+
await neogma.queryRunner.run(
|
|
341
|
+
`UNWIND $leases AS lease MATCH (i:InboxBatchItem {id: lease.id})
|
|
342
|
+
SET i.lock = coalesce(i.lock, 0) + 1
|
|
343
|
+
WITH i, lease WHERE i.state = 'QUEUED' AND i.dispatchAt = lease.dispatchAt
|
|
344
|
+
SET i.dispatchAt = $next`,
|
|
345
|
+
{ leases, next: Date.now() + 1_800_000 }
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** Poison messages become visible terminal outcomes instead of disappearing into the DLQ. */
|
|
350
|
+
export const deadLetterBatchItem = async (id: string): Promise<void> =>
|
|
351
|
+
transaction(async tx => {
|
|
352
|
+
const rows = await tx.run(
|
|
353
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.lock = coalesce(i.lock, 0) + 1 RETURN i`,
|
|
354
|
+
{ id }
|
|
355
|
+
);
|
|
356
|
+
const item = rows.records[0]?.get('i').properties as BatchItem | undefined;
|
|
357
|
+
if (!item || ['COMPLETED', 'NEEDS_RECONCILIATION'].includes(item.state)) return;
|
|
358
|
+
if (item.state === 'PROCESSING' && Number(item.leaseUntil) > Date.now())
|
|
359
|
+
throw new Error('Inbox item still has an active worker');
|
|
360
|
+
const uncertain = item.phase === 'ISSUING';
|
|
361
|
+
const replay = await tx.run(
|
|
362
|
+
`MATCH (r:InboxBatchReplay {itemId: $id}) WHERE r.marker IS NULL RETURN r.value AS value`,
|
|
363
|
+
{ id }
|
|
364
|
+
);
|
|
365
|
+
const saved = replay.records[0]?.get('value');
|
|
366
|
+
if (!uncertain)
|
|
367
|
+
await tx.run(
|
|
368
|
+
`MATCH (r:InboxBatchReplay {itemId: $id}) WHERE r.marker IS NOT NULL DELETE r`,
|
|
369
|
+
{ id }
|
|
370
|
+
);
|
|
371
|
+
await tx.run(
|
|
372
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.state = $state, i.owner = null, i.leaseUntil = null, i.result = $result`,
|
|
373
|
+
{
|
|
374
|
+
id,
|
|
375
|
+
state: uncertain && !saved ? 'NEEDS_RECONCILIATION' : 'COMPLETED',
|
|
376
|
+
result: saved ?? null,
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
await finalizeSettledBatch(tx, item.batchId);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/** Recover bounded groups in short transactions without holding locks across the whole maintenance run. */
|
|
383
|
+
export const recoverInboxJobs = async (deadline = Date.now() + 20_000): Promise<void> => {
|
|
384
|
+
await ensureInboxBatchConstraints();
|
|
385
|
+
if (Date.now() >= deadline) return;
|
|
386
|
+
const expired = await neogma.queryRunner.run(
|
|
387
|
+
`MATCH (i:InboxBatchItem) WHERE i.state = 'PROCESSING' AND i.leaseUntil < $now
|
|
388
|
+
RETURN i.id AS id LIMIT 100`,
|
|
389
|
+
{ now: Date.now() }
|
|
390
|
+
);
|
|
391
|
+
for (const row of expired.records) {
|
|
392
|
+
if (Date.now() >= deadline) break;
|
|
393
|
+
await transaction(async tx => {
|
|
394
|
+
const rows = await tx.run(
|
|
395
|
+
`MATCH (i:InboxBatchItem {id: $id})
|
|
396
|
+
SET i.lock = coalesce(i.lock, 0) + 1
|
|
397
|
+
WITH i WHERE i.state = 'PROCESSING' AND i.leaseUntil < $now RETURN i`,
|
|
398
|
+
{ id: row.get('id'), now: Date.now() }
|
|
399
|
+
);
|
|
400
|
+
const item = rows.records[0]?.get('i').properties as BatchItem | undefined;
|
|
401
|
+
if (!item) return;
|
|
402
|
+
// A persisted success is authoritative even if the worker lost its completion reply.
|
|
403
|
+
const replay = await tx.run(
|
|
404
|
+
`MATCH (r:InboxBatchReplay {itemId: $id}) WHERE r.marker IS NULL RETURN r.value AS value`,
|
|
405
|
+
{ id: item.id }
|
|
406
|
+
);
|
|
407
|
+
const saved = replay.records[0]?.get('value');
|
|
408
|
+
const state = saved
|
|
409
|
+
? 'COMPLETED'
|
|
410
|
+
: item.phase === 'ISSUING'
|
|
411
|
+
? 'NEEDS_RECONCILIATION'
|
|
412
|
+
: Number(item.attempts) >= 5
|
|
413
|
+
? 'COMPLETED'
|
|
414
|
+
: 'QUEUED';
|
|
415
|
+
if (item.phase !== 'ISSUING')
|
|
416
|
+
await tx.run(
|
|
417
|
+
`MATCH (r:InboxBatchReplay {itemId: $id}) WHERE r.marker IS NOT NULL DELETE r`,
|
|
418
|
+
{ id: item.id }
|
|
419
|
+
);
|
|
420
|
+
await tx.run(
|
|
421
|
+
`MATCH (i:InboxBatchItem {id: $id}) SET i.state = $state, i.owner = null,
|
|
422
|
+
i.leaseUntil = null, i.dispatchAt = 0, i.result = $result`,
|
|
423
|
+
{ id: item.id, state, result: saved ?? null }
|
|
424
|
+
);
|
|
425
|
+
await finalizeSettledBatch(tx, item.batchId);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (Date.now() < deadline) {
|
|
429
|
+
// Also finalize older terminal batches that predate immediate terminal cleanup.
|
|
430
|
+
const settled = await neogma.queryRunner.run(
|
|
431
|
+
`MATCH (b:InboxBatch) WHERE b.completedAt IS NULL
|
|
432
|
+
AND NOT EXISTS { MATCH (b)-[:HAS_ITEM]->(i) WHERE i.state IN ['QUEUED', 'PROCESSING'] }
|
|
433
|
+
RETURN b.id AS id LIMIT 100`
|
|
434
|
+
);
|
|
435
|
+
for (const row of settled.records) {
|
|
436
|
+
if (Date.now() >= deadline) break;
|
|
437
|
+
await transaction(tx => finalizeSettledBatch(tx, row.get('id')));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Leave room for each transaction to finish; checking only for a future deadline can
|
|
442
|
+
// start a large delete immediately before Lambda shutdown. Recheck between steps.
|
|
443
|
+
if (deadline - Date.now() > CLEANUP_MARGIN_MS)
|
|
444
|
+
await transaction(async tx => {
|
|
445
|
+
await tx.run(
|
|
446
|
+
`MATCH (b:InboxBatch) WHERE b.completedAt < $cutoff WITH b LIMIT 25
|
|
447
|
+
MATCH (b)-[:HAS_ITEM]->(i) DETACH DELETE i, b`,
|
|
448
|
+
{ cutoff: Date.now() - 30 * DAY }
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
if (deadline - Date.now() > CLEANUP_MARGIN_MS)
|
|
452
|
+
await transaction(async tx => {
|
|
453
|
+
await tx.run(
|
|
454
|
+
`MATCH (r:InboxBatchReplay) WHERE r.expiresAt < $now WITH r LIMIT 1000 DELETE r`,
|
|
455
|
+
{ now: Date.now() }
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
if (deadline - Date.now() > CLEANUP_MARGIN_MS)
|
|
459
|
+
await transaction(async tx => {
|
|
460
|
+
// Internal keys cannot be supplied by clients, and messages cannot claim a deleted
|
|
461
|
+
// item. Client-keyed reservations must continue blocking uncertain reissuance.
|
|
462
|
+
await tx.run(
|
|
463
|
+
`MATCH (r:InboxBatchReplay)
|
|
464
|
+
WHERE r.id STARTS WITH 'internal:' AND r.expiresAt IS NULL
|
|
465
|
+
AND NOT EXISTS { MATCH (i:InboxBatchItem) WHERE i.id = r.itemId }
|
|
466
|
+
WITH r LIMIT 1000 DELETE r`
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
};
|