@cosmicdrift/kumiko-framework 0.333.0 → 0.335.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/auth-routes.js +19 -4
- package/dist/api/client-ip.js +3 -6
- package/dist/api/extra-route.js +3 -2
- package/dist/api/pii-leak-guard.js +7 -4
- package/dist/api/redis-sse-broker.d.ts +2 -0
- package/dist/api/redis-sse-broker.js +6 -7
- package/dist/api/routes.js +16 -17
- package/dist/api/server.js +14 -7
- package/dist/api/sse-broker.d.ts +3 -0
- package/dist/api/sse-broker.js +14 -0
- package/dist/api/sse-route.d.ts +1 -1
- package/dist/api/sse-route.js +39 -6
- package/dist/bun-db/connection.js +0 -2
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +1 -0
- package/dist/bun-db/query.js +15 -4
- package/dist/crypto/kek-source.js +40 -36
- package/dist/crypto/kms-adapter.js +2 -2
- package/dist/db/apply-entity-event.js +8 -2
- package/dist/db/blind-index-cleanup.js +11 -1
- package/dist/db/eagerload.js +2 -1
- package/dist/db/entity-table-meta.js +5 -3
- package/dist/db/event-store-executor-context.js +13 -19
- package/dist/db/event-store-executor-read.js +39 -36
- package/dist/db/event-store-executor-write.js +15 -17
- package/dist/db/migrate-generator.js +27 -28
- package/dist/db/pg-error.js +9 -5
- package/dist/db/queries/backfill-pii.js +15 -5
- package/dist/db/queries/shadow-swap.js +5 -4
- package/dist/db/table-builder.d.ts +1 -0
- package/dist/db/table-builder.js +7 -15
- package/dist/db/tenant-db.js +18 -2
- package/dist/derivatives/variant-key.js +3 -1
- package/dist/engine/boot-validator/access-declarations.js +12 -3
- package/dist/engine/boot-validator/config-deps.js +13 -0
- package/dist/engine/boot-validator/entity-handler.js +13 -1
- package/dist/engine/boot-validator/entity-list-screens.d.ts +1 -0
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/index.js +6 -6
- package/dist/engine/boot-validator/nav.d.ts +2 -1
- package/dist/engine/boot-validator/nav.js +10 -20
- package/dist/engine/boot-validator/ownership.d.ts +1 -1
- package/dist/engine/boot-validator/ownership.js +2 -1
- package/dist/engine/boot-validator/pii-retention.d.ts +2 -0
- package/dist/engine/boot-validator/pii-retention.js +44 -4
- package/dist/engine/boot-validator/projection-list-screens.js +8 -0
- package/dist/engine/boot-validator/query-output-columns.js +41 -5
- package/dist/engine/boot-validator/screens.d.ts +2 -2
- package/dist/engine/boot-validator/screens.js +138 -36
- package/dist/engine/boot-validator/transfer-graph.js +9 -6
- package/dist/engine/boot-validator/url-prefill-fields.js +8 -6
- package/dist/engine/boot-validator/zod-shape.js +17 -4
- package/dist/engine/build-app-schema.js +65 -19
- package/dist/engine/build-config-feature-schema.js +106 -22
- package/dist/engine/changeset-changes.js +24 -10
- package/dist/engine/config-helpers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +7 -2
- package/dist/engine/feature-ast/extractors/events.js +11 -8
- package/dist/engine/feature-ast/extractors/handlers.js +38 -16
- package/dist/engine/feature-ast/extractors/resolve-import.d.ts +7 -2
- package/dist/engine/feature-ast/extractors/resolve-import.js +42 -15
- package/dist/engine/feature-ast/parse.d.ts +1 -0
- package/dist/engine/feature-ast/parse.js +18 -3
- package/dist/engine/feature-ast/patch.js +80 -61
- package/dist/engine/feature-ast/patcher.d.ts +1 -0
- package/dist/engine/feature-ast/patcher.js +2 -1
- package/dist/engine/feature-ast/render.js +1 -1
- package/dist/engine/feature-entity-handlers.js +22 -0
- package/dist/engine/field-access.d.ts +2 -0
- package/dist/engine/field-access.js +40 -0
- package/dist/engine/index.d.ts +5 -4
- package/dist/engine/index.js +4 -3
- package/dist/engine/ownership.d.ts +1 -0
- package/dist/engine/ownership.js +15 -2
- package/dist/engine/pattern-library/mixed-schemas.js +15 -29
- package/dist/engine/pattern-library/shared-fields.d.ts +2 -0
- package/dist/engine/pattern-library/shared-fields.js +17 -0
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/role-assignment.d.ts +7 -1
- package/dist/engine/role-assignment.js +27 -7
- package/dist/engine/schema-builder.d.ts +2 -0
- package/dist/engine/schema-builder.js +20 -3
- package/dist/engine/screen-helpers.js +15 -4
- package/dist/engine/steps/_event-match.js +22 -3
- package/dist/engine/steps/_read-source.js +8 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/engine/where-rule-lint.js +22 -4
- package/dist/env/index.js +21 -1
- package/dist/errors/reasons.d.ts +0 -1
- package/dist/errors/reasons.js +2 -5
- package/dist/event-store/event-store.js +6 -1
- package/dist/event-store/provenance-append.js +4 -1
- 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.d.ts +0 -1
- package/dist/files/file-routes.js +22 -7
- package/dist/files/local-provider.js +13 -10
- package/dist/files/provider-resolver.js +20 -1
- package/dist/files/types.js +16 -1
- package/dist/i18n/request-locale.d.ts +3 -2
- package/dist/i18n/request-locale.js +16 -6
- package/dist/i18n/required-surface-keys.js +28 -35
- package/dist/i18n/settings-hub-keys.js +21 -4
- package/dist/jobs/index.d.ts +2 -2
- package/dist/jobs/index.js +1 -1
- package/dist/jobs/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +150 -59
- package/dist/migrations/index.d.ts +1 -1
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/pending-rebuilds.d.ts +4 -0
- package/dist/migrations/pending-rebuilds.js +23 -1
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +3 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/pipeline/dispatch-batch.js +8 -3
- package/dist/pipeline/dispatch-query.d.ts +1 -1
- package/dist/pipeline/dispatch-query.js +2 -2
- package/dist/pipeline/dispatch-shared.d.ts +2 -3
- package/dist/pipeline/dispatch-shared.js +14 -9
- package/dist/pipeline/dispatch-stream.js +2 -5
- package/dist/pipeline/dispatch-write.js +27 -7
- package/dist/pipeline/dispatcher-utils.js +4 -1
- package/dist/pipeline/dispatcher.js +1 -2
- package/dist/pipeline/event-dispatcher-admin.js +12 -2
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +26 -11
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/member-read-only-transaction.js +1 -14
- package/dist/pipeline/msp-rebuild.d.ts +1 -0
- package/dist/pipeline/msp-rebuild.js +1 -0
- package/dist/pipeline/system-hooks.d.ts +1 -1
- package/dist/pipeline/system-hooks.js +9 -7
- package/dist/pipeline/tenant-timezone-cache.d.ts +2 -0
- package/dist/pipeline/tenant-timezone-cache.js +20 -6
- package/dist/pipeline/write-result-masking.d.ts +6 -0
- package/dist/pipeline/write-result-masking.js +36 -0
- package/dist/redis/pubsub-signal.d.ts +2 -0
- package/dist/redis/pubsub-signal.js +2 -2
- package/dist/schema-cli.js +4 -14
- package/dist/search/meilisearch-adapter.js +17 -6
- package/dist/search/purge-subject.js +8 -3
- package/dist/stack/drain-jobs.js +24 -12
- package/dist/stack/table-helpers.js +10 -8
- package/dist/testing/closed-connection-error.js +2 -3
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/testing/wait-for.js +1 -0
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.d.ts +1 -0
- package/dist/upgrade-cli.js +36 -15
- package/package.json +4 -4
- package/src/changes.json +218 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +14 -2
- package/src/scripts/codemod/migrate-db-raw.ts +16 -8
- package/src/scripts/codemod/migrate-open-to-all.ts +7 -1
- package/src/scripts/codemod/pii-personal-migration.ts +56 -19
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +83 -26
- package/dist/scripts/codemod/crypto-shredding-testing-move.d.ts +0 -2
- package/dist/scripts/codemod/crypto-shredding-testing-move.js +0 -109
- package/dist/scripts/codemod/migrate-cross-tenant.d.ts +0 -19
- package/dist/scripts/codemod/migrate-cross-tenant.js +0 -266
- package/dist/scripts/codemod/migrate-db-raw.d.ts +0 -23
- package/dist/scripts/codemod/migrate-db-raw.js +0 -345
- package/dist/scripts/codemod/migrate-open-to-all.d.ts +0 -22
- package/dist/scripts/codemod/migrate-open-to-all.js +0 -194
- package/dist/scripts/codemod/pii-personal-migration.d.ts +0 -11
- package/dist/scripts/codemod/pii-personal-migration.js +0 -615
- package/dist/scripts/codemod/unprocessable-error-details-reason.d.ts +0 -2
- package/dist/scripts/codemod/unprocessable-error-details-reason.js +0 -235
package/dist/migrations/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
|
|
2
2
|
export { assertKumikoSchemaCurrent, detectKumikoDrift, formatKumikoDriftReport, SchemaDriftError, } from "./kumiko-drift.js";
|
|
3
3
|
// Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
|
|
4
|
-
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
4
|
+
export { createPendingRebuildsTable, enqueueProjectionRebuild, listPendingRebuilds, PROJECTION_REBUILD_JOB, pendingRebuildsTable, queueRebuildsFromMarkers, rebuildProjectionOrMultiStream, reportPendingRebuildRun, runPendingRebuilds, } from "./pending-rebuilds.js";
|
|
5
5
|
// Boot-time backfill: re-encrypts plaintext events after a PII annotation was added.
|
|
6
6
|
export { createPiiBackfillStateTable, piiAnnotationFingerprint, piiBackfillStateTable, runPiiEventBackfill, } from "./pii-event-backfill.js";
|
|
7
7
|
// tableName → projection-name, für den app-seitigen Projection-Rebuild.
|
|
@@ -57,6 +57,10 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
|
|
|
57
57
|
readonly skipApplyErrors?: boolean;
|
|
58
58
|
};
|
|
59
59
|
}): Promise<RebuildResult>;
|
|
60
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
61
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
62
|
+
* report and the error text cannot drift apart. */
|
|
63
|
+
export declare function reportPendingRebuildRun(run: PendingRebuildRun, log: (line: string) => void): void;
|
|
60
64
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
61
65
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
62
66
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -71,7 +71,12 @@ export async function clearPendingRebuilds(db, rows) {
|
|
|
71
71
|
export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
72
72
|
const { db, registry, signal, errorPolicy } = deps;
|
|
73
73
|
if (registry.getAllMultiStreamProjections().has(name)) {
|
|
74
|
-
return rebuildMultiStreamProjection(name, {
|
|
74
|
+
return rebuildMultiStreamProjection(name, {
|
|
75
|
+
db,
|
|
76
|
+
registry,
|
|
77
|
+
markDeadOnFailure: false,
|
|
78
|
+
...(signal && { signal }),
|
|
79
|
+
});
|
|
75
80
|
}
|
|
76
81
|
return rebuildProjection(name, {
|
|
77
82
|
db,
|
|
@@ -80,6 +85,23 @@ export async function rebuildProjectionOrMultiStream(name, deps) {
|
|
|
80
85
|
...(errorPolicy && { errorPolicy }),
|
|
81
86
|
});
|
|
82
87
|
}
|
|
88
|
+
/** Logs a finished queue drain and throws when any projection failed; the
|
|
89
|
+
* failed tables stay queued. Shared by every `schema apply` entry point so the
|
|
90
|
+
* report and the error text cannot drift apart. */
|
|
91
|
+
export function reportPendingRebuildRun(run, log) {
|
|
92
|
+
if (run.rebuilt.length > 0) {
|
|
93
|
+
log(` Rebuild ${run.rebuilt.length} Projection(s)…`);
|
|
94
|
+
for (const r of run.rebuilt) {
|
|
95
|
+
log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
|
|
96
|
+
}
|
|
97
|
+
log("");
|
|
98
|
+
}
|
|
99
|
+
if (run.failed.length > 0) {
|
|
100
|
+
throw new Error(`Projection rebuild failed for: ${run.failed
|
|
101
|
+
.map((f) => `${f.projection} (${f.error})`)
|
|
102
|
+
.join("; ")}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
83
105
|
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
84
106
|
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
85
107
|
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
@@ -20,4 +20,5 @@ export declare function createEscapeHatchReporter(opts: {
|
|
|
20
20
|
readonly log?: Logger;
|
|
21
21
|
readonly window?: EscapeHatchReportWindow;
|
|
22
22
|
}): EscapeHatchReporter;
|
|
23
|
+
export declare const UNATTRIBUTED_ACTOR = "system";
|
|
23
24
|
export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
|
|
@@ -83,6 +83,9 @@ export function createEscapeHatchReporter(opts) {
|
|
|
83
83
|
reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
// Actor for escape-hatch uses whose caller did not thread one through. Stays
|
|
87
|
+
// "system" so audit events persisted before the constant existed remain valid.
|
|
88
|
+
export const UNATTRIBUTED_ACTOR = "system";
|
|
86
89
|
export function fallbackEscapeHatchReporter(tenantId) {
|
|
87
90
|
return createEscapeHatchReporter({
|
|
88
91
|
handler: "<unattributed>",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
|
|
2
2
|
export { observabilityContext } from "./context.js";
|
|
3
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
3
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
4
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
5
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public surface of the observability module.
|
|
2
2
|
export { createConsoleProvider } from "./console-provider.js";
|
|
3
3
|
export { observabilityContext } from "./context.js";
|
|
4
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -7,6 +7,7 @@ import { resolveDbSource } from "./dispatch-shared.js";
|
|
|
7
7
|
import { executeNestedWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
9
|
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin.js";
|
|
10
|
+
import { maskBatchResultForClient } from "./write-result-masking.js";
|
|
10
11
|
// afterCommit hooks fire in flushAfterCommit, outside the command's scope.
|
|
11
12
|
function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
|
|
12
13
|
for (let i = fromIndex; i < afterCommitHooks.length; i++) {
|
|
@@ -51,20 +52,24 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
51
52
|
idempotencyToken = checked.token;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
// Hooks already ran on the plaintext results; what is returned and cached
|
|
56
|
+
// for retries must not carry writeOnly values.
|
|
57
|
+
const maskForClient = (result) => maskBatchResultForClient(ctx.registry, commands, result);
|
|
54
58
|
// Cache the result under requestId so retries get the same answer. Only a
|
|
55
59
|
// provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
|
|
56
60
|
const finalize = async (result) => {
|
|
61
|
+
const masked = maskForClient(result);
|
|
57
62
|
if (requestId && idempotency && idempotencyToken) {
|
|
58
|
-
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken,
|
|
63
|
+
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, masked);
|
|
59
64
|
}
|
|
60
|
-
return
|
|
65
|
+
return masked;
|
|
61
66
|
};
|
|
62
67
|
// Never for the no-tx fallback: without a rollback, a re-run would repeat
|
|
63
68
|
// the side effects of the commands that already ran.
|
|
64
69
|
const releaseOrFinalize = async (result, isRetryableRollback) => {
|
|
65
70
|
if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
|
|
66
71
|
await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
|
|
67
|
-
return result;
|
|
72
|
+
return maskForClient(result);
|
|
68
73
|
}
|
|
69
74
|
return finalize(result);
|
|
70
75
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { DbTx } from "../db/connection.js";
|
|
2
2
|
import type { SessionUser } from "../engine/types/index.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type DispatchContext, type WriteOrigin } from "./dispatch-shared.js";
|
|
4
4
|
export declare function executeQuery(ctx: DispatchContext, type: string, payload: unknown, user: SessionUser, origin: WriteOrigin, tx?: DbTx): Promise<unknown>;
|
|
@@ -2,7 +2,7 @@ import { hasAccess } from "../engine/access.js";
|
|
|
2
2
|
import { filterReadFields } from "../engine/field-access.js";
|
|
3
3
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
|
|
4
4
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
5
|
-
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
5
|
+
import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
|
|
6
6
|
import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
|
|
7
7
|
// Standalone query execution — used by the public dispatcher.query() and
|
|
8
8
|
// by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
|
|
@@ -51,7 +51,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
|
|
|
51
51
|
parsed.data !== null &&
|
|
52
52
|
parsed.data["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
53
53
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
54
|
-
return user
|
|
54
|
+
return isMemberResolutionPrincipal(user)
|
|
55
55
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) => runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx))
|
|
56
56
|
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
|
|
57
57
|
}
|
|
@@ -6,8 +6,7 @@ import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extensio
|
|
|
6
6
|
import type { TenantId } from "../engine/types/identifiers.js";
|
|
7
7
|
import type { AppContext, HandlerContext, JobRunnerRef, RateLimitDeclaration, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
8
8
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
9
|
-
import { type EscapeHatchReportWindow } from "../observability/
|
|
10
|
-
import { type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
9
|
+
import { type EscapeHatchReportWindow, type getFallbackMeter, getFallbackTracer } from "../observability/index.js";
|
|
11
10
|
import { type AfterCommitHook } from "./dispatcher-utils.js";
|
|
12
11
|
import type { IdempotencyGuard } from "./idempotency.js";
|
|
13
12
|
import type { LifecycleHooks } from "./lifecycle-pipeline.js";
|
|
@@ -49,7 +48,7 @@ export type DispatchContext = {
|
|
|
49
48
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
50
49
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
51
50
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
52
|
-
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean): Promise<HandlerContext>;
|
|
51
|
+
export declare function buildHandlerContext(ctx: DispatchContext, type: string, user: SessionUser, origin: WriteOrigin, tx?: DbTx, afterCommitHooks?: AfterCommitHook[], includeDeleted?: boolean, isPostCommit?: boolean): Promise<HandlerContext>;
|
|
53
52
|
export declare function runHandlerInstrumented<T>(ctx: DispatchContext, type: string, operation: "query" | "write" | "stream", user: SessionUser, inner: () => Promise<T>): Promise<T>;
|
|
54
53
|
export declare function runStreamInstrumented<T>(ctx: DispatchContext, type: string, user: SessionUser, inner: () => AsyncGenerator<T>): AsyncGenerator<T>;
|
|
55
54
|
export declare function checkFeatureEnabled(ctx: DispatchContext, qualifiedHandler: string, tenantId: TenantId): Promise<import("../errors/index.js").FeatureDisabledError | undefined>;
|
|
@@ -11,13 +11,13 @@ import { loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapsho
|
|
|
11
11
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
|
|
12
12
|
import { createFileContext } from "../files/file-handle.js";
|
|
13
13
|
import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
|
|
14
|
-
import { createEscapeHatchReporter, } from "../observability/
|
|
15
|
-
import { createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
14
|
+
import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
|
|
16
15
|
import { buildBucketKey } from "../rate-limit/index.js";
|
|
17
16
|
import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
|
|
18
17
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
|
|
19
18
|
import { appendDomainEventCore } from "./append-event-core.js";
|
|
20
19
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver.js";
|
|
20
|
+
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
21
21
|
import { executeQuery } from "./dispatch-query.js";
|
|
22
22
|
import { executeWrite } from "./dispatch-write.js";
|
|
23
23
|
import { dispatcherSpanAttributes, isFailedWriteResult, } from "./dispatcher-utils.js";
|
|
@@ -135,7 +135,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
135
135
|
...(handlerContext.notify && { notify: denyMemberResolutionWrite }),
|
|
136
136
|
};
|
|
137
137
|
}
|
|
138
|
-
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted) {
|
|
138
|
+
export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, isPostCommit) {
|
|
139
139
|
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
140
140
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
141
141
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -241,6 +241,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
241
241
|
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
242
242
|
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
243
243
|
const bridgeSink = afterCommitHooks ?? [];
|
|
244
|
+
// Post-commit hooks have no transaction left, so a bare executeWrite would
|
|
245
|
+
// auto-commit event append, projections and inTransaction hooks separately
|
|
246
|
+
// and its afterCommit hooks would land in an already-flushed sink. A
|
|
247
|
+
// batch-of-one gives the write its own transaction and its own flush.
|
|
248
|
+
const writeAsUser = async (asUser, targetType, payload) => isPostCommit
|
|
249
|
+
? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
|
|
250
|
+
: executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink);
|
|
244
251
|
const scheduleAfterCommit = (hook) => {
|
|
245
252
|
bridgeSink.push(hook);
|
|
246
253
|
};
|
|
@@ -248,7 +255,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
248
255
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
249
256
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
250
257
|
queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
|
|
251
|
-
writeAs:
|
|
258
|
+
writeAs: writeAsUser,
|
|
252
259
|
}, identitySwitchAudit);
|
|
253
260
|
// Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
|
|
254
261
|
// on first actual use, not on every HandlerContext build.
|
|
@@ -289,10 +296,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
289
296
|
const bridge = {
|
|
290
297
|
query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
291
298
|
queryAs: identitySwitch.queryAs,
|
|
292
|
-
write:
|
|
293
|
-
const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
|
|
294
|
-
return res;
|
|
295
|
-
},
|
|
299
|
+
write: (targetType, payload) => writeAsUser(user, targetType, payload),
|
|
296
300
|
writeAs: identitySwitch.writeAs,
|
|
297
301
|
// Strict + unsafe share the same runtime — only the type-surface
|
|
298
302
|
// differs. The strict signature is what's exposed to typed callers;
|
|
@@ -545,9 +549,10 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
545
549
|
tenantTz = cachedTz.value;
|
|
546
550
|
}
|
|
547
551
|
else {
|
|
552
|
+
const generationAtRead = ctx.tenantTimezoneCache.generation();
|
|
548
553
|
tenantTz = await config(TENANT_TIMEZONE_CONFIG_KEY);
|
|
549
554
|
if (tx === undefined) {
|
|
550
|
-
ctx.tenantTimezoneCache.
|
|
555
|
+
ctx.tenantTimezoneCache.setIfFresh(user.tenantId, typeof tenantTz === "string" ? tenantTz : undefined, generationAtRead);
|
|
551
556
|
}
|
|
552
557
|
}
|
|
553
558
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
|
|
1
2
|
import { hasAccess } from "../engine/access.js";
|
|
2
3
|
import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
|
|
3
4
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
@@ -39,13 +40,9 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
|
|
|
39
40
|
const invalidated = new Promise((resolve) => {
|
|
40
41
|
resolveInvalidated = resolve;
|
|
41
42
|
});
|
|
42
|
-
const streamCredential = {
|
|
43
|
-
...(user.sid !== undefined && { sid: user.sid }),
|
|
44
|
-
...(user.pat?.tokenId !== undefined && { patTokenId: user.pat.tokenId }),
|
|
45
|
-
};
|
|
46
43
|
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
47
44
|
resolveInvalidated?.();
|
|
48
|
-
},
|
|
45
|
+
}, accessInvalidationCredentialFor(user));
|
|
49
46
|
let iterator;
|
|
50
47
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
51
48
|
// Awaiting `iterator.return()` in that state deadlocks async generators in
|
|
@@ -5,7 +5,7 @@ import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
|
|
|
5
5
|
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
6
6
|
import { hasAccess } from "../engine/access.js";
|
|
7
7
|
import { ConfigScopes } from "../engine/constants.js";
|
|
8
|
-
import { checkWriteFieldOwnership, checkWriteFieldRoles } from "../engine/field-access.js";
|
|
8
|
+
import { checkWriteFieldOwnership, checkWriteFieldRoles, maskWriteOnlyFields, } from "../engine/field-access.js";
|
|
9
9
|
import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
10
10
|
import { HookPhases } from "../engine/types/index.js";
|
|
11
11
|
import { runValidation } from "../engine/validation.js";
|
|
@@ -97,7 +97,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
97
97
|
if (result.kind === "save") {
|
|
98
98
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
99
99
|
afterCommitHooks.push(async () => {
|
|
100
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined,
|
|
100
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
|
|
101
101
|
await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
102
102
|
});
|
|
103
103
|
}
|
|
@@ -105,7 +105,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
105
105
|
await lifecycle.runPreDelete(type, result, handlerContext);
|
|
106
106
|
await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
|
|
107
107
|
afterCommitHooks.push(async () => {
|
|
108
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined,
|
|
108
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, true);
|
|
109
109
|
await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
110
110
|
});
|
|
111
111
|
}
|
|
@@ -115,8 +115,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
115
115
|
//
|
|
116
116
|
// Contract:
|
|
117
117
|
// - `tx` is the active Drizzle transaction handle, or undefined for "no
|
|
118
|
-
// surrounding transaction" —
|
|
119
|
-
// transaction already committed (afterCommit phase).
|
|
118
|
+
// surrounding transaction" — no Postgres is configured.
|
|
120
119
|
// - `afterCommitHooks` collects deferred side-effects that must only fire
|
|
121
120
|
// after the transaction commits. The caller flushes them on commit, drops
|
|
122
121
|
// them on rollback. executeWrite never fires them directly.
|
|
@@ -145,6 +144,23 @@ async function isParentRowHiddenFromCaller(ctx, parentEntityName, parentId, user
|
|
|
145
144
|
return true;
|
|
146
145
|
return hasTenantColumn(table) && visibleRow["tenantId"] !== user.tenantId;
|
|
147
146
|
}
|
|
147
|
+
// A custom create handler may return a partial row. The ownership check only
|
|
148
|
+
// inspects the keys it is given, so a row missing the ownership-bound field
|
|
149
|
+
// would be denied (rule evaluated against undefined) or skipped entirely.
|
|
150
|
+
// Complete it from the stored row, read through the caller's tenant filter.
|
|
151
|
+
async function completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx) {
|
|
152
|
+
if (Object.keys(parentEntity.fields).every((fieldName) => fieldName in parentRow)) {
|
|
153
|
+
return parentRow;
|
|
154
|
+
}
|
|
155
|
+
const table = getTable(ctx, parentEntityName);
|
|
156
|
+
const source = resolveDbSource(ctx, tx);
|
|
157
|
+
if (!table || !source || typeof parentRow["id"] !== "string")
|
|
158
|
+
return parentRow;
|
|
159
|
+
const stored = await createTenantDb(source, user.tenantId, "tenant").fetchOne(table, {
|
|
160
|
+
id: parentRow["id"],
|
|
161
|
+
});
|
|
162
|
+
return stored ? { ...stored, ...parentRow } : parentRow;
|
|
163
|
+
}
|
|
148
164
|
// Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
|
|
149
165
|
//
|
|
150
166
|
// When a parent `:create` handler's payload carries values under keys
|
|
@@ -233,7 +249,8 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
233
249
|
if (parentEntityName) {
|
|
234
250
|
const parentEntity = registry.getEntity(parentEntityName);
|
|
235
251
|
if (parentEntity) {
|
|
236
|
-
const
|
|
252
|
+
const ownershipRow = await completeParentRow(ctx, parentEntityName, parentEntity, parentRow, user, tx);
|
|
253
|
+
const deniedField = checkWriteFieldOwnership(parentEntity, ownershipRow, user);
|
|
237
254
|
if (deniedField) {
|
|
238
255
|
return writeFailure(new AccessDeniedError({
|
|
239
256
|
message: `nested-write: parent row ownership check failed on field "${deniedField}" of "${type}" — refusing to attach children to a parent row the caller does not own`,
|
|
@@ -262,7 +279,10 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
262
279
|
}
|
|
263
280
|
const subWrapper = subResult.data; // @cast-boundary engine-payload
|
|
264
281
|
const subRow = (subWrapper["data"] ?? subWrapper); // @cast-boundary engine-payload
|
|
265
|
-
|
|
282
|
+
const subEntityName = registry.getHandlerEntity(spec.subType);
|
|
283
|
+
const subEntity = subEntityName ? registry.getEntity(subEntityName) : undefined;
|
|
284
|
+
// parentRow is returned to the client; the child's own SaveContext keeps the plaintext.
|
|
285
|
+
subRows.push(subEntity ? maskWriteOnlyFields(subEntity, subRow) : subRow);
|
|
266
286
|
}
|
|
267
287
|
parentRow[spec.key] = subRows;
|
|
268
288
|
}
|
|
@@ -4,8 +4,11 @@ import { VersionConflictError as EventStoreVersionConflictError } from "../event
|
|
|
4
4
|
export function isFailedWriteResult(result) {
|
|
5
5
|
return (!!result && typeof result === "object" && "isSuccess" in result && result.isSuccess === false);
|
|
6
6
|
}
|
|
7
|
+
const LIFECYCLE_KINDS = ["save", "delete"];
|
|
7
8
|
export function isLifecycleResult(data) {
|
|
8
|
-
|
|
9
|
+
if (!data || typeof data !== "object" || !("kind" in data))
|
|
10
|
+
return false;
|
|
11
|
+
return LIFECYCLE_KINDS.some((kind) => kind === data.kind);
|
|
9
12
|
}
|
|
10
13
|
export function isWriteResultShape(result) {
|
|
11
14
|
return (!!result &&
|
|
@@ -2,8 +2,7 @@ import { runWithWriteOrigin } from "../api/request-context.js";
|
|
|
2
2
|
import { DEPRECATED_CROSS_TENANT_SIGNAL, isDeprecatedCrossTenantHandler, } from "../engine/entity-handlers.js";
|
|
3
3
|
import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
|
|
4
4
|
import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
|
|
5
|
-
import { createEscapeHatchReportWindow } from "../observability/
|
|
6
|
-
import { getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
5
|
+
import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
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";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { advanceConsumerPastEventReturning, removePendingGapReturning, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
|
|
1
|
+
import { advanceConsumerPastEventReturning, removePendingGapReturning, selectConsumerForUpdate, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
|
|
2
2
|
import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
|
|
3
3
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
|
|
4
4
|
import { getEventsHighWaterMark } from "../event-store/index.js";
|
|
@@ -50,6 +50,14 @@ async function requireConsumerRow(db, name, instanceId) {
|
|
|
50
50
|
}
|
|
51
51
|
return row;
|
|
52
52
|
}
|
|
53
|
+
async function requireConsumerRowForUpdate(tx, name, instanceId) {
|
|
54
|
+
const raw = await selectConsumerForUpdate(tx, name, instanceId);
|
|
55
|
+
if (!raw) {
|
|
56
|
+
throw new Error(`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
|
|
57
|
+
`For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
|
|
58
|
+
}
|
|
59
|
+
return coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
60
|
+
}
|
|
53
61
|
async function applyConsumerStatusTransition(db, name, instanceId, targetStatus) {
|
|
54
62
|
const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
|
|
55
63
|
const updated = raw && coerceRow(raw, extractTableInfo(eventConsumerStateTable));
|
|
@@ -84,8 +92,10 @@ export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENT
|
|
|
84
92
|
// event after the cursor. Single TX so concurrent dispatcher passes can't
|
|
85
93
|
// double-advance. Nothing to skip → idempotent no-op.
|
|
86
94
|
export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
|
|
87
|
-
const before = await requireConsumerRow(db, name, instanceId);
|
|
88
95
|
return db.begin(async (tx) => {
|
|
96
|
+
// Locked read: a dispatcher turn committing new gaps between an unlocked
|
|
97
|
+
// read and the pending_gaps overwrite below would have them dropped.
|
|
98
|
+
const before = await requireConsumerRowForUpdate(tx, name, instanceId);
|
|
89
99
|
const pendingGaps = before.pendingGaps;
|
|
90
100
|
const failedEventId = before.lastFailedEventId;
|
|
91
101
|
const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
|
|
@@ -31,6 +31,7 @@ export type EventDispatcher = {
|
|
|
31
31
|
runOnce(): Promise<DispatcherPassResult>;
|
|
32
32
|
ensureRegistered(): Promise<void>;
|
|
33
33
|
drain(): Promise<void>;
|
|
34
|
+
withBackgroundPassesPaused<T>(fn: () => Promise<T>): Promise<T>;
|
|
34
35
|
readonly consumers: readonly EventConsumer[];
|
|
35
36
|
};
|
|
36
37
|
export type EventDispatcherOptions = {
|
|
@@ -170,6 +170,15 @@ export function createEventDispatcher(options) {
|
|
|
170
170
|
// skip: errors already recorded per-consumer inside the pass
|
|
171
171
|
})));
|
|
172
172
|
}
|
|
173
|
+
let backgroundPassPauseDepth = 0;
|
|
174
|
+
function runBackgroundPass() {
|
|
175
|
+
// skip: background passes are paused
|
|
176
|
+
if (backgroundPassPauseDepth > 0)
|
|
177
|
+
return;
|
|
178
|
+
void runOnce().catch(() => {
|
|
179
|
+
// skip: per-consumer errors already recorded in the state row
|
|
180
|
+
});
|
|
181
|
+
}
|
|
173
182
|
async function runOnce() {
|
|
174
183
|
if (!preRegistered) {
|
|
175
184
|
throw new Error("EventDispatcher.runOnce() called before start() — consumer state rows are not registered. Call start() first (production) or ensureRegistered() (tests after truncating kumiko_event_consumers).");
|
|
@@ -445,11 +454,7 @@ export function createEventDispatcher(options) {
|
|
|
445
454
|
// any consumer that exists, including freshly-deployed ones.
|
|
446
455
|
await preRegisterConsumers(db, consumers, options.instanceId);
|
|
447
456
|
preRegistered = true;
|
|
448
|
-
timer = setInterval(
|
|
449
|
-
void runOnce().catch(() => {
|
|
450
|
-
// skip: per-consumer errors already recorded in the state row
|
|
451
|
-
});
|
|
452
|
-
}, pollIntervalMs);
|
|
457
|
+
timer = setInterval(runBackgroundPass, pollIntervalMs);
|
|
453
458
|
// NOTIFY-based wake-up: subscribe on the same channel that
|
|
454
459
|
// event-store.append fires on commit. Fires runOnce directly, no
|
|
455
460
|
// polling round-trip. The timer stays on as a belt-and-braces
|
|
@@ -464,11 +469,7 @@ export function createEventDispatcher(options) {
|
|
|
464
469
|
emitEventDispatcherListenConnected(meter, false);
|
|
465
470
|
if (options.pgClient) {
|
|
466
471
|
try {
|
|
467
|
-
const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, () => {
|
|
468
|
-
void runOnce().catch(() => {
|
|
469
|
-
// skip: per-consumer errors already recorded in the state row
|
|
470
|
-
});
|
|
471
|
-
}, () => {
|
|
472
|
+
const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
|
|
472
473
|
// Fires on initial connect and again when postgres.js's own
|
|
473
474
|
// re-LISTEN lands after a drop; during an outage that re-LISTEN
|
|
474
475
|
// waits in its connection backoff (measured 10-30s after the DB
|
|
@@ -517,7 +518,21 @@ export function createEventDispatcher(options) {
|
|
|
517
518
|
await preRegisterConsumers(db, consumers, options.instanceId);
|
|
518
519
|
preRegistered = true;
|
|
519
520
|
},
|
|
520
|
-
drain
|
|
521
|
+
async drain() {
|
|
522
|
+
await drainInFlightPasses();
|
|
523
|
+
await drainInFlightTurns();
|
|
524
|
+
},
|
|
525
|
+
async withBackgroundPassesPaused(fn) {
|
|
526
|
+
backgroundPassPauseDepth++;
|
|
527
|
+
try {
|
|
528
|
+
await drainInFlightPasses();
|
|
529
|
+
await drainInFlightTurns();
|
|
530
|
+
return await fn();
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
backgroundPassPauseDepth--;
|
|
534
|
+
}
|
|
535
|
+
},
|
|
521
536
|
runOnce,
|
|
522
537
|
};
|
|
523
538
|
}
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
|
|
2
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, } from "../observability/escape-hatch-report.js";
|
|
2
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
3
3
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
|
|
4
4
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
5
5
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, } from "../observability/escape-hatch-report.js";
|
|
1
|
+
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
3
3
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
4
4
|
export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
|
|
@@ -3,21 +3,8 @@ import { asRawClient, runInSavepoint, transaction } from "../db/query.js";
|
|
|
3
3
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
|
|
4
4
|
import { resolveDbSource } from "./dispatch-shared.js";
|
|
5
5
|
const PG_READ_ONLY_SQLSTATE = "25006";
|
|
6
|
-
// Bun.SQL surfaces the PG SQLSTATE as `errno` (code stays "ERR_POSTGRES_SERVER_ERROR");
|
|
7
|
-
// postgres.js surfaces it as `code`, possibly one `.cause` layer down (DrizzleQueryError-style wrapping).
|
|
8
6
|
function isReadOnlyTransactionViolation(e) {
|
|
9
|
-
|
|
10
|
-
return true;
|
|
11
|
-
if (typeof e !== "object" || e === null)
|
|
12
|
-
return false;
|
|
13
|
-
// @cast-boundary error-details — Bun.SQL error shape (errno)
|
|
14
|
-
if (e.errno === PG_READ_ONLY_SQLSTATE)
|
|
15
|
-
return true;
|
|
16
|
-
// @cast-boundary error-details — Bun.SQL error shape (errno) under .cause
|
|
17
|
-
const cause = e.cause;
|
|
18
|
-
if (typeof cause !== "object" || cause === null)
|
|
19
|
-
return false;
|
|
20
|
-
return cause.errno === PG_READ_ONLY_SQLSTATE;
|
|
7
|
+
return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
|
|
21
8
|
}
|
|
22
9
|
async function runInDiscardedReadOnlySavepoint(tx, fn) {
|
|
23
10
|
const discardMarker = {};
|
|
@@ -8,5 +8,6 @@ export type MspRebuildDeps = {
|
|
|
8
8
|
readonly meter?: Meter;
|
|
9
9
|
readonly onMetrics?: (result: RebuildResult) => void;
|
|
10
10
|
readonly markDeadOnFailure?: boolean;
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
11
12
|
};
|
|
12
13
|
export declare function rebuildMultiStreamProjection(mspName: string, deps: MspRebuildDeps): Promise<RebuildResult>;
|
|
@@ -71,6 +71,7 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
|
|
|
71
71
|
const events = await selectMany(tx, eventsTable, { type: [...subscribedTypes], id: { lte: horizon } }, { orderBy: { col: "id", direction: "asc" } });
|
|
72
72
|
const upcasters = registry.getEventUpcasters();
|
|
73
73
|
for (const row of events) {
|
|
74
|
+
deps.signal?.throwIfAborted();
|
|
74
75
|
const raw = {
|
|
75
76
|
id: String(row.id),
|
|
76
77
|
aggregateId: row.aggregateId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type SseBroker } from "../api/sse-broker.js";
|
|
2
2
|
import type { EntityId, JobRunnerRef, Registry, TenantId } from "../engine/types/index.js";
|
|
3
3
|
import type { SearchAdapter, SearchDocument } from "../search/types.js";
|
|
4
4
|
import type { EventConsumer } from "./event-dispatcher.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { readNonEmptyStringArray, } from "../api/sse-broker.js";
|
|
1
2
|
import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
|
|
2
3
|
import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
|
|
3
4
|
import { tenantChannel } from "../engine/constants.js";
|
|
5
|
+
import { InternalError } from "../errors/index.js";
|
|
4
6
|
// --- Search Index Consumer (async, via event-dispatcher) ---
|
|
5
7
|
//
|
|
6
8
|
// Search-Indexierung läuft seit D.4 als async EventConsumer über den event-
|
|
@@ -187,8 +189,13 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
|
|
|
187
189
|
// DbConnection: event-dispatcher creation is gated on it being set
|
|
188
190
|
// (api/server.ts), never a HandlerContext's already-tenant-scoped TenantDb.
|
|
189
191
|
const baseDb = ctx.db;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
// undefined must keep meaning "no live row" (the caller removes the index
|
|
193
|
+
// entry), so a missing db fails the event for retry instead.
|
|
194
|
+
if (!baseDb) {
|
|
195
|
+
throw new InternalError({
|
|
196
|
+
message: `search consumer: no db on the consumer context — cannot read the live row of "${entityName}" ${entityId}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
192
199
|
const table = entityTableFromRegistry(registry, entityName, entity);
|
|
193
200
|
const where = { id: entityId };
|
|
194
201
|
if (entity.softDelete && table["isDeleted"]) {
|
|
@@ -511,11 +518,6 @@ function readUserIdFromPreviousSnapshot(payload) {
|
|
|
511
518
|
const userId = previous["userId"];
|
|
512
519
|
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
513
520
|
}
|
|
514
|
-
function readNonEmptyStringArray(value) {
|
|
515
|
-
if (!Array.isArray(value) || value.length === 0)
|
|
516
|
-
return undefined;
|
|
517
|
-
return value.every((v) => typeof v === "string") ? value : undefined;
|
|
518
|
-
}
|
|
519
521
|
// session-revoked's scope resolution, in order:
|
|
520
522
|
// 1. streamScope === "revoked-sessions" with a non-empty sessionIds array
|
|
521
523
|
// (only the raw sessionRevoker callback sets this) → narrow "sessions"
|
|
@@ -10,6 +10,8 @@ export type TenantTimezoneCacheEntry = {
|
|
|
10
10
|
export type TenantTimezoneCache = {
|
|
11
11
|
get(tenantId: TenantId): TenantTimezoneCacheEntry | undefined;
|
|
12
12
|
set(tenantId: TenantId, value: string | undefined): void;
|
|
13
|
+
generation(): number;
|
|
14
|
+
setIfFresh(tenantId: TenantId, value: string | undefined, generationAtRead: number): void;
|
|
13
15
|
invalidate(tenantId: TenantId): void;
|
|
14
16
|
clear(): void;
|
|
15
17
|
size(): number;
|