@cosmicdrift/kumiko-framework 0.385.0 → 0.387.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 (298) hide show
  1. package/LICENSE +21 -57
  2. package/README.md +2 -2
  3. package/agents/widgets.md +39 -39
  4. package/dist/api/api-constants.js +2 -2
  5. package/dist/api/auth-middleware.js +7 -7
  6. package/dist/api/auth-rate-limiters.js +7 -3
  7. package/dist/api/auth-routes-mfa.js +1 -1
  8. package/dist/api/auth-routes-session.js +1 -1
  9. package/dist/api/auth-routes-shared.js +7 -3
  10. package/dist/api/auth-routes-signup.js +1 -1
  11. package/dist/api/client-ip.js +3 -1
  12. package/dist/api/extra-route.js +1 -1
  13. package/dist/api/pii-leak-guard.js +4 -2
  14. package/dist/api/readiness.js +5 -5
  15. package/dist/api/redis-sse-broker.js +8 -6
  16. package/dist/api/request-context.d.ts +2 -0
  17. package/dist/api/request-context.js +16 -7
  18. package/dist/api/routes.js +2 -2
  19. package/dist/api/server.js +34 -33
  20. package/dist/api/sse-broker.js +1 -1
  21. package/dist/api/sse-route.d.ts +13 -13
  22. package/dist/api/sse-route.js +15 -15
  23. package/dist/auth/index.js +4 -4
  24. package/dist/auth/roles.d.ts +4 -4
  25. package/dist/auth/roles.js +24 -24
  26. package/dist/compliance/duration-spec.js +18 -20
  27. package/dist/compliance/index.js +2 -3
  28. package/dist/compliance/override-schema.d.ts +4 -4
  29. package/dist/compliance/override-schema.js +19 -22
  30. package/dist/compliance/profiles.d.ts +26 -29
  31. package/dist/compliance/profiles.js +62 -69
  32. package/dist/compliance/sub-processors.d.ts +28 -30
  33. package/dist/compliance/sub-processors.js +17 -21
  34. package/dist/consumer-cli.js +4 -4
  35. package/dist/crypto/blind-index.js +18 -17
  36. package/dist/crypto/ciphertext-pattern.js +1 -1
  37. package/dist/crypto/event-pii.js +3 -3
  38. package/dist/crypto/kek-source.d.ts +1 -1
  39. package/dist/crypto/kek-source.js +3 -2
  40. package/dist/crypto/kms-adapter.d.ts +1 -1
  41. package/dist/crypto/kms-adapter.js +4 -6
  42. package/dist/crypto/kms-wiring.d.ts +1 -2
  43. package/dist/crypto/kms-wiring.js +2 -2
  44. package/dist/crypto/pg-kms-adapter.js +2 -6
  45. package/dist/crypto/pii-field-encryption.js +4 -4
  46. package/dist/crypto/subject-resolver.d.ts +1 -1
  47. package/dist/crypto/subject-resolver.js +2 -2
  48. package/dist/db/apply-entity-event.d.ts +6 -6
  49. package/dist/db/apply-entity-event.js +55 -55
  50. package/dist/db/blind-index-cleanup.js +7 -7
  51. package/dist/db/collect-table-metas.js +10 -11
  52. package/dist/db/compound-types.d.ts +1 -1
  53. package/dist/db/compound-types.js +12 -12
  54. package/dist/db/cross-tenant-reads.d.ts +8 -0
  55. package/dist/db/cross-tenant-reads.js +23 -0
  56. package/dist/db/cursor.js +3 -4
  57. package/dist/db/dialect.d.ts +2 -2
  58. package/dist/db/dialect.js +2 -2
  59. package/dist/db/eagerload.d.ts +3 -3
  60. package/dist/db/eagerload.js +21 -12
  61. package/dist/db/entity-table-meta.d.ts +2 -2
  62. package/dist/db/entity-table-meta.js +4 -4
  63. package/dist/db/event-store-executor-context.d.ts +1 -1
  64. package/dist/db/event-store-executor-context.js +13 -13
  65. package/dist/db/event-store-executor-read.js +27 -28
  66. package/dist/db/event-store-executor-write.js +65 -70
  67. package/dist/db/located-timestamp.d.ts +11 -11
  68. package/dist/db/located-timestamp.js +29 -30
  69. package/dist/db/migrate-generator.d.ts +1 -1
  70. package/dist/db/migrate-generator.js +26 -19
  71. package/dist/db/migrate-runner.js +25 -25
  72. package/dist/db/money.d.ts +6 -6
  73. package/dist/db/money.js +29 -27
  74. package/dist/db/queries/backfill-pii.js +5 -5
  75. package/dist/db/queries/ddl.js +1 -1
  76. package/dist/db/queries/es-ops.d.ts +1 -1
  77. package/dist/db/queries/es-ops.js +1 -1
  78. package/dist/db/queries/event-consumer.js +3 -3
  79. package/dist/db/queries/event-store.js +4 -2
  80. package/dist/db/queries/projection-rebuild.js +3 -3
  81. package/dist/db/queries/raw-sql.d.ts +2 -2
  82. package/dist/db/queries/raw-sql.js +2 -2
  83. package/dist/db/queries/shadow-swap.js +19 -19
  84. package/dist/db/queries/test-stack.d.ts +4 -1
  85. package/dist/db/queries/test-stack.js +15 -2
  86. package/dist/db/query.d.ts +1 -1
  87. package/dist/db/query.js +6 -6
  88. package/dist/db/rebuild-marker.js +20 -21
  89. package/dist/db/replay-migration-sql.js +2 -2
  90. package/dist/db/schema-inspection.js +4 -5
  91. package/dist/db/table-builder.d.ts +1 -2
  92. package/dist/db/table-builder.js +82 -82
  93. package/dist/db/tenant-db.d.ts +2 -2
  94. package/dist/db/tenant-db.js +9 -2
  95. package/dist/derivatives/delete-stored-file.js +5 -2
  96. package/dist/derivatives/derivatives-context.js +1 -1
  97. package/dist/engine/boot-validator/access-roles.js +4 -3
  98. package/dist/engine/boot-validator/api-ext.js +14 -14
  99. package/dist/engine/boot-validator/config-deps.js +7 -6
  100. package/dist/engine/boot-validator/detail-screens.js +3 -3
  101. package/dist/engine/boot-validator/entity-handler.js +60 -61
  102. package/dist/engine/boot-validator/gdpr-storage.js +4 -4
  103. package/dist/engine/boot-validator/i18n-keys.js +1 -1
  104. package/dist/engine/boot-validator/index.d.ts +2 -2
  105. package/dist/engine/boot-validator/index.js +20 -20
  106. package/dist/engine/boot-validator/nav.js +5 -5
  107. package/dist/engine/boot-validator/ownership.js +12 -9
  108. package/dist/engine/boot-validator/parent-ref.js +1 -1
  109. package/dist/engine/boot-validator/pii-retention.js +50 -57
  110. package/dist/engine/boot-validator/projection-list-screens.js +7 -7
  111. package/dist/engine/boot-validator/query-refs.js +1 -1
  112. package/dist/engine/boot-validator/screens-actions.js +15 -16
  113. package/dist/engine/boot-validator/screens-forms.js +10 -11
  114. package/dist/engine/boot-validator/screens-lists.js +14 -15
  115. package/dist/engine/boot-validator/screens.js +34 -40
  116. package/dist/engine/boot-validator/security-baseline.js +3 -2
  117. package/dist/engine/boot-validator/transfer-graph.js +2 -2
  118. package/dist/engine/boot-validator/workspaces.js +3 -3
  119. package/dist/engine/bridge-stub.js +3 -4
  120. package/dist/engine/build-app-schema.js +75 -75
  121. package/dist/engine/build-config-feature-schema.js +50 -50
  122. package/dist/engine/build-target.js +20 -23
  123. package/dist/engine/codemod/pipeline-codemod.js +20 -13
  124. package/dist/engine/config-helpers.js +4 -4
  125. package/dist/engine/constants.js +2 -2
  126. package/dist/engine/create-app.js +2 -2
  127. package/dist/engine/define-feature.js +12 -11
  128. package/dist/engine/define-handler.js +12 -11
  129. package/dist/engine/define-step.js +1 -1
  130. package/dist/engine/define-workflow.d.ts +5 -5
  131. package/dist/engine/define-workflow.js +6 -6
  132. package/dist/engine/entity-handlers.js +13 -14
  133. package/dist/engine/event-def.js +2 -2
  134. package/dist/engine/extension-names.d.ts +16 -17
  135. package/dist/engine/extension-names.js +37 -38
  136. package/dist/engine/extensions/escape-hatch-usage.js +1 -1
  137. package/dist/engine/extensions/tenant-data.js +2 -2
  138. package/dist/engine/extensions/user-data.d.ts +23 -24
  139. package/dist/engine/factories.d.ts +14 -13
  140. package/dist/engine/factories.js +38 -37
  141. package/dist/engine/feature-ast/entity-field-types.js +1 -1
  142. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  143. package/dist/engine/feature-ast/extractors/hooks.js +1 -1
  144. package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
  145. package/dist/engine/feature-ast/extractors/shared.js +2 -3
  146. package/dist/engine/feature-ast/parse.js +13 -13
  147. package/dist/engine/feature-ast/patch.js +1 -1
  148. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  149. package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
  150. package/dist/engine/feature-ast/patterns.d.ts +2 -2
  151. package/dist/engine/feature-ast/render.js +2 -2
  152. package/dist/engine/feature-config-events-jobs.js +15 -12
  153. package/dist/engine/feature-entity-handlers.js +9 -5
  154. package/dist/engine/feature-manifest.d.ts +9 -9
  155. package/dist/engine/feature-manifest.js +6 -7
  156. package/dist/engine/feature-ui-extensions.d.ts +3 -3
  157. package/dist/engine/feature-ui-extensions.js +30 -22
  158. package/dist/engine/field-access.js +5 -6
  159. package/dist/engine/index.d.ts +1 -0
  160. package/dist/engine/index.js +4 -4
  161. package/dist/engine/object-form.d.ts +1 -0
  162. package/dist/engine/object-form.js +14 -4
  163. package/dist/engine/ownership.js +3 -3
  164. package/dist/engine/parse-ref-target.js +5 -7
  165. package/dist/engine/pattern-library/index.js +2 -2
  166. package/dist/engine/pattern-library/library.js +2 -2
  167. package/dist/engine/pattern-library/types.js +3 -3
  168. package/dist/engine/pipeline.js +2 -2
  169. package/dist/engine/project-app-schema-for-roles.js +1 -1
  170. package/dist/engine/projection-helpers.js +9 -9
  171. package/dist/engine/qualified-name.js +2 -2
  172. package/dist/engine/registry-facade.js +1 -1
  173. package/dist/engine/registry-ingest.js +12 -12
  174. package/dist/engine/registry-state.js +17 -19
  175. package/dist/engine/registry-validate.js +12 -12
  176. package/dist/engine/run-in.js +2 -2
  177. package/dist/engine/run-pipeline.js +7 -0
  178. package/dist/engine/schema-builder.js +40 -38
  179. package/dist/engine/screen-filter-ops.js +17 -20
  180. package/dist/engine/screen-helpers.js +4 -3
  181. package/dist/engine/state-machine.d.ts +12 -12
  182. package/dist/engine/state-machine.js +7 -7
  183. package/dist/engine/steps/_resolver-utils.js +4 -3
  184. package/dist/engine/steps/for-each.js +4 -4
  185. package/dist/engine/steps/unsafe-projection-delete.js +1 -1
  186. package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
  187. package/dist/engine/steps/wait-for-event.js +4 -4
  188. package/dist/engine/steps/workflow-system-events.js +3 -3
  189. package/dist/engine/tier-resolver-extension.d.ts +29 -27
  190. package/dist/engine/tier-resolver-extension.js +26 -26
  191. package/dist/engine/transfer-adjacency.js +2 -2
  192. package/dist/engine/validate-projection-allowlist.js +7 -27
  193. package/dist/engine/walk-step-instances.d.ts +2 -0
  194. package/dist/engine/walk-step-instances.js +38 -0
  195. package/dist/engine/where-rule-lint.js +1 -1
  196. package/dist/engine/write-handler-qn-extract.js +1 -1
  197. package/dist/entrypoint/index.js +3 -3
  198. package/dist/env/index.d.ts +7 -6
  199. package/dist/env/index.js +10 -9
  200. package/dist/errors/transition-details.js +7 -7
  201. package/dist/errors/write-error-info.d.ts +6 -6
  202. package/dist/errors/write-error-info.js +6 -6
  203. package/dist/es-ops/context.d.ts +9 -9
  204. package/dist/es-ops/context.js +28 -28
  205. package/dist/es-ops/index.js +7 -9
  206. package/dist/es-ops/operations-schema.js +4 -10
  207. package/dist/es-ops/runner.d.ts +11 -11
  208. package/dist/es-ops/runner.js +49 -53
  209. package/dist/es-ops/types.d.ts +55 -55
  210. package/dist/es-ops/types.js +11 -11
  211. package/dist/event-store/admin-api.js +6 -7
  212. package/dist/event-store/errors.js +1 -1
  213. package/dist/event-store/event-store.js +2 -2
  214. package/dist/event-store/rebuild-dead-letter.js +1 -1
  215. package/dist/files/feature.js +9 -10
  216. package/dist/files/file-handle.js +5 -6
  217. package/dist/files/file-ref-entity.js +15 -15
  218. package/dist/files/file-ref-table.js +7 -8
  219. package/dist/files/file-routes.d.ts +7 -0
  220. package/dist/files/file-routes.js +11 -5
  221. package/dist/files/in-memory-provider.js +8 -9
  222. package/dist/files/local-provider.js +18 -25
  223. package/dist/files/provider-resolver.d.ts +7 -7
  224. package/dist/files/storage-tracking.js +2 -2
  225. package/dist/files/zip-stream.d.ts +12 -19
  226. package/dist/files/zip-stream.js +44 -63
  227. package/dist/i18n/required-surface-keys.d.ts +1 -1
  228. package/dist/i18n/required-surface-keys.js +8 -8
  229. package/dist/internal/stack-support.d.ts +1 -1
  230. package/dist/internal/stack-support.js +1 -1
  231. package/dist/jobs/job-runner-helpers.d.ts +1 -2
  232. package/dist/jobs/job-runner-helpers.js +8 -18
  233. package/dist/jobs/job-runner-schedulers.d.ts +1 -1
  234. package/dist/jobs/job-runner-schedulers.js +3 -4
  235. package/dist/jobs/job-runner.js +17 -18
  236. package/dist/lifecycle/lifecycle.js +3 -5
  237. package/dist/lifecycle/signal-handlers.js +5 -4
  238. package/dist/logging/utils.d.ts +2 -1
  239. package/dist/logging/utils.js +38 -10
  240. package/dist/migrations/index.js +1 -1
  241. package/dist/migrations/kumiko-drift.js +14 -13
  242. package/dist/migrations/pending-rebuilds.d.ts +21 -20
  243. package/dist/migrations/pending-rebuilds.js +27 -27
  244. package/dist/migrations/projection-table-index.d.ts +3 -3
  245. package/dist/migrations/projection-table-index.js +8 -8
  246. package/dist/observability/metric-validator.js +1 -1
  247. package/dist/observability/metrics-handle.js +3 -3
  248. package/dist/pipeline/append-event-core.js +11 -2
  249. package/dist/pipeline/cascade-handler.d.ts +1 -1
  250. package/dist/pipeline/dispatch-batch.js +2 -2
  251. package/dist/pipeline/dispatch-query.js +10 -10
  252. package/dist/pipeline/dispatch-shared.d.ts +1 -0
  253. package/dist/pipeline/dispatch-shared.js +42 -19
  254. package/dist/pipeline/dispatch-stream.js +1 -1
  255. package/dist/pipeline/dispatch-write.js +3 -3
  256. package/dist/pipeline/dispatcher-utils.d.ts +1 -1
  257. package/dist/pipeline/dispatcher-utils.js +1 -1
  258. package/dist/pipeline/event-consumer-state.js +2 -2
  259. package/dist/pipeline/event-dispatcher-delivery.js +1 -1
  260. package/dist/pipeline/event-dispatcher.js +17 -28
  261. package/dist/pipeline/irreversible-operation-gate.js +1 -1
  262. package/dist/pipeline/lifecycle-pipeline.js +9 -9
  263. package/dist/pipeline/member-read-only-transaction.js +1 -3
  264. package/dist/pipeline/msp-rebuild.js +1 -1
  265. package/dist/pipeline/projection-rebuild.js +13 -12
  266. package/dist/pipeline/system-hooks.js +39 -41
  267. package/dist/pipeline/system-identity-switch.js +1 -3
  268. package/dist/pipeline/tenant-timezone-cache.js +1 -1
  269. package/dist/random/generate.d.ts +29 -29
  270. package/dist/random/generate.js +30 -30
  271. package/dist/random/words.js +20 -20
  272. package/dist/rate-limit/middleware.js +9 -5
  273. package/dist/redis/client.js +2 -2
  274. package/dist/redis/pubsub-signal.js +14 -17
  275. package/dist/schema-cli.js +116 -22
  276. package/dist/search/index.js +4 -4
  277. package/dist/search/purge-subject.js +4 -5
  278. package/dist/search/reindex-entity.js +6 -9
  279. package/dist/time/boot-tz-warning.d.ts +2 -2
  280. package/dist/time/boot-tz-warning.js +8 -8
  281. package/dist/time/iana.js +10 -10
  282. package/dist/time/index.js +9 -9
  283. package/dist/time/legacy-date.d.ts +2 -2
  284. package/dist/time/legacy-date.js +6 -8
  285. package/dist/time/polyfill.js +1 -1
  286. package/dist/time/tz-context.d.ts +1 -1
  287. package/dist/time/tz-context.js +1 -1
  288. package/dist/ui-types/app-schema.js +7 -7
  289. package/dist/ui-types/list-row-meta.js +4 -4
  290. package/dist/upgrade-cli.js +10 -9
  291. package/dist/utils/compare.js +1 -1
  292. package/package.json +4 -4
  293. package/src/changes.json +54 -0
  294. package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
  295. package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
  296. package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
  297. package/src/scripts/codemod/pii-personal-migration.ts +17 -8
  298. package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
