@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -3,7 +3,7 @@ import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/
3
3
  import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
4
4
  import { getEventsHighWaterMark } from "../event-store/index.js";
5
5
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
6
- import { rangeContainsId, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
6
+ import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
7
7
  // --- Ops recovery surface ---
8
8
  //
9
9
  // These are intentionally verb-distinct; each maps to a CLI sub-command.
@@ -84,6 +84,19 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
84
84
  }
85
85
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
86
86
  }
87
+ // Delivery stops at the poison event, so ids between the cursor and it were
88
+ // invisible during that pass (in-flight commits); keeping them as a pending gap
89
+ // avoids losing them when the cursor jumps over.
90
+ async function invisibleWindowGap(tx, cursor, poisonId) {
91
+ const windowStart = cursor + 1n;
92
+ if (poisonId <= windowStart)
93
+ return null;
94
+ return {
95
+ from: windowStart.toString(),
96
+ to: (poisonId - 1n).toString(),
97
+ xmax: await selectSnapshotXmax(tx),
98
+ };
99
+ }
87
100
  // skipPoisonEvent skips the event whose handler failed last
88
101
  // (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
89
102
  // pending_gaps it is split out of its range, otherwise the cursor moves onto
@@ -109,7 +122,7 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
109
122
  const newPendingGaps = pendingGaps.flatMap((gap) => rangeContainsId(gap, pendingPoisonId)
110
123
  ? splitRangeExcludingIds(gap, [pendingPoisonId])
111
124
  : [gap]);
112
- const raw = await removePendingGapReturning(tx, name, instanceId, newPendingGaps);
125
+ const raw = await removePendingGapReturning(tx, name, instanceId, mergeContiguousGapRanges(newPendingGaps));
113
126
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
114
127
  if (!updated)
115
128
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
@@ -130,15 +143,8 @@ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SEN
130
143
  // Delivery stops at the poison event, so ids between the cursor and it were
131
144
  // invisible during that pass (in-flight commits). Jumping the cursor over them
132
145
  // would lose them; keep them as a pending gap like the dispatcher's own gaps.
133
- const skippedWindowStart = before.lastProcessedEventId + 1n;
134
- const gapToAdd = poisonId > skippedWindowStart
135
- ? {
136
- from: skippedWindowStart.toString(),
137
- to: (poisonId - 1n).toString(),
138
- xmax: await selectSnapshotXmax(tx),
139
- }
140
- : null;
141
- const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, gapToAdd);
146
+ const gapToAdd = await invisibleWindowGap(tx, before.lastProcessedEventId, poisonId);
147
+ const raw = await advanceConsumerPastEventReturning(tx, name, instanceId, poisonId, mergeContiguousGapRanges(gapToAdd ? [...pendingGaps, gapToAdd] : pendingGaps));
142
148
  const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
143
149
  if (!updated)
144
150
  throw new Error(`Consumer "${name}" (instance_id="${instanceId}") vanished mid-skip — retry.`);
@@ -68,4 +68,4 @@ export type PersistedConsumerOutcome = DeliveryOutcome & {
68
68
  readonly pendingGaps: readonly PendingGapEntry[];
69
69
  };
70
70
  export declare function persistConsumerOutcome(tx: DbTx, name: string, instanceId: string, outcome: PersistedConsumerOutcome): Promise<void>;
71
- export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter): Promise<void>;
71
+ export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter, pendingGapRanges: number): Promise<void>;
@@ -6,7 +6,7 @@ import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries
6
6
  import { coerceRow, extractTableInfo } from "../db/query.js";
7
7
  import { qnScope } from "../engine/qualified-name.js";
8
8
  import { eventsTable, toStoredEvent as rowToStoredEvent, } from "../event-store/index.js";
