@cosmicdrift/kumiko-framework 0.385.0 → 0.387.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -57
- package/README.md +2 -2
- package/agents/widgets.md +39 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +34 -33
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +15 -15
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.js +4 -4
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +2 -2
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-context.d.ts +1 -1
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read.js +27 -28
- package/dist/db/event-store-executor-write.js +65 -70
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +26 -19
- package/dist/db/migrate-runner.js +25 -25
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +6 -6
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +82 -82
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +9 -2
- package/dist/derivatives/delete-stored-file.js +5 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +3 -3
- package/dist/engine/boot-validator/entity-handler.js +60 -61
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +1 -1
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +20 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +50 -57
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.js +15 -16
- package/dist/engine/boot-validator/screens-forms.js +10 -11
- package/dist/engine/boot-validator/screens-lists.js +14 -15
- package/dist/engine/boot-validator/screens.js +34 -40
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +75 -75
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +12 -11
- package/dist/engine/define-handler.js +12 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.js +13 -14
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +1 -1
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
- package/dist/engine/feature-ast/patterns.d.ts +2 -2
- package/dist/engine/feature-ast/render.js +2 -2
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +9 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +12 -12
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +40 -38
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +7 -27
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.js +3 -3
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +28 -28
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +6 -7
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +2 -2
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +5 -6
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +11 -5
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +8 -8
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +1 -2
- package/dist/jobs/job-runner-helpers.js +8 -18
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner.js +17 -18
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +10 -10
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +42 -19
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +3 -3
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-delivery.js +1 -1
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/projection-rebuild.js +13 -12
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli.js +116 -22
- package/dist/search/index.js +4 -4
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +10 -9
- package/dist/utils/compare.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/pii-personal-migration.ts +17 -8
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { requestContext } from "../api/request-context.js";
|
|
2
3
|
import { SYSTEM_EVENT_PREFIX } from "../crypto/system-event-pii.js";
|
|
3
4
|
import { qnScope, toKebab } from "../engine/qualified-name.js";
|
|
@@ -6,6 +7,14 @@ import { isStreamArchived } from "../event-store/archive.js";
|
|
|
6
7
|
import { ArchivedStreamError } from "../event-store/errors.js";
|
|
7
8
|
import { append, getStreamVersion } from "../event-store/event-store.js";
|
|
8
9
|
import { runProjectionsForEvent } from "./projections-runner.js";
|
|
10
|
+
function requirePayloadObject(payload, eventType) {
|
|
11
|
+
if (!isPlainObject(payload)) {
|
|
12
|
+
throw new InternalError({
|
|
13
|
+
message: `appendEvent("${eventType}"): payload must be an object, got ${Array.isArray(payload) ? "array" : typeof payload}`,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return payload;
|
|
17
|
+
}
|
|
9
18
|
export async function appendDomainEventCore(deps, args) {
|
|
10
19
|
const isSystemEvent = args.type.startsWith(SYSTEM_EVENT_PREFIX);
|
|
11
20
|
const eventDef = deps.registry.getEvent(args.type);
|
|
@@ -42,10 +51,10 @@ export async function appendDomainEventCore(deps, args) {
|
|
|
42
51
|
const parsed = eventDef.schema.safeParse(args.payload ?? {});
|
|
43
52
|
if (!parsed.success)
|
|
44
53
|
throw validationErrorFromZod(parsed.error);
|
|
45
|
-
validatedPayload = parsed.data;
|
|
54
|
+
validatedPayload = requirePayloadObject(parsed.data, args.type);
|
|
46
55
|
}
|
|
47
56
|
else {
|
|
48
|
-
validatedPayload = (args.payload ?? {});
|
|
57
|
+
validatedPayload = requirePayloadObject(args.payload ?? {}, args.type);
|
|
49
58
|
}
|
|
50
59
|
// Archive guard: block writes on archived streams. Without this an append
|
|
51
60
|
// would produce an "invisible" row that loadAggregate filters out by default
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TableColumns } from "../db/dialect.ts";
|
|
2
2
|
import type { PreDeleteHookFn, Registry } from "../engine/types/index.ts";
|
|
3
3
|
import type { SystemHookDef } from "./lifecycle-pipeline.ts";
|
|
4
|
-
type TableMap = ReadonlyMap<string, TableColumns
|
|
4
|
+
type TableMap = ReadonlyMap<string, TableColumns>;
|
|
5
5
|
export declare function createCascadeDeleteHook(registry: Registry, tables: TableMap): SystemHookDef<PreDeleteHookFn>;
|
|
6
6
|
export {};
|
|
@@ -3,7 +3,7 @@ import { transaction } from "../db/query.js";
|
|
|
3
3
|
import { InternalError, toWriteErrorInfo, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
4
4
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
5
|
import { parseJsonSafe } from "../utils/safe-json.js";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveAppPoolDb } from "./dispatch-shared.js";
|
|
7
7
|
import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
|
|
8
8
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
9
9
|
import { reserveBeforeTransaction, runAsCommand, } from "./pre-transaction-reservations.js";
|
|
@@ -209,7 +209,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
209
209
|
}
|
|
210
210
|
// batch() opens its own outer transaction — needs the top-level
|
|
211
211
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
212
|
-
const db =
|
|
212
|
+
const db = resolveAppPoolDb(ctx);
|
|
213
213
|
if (!db || runsWithoutTransaction) {
|
|
214
214
|
// Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
|
|
215
215
|
// there is no transaction to open. Fall back to
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
|
+
import { isPlainObject, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
2
|
import { hasAccess } from "../engine/access.js";
|
|
3
3
|
import { tenantOverrideDenied } from "../engine/cross-tenant.js";
|
|
4
4
|
import { filterReadFields } from "../engine/field-access.js";
|
|
@@ -60,16 +60,16 @@ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user
|
|
|
60
60
|
const { registry } = ctx;
|
|
61
61
|
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, undefined, includeDeleted, undefined, outerNotifySink);
|
|
62
62
|
let result = await handler.handler({ type, payload, user }, handlerContext);
|
|
63
|
-
// postQuery
|
|
64
|
-
//
|
|
65
|
-
// responsible for
|
|
66
|
-
//
|
|
63
|
+
// postQuery hooks run BEFORE field-access filtering so they see raw data and can
|
|
64
|
+
// merge custom fields, computed counts, tags, and similar values. Each hook is
|
|
65
|
+
// responsible for applying field access to values it adds; the filter below
|
|
66
|
+
// only knows the entity's standard fields.
|
|
67
67
|
//
|
|
68
|
-
// Two
|
|
68
|
+
// Two hook scopes run in this order:
|
|
69
69
|
// 1. Handler-keyed hooks via r.hook({ type: "postQuery", target: "ns:query:list", handler })
|
|
70
|
-
// —
|
|
70
|
+
// — run only for that handler
|
|
71
71
|
// 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
|
|
72
|
-
// —
|
|
72
|
+
// — run for all query handlers of the entity
|
|
73
73
|
const entityName = registry.getHandlerEntity(type);
|
|
74
74
|
// Handler-keyed postQuery hooks fire for any query (incl. entity-less
|
|
75
75
|
// standalone queries like "ns:dashboard"). Entity-keyed hooks only apply
|
|
@@ -87,7 +87,7 @@ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user
|
|
|
87
87
|
}
|
|
88
88
|
result = rows;
|
|
89
89
|
}
|
|
90
|
-
else if (Array.isArray(result
|
|
90
|
+
else if (isPlainObject(result) && Array.isArray(result["rows"])) {
|
|
91
91
|
// @cast-boundary engine-payload
|
|
92
92
|
const r = result;
|
|
93
93
|
let rows = r.rows;
|
|
@@ -121,7 +121,7 @@ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user
|
|
|
121
121
|
}
|
|
122
122
|
else {
|
|
123
123
|
const resultAsDbRow = result; // @cast-boundary engine-payload
|
|
124
|
-
if (Array.isArray(resultAsDbRow
|
|
124
|
+
if (Array.isArray(resultAsDbRow["rows"])) {
|
|
125
125
|
// generic handler-result shape narrow
|
|
126
126
|
const r = result; // @cast-boundary engine-payload
|
|
127
127
|
result = {
|
|
@@ -52,6 +52,7 @@ export type DispatchContext = {
|
|
|
52
52
|
};
|
|
53
53
|
export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
|
|
54
54
|
export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
|
|
55
|
+
export declare function resolveAppPoolDb(ctx: DispatchContext): DbConnection | undefined;
|
|
55
56
|
export declare function isMemberResolutionPrincipal(user: SessionUser): boolean;
|
|
56
57
|
export declare function applyMemberResolutionReadOnly(handlerContext: HandlerContext): HandlerContext;
|
|
57
58
|
export declare function perCommandNestedDenied(targetType: string): InternalError;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
import { requestContext, runWithOrigin } from "../api/request-context.js";
|
|
1
|
+
import { crossTenantReadsGrantFor, escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { isAgentToolCallScope, requestContext, runWithOrigin } from "../api/request-context.js";
|
|
3
|
+
import { createHandlerCrossTenantReads } from "../db/cross-tenant-reads.js";
|
|
3
4
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
4
5
|
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
|
|
5
6
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
@@ -41,13 +42,13 @@ export { TENANT_TIMEZONE_CONFIG_KEY };
|
|
|
41
42
|
export const CONFIG_WRITE_SET_TYPE = "config:write:set";
|
|
42
43
|
export const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
|
|
43
44
|
export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
44
|
-
// Narrowing
|
|
45
|
-
// dispatch
|
|
46
|
-
// appendEvent/projection
|
|
45
|
+
// Narrowing helper: AppContext.db is DbConnection|TenantDb|undefined. The
|
|
46
|
+
// dispatch paths need DbConnection (or DbTx from the caller scope) for
|
|
47
|
+
// appendEvent/projection writes; the TenantDb branch is excluded here.
|
|
47
48
|
export function resolveDbSource(ctx, tx) {
|
|
48
49
|
return tx ?? resolveAppPoolDb(ctx);
|
|
49
50
|
}
|
|
50
|
-
function resolveAppPoolDb(ctx) {
|
|
51
|
+
export function resolveAppPoolDb(ctx) {
|
|
51
52
|
return ctx.appContext.db; // @cast-boundary db-operator
|
|
52
53
|
}
|
|
53
54
|
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
@@ -186,6 +187,7 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
186
187
|
// A read as a member gets no system-scope DB, preSave pipeline or file storage.
|
|
187
188
|
runPreSave: undefined,
|
|
188
189
|
systemDb: undefined,
|
|
190
|
+
crossTenantReads: undefined,
|
|
189
191
|
files: undefined,
|
|
190
192
|
derivatives: undefined,
|
|
191
193
|
...(handlerContext.jobRunner && { jobRunner: denyingJobRunnerProxy() }),
|
|
@@ -326,6 +328,28 @@ readOnlyForTenant = false) {
|
|
|
326
328
|
const systemDb = isSystem && db
|
|
327
329
|
? createUncheckedSystemDb(db, rawDbOutsideTransaction, reportEscapeHatch)
|
|
328
330
|
: undefined;
|
|
331
|
+
// An agent tool call runs with the user's session but feeds the LLM context, so
|
|
332
|
+
// cross-tenant rows must never reach it, even when unsafeRaw implies the grant.
|
|
333
|
+
const crossTenantReadsReason = isAgentToolCallScope()
|
|
334
|
+
? undefined
|
|
335
|
+
: crossTenantReadsGrantFor(handlerEscapeHatch)?.reason;
|
|
336
|
+
const crossTenantReads = dbSource && crossTenantReadsReason !== undefined
|
|
337
|
+
? createHandlerCrossTenantReads({
|
|
338
|
+
db: dbSource,
|
|
339
|
+
tenantId: user.tenantId,
|
|
340
|
+
tracer: context.tracer,
|
|
341
|
+
meter: context.meter,
|
|
342
|
+
signal: reqCtx?.signal,
|
|
343
|
+
reason: crossTenantReadsReason,
|
|
344
|
+
report: reportEscapeHatch,
|
|
345
|
+
grants: {
|
|
346
|
+
report: reportEscapeHatch,
|
|
347
|
+
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
348
|
+
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
349
|
+
projectionRegistry: registry,
|
|
350
|
+
},
|
|
351
|
+
})
|
|
352
|
+
: undefined;
|
|
329
353
|
// Exposed as ctx.db below — the internal `db` above stays the real,
|
|
330
354
|
// working TenantDb for this function's own use (config/derivatives
|
|
331
355
|
// accessors, systemDb construction).
|
|
@@ -468,8 +492,8 @@ readOnlyForTenant = false) {
|
|
|
468
492
|
const meter = context.meter;
|
|
469
493
|
const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
470
494
|
// ctx.metricsFor(featureName) — shared/library code binds to a feature
|
|
471
|
-
// name of its own choosing instead of the dispatching handler's
|
|
472
|
-
//
|
|
495
|
+
// name of its own choosing instead of the dispatching handler's.
|
|
496
|
+
// Unregistered names no-op rather than throw.
|
|
473
497
|
const metricsFor = createMetricsForFactory(meter);
|
|
474
498
|
// A failed inner write has already appended its event and projections when a later
|
|
475
499
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
@@ -795,7 +819,7 @@ readOnlyForTenant = false) {
|
|
|
795
819
|
// (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
|
|
796
820
|
//
|
|
797
821
|
// Resolving this via `config()` is a config-table SELECT on every single
|
|
798
|
-
// dispatch
|
|
822
|
+
// dispatch — ctx.tenantTimezoneCache memoizes it per tenant.
|
|
799
823
|
// dispatch-write.ts invalidates on config:write:set/reset for this key.
|
|
800
824
|
// Only populate from a tx-free read (tx === undefined): a write-path
|
|
801
825
|
// lookup runs inside the caller's own open transaction, and caching a
|
|
@@ -835,8 +859,8 @@ readOnlyForTenant = false) {
|
|
|
835
859
|
});
|
|
836
860
|
// ctx.locale — request-layer signal (X-Locale header → Accept-Language,
|
|
837
861
|
// resolved once at the HTTP boundary by request-id-middleware.ts) wins;
|
|
838
|
-
// then SessionUser.locale (set at login
|
|
839
|
-
//
|
|
862
|
+
// then SessionUser.locale (set at login — stale until re-login, same
|
|
863
|
+
// tradeoff as ctx.tz.user); falls back to the app's
|
|
840
864
|
// boot-configured defaultLocale, then DEFAULT_LOCALE. Mirrors ctx.tz's
|
|
841
865
|
// Request → Session → Boot-Default chain above.
|
|
842
866
|
const safeUserLocale = user.locale !== undefined && isValidLocaleTag(user.locale) ? user.locale : undefined;
|
|
@@ -855,6 +879,7 @@ readOnlyForTenant = false) {
|
|
|
855
879
|
db: exposedDb,
|
|
856
880
|
dbOutsideTransaction,
|
|
857
881
|
...(systemDb && { systemDb }),
|
|
882
|
+
...(crossTenantReads && { crossTenantReads }),
|
|
858
883
|
log,
|
|
859
884
|
notify,
|
|
860
885
|
...(config && { config }),
|
|
@@ -864,8 +889,7 @@ readOnlyForTenant = false) {
|
|
|
864
889
|
...(derivatives && { derivatives }),
|
|
865
890
|
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
866
891
|
// a handler actually starts building its write — bound here so entity
|
|
867
|
-
// CRUD handlers (entity-handlers.ts) can forward it to the executor
|
|
868
|
-
// (kumiko-framework#1672).
|
|
892
|
+
// CRUD handlers (entity-handlers.ts) can forward it to the executor.
|
|
869
893
|
...(lifecycle && {
|
|
870
894
|
runPreSave: (handlerName, changes, previous, isNew) => lifecycle.runPreSave(handlerName, changes, previous, isNew, context),
|
|
871
895
|
}),
|
|
@@ -886,11 +910,10 @@ readOnlyForTenant = false) {
|
|
|
886
910
|
// Lets write handlers call ctx.jobRunner.dispatch(...) directly, same
|
|
887
911
|
// as a follow-up job would (test-stack.ts wires the matching runner).
|
|
888
912
|
...(jobRunner && { jobRunner }),
|
|
889
|
-
// ctx.user
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
// event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
|
|
913
|
+
// ctx.user as a convenience alias for event.user. The intuitive path—"the
|
|
914
|
+
// context knows its user"—lets handlers avoid writing `event.user.tenantId`
|
|
915
|
+
// and reduces errors in ctx.user access patterns. It is identical to
|
|
916
|
+
// event.user; identity switches still use queryAs/writeAs.
|
|
894
917
|
user,
|
|
895
918
|
_tenantId: user.tenantId,
|
|
896
919
|
...(hasLiveCommitSink && { scheduleAfterCommit }),
|
|
@@ -945,7 +968,7 @@ export async function runHandlerInstrumented(ctx, type, operation, user, inner)
|
|
|
945
968
|
attributes: dispatcherSpanAttributes(type, operation, user, registry.getHandlerFeature(type)),
|
|
946
969
|
}, async (span) => {
|
|
947
970
|
try {
|
|
948
|
-
//
|
|
971
|
+
// Everything the handler writes, including entity-executor
|
|
949
972
|
// writes below it, is attributed to this handler.
|
|
950
973
|
const result = await runWithOrigin({
|
|
951
974
|
handler: type,
|
|
@@ -53,7 +53,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
|
|
|
53
53
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
54
54
|
// Awaiting `iterator.return()` in that state deadlocks async generators in
|
|
55
55
|
// Bun (overlapping next+return). Track abandonment so finally skips the
|
|
56
|
-
// await; close is fire-and-forget instead
|
|
56
|
+
// await; close is fire-and-forget instead.
|
|
57
57
|
let abandonedForInvalidation = false;
|
|
58
58
|
try {
|
|
59
59
|
const handlerContext = await buildHandlerContext(ctx, type, user, origin);
|
|
@@ -54,7 +54,7 @@ function isConfigWriteResult(data) {
|
|
|
54
54
|
return false;
|
|
55
55
|
return typeof data.key === "string";
|
|
56
56
|
}
|
|
57
|
-
//
|
|
57
|
+
// dispatch-shared.ts caches the resolved tenant:config:timezone
|
|
58
58
|
// value per tenant. A successful write to that key must drop the stale
|
|
59
59
|
// entry — tenant/user scope only affects the writing tenant, system scope
|
|
60
60
|
// changes the fallback every tenant without an override reads, so the
|
|
@@ -252,8 +252,8 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
252
252
|
// enumerates columns generically WOULD see `tasks`; no such subscriber
|
|
253
253
|
// exists today. If you add one that iterates `Object.keys(save.data)`,
|
|
254
254
|
// filter by `entity.fields` membership to stay correct.
|
|
255
|
-
// handler-Result.data
|
|
256
|
-
// write
|
|
255
|
+
// handler-Result.data is generic across all entity handlers; nested-
|
|
256
|
+
// write inspects the shape structurally.
|
|
257
257
|
const parentWrapper = parentResult.data; // @cast-boundary engine-payload
|
|
258
258
|
const parentRow = (parentWrapper["data"] ?? parentWrapper); // @cast-boundary engine-payload
|
|
259
259
|
const parentId = parentRow["id"];
|
|
@@ -5,7 +5,7 @@ export type FailedWriteResult = Extract<WriteResult, {
|
|
|
5
5
|
}>;
|
|
6
6
|
export declare function isFailedWriteResult(result: unknown): result is FailedWriteResult;
|
|
7
7
|
export declare function isLifecycleResult(data: unknown): data is LifecycleResult;
|
|
8
|
-
export declare function isWriteResultShape(result: unknown):
|
|
8
|
+
export declare function isWriteResultShape(result: unknown): result is WriteResult;
|
|
9
9
|
export declare function describeShape(result: unknown): string;
|
|
10
10
|
export declare function dispatcherSpanAttributes(type: string, operation: "query" | "write" | "stream", user: SessionUser, feature: string | undefined): Record<string, string | number | boolean>;
|
|
11
11
|
export type AfterCommitHook = () => Promise<void>;
|
|
@@ -56,7 +56,7 @@ export function extractNestedSpecs(parentType, payload, registry) {
|
|
|
56
56
|
if (!entityName)
|
|
57
57
|
return null;
|
|
58
58
|
const relations = registry.getRelations(entityName);
|
|
59
|
-
const source = payload; // @cast-boundary engine-payload — generic dispatch
|
|
59
|
+
const source = payload; // @cast-boundary engine-payload — generic dispatch across all entity types
|
|
60
60
|
const clean = { ...source };
|
|
61
61
|
const specs = [];
|
|
62
62
|
const typeIssues = [];
|
|
@@ -4,7 +4,7 @@ import { tableExists } from "../db/schema-inspection.js";
|
|
|
4
4
|
import { unsafePushTables } from "../db/table-push.js";
|
|
5
5
|
// Reserved sentinel used in the instance_id column for consumers whose
|
|
6
6
|
// delivery is "shared" — i.e. one cursor across all dispatcher instances
|
|
7
|
-
// (the default
|
|
7
|
+
// (the default behaviour). Per-instance consumers store
|
|
8
8
|
// the concrete instanceId. Postgres PK columns can't be NULL and
|
|
9
9
|
// `NULL = NULL` is UNKNOWN in SQL — a nullable instance_id would break
|
|
10
10
|
// both uniqueness and PK constraints. The boot-validator refuses to
|
|
@@ -62,7 +62,7 @@ export async function createEventConsumerStateTable(db) {
|
|
|
62
62
|
// no need to pre-check columnNamesOf first. Two instances booting
|
|
63
63
|
// concurrently against the same DB (this table is explicitly
|
|
64
64
|
// multi-instance) both ALTER, neither crashes on "column already
|
|
65
|
-
// exists"
|
|
65
|
+
// exists".
|
|
66
66
|
await alterTableAddColumn(db, "kumiko_event_consumers", "rearm_count", "integer", " DEFAULT 0", " NOT NULL",
|
|
67
67
|
/* ifNotExists */ true);
|
|
68
68
|
await alterTableAddColumn(db, "kumiko_event_consumers", "pending_gaps", "jsonb", " DEFAULT '[]'::jsonb", " NOT NULL",
|
|
@@ -239,7 +239,7 @@ export async function deliverEvents(consumer, events, context, maxAttempts, stat
|
|
|
239
239
|
// turn (see applyBatch for why). requestId falls back to a fresh id
|
|
240
240
|
// because the dispatcher runs outside any HTTP request (background poll),
|
|
241
241
|
// and a stable log-correlation handle is still useful for debugging.
|
|
242
|
-
//
|
|
242
|
+
// An event this apply writes is attributed to the consumer, not
|
|
243
243
|
// to whatever wrote the triggering event; causationId already links back.
|
|
244
244
|
function buildConsumerRequestScope(consumer, causationSource) {
|
|
245
245
|
const correlationId = causationSource.metadata.correlationId ?? requestContext.generateId();
|
|
@@ -2,6 +2,7 @@ import { extractPgError } from "../db/pg-error.js";
|
|
|
2
2
|
import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
4
4
|
import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
|
|
5
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
6
|
import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
|
|
6
7
|
import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
|
|
7
8
|
import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
|
|
@@ -52,15 +53,9 @@ export function createEventDispatcher(options) {
|
|
|
52
53
|
}
|
|
53
54
|
const tracer = options.tracer ?? getFallbackTracer();
|
|
54
55
|
const meter = options.meter ?? getFallbackMeter();
|
|
55
|
-
// A standalone dispatcher has no logger on its context;
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
context.log.error(message);
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
console.error(message);
|
|
62
|
-
}
|
|
63
|
-
};
|
|
56
|
+
// A standalone dispatcher has no logger on its context; the fallback keeps the failure in pod logs.
|
|
57
|
+
const dispatcherLog = createFallbackLogger("event-dispatcher", context.log);
|
|
58
|
+
const logDispatcherError = (message) => dispatcherLog.error(message);
|
|
64
59
|
// Tracks which (consumer, instanceId) pairs already fired
|
|
65
60
|
// kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
|
|
66
61
|
// across many poll passes emits the ops-signal once, not every pass
|
|
@@ -79,7 +74,7 @@ export function createEventDispatcher(options) {
|
|
|
79
74
|
if (count <= PENDING_GAPS_WARN_THRESHOLD || reportedLargeGapSets.has(key))
|
|
80
75
|
return;
|
|
81
76
|
reportedLargeGapSets.add(key);
|
|
82
|
-
logDispatcherError(
|
|
77
|
+
logDispatcherError(`${consumerName} tracks ${count} pending gap ranges (> ${PENDING_GAPS_WARN_THRESHOLD}); ` +
|
|
83
78
|
`a long-running write transaction is probably holding back the snapshot xmin`);
|
|
84
79
|
};
|
|
85
80
|
// Per-(consumer, instanceId) backoff after a thrown pass (see the catch in
|
|
@@ -245,13 +240,13 @@ export function createEventDispatcher(options) {
|
|
|
245
240
|
// consumers see the same disabled-set even if an operator flips a
|
|
246
241
|
// toggle mid-pass, so "this event batch" decisions stay consistent.
|
|
247
242
|
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
// Tier
|
|
253
|
-
//
|
|
254
|
-
// SYSTEM_TENANT_ID
|
|
243
|
+
// Tier composition: the per-tenant resolver is consulted once per pass
|
|
244
|
+
// with SYSTEM_TENANT_ID. Async events are tier-agnostic — if a tenant is
|
|
245
|
+
// downgraded, its queued events are still processed (events are immutable,
|
|
246
|
+
// and the projection is eventually consistent).
|
|
247
|
+
// Tier cuts apply at request time in the sync dispatcher and lifecycle
|
|
248
|
+
// pipeline, not during async replay. The app-level resolver decides what
|
|
249
|
+
// it returns for SYSTEM_TENANT_ID (typically: the union of all tier features).
|
|
255
250
|
const effective = context.effectiveFeatures?.(SYSTEM_TENANT_ID);
|
|
256
251
|
// Lock-free pre-check, once per pass: which consumers are provably idle
|
|
257
252
|
// (no locking, no xid, no WAL — see selectIdleConsumerKeys). Read
|
|
@@ -275,13 +270,7 @@ export function createEventDispatcher(options) {
|
|
|
275
270
|
if (idlePreCheckErrorLogged) {
|
|
276
271
|
idlePreCheckErrorLogged = false;
|
|
277
272
|
emitEventDispatcherListenConnected(meter, listenSubscribed);
|
|
278
|
-
|
|
279
|
-
if (context.log) {
|
|
280
|
-
context.log.info(recoveredMsg);
|
|
281
|
-
}
|
|
282
|
-
else {
|
|
283
|
-
console.log(recoveredMsg);
|
|
284
|
-
}
|
|
273
|
+
dispatcherLog.info("idle pre-check recovered, database reachable again");
|
|
285
274
|
}
|
|
286
275
|
}
|
|
287
276
|
catch (e) {
|
|
@@ -297,7 +286,7 @@ export function createEventDispatcher(options) {
|
|
|
297
286
|
if (isConnectionLossError(e))
|
|
298
287
|
emitEventDispatcherListenConnected(meter, false);
|
|
299
288
|
const msg = e instanceof Error ? e.message : String(e);
|
|
300
|
-
const logMsg = `
|
|
289
|
+
const logMsg = `idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
|
|
301
290
|
logDispatcherError(logMsg);
|
|
302
291
|
}
|
|
303
292
|
}
|
|
@@ -438,7 +427,7 @@ export function createEventDispatcher(options) {
|
|
|
438
427
|
// callers and at-least-once-with-duplicate-delivery on the next pass;
|
|
439
428
|
// neither is what we want, so ops needs to see it.
|
|
440
429
|
const msg = e instanceof Error ? e.message : String(e);
|
|
441
|
-
logDispatcherError(
|
|
430
|
+
logDispatcherError(`${consumer.name} pass failed: ${msg}`);
|
|
442
431
|
span.setStatus("error", msg);
|
|
443
432
|
// The whole pass rolled back, so markProcessing/persistConsumerOutcome
|
|
444
433
|
// never committed — attempts/last_error are stuck at their pre-pass
|
|
@@ -452,7 +441,7 @@ export function createEventDispatcher(options) {
|
|
|
452
441
|
}
|
|
453
442
|
catch (persistErr) {
|
|
454
443
|
const persistMsg = persistErr instanceof Error ? persistErr.message : String(persistErr);
|
|
455
|
-
const failureMsg =
|
|
444
|
+
const failureMsg = `${consumer.name} failed to persist pass-failure state: ${persistMsg}`;
|
|
456
445
|
logDispatcherError(failureMsg);
|
|
457
446
|
}
|
|
458
447
|
// Escalate the backoff so this consumer isn't retried on the very
|
|
@@ -519,7 +508,7 @@ export function createEventDispatcher(options) {
|
|
|
519
508
|
catch (e) {
|
|
520
509
|
emitEventDispatcherListenConnected(meter, false);
|
|
521
510
|
const msg = e instanceof Error ? e.message : String(e);
|
|
522
|
-
|
|
511
|
+
dispatcherLog.error("pg LISTEN failed", { err: msg });
|
|
523
512
|
}
|
|
524
513
|
}
|
|
525
514
|
},
|
|
@@ -46,7 +46,7 @@ export function assertIrreversibleOperationAllowed(operation) {
|
|
|
46
46
|
// Fields flagged readAsInstruction: true have their write payload presence checked here — the
|
|
47
47
|
// same reasoning as assertIrreversibleOperationAllowed, but the irreversible property belongs to
|
|
48
48
|
// the target field, not the verb. Presence in the payload (not a value comparison against the
|
|
49
|
-
// loaded row) is the deliberate fail-closed choice
|
|
49
|
+
// loaded row) is the deliberate fail-closed choice.
|
|
50
50
|
export function assertInstructionFieldWriteAllowed(entityName, verb, writtenFieldNames, instructionFieldNames) {
|
|
51
51
|
const fields = writtenFieldNames.filter((name) => instructionFieldNames.includes(name));
|
|
52
52
|
// skip: the write touches no readAsInstruction field.
|
|
@@ -5,17 +5,17 @@ function resolveTracer(context) {
|
|
|
5
5
|
return context.tracer ?? getFallbackTracer();
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Resolves the effective feature set for the active context's tenant.
|
|
9
9
|
*
|
|
10
|
-
* `_tenantId`
|
|
11
|
-
*
|
|
12
|
-
* pipeline)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* `_tenantId` is copied from `user.tenantId` when the dispatcher builds the
|
|
11
|
+
* HandlerContext. Legacy call sites without a user (system jobs, boot-time
|
|
12
|
+
* pipeline) leave it undefined; `effectiveFeatures(undefined)` is type-unsafe,
|
|
13
|
+
* so this returns undefined and treats that as "all features on" (registry
|
|
14
|
+
* filter hooks accept undefined to skip filtering).
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
* preDelete, postDelete)
|
|
18
|
-
*
|
|
16
|
+
* This is the single path used by all four lifecycle-hook call sites
|
|
17
|
+
* (preSave, postSave, preDelete, postDelete), keeping tenant scoping
|
|
18
|
+
* consistent.
|
|
19
19
|
*/
|
|
20
20
|
function currentEffectiveFeatures(context) {
|
|
21
21
|
if (context._tenantId === undefined)
|
|
@@ -63,9 +63,7 @@ export async function runInMemberReadOnlyTransaction(ctx, tx, fn) {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
// Savepoint even on the pool path: Postgres rejects a READ WRITE reset (SQLSTATE 25001) inside a subtransaction.
|
|
66
|
-
return await transaction(pool, (outer) =>
|
|
67
|
-
// @cast-boundary driver begin() handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
68
|
-
runInDiscardedReadOnlySavepoint(outer, timeoutMs, true, fn));
|
|
66
|
+
return await transaction(pool, (outer) => runInDiscardedReadOnlySavepoint(outer, timeoutMs, true, fn));
|
|
69
67
|
}
|
|
70
68
|
catch (e) {
|
|
71
69
|
if (e instanceof AccessDeniedError)
|
|
@@ -159,7 +159,7 @@ async function replayMultiStreamProjectionIntoShadow(mspName, deps) {
|
|
|
159
159
|
durationMs: Date.now() - startedAt,
|
|
160
160
|
// countColumnDrift is single-table (public.<t> vs kumiko_rebuild.<t>) and
|
|
161
161
|
// not wired into the multi-stream rebuild path — MSPs don't run the
|
|
162
|
-
// implicit-projection ghost-row guard either.
|
|
162
|
+
// implicit-projection ghost-row guard either. The scope is single-table.
|
|
163
163
|
columnDriftCount: 0,
|
|
164
164
|
};
|
|
165
165
|
if (deps.meter) {
|
|
@@ -5,8 +5,10 @@ import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
|
5
5
|
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
6
6
|
import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
|
|
7
7
|
import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
|
|
8
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
8
9
|
import { emitProjectionRebuild } from "../observability/standard-metrics.js";
|
|
9
10
|
import { projectionStateTable } from "./projection-state.js";
|
|
11
|
+
const log = createFallbackLogger("kumiko:projection-rebuild");
|
|
10
12
|
// Events replayed per catch-up batch. Each batch is a fresh READ COMMITTED
|
|
11
13
|
// SELECT, so a batch shorter than this means the currently-committed tail is
|
|
12
14
|
// drained.
|
|
@@ -32,7 +34,7 @@ function rowToStoredEvent(row) {
|
|
|
32
34
|
createdBy: row.createdBy,
|
|
33
35
|
};
|
|
34
36
|
}
|
|
35
|
-
// Ops-facing log line for countColumnDrift's result. No-op when clean.
|
|
37
|
+
// Ops-facing log line for countColumnDrift's result. No-op when clean.
|
|
36
38
|
function warnOnColumnDrift(projectionName, tableName, drift) {
|
|
37
39
|
// skip: no drift found — nothing to warn about
|
|
38
40
|
if (drift.rowCount === 0)
|
|
@@ -41,7 +43,7 @@ function warnOnColumnDrift(projectionName, tableName, drift) {
|
|
|
41
43
|
const countLabel = sampledIds.size < drift.rowCount
|
|
42
44
|
? `${drift.rowCount} (sample truncated)`
|
|
43
45
|
: String(drift.rowCount);
|
|
44
|
-
|
|
46
|
+
log.warn(`projection "${projectionName}": ${countLabel} live row(s) in "${tableName}" have ` +
|
|
45
47
|
`column values that differ from the freshly-replayed shadow (sample: ${drift.sample.join(", ")}). ` +
|
|
46
48
|
"Not blocking — replay wins on swap. Likely cause: a legacy direct-write predating the row's " +
|
|
47
49
|
"event history (#494), or a sensitive custom field pending #972. Investigate if unexpected.");
|
|
@@ -71,7 +73,7 @@ async function replayProjectionIntoShadow(projectionName, deps) {
|
|
|
71
73
|
let lastProcessedEventId = 0n;
|
|
72
74
|
const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
|
|
73
75
|
// Quarantined events, collected in memory and written once after the
|
|
74
|
-
// replay settles — the
|
|
76
|
+
// replay settles — the full-re-replay path would otherwise duplicate
|
|
75
77
|
// the dead-letter rows.
|
|
76
78
|
let skipped = [];
|
|
77
79
|
// One chronological batch of events after lastProcessedEventId, applied into
|
|
@@ -145,7 +147,7 @@ async function replayProjectionIntoShadow(projectionName, deps) {
|
|
|
145
147
|
// Fence the live table, then drain the final delta. Once ACCESS
|
|
146
148
|
// EXCLUSIVE is held no concurrent apply can commit a new event, so this
|
|
147
149
|
// loop terminates and the shadow ends up reflecting every committed
|
|
148
|
-
// event — closing
|
|
150
|
+
// event — closing the write-loss window for single-pod rebuilds.
|
|
149
151
|
await fenceLiveTable(tx, meta.tableName, fenceLockTimeoutMs);
|
|
150
152
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
151
153
|
// keep draining full batches; a short batch ends the loop
|
|
@@ -153,7 +155,7 @@ async function replayProjectionIntoShadow(projectionName, deps) {
|
|
|
153
155
|
// Fenced → the subscribed-event set is final (every live apply blocks
|
|
154
156
|
// on the live-table lock). If fewer events were applied than exist, a
|
|
155
157
|
// lower-id event committed late during the unlocked phase and the
|
|
156
|
-
// id-cursor leapt past it
|
|
158
|
+
// id-cursor leapt past it. Rebuild the shadow from scratch and
|
|
157
159
|
// replay the whole log under the fence: a fresh shadow means no
|
|
158
160
|
// double-apply (the rejected "re-drain into a populated shadow" hazard),
|
|
159
161
|
// and the full-replay cost is paid only on this rare detected path.
|
|
@@ -184,12 +186,12 @@ async function replayProjectionIntoShadow(projectionName, deps) {
|
|
|
184
186
|
}
|
|
185
187
|
// Guard the swap: abort if the process has no blind-index key configured
|
|
186
188
|
// but the live table already has populated bidx columns — the rebuild
|
|
187
|
-
// would otherwise silently null them out
|
|
189
|
+
// would otherwise silently null them out. Applies to every
|
|
188
190
|
// projection, not just implicit ones: the shadow always gets a fresh
|
|
189
191
|
// computeBlindIndexValues() pass regardless of projection kind.
|
|
190
192
|
await assertNoBlindIndexLoss(tx, meta.tableName, meta, projectionName);
|
|
191
193
|
// Guard the swap: abort if the live table holds a row no event can
|
|
192
|
-
// reconstruct (
|
|
194
|
+
// reconstruct (ghost — direct-inserted without a .created event),
|
|
193
195
|
// which the swap would silently drop. Implicit projections only.
|
|
194
196
|
if (projection.isImplicit === true) {
|
|
195
197
|
await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList, projection.extraSourceRowIds);
|
|
@@ -249,11 +251,10 @@ export async function getProjectionState(db, projectionName) {
|
|
|
249
251
|
// The registry is the source-of-truth for which projections exist; the
|
|
250
252
|
// state table holds per-projection rebuild info and may be sparse.
|
|
251
253
|
//
|
|
252
|
-
// Implicit
|
|
253
|
-
//
|
|
254
|
-
// `<feature>:projection:<entity>-entity
|
|
255
|
-
//
|
|
256
|
-
// `{ includeImplicit: true }` opt-in einschalten.
|
|
254
|
+
// Implicit projections (auto-registered by r.entity, one per entity) are filtered
|
|
255
|
+
// out by default. They stay addressable as rebuild targets via
|
|
256
|
+
// `<feature>:projection:<entity>-entity`, but would dominate `kumiko project list`
|
|
257
|
+
// without adding information. Opt in with `{ includeImplicit: true }`.
|
|
257
258
|
export async function listProjectionsWithState(db, registry, options = {}) {
|
|
258
259
|
const projections = registry.getAllProjections();
|
|
259
260
|
const stateRows = await selectMany(db, projectionStateTable);
|