@@ -4,25 +4,26 @@ import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFiel
4
4
  import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
5
5
  import { tenantChannel } from "../engine/constants.js";
6
6
  import { InternalError } from "../errors/index.js";
7
+ import { createFallbackLogger } from "../logging/utils.js";
8
+ const log = createFallbackLogger("kumiko:search");
7
9
  // --- Search Index Consumer (async, via event-dispatcher) ---
8
10
  //
9
- // Search-Indexierung läuft seit D.4 als async EventConsumer über den event-
10
- // dispatcher, nicht mehr als synchroner postSave/postDelete-hook. Das
11
- // spiegelt Marten's ISubscription-Pattern: ein einziger async Pfad für alle
12
- // non-inline side-effects.
11
+ // Search indexing runs as an async EventConsumer through the event dispatcher,
12
+ // rather than as a synchronous postSave/postDelete hook. This mirrors Marten's
13
+ // ISubscription pattern: one async path for all non-inline side effects.
13
14
  //
14
15
  // Event → Search-Op Mapping:
15
16
  //
16
17
  // <entity>.created → index(tenantId, doc) // state = event.payload
17
18
  // <entity>.updated → index(tenantId, doc) // state = { ...previous, ...changes }
18
19
  // <entity>.restored → index(tenantId, doc) // state = event.payload.previous