9
- import { emitDispatcherError, emitEventConsumerLag, getFallbackMeter, } from "../observability/index.js";
9
+ import { emitDispatcherError, emitEventConsumerLag, emitEventConsumerPendingGaps, getFallbackMeter, } from "../observability/index.js";
10
10
  import { ConsumerStatuses, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
11
11
  import { parseWriteOrigin } from "./write-origin.js";
12
12
  // Fails closed without throwing: a throw would poison the event for every consumer.
@@ -315,8 +315,9 @@ export async function persistConsumerOutcome(tx, name, instanceId, outcome) {
315
315
  // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
316
316
  // consistent with the cursor we just advanced to. `MAX(id)` on the events
317
317
  // table is an O(1) reverse-index scan — cheap even under load.
318
- export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter) {
318
+ export async function emitLagFromTx(tx, consumerName, instanceId, cursor, meter, pendingGapRanges) {
319
319
  const head = await selectEventsHeadId(tx);
320
320
  const lag = head > cursor ? Number(head - cursor) : 0;
321
321
  emitEventConsumerLag(meter, { consumer: consumerName, instanceId }, lag);
322
+ emitEventConsumerPendingGaps(meter, { consumer: consumerName, instanceId }, pendingGapRanges);
322
323
  }
@@ -1,15 +1,33 @@
1
+ import { extractPgError } from "../db/pg-error.js";
1
2
  import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
2
3
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
3
4
  import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
4
5
  import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
5
6
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
6
7
  import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
7
- import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
8
+ import { mergeContiguousGapRanges, partitionBurntGaps, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
8
9
  const DEFAULT_BATCH_SIZE = 200;
9
10
  const DEFAULT_POLL_MS = 100;
10
11
  const DEFAULT_MAX_ATTEMPTS = 10;
11
12
  const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
12
13
  const DEFAULT_MAX_REARM_COUNT = 3;
14
+ const PENDING_GAPS_WARN_THRESHOLD = 1000;
15
+ // Only these say the database socket itself is gone; timeouts, schema or pool errors do not
16
+ // imply the dedicated LISTEN connection dropped.
17
+ const CONNECTION_LOSS_CODES = new Set([
18
+ "CONNECTION_CLOSED",
19
+ "CONNECTION_DESTROYED",
20
+ "CONNECTION_ENDED",
21
+ "CONNECT_TIMEOUT",
22
+ "ECONNREFUSED",
23
+ "ECONNRESET",
24
+ "ERR_POSTGRES_CONNECTION_CLOSED",
25
+ "57P01",
26
+ ]);
27
+ function isConnectionLossError(e) {
28
+ const code = extractPgError(e)?.code;
29
+ return code !== undefined && CONNECTION_LOSS_CODES.has(code);
30
+ }
13
31
  // Backoff for a consumer whose pass THREW (the processConsumer catch below)
14
32
  // — an infra-level failure (db.begin's callback rejected outside deliverEvents'
15
33
  // own per-event try/catch), not a poisoned event. Doubles per consecutive
@@ -52,6 +70,19 @@ export function createEventDispatcher(options) {
52
70
  // which is fine: a fresh process re-observing a still-dead consumer is
53
71
  // exactly the "still needs a human" signal ops wants.
54
72
  const reportedDeadConsumers = new Set();
73
+ // Same once-per-process dedup for the pending_gaps size warning.
74
+ const reportedLargeGapSets = new Set();
75
+ // A long-running writer transaction pins the cluster-wide xmin, so rolled-back appends
76
+ // (each burns a sequence id) accumulate as gaps and every turn ships them all to the fetch.
77
+ const warnOnceOnLargeGapSet = (consumerName, instance, count) => {
78
+ const key = `${consumerName}:${instance}`;
79
+ // skip: below the warn threshold, or already reported once for this consumer instance
80
+ if (count <= PENDING_GAPS_WARN_THRESHOLD || reportedLargeGapSets.has(key))
81
+ return;
82
+ reportedLargeGapSets.add(key);
83
+ logDispatcherError(`[event-dispatcher] ${consumerName} tracks ${count} pending gap ranges (> ${PENDING_GAPS_WARN_THRESHOLD}); ` +
84
+ `a long-running write transaction is probably holding back the snapshot xmin`);
85
+ };
55
86
  // Per-(consumer, instanceId) backoff after a thrown pass (see the catch in
56
87
  // processConsumer). Keyed the same way as reportedDeadConsumers.
57
88
  // consecutiveFailures drives the exponential delay; retryAtMs is the
@@ -67,6 +98,9 @@ export function createEventDispatcher(options) {
67
98
  // unrelated outage gets its own fresh log line instead of staying
68
99
  // permanently suppressed by this process's flag.
69
100
  let idlePreCheckErrorLogged = false;
101
+ // Mirrors the last gauge value derived from the LISTEN connection (set by onlisten), so the
102
+ // pre-check recovery can restore it after a connection-loss error flipped it to 0.
103
+ let listenSubscribed = false;
70
104
  let running = false;
71
105
  // Separate from `running` on purpose: pre-registration of consumer state
72
106
  // rows is a one-time boot action, while running/timer/LISTEN is a
@@ -241,6 +275,7 @@ export function createEventDispatcher(options) {
241
275
  })));
242
276
  if (idlePreCheckErrorLogged) {
243
277
  idlePreCheckErrorLogged = false;
278
+ emitEventDispatcherListenConnected(meter, listenSubscribed);
244
279
  const recoveredMsg = "[event-dispatcher] idle pre-check recovered, database reachable again";
245
280
  if (context.log) {
246
281
  context.log.info(recoveredMsg);
@@ -258,9 +293,10 @@ export function createEventDispatcher(options) {
258
293
  // signal for ops, not a delivery failure.
259
294
  if (!idlePreCheckErrorLogged) {
260
295
  idlePreCheckErrorLogged = true;
261
- // The LISTEN peer talks to the same DB, so it is gone too; only a
262
- // later onlisten may flip the gauge back to 1.
263
- emitEventDispatcherListenConnected(meter, false);
296
+ // A lost DB socket takes the LISTEN peer down with it; other pre-check failures
297
+ // (statement_timeout, pool exhaustion, schema errors) say nothing about LISTEN.
298
+ if (isConnectionLossError(e))
299
+ emitEventDispatcherListenConnected(meter, false);
264
300
  const msg = e instanceof Error ? e.message : String(e);
265
301
  const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
266
302
  logDispatcherError(logMsg);
@@ -290,6 +326,7 @@ export function createEventDispatcher(options) {
290
326
  "consumer.instance_id": instanceId,
291
327
  },
292
328
  });
329
+ let cursorBeforePass;
293
330
  try {
294
331
  await db.begin(async (tx) => {
295
332
  const acquired = await acquireConsumerState(tx, consumer.name, instanceId, rearmCooldownMs, maxRearmCount);
@@ -313,6 +350,7 @@ export function createEventDispatcher(options) {
313
350
  // staying permanently suppressed by this process's Set.
314
351
  reportedDeadConsumers.delete(`${consumer.name}:${instanceId}`);
315
352
  const oldCursor = acquired.state.lastProcessedEventId;
353
+ cursorBeforePass = oldCursor;
316
354
  const pendingGaps = acquired.state.pendingGaps;
317
355
  // xmin BEFORE the fetch: a range is provably burnt only once this has
318
356
  // passed the xmax recorded with it. Only needed when ranges exist.
@@ -377,10 +415,11 @@ export function createEventDispatcher(options) {
377
415
  }));
378
416
  const persistedOutcome = {
379
417
  ...outcome,
380
- pendingGaps: [...keptGaps, ...newGaps],
418
+ pendingGaps: mergeContiguousGapRanges([...keptGaps, ...newGaps]),
381
419
  };
420
+ warnOnceOnLargeGapSet(consumer.name, instanceId, persistedOutcome.pendingGaps.length);
382
421
  await persistConsumerOutcome(tx, consumer.name, instanceId, persistedOutcome);
383
- await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
422
+ await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter, persistedOutcome.pendingGaps.length);
384
423
  });
385
424
  // Pass completed without throwing — clear any backoff from a prior
386
425
  // failure so the next unrelated outage gets its own fresh delay
@@ -409,7 +448,7 @@ export function createEventDispatcher(options) {
409
448
  // so any failure here is swallowed after logging.
410
449
  try {
411
450
  await db.begin(async (tx) => {
412
- await recordConsumerPassFailure(tx, consumer.name, instanceId, msg);
451
+ await recordConsumerPassFailure(tx, consumer.name, instanceId, msg, cursorBeforePass);
413
452
  });
414
453
  }
415
454
  catch (persistErr) {
@@ -472,6 +511,7 @@ export function createEventDispatcher(options) {
472
511
  // ponytail: no own LISTEN reconnect, the backoff gap is polled;
473
512
  // add a dedicated LISTEN connection if prod wires a pgClient
474
513
  // and that latency matters.
514
+ listenSubscribed = true;
475
515
  emitEventDispatcherListenConnected(meter, true);
476
516
  });
477
517
  pgUnlisten = sub.unlisten;
@@ -498,6 +538,7 @@ export function createEventDispatcher(options) {
498
538
  // subscription is being torn down anyway.
499
539
  });
500
540
  pgUnlisten = null;
541
+ listenSubscribed = false;
501
542
  emitEventDispatcherListenConnected(meter, false);
502
543
  }
503
544
  // Drain any in-flight pass so shutdown observes consistent state —
@@ -1,5 +1,7 @@
1
1
  export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
2
2
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
3
+ export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
4
+ export { appendDomainEventCore } from "./append-event-core.js";
3
5
  export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
4
6
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
7
  export { createCascadeDeleteHook } from "./cascade-handler.js";
@@ -1,4 +1,5 @@
1
1
  export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
2
+ export { appendDomainEventCore } from "./append-event-core.js";
2
3
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
3
4
  export { createCascadeDeleteHook } from "./cascade-handler.js";
4
5
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
@@ -1,8 +1,9 @@
1
1
  import { extractPgError } from "../db/pg-error.js";
2
2
  import { asRawClient, runInSavepoint, transaction } from "../db/query.js";
3
- import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
3
+ import { AccessDeniedError, InternalError, MemberReadTimeoutError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
4
4
  import { resolveDbSource } from "./dispatch-shared.js";
5
5
  const PG_READ_ONLY_SQLSTATE = "25006";
6
+ const PG_QUERY_CANCELED_SQLSTATE = "57014";
6
7
  function isReadOnlyTransactionViolation(e) {
7
8
  return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
8
9
  }
@@ -12,12 +13,27 @@ class SavepointDiscard extends Error {
12
13
  this.name = "SavepointDiscard";
13
14
  }
14
15
  }
15
- async function runInDiscardedReadOnlySavepoint(tx, fn) {
16
+ function isStatementTimeout(e) {
17
+ return extractPgError(e)?.code === PG_QUERY_CANCELED_SQLSTATE;
18
+ }
19
+ // SET cannot take bind parameters, so the value is interpolated — only after it is proven a plain integer.
20
+ function memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout) {
21
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
22
+ throw new InternalError({
23
+ message: `member read timeout must be a positive integer, got ${timeoutMs}`,
24
+ });
25
+ }
26
+ const idleTimeout = bindIdleTimeout
27
+ ? `; SET LOCAL idle_in_transaction_session_timeout = ${timeoutMs}`
28
+ : "";
29
+ return `SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = ${timeoutMs}${idleTimeout}`;
30
+ }
31
+ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, fn) {
16
32
  const settled = {};
17
33
  try {
18
34
  await runInSavepoint(tx, async (sp) => {
19
35
  // kumiko-lint-ignore raw-sql transaction characteristic, no query helper
20
- await asRawClient(sp).unsafe("SET TRANSACTION READ ONLY");
36
+ await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
21
37
  // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
22
38
  settled.outcome = { value: await fn(sp) };
23
39
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
@@ -36,9 +52,10 @@ async function runInDiscardedReadOnlySavepoint(tx, fn) {
36
52
  return settled.outcome.value;
37
53
  }
38
54
  export async function runInMemberReadOnlyTransaction(ctx, tx, fn) {
55
+ const timeoutMs = ctx.memberReadTimeoutMs;
39
56
  try {
40
57
  if (tx)
41
- return await runInDiscardedReadOnlySavepoint(tx, fn);
58
+ return await runInDiscardedReadOnlySavepoint(tx, timeoutMs, false, fn);
42
59
  const pool = resolveDbSource(ctx, undefined);
43
60
  if (!pool) {
44
61
  throw new InternalError({
@@ -48,13 +65,16 @@ export async function runInMemberReadOnlyTransaction(ctx, tx, fn) {
48
65
  // Savepoint even on the pool path: Postgres rejects a READ WRITE reset (SQLSTATE 25001) inside a subtransaction.
49
66
  return await transaction(pool, (outer) =>
50
67
  // @cast-boundary driver begin() handle — structurally a DbTx, same shape runInSavepoint's caller relies on
51
- runInDiscardedReadOnlySavepoint(outer, fn));
68
+ runInDiscardedReadOnlySavepoint(outer, timeoutMs, true, fn));
52
69
  }
53
70
  catch (e) {
54
71
  if (e instanceof AccessDeniedError)
55
72
  throw e;
56
73
  if (isReadOnlyTransactionViolation(e))
57
74
  throw memberResolutionReadOnlyDenied(e);
75
+ if (isStatementTimeout(e)) {
76
+ throw new MemberReadTimeoutError({ timeoutMs, ...(e instanceof Error && { cause: e }) });
77
+ }
58
78
  throw e;
59
79
  }
60
80
  }
@@ -67,6 +67,7 @@ export function createMemberReaderFn(ctx, tenantId, tx) {
67
67
  }
68
68
  // Only successful resolutions are cached — a rejected/failed resolution
69
69
  // deletes its own entry so a transient error can't poison the rest of the run.
70
+ // No TTL: a membership revoked mid-run is only noticed by the next reader.
70
71
  const cache = new Map();
71
72
  function resolve(userId) {
72
73
  const cached = cache.get(userId);
@@ -8,7 +8,7 @@ import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../even
8
8
  import { upcastStoredEvents } from "../event-store/upcaster.js";
9
9
  import { emitProjectionRebuild } from "../observability/standard-metrics.js";
10
10
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
11
- import { capPendingGapsBelowCursor, subtractSortedIdsFromRanges } from "./pending-gap-ranges.js";
11
+ import { capPendingGapsBelowCursor, mergeContiguousGapRanges, subtractSortedIdsFromRanges, } from "./pending-gap-ranges.js";
12
12
  function createRebuildCtx(registry, db, tenantId) {
13
13
  // Both surfaces throw — rebuild MUST NOT emit. We share one impl.
14
14
  const refuseAppend = async (args) => {
@@ -128,9 +128,9 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
128
128
  const survivingGaps = subtractSortedIdsFromRanges(seededGaps, replayedIds);
129
129
  const cappedGaps = capPendingGapsBelowCursor(survivingGaps, lastProcessedEventId);
130
130
  const postReplayXmax = cappedGaps.length > 0 ? await selectSnapshotXmax(tx) : null;
131
- const pendingGaps = postReplayXmax === null
131
+ const pendingGaps = mergeContiguousGapRanges(postReplayXmax === null
132
132
  ? cappedGaps
133
- : cappedGaps.map((gap) => ({ ...gap, xmax: postReplayXmax }));
133
+ : cappedGaps.map((gap) => ({ ...gap, xmax: postReplayXmax })));
134
134
  await updateConsumerRebuildCursor(tx, mspName, SHARED_INSTANCE_SENTINEL, lastProcessedEventId, pendingGaps);
135
135
  await swapShadowIntoLive(tx, meta.tableName);
136
136
  });
@@ -10,3 +10,4 @@ export declare function partitionBurntGaps(gaps: readonly PendingGapEntry[], fet
10
10
  export declare function splitRangeExcludingIds(range: PendingGapEntry, excludeIds: readonly bigint[]): PendingGapEntry[];
11
11
  export declare function subtractSortedIdsFromRanges(ranges: readonly PendingGapEntry[], sortedIds: readonly bigint[]): PendingGapEntry[];
12
12
  export declare function capPendingGapsBelowCursor(gaps: readonly PendingGapEntry[], cursor: bigint): PendingGapEntry[];
13
+ export declare function mergeContiguousGapRanges(gaps: readonly PendingGapEntry[]): PendingGapEntry[];
@@ -97,3 +97,25 @@ export function capPendingGapsBelowCursor(gaps, cursor) {
97
97
  }
98
98
  return capped;
99
99
  }
100
+ // Merges ranges that touch exactly (end + 1 === next start). Keeps the row small
101
+ // when many adjacent id holes appear one pass after another. The merged xmax is
102
+ // the larger one: finality may only be claimed once the later-recorded bound has
103
+ // passed, so merging can delay it but never make it premature.
104
+ export function mergeContiguousGapRanges(gaps) {
105
+ const sorted = [...gaps].sort((a, b) => {
106
+ const diff = BigInt(a.from) - BigInt(b.from);
107
+ return diff < 0n ? -1 : diff > 0n ? 1 : 0;
108
+ });
109
+ const merged = [];
110
+ for (const gap of sorted) {
111
+ const last = merged.at(-1);
112
+ if (last && BigInt(last.to) + 1n === BigInt(gap.from)) {
113
+ const xmax = BigInt(gap.xmax) > BigInt(last.xmax) ? gap.xmax : last.xmax;
114
+ merged[merged.length - 1] = { from: last.from, to: gap.to, xmax };
115
+ }
116
+ else {
117
+ merged.push(gap);
118
+ }
119
+ }
120
+ return merged;
121
+ }
@@ -400,7 +400,7 @@ export function createSseBroadcastEventConsumer(sseBroker) {
400
400
  // exist yet has no audience to replay it to.
401
401
  startFrom: "now",
402
402
  handler: async (event) => {
403
- sseBroker.pushToChannel(tenantChannel(event.tenantId), {
403
+ await sseBroker.pushToChannel(tenantChannel(event.tenantId), {
404
404
  type: event.type,
405
405
  data: {
406
406
  id: event.aggregateId,
@@ -571,7 +571,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
571
571
  // every user behind one bad row).
572
572
  if (typeof userId !== "string" || userId.length === 0)
573
573
  return;
574
- sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
574
+ await sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
575
575
  }
576
576
  if (event.type === PAT_REVOKED_EVENT_TYPE) {
577
577
  const userId = event.payload["userId"];
@@ -579,7 +579,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
579
579
  // session-revoked above.
580
580
  if (typeof userId !== "string" || userId.length === 0)
581
581
  return;
582
- sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
582
+ await sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
583
583
  }
584
584
  if (event.type === TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE ||
585
585
  event.type === TENANT_MEMBERSHIP_DELETED_EVENT_TYPE) {
@@ -588,7 +588,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
588
588
  // reasoning as above.
589
589
  if (userId === undefined)
590
590
  return;
591
- sseBroker.publishAccessInvalidation(userId);
591
+ await sseBroker.publishAccessInvalidation(userId);
592
592
  }
593
593
  },
594
594
  };
@@ -24,7 +24,7 @@ type MemberReaderAudit = IdentitySwitchAudit & {
24
24
  };
25
25
  export declare function createGatedIdentitySwitch(callerLabel: string, caller: SessionUser | undefined, hasGrant: boolean, ungated: IdentitySwitch, audit?: IdentitySwitchAudit): IdentitySwitch;
26
26
  export declare function createGatedMemberReader(callerLabel: string, allowSystemIdentity: boolean, ungated: MemberReader, audit?: MemberReaderAudit): MemberReader;
27
- export declare function createGatedProjectionReader(callerLabel: string, hasGrant: boolean, ungated: ProjectionReader, audit?: IdentitySwitchAudit): ProjectionReader;
27
+ export declare function createGatedProjectionReader(callerLabel: string, hasGrant: boolean, ungated: ProjectionReader, audit?: IdentitySwitchAudit, projectionHasTenantColumn?: (qualifiedName: string) => boolean): ProjectionReader;
28
28
  export declare function withHookEscapeHatchGrant<TContext extends object>(context: TContext, callerLabel: string, escapeHatch: EscapeHatchDeclaration | undefined): TContext;
29
29
  export declare function bindHookEscapeHatchGrant(fn: LifecycleHookFn, label: string, escapeHatch: EscapeHatchDeclaration | undefined): LifecycleHookFn;
30
30
  export {};
@@ -104,11 +104,12 @@ export function createGatedMemberReader(callerLabel, allowSystemIdentity, ungate
104
104
  }
105
105
  // Same idea, for ctx.queryProjection — a single function rather than a pair.
106
106
  const ungatedProjectionReaderByGated = new WeakMap();
107
- // ctx.queryProjection's gate: only { unsafeAllTenants: true } is grant-checked —
108
- // a plain call always passes through, tenant-scoped by the projection's own table.
109
- export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audit) {
107
+ // ctx.queryProjection's gate: only { unsafeAllTenants: true } on a projection with a
108
+ // tenant column is grant-checked — a plain call always passes through, tenant-scoped
109
+ // by the projection's own table, and the flag is a no-op on a tenant-less one.
110
+ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audit, projectionHasTenantColumn = () => true) {
110
111
  const gated = async (qualifiedName, options) => {
111
- if (options?.unsafeAllTenants === true) {
112
+ if (options?.unsafeAllTenants === true && projectionHasTenantColumn(qualifiedName)) {
112
113
  if (!hasGrant)
113
114
  throw unsafeAllTenantsDenied(callerLabel);
114
115
  if (audit?.reason !== undefined) {
@@ -4,9 +4,11 @@
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
6
6
  // cost; dispatch-write.ts invalidates entries synchronously on
7
- // config:write:set/reset for this key, so the TTL below only covers writes
8
- // that bypass that path (migrations, seeds, direct DB edits).
9
- const DEFAULT_TTL_MS = 5 * 60 * 1000;
7
+ // config:write:set/reset for this key, but only in the process that handled
8
+ // the write. The TTL below therefore bounds how long OTHER replicas (and
9
+ // writes that bypass that path: migrations, seeds, direct DB edits) keep
10
+ // serving the old value, hence the short default.
11
+ const DEFAULT_TTL_MS = 30 * 1000;
10
12
  const DEFAULT_MAX_ENTRIES = 1000;
11
13
  export function createTenantTimezoneCache(opts = {}) {
12
14
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
@@ -2,6 +2,7 @@ import type { TenantId } from "../engine/types/identifiers.js";
2
2
  export declare const CACHE_SYNC_TOPICS: {
3
3
  readonly tenantConfig: "tenant-config";
4
4
  readonly tierAssignment: "tier-assignment";
5
+ readonly featureToggle: "feature-toggle";
5
6
  };
6
7
  export type TenantConfigSyncMessage = {
7
8
  readonly tenantId: TenantId;
@@ -1,6 +1,7 @@
1
1
  export const CACHE_SYNC_TOPICS = {
2
2
  tenantConfig: "tenant-config",
3
3
  tierAssignment: "tier-assignment",
4
+ featureToggle: "feature-toggle",
4
5
  };
5
6
  function hasOptionalStringKey(value) {
6
7
  return !("key" in value) || value.key === undefined || typeof value.key === "string";
@@ -6,4 +6,5 @@ export type RedisClientOptions = {
6
6
  readonly extra?: Readonly<Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">>;
7
7
  };
8
8
  export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
9
+ export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
9
10
  export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;
@@ -26,6 +26,13 @@ export function createRedisClient(url, options = {}) {
26
26
  maxRetriesPerRequest,
27
27
  });
28
28
  }
29
+ // Redis Pub/Sub ignores the DB index and ioredis keyPrefix, so apps or environments sharing one
30
+ // Redis instance would see each other's SSE and toggle-sync traffic. An optional prefix
31
+ // namespaces those channels; unset keeps the unprefixed channel names.
32
+ export function redisChannelPrefixFromEnv(env = process.env) {
33
+ const raw = env["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim();
34
+ return raw ? `${raw}:` : "";
35
+ }
29
36
  // Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
30
37
  // malformed values throw at boot rather than silently falling back to
31
38
  // defaults.
@@ -3,6 +3,6 @@ export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCac
3
3
  export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.js";
4
4
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
5
5
  export type { RedisClientOptions } from "./client.js";
6
- export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
6
+ export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
7
7
  export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal.js";
8
8
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -1,4 +1,4 @@
1
1
  export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, resolveCacheSyncBus, } from "./cache-sync-bus.js";
2
2
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
3
- export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
3
+ export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
4
4
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -1,6 +1,7 @@
1
1
  import { type RedisClientOptions } from "./client.js";
2
2
  export type PubSubSignal = {
3
3
  publish(channel: string, payload: unknown): void;
4
+ publishConfirmed(channel: string, payload: unknown): Promise<void>;
4
5
  onMessage(listener: (channel: string, payload: unknown) => void): void;
5
6
  onReconnect(listener: () => void): void;
6
7
  close(): Promise<void>;
@@ -92,6 +92,9 @@ export function createRedisPubSubSignal(opts) {
92
92
  console.error(`[kumiko:${opts.label}] publish failed:`, err);
93
93
  });
94
94
  },
95
+ async publishConfirmed(channel, payload) {
96
+ await publisher.publish(channel, JSON.stringify(payload));
97
+ },
95
98
  onMessage(listener) {
96
99
  listeners.add(listener);
97
100
  },
@@ -20,6 +20,14 @@ async function awaitSucceededTask(enqueued, toleratedErrorCodes = []) {
20
20
  // would fail forever.
21
21
  const REMOVE_TOLERATED_ERROR_CODES = [ErrorStatusCode.INDEX_NOT_FOUND];
22
22
  const INDEX_LIST_PAGE_SIZE = 100;
23
+ // What an unconfigured Meilisearch index reports as searchableAttributes.
24
+ const MEILI_ALL_ATTRIBUTES = "*";
25
+ function isIndexNotFound(err) {
26
+ const cause = typeof err === "object" && err !== null ? err.cause : null;
27
+ return (typeof cause === "object" &&
28
+ cause !== null &&
29
+ cause.code === ErrorStatusCode.INDEX_NOT_FOUND);
30
+ }
23
31
  // Exported for unit tests (index-name / primary-key shape) without a live Meili.
24
32
  export function meilisearchTenantIndex(prefix, tenantId) {
25
33
  return `${prefix}t${tenantId}`;
@@ -58,6 +66,24 @@ export function createMeilisearchAdapter(options) {
58
66
  // found". A failed configure is never memoized — remove the map entry (iff
59
67
  // it's still the same in-flight promise) and rethrow, so a transient
60
68
  // Meili outage doesn't permanently poison the tenant for the process.
69
+ // The configured-tenants map is process memory only: after a restart a stored explicit
70
+ // configure() must not be overwritten by the registry default, so the default is applied only
71
+ // to an index that is missing or still carries Meilisearch's factory settings.
72
+ async function indexNeedsDefaultConfig(tenantId) {
73
+ try {
74
+ const settings = await client.index(meilisearchTenantIndex(prefix, tenantId)).getSettings();
75
+ const searchable = settings.searchableAttributes ?? [MEILI_ALL_ATTRIBUTES];
76
+ const filterable = settings.filterableAttributes ?? [];
77
+ return (searchable.length === 1 &&
78
+ searchable[0] === MEILI_ALL_ATTRIBUTES &&
79
+ !filterable.includes("_type"));
80
+ }
81
+ catch (err) {
82
+ if (isIndexNotFound(err))
83
+ return true;
84
+ throw err;
85
+ }
86
+ }
61
87
  async function ensureConfigured(tenantId) {
62
88
  // skip: no registry-derived default — callers configure tenants explicitly.
63
89
  if (!defaultConfig)
@@ -68,10 +94,14 @@ export function createMeilisearchAdapter(options) {
68
94
  // skip: configured (or in flight) already for this tenant.
69
95
  return;
70
96
  }
71
- await trackConfigure(tenantId, defaultConfig);
97
+ const config = defaultConfig;
98
+ await trackConfigure(tenantId, async () => {
99
+ if (await indexNeedsDefaultConfig(tenantId))
100
+ await applyConfig(tenantId, config);
101
+ });
72
102
  }
73
- async function trackConfigure(tenantId, config) {
74
- const pending = applyConfig(tenantId, config);
103
+ async function trackConfigure(tenantId, run) {
104
+ const pending = run();
75
105
  configuredTenants.set(tenantId, pending);
76
106
  try {
77
107
  await pending;
@@ -88,7 +118,7 @@ export function createMeilisearchAdapter(options) {
88
118
  defaultConfig = config;
89
119
  },
90
120
  async configure(tenantId, config) {
91
- await trackConfigure(tenantId, config);
121
+ await trackConfigure(tenantId, () => applyConfig(tenantId, config));
92
122
  },
93
123
  async index(tenantId, doc) {
94
124
  await ensureConfigured(tenantId);
@@ -146,7 +146,7 @@ export type TestStackOptions = {
146
146
  runLogger?: (deps: {
147
147
  registry: Registry;
148
148
  db: import("../db/index.js").DbConnection;
149
- }) => Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed"> | undefined;
149
+ }) => Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"> | undefined;
150
150
  };
151
151
  /** Override the event dispatcher's polling-timer interval. Default 50ms.
152
152
  * Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
@@ -157,6 +157,9 @@ export type TestStackOptions = {
157
157
  * them themselves. Default true — false pushes only projection/MSP/
158
158
  * storeTable sources, same as before fw#3102. */
159
159
  entityTables?: boolean;
160
+ /** Time source of the stack's rate-limit resolver, so tests can freeze the
161
+ * clock instead of racing the window. Default: system time. */
162
+ rateLimitNowMs?: () => number;
160
163
  /** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
161
164
  * — see there. Default 0. */
162
165
  trustedProxyHops?: number;