@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +319 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -1,34 +1,59 @@
1
1
  import { deleteMany, selectMany } from "../db/query.js";
2
- import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
2
+ import { deleteFileRefBytes } from "../derivatives/delete-stored-file.js";
3
3
  export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
4
+ const PAGE_ORDER = [
5
+ { col: "deletedAt", direction: "asc" },
6
+ { col: "id", direction: "asc" },
7
+ ];
8
+ // Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
9
+ // strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
10
+ // query repeats that bound next to the cursor.
11
+ async function selectNextPage(args) {
12
+ const { db, table, where, expiredBefore, cursor, pageSize } = args;
13
+ const options = { orderBy: PAGE_ORDER, limit: pageSize };
14
+ if (cursor === undefined)
15
+ return selectMany(db, table, where, options);
16
+ const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
17
+ if (sameInstant.length >= pageSize)
18
+ return sameInstant;
19
+ const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
20
+ return [...sameInstant, ...later];
21
+ }
4
22
  // Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
5
23
  // rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
6
- // row whose bytes could not be removed stays and the next run retries it.
24
+ // row whose bytes could not be removed stays and the next run retries it. A run walks the
25
+ // expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
26
+ // rows behind them; the run ends on the first empty page.
7
27
  export async function purgeExpiredFileRefsWithBytes(args) {
8
- const { db, table, where, files, log } = args;
28
+ const { db, table, where, expiredBefore, files, log } = args;
29
+ const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
9
30
  if (!files) {
10
31
  log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
11
32
  return;
12
33
  }
13
- const rows = await selectMany(db, table, where, {
14
- orderBy: [{ col: "deletedAt", direction: "asc" }],
15
- limit: EXPIRED_FILE_REF_PURGE_BATCH,
16
- });
17
- const purgedIds = [];
18
- const store = { list: files.list, delete: (key) => files.ref(key).delete() };
19
- for (const row of rows) {
20
- try {
21
- const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
22
- if (failedKeys.length === 0) {
23
- purgedIds.push(row.id);
24
- continue;
34
+ let cursor;
35
+ for (;;) {
36
+ const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
37
+ const lastRow = rows[rows.length - 1];
38
+ // skip: an empty page means every expired row has been visited
39
+ if (lastRow === undefined)
40
+ return;
41
+ cursor = lastRow;
42
+ const purgedIds = [];
43
+ for (const row of rows) {
44
+ try {
45
+ const failedKeys = await deleteFileRefBytes(files, row, "soft-delete-cleanup:fileRef");
46
+ if (failedKeys.length === 0) {
47
+ purgedIds.push(row.id);
48
+ continue;
49
+ }
50
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
51
+ }
52
+ catch (err) {
53
+ log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
25
54
  }
26
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
27
- }
28
- catch (err) {
29
- log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
30
55
  }
56
+ if (purgedIds.length > 0)
57
+ await deleteMany(db, table, { ...where, id: { in: purgedIds } });
31
58
  }
32
- if (purgedIds.length > 0)
33
- await deleteMany(db, table, { ...where, id: { in: purgedIds } });
34
59
  }
@@ -13,6 +13,41 @@ const UNAMBIGUOUS_SNIFFED_TYPES = new Set([
13
13
  ]);
14
14
  const ZIP_SIGNATURE = [0x50, 0x4b, 0x03, 0x04];
15
15
  const XML_PROBE_BYTES = 512;
16
+ // Root elements of markup that starts with `<` but is not a document the XML
17
+ // ingest providers should receive: HTML (documents and fragments), SVG.
18
+ const NON_XML_ROOT_ELEMENTS = new Set([
19
+ "html",
20
+ "head",
21
+ "body",
22
+ "script",
23
+ "style",
24
+ "meta",
25
+ "link",
26
+ "title",
27
+ "svg",
28
+ "div",
29
+ "span",
30
+ "p",
31
+ "a",
32
+ "img",
33
+ "br",
34
+ "hr",
35
+ "table",
36
+ "ul",
37
+ "ol",
38
+ "li",
39
+ "h1",
40
+ "h2",
41
+ "h3",
42
+ "h4",
43
+ "h5",
44
+ "h6",
45
+ "section",
46
+ "article",
47
+ "font",
48
+ "center",
49
+ ]);
50
+ const ROOT_ELEMENT_PATTERN = /^<(?:[A-Za-z_][\w.-]*:)?([A-Za-z_][\w.-]*)/;
16
51
  const EXTENSION_FALLBACK = {
17
52
  xml: "application/xml",
18
53
  pdf: "application/pdf",
@@ -38,8 +73,8 @@ function hasPrefix(bytes, prefix) {
38
73
  return prefix.every((byte, i) => bytes[i] === byte);
39
74
  }
40
75
  // Textual start only: a NUL in the probe means binary (or UTF-16, which is
41
- // deliberately not detected). HTML also starts with `<` and must not be
42
- // relabelled as XML.
76
+ // deliberately not detected). Without an XML declaration the root element
77
+ // decides: HTML/SVG roots (also namespace-prefixed) are not relabelled as XML.
43
78
  function looksLikeXml(bytes) {
44
79
  const probe = bytes.subarray(0, XML_PROBE_BYTES);
45
80
  if (probe.includes(0))
@@ -47,9 +82,10 @@ function looksLikeXml(bytes) {
47
82
  const text = new TextDecoder().decode(probe).replace(/^/, "").trimStart();
48
83
  if (text.startsWith("<?xml"))
49
84
  return true;
50
- if (!/^<[A-Za-z_]/.test(text))
85
+ const root = ROOT_ELEMENT_PATTERN.exec(text)?.[1];
86
+ if (root === undefined)
51
87
  return false;
52
- return !/^<(html|head|body|script)\b/i.test(text);
88
+ return !NON_XML_ROOT_ELEMENTS.has(root.toLowerCase());
53
89
  }
54
90
  function extensionOf(filename) {
55
91
  const dot = filename.lastIndexOf(".");
@@ -12,6 +12,7 @@
12
12
  // into createApp / setupTestStack alongside their domain features.
13
13
  import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect.js";
14
14
  import { entityEventName, executeRawQueryRead } from "../db/index.js";
15
+ import { toPostgresArrayLiteral } from "../db/queries/postgres-array-literal.js";
15
16
  import { incrementCounter } from "../db/query.js";
16
17
  import { defineFeature, qn, toKebab } from "../engine/index.js";
17
18
  import { SHARED_INSTANCE_SENTINEL, selectConsumerCursorForUpdate, } from "../pipeline/index.js";
@@ -141,12 +142,12 @@ export async function transferTenantStorageUsage(args) {
141
142
  if (!cursor)
142
143
  return;
143
144
  const rows = await executeRawQueryRead(db, `SELECT "id"::text AS id, "type", "payload"::text AS payload FROM kumiko_events
144
- WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3)
145
- AND "type" = ANY($4) AND "id" <= $5`, [
145
+ WHERE "tenant_id" = $1 AND "aggregate_type" = $2 AND "aggregate_id" = ANY($3::uuid[])
146
+ AND "type" = ANY($4::text[]) AND "id" <= $5`, [
146
147
  sourceTenantId,
147
148
  FILE_REF_AGGREGATE_TYPE,
148
- fileRefIds,
149
- TRACKED_FILE_REF_EVENT_TYPES,
149
+ toPostgresArrayLiteral(fileRefIds),
150
+ toPostgresArrayLiteral(TRACKED_FILE_REF_EVENT_TYPES),
150
151
  cursor.lastProcessedEventId,
151
152
  ]);
152
153
  let bytes = 0;
@@ -1,3 +1,4 @@
1
+ import { readZipEntryNames } from "./zip-entry-names.js";
1
2
  // Lives here (not in file-ref-entity.ts) so engine code can name the entity without importing
2
3
  // a module that calls createEntity at load time.
3
4
  export const FILE_REF_ENTITY_NAME = "fileRef";
@@ -111,13 +112,9 @@ const MAGIC_BYTE_SIGNATURES = [
111
112
  // so a byte search finds the docx part directory without unzipping. The main
112
113
  // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
113
114
  const DOCX_PART_PREFIX = "word/";
114
- function containsAscii(bytes, ascii) {
115
- const first = ascii.charCodeAt(0);
116
- for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
117
- if (startsWithAscii(bytes, ascii, i))
118
- return true;
119
- }
120
- return false;
115
+ function hasDocxPart(zipBytes) {
116
+ const names = readZipEntryNames(zipBytes);
117
+ return names?.some((name) => name.startsWith(DOCX_PART_PREFIX)) ?? false;
121
118
  }
122
119
  function startsWithBytes(bytes, signature) {
123
120
  if (bytes.length < signature.length)
@@ -237,7 +234,7 @@ export function validateFileContent(fileName, content, accept) {
237
234
  return { kind: "ok" };
238
235
  const sniffed = sniffMimeType(content);
239
236
  // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
240
- if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
237
+ if (sniffed === DOCX_MIME_TYPE && !hasDocxPart(content)) {
241
238
  return {
242
239
  kind: "rejected",
243
240
  error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
@@ -0,0 +1 @@
1
+ export declare function readZipEntryNames(bytes: Uint8Array): readonly string[] | null;
@@ -0,0 +1,46 @@
1
+ const EOCD_SIGNATURE = 0x06054b50;
2
+ const CENTRAL_HEADER_SIGNATURE = 0x02014b50;
3
+ const EOCD_MIN_LENGTH = 22;
4
+ const EOCD_MAX_COMMENT_LENGTH = 0xffff;
5
+ const CENTRAL_HEADER_FIXED_LENGTH = 46;
6
+ // Entry names from the central directory, which (unlike local headers) always
7
+ // carries sizes and names even for streamed ZIPs written with data descriptors.
8
+ // Returns null when the container is not a readable (non-ZIP64) ZIP.
9
+ export function readZipEntryNames(bytes) {
10
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
11
+ const eocdOffset = findEndOfCentralDirectory(view);
12
+ if (eocdOffset === null)
13
+ return null;
14
+ const entryCount = view.getUint16(eocdOffset + 10, true);
15
+ let offset = view.getUint32(eocdOffset + 16, true);
16
+ const decoder = new TextDecoder();
17
+ const names = [];
18
+ for (let i = 0; i < entryCount; i++) {
19
+ if (offset + CENTRAL_HEADER_FIXED_LENGTH > view.byteLength)
20
+ return null;
21
+ if (view.getUint32(offset, true) !== CENTRAL_HEADER_SIGNATURE)
22
+ return null;
23
+ const nameLength = view.getUint16(offset + 28, true);
24
+ const extraLength = view.getUint16(offset + 30, true);
25
+ const commentLength = view.getUint16(offset + 32, true);
26
+ const nameStart = offset + CENTRAL_HEADER_FIXED_LENGTH;
27
+ if (nameStart + nameLength > view.byteLength)
28
+ return null;
29
+ names.push(decoder.decode(bytes.subarray(nameStart, nameStart + nameLength)));
30
+ offset = nameStart + nameLength + extraLength + commentLength;
31
+ }
32
+ return names;
33
+ }
34
+ function findEndOfCentralDirectory(view) {
35
+ const lowest = Math.max(0, view.byteLength - EOCD_MIN_LENGTH - EOCD_MAX_COMMENT_LENGTH);
36
+ for (let offset = view.byteLength - EOCD_MIN_LENGTH; offset >= lowest; offset--) {
37
+ if (view.getUint32(offset, true) !== EOCD_SIGNATURE)
38
+ continue;
39
+ // A signature inside the archive comment is skipped: the real record's
40
+ // comment length reaches exactly to the end of the buffer.
41
+ const commentLength = view.getUint16(offset + 20, true);
42
+ if (offset + EOCD_MIN_LENGTH + commentLength === view.byteLength)
43
+ return offset;
44
+ }
45
+ return null;
46
+ }
@@ -1,2 +1,2 @@
1
- export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
1
+ export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
2
2
  export { createJobRunner, serializeJobSubject } from "./job-runner.js";
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
29
29
  readonly tenantId: string;
30
30
  readonly subject: string | null;
31
31
  };
32
+ export type JobDroppedMeta = {
33
+ readonly tenantId: string;
34
+ };
32
35
  export type JobOutcomeMeta = {
33
36
  readonly tenantId: string;
34
37
  readonly finalAttempt: boolean;
@@ -57,6 +60,7 @@ export type JobRunnerOptions = {
57
60
  consumerLane?: JobRunIn | undefined;
58
61
  queueNamePrefix?: string | undefined;
59
62
  bootRedisTimeoutMs?: number | undefined;
63
+ schedulerSeenRefreshMs?: number | undefined;
60
64
  jobRetention?: {
61
65
  completedAgeSec?: number | undefined;
62
66
  failedAgeSec?: number | undefined;
@@ -66,6 +70,6 @@ export type JobRunnerOptions = {
66
70
  onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
67
71
  onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
68
72
  onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
69
- onJobDropped?: (jobName: string, jobId: string) => void;
73
+ onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
70
74
  };
71
75
  export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
@@ -12,7 +12,7 @@ import { InternalError } from "../errors/index.js";
12
12
  import { isKumikoError } from "../errors/kumiko-error.js";
13
13
  import { createFileContext } from "../files/file-handle.js";
14
14
  import { createFallbackLogger } from "../logging/index.js";
15
- import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
15
+ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
16
16
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
17
17
  import { RedisKeys } from "../pipeline/redis-keys.js";
18
18
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
@@ -82,6 +82,18 @@ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
82
82
  function schedulerSeenKey(queue) {
83
83
  return queue.toKey("kumiko-scheduler-seen");
84
84
  }
85
+ // "Seen" has to mean a live runner holds the scheduler, so runners re-stamp
86
+ // their own ids periodically; a single stamp at start() would let a runner
87
+ // restarting during a mixed-version window longer than the grace period delete
88
+ // a scheduler a healthy newer pod still owns.
89
+ async function markSchedulersSeen(queue, schedulerIds) {
90
+ // skip: no cron scheduler on this lane, nothing to stamp
91
+ if (schedulerIds.size === 0)
92
+ return;
93
+ const stamp = String(Date.now());
94
+ const entries = Object.fromEntries([...schedulerIds].map((id) => [id, stamp]));
95
+ await (await queue.client).hset(schedulerSeenKey(queue), entries);
96
+ }
85
97
  // A cron job dropped from the app (or an older scheduler-id format) leaves its
86
98
  // scheduler in Redis, where it keeps firing jobs the worker no longer knows
87
99
  // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
@@ -364,6 +376,7 @@ export function createJobRunner(options) {
364
376
  const { registry, context, redisUrl, consumerLane } = options;
365
377
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
366
378
  const bootRedisTimeoutMs = options.bootRedisTimeoutMs ?? BOOT_REDIS_TIMEOUT_MS;
379
+ const schedulerSeenRefreshMs = options.schedulerSeenRefreshMs ?? ORPHAN_SCHEDULER_GRACE_MS / 4;
367
380
  const redisOpts = parseRedisOpts(redisUrl);
368
381
  // Use the context's tracer when present (observability-provider injected at
369
382
  // boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
@@ -524,6 +537,11 @@ export function createJobRunner(options) {
524
537
  }
525
538
  let worker = null;
526
539
  let queueDepthTimer = null;
540
+ let schedulerSeenTimer = null;
541
+ let schedulerStampPending = false;
542
+ // A timed-out gate is abandoned, not aborted (the job context carries no
543
+ // AbortSignal); stop() waits for it so it cannot write after the shutdown.
544
+ const abandonedBootGates = new Set();
527
545
  // Forward reference to the runner's own API, exposed on the job-handler ctx
528
546
  // so a handler can dispatch a follow-up job (job→job chaining, e.g.
529
547
  // delivery.render → delivery.send). Assigned just before return; reads happen
@@ -611,12 +629,16 @@ export function createJobRunner(options) {
611
629
  });
612
630
  }
613
631
  }
614
- async function announceDropped(jobName, jobDef, bullJobId) {
632
+ async function announceDropped(jobName, jobDef, bullJobId, data) {
615
633
  // skip: job did not opt in or no logger wired
616
634
  if (!jobDef.tenantVisibleRun || !options.onJobDropped)
617
635
  return;
636
+ const tenantId = tenantIdOfJobData(data);
637
+ // skip: tenant-less run — announceQueued never recorded a row for it
638
+ if (tenantId === SYSTEM_TENANT_ID)
639
+ return;
618
640
  try {
619
- await options.onJobDropped(jobName, bullJobId);
641
+ await options.onJobDropped(jobName, bullJobId, { tenantId });
620
642
  }
621
643
  catch (err) {
622
644
  errorLogger.warn("tenant-visible queued state not cleared", {
@@ -732,7 +754,7 @@ export function createJobRunner(options) {
732
754
  ...buildRetryBullOpts(jobDef, priorAttempts),
733
755
  });
734
756
  if (bullJob.id !== undefined && bullJob.id !== requeued.id) {
735
- await announceDropped(jobName, jobDef, bullJob.id);
757
+ await announceDropped(jobName, jobDef, bullJob.id, bullJob.data);
736
758
  }
737
759
  await announceQueued(jobName, jobDef, requeued.id, requeuedData);
738
760
  // skip: lock taken, work re-enqueued with delay, current invocation done
@@ -939,6 +961,7 @@ export function createJobRunner(options) {
939
961
  attempt,
940
962
  finalAttempt,
941
963
  log: createJobLogger(logs),
964
+ metricsFor: createMetricsForFactory(context.meter),
942
965
  ...(triggerName !== undefined && { triggerName }),
943
966
  ...(triggerEvent !== undefined && { triggerEvent }),
944
967
  write: (qn, payload) => {
@@ -1074,17 +1097,18 @@ export function createJobRunner(options) {
1074
1097
  });
1075
1098
  }, BOOT_GATE_SLOW_WARN_MS)
1076
1099
  : undefined;
1100
+ const gateRun = handleJob({
1101
+ id: bootJobIdForJobName(name),
1102
+ name,
1103
+ data: {},
1104
+ attemptsMade: 0,
1105
+ retryable: false,
1106
+ });
1107
+ const gateSettled = gateRun.then(() => undefined, () => undefined);
1108
+ abandonedBootGates.add(gateSettled);
1109
+ void gateSettled.then(() => abandonedBootGates.delete(gateSettled));
1077
1110
  try {
1078
- await Promise.race([
1079
- handleJob({
1080
- id: bootJobIdForJobName(name),
1081
- name,
1082
- data: {},
1083
- attemptsMade: 0,
1084
- retryable: false,
1085
- }),
1086
- ...(gateTimeout ? [gateTimeout.promise] : []),
1087
- ]);
1111
+ await Promise.race([gateRun, ...(gateTimeout ? [gateTimeout.promise] : [])]);
1088
1112
  }
1089
1113
  catch (err) {
1090
1114
  // Leaving the worker's Redis connections open would keep the event loop alive and turn the aborted boot into
@@ -1100,6 +1124,74 @@ export function createJobRunner(options) {
1100
1124
  }
1101
1125
  }
1102
1126
  }
1127
+ function cronSchedulerIdsForLane(lane) {
1128
+ const ids = new Set();
1129
+ for (const [name, jobDef] of allJobs) {
1130
+ if (laneForJob(jobDef) === lane && "cron" in jobDef.trigger) {
1131
+ ids.add(schedulerIdForJobName(name));
1132
+ }
1133
+ }
1134
+ return ids;
1135
+ }
1136
+ async function upsertCronSchedulers(queue, lane) {
1137
+ for (const [name, jobDef] of allJobs) {
1138
+ if (laneForJob(jobDef) !== lane)
1139
+ continue;
1140
+ if ("cron" in jobDef.trigger) {
1141
+ await queue.upsertJobScheduler(schedulerIdForJobName(name), cronRepeatOptions(jobDef.trigger), {
1142
+ name: jobDef.perTenant ? `_perTenant:${name}` : name,
1143
+ data: {},
1144
+ // Queue-level defaultJobOptions (jobRetentionOpts) covers
1145
+ // retention; a count here would sweep queue-wide again and
1146
+ // evict perTenant children and boot jobs.
1147
+ opts: {
1148
+ ...buildRetryBullOpts(jobDef),
1149
+ },
1150
+ });
1151
+ }
1152
+ }
1153
+ }
1154
+ // After a failed initial stamp an older runner may have removed our
1155
+ // schedulers as unseen, so the next tick stamps and re-upserts before the
1156
+ // flag clears.
1157
+ async function refreshSchedulerSeen(queue, lane, schedulerIds) {
1158
+ await markSchedulersSeen(queue, schedulerIds);
1159
+ if (schedulerStampPending) {
1160
+ await upsertCronSchedulers(queue, lane);
1161
+ schedulerStampPending = false;
1162
+ }
1163
+ }
1164
+ function startSchedulerSeenRefresh(queue, lane, schedulerIds) {
1165
+ if (schedulerIds.size === 0)
1166
+ return null;
1167
+ const timer = setInterval(() => {
1168
+ refreshSchedulerSeen(queue, lane, schedulerIds).catch((err) => {
1169
+ errorLogger.warn("scheduler seen refresh failed", {
1170
+ error: err instanceof Error ? err.message : String(err),
1171
+ });
1172
+ });
1173
+ }, schedulerSeenRefreshMs);
1174
+ timer.unref();
1175
+ return timer;
1176
+ }
1177
+ // Bounded: a gate that never returns must not block shutdown as well.
1178
+ async function settleAbandonedBootGates(maxWaitMs) {
1179
+ // skip: no timed-out gate left behind
1180
+ if (abandonedBootGates.size === 0)
1181
+ return;
1182
+ const wait = timeoutReject(maxWaitMs, "job-runner: abandoned boot gate still running at stop()");
1183
+ try {
1184
+ await Promise.race([Promise.all(abandonedBootGates), wait.promise]);
1185
+ }
1186
+ catch (err) {
1187
+ errorLogger.warn("abandoned boot gate did not finish before stop()", {
1188
+ error: err instanceof Error ? err.message : String(err),
1189
+ });
1190
+ }
1191
+ finally {
1192
+ wait.cancel();
1193
+ }
1194
+ }
1103
1195
  const runnerApi = {
1104
1196
  async start() {
1105
1197
  // skip: enqueuer-only runner — no BullMQ worker, no cron schedules,
@@ -1148,28 +1240,18 @@ export function createJobRunner(options) {
1148
1240
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
1149
1241
  // assigned to the other lane get their cron/boot wiring from the
1150
1242
  // runner running on that lane. Running both here would double-fire.
1151
- const expectedSchedulerIds = new Set();
1152
- for (const [name, jobDef] of allJobs) {
1153
- if (laneForJob(jobDef) !== consumerLane)
1154
- continue;
1155
- if ("cron" in jobDef.trigger) {
1156
- const schedulerId = schedulerIdForJobName(name);
1157
- expectedSchedulerIds.add(schedulerId);
1158
- await consumerQueue.upsertJobScheduler(schedulerId, cronRepeatOptions(jobDef.trigger), {
1159
- name: jobDef.perTenant ? `_perTenant:${name}` : name,
1160
- data: {},
1161
- // Queue-level defaultJobOptions (jobRetentionOpts) covers
1162
- // retention; a count here would sweep queue-wide again and
1163
- // evict perTenant children and boot jobs.
1164
- opts: {
1165
- ...buildRetryBullOpts(jobDef),
1166
- },
1167
- });
1168
- await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
1169
- [schedulerId]: String(Date.now()),
1170
- });
1171
- }
1172
- }
1243
+ const expectedSchedulerIds = cronSchedulerIdsForLane(consumerLane);
1244
+ // Before the upserts: a seen entry without a scheduler is harmless, a
1245
+ // scheduler a concurrent older runner finds without one gets deleted.
1246
+ // Best-effort like the periodic refresh: a failed stamp must not stop the start.
1247
+ await markSchedulersSeen(consumerQueue, expectedSchedulerIds).catch((err) => {
1248
+ schedulerStampPending = true;
1249
+ errorLogger.warn("scheduler seen stamp failed", {
1250
+ error: err instanceof Error ? err.message : String(err),
1251
+ });
1252
+ });
1253
+ await upsertCronSchedulers(consumerQueue, consumerLane);
1254
+ schedulerSeenTimer = startSchedulerSeenRefresh(consumerQueue, consumerLane, expectedSchedulerIds);
1173
1255
  await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
1174
1256
  info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
1175
1257
  warn: errorLogger.warn,
@@ -1217,6 +1299,10 @@ export function createJobRunner(options) {
1217
1299
  clearInterval(queueDepthTimer);
1218
1300
  queueDepthTimer = null;
1219
1301
  }
1302
+ if (schedulerSeenTimer) {
1303
+ clearInterval(schedulerSeenTimer);
1304
+ schedulerSeenTimer = null;
1305
+ }
1220
1306
  if (worker) {
1221
1307
  await worker.close();
1222
1308
  worker = null;
@@ -1226,6 +1312,7 @@ export function createJobRunner(options) {
1226
1312
  // without listeners -> unhandled rejection (fw#1805). Wait for readiness
1227
1313
  // first; a failed/timed-out wait must not block the close itself.
1228
1314
  const stopReadyTimeoutMs = Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS);
1315
+ await settleAbandonedBootGates(stopReadyTimeoutMs);
1229
1316
  const readinessTimeout = timeoutReject(stopReadyTimeoutMs, `job-runner: Queue not ready within ${stopReadyTimeoutMs}ms during stop()`);
1230
1317
  try {
1231
1318
  await Promise.race([
@@ -1298,7 +1385,7 @@ export function createJobRunner(options) {
1298
1385
  for (const j of waiting) {
1299
1386
  if (j.name === jobName && j.id) {
1300
1387
  await j.remove();
1301
- await announceDropped(jobName, jobDef, j.id);
1388
+ await announceDropped(jobName, jobDef, j.id, j.data);
1302
1389
  }
1303
1390
  }
1304
1391
  break;
@@ -11,10 +11,11 @@ export declare function createEscapeHatchReportWindow(opts?: {
11
11
  }): EscapeHatchReportWindow;
12
12
  export type EscapeHatchProcessDedup = {
13
13
  readonly kinds: ReadonlySet<EscapeHatchKind>;
14
- readonly seen: Set<string>;
14
+ readonly lastAuditedAt: Map<string, number>;
15
15
  };
16
16
  export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
17
17
  export declare function withEscapeHatchAuditScope<T>(run: () => Promise<T>): Promise<T>;
18
+ export declare function withEscapeHatchAuditScopeStream<T>(run: () => AsyncGenerator<T>): AsyncGenerator<T>;
18
19
  export declare function flushEscapeHatchAudits(): Promise<void>;
19
20
  export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
20
21
  readonly sink?: EscapeHatchAuditSink;
@@ -27,8 +27,11 @@ export function createEscapeHatchReportWindow(opts) {
27
27
  },
28
28
  };
29
29
  }
30
+ // A long-lived worker must leave an audit event inside every retention window
31
+ // (90 days), or its crons vanish from the log once the first event is pruned.
32
+ const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
30
33
  export function createEscapeHatchProcessDedup(kinds) {
31
- return { kinds: new Set(kinds), seen: new Set() };
34
+ return { kinds: new Set(kinds), lastAuditedAt: new Map() };
32
35
  }
33
36
  // Default window for createEscapeHatchReporter callers that don't pass their own.
34
37
  const fallbackReportWindow = createEscapeHatchReportWindow();
@@ -37,9 +40,14 @@ const processPendingAudits = new Set();
37
40
  // The sink writes of the dispatch or job run currently executing.
38
41
  const scopePendingAudits = new AsyncLocalStorage();
39
42
  function trackPendingAudit(write) {
43
+ const scope = scopePendingAudits.getStore();
40
44
  processPendingAudits.add(write);
41
- scopePendingAudits.getStore()?.add(write);
42
- void write.finally(() => processPendingAudits.delete(write));
45
+ scope?.add(write);
46
+ // Settled writes leave the scope so a long-lived stream does not accumulate them.
47
+ void write.finally(() => {
48
+ processPendingAudits.delete(write);
49
+ scope?.delete(write);
50
+ });
43
51
  }
44
52
  // Awaits the audit writes started inside `run` once it has returned, so callers place it around
45
53
  // the transaction and the write is durable when the dispatch or run ends. Nested scopes belong
@@ -56,6 +64,30 @@ export async function withEscapeHatchAuditScope(run) {
56
64
  await Promise.allSettled([...pending]);
57
65
  }
58
66
  }
67
+ // Stream variant: the scope has to be entered on every pull because an async generator body
68
+ // runs in the caller's async context, and the wait happens once the stream ends or is closed.
69
+ export async function* withEscapeHatchAuditScopeStream(run) {
70
+ if (scopePendingAudits.getStore() !== undefined) {
71
+ yield* run();
72
+ // skip: an enclosing scope already waits for the pending audits
73
+ return;
74
+ }
75
+ const pending = new Set();
76
+ const source = run();
77
+ try {
78
+ while (true) {
79
+ const next = await scopePendingAudits.run(pending, () => source.next());
80
+ // skip: the source is exhausted
81
+ if (next.done)
82
+ return;
83
+ yield next.value;
84
+ }
85
+ }
86
+ finally {
87
+ await scopePendingAudits.run(pending, () => source.return(undefined));
88
+ await Promise.allSettled([...pending]);
89
+ }
90
+ }
59
91
  export async function flushEscapeHatchAudits() {
60
92
  while (processPendingAudits.size > 0) {
61
93
  await Promise.allSettled([...processPendingAudits]);
@@ -105,11 +137,20 @@ export function createEscapeHatchReporter(opts) {
105
137
  emitEscapeHatchUse(opts.meter, opts.handler, kind);
106
138
  const { processDedup } = opts;
107
139
  if (processDedup?.kinds.has(kind)) {
108
- const processKey = JSON.stringify([opts.handler, kind, opts.tenantId]);
109
- // skip: this (handler, kind, tenant) was already audited by this process
110
- if (processDedup.seen.has(processKey))
140
+ const processKey = JSON.stringify([
141
+ opts.handler,
142
+ kind,
143
+ opts.tenantId,
144
+ target?.id ?? null,
145
+ target?.tenantId ?? null,
146
+ ]);
147
+ const nowMs = now();
148
+ const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
149
+ // skip: audited by this process within the re-audit interval
150
+ if (lastAuditedAt !== undefined && nowMs - lastAuditedAt < PROCESS_REAUDIT_INTERVAL_MS) {
111
151
  return;
112
- processDedup.seen.add(processKey);
152
+ }
153
+ processDedup.lastAuditedAt.set(processKey, nowMs);
113
154
  reportEscapeHatchUse(eventFor(kind, reason, target), deps);
114
155
  // skip: audited above, the window dedup below only serves the other kinds
115
156
  return;
@@ -4,7 +4,7 @@ export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
4
4
  export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
5
5
  export { generateSpanId, generateTraceId } from "./ids.js";
6
6
  export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
7
- export { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
7
+ export { createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, createUnboundMetricsHandle, } from "./metrics-handle.js";
8
8
  export { type ObservabilityWiring, prometheusMetricsEnvSchema, resolveObservabilityWiring, } from "./metrics-wiring.js";
9
9
  export { createNoopProvider } from "./noop-provider.js";
10
10
  export { createPrometheusMeter, type PrometheusMeter, type PrometheusMeterSnapshot, serializeOpenMetrics, } from "./prometheus-meter.js";