@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.
Files changed (274) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +177 -159
  3. package/CHANGELOG.md +148 -0
  4. package/README.md +26 -8
  5. package/compose.inbox.yml +7 -0
  6. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  7. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  8. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  9. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
  10. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  11. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
  12. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  13. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
  14. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  15. package/dist/accesslayer/credential/create.d.ts +2 -1
  16. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/credential/create.test.d.ts +2 -0
  18. package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
  19. package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
  20. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  21. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  22. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  23. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  24. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  25. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  26. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  27. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  28. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  29. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  31. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  32. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  33. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  34. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  35. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  36. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  37. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  38. package/dist/cache/atomic-counter.test.d.ts +2 -0
  39. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  40. package/dist/cache/index.d.ts +1 -1
  41. package/dist/cache/index.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +46 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/credential-refresh.d.ts.map +1 -1
  45. package/dist/didkit_wasm_bg.wasm +0 -0
  46. package/dist/helpers/activity.helpers.d.ts +4 -0
  47. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  48. package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
  49. package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
  50. package/dist/helpers/boost.helpers.d.ts +3 -7
  51. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  52. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  53. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  54. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  55. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  56. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  57. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  58. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  60. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  61. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  62. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  63. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  64. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  65. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  66. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  67. package/dist/helpers/credential.helpers.d.ts +4 -2
  68. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  69. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  71. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  72. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  73. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  74. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  75. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  76. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  77. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  78. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  79. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  80. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  81. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  82. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  83. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  84. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  85. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  86. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  87. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  88. package/dist/helpers/inbox.helpers.d.ts +9 -1
  89. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  90. package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
  91. package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
  92. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  93. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  94. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  95. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  96. package/dist/helpers/shim.d.ts +1 -1
  97. package/dist/helpers/shim.d.ts.map +1 -1
  98. package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
  99. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  100. package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
  101. package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
  102. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  103. package/dist/helpers/status-list.helpers.test.d.ts +2 -0
  104. package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
  105. package/dist/inbox-queue-local.d.ts +2 -0
  106. package/dist/inbox-queue-local.d.ts.map +1 -0
  107. package/dist/index.js +2 -2
  108. package/dist/models/AuthGrant.d.ts.map +1 -1
  109. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  110. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  111. package/dist/models/Credential.d.ts +1 -0
  112. package/dist/models/Credential.d.ts.map +1 -1
  113. package/dist/models/CredentialActivity.d.ts +1 -0
  114. package/dist/models/CredentialActivity.d.ts.map +1 -1
  115. package/dist/models/CredentialRefresh.d.ts +5 -2
  116. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  117. package/dist/models/InboxCredential.d.ts +1 -0
  118. package/dist/models/InboxCredential.d.ts.map +1 -1
  119. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  120. package/dist/openapi.d.ts.map +1 -1
  121. package/dist/routes/act-as.test.d.ts +2 -0
  122. package/dist/routes/act-as.test.d.ts.map +1 -0
  123. package/dist/routes/activity.d.ts +3 -0
  124. package/dist/routes/activity.d.ts.map +1 -1
  125. package/dist/routes/app-store.d.ts.map +1 -1
  126. package/dist/routes/auth-grants.d.ts +10 -0
  127. package/dist/routes/auth-grants.d.ts.map +1 -1
  128. package/dist/routes/boosts.d.ts +506 -12
  129. package/dist/routes/boosts.d.ts.map +1 -1
  130. package/dist/routes/contracts.d.ts +48 -2
  131. package/dist/routes/contracts.d.ts.map +1 -1
  132. package/dist/routes/credential-refreshes.d.ts +4 -4
  133. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  134. package/dist/routes/credentials.d.ts +8 -2
  135. package/dist/routes/credentials.d.ts.map +1 -1
  136. package/dist/routes/did-metadata.d.ts +8 -8
  137. package/dist/routes/federation.d.ts +2 -2
  138. package/dist/routes/inbox.d.ts +973 -31
  139. package/dist/routes/inbox.d.ts.map +1 -1
  140. package/dist/routes/index.d.ts +47 -127
  141. package/dist/routes/index.d.ts.map +1 -1
  142. package/dist/routes/presentations.d.ts +6 -6
  143. package/dist/routes/storage.d.ts +63 -63
  144. package/dist/routes/test.d.ts +6 -0
  145. package/dist/routes/test.d.ts.map +1 -1
  146. package/dist/routes/workflows.d.ts +14 -14
  147. package/dist/routes/workflows.d.ts.map +1 -1
  148. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  149. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  150. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  151. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  152. package/dist/types/activity.d.ts +4 -0
  153. package/dist/types/activity.d.ts.map +1 -1
  154. package/dist/types/consentflowcontract.d.ts +22 -8
  155. package/dist/types/consentflowcontract.d.ts.map +1 -1
  156. package/dist/types/credential-refresh.d.ts +10 -1
  157. package/dist/types/credential-refresh.d.ts.map +1 -1
  158. package/dist/types/credential.d.ts +7 -0
  159. package/dist/types/credential.d.ts.map +1 -1
  160. package/dist/types/did-metadata.d.ts +2 -2
  161. package/dist/types/inbox-batch.d.ts +38 -0
  162. package/dist/types/inbox-batch.d.ts.map +1 -0
  163. package/inbox-e2e-setup.ts +53 -1
  164. package/inbox-elasticmq.conf +10 -0
  165. package/lambda.ts +61 -16
  166. package/package.json +18 -16
  167. package/scripts/setup-inbox-queues.ts +60 -0
  168. package/serverless-local.yml +2 -0
  169. package/serverless.yml +75 -0
  170. package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
  171. package/src/accesslayer/auth-grant/read.ts +2 -1
  172. package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
  173. package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
  174. package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
  175. package/src/accesslayer/credential/create.test.ts +141 -0
  176. package/src/accesslayer/credential/create.ts +27 -3
  177. package/src/accesslayer/credential-activity/create.ts +1 -0
  178. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  179. package/src/accesslayer/credential-refresh/index.ts +1 -0
  180. package/src/accesslayer/credential-refresh/read.ts +8 -2
  181. package/src/accesslayer/credential-refresh/update.ts +4 -0
  182. package/src/accesslayer/inbox-batch/store.ts +469 -0
  183. package/src/accesslayer/inbox-credential/create.ts +51 -19
  184. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  185. package/src/accesslayer/inbox-credential/update.ts +1 -1
  186. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  187. package/src/cache/atomic-counter.test.ts +6 -0
  188. package/src/cache/index.ts +25 -13
  189. package/src/config/environment.ts +26 -0
  190. package/src/credential-refresh.ts +5 -1
  191. package/src/docker-entry.ts +8 -0
  192. package/src/helpers/activity.helpers.ts +4 -0
  193. package/src/helpers/app-counter.helpers.ts +1 -1
  194. package/src/helpers/boost-storage.helpers.test.ts +242 -0
  195. package/src/helpers/boost.helpers.ts +58 -437
  196. package/src/helpers/contact-method.helpers.test.ts +43 -0
  197. package/src/helpers/contact-method.helpers.ts +3 -4
  198. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  199. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  200. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  201. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  202. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  203. package/src/helpers/credential-refresh.helpers.ts +385 -107
  204. package/src/helpers/credential.helpers.ts +6 -2
  205. package/src/helpers/finalize-inbox.helpers.ts +39 -22
  206. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  207. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  208. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  209. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  210. package/src/helpers/inbox-batch.helpers.ts +374 -0
  211. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  212. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  213. package/src/helpers/inbox-queue.helpers.ts +372 -0
  214. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  215. package/src/helpers/inbox.helpers.ts +301 -50
  216. package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
  217. package/src/helpers/notifications.helpers.ts +4 -2
  218. package/src/helpers/rateLimit.helpers.ts +2 -5
  219. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  220. package/src/helpers/shim.ts +32 -31
  221. package/src/helpers/signingAuthority.helpers.test.ts +198 -0
  222. package/src/helpers/signingAuthority.helpers.ts +41 -18
  223. package/src/helpers/status-list.helpers.test.ts +77 -0
  224. package/src/helpers/status-list.helpers.ts +31 -8
  225. package/src/inbox-queue-local.ts +28 -0
  226. package/src/models/AuthGrant.ts +1 -0
  227. package/src/models/ConsentFlowTerms.ts +4 -0
  228. package/src/models/ConsentFlowTransaction.ts +4 -0
  229. package/src/models/Credential.ts +1 -0
  230. package/src/models/CredentialActivity.ts +3 -5
  231. package/src/models/CredentialRefresh.ts +4 -2
  232. package/src/models/InboxCredential.ts +2 -0
  233. package/src/models/credential-refresh-constraints.test.ts +8 -3
  234. package/src/models/credential-refresh-constraints.ts +5 -0
  235. package/src/openapi.ts +8 -0
  236. package/src/routes/act-as.test.ts +239 -0
  237. package/src/routes/app-store.ts +69 -52
  238. package/src/routes/auth-grants.ts +13 -11
  239. package/src/routes/boosts.ts +943 -120
  240. package/src/routes/contracts.ts +55 -7
  241. package/src/routes/credential-refreshes.test.ts +8 -2
  242. package/src/routes/credential-refreshes.ts +4 -1
  243. package/src/routes/credentials.ts +2 -1
  244. package/src/routes/inbox.ts +95 -49
  245. package/src/routes/index.ts +164 -88
  246. package/src/routes/skills.ts +1 -1
  247. package/src/routes/workflows.ts +80 -31
  248. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  249. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  250. package/src/services/registry/registry.factory.ts +2 -1
  251. package/src/types/activity.ts +2 -0
  252. package/src/types/consentflowcontract.ts +21 -3
  253. package/src/types/credential-refresh.ts +21 -2
  254. package/src/types/credential.ts +13 -1
  255. package/src/types/inbox-batch.ts +46 -0
  256. package/test/app-store-listing.spec.ts +66 -30
  257. package/test/atomic-counter.e2e.ts +12 -0
  258. package/test/auth-grant.spec.ts +21 -21
  259. package/test/bitstring-status-list.spec.ts +12 -9
  260. package/test/consentflow.spec.ts +453 -26
  261. package/test/credential-refresh-email.spec.ts +663 -0
  262. package/test/credential-refresh-publication.spec.ts +13 -5
  263. package/test/guardian-gated-routes.spec.ts +337 -180
  264. package/test/helpers/inbox-e2e-environment.ts +6 -0
  265. package/test/inbox-batch.spec.ts +1338 -0
  266. package/test/inbox-refresh.spec.ts +534 -0
  267. package/test/inbox-security.e2e.ts +48 -3
  268. package/test/openapi.spec.ts +41 -1
  269. package/test/refresh-send.spec.ts +1799 -0
  270. package/test/registry.spec.ts +56 -11
  271. package/test/revoke-suspend-per-instance.spec.ts +9 -1
  272. package/vitest.config.ts +13 -0
  273. package/vitest.inbox-e2e.config.ts +5 -1
  274. 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
+ };
@@ -1,3 +1,4 @@
1
1
  export * from './create';
2
+ export * from './email-delivery';
2
3
  export * from './read';
3
4
  export * from './update';
@@ -262,8 +262,14 @@ export const getCredentialRefreshVersions = async (
262
262
  }
263
263
 
264
264
  const result = await neogma.queryRunner.run(
265
- `MATCH (version:Credential {refreshId: $refreshId})
266
- WHERE $beforeVersion IS NULL OR version.version < $beforeVersion
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
+ };