19
- // <entity>.deleted → remove(tenantId, type, id)
20
- // <entity>.forgotten → remove(tenantId, type, id)
21
- // <entity>.<named> → index or remove, state read from the live
22
- // projection row (#2765) — a named domain event
23
- // carries only a payload slice, not the full
24
- // entity, so there's no fixed shape to reconstruct
25
- // state from the way created/updated/restored do.
20
+ // <entity>.deleted → remove(tenantId, type, id)
21
+ // <entity>.forgotten → remove(tenantId, type, id)
22
+ // <entity>.<named> → index or remove, state read from the live
23
+ // projection row — a named domain event carries
24
+ // only a payload slice, not the full entity, so
25
+ // there's no fixed shape to reconstruct state from
26
+ // as there is for created/updated/restored.
26
27
  // Only queried when the entity declares searchable
27
28
  // fields/extensions; no row (hard/soft-deleted) →
28
29
  // remove, same as the deleted branch.
@@ -36,7 +37,7 @@ import { InternalError } from "../errors/index.js";
36
37
  // batchHandler collapses a turn's events to one search-op per
37
38
  // (tenantId, entityType, entityId) and issues one indexBatch/removeBatch
38
39
  // call per tenant instead of one round-trip per event; handler stays the
39
- // per-event fallback the dispatcher falls back to when a batch throws.
40
+ // per-event fallback the dispatcher uses when a batch throws.
40
41
  export const SEARCH_CONSUMER_NAME = "system:consumer:search";
