@cosmicdrift/kumiko-framework 0.353.0 → 0.354.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +28 -3
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +2 -6
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +29 -2
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +59 -18
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/queries/backfill-pii.d.ts +1 -0
- package/dist/db/queries/backfill-pii.js +11 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +32 -3
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +5 -0
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +40 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +98 -7
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +55 -34
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +41 -4
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +2 -3
- package/dist/engine/index.d.ts +8 -2
- package/dist/engine/index.js +6 -1
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/project-app-schema-for-roles.js +3 -1
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.js +45 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-routes.js +10 -4
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +198 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
2
2
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
|
+
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
|
+
import { findUnavailableSelectOptions, } from "../engine/screen-helpers.js";
|
|
4
6
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
5
7
|
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
6
8
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
@@ -51,12 +53,32 @@ function isFrameworkSystemUser(user) {
|
|
|
51
53
|
function isUnchangedValue(a, b) {
|
|
52
54
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
53
55
|
}
|
|
56
|
+
function selectOptionNotAvailableFailure(entries) {
|
|
57
|
+
const [first] = entries;
|
|
58
|
+
if (!first)
|
|
59
|
+
return undefined;
|
|
60
|
+
const i18nKey = "kumiko.validation.optionNotAvailable";
|
|
61
|
+
return writeFailure(new UnprocessableError("select_option_not_available", {
|
|
62
|
+
i18nKey,
|
|
63
|
+
details: {
|
|
64
|
+
field: first.field,
|
|
65
|
+
value: first.value,
|
|
66
|
+
allowed: first.allowed,
|
|
67
|
+
fields: entries.map((entry) => ({
|
|
68
|
+
path: entry.field,
|
|
69
|
+
code: "select_option_not_available",
|
|
70
|
+
i18nKey,
|
|
71
|
+
params: { allowed: entry.allowed },
|
|
72
|
+
})),
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
54
76
|
// preSave runs before ownership checks: authorization must evaluate the row
|
|
55
77
|
// as it will actually be persisted, including hook-derived fields
|
|
56
78
|
// (kumiko-framework#1672). A throwing hook is an app-author bug (bad
|
|
57
79
|
// business rule, not a framework fault) — map it to a clean writeFailure
|
|
58
80
|
// instead of letting it propagate as an internal_error 500.
|
|
59
|
-
async function runPreSave(preSave, changes, previous, isNew, entityName, action) {
|
|
81
|
+
async function runPreSave(preSave, changes, previous, isNew, entityName, action, outputSchema) {
|
|
60
82
|
if (!preSave)
|
|
61
83
|
return { data: changes };
|
|
62
84
|
try {
|
|
@@ -65,6 +87,25 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action)
|
|
|
65
87
|
// must not leak them into the persisted row — aggregateId already comes
|
|
66
88
|
// from generateId()/the loaded row, not from hook output (fw#1685).
|
|
67
89
|
const { id: _hookId, version: _hookVersion, ...safe } = hookResult;
|
|
90
|
+
// Hook output skips the handler's input validation yet goes straight into the projection.
|
|
91
|
+
// Validated only, not replaced by the parse result: hooks may set fields in storage form.
|
|
92
|
+
const parsed = outputSchema().safeParse(safe);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return {
|
|
95
|
+
failure: writeFailure(new UnprocessableError("presave_hook_invalid_output", {
|
|
96
|
+
i18nKey: "errors.presaveHookInvalidOutput",
|
|
97
|
+
details: {
|
|
98
|
+
entityName,
|
|
99
|
+
action,
|
|
100
|
+
handler: `${entityName}:${action}`,
|
|
101
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
102
|
+
path: issue.path.join("."),
|
|
103
|
+
message: issue.message,
|
|
104
|
+
})),
|
|
105
|
+
},
|
|
106
|
+
})),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
68
109
|
return { data: safe };
|
|
69
110
|
}
|
|
70
111
|
catch (e) {
|
|
@@ -79,6 +120,10 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action)
|
|
|
79
120
|
export function createWriteVerbs(ctx) {
|
|
80
121
|
const { table, entity, entityName, entityCache, softDelete, streamTenantFor, encryptForStorage, decryptForRead, applyDefaults, stripSensitive, loadById, assertStreamWritable, loadExpectSnapshot, } = ctx;
|
|
81
122
|
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
123
|
+
let insertSchema;
|
|
124
|
+
let updateSchema;
|
|
125
|
+
const createOutputSchema = () => (insertSchema ??= buildInsertSchema(entity));
|
|
126
|
+
const updateOutputSchema = () => (updateSchema ??= buildUpdateSchema(entity));
|
|
82
127
|
return {
|
|
83
128
|
async create(payload, user, db, options) {
|
|
84
129
|
if (isForeignTenantOnGlobalEntity(entity, payload["tenantId"])) {
|
|
@@ -94,7 +139,7 @@ export function createWriteVerbs(ctx) {
|
|
|
94
139
|
const explicitId = typeof payload["id"] === "string" ? payload["id"] : undefined; // @cast-boundary engine-payload
|
|
95
140
|
const aggregateId = explicitId ?? generateId();
|
|
96
141
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
97
|
-
const preSaveResult = await runPreSave(options?.preSave, applyDefaults(payloadWithoutId), {}, true, entityName, "create");
|
|
142
|
+
const preSaveResult = await runPreSave(options?.preSave, applyDefaults(payloadWithoutId), {}, true, entityName, "create", createOutputSchema);
|
|
98
143
|
if ("failure" in preSaveResult)
|
|
99
144
|
return preSaveResult.failure;
|
|
100
145
|
const data = preSaveResult.data;
|
|
@@ -132,6 +177,9 @@ export function createWriteVerbs(ctx) {
|
|
|
132
177
|
},
|
|
133
178
|
}));
|
|
134
179
|
}
|
|
180
|
+
const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
|
|
181
|
+
if (unavailableOnCreate)
|
|
182
|
+
return unavailableOnCreate;
|
|
135
183
|
// Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
|
|
136
184
|
// dieselbe Pipeline. Caller schickt combined API-Form, Framework
|
|
137
185
|
// speichert flat DB-Form. Siehe db/compound-types.ts.
|
|
@@ -261,7 +309,7 @@ export function createWriteVerbs(ctx) {
|
|
|
261
309
|
const previous = await loadById(payload.id, db);
|
|
262
310
|
if (!previous)
|
|
263
311
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
264
|
-
const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update");
|
|
312
|
+
const preSaveResult = await runPreSave(updateOptions?.preSave, payload.changes, previous, false, entityName, "update", updateOutputSchema);
|
|
265
313
|
if ("failure" in preSaveResult)
|
|
266
314
|
return preSaveResult.failure;
|
|
267
315
|
const changes = preSaveResult.data;
|
|
@@ -316,6 +364,9 @@ export function createWriteVerbs(ctx) {
|
|
|
316
364
|
},
|
|
317
365
|
}));
|
|
318
366
|
}
|
|
367
|
+
const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
368
|
+
if (unavailableOnUpdate)
|
|
369
|
+
return unavailableOnUpdate;
|
|
319
370
|
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
320
371
|
// Stream-version is authoritative, not row.version. `ctx.appendEvent`
|
|
321
372
|
// can bump the stream between CRUD writes (domain event on the same
|
package/dist/db/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cu
|
|
|
9
9
|
export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
|
|
10
10
|
export type { SchemaTable, SelectQuery, TableColumns } from "./dialect.js";
|
|
11
11
|
export { bigint, bigserial, boolean, extractTableName, index, instant, instantToDriver, integer, jsonb, moneyAmount, numeric, primaryKey, serial, sql, table, text, timestamp, uniqueIndex, uuid, } from "./dialect.js";
|
|
12
|
-
export type { EagerLoadEntityResolver, EagerloadedRow } from "./eagerload.js";
|
|
12
|
+
export type { EagerLoadEntityResolver, EagerLoadViewer, EagerloadedRow } from "./eagerload.js";
|
|
13
13
|
export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
|
|
14
14
|
export { collectEncryptedFieldNames, configuredEntityFieldEncryption, configureEntityFieldEncryption, decryptEntityFieldValues, encryptEntityFieldValues, } from "./entity-field-encryption.js";
|
|
15
15
|
export { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
@@ -336,7 +336,7 @@ export function renderMigrationSql(diff, options) {
|
|
|
336
336
|
lines.push(`-- Migration ${seq}_${options.name}`);
|
|
337
337
|
lines.push(`-- Generated: ${new Date().toISOString()}`);
|
|
338
338
|
lines.push("-- ");
|
|
339
|
-
lines.push("-- This file is generated by `kumiko
|
|
339
|
+
lines.push("-- This file is generated by `kumiko-schema generate`. You may");
|
|
340
340
|
lines.push("-- hand-edit it before committing — add partial-indexes, BRIN-");
|
|
341
341
|
lines.push("-- variants, performance-tuning. After commit + apply, NEVER edit");
|
|
342
342
|
lines.push("-- this file again (production-DBs already ran it). Write a new");
|
|
@@ -33,6 +33,7 @@ export type PiiBackfillOptions = {
|
|
|
33
33
|
};
|
|
34
34
|
export type PiiBackfillBatchOptions = PiiBackfillOptions & {
|
|
35
35
|
readonly afterEventId?: bigint | string;
|
|
36
|
+
readonly eventIds?: readonly string[];
|
|
36
37
|
readonly scanCache?: PiiBackfillScanCache;
|
|
37
38
|
};
|
|
38
39
|
export declare function backfillEventPiiEncryption(db: DbRunner, registry: Registry, options?: PiiBackfillOptions): Promise<PiiBackfillResult>;
|
|
@@ -137,11 +137,16 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
137
137
|
});
|
|
138
138
|
if (aggregateTypes.length === 0 && catalogTypes.length === 0)
|
|
139
139
|
return emptyBatch();
|
|
140
|
-
const rows = (await
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
const rows = (await (options.eventIds
|
|
141
|
+
? raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
142
|
+
FROM "kumiko_events"
|
|
143
|
+
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" = ANY($3::bigint[])
|
|
144
|
+
ORDER BY "id" ASC`, [aggregateTypes, catalogTypes, [...options.eventIds]])
|
|
145
|
+
: raw.unsafe(`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
146
|
+
FROM "kumiko_events"
|
|
147
|
+
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
|
|
148
|
+
ORDER BY "id" ASC
|
|
149
|
+
LIMIT $4`, [aggregateTypes, catalogTypes, String(options.afterEventId ?? 0), batchSize])));
|
|
145
150
|
const last = rows[rows.length - 1];
|
|
146
151
|
if (last === undefined)
|
|
147
152
|
return emptyBatch();
|
|
@@ -192,7 +197,7 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
192
197
|
return {
|
|
193
198
|
...result,
|
|
194
199
|
lastEventId: String(last.id),
|
|
195
|
-
scannedAll: rows.length < batchSize,
|
|
200
|
+
scannedAll: options.eventIds !== undefined || rows.length < batchSize,
|
|
196
201
|
firstFailedEventId,
|
|
197
202
|
touchedAggregateTypes: [...touchedAggregateTypes],
|
|
198
203
|
touchedEventTypes: [...touchedEventTypes],
|
|
@@ -9,14 +9,13 @@ export declare function selectEventIdHorizonWithMissingRanges(db: AnyDb): Promis
|
|
|
9
9
|
readonly gaps: PendingGapEntry[];
|
|
10
10
|
}>;
|
|
11
11
|
export declare function insertConsumerIfAbsent(db: AnyDb, name: string, instanceId: string, startFrom?: "beginning" | "now"): Promise<void>;
|
|
12
|
-
export declare function deleteOrphanedPerInstanceConsumerRows(db: AnyDb, consumerNames: readonly string[], sharedInstanceSentinel: string): Promise<void>;
|
|
13
12
|
export declare function selectProvablyIdleConsumerPairs(db: AnyDb, names: readonly string[], instanceIds: readonly string[], deadStatus: typeof ConsumerStatuses.dead): Promise<ReadonlyArray<{
|
|
14
13
|
readonly name: string;
|
|
15
14
|
readonly instanceId: string;
|
|
16
15
|
}>>;
|
|
17
16
|
export declare function selectConsumerForUpdateSkipLocked(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
18
17
|
export declare function markConsumerProcessing(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
19
|
-
export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string): Promise<void>;
|
|
18
|
+
export declare function recordConsumerPassFailure(db: AnyDb, name: string, instanceId: string, errorMessage: string, cursorBeforePass?: bigint): Promise<void>;
|
|
20
19
|
export type ConsumerDeliveryOutcome = {
|
|
21
20
|
readonly cursor: bigint;
|
|
22
21
|
readonly attempts: number;
|
|
@@ -28,7 +27,7 @@ export type ConsumerDeliveryOutcome = {
|
|
|
28
27
|
};
|
|
29
28
|
export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
|
|
30
29
|
export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
|
|
31
|
-
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint,
|
|
30
|
+
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<Record<string, unknown> | undefined>;
|
|
32
31
|
export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
33
32
|
export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
34
33
|
export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
|
|
@@ -23,28 +23,46 @@ export async function lockEventConsumersShareMode(db) {
|
|
|
23
23
|
await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
|
|
24
24
|
}
|
|
25
25
|
// The MAX(id) horizon plus every currently-open id gap below it, read from a
|
|
26
|
-
// single snapshot
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
26
|
+
// single statement snapshot. Used both to seed startFrom "now" (#3231) and to
|
|
27
|
+
// seed an MSP rebuild's handoff to the live dispatcher (msp-rebuild.ts).
|
|
28
|
+
//
|
|
29
|
+
// Scan lower bound: the consumer with the highest cursor already tracks every
|
|
30
|
+
// open gap at or below that cursor in its own pending_gaps, so those are taken
|
|
31
|
+
// over verbatim (keeping their xmax) and only ids above the cursor are scanned,
|
|
32
|
+
// with the cursor itself as virtual predecessor. Without any consumer the
|
|
33
|
+
// cursor is 0 and the scan covers the whole table. No bound derived from xmin or
|
|
34
|
+
// id margins: ids and xids interleave, so such a bound cannot be proven safe.
|
|
35
|
+
// Above the cursor a gap can only fill in if some transaction holding a lower id
|
|
36
|
+
// is still in flight, which pg_snapshot_xip being empty rules out, so the window
|
|
37
|
+
// scan is skipped then.
|
|
32
38
|
export async function selectEventIdHorizonWithMissingRanges(db) {
|
|
33
|
-
const rows = (await asRawClient(db).unsafe(`WITH
|
|
34
|
-
SELECT
|
|
39
|
+
const rows = (await asRawClient(db).unsafe(`WITH a AS (
|
|
40
|
+
SELECT "last_processed_event_id" AS cursor, "pending_gaps" AS gaps
|
|
41
|
+
FROM "kumiko_event_consumers"
|
|
42
|
+
ORDER BY "last_processed_event_id" DESC
|
|
43
|
+
LIMIT 1
|
|
35
44
|
),
|
|
45
|
+
c AS (SELECT COALESCE((SELECT cursor FROM a), 0) AS v),
|
|
46
|
+
b AS (SELECT COALESCE(MAX("id"), 0) AS horizon FROM "kumiko_events"),
|
|
36
47
|
r AS (
|
|
37
|
-
SELECT
|
|
38
|
-
|
|
48
|
+
SELECT (g ->> 'from')::bigint AS f, (g ->> 'to')::bigint AS t, g ->> 'xmax' AS x
|
|
49
|
+
FROM a,
|
|
50
|
+
jsonb_array_elements(CASE WHEN jsonb_typeof(a.gaps) = 'array' THEN a.gaps ELSE '[]'::jsonb END) AS g
|
|
51
|
+
UNION ALL
|
|
52
|
+
SELECT c.v + 1, m.min_id - 1, pg_snapshot_xmax(pg_current_snapshot())::text
|
|
53
|
+
FROM c,
|
|
54
|
+
(SELECT MIN(e."id") AS min_id FROM "kumiko_events" e WHERE e."id" > (SELECT v FROM c)) m
|
|
55
|
+
WHERE m.min_id > c.v + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
|
|
39
56
|
UNION ALL
|
|
40
|
-
SELECT w."id" + 1, w.next_id - 1
|
|
41
|
-
FROM (SELECT "id", lead("id") OVER (ORDER BY "id") AS next_id
|
|
57
|
+
SELECT w."id" + 1, w.next_id - 1, pg_snapshot_xmax(pg_current_snapshot())::text
|
|
58
|
+
FROM (SELECT e."id", lead(e."id") OVER (ORDER BY e."id") AS next_id
|
|
59
|
+
FROM "kumiko_events" e WHERE e."id" > (SELECT v FROM c)) w
|
|
42
60
|
WHERE w.next_id > w."id" + 1 AND EXISTS (SELECT 1 FROM pg_snapshot_xip(pg_current_snapshot()))
|
|
43
61
|
)
|
|
44
62
|
SELECT b.horizon::text AS horizon,
|
|
45
|
-
pg_snapshot_xmax(pg_current_snapshot())::text AS xmax,
|
|
46
63
|
r.f::text AS gap_from,
|
|
47
|
-
r.t::text AS gap_to
|
|
64
|
+
r.t::text AS gap_to,
|
|
65
|
+
r.x AS gap_xmax
|
|
48
66
|
FROM b LEFT JOIN r ON true
|
|
49
67
|
ORDER BY r.f`));
|
|
50
68
|
const first = rows[0];
|
|
@@ -52,9 +70,9 @@ export async function selectEventIdHorizonWithMissingRanges(db) {
|
|
|
52
70
|
throw new Error("selectEventIdHorizonWithMissingRanges: no row returned");
|
|
53
71
|
const gaps = [];
|
|
54
72
|
for (const row of rows) {
|
|
55
|
-
if (row.gap_from === null || row.gap_to === null)
|
|
73
|
+
if (row.gap_from === null || row.gap_to === null || row.gap_xmax === null)
|
|
56
74
|
continue;
|
|
57
|
-
gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.
|
|
75
|
+
gaps.push({ from: row.gap_from, to: row.gap_to, xmax: row.gap_xmax });
|
|
58
76
|
}
|
|
59
77
|
return { horizon: BigInt(first.horizon), gaps };
|
|
60
78
|
}
|
|
@@ -85,26 +103,6 @@ export async function insertConsumerIfAbsent(db, name, instanceId, startFrom = "
|
|
|
85
103
|
ON CONFLICT ("name", "instance_id") DO NOTHING`, [name, instanceId]);
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
|
-
// fw#2625: a consumer that moved from delivery: "per-instance" to "shared"
|
|
89
|
-
// leaves its old per-instance rows behind — no dispatcher will ever advance
|
|
90
|
-
// them again, and pruneEvents stays pinned to their stale cursor forever.
|
|
91
|
-
// Scoped by name + "not the shared sentinel" so a still-per-instance
|
|
92
|
-
// consumer's live rows (or an already-migrated __shared__ row) are never
|
|
93
|
-
// touched.
|
|
94
|
-
export async function deleteOrphanedPerInstanceConsumerRows(db, consumerNames,
|
|
95
|
-
// Passed in rather than imported from event-consumer-state.ts (which
|
|
96
|
-
// defines SHARED_INSTANCE_SENTINEL): that module already imports from
|
|
97
|
-
// this one for the DB queries it needs, so importing back would be a
|
|
98
|
-
// require cycle. The one caller lives in that same file and has the
|
|
99
|
-
// constant in scope.
|
|
100
|
-
sharedInstanceSentinel) {
|
|
101
|
-
// skip: nothing to delete — an empty ANY($1) would still hit the table,
|
|
102
|
-
// pointlessly, on every boot for an app with no migrated consumers yet.
|
|
103
|
-
if (consumerNames.length === 0)
|
|
104
|
-
return;
|
|
105
|
-
await asRawClient(db).unsafe(`DELETE FROM "kumiko_event_consumers"
|
|
106
|
-
WHERE "name" = ANY($1) AND "instance_id" != $2`, [consumerNames, sharedInstanceSentinel]);
|
|
107
|
-
}
|
|
108
106
|
// Lock-free pre-check for doPass: which (name, instance_id) pairs are
|
|
109
107
|
// provably idle — no locking, no xid, no WAL. A pair only qualifies when its
|
|
110
108
|
// row exists, its status isn't dead (dead must still go through
|
|
@@ -115,7 +113,7 @@ sharedInstanceSentinel) {
|
|
|
115
113
|
// `deadStatus` is passed in rather than imported as a value from
|
|
116
114
|
// event-consumer-state.ts (which defines ConsumerStatuses) — that module
|
|
117
115
|
// already imports from this one, so a value import back would be a require
|
|
118
|
-
// cycle
|
|
116
|
+
// cycle. The
|
|
119
117
|
// type-only import keeps the parameter pinned to the "dead" literal.
|
|
120
118
|
export async function selectProvablyIdleConsumerPairs(db, names, instanceIds, deadStatus) {
|
|
121
119
|
const rows = (await asRawClient(db).unsafe(`SELECT c."name" AS name, c."instance_id" AS instance_id
|
|
@@ -148,11 +146,24 @@ export async function markConsumerProcessing(db, name, instanceId) {
|
|
|
148
146
|
// caller's console.error — status and the cursor are left as-is too, since
|
|
149
147
|
// we don't know at this point whether the consumer should be "dead" (that's
|
|
150
148
|
// the deliverEvents/maxAttempts contract, which never ran this pass).
|
|
151
|
-
|
|
149
|
+
//
|
|
150
|
+
// Another replica sharing this (name, instance_id) may commit a successful
|
|
151
|
+
// pass between the rollback and this write. `cursorBeforePass` pins the
|
|
152
|
+
// write to the state the failed pass started from, so a stale error can't
|
|
153
|
+
// overwrite that success; a row another pass currently holds is skipped
|
|
154
|
+
// instead of blocking this catch path on its lock. Undefined when the pass
|
|
155
|
+
// failed before it read the cursor.
|
|
156
|
+
export async function recordConsumerPassFailure(db, name, instanceId, errorMessage, cursorBeforePass) {
|
|
152
157
|
await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
153
158
|
"last_error" = $1,
|
|
154
159
|
"updated_at" = now()
|
|
155
|
-
WHERE "name" = $2 AND "instance_id" = $3
|
|
160
|
+
WHERE "name" = $2 AND "instance_id" = $3
|
|
161
|
+
AND ($4::bigint IS NULL OR "last_processed_event_id" = $4::bigint)
|
|
162
|
+
AND ("name", "instance_id") IN (
|
|
163
|
+
SELECT "name", "instance_id" FROM "kumiko_event_consumers"
|
|
164
|
+
WHERE "name" = $2 AND "instance_id" = $3
|
|
165
|
+
FOR UPDATE SKIP LOCKED
|
|
166
|
+
)`, [errorMessage, name, instanceId, cursorBeforePass ?? null]);
|
|
156
167
|
}
|
|
157
168
|
export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcome) {
|
|
158
169
|
// Advancing the cursor proves this pass wasn't poison — reset the re-arm
|
|
@@ -189,8 +200,8 @@ export async function updateConsumerStatusReturning(db, name, instanceId, status
|
|
|
189
200
|
return rows[0];
|
|
190
201
|
}
|
|
191
202
|
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
|
|
192
|
-
//
|
|
193
|
-
|
|
203
|
+
// Full list, including ids skipped over between the old cursor and eventId that were not visible yet.
|
|
204
|
+
pendingGaps) {
|
|
194
205
|
const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
195
206
|
"last_processed_event_id" = $1,
|
|
196
207
|
"status" = 'idle',
|
|
@@ -199,10 +210,10 @@ gapToAdd = null) {
|
|
|
199
210
|
"rearm_count" = 0,
|
|
200
211
|
"last_failed_event_id" = NULL,
|
|
201
212
|
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
202
|
-
"pending_gaps" =
|
|
213
|
+
"pending_gaps" = $4::text::jsonb,
|
|
203
214
|
"updated_at" = now()
|
|
204
215
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
205
|
-
RETURNING *`, [eventId, name, instanceId,
|
|
216
|
+
RETURNING *`, [eventId, name, instanceId, JSON.stringify(pendingGaps)]));
|
|
206
217
|
return rows[0];
|
|
207
218
|
}
|
|
208
219
|
export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
|
|
1
2
|
import type { AnyDb } from "../query.js";
|
|
2
3
|
export declare function claimXactId(db: AnyDb): Promise<void>;
|
|
4
|
+
export declare function runAtomicEventWrite<T>(db: DbRunner, write: (runner: DbRunner) => Promise<T>): Promise<T>;
|
|
3
5
|
/** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
|
|
4
6
|
export declare function notifyPgChannel(db: AnyDb, channel: string): Promise<void>;
|
|
7
|
+
export declare const MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME = "events_membership_payload_tenant_idx";
|
|
5
8
|
export declare function ensureIdempotencyKeyIndex(db: AnyDb): Promise<void>;
|
|
9
|
+
export declare function ensureMembershipPayloadTenantIndex(db: AnyDb): Promise<void>;
|
|
6
10
|
export type SubsequentEventInsertParams = {
|
|
7
11
|
readonly aggregateId: string;
|
|
8
12
|
readonly aggregateType: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constraintOf, isLockNotAvailable, isTableAlreadyExists, isUniqueViolation, } from "../pg-error.js";
|
|
2
|
-
import { asRawClient, unsafeReadRetrying } from "../query.js";
|
|
2
|
+
import { asRawClient, runInNewTransaction, unsafeReadRetrying } from "../query.js";
|
|
3
3
|
// Gap-finality (event-dispatcher pending_gaps) needs every holder of an
|
|
4
4
|
// event id to already have a *real* xact id by the time it inserts — Postgres
|
|
5
5
|
// assigns those lazily on first write, so a bare INSERT alone doesn't
|
|
@@ -8,10 +8,25 @@ import { asRawClient, unsafeReadRetrying } from "../query.js";
|
|
|
8
8
|
export async function claimXactId(db) {
|
|
9
9
|
await asRawClient(db).unsafe(`SELECT pg_current_xact_id()`);
|
|
10
10
|
}
|
|
11
|
+
// Outside a transaction every statement commits on its own, so claim, insert and
|
|
12
|
+
// notify would be three commits and the claimed xact id would be released before
|
|
13
|
+
// the insert's. A pool runner (has begin()) therefore gets one transaction around
|
|
14
|
+
// the whole write; inside a caller's tx or savepoint the caller's tx already is
|
|
15
|
+
// that unit. Bun.SQL's TransactionSQL extends SQL and still exposes begin(), so
|
|
16
|
+
// only a runner without savepoint() counts as a pool.
|
|
17
|
+
export async function runAtomicEventWrite(db, write) {
|
|
18
|
+
const raw = asRawClient(db);
|
|
19
|
+
const isPool = typeof raw.begin === "function" && typeof raw.savepoint !== "function";
|
|
20
|
+
if (!isPool)
|
|
21
|
+
return write(db);
|
|
22
|
+
return runInNewTransaction(db, (tx) => write(tx));
|
|
23
|
+
}
|
|
11
24
|
/** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
|
|
12
25
|
export async function notifyPgChannel(db, channel) {
|
|
13
26
|
await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
|
|
14
27
|
}
|
|
28
|
+
const IDEMPOTENCY_INDEX_NAME = "events_idempotency_uq";
|
|
29
|
+
export const MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME = "events_membership_payload_tenant_idx";
|
|
15
30
|
// Tenant-scoped partial unique index over metadata.idempotencyKey.
|
|
16
31
|
// Expression index straight on the jsonb column — no dedicated key column,
|
|
17
32
|
// so it needs no INSERT-path change and covers admin-api's raw appends too
|
|
@@ -30,9 +45,9 @@ export async function notifyPgChannel(db, channel) {
|
|
|
30
45
|
// undefined = index doesn't exist at all (nothing to drop, CREATE below
|
|
31
46
|
// handles it); false = exists but INVALID (crashed mid-build, needs DROP +
|
|
32
47
|
// rebuild); true = exists and valid.
|
|
33
|
-
async function indexValidity(client) {
|
|
48
|
+
async function indexValidity(client, indexName = IDEMPOTENCY_INDEX_NAME) {
|
|
34
49
|
const rows = await client.unsafe(`SELECT i.indisvalid FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid ` +
|
|
35
|
-
`WHERE c.relname =
|
|
50
|
+
`WHERE c.relname = $1 AND i.indrelid = '"kumiko_events"'::regclass`, [indexName]);
|
|
36
51
|
return rows[0]?.indisvalid;
|
|
37
52
|
}
|
|
38
53
|
export async function ensureIdempotencyKeyIndex(db) {
|
|
@@ -72,6 +87,28 @@ export async function ensureIdempotencyKeyIndex(db) {
|
|
|
72
87
|
throw duplicateIdempotencyKeyErrorOr(e);
|
|
73
88
|
}
|
|
74
89
|
}
|
|
90
|
+
// forget-cleanup reads the membership history by payload->>'tenantId' (not the
|
|
91
|
+
// tenant_id column — cross-tenant SystemAdmin adds land under the actor's
|
|
92
|
+
// tenant), which no other index covers: without this it scans every event.
|
|
93
|
+
// Same CONCURRENTLY + INVALID-rebuild handling as the idempotency index.
|
|
94
|
+
export async function ensureMembershipPayloadTenantIndex(db) {
|
|
95
|
+
const client = asRawClient(db);
|
|
96
|
+
try {
|
|
97
|
+
if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === false) {
|
|
98
|
+
await client.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS "${MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME}"`);
|
|
99
|
+
}
|
|
100
|
+
await client.unsafe(`CREATE INDEX CONCURRENTLY IF NOT EXISTS "${MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME}" ON "kumiko_events" ` +
|
|
101
|
+
`((("payload"->>'tenantId'))) WHERE "aggregate_type" = 'tenant-membership'`);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (isBenignConcurrentIndexBuildRace(e)) {
|
|
105
|
+
// skip: sibling pod already built a valid index — this race loser is done.
|
|
106
|
+
if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === true)
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
75
112
|
// Two pods booting concurrently against the same DB (rolling deploy): both
|
|
76
113
|
// see the index missing/invalid and both start a DROP/CREATE CONCURRENTLY
|
|
77
114
|
// build. The loser typically does NOT get the plain duplicate-relation
|
|
@@ -2,6 +2,13 @@ import type { AnyDb } from "../query.js";
|
|
|
2
2
|
export type PiiBackfillStateRow = {
|
|
3
3
|
readonly cursor: string;
|
|
4
4
|
readonly completed: boolean;
|
|
5
|
+
readonly failedEventIds: readonly string[];
|
|
5
6
|
};
|
|
6
7
|
export declare function readPiiBackfillState(db: AnyDb, fingerprint: string): Promise<PiiBackfillStateRow | null>;
|
|
7
|
-
export
|
|
8
|
+
export type PiiBackfillStateUpdate = {
|
|
9
|
+
readonly cursor: bigint;
|
|
10
|
+
readonly markCompleted: boolean;
|
|
11
|
+
readonly addFailedEventIds: readonly string[];
|
|
12
|
+
readonly removeFailedEventIds: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function persistPiiBackfillState(db: AnyDb, fingerprint: string, update: PiiBackfillStateUpdate): Promise<void>;
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import { asRawClient } from "../query.js";
|
|
2
2
|
export async function readPiiBackfillState(db, fingerprint) {
|
|
3
|
-
const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed"
|
|
3
|
+
const rows = (await asRawClient(db).unsafe(`SELECT "cursor_event_id"::text AS "cursor", "completed_at" IS NOT NULL AS "completed",
|
|
4
|
+
"failed_event_ids"::text[] AS "failedEventIds"
|
|
4
5
|
FROM "kumiko_pii_backfill_state" WHERE "fingerprint" = $1`, [fingerprint]));
|
|
5
6
|
return rows[0] ?? null;
|
|
6
7
|
}
|
|
7
|
-
export async function persistPiiBackfillState(db, fingerprint,
|
|
8
|
-
await asRawClient(db).unsafe(`INSERT INTO "kumiko_pii_backfill_state" ("fingerprint", "cursor_event_id", "completed_at", "updated_at")
|
|
9
|
-
VALUES ($1, $2::bigint, CASE WHEN $3::boolean THEN now() ELSE NULL END, now())
|
|
8
|
+
export async function persistPiiBackfillState(db, fingerprint, update) {
|
|
9
|
+
await asRawClient(db).unsafe(`INSERT INTO "kumiko_pii_backfill_state" ("fingerprint", "cursor_event_id", "completed_at", "failed_event_ids", "updated_at")
|
|
10
|
+
VALUES ($1, $2::bigint, CASE WHEN $3::boolean THEN now() ELSE NULL END, $4::bigint[], now())
|
|
10
11
|
ON CONFLICT ("fingerprint") DO UPDATE SET
|
|
11
12
|
"cursor_event_id" = GREATEST("kumiko_pii_backfill_state"."cursor_event_id", EXCLUDED."cursor_event_id"),
|
|
12
13
|
"completed_at" = COALESCE("kumiko_pii_backfill_state"."completed_at", EXCLUDED."completed_at"),
|
|
13
|
-
"
|
|
14
|
+
"failed_event_ids" = ARRAY(
|
|
15
|
+
SELECT DISTINCT failed FROM unnest("kumiko_pii_backfill_state"."failed_event_ids" || EXCLUDED."failed_event_ids") AS failed
|
|
16
|
+
WHERE failed <> ALL($5::bigint[])
|
|
17
|
+
ORDER BY failed),
|
|
18
|
+
"updated_at" = now()`, [
|
|
19
|
+
fingerprint,
|
|
20
|
+
update.cursor.toString(),
|
|
21
|
+
update.markCompleted,
|
|
22
|
+
[...update.addFailedEventIds],
|
|
23
|
+
[...update.removeFailedEventIds],
|
|
24
|
+
]);
|
|
14
25
|
}
|
|
@@ -266,21 +266,50 @@ export async function assertNoUnreachableLiveRows(tx, projectionName, tableName,
|
|
|
266
266
|
`derived from the source event declare \`rowIdOf\` on r.extendEntityProjection. See ` +
|
|
267
267
|
`docs/reference/entity-write-patterns.md. Rebuild aborted; live table untouched.`);
|
|
268
268
|
}
|
|
269
|
+
// A key is configured, but not necessarily the one the live bidx values were
|
|
270
|
+
// computed with (typo'd secret, key rotated in only one of migrate-db / app).
|
|
271
|
+
// The replay would then rewrite every bidx column with the wrong key and the
|
|
272
|
+
// swap would succeed without any error. A NULL on either side is skipped: GDPR
|
|
273
|
+
// shredding legitimately nulls a bidx, and a plaintext-era row has none yet.
|
|
274
|
+
async function assertBlindIndexKeyMatchesLive(raw, tableName, quotedTable, bidxCols, projectionName) {
|
|
275
|
+
const differs = bidxCols
|
|
276
|
+
.map((c) => {
|
|
277
|
+
const col = quoteTableIdent(c.name);
|
|
278
|
+
return `(l.${col} IS NOT NULL AND s.${col} IS NOT NULL AND l.${col} IS DISTINCT FROM s.${col})`;
|
|
279
|
+
})
|
|
280
|
+
.join(" OR ");
|
|
281
|
+
const rows = await raw.unsafe(`SELECT count(*)::text AS total
|
|
282
|
+
FROM public.${quotedTable} l
|
|
283
|
+
JOIN ${SCHEMA_IDENT}.${quotedTable} s ON s."id" = l."id"
|
|
284
|
+
WHERE ${differs}`);
|
|
285
|
+
const count = Number(rows[0]?.total ?? "0");
|
|
286
|
+
// skip: every comparable bidx value matches the replay — same key as the live data
|
|
287
|
+
if (count === 0)
|
|
288
|
+
return;
|
|
289
|
+
throw new Error(`projection-rebuild "${projectionName}": "${tableName}" has ${count} row(s) whose blind-index ` +
|
|
290
|
+
`value differs from the replayed one. The KUMIKO_BLIND_INDEX_KEY in this process is not the ` +
|
|
291
|
+
`key those values were computed with, so the swap would break equality lookups on that ` +
|
|
292
|
+
`field (login, password reset) without any error. Use the same KUMIKO_BLIND_INDEX_KEY as ` +
|
|
293
|
+
`the running app in the process running this apply/rebuild. Rebuild aborted; live table ` +
|
|
294
|
+
`untouched.`);
|
|
295
|
+
}
|
|
269
296
|
// The bidx column is schema-driven, not key-driven: it exists NULL in a
|
|
270
297
|
// plaintext install and in the fw#1610 case (KMS configured, no index key),
|
|
271
298
|
// both of which are correct as-is. Only a POPULATED column with no key
|
|
272
299
|
// configured in THIS process means the rebuild is about to overwrite proof
|
|
273
300
|
// of a real index with NULL — that's the one provable data-loss case.
|
|
274
301
|
export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName) {
|
|
275
|
-
// skip: a key is configured — the replay recomputes every bidx column with it
|
|
276
|
-
if (configuredBlindIndexKey() !== undefined)
|
|
277
|
-
return;
|
|
278
302
|
const bidxCols = meta.columns.filter((c) => c.name.endsWith("_bidx"));
|
|
279
303
|
// skip: no blind-index column on this table — nothing the rebuild could lose
|
|
280
304
|
if (bidxCols.length === 0)
|
|
281
305
|
return;
|
|
282
306
|
const t = quoteTableIdent(tableName);
|
|
283
307
|
const raw = asRawClient(tx);
|
|
308
|
+
if (configuredBlindIndexKey() !== undefined) {
|
|
309
|
+
await assertBlindIndexKeyMatchesLive(raw, tableName, t, bidxCols, projectionName);
|
|
310
|
+
// skip: a configured key is verified against live rows above, the null-count check below is for keyless boots
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
284
313
|
const where = bidxCols.map((c) => `${quoteTableIdent(c.name)} IS NOT NULL`).join(" OR ");
|
|
285
314
|
const rows = await raw.unsafe(`SELECT count(*)::text AS total FROM public.${t} WHERE ${where}`);
|
|
286
315
|
const count = Number(rows[0]?.total ?? "0");
|
package/dist/db/render-ddl.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Pure renderer: EntityTableMeta → SQL DDL statements.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// The migrate generator (`kumiko-schema generate`) uses it to write the
|
|
3
|
+
// initial SQL files. The output is a starting point for review: the app
|
|
4
|
+
// author may hand-edit the SQL (extra index, partial index, BRIN, custom
|
|
5
|
+
// clauses) before committing it.
|
|
6
6
|
//
|
|
7
|
-
// NO-MAGIC-ON-DATA:
|
|
8
|
-
//
|
|
7
|
+
// NO-MAGIC-ON-DATA: this renderer never runs at app runtime, only as a build
|
|
8
|
+
// step. The runner reads the checked-in SQL, not renderer output.
|
|
9
9
|
import { pgTypeToSqlType } from "./dialect.js";
|
|
10
10
|
function quoteIdent(name) {
|
|
11
11
|
return `"${name.replace(/"/g, '""')}"`;
|
package/dist/db/table-builder.js
CHANGED
|
@@ -253,17 +253,17 @@ const ROW_META_WITH_SOFT_DELETE = new Set(Object.keys(buildBaseColumns(true)));
|
|
|
253
253
|
export function rowMetaFieldNames(softDelete) {
|
|
254
254
|
return softDelete ? ROW_META_WITH_SOFT_DELETE : ROW_META_BASE;
|
|
255
255
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return;
|
|
256
|
+
// pgTable's own meta stamp sees neither entity.tenancy nor that this table is an executor-managed projection;
|
|
257
|
+
// patch both so db.global()'s runtime guards match the type brands.
|
|
258
|
+
function stampEntityTableMeta(table, entity) {
|
|
260
259
|
const meta = table[KUMIKO_META_SYMBOL];
|
|
261
260
|
// skip: no meta stamp present — nothing to patch
|
|
262
261
|
if (!meta)
|
|
263
262
|
return;
|
|
264
263
|
table[KUMIKO_META_SYMBOL] = {
|
|
265
264
|
...meta,
|
|
266
|
-
|
|
265
|
+
source: "managed",
|
|
266
|
+
...(entity.tenancy === "global" && { tenancy: "global" }),
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
269
|
/**
|
|
@@ -416,7 +416,6 @@ export function buildEntityTable(entityName, entity, options) {
|
|
|
416
416
|
}
|
|
417
417
|
return indexes;
|
|
418
418
|
});
|
|
419
|
-
|
|
420
|
-
stampGlobalTenancyMeta(built, entity);
|
|
419
|
+
stampEntityTableMeta(built, entity);
|
|
421
420
|
return built;
|
|
422
421
|
}
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityTableMeta } from "@cosmicdrift/kumiko-types/entity-table-meta-types";
|
|
2
|
-
import type { EscapeHatchDeclaration, EscapeHatchReporter } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import type { EscapeHatchDeclaration, EscapeHatchReporter, EscapeHatchTarget } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import { type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
4
4
|
import { type TenantDb, type TenantDbMode, type UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
5
5
|
import { asRawClient } from "../db/query.js";
|
|
@@ -28,7 +28,10 @@ export type TenantDbGrants = {
|
|
|
28
28
|
};
|
|
29
29
|
export type PersonalDataGate = (tableName: string, keys: readonly string[], entity?: EntityDefinition) => void;
|
|
30
30
|
export declare function withUnsafeRawGrant(tenantDb: TenantDb, grant: EscapeHatchDeclaration | undefined): TenantDb;
|
|
31
|
-
export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string
|
|
31
|
+
export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, reason: string, options?: {
|
|
32
|
+
readonly deferReport?: boolean;
|
|
33
|
+
}): TenantDb;
|
|
34
|
+
export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
|
|
32
35
|
export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
|
|
33
36
|
export declare function createTenantDb(db: DbRunner, tenantId: TenantId, mode?: TenantDbMode, tracer?: Tracer, meter?: Meter, signal?: AbortSignal, grants?: TenantDbGrants): TenantDb;
|
|
34
37
|
export { asRawClient };
|