@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
|
@@ -14,6 +14,7 @@ import { bigint, instant, table as pgTable, sql, text } from "../db/dialect.js";
|
|
|
14
14
|
import { extractTableName } from "../db/index.js";
|
|
15
15
|
import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock.js";
|
|
16
16
|
import { backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
17
|
+
import { alterTableAddColumn } from "../db/queries/ddl.js";
|
|
17
18
|
import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
|
|
18
19
|
import { upsertOnConflict } from "../db/query.js";
|
|
19
20
|
import { tableExists } from "../db/schema-inspection.js";
|
|
@@ -37,10 +38,12 @@ export const piiBackfillStateTable = pgTable("kumiko_pii_backfill_state", {
|
|
|
37
38
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql `now()`),
|
|
38
39
|
});
|
|
39
40
|
export async function createPiiBackfillStateTable(db) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if (!(await tableExists(db, "public.kumiko_pii_backfill_state"))) {
|
|
42
|
+
await unsafePushTables(db, { kumikoPiiBackfillState: piiBackfillStateTable });
|
|
43
|
+
}
|
|
44
|
+
// Added after the table's first deploy; IF NOT EXISTS keeps concurrent replica boots safe.
|
|
45
|
+
await alterTableAddColumn(db, "kumiko_pii_backfill_state", "failed_event_ids", "bigint[]", " DEFAULT '{}'::bigint[]", " NOT NULL",
|
|
46
|
+
/* ifNotExists */ true);
|
|
44
47
|
}
|
|
45
48
|
// Replicas boot concurrently: two of them can both pass the tableExists gate and
|
|
46
49
|
// one CREATE then fails. The table existing afterwards means the other won.
|
|
@@ -107,17 +110,19 @@ function initialScanCursor(state) {
|
|
|
107
110
|
// One transaction per batch: the advisory lock serializes pods, and the payload
|
|
108
111
|
// is re-read inside it so a value is never encrypted twice and two backfills
|
|
109
112
|
// never mint the same DEK. Returns null when another pod already finished a full run.
|
|
110
|
-
async function runOneBatch(ctx, progress) {
|
|
113
|
+
async function runOneBatch(ctx, progress, retryEventIds) {
|
|
111
114
|
return ctx.db.begin(async (tx) => {
|
|
112
115
|
await acquireNamespacedAdvisoryLock(tx, PII_EVENT_BACKFILL_LOCK_NAMESPACE, PII_EVENT_BACKFILL_LOCK_KEY);
|
|
113
116
|
const state = await readPiiBackfillState(tx, ctx.fingerprint);
|
|
114
|
-
if (ctx.mode === "full" && state?.completed === true)
|
|
117
|
+
if (retryEventIds === undefined && ctx.mode === "full" && state?.completed === true) {
|
|
115
118
|
return null;
|
|
119
|
+
}
|
|
116
120
|
const startCursor = ctx.mode === "full" && state
|
|
117
121
|
? maxBigInt(progress.scanCursor, BigInt(state.cursor))
|
|
118
122
|
: progress.scanCursor;
|
|
119
123
|
const batch = await backfillEventPiiEncryptionBatch(tx, ctx.registry, {
|
|
120
124
|
afterEventId: startCursor,
|
|
125
|
+
...(retryEventIds && { eventIds: retryEventIds }),
|
|
121
126
|
batchSize: ctx.batchSize,
|
|
122
127
|
resolveOwnerFromProjection: true,
|
|
123
128
|
scanCache: progress.scanCache,
|
|
@@ -128,10 +133,15 @@ async function runOneBatch(ctx, progress) {
|
|
|
128
133
|
: [];
|
|
129
134
|
if (tables.length > 0)
|
|
130
135
|
await queueRebuildTables(tx, tables, ctx.migrationId);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
// The cursor moves past failed events: they are recorded here and retried on their own,
|
|
137
|
+
// so one permanently failing event no longer forces a rescan from it on every boot.
|
|
138
|
+
const failedIds = batch.failures.map((failure) => failure.eventId);
|
|
139
|
+
await persistPiiBackfillState(tx, ctx.fingerprint, {
|
|
140
|
+
cursor: retryEventIds ? 0n : advancedCursor(batch, startCursor),
|
|
141
|
+
markCompleted: retryEventIds === undefined && batch.scannedAll,
|
|
142
|
+
addFailedEventIds: failedIds,
|
|
143
|
+
removeFailedEventIds: retryEventIds?.filter((id) => !failedIds.includes(id)) ?? [],
|
|
144
|
+
});
|
|
135
145
|
return { batch, tables };
|
|
136
146
|
});
|
|
137
147
|
}
|
|
@@ -141,14 +151,25 @@ function recordBatch(progress, step) {
|
|
|
141
151
|
progress.totals.updatedEvents += batch.updatedEvents;
|
|
142
152
|
progress.totals.encryptedFields += batch.encryptedFields;
|
|
143
153
|
progress.totals.erasedFields += batch.erasedFields;
|
|
144
|
-
|
|
154
|
+
for (const failure of batch.failures) {
|
|
155
|
+
// The catch-up overlap and the per-event retry can hit the same failed event twice in one run.
|
|
156
|
+
if (progress.failedIdsSeen.has(failure.eventId))
|
|
157
|
+
continue;
|
|
158
|
+
progress.failedIdsSeen.add(failure.eventId);
|
|
159
|
+
progress.failures.push(failure);
|
|
160
|
+
}
|
|
145
161
|
for (const table of step.tables)
|
|
146
162
|
progress.queuedTables.add(table);
|
|
147
|
-
if (batch.failures.length > 0)
|
|
148
|
-
progress.failureSeen = true;
|
|
149
163
|
}
|
|
150
164
|
// Returns true when the run was aborted between batches.
|
|
151
|
-
async function scanAllBatches(ctx, progress, signal) {
|
|
165
|
+
async function scanAllBatches(ctx, progress, signal, retryEventIds) {
|
|
166
|
+
for (let from = 0; from < retryEventIds.length; from += ctx.batchSize) {
|
|
167
|
+
if (signal?.aborted)
|
|
168
|
+
return true;
|
|
169
|
+
const step = await runOneBatch(ctx, progress, retryEventIds.slice(from, from + ctx.batchSize));
|
|
170
|
+
if (step !== null)
|
|
171
|
+
recordBatch(progress, step);
|
|
172
|
+
}
|
|
152
173
|
for (;;) {
|
|
153
174
|
if (signal?.aborted)
|
|
154
175
|
return true;
|
|
@@ -163,7 +184,9 @@ async function scanAllBatches(ctx, progress, signal) {
|
|
|
163
184
|
}
|
|
164
185
|
function reportRunOutcome(log, progress) {
|
|
165
186
|
if (progress.failures.length > 0) {
|
|
166
|
-
log.error(`${progress.failures.length} event(s) could not be PII-encrypted
|
|
187
|
+
log.error(`${progress.failures.length} event(s) could not be PII-encrypted; the scan moved past them and ` +
|
|
188
|
+
"only these events are retried on each boot. Fix the cause, or erase subjects that stay unresolvable via " +
|
|
189
|
+
"backfillEventPiiEncryption(..., { eraseUnresolvableSubjects: true })", {
|
|
167
190
|
failureCount: progress.failures.length,
|
|
168
191
|
eventIds: progress.failures.slice(0, MAX_LOGGED_FAILURE_IDS).map((f) => f.eventId),
|
|
169
192
|
});
|
|
@@ -180,24 +203,27 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
|
|
|
180
203
|
const fingerprint = piiAnnotationFingerprint(registry);
|
|
181
204
|
await ensureInfraTable(db, "kumiko_pii_backfill_state", createPiiBackfillStateTable);
|
|
182
205
|
await ensureInfraTable(db, "kumiko_pending_rebuilds", createPendingRebuildsTable);
|
|
183
|
-
const
|
|
206
|
+
const initialState = await readPiiBackfillState(db, fingerprint);
|
|
207
|
+
const { mode, scanCursor } = initialScanCursor(initialState);
|
|
184
208
|
const ctx = {
|
|
185
209
|
db,
|
|
186
210
|
registry,
|
|
187
211
|
fingerprint,
|
|
188
|
-
|
|
212
|
+
// The per-run suffix keeps a peer replica's re-queue of the same table (new
|
|
213
|
+
// migration_id via the upsert) from being deleted by this run's clear.
|
|
214
|
+
migrationId: `${PII_BACKFILL_MIGRATION_PREFIX}${fingerprint.slice(0, 12)}:${crypto.randomUUID()}`,
|
|
189
215
|
mode,
|
|
190
216
|
batchSize: options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
191
217
|
};
|
|
192
218
|
const progress = {
|
|
193
219
|
scanCursor,
|
|
194
|
-
failureSeen: false,
|
|
195
220
|
scanCache: {},
|
|
196
221
|
totals: { scannedEvents: 0, updatedEvents: 0, encryptedFields: 0, erasedFields: 0 },
|
|
197
222
|
failures: [],
|
|
198
223
|
queuedTables: new Set(),
|
|
224
|
+
failedIdsSeen: new Set(),
|
|
199
225
|
};
|
|
200
|
-
const aborted = await scanAllBatches(ctx, progress, options.signal);
|
|
226
|
+
const aborted = await scanAllBatches(ctx, progress, options.signal, initialState?.failedEventIds ?? []);
|
|
201
227
|
reportRunOutcome(log, progress);
|
|
202
228
|
const rebuild = aborted ? null : await rebuildQueuedTables(db, registry, options.signal, log);
|
|
203
229
|
const queuedTables = [...progress.queuedTables];
|
|
@@ -218,12 +244,7 @@ export async function runPiiEventBackfill(db, registry, options = {}) {
|
|
|
218
244
|
rebuild,
|
|
219
245
|
};
|
|
220
246
|
}
|
|
221
|
-
|
|
222
|
-
function persistableCursor(batch, startCursor) {
|
|
223
|
-
if (batch.firstFailedEventId !== null) {
|
|
224
|
-
const beforeFailure = BigInt(batch.firstFailedEventId) - 1n;
|
|
225
|
-
return beforeFailure > startCursor ? beforeFailure : startCursor;
|
|
226
|
-
}
|
|
247
|
+
function advancedCursor(batch, startCursor) {
|
|
227
248
|
return batch.lastEventId === null ? startCursor : BigInt(batch.lastEventId);
|
|
228
249
|
}
|
|
229
250
|
function maxBigInt(a, b) {
|
|
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
|
|
|
12
12
|
export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
|
|
13
13
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
14
14
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
15
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
15
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
16
|
export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
|
|
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
|
|
|
13
13
|
export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
|
|
14
14
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
15
15
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
16
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerPendingGaps, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
@@ -2,7 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { type PrometheusMeter } from "./prometheus-meter.js";
|
|
3
3
|
import type { ObservabilityProvider } from "./types/index.js";
|
|
4
4
|
export declare const prometheusMetricsEnvSchema: z.ZodObject<{
|
|
5
|
-
PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodString
|
|
5
|
+
PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodLiteral<"">, z.ZodTransform<undefined, "">>, z.ZodString]>>;
|
|
6
6
|
}, z.core.$strip>;
|
|
7
7
|
type PrometheusObservabilityProvider = ObservabilityProvider & {
|
|
8
8
|
readonly meter: PrometheusMeter;
|
|
@@ -2,9 +2,9 @@ import * as z from "zod";
|
|
|
2
2
|
import { createNoopProvider } from "./noop-provider.js";
|
|
3
3
|
import { createPrometheusMeter } from "./prometheus-meter.js";
|
|
4
4
|
export const prometheusMetricsEnvSchema = z.object({
|
|
5
|
+
// An empty value (blank .env line, empty k8s secret) means "unset", not "invalid".
|
|
5
6
|
PROMETHEUS_METRICS_TOKEN: z
|
|
6
|
-
.string()
|
|
7
|
-
.min(32)
|
|
7
|
+
.union([z.literal("").transform(() => undefined), z.string().min(32)])
|
|
8
8
|
.optional()
|
|
9
9
|
.describe("Bearer token for /metrics; unset keeps the endpoint off.")
|
|
10
10
|
.meta({ kumiko: { pulumi: { secret: true, generator: "openssl rand -base64 32" } } }),
|
|
@@ -28,6 +28,10 @@ export declare function emitEventConsumerLag(meter: Meter, labels: {
|
|
|
28
28
|
readonly consumer: string;
|
|
29
29
|
readonly instanceId: string;
|
|
30
30
|
}, lagEvents: number): void;
|
|
31
|
+
export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
|
|
32
|
+
readonly consumer: string;
|
|
33
|
+
readonly instanceId: string;
|
|
34
|
+
}, gapRanges: number): void;
|
|
31
35
|
export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
|
|
32
36
|
readonly consumer: string;
|
|
33
37
|
readonly instanceId: string;
|
|
@@ -61,6 +61,12 @@ export const STANDARD_METRIC_DEFS = [
|
|
|
61
61
|
// per-instance consumers (one cursor per dispatcher). Without this,
|
|
62
62
|
// Prometheus collapses per-instance shards into last-writer-wins and
|
|
63
63
|
// per-instance lag is invisible to alerting.
|
|
64
|
+
{
|
|
65
|
+
name: "kumiko_event_consumer_pending_gaps",
|
|
66
|
+
type: "gauge",
|
|
67
|
+
description: "Number of unresolved id ranges below the consumer's cursor (ids not yet visible or proven rolled back).",
|
|
68
|
+
labels: ["consumer", "instance_id"],
|
|
69
|
+
},
|
|
64
70
|
{
|
|
65
71
|
name: "kumiko_event_consumer_lag_events",
|
|
66
72
|
type: "gauge",
|
|
@@ -212,6 +218,12 @@ export function emitEventConsumerLag(meter, labels, lagEvents) {
|
|
|
212
218
|
instance_id: labels.instanceId,
|
|
213
219
|
});
|
|
214
220
|
}
|
|
221
|
+
export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
|
|
222
|
+
meter.gauge("kumiko_event_consumer_pending_gaps").set(gapRanges, {
|
|
223
|
+
consumer: labels.consumer,
|
|
224
|
+
instance_id: labels.instanceId,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
215
227
|
// Caller (event-dispatcher.ts) is responsible for calling this exactly
|
|
216
228
|
// once per (consumer, instanceId) transition into exhausted-dead — not
|
|
217
229
|
// once per poll pass while it stays dead.
|
|
@@ -46,7 +46,9 @@ export type DispatchContext = {
|
|
|
46
46
|
tracer: ReturnType<typeof getFallbackTracer>;
|
|
47
47
|
meter: ReturnType<typeof getFallbackMeter>;
|
|
48
48
|
membershipQuery: string;
|
|
49
|
+
memberReadTimeoutMs: number;
|
|
49
50
|
};
|
|
51
|
+
export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
|
|
50
52
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
51
53
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
52
54
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { requestContext, runWithOrigin } from "../api/request-context.js";
|
|
3
3
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
4
|
-
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
|
|
4
|
+
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTransaction, } from "../db/tenant-db.js";
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
6
|
import { createSystemUser } from "../engine/system-user.js";
|
|
7
7
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
@@ -40,6 +40,7 @@ export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
|
|
|
40
40
|
// exercises both against the real config feature.
|
|
41
41
|
export const CONFIG_WRITE_SET_TYPE = "config:write:set";
|
|
42
42
|
export const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
|
|
43
|
+
export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
43
44
|
// Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
|
|
44
45
|
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
45
46
|
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
@@ -113,14 +114,57 @@ function denyingJobRunnerProxy() {
|
|
|
113
114
|
},
|
|
114
115
|
});
|
|
115
116
|
}
|
|
117
|
+
// Allowlist (not denylist): a method added to the target later stays denied
|
|
118
|
+
// until someone decides it is read-only.
|
|
119
|
+
function allowlistedReadOnlyProxy(target, allowedMethods) {
|
|
120
|
+
return new Proxy(target, {
|
|
121
|
+
get(inner, prop) {
|
|
122
|
+
if (typeof prop === "string" && allowedMethods.has(prop)) {
|
|
123
|
+
const method = Reflect.get(inner, prop, inner);
|
|
124
|
+
return typeof method === "function" ? method.bind(inner) : method;
|
|
125
|
+
}
|
|
126
|
+
// Symbol reads and "then" come from await/Promise.resolve/inspect, not from handler code.
|
|
127
|
+
if (typeof prop === "symbol" || prop === "then")
|
|
128
|
+
return undefined;
|
|
129
|
+
throw memberResolutionReadOnlyDenied();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const MEMBER_READ_SEARCH_METHODS = new Set(["search"]);
|
|
134
|
+
const MEMBER_READ_ENTITY_CACHE_METHODS = new Set(["get", "mget"]);
|
|
135
|
+
const MEMBER_READ_REDIS_METHODS = new Set([
|
|
136
|
+
"get",
|
|
137
|
+
"mget",
|
|
138
|
+
"exists",
|
|
139
|
+
"ttl",
|
|
140
|
+
"pttl",
|
|
141
|
+
"strlen",
|
|
142
|
+
"type",
|
|
143
|
+
"hget",
|
|
144
|
+
"hmget",
|
|
145
|
+
"hgetall",
|
|
146
|
+
"hexists",
|
|
147
|
+
"hlen",
|
|
148
|
+
"sismember",
|
|
149
|
+
"smembers",
|
|
150
|
+
"scard",
|
|
151
|
+
"llen",
|
|
152
|
+
"lrange",
|
|
153
|
+
"zscore",
|
|
154
|
+
"zcard",
|
|
155
|
+
"zrange",
|
|
156
|
+
]);
|
|
116
157
|
// Every write/side-effect surface throws, so "no writeAsMember" holds even
|
|
117
158
|
// if a handler tries ctx.write directly on a resolved member principal.
|
|
159
|
+
// Deliberately open: `db` (guarded by the READ ONLY transaction + memberReadOnly grant),
|
|
160
|
+
// and read-only methods of searchAdapter / redis / entityCache (allowlisted).
|
|
118
161
|
export function applyMemberResolutionReadOnly(handlerContext) {
|
|
119
162
|
return {
|
|
120
163
|
...handlerContext,
|
|
121
164
|
// `db` stays open — executeQuery runs the whole handler in a Postgres READ ONLY
|
|
122
165
|
// transaction, and its `memberReadOnly` grant denies every raw-runner handout.
|
|
123
166
|
dbOutsideTransaction: undefined,
|
|
167
|
+
outsideTransaction: undefined,
|
|
124
168
|
write: denyMemberResolutionWrite,
|
|
125
169
|
writeAs: denyMemberResolutionWrite,
|
|
126
170
|
// A resolved member cannot switch identity either — the target would get a normal, writable context.
|
|
@@ -144,6 +188,15 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
144
188
|
derivatives: undefined,
|
|
145
189
|
...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
|
|
146
190
|
...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
|
|
191
|
+
...(handlerContext.searchAdapter && {
|
|
192
|
+
searchAdapter: allowlistedReadOnlyProxy(handlerContext.searchAdapter, MEMBER_READ_SEARCH_METHODS),
|
|
193
|
+
}),
|
|
194
|
+
...(handlerContext.redis && {
|
|
195
|
+
redis: allowlistedReadOnlyProxy(handlerContext.redis, MEMBER_READ_REDIS_METHODS),
|
|
196
|
+
}),
|
|
197
|
+
...(handlerContext.entityCache && {
|
|
198
|
+
entityCache: allowlistedReadOnlyProxy(handlerContext.entityCache, MEMBER_READ_ENTITY_CACHE_METHODS),
|
|
199
|
+
}),
|
|
147
200
|
};
|
|
148
201
|
}
|
|
149
202
|
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
|
|
@@ -210,6 +263,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
210
263
|
? createSystemScopedDbGuard(type, "dbOutsideTransaction", "ctx.systemDb.outsideTransaction.assertTenantMatch(...) or " +
|
|
211
264
|
"ctx.systemDb.outsideTransaction.acknowledgeCrossTenant(...)")
|
|
212
265
|
: rawDbOutsideTransaction;
|
|
266
|
+
// Not offered to r.systemScope() handlers: the raw outside-tx db is unfiltered in
|
|
267
|
+
// system mode, so fn would hand them the same cross-tenant door the guards above close.
|
|
268
|
+
const outsideTransaction = !isSystem && rawDbOutsideTransaction
|
|
269
|
+
? (fn) => runInOwnTransaction(rawDbOutsideTransaction, fn)
|
|
270
|
+
: undefined;
|
|
213
271
|
const log = context.log?.child({
|
|
214
272
|
handler: type,
|
|
215
273
|
tenantId: user.tenantId,
|
|
@@ -255,7 +313,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
255
313
|
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
256
314
|
// to a statically-injected context.files (tests).
|
|
257
315
|
const fileResolver = context._fileProviderResolver;
|
|
258
|
-
const files = fileResolver
|
|
316
|
+
const files = fileResolver
|
|
317
|
+
? createFileContext(() => fileResolver(user.tenantId), user.tenantId)
|
|
318
|
+
: context.files;
|
|
259
319
|
// ctx.derivatives builds on ctx.files (needs a FileContext to read the
|
|
260
320
|
// original + write the variant) plus db (to look up the FileRef row) — so
|
|
261
321
|
// it can only be constructed exactly when files+db both resolved; falls
|
|
@@ -302,13 +362,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
302
362
|
ungatedMemberReader ??= createMemberReaderFn(ctx, user.tenantId, tx);
|
|
303
363
|
return ungatedMemberReader(userId, qn, payload);
|
|
304
364
|
}, { ...identitySwitchAudit, tenantId: user.tenantId });
|
|
365
|
+
// queryProjection works against both single-stream and multi-stream
|
|
366
|
+
// projections. MSPs without a table cannot be queried — those are
|
|
367
|
+
// side-effect-only consumers (no state to read back).
|
|
368
|
+
const findProjectionTable = (qualifiedName) => registry.getAllProjections().get(qualifiedName)?.table ??
|
|
369
|
+
registry.getAllMultiStreamProjections().get(qualifiedName)?.table;
|
|
305
370
|
const ungatedQueryProjection = async (qualifiedName, queryOptions) => {
|
|
306
|
-
|
|
307
|
-
// projections. MSPs without a table cannot be queried — those are
|
|
308
|
-
// side-effect-only consumers (no state to read back).
|
|
309
|
-
const singleProj = registry.getAllProjections().get(qualifiedName);
|
|
310
|
-
const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
|
|
311
|
-
const projTable = singleProj?.table ?? mspProj?.table;
|
|
371
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
312
372
|
if (!projTable) {
|
|
313
373
|
const singleNames = [...registry.getAllProjections().keys()];
|
|
314
374
|
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter((n) => registry.getAllMultiStreamProjections().get(n)?.table);
|
|
@@ -330,7 +390,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
330
390
|
const rows = await selectMany(dbSource, projTable, where);
|
|
331
391
|
return rows; // @cast-boundary engine-payload
|
|
332
392
|
};
|
|
333
|
-
const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit)
|
|
393
|
+
const queryProjection = createGatedProjectionReader(`handler "${type}"`, hasIdentitySwitchGrant, ungatedQueryProjection, identitySwitchAudit, (qualifiedName) => {
|
|
394
|
+
const projTable = findProjectionTable(qualifiedName);
|
|
395
|
+
// Unknown projection: keep the grant check first, the ungated reader reports the real error.
|
|
396
|
+
return projTable === undefined || hasTenantColumn(projTable);
|
|
397
|
+
});
|
|
334
398
|
const bridge = {
|
|
335
399
|
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
336
400
|
queryAs: identitySwitch.queryAs,
|
|
@@ -624,6 +688,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
624
688
|
registry,
|
|
625
689
|
db: exposedDb,
|
|
626
690
|
dbOutsideTransaction,
|
|
691
|
+
...(outsideTransaction && { outsideTransaction }),
|
|
627
692
|
...(systemDb && { systemDb }),
|
|
628
693
|
log,
|
|
629
694
|
notify,
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
import type { SessionUser } from "../engine/types/index.js";
|
|
2
2
|
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
3
|
-
export
|
|
3
|
+
export type StreamOptions = {
|
|
4
|
+
/** JWT exp (epoch seconds) the stream was opened with; the stream ends when it passes. */
|
|
5
|
+
readonly tokenExpiresAtSec?: number | undefined;
|
|
6
|
+
};
|
|
7
|
+
export declare function executeStream(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, options?: StreamOptions): AsyncGenerator<unknown>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
2
|
+
import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
|
|
2
3
|
import { hasAccess } from "../engine/access.js";
|
|
3
4
|
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
4
5
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
@@ -10,10 +11,10 @@ import { handlerAccessError } from "./handler-access-error.js";
|
|
|
10
11
|
// of returning a single response. streamHandler never entity-maps (unlike
|
|
11
12
|
// write/queryHandler — see feature-entity-handlers.ts), so there's no
|
|
12
13
|
// field-access filter or postQuery-hook stage to run here.
|
|
13
|
-
export async function* executeStream(ctx, type, payload, user, origin) {
|
|
14
|
-
yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin));
|
|
14
|
+
export async function* executeStream(ctx, type, payload, user, origin, options) {
|
|
15
|
+
yield* runStreamInstrumented(ctx, type, user, () => executeStreamInner(ctx, type, payload, user, origin, options));
|
|
15
16
|
}
|
|
16
|
-
async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
17
|
+
async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
17
18
|
const { registry } = ctx;
|
|
18
19
|
const handler = registry.getStreamHandler(type);
|
|
19
20
|
if (!handler)
|
|
@@ -32,15 +33,18 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
32
33
|
if (!parsed.success) {
|
|
33
34
|
throw validationErrorFromZod(parsed.error);
|
|
34
35
|
}
|
|
35
|
-
// Idle (heartbeat-only) streams must also cut on access revoke — race each
|
|
36
|
-
// pull against an
|
|
37
|
-
let
|
|
38
|
-
const
|
|
39
|
-
|
|
36
|
+
// Idle (heartbeat-only) streams must also cut on access revoke or token expiry — race each
|
|
37
|
+
// pull against an ended Deferred instead of a post-chunk boolean.
|
|
38
|
+
let resolveEnded;
|
|
39
|
+
const ended = new Promise((resolve) => {
|
|
40
|
+
resolveEnded = resolve;
|
|
40
41
|
});
|
|
41
42
|
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
42
|
-
|
|
43
|
+
resolveEnded?.("revoked");
|
|
43
44
|
}, accessInvalidationCredentialFor(user));
|
|
45
|
+
const expiryTimer = scheduleTokenExpiry(options?.tokenExpiresAtSec, () => {
|
|
46
|
+
resolveEnded?.("expired");
|
|
47
|
+
});
|
|
44
48
|
let iterator;
|
|
45
49
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
46
50
|
// Awaiting `iterator.return()` in that state deadlocks async generators in
|
|
@@ -55,14 +59,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
55
59
|
const nextPull = iterator.next();
|
|
56
60
|
const outcome = await Promise.race([
|
|
57
61
|
nextPull.then((result) => ({ kind: "chunk", result })),
|
|
58
|
-
|
|
62
|
+
ended.then((reason) => ({ kind: "ended", reason })),
|
|
59
63
|
]);
|
|
60
|
-
if (outcome.kind === "
|
|
64
|
+
if (outcome.kind === "ended") {
|
|
61
65
|
abandonedForInvalidation = true;
|
|
62
66
|
void nextPull.catch(() => { });
|
|
63
67
|
void iterator.return?.(undefined)?.then(undefined, () => { });
|
|
64
68
|
throw new AccessDeniedError({
|
|
65
|
-
message:
|
|
69
|
+
message: outcome.reason === "expired"
|
|
70
|
+
? `token expired mid-stream for ${type}`
|
|
71
|
+
: `access revoked mid-stream for ${type}`,
|
|
66
72
|
details: { handler: type },
|
|
67
73
|
});
|
|
68
74
|
}
|
|
@@ -75,6 +81,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
75
81
|
}
|
|
76
82
|
finally {
|
|
77
83
|
unsubscribeAccessInvalidation?.();
|
|
84
|
+
clearTimeout(expiryTimer);
|
|
78
85
|
// Close the generator so cleanup runs; skip awaiting return() after
|
|
79
86
|
// access-revoke abandonment — overlapping next()+return() can deadlock.
|
|
80
87
|
if (iterator !== undefined && !abandonedForInvalidation) {
|
|
@@ -4,6 +4,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
|
|
|
4
4
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
5
5
|
import type { ActiveMembershipResult, AppContext, DispatchWriteRef, JobRunnerRef, MemberReader, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
6
6
|
import type { BatchCommand, BatchResult } from "./dispatch-shared.js";
|
|
7
|
+
import { type StreamOptions } from "./dispatch-stream.js";
|
|
7
8
|
import { type HandlerType } from "./dispatcher-utils.js";
|
|
8
9
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
9
10
|
import type { LifecycleHooks } from "./lifecycle-pipeline.js";
|
|
@@ -14,6 +15,7 @@ export type DispatcherOptions = {
|
|
|
14
15
|
lifecycle?: LifecycleHooks;
|
|
15
16
|
jobRunner?: JobRunnerRef;
|
|
16
17
|
membershipQuery?: string;
|
|
18
|
+
memberReadTimeoutMs?: number;
|
|
17
19
|
effectiveFeatures?: EffectiveFeaturesResolver;
|
|
18
20
|
sseBroker?: SseBroker;
|
|
19
21
|
cacheSync?: CacheSyncBus | null;
|
|
@@ -21,7 +23,7 @@ export type DispatcherOptions = {
|
|
|
21
23
|
export type Dispatcher = {
|
|
22
24
|
write(type: HandlerType, payload: unknown, user: SessionUser, requestId?: string): Promise<WriteResult>;
|
|
23
25
|
query(type: HandlerType, payload: unknown, user: SessionUser): Promise<unknown>;
|
|
24
|
-
stream(type: HandlerType, payload: unknown, user: SessionUser): AsyncGenerator<unknown>;
|
|
26
|
+
stream(type: HandlerType, payload: unknown, user: SessionUser, options?: StreamOptions): AsyncGenerator<unknown>;
|
|
25
27
|
command(type: HandlerType, payload: unknown, user: SessionUser): Promise<void>;
|
|
26
28
|
batch(commands: readonly BatchCommand[], user: SessionUser, requestId?: string): Promise<BatchResult>;
|
|
27
29
|
resolveAuthClaims(user: SessionUser): Promise<Record<string, unknown>>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
2
|
-
import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from "../engine/entity-handlers.js";
|
|
3
2
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
4
3
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
5
4
|
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
@@ -7,7 +6,7 @@ import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-syn
|
|
|
7
6
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
8
7
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
9
8
|
import { executeQuery } from "./dispatch-query.js";
|
|
10
|
-
import { resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY } from "./dispatch-shared.js";
|
|
9
|
+
import { DEFAULT_MEMBER_READ_TIMEOUT_MS, resolveAuthClaimsFn, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
11
10
|
import { executeStream } from "./dispatch-stream.js";
|
|
12
11
|
import { resolveType } from "./dispatcher-utils.js";
|
|
13
12
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
@@ -49,6 +48,12 @@ export function dispatcherToWriteRef(dispatcher) {
|
|
|
49
48
|
export function createDispatcher(registry, context, options = {}) {
|
|
50
49
|
const { idempotency, lifecycle, jobRunner, effectiveFeatures, sseBroker } = options;
|
|
51
50
|
const membershipQuery = options.membershipQuery ?? TENANT_MEMBERSHIPS_QUERY;
|
|
51
|
+
const memberReadTimeoutMs = options.memberReadTimeoutMs ?? DEFAULT_MEMBER_READ_TIMEOUT_MS;
|
|
52
|
+
if (!Number.isSafeInteger(memberReadTimeoutMs) || memberReadTimeoutMs <= 0) {
|
|
53
|
+
throw new InternalError({
|
|
54
|
+
message: `DispatcherOptions.memberReadTimeoutMs must be a positive integer, got ${memberReadTimeoutMs}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
52
57
|
// Pre-build tables and transition maps for auto-guard (avoid per-request allocation)
|
|
53
58
|
const tableCache = new Map();
|
|
54
59
|
const transitionCache = new Map();
|
|
@@ -75,17 +80,6 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
75
80
|
// Ensure standard metrics exist on whatever meter we ended up with.
|
|
76
81
|
// Idempotent: buildServer may have registered them already.
|
|
77
82
|
registerStandardMetrics(dispatcherMeter);
|
|
78
|
-
for (const def of [
|
|
79
|
-
...registry.getAllWriteHandlers().values(),
|
|
80
|
-
...registry.getAllQueryHandlers().values(),
|
|
81
|
-
]) {
|
|
82
|
-
if (isDeprecatedCrossTenantHandler(def.handler)) {
|
|
83
|
-
context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
|
|
84
|
-
handler: def.name,
|
|
85
|
-
migration: 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/migrate-cross-tenant.ts)',
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
83
|
const ctx = {
|
|
90
84
|
registry,
|
|
91
85
|
appContext: context,
|
|
@@ -102,6 +96,7 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
102
96
|
tracer: dispatcherTracer,
|
|
103
97
|
meter: dispatcherMeter,
|
|
104
98
|
membershipQuery,
|
|
99
|
+
memberReadTimeoutMs,
|
|
105
100
|
};
|
|
106
101
|
const dispatcher = {
|
|
107
102
|
async write(typeOrRef, payload, user, requestId) {
|
|
@@ -116,9 +111,9 @@ export function createDispatcher(registry, context, options = {}) {
|
|
|
116
111
|
const origin = rootWriteOrigin(registry, type, user);
|
|
117
112
|
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
118
113
|
},
|
|
119
|
-
stream: (typeOrRef, payload, user) => {
|
|
114
|
+
stream: (typeOrRef, payload, user, options) => {
|
|
120
115
|
const type = resolveType(typeOrRef);
|
|
121
|
-
return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
|
|
116
|
+
return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
|
|
122
117
|
},
|
|
123
118
|
async command(typeOrRef, payload, user) {
|
|
124
119
|
const type = resolveType(typeOrRef);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { bigint, index, instant, integer, jsonb, table as pgTable, primaryKey, sql, text, } from "../db/dialect.js";
|
|
2
2
|
import { alterTableAddColumn } from "../db/queries/ddl.js";
|
|
3
|
-
import { deleteOrphanedPerInstanceConsumerRows } from "../db/queries/event-consumer.js";
|
|
4
3
|
import { tableExists } from "../db/schema-inspection.js";
|
|
5
4
|
import { unsafePushTables } from "../stack/index.js";
|
|
6
5
|
// Reserved sentinel used in the instance_id column for consumers whose
|
|
@@ -39,20 +38,6 @@ export const eventConsumerStateTable = pgTable("kumiko_event_consumers", {
|
|
|
39
38
|
pk: primaryKey({ columns: [t.name, t.instanceId] }),
|
|
40
39
|
statusIdx: index("kumiko_event_consumers_status_idx").on(t.status),
|
|
41
40
|
}));
|
|
42
|
-
// fw#2625: these three consumers moved from delivery: "per-instance" to
|
|
43
|
-
// "shared" (see system-hooks.ts and bundled-features/feature-toggles/
|
|
44
|
-
// feature.ts). Their old per-instance rows are now orphaned — no dispatcher
|
|
45
|
-
// will ever advance them again — and pruneEvents stays pinned to whichever
|
|
46
|
-
// one has the lowest cursor forever. Named here (not imported from
|
|
47
|
-
// system-hooks.ts / feature.ts) to avoid a pipeline → system-hooks /
|
|
48
|
-
// bundled-features dependency for three string literals; those modules'
|
|
49
|
-
// own exported constants (or, for feature-toggles, the qualified consumer
|
|
50
|
-
// name qualify() produces) are the source of truth these must match.
|
|
51
|
-
const ORPHANED_PER_INSTANCE_CONSUMER_NAMES = [
|
|
52
|
-
"system:consumer:sse-broadcast",
|
|
53
|
-
"system:consumer:access-invalidation",
|
|
54
|
-
"feature-toggles:projection:toggle-cache-sync",
|
|
55
|
-
];
|
|
56
41
|
// Object-const form lets call sites write `ConsumerStatuses.disabled` instead
|
|
57
42
|
// of the raw string, which keeps status checks refactor-safe. The runtime
|
|
58
43
|
// value on each field is the same string the DB stores — no mapping needed.
|
|
@@ -84,19 +69,8 @@ export async function createEventConsumerStateTable(db) {
|
|
|
84
69
|
/* ifNotExists */ true);
|
|
85
70
|
await alterTableAddColumn(db, "kumiko_event_consumers", "last_failed_event_id", "bigint", "", "",
|
|
86
71
|
/* ifNotExists */ true);
|
|
87
|
-
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
88
|
-
// an old pod's still-in-use per-instance row out from under it. That old
|
|
89
|
-
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|
|
90
|
-
// access-invalidation events for the rest of its lifetime — a one-time,
|
|
91
|
-
// few-second gap that resolves itself as clients reconnect onto new pods
|
|
92
|
-
// during the same rollout. Not a replay risk: preRegisterConsumers only
|
|
93
|
-
// (re-)inserts a row at dispatcher startup, so the deleted pod won't
|
|
94
|
-
// silently get a fresh cursor-0 row and replay history mid-flight.
|
|
95
|
-
await deleteOrphanedPerInstanceConsumerRows(db, ORPHANED_PER_INSTANCE_CONSUMER_NAMES, SHARED_INSTANCE_SENTINEL);
|
|
96
72
|
// skip: table (+ any missing column) is already up to date
|
|
97
73
|
return;
|
|
98
74
|
}
|
|
99
|
-
// A freshly created table has no rows to clean up — the DELETE only
|
|
100
|
-
// matters for a DB that already ran the old per-instance consumers.
|
|
101
75
|
await unsafePushTables(db, { kumikoEventConsumers: eventConsumerStateTable });
|
|
102
76
|
}
|