41
42
  // An executor update with includeDeleted leaves the row deleted; indexing it would make it findable again.
42
43
  function isUpdateOfSoftDeletedRow(event, verb) {
@@ -58,7 +59,7 @@ async function resolveSearchOperation(event, ctx, registry) {
58
59
  state = reconstructStateForSearch(event.payload, verb);
59
60
  }
60
61
  else {
61
- // #2765 — named domain event: no fixed payload shape to reconstruct
62
+ // Named domain event: no fixed payload shape to reconstruct
62
63
  // state from. Only worth a projection read when the entity actually
63
64
  // declared searchable fields/extensions — same check
64
65
  // buildSearchDocument makes below, done here first so a non-
@@ -68,7 +69,7 @@ async function resolveSearchOperation(event, ctx, registry) {
68
69
  (registry.getSearchableFields(entityName).length > 0 ||
69
70
  registry.getSearchPayloadExtensions(entityName).length > 0);
70
71
  // skip: entity declares no searchable fields/extensions — nothing to
71
- // index, stays a no-op with no extra query, same as before #2765.
72
+ // index, stays a no-op with no extra query, same as before.
72
73
  if (!entity || !isSearchable)
73
74
  return undefined;
74
75
  const row = await readProjectionRowForSearch(ctx, registry, entityName, entity, tenantId, event.aggregateId);
@@ -183,7 +184,7 @@ export function createSearchEventConsumer(searchAdapter, registry) {
183
184
  },
184
185
  };
185
186
  }
186
- // #2765 — reads the live projection row for a named domain event, tenant-
187
+ // Reads the live projection row for a named domain event, tenant-
187
188
  // scoped through createTenantDb the same way MultiStreamProjection appliers
188
189
  // scope their apply()-writes (see api/server.ts), on the table the registry
189
190
  // actually booted (entityTableFromRegistry) rather than one freshly derived
@@ -217,7 +218,7 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
217
218
  }
218
219
  return state;
219
220
  }
220
- // #1610 — subject-annotated searchable fields are ciphertext in the event
221
+ // Subject-annotated searchable fields are ciphertext in the event
221
222
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
222
223
  // values rather than indexing blobs.
223
224
  export async function decryptSearchableSubjectFields(entityName, state, registry) {
@@ -242,8 +243,8 @@ export async function decryptSearchableSubjectFields(entityName, state, registry
242
243
  });
243
244
  }
