@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.
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- 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
|
|
134
|
-
const
|
|
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
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
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 —
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 }
|
|
108
|
-
// a plain call always passes through, tenant-scoped
|
|
109
|
-
|
|
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,
|
|
8
|
-
//
|
|
9
|
-
|
|
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";
|
package/dist/redis/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/redis/client.js
CHANGED
|
@@ -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.
|
package/dist/redis/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/redis/index.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
74
|
-
const pending =
|
|
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;
|