244
245
  catch (err) {
245
- console.warn(`[kumiko:search] decryptSearchableSubjectFields failed for "${entityName}" — ` +
246
- `dropping ciphertext fields for this document instead of wedging the consumer.`, err);
246
+ log.warn(`decryptSearchableSubjectFields failed for "${entityName}" — ` +
247
+ `dropping ciphertext fields for this document instead of wedging the consumer.`, { err });
247
248
  const out = { ...state };
248
249
  for (const name of fields) {
249
250
  if (isPiiCiphertext(out[name]))
@@ -295,7 +296,7 @@ function warnOncePerKeyCollision(registry, entityName, key, isBaseField) {
295
296
  return;
296
297
  warned.add(dedupKey);
297
298
  const collidesWith = isBaseField ? `base field "${key}"` : `earlier contributor key "${key}"`;
298
- console.warn(`[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
299
+ log.warn(`searchPayloadExtension on "${entityName}" tried to overwrite ` +
299
300
  `${collidesWith} — keeping the first value. Rename the contributor key.`);
300
301
  }
301
302
  // Build a SearchDocument from raw field-state. Parallel to the old
@@ -307,10 +308,9 @@ export async function buildSearchDocument(entityName, entityId, state, registry)
307
308
  return null;
308
309
  const searchableFields = registry.getSearchableFields(entityName);
309
310
  const extensions = registry.getSearchPayloadExtensions(entityName);
310
- // Skip-Guard: kein indexable payload UND keine Extensions → kein doc.
311
- // Extensions können auch ohne searchable-Stammfields den index befüllen
312
- // (z.B. customFields-only-indexierung), daher muss die check beide
313
- // berücksichtigen.
311
+ // Skip guard: no indexable payload and no extensions means there is no doc.
312
+ // Extensions can populate the index without searchable base fields
313
+ // (for example, customFields-only indexing), so both must be checked.
314
314
  if (searchableFields.length === 0 && extensions.length === 0)
315
315
  return null;
316
316
  const embeddedFields = new Set();
@@ -348,15 +348,15 @@ export async function buildSearchDocument(entityName, entityId, state, registry)
348
348
  fields[f] = state[f];
349
349
  }
350
350
  }
351
- // F3 — Search-Payload-Extensions: contributors merge flat fields into the
351
+ // Search-Payload-Extensions: contributors merge flat fields into the
352
352
  // search-doc (customFields-bundle / tags / computed-counts / etc.).
353
353
  // Sequential await — extensions are expected sync or sub-millisecond async;
354
354
  // sequential keeps the path simple and deterministic.
355
355
  //
356
356
  // Precedence is base-fields-win: a contributor key that collides with a
357
- // searchable Stammfield is dropped (not silently merged over the real value)
358
- // and warned. A jsonb custom-field that happens to share a Stammfield name
359
- // must not shadow the indexed Stammfield.
357
+ // searchable base field is dropped (not silently merged over the real value)
358
+ // and warned. A jsonb custom-field that happens to share a base field name
359
+ // must not shadow the indexed base field.
360
360
  const baseFieldKeys = new Set(Object.keys(fields));
361
361
  for (const contribute of extensions) {
362
362
  const contributed = await contribute({ entityName, entityId, state });
@@ -395,7 +395,7 @@ export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
395
395
  export function createSseBroadcastEventConsumer(sseBroker) {
396
396
  return {
397
397
  name: SSE_BROADCAST_CONSUMER_NAME,
398
- // Shared delivery: one cursor reads every event exactly once (fw#2625).
398
+ // Shared delivery: one cursor reads every event exactly once.
399
399
  // Fanout to every process's own SSE clients happens below, through
400
400
  // sseBroker.pushToChannel — a Redis-backed broker (the default once
401
401
  // REDIS_URL is set) republishes across replicas, so every pod's
@@ -425,7 +425,7 @@ export function createSseBroadcastEventConsumer(sseBroker) {
425
425
  // r.multiStreamProjection's ctx.unsafeAppendEvent, or a raw
426
426
  // event-store-executor write (e.g. `files`' fileRef.created) — never
427
427
  // reached it, so a job could never trigger on an r.defineEvent-registered
428
- // event (kumiko-framework#1505).
428
+ // event.
429
429
  //
430
430
  // This consumer closes that gap the same way search/SSE do: read every
431
431
  // committed event off the shared cursor and re-check job triggers. It is
@@ -467,10 +467,9 @@ export function createJobTriggerEventConsumer(jobRunner, registry) {
467
467
  }
468
468
  // --- Access-Invalidation Consumer (async, via event-dispatcher) ---
469
469
  //
470
- // #1524 (Design), #1558 (channel + stream subscribe), #1559 (session-revoke
471
- // emits an event). This consumer is the last leg: it watches for the two
472
- // event types that can make an already-issued JWT stale mid-stream and
473
- // pushes an invalidation through sseBroker.publishAccessInvalidation, which
470
+ // This consumer is the last leg: it watches for the two event types that
471
+ // can make an already-issued JWT stale mid-stream and pushes an invalidation
472
+ // through sseBroker.publishAccessInvalidation, which
474
473
  // dispatch-stream.ts's subscribeAccessInvalidation listener turns into an
475
474
  // AccessDeniedError thrown into the open stream.
476
475
  //
@@ -499,21 +498,20 @@ export function createJobTriggerEventConsumer(jobRunner, registry) {
499
498
  // payload.previous, the full pre-write entity snapshot written by
500
499
  // createEventStoreExecutor. tenantMembershipEntity declares no
501
500
  // encrypted/PII fields, so previous.userId is plaintext — no KMS
502
- // decrypt step (see kumiko-framework#1560 PR discussion; the issue's
503
- // original handoff assumed encryption that doesn't apply here).
501
+ // decrypt step; the original handoff assumed encryption that doesn't
502
+ // apply here.
504
503
  //
505
504
  // Over-invalidation (e.g. tenant-membership.updated fired by something
506
505
  // other than a role change) is fail-safe: the stream closes, the client
507
- // reconnects and re-authorizes. No opt-out/allowlist — #1524 chose
508
- // global-by-default specifically so this can't be silently disabled per
509
- // handler.
506
+ // reconnects and re-authorizes. No opt-out/allowlist — global-by-default
507
+ // specifically so this can't be silently disabled per handler.
510
508
  //
511
509
  // Scope note: entityEventName also emits "tenant-membership.forgotten" (DSGVO
512
510
  // erasure) and ".restored" — .restored re-grants access so ignoring it is
513
511
  // correct, .forgotten is not currently produced by any user-data-rights
514
512
  // pipeline for memberships but would be revocation-relevant if it ever is.
515
- // Out of #1560's stated scope (session-revoke + role/membership change),
516
- // tracked rather than handled speculatively here.
513
+ // Out of the stated scope (session-revoke + role/membership change), tracked
514
+ // rather than handled speculatively here.
517
515
  export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
518
516
  const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
519
517
  const PAT_REVOKED_EVENT_TYPE = "personal-access-tokens:event:pat-revoked";
@@ -557,7 +555,7 @@ function readPatRevokedScope(payload) {
557
555
  export function createAccessInvalidationEventConsumer(sseBroker) {
558
556
  return {
559
557
  name: ACCESS_INVALIDATION_CONSUMER_NAME,
560
- // Shared delivery, same reasoning as SSE broadcast (fw#2625): one
558
+ // Shared delivery, same reasoning as SSE broadcast: one
561
559
  // cursor reads every event once and calls sseBroker.publishAccessInvalidation
562
560
  // below, which a Redis-backed broker republishes to every replica's
563
561
  // subscribeAccessInvalidation listeners — no per-instance cursor needed
@@ -1,5 +1,5 @@
1
1
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
- import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant, } from "../db/tenant-db.js";
2
+ import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant } from "../db/tenant-db.js";
3
3
  import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
4
4
  import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors/index.js";
5
5
  export function isSystemIdentity(user) {
@@ -235,12 +235,10 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
235
235
  }),
236
236
  ...gatedMemberReaderField(callerLabel, identityHatch, ctxQueryAsMember),
237
237
  ...gatedProjectionReaderField(callerLabel, identityHatch, ctxQueryProjection),
238
- // @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
239
238
  ...(ctxDb && { db: withUnsafeRawGrant(ctxDb, rawHatch) }),
240
239
  ...(ctxDbOutsideTransaction && {
241
240
  dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, rawHatch),
242
241
  }),
243
- // @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
244
242
  ...(ctxSystemDb && {
245
243
  systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, rawHatch, callerLabel),
246
244
  }),
@@ -1,5 +1,5 @@
1
1
  // Per-dispatcher in-process cache for the resolved tenant:config:timezone
2
- // value (fw#2462). dispatch-shared.ts's buildHandlerContext resolves that
2
+ // value. dispatch-shared.ts's buildHandlerContext resolves that
3
3
  // key on every dispatch to build ctx.tz — without a cache that's a config
4
4
  // SELECT (sometimes two, cascade + fallback) per request, often inside an
5
5
  // open write transaction. A tenant-keyed TTL cache removes the steady-state
@@ -1,56 +1,56 @@
1
1
  export type AdjNounNameOptions = {
2
- /** Trennzeichen zwischen adj/noun (und ggf. suffix). Default "-". */
2
+ /** Separator between adj/noun (and, if applicable, suffix). Default "-". */
3
3
  readonly separator?: string;
4
- /** Wenn gesetzt, wird ein random-suffix der Länge .length angehängt
5
- * (no-confusable-Alphabet). Empfohlen 3 Zeichen = 32^3 = 32.768
6
- * zusätzliche Combinations pro Wortpaar. */
4
+ /** If set, appends a random suffix of length .length
5
+ * (no-confusable alphabet). 3 characters are recommended = 32^3 = 32,768
6
+ * additional combinations per word pair. */
7
7
  readonly suffix?: {
8
8
  readonly length: number;
9
9
  };
10
10
  /** Custom Adjective-Liste — default ADJECTIVES (191 generic). */
11
11
  readonly adjectives?: readonly string[];
12
- /** Custom Noun-Liste — default NOUNS (173 generic). Apps die Domain-
13
- * spezifische Slugs wollen (z.B. webhook-feature mit eigenen
14
- * -receiver/-listener-Substantiven) reichen ihre eigene Liste. */
12
+ /** Custom noun list — default NOUNS (173 generic). Apps that want
13
+ * domain-specific slugs (e.g. webhook features with their own
14
+ * -receiver/-listener nouns) provide their own list. */
15
15
  readonly nouns?: readonly string[];
16
16
  };
17
- /** Sync Generator: produziert "happy-cloud" oder "happy-cloud-k8x"
18
- * (mit suffix). Kein Conflict-Check — Caller verantwortlich für
19
- * Uniqueness, oder generateUniqueName() für die async Variante. */
17
+ /** Sync generator: produces "happy-cloud" or "happy-cloud-k8x"
18
+ * (with suffix). No conflict check — the caller is responsible for
19
+ * uniqueness, or can use generateUniqueName() for the async variant. */
20
20
  export declare function generateAdjNounName(options?: AdjNounNameOptions): string;
21
- /** Random-String aus dem no-confusable-Alphabet. Für Suffix-bei-Kollision
22
- * oder als standalone short-IDs (z.B. Webhook-Verifizierungs-Codes
23
- * zum Vorlesen am Telefon). length ≥ 1. */
21
+ /** Random string from the no-confusable alphabet. For suffixes after
22
+ * collisions or as standalone short IDs (e.g. webhook verification codes
23
+ * to be read aloud over the phone). length ≥ 1. */
24
24
  export declare function generateNoConfusableId(length: number): string;
25
25
  export type GenerateUniqueNameOptions = {
26
- /** Caller-Predicate. Returns true wenn der Name noch nicht vergeben
27
- * ist (typisch: DB-Query "select where slug=$1" → row count === 0). */
26
+ /** Caller predicate. Returns true if the name is not yet assigned
27
+ * (typically: DB query "select where slug=$1" → row count === 0). */
28
28
  readonly isAvailable: (name: string) => Promise<boolean>;
29
- /** Max Versuche OHNE Suffix bevor wir auf suffix-mode wechseln.
30
- * Default 3. Bei 33.043 Default-Combos und ~150 existierenden
31
- * Tenants liegt p(Kollision) < 1% — 3 Versuche reichen weit. */
29
+ /** Maximum attempts WITHOUT a suffix before switching to suffix mode.
30
+ * Default 3. With 33,043 default combinations and ~150 existing
31
+ * tenants, p(collision) < 1% — 3 attempts are more than sufficient. */
32
32
  readonly maxCleanAttempts?: number;
33
- /** Suffix-Länge bei Kollision-Mode. Default 3 (= 32.768 Combinations
34
- * pro Wortpaar). */
33
+ /** Suffix length in collision mode. Default 3 (= 32,768 combinations
34
+ * per word pair). */
35
35
  readonly suffixLength?: number;
36
- /** Hard-Cap an Total-Versuchen bevor wir aufgeben. Default 20.
37
- * Praktisch nie erreicht — wenn doch, ist die Wortliste leer oder
38
- * isAvailable() returnt durchgängig false (DB-Bug). */
36
+ /** Hard cap on total attempts before giving up. Default 20.
37
+ * Rarely reached in practice — if it is, the word list may be empty or
38
+ * isAvailable() may return false consistently (DB bug). */
39
39
  readonly maxTotalAttempts?: number;
40
40
  readonly separator?: string;
41
41
  readonly adjectives?: readonly string[];
42
42
  readonly nouns?: readonly string[];
43
43
  };
44
- /** Generiert einen unique Adj-Noun-Slug indem es bis zu maxCleanAttempts
45
- * saubere Wortpaare versucht, danach mit random Suffix bis maxTotal-
46
- * Attempts. Wirft wenn auch das nicht hilft (= caller hat ein Bug
47
- * mit isAvailable, oder die Wortliste ist defekt).
44
+ /** Generates a unique Adj-Noun slug by trying up to maxCleanAttempts
45
+ * clean word pairs, then using a random suffix up to maxTotal-
46
+ * Attempts. Throws if that also fails (= the caller has a bug
47
+ * in isAvailable, or the word list is defective).
48
48
  *
49
- * Beispiel:
49
+ * Example:
50
50
  * const slug = await generateUniqueName({
51
51
  * isAvailable: async (s) =>
52
52
  * !(await db.select().from(tenants).where(eq(tenants.tenantKey, s)).then(r => r.length > 0)),
53
53
  * });
54
- * // → "happy-cloud" oder "happy-cloud-k8x" bei Kollision
54
+ * // → "happy-cloud" or "happy-cloud-k8x" on collision
55
55
  */
56
56
  export declare function generateUniqueName(options: GenerateUniqueNameOptions): Promise<string>;
@@ -1,23 +1,23 @@
1
- // Random-Generator für human-readable Resource-Identifier:
2
- // - Tenant-Keys ("happy-cloud", "swift-river-k8x")
3
- // - Webhook-Subscriber-Slugs ("bold-falcon-receiver" mit custom nouns)
4
- // - API-Key-Display-Names (3 Vorschläge anbieten beim Create)
5
- // - Tenant-Subdomain-Vorschläge im Subdomain-Setup-Screen
6
- // - Test-Fixtures mit lesbaren Identifiern
1
+ // Random generator for human-readable resource identifiers:
2
+ // - Tenant keys ("happy-cloud", "swift-river-k8x")
3
+ // - Webhook subscriber slugs ("bold-falcon-receiver" with custom nouns)
4
+ // - API key display names (offer 3 suggestions on creation)
5
+ // - Tenant subdomain suggestions in the subdomain setup screen
6
+ // - Test fixtures with readable identifiers
7
7
  //
8
- // NICHT für Security-Token (CSRF, Session, API-Keys, OAuth-State,
9
- // Reset-Token). Math.random() ist nicht kryptografisch unvorhersagbar.
10
- // Authority-binding für Tenant-Keys läuft über JWT + DB-Lookup, nicht
11
- // über Slug-Geheimhaltung — der Slug darf erratbar sein.
8
+ // NOT for security tokens (CSRF, session, API keys, OAuth state,
9
+ // reset tokens). Math.random() is not cryptographically unpredictable.
10
+ // Authority binding for tenant keys uses JWT + DB lookup, not slug secrecy;
11
+ // the slug may be guessable.
12
12
  //
13
- // Universal-safe: Math.random() läuft in Bun, Node, Metro/RN, Expo-Web.
14
- // Keine node:crypto-Imports.
13
+ // Safe across environments: Math.random() runs in Bun, Node, Metro/RN, and
14
+ // Expo Web. No node:crypto imports.
15
15
  import { ADJECTIVES, NOUNS } from "./words.js";
16
- // Alphabet ohne handgetippt verwechselbare Zeichen:
17
- // - keine 0/O (Null vs Großbuchstabe O)
18
- // - keine 1/l/I (Eins vs Kleinbuchstabe L vs Großbuchstabe I)
19
- // Resultat: 32 Zeichen, sicher beim Telefon-Buchstabieren UND beim
20
- // Mailtext-Copy-Paste in fremde Schriftarten.
16
+ // Alphabet without characters that are easily confused when entered manually:
17
+ // - no 0/O (zero versus uppercase O)
18
+ // - no 1/l/I (one versus lowercase L versus uppercase I)
19
+ // Result: 32 characters, safe for spelling over the phone AND for copying
20
+ // from email text in unfamiliar fonts.
21
21
  const NO_CONFUSABLE_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
22
22
  const NO_CONFUSABLE_CHARS = Object.freeze(NO_CONFUSABLE_ALPHABET.split(""));
23
23
  function pickRandom(arr) {
@@ -25,15 +25,15 @@ function pickRandom(arr) {
25
25
  throw new Error("pickRandom: cannot pick from empty array");
26
26
  }
27
27
  const value = arr[Math.floor(Math.random() * arr.length)];
28
- // Length-Check oben garantiert dass index in-range ist.
28
+ // The length check above guarantees that the index is in range.
29
29
  if (value === undefined) {
30
30
  throw new Error("pickRandom: indexed value undefined (sparse array?)");
31
31
  }
32
32
  return value;
33
33
  }
34
- /** Sync Generator: produziert "happy-cloud" oder "happy-cloud-k8x"
35
- * (mit suffix). Kein Conflict-Check — Caller verantwortlich für
36
- * Uniqueness, oder generateUniqueName() für die async Variante. */
34
+ /** Sync generator: produces "happy-cloud" or "happy-cloud-k8x"
35
+ * (with suffix). No conflict check — the caller is responsible for
36
+ * uniqueness, or can use generateUniqueName() for the async variant. */
37
37
  export function generateAdjNounName(options = {}) {
38
38
  const sep = options.separator ?? "-";
39
39
  const adjs = options.adjectives ?? ADJECTIVES;
@@ -44,9 +44,9 @@ export function generateAdjNounName(options = {}) {
44
44
  }
45
45
  return name;
46
46
  }
47
- /** Random-String aus dem no-confusable-Alphabet. Für Suffix-bei-Kollision
48
- * oder als standalone short-IDs (z.B. Webhook-Verifizierungs-Codes
49
- * zum Vorlesen am Telefon). length ≥ 1. */
47
+ /** Random string from the no-confusable alphabet. For suffixes after
48
+ * collisions or as standalone short IDs (e.g. webhook verification codes
49
+ * to be read aloud over the phone). length ≥ 1. */
50
50
  export function generateNoConfusableId(length) {
51
51
  if (length < 1) {
52
52
  throw new Error(`generateNoConfusableId: length must be ≥ 1 (got ${length})`);
@@ -57,17 +57,17 @@ export function generateNoConfusableId(length) {
57
57
  }
58
58
  return id;
59
59
  }
60
- /** Generiert einen unique Adj-Noun-Slug indem es bis zu maxCleanAttempts
61
- * saubere Wortpaare versucht, danach mit random Suffix bis maxTotal-
62
- * Attempts. Wirft wenn auch das nicht hilft (= caller hat ein Bug
63
- * mit isAvailable, oder die Wortliste ist defekt).
60
+ /** Generates a unique Adj-Noun slug by trying up to maxCleanAttempts
61
+ * clean word pairs, then using a random suffix up to maxTotal-
62
+ * Attempts. Throws if that also fails (= the caller has a bug
63
+ * in isAvailable, or the word list is defective).
64
64
  *
65
- * Beispiel:
65
+ * Example:
66
66
  * const slug = await generateUniqueName({
67
67
  * isAvailable: async (s) =>
68
68
  * !(await db.select().from(tenants).where(eq(tenants.tenantKey, s)).then(r => r.length > 0)),
69
69
  * });
70
- * // → "happy-cloud" oder "happy-cloud-k8x" bei Kollision
70
+ * // → "happy-cloud" or "happy-cloud-k8x" on collision
71
71
  */
72
72
  export async function generateUniqueName(options) {
73
73
  const maxClean = options.maxCleanAttempts ?? 3;
@@ -1,26 +1,26 @@
1
- // Wortlisten für human-readable Resource-Slugs (Tenant-Keys, Webhook-
2
- // Subscribers, API-Key-Display-Names, Test-Fixtures). Heroku/Docker-
3
- // Style — adjective + noun produzieren aussprechbare, merkbare,
4
- // PII-freie Identifier.
1
+ // Word lists for human-readable resource slugs (tenant keys, webhook-
2
+ // subscribers, API-key display names, test fixtures). Heroku/Docker-
3
+ // style — adjective + noun produce pronounceable, memorable,
4
+ // PII-free identifiers.
5
5
  //
6
- // Auswahl-Kriterien:
7
- // - Emotional-neutral und professionell (kein "fluffy", "sneaky")
8
- // - Keine Tiernamen (Heroku-Cliché)
9
- // - Keine Personennamen (cultural appropriation, prominenten-collision)
10
- // - Keine Themen-Cluster (kein Wetter-only, kein Geographie-only)
11
- // - Lowercase, ASCII-only, keine Bindestriche im Wort selbst
12
- // - 3-10 Buchstaben pro Wort (kompakter Slug)
13
- // - Aussprechbar in Deutsch UND Englisch (User-Telefon-Support)
14
- // - Keine Wörter mit ambiguer Bedeutung in Englisch+Deutsch
6
+ // Selection criteria:
7
+ // - Emotionally neutral and professional (no "fluffy", "sneaky")
8
+ // - No animal names (Heroku cliché)
9
+ // - No personal names (cultural appropriation, celebrity collisions)
10
+ // - No thematic clusters (neither weather-only nor geography-only)
11
+ // - Lowercase, ASCII-only, with no hyphens within a word
12
+ // - 3–10 letters per word (compact slug)
13
+ // - Pronounceable in German AND English (user phone support)
14
+ // - No words with ambiguous meanings in English and German
15
15
  //
16
- // 191 × 173 = 33.043 saubere Kombinationen. Birthday-Bound: ab ~180
17
- // Tenants wird kumulativ die erste Kollision wahrscheinlich (~50%);
18
- // pro Einzel-Draw bleibt p(Kollision) < 1% (vgl. generate.ts). Bei
19
- // Kollision greift der Suffix-Pfad in generateUniqueName.
16
+ // 191 × 173 = 33,043 clean combinations. Birthday bound: from ~180
17
+ // tenants onward, the first collision becomes likely cumulatively (~50%);
18
+ // for an individual draw, p(collision) remains < 1% (see generate.ts). On
19
+ // collision, the suffix path in generateUniqueName is used.
20
20
  //
21
- // Erweiterung: weitere Wörter unten anhängen reicht (sortiert ist
22
- // hilfreich für Reviews aber nicht erforderlich). Caller können auch
23
- // eigene Listen via generateUniqueName({ adjectives, nouns }) reichen.
21
+ // Extension: appending more words below is sufficient (sorting helps with
22
+ // reviews but is not required). Callers can also provide custom lists via
23
+ // generateUniqueName({ adjectives, nouns }).
24
24
  export const ADJECTIVES = Object.freeze([
25
25
  "agile",
26
26
  "amber",
@@ -108,21 +108,25 @@ export function createFailOpenReporter(label, meter, now = Date.now) {
108
108
  lastWarnAt = nowMs;
109
109
  logger.warn("rate limiter unavailable, failing open", {
110
110
  label,
111
- // ioredis errors can carry the command args, i.e. the bucket key with IP/target.
111
+ // ioredis errors can carry the command args, i.e. the bucket key. scrubLogText masks an e-mail
112
+ // address in it; the client IP stays in the line as an operational datum.
112
113
  error: scrubLogText(err instanceof Error ? err.message : String(err)),
113
114
  });
114
115
  };
115
116
  }
117
+ const failClosedLogger = createFallbackLogger("rate-limit");
116
118
  function defaultOnFailClosed(label) {
117
119
  return (err) => {
118
120
  // Loud by default — fail-closed for an unknown reason is an ops
119
121
  // signal, not something to swallow silently. Production deploys
120
122
  // override this with a structured logger; the default keeps the
121
123
  // noise visible if no logger is wired.
122
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
123
- console.error(`[rate-limit ${label}] fail-closed (refusing request):`,
124
- // ioredis errors can carry the command args, i.e. the bucket key with IP/target.
125
- err instanceof Error ? err.message : String(err));
124
+ failClosedLogger.error("fail-closed (refusing request)", {
125
+ label,
126
+ // ioredis errors can carry the command args, i.e. the bucket key. scrubLogText masks an e-mail
127
+ // address in it; the client IP stays in the line as an operational datum.
128
+ error: scrubLogText(err instanceof Error ? err.message : String(err)),
129
+ });
126
130
  };
127
131
  }
128
132
  function setRateLimitHeaders(c, decision) {
@@ -8,8 +8,8 @@
8
8
  // production defaults stay consistent and env-var wiring lives in one
9
9
  // place. Tests / one-off scripts can still `new Redis()` directly.
10
10
  //
11
- // Split out of index.ts (fw#2625) so pubsub-signal.ts can import this
12
- // without a same-directory cycle back through the barrel.
11
+ // Split out of index.ts so pubsub-signal.ts can import this without a
12
+ // same-directory cycle back through the barrel.
13
13
  import { Redis } from "ioredis";
14
14
  import { scrubLogText } from "../logging/scrub.js";
15
15
  import { readPositiveIntEnv } from "../utils/env-parse.js";
@@ -1,18 +1,19 @@
1
+ import { createFallbackLogger } from "../logging/utils.js";
1
2
  import { createRedisClient } from "./client.js";
2
3
  // Redis emits 'error' on connection blips (reconnects transparently); an
3
- // unhandled listener crashes the process (fw#1805 — job-runner hit the same
4
- // class with BullMQ's internal client).
5
- function logConnectionError(label, role) {
4
+ // unhandled listener crashes the process — job-runner hit the same
5
+ // class with BullMQ's internal client.
6
+ function logConnectionError(log, role) {
6
7
  return (err) => {
7
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback — no logger is threaded through PubSubSignalOptions.
8
- console.error(`[kumiko:${label}] ${role} connection error:`, err.message);
8
+ log.error(`${role} connection error`, { error: err.message });
9
9
  };
10
10
  }
11
11
  export function createRedisPubSubSignal(opts) {
12
+ const log = createFallbackLogger(`kumiko:${opts.label}`);
12
13
  const publisher = createRedisClient(opts.redisUrl, opts.clientOptions);
13
14
  const subscriber = createRedisClient(opts.redisUrl, opts.clientOptions);
14
- publisher.on("error", logConnectionError(opts.label, "publisher"));
15
- subscriber.on("error", logConnectionError(opts.label, "subscriber"));
15
+ publisher.on("error", logConnectionError(log, "publisher"));
16
+ subscriber.on("error", logConnectionError(log, "subscriber"));
16
17
  const listeners = new Set();
17
18
  const reconnectListeners = new Set();
18
19
  // Sole purpose: stop close() from racing the initial psubscribe (see
@@ -26,8 +27,7 @@ export function createRedisPubSubSignal(opts) {
26
27
  payload = JSON.parse(message);
27
28
  }
28
29
  catch (err) {
29
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
30
- console.error(`[kumiko:${opts.label}] dropping unparseable message on "${channel}":`, err);
30
+ log.error(`dropping unparseable message on "${channel}"`, { err });
31
31
  // skip: unparseable payload already logged above, nothing to deliver
32
32
  return;
33
33
  }
@@ -37,14 +37,14 @@ export function createRedisPubSubSignal(opts) {
37
37
  // ponytail: psubscribe on one pattern rather than per-channel
38
38
  // subscribe/unsubscribe — every instance receives every message that
39
39
  // matches, filtered by the caller. That's strictly no worse than the
40
- // pre-fw#2625 per-instance-cursor model (which already read every event
40
+ // per-instance-cursor model (which already read every event
41
41
  // from Postgres in every process) and sidesteps the subscribe-ack/
42
42
  // first-publish race a per-channel scheme would have. Revisit with
43
43
  // per-channel subscribe only if fanout volume becomes the bottleneck.
44
44
  //
45
45
  // Awaited by close() below — quitting while this is still in flight (a
46
46
  // shutdown landing immediately after construction, e.g. a test closing
47
- // right after building the signal) makes ioredis rejects the queued
47
+ // right after building the signal) makes ioredis reject the queued
48
48
  // psubscribe with "Connection is closed" instead of it completing
49
49
  // cleanly first. Not caught here or logged when a close is already
50
50
  // underway (`closed`), so an intentional shutdown never surfaces this
@@ -54,8 +54,7 @@ export function createRedisPubSubSignal(opts) {
54
54
  // this rejection is the expected side effect, not an ops-visible error.
55
55
  if (closed)
56
56
  return;
57
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
58
- console.error(`[kumiko:${opts.label}] psubscribe failed:`, err);
57
+ log.error("psubscribe failed", { err });
59
58
  });
60
59
  // ioredis re-sends the psubscribe itself (autoResubscribe) but emits no ack
61
60
  // event; repeating it here queues behind that one on the same connection,
@@ -77,8 +76,7 @@ export function createRedisPubSubSignal(opts) {
77
76
  // skip: a close() already underway intentionally quit the connection
78
77
  if (closed)
79
78
  return;
80
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
81
- console.error(`[kumiko:${opts.label}] resubscribe failed:`, err);
79
+ log.error("resubscribe failed", { err });
82
80
  });
83
81
  });
84
82
  return {
@@ -88,8 +86,7 @@ export function createRedisPubSubSignal(opts) {
88
86
  // connections — this rejection is the expected side effect.
89
87
  if (closed)
90
88
  return;
91
- // biome-ignore lint/suspicious/noConsole: ops-visible fallback, see logConnectionError above.
92
- console.error(`[kumiko:${opts.label}] publish failed:`, err);
89
+ log.error("publish failed", { err });
93
90
  });
94
91
  },
95
92
  async publishConfirmed(channel, payload) {