@cosmicdrift/kumiko-framework 0.388.0 → 0.389.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/AGENTS.md +3 -0
- package/agents/examples/golden-path.md +13 -1
- package/agents/examples/lifecycle-hooks.md +55 -2
- package/agents/examples/list-table-options.md +466 -0
- package/agents/examples/pdf-audit-export.md +4 -2
- package/agents/examples/state-machine.md +3 -2
- package/agents/examples/tenant-isolation.md +73 -1
- package/agents/examples.md +1 -0
- package/agents/widgets.md +1 -0
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-routes-shared.d.ts +2 -0
- package/dist/api/auth-routes-shared.js +5 -0
- package/dist/api/client-error-route.js +2 -3
- package/dist/api/entity-read-access.d.ts +2 -1
- package/dist/api/entity-read-access.js +14 -0
- package/dist/api/export/export-cells-reference.d.ts +9 -0
- package/dist/api/export/export-cells-reference.js +21 -0
- package/dist/api/export/export-cells.d.ts +11 -0
- package/dist/api/export/export-cells.js +136 -0
- package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
- package/dist/api/export/export-ciphertext-guard.js +27 -0
- package/dist/api/export/export-columns.d.ts +29 -0
- package/dist/api/export/export-columns.js +85 -0
- package/dist/api/export/export-errors.d.ts +4 -0
- package/dist/api/export/export-errors.js +14 -0
- package/dist/api/export/export-route.d.ts +4 -0
- package/dist/api/export/export-route.js +74 -0
- package/dist/api/export/export-rows.d.ts +13 -0
- package/dist/api/export/export-rows.js +62 -0
- package/dist/api/export/export-screen.d.ts +12 -0
- package/dist/api/export/export-screen.js +47 -0
- package/dist/api/export/export-xlsx-writer.d.ts +4 -0
- package/dist/api/export/export-xlsx-writer.js +21 -0
- package/dist/api/extra-route.d.ts +4 -0
- package/dist/api/pii-leak-guard.d.ts +3 -0
- package/dist/api/pii-leak-guard.js +3 -2
- package/dist/api/routes.d.ts +16 -0
- package/dist/api/routes.js +2 -2
- package/dist/api/server-extra-routes.js +3 -0
- package/dist/api/server-routes.d.ts +1 -1
- package/dist/api/server-routes.js +8 -2
- package/dist/api/server-wiring.js +2 -0
- package/dist/api/sse-route.d.ts +2 -1
- package/dist/api/sse-route.js +11 -1
- package/dist/config/index.d.ts +3 -1
- package/dist/crypto/pg-kms-adapter.d.ts +1 -0
- package/dist/crypto/pg-kms-adapter.js +12 -18
- package/dist/db/aggregate-sql.d.ts +2 -0
- package/dist/db/aggregate-sql.js +10 -0
- package/dist/db/closed-connection.js +12 -19
- package/dist/db/connection.d.ts +4 -5
- package/dist/db/connection.js +29 -20
- package/dist/db/driver-row-coercion.js +4 -4
- package/dist/db/driver-value-preparation.js +6 -4
- package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
- package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
- package/dist/db/event-store-executor-read-aggregate.js +36 -5
- package/dist/db/event-store-executor-read-filter.js +6 -3
- package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
- package/dist/db/event-store-executor-write-atomic.js +41 -0
- package/dist/db/event-store-executor-write-steps.d.ts +0 -3
- package/dist/db/event-store-executor-write-steps.js +0 -5
- package/dist/db/event-store-executor-write.js +15 -15
- package/dist/db/event-store-executor.js +2 -1
- package/dist/db/executor-managed-tables.js +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +3 -3
- package/dist/db/pg-error.js +26 -21
- package/dist/db/queries/backfill-pii.js +3 -6
- package/dist/db/queries/database-now.js +2 -1
- package/dist/db/queries/shadow-swap.js +2 -3
- package/dist/db/query.d.ts +14 -0
- package/dist/db/query.js +68 -43
- package/dist/db/reference-data.js +14 -2
- package/dist/db/statement-timeout.js +2 -1
- package/dist/db/tenant-db-upsert.d.ts +5 -0
- package/dist/db/tenant-db-upsert.js +91 -0
- package/dist/db/tenant-db.js +11 -1
- package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
- package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
- package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
- package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
- package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
- package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
- package/dist/engine/boot-validator/list-column-layout.js +41 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
- package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
- package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
- package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-export-options.js +27 -0
- package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
- package/dist/engine/boot-validator/list-table-options.js +77 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
- package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
- package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
- package/dist/engine/boot-validator/projection-list-screens.js +3 -13
- package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
- package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
- package/dist/engine/boot-validator/screens-lists.js +12 -2
- package/dist/engine/build-app-schema.d.ts +2 -1
- package/dist/engine/build-app-schema.js +9 -1
- package/dist/engine/build-config-feature-schema.js +6 -1
- package/dist/engine/define-feature.js +4 -0
- package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/index.js +1 -1
- package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
- package/dist/engine/feature-ast/extractors/round1.js +6 -0
- package/dist/engine/feature-ast/index.d.ts +1 -1
- package/dist/engine/feature-ast/parse.js +3 -1
- package/dist/engine/feature-ast/patch.d.ts +2 -0
- package/dist/engine/feature-ast/patch.js +1 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
- package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
- package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
- package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
- package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
- package/dist/engine/feature-ast/patterns.d.ts +5 -1
- package/dist/engine/feature-ast/patterns.js +1 -0
- package/dist/engine/feature-ast/render.js +4 -0
- package/dist/engine/feature-builder-state.d.ts +1 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/field-access.d.ts +1 -0
- package/dist/engine/field-access.js +12 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/pattern-library/library.js +2 -1
- package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
- package/dist/engine/pattern-library/static-schemas.js +9 -0
- package/dist/engine/screen-filter-ops.js +2 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
- package/dist/engine/types/feature.d.ts +1 -0
- package/dist/engine/types/index.d.ts +4 -3
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +3 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +7 -4
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/create-file-ref-from-bytes.js +3 -0
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +11 -2
- package/dist/files/file-routes.d.ts +2 -0
- package/dist/files/file-routes.js +29 -2
- package/dist/files/provider-resolver.d.ts +1 -0
- package/dist/files/provider-resolver.js +1 -1
- package/dist/files/record-write-check.d.ts +6 -0
- package/dist/files/record-write-check.js +13 -0
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/files/upload-ledger.js +4 -4
- package/dist/i18n/required-surface-keys.js +7 -2
- package/dist/internal/testing.js +1 -1
- package/dist/jobs/job-runner-scope.js +6 -6
- package/dist/jobs/job-runner.js +3 -0
- package/dist/observability/escape-hatch-report.js +9 -2
- package/dist/pipeline/after-rollback-sink.d.ts +6 -0
- package/dist/pipeline/after-rollback-sink.js +32 -0
- package/dist/pipeline/dispatch-batch.js +8 -1
- package/dist/pipeline/dispatch-shared.js +4 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +1 -0
- package/dist/pipeline/event-dispatcher.js +6 -9
- package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
- package/dist/pipeline/handler-context-dispatch.js +13 -1
- package/dist/pipeline/handler-context-events.d.ts +2 -4
- package/dist/pipeline/handler-context-events.js +6 -4
- package/dist/pipeline/system-hooks.d.ts +3 -1
- package/dist/pipeline/system-hooks.js +55 -6
- package/dist/pipeline/write-result-masking.js +13 -10
- package/dist/schema-cli/baseline-command.js +1 -1
- package/dist/schema-cli/status-command.js +1 -1
- package/dist/search/reindex-entity.js +15 -5
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-changelog.d.ts +2 -0
- package/dist/upgrade-changelog.js +14 -0
- package/dist/upgrade-cli.d.ts +3 -12
- package/dist/upgrade-cli.js +22 -76
- package/dist/upgrade-marker-parse.d.ts +32 -0
- package/dist/upgrade-marker-parse.js +73 -0
- package/package.json +9 -6
- package/src/changes.json +245 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/db-factory.ts +1 -1
- package/dist/engine/provider-plugin-context.d.ts +0 -3
- package/dist/engine/provider-plugin-context.js +0 -28
|
@@ -199,8 +199,9 @@ function pushRelatedListSectionKeys(out, section) {
|
|
|
199
199
|
}
|
|
200
200
|
for (const action of section.rowActions ?? [])
|
|
201
201
|
pushRowActionKeys(out, action);
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
const grouping = section.table?.grouping;
|
|
203
|
+
pushKey(out, grouping?.label);
|
|
204
|
+
for (const label of Object.values(grouping?.labels ?? {}))
|
|
204
205
|
pushKey(out, label);
|
|
205
206
|
}
|
|
206
207
|
function pushListEmptyStateKeys(out, emptyState) {
|
|
@@ -237,6 +238,8 @@ function pushListChromeKeys(out, list) {
|
|
|
237
238
|
pushRowActionKeys(out, action);
|
|
238
239
|
for (const action of list.toolbarActions ?? [])
|
|
239
240
|
pushToolbarActionKeys(out, action);
|
|
241
|
+
for (const bulk of list.bulkActions ?? [])
|
|
242
|
+
pushKey(out, bulk.confirm);
|
|
240
243
|
}
|
|
241
244
|
function collectEntityListKeys(ctx, list) {
|
|
242
245
|
for (const col of list.columns) {
|
|
@@ -372,6 +375,8 @@ function collectProjectionDetailKeys(ctx, detail) {
|
|
|
372
375
|
}
|
|
373
376
|
if (section.kind === "relatedList") {
|
|
374
377
|
pushRelatedListSectionKeys(ctx.out, section);
|
|
378
|
+
for (const bulk of section.bulkActions ?? [])
|
|
379
|
+
pushKey(ctx.out, bulk.confirm);
|
|
375
380
|
continue;
|
|
376
381
|
}
|
|
377
382
|
if (isWriteFormEditSection(section)) {
|
package/dist/internal/testing.js
CHANGED
|
@@ -14,5 +14,5 @@ export function withExecutorManagedWritesForTests(db) {
|
|
|
14
14
|
}
|
|
15
15
|
if (isDbRunner(db))
|
|
16
16
|
return withExecutorManagedWrites(db);
|
|
17
|
-
throw new Error("withExecutorManagedWritesForTests: expected a TenantDb or a
|
|
17
|
+
throw new Error("withExecutorManagedWritesForTests: expected a TenantDb or a Bun.SQL runner");
|
|
18
18
|
}
|
|
@@ -13,21 +13,21 @@ export function laneForJob(def) {
|
|
|
13
13
|
// lane (jobDef.runIn is static), but walking both is cheap and avoids
|
|
14
14
|
// a subtle bug if someone ever reassigns a job to a different lane
|
|
15
15
|
// between deploys while old queue contents are still draining.
|
|
16
|
+
// One getJobs per queue reads the requested lists in a single Lua script, so a job
|
|
17
|
+
// moving from waiting to active between two separate reads can no longer vanish
|
|
18
|
+
// from the count; that gap let a third dispatch pass the cap in CI.
|
|
16
19
|
// `activeOnly` + `self` serve the defer check, which runs inside the job being
|
|
17
20
|
// counted: it must not see waiting jobs or itself, and only counts jobs that
|
|
18
21
|
// became active before it. Without that order, workers that fetch a burst of
|
|
19
22
|
// jobs together all see each other as active and all defer, again and again.
|
|
20
23
|
export async function isOverPerTenantLimit(scope, jobName, tenantId, max, countFilter = {}) {
|
|
21
24
|
const { queues } = scope;
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
queues.worker.getActive(),
|
|
25
|
-
...(countFilter.activeOnly ? [] : [queues.api.getWaiting(), queues.worker.getWaiting()]),
|
|
26
|
-
]);
|
|
25
|
+
const types = countFilter.activeOnly ? ["active"] : ["active", "waiting"];
|
|
26
|
+
const results = await Promise.all([queues.api.getJobs(types), queues.worker.getJobs(types)]);
|
|
27
27
|
let count = 0;
|
|
28
28
|
for (const list of results) {
|
|
29
29
|
for (const j of list) {
|
|
30
|
-
if (j.name !== jobName)
|
|
30
|
+
if (j === undefined || j.name !== jobName)
|
|
31
31
|
continue;
|
|
32
32
|
if (countFilter.self && !isActiveBefore(j, countFilter.self))
|
|
33
33
|
continue;
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -264,6 +264,9 @@ export function createJobRunner(options) {
|
|
|
264
264
|
// a hanging process instead of a non-zero exit.
|
|
265
265
|
await worker?.close();
|
|
266
266
|
worker = null;
|
|
267
|
+
// A failed start() may never be followed by stop(); the abandoned gate must not keep
|
|
268
|
+
// writing to DB/Redis while the process shuts down.
|
|
269
|
+
await settleAbandonedBootGates(Math.min(bootRedisTimeoutMs, STOP_QUEUE_READY_TIMEOUT_MS));
|
|
267
270
|
throw err;
|
|
268
271
|
}
|
|
269
272
|
finally {
|
|
@@ -40,6 +40,12 @@ const READ_WINDOW_KINDS = new Set([
|
|
|
40
40
|
// A long-lived worker must leave an audit event inside every retention window
|
|
41
41
|
// (90 days), or its crons vanish from the log once the first event is pruned.
|
|
42
42
|
const PROCESS_REAUDIT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
// Declared escape hatches of system crons are audited once per day and process for each
|
|
44
|
+
// (handler, kind, tenant, target): the declaration, the system actor and the reason are
|
|
45
|
+
// identical on every run, so repeats carry no information. The target is part of the
|
|
46
|
+
// key only where it names a table (one run can write several global tables); a row target
|
|
47
|
+
// would add a key and an audit event per touched foreign row.
|
|
48
|
+
const TABLE_TARGET_KINDS = new Set(["global-write"]);
|
|
43
49
|
// Row-level targets add a key per touched row; without a sweep a long-lived worker keeps them all.
|
|
44
50
|
function sweepExpiredProcessDedup(dedup, nowMs) {
|
|
45
51
|
if (nowMs - dedup.lastSweep.atMs >= PROCESS_REAUDIT_INTERVAL_MS) {
|
|
@@ -157,12 +163,13 @@ export function createEscapeHatchReporter(opts) {
|
|
|
157
163
|
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
158
164
|
const { processDedup } = opts;
|
|
159
165
|
if (processDedup?.kinds.has(kind)) {
|
|
166
|
+
const keyTarget = TABLE_TARGET_KINDS.has(kind) ? target : undefined;
|
|
160
167
|
const processKey = JSON.stringify([
|
|
161
168
|
opts.handler,
|
|
162
169
|
kind,
|
|
163
170
|
opts.tenantId,
|
|
164
|
-
|
|
165
|
-
|
|
171
|
+
keyTarget?.id ?? null,
|
|
172
|
+
keyTarget?.tenantId ?? null,
|
|
166
173
|
]);
|
|
167
174
|
const nowMs = now();
|
|
168
175
|
const lastAuditedAt = processDedup.lastAuditedAt.get(processKey);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { createFallbackLogger } from "../logging/utils.ts";
|
|
2
|
+
import type { AfterCommitHook } from "./dispatcher-utils.ts";
|
|
3
|
+
export type AfterRollbackHook = () => Promise<void>;
|
|
4
|
+
export declare function afterRollbackHooksOf(commitSink: AfterCommitHook[]): AfterRollbackHook[];
|
|
5
|
+
export declare function carrySavepointRollbackHooks(innerCommitSink: AfterCommitHook[], outerCommitSink: AfterCommitHook[], savepointRolledBack: boolean): void;
|
|
6
|
+
export declare function flushAfterRollback(commitSink: AfterCommitHook[], logError: Pick<ReturnType<typeof createFallbackLogger>, "error">): Promise<void>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Side channel keyed by the afterCommit sink array, so executeWrite and friends keep their signatures.
|
|
2
|
+
// Invariant: every registration runs at most once, and only if the work it compensates was discarded.
|
|
3
|
+
const rollbackSinks = new WeakMap();
|
|
4
|
+
export function afterRollbackHooksOf(commitSink) {
|
|
5
|
+
const existing = rollbackSinks.get(commitSink);
|
|
6
|
+
if (existing)
|
|
7
|
+
return existing;
|
|
8
|
+
const created = [];
|
|
9
|
+
rollbackSinks.set(commitSink, created);
|
|
10
|
+
return created;
|
|
11
|
+
}
|
|
12
|
+
// A rolled-back savepoint is not compensated immediately: the compensation could wait on locks of
|
|
13
|
+
// the still-open outer transaction. It runs once after that transaction ends, via the commit sink
|
|
14
|
+
// on commit or the rollback sink on rollback.
|
|
15
|
+
export function carrySavepointRollbackHooks(innerCommitSink, outerCommitSink, savepointRolledBack) {
|
|
16
|
+
const innerHooks = rollbackSinks.get(innerCommitSink) ?? [];
|
|
17
|
+
if (innerHooks.length > 0) {
|
|
18
|
+
if (savepointRolledBack)
|
|
19
|
+
outerCommitSink.push(...innerHooks);
|
|
20
|
+
afterRollbackHooksOf(outerCommitSink).push(...innerHooks);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function flushAfterRollback(commitSink, logError) {
|
|
24
|
+
const hooks = rollbackSinks.get(commitSink) ?? [];
|
|
25
|
+
const outcomes = await Promise.allSettled(hooks.map((hook) => hook()));
|
|
26
|
+
for (const outcome of outcomes) {
|
|
27
|
+
if (outcome.status === "rejected") {
|
|
28
|
+
const detail = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
29
|
+
logError.error("afterRollback hook failed", { error: detail });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -3,6 +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 { flushAfterRollback } from "./after-rollback-sink.js";
|
|
6
7
|
import { resolveAppPoolDb } from "./dispatch-shared.js";
|
|
7
8
|
import { executeNestedWrite, executeWrite } from "./dispatch-write.js";
|
|
8
9
|
import { BatchRollback, isLifecycleResult, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
@@ -36,6 +37,9 @@ async function runReservedCommand(scope, cmd, index, tx) {
|
|
|
36
37
|
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
37
38
|
return res;
|
|
38
39
|
}
|
|
40
|
+
function rollbackLogger(scope) {
|
|
41
|
+
return createFallbackLogger("dispatcher", scope.ctx.appContext.log);
|
|
42
|
+
}
|
|
39
43
|
// Sequential fallback without an outer transaction. A failure result and a throw both end the
|
|
40
44
|
// batch, so both give the reserved capacity back.
|
|
41
45
|
async function runCommandsWithoutTransaction(scope, commands, results) {
|
|
@@ -50,12 +54,14 @@ async function runCommandsWithoutTransaction(scope, commands, results) {
|
|
|
50
54
|
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
51
55
|
// matching the semantic "failure = side-effects don't fire".
|
|
52
56
|
await scope.reservations.releaseAll();
|
|
57
|
+
await flushAfterRollback(scope.afterCommitHooks, rollbackLogger(scope));
|
|
53
58
|
return { index: i, error: res.error };
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
catch (e) {
|
|
58
63
|
await scope.reservations.releaseAll();
|
|
64
|
+
await flushAfterRollback(scope.afterCommitHooks, rollbackLogger(scope));
|
|
59
65
|
throw e;
|
|
60
66
|
}
|
|
61
67
|
return undefined;
|
|
@@ -208,7 +214,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
208
214
|
}, reservations.error.httpStatus >= 500);
|
|
209
215
|
}
|
|
210
216
|
// batch() opens its own outer transaction — needs the top-level
|
|
211
|
-
// connection's `.begin()` (
|
|
217
|
+
// connection's `.begin()` (a transaction handle cannot begin another one).
|
|
212
218
|
const db = resolveAppPoolDb(ctx);
|
|
213
219
|
if (!db || runsWithoutTransaction) {
|
|
214
220
|
// Without a DB connection (or for a perCommand handler, whose writes open their own transactions)
|
|
@@ -250,6 +256,7 @@ async function runBatchBody(ctx, commands, user, requestId, inheritedOrigin) {
|
|
|
250
256
|
// Rollback or failed COMMIT: the transaction is over, so the reserved capacity goes back.
|
|
251
257
|
// The release is outcome-safe: it only gives back a reservation row the handler tx did not delete.
|
|
252
258
|
await reservations.releaseAll();
|
|
259
|
+
await flushAfterRollback(afterCommitHooks, createFallbackLogger("dispatcher", context.log));
|
|
253
260
|
if (e instanceof BatchRollback) {
|
|
254
261
|
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
255
262
|
// deterministic and stays cached; a 5xx may be transient.
|
|
@@ -140,6 +140,9 @@ export function applyMemberResolutionReadOnly(handlerContext) {
|
|
|
140
140
|
scheduleAfterCommit: () => {
|
|
141
141
|
throw memberResolutionReadOnlyDenied();
|
|
142
142
|
},
|
|
143
|
+
scheduleAfterRollback: () => {
|
|
144
|
+
throw memberResolutionReadOnlyDenied();
|
|
145
|
+
},
|
|
143
146
|
// A read as a member gets no system-scope DB, preSave pipeline or file storage.
|
|
144
147
|
runPreSave: undefined,
|
|
145
148
|
systemDb: undefined,
|
|
@@ -199,6 +202,7 @@ export function denyTransactionBoundSurfaces(handlerContext) {
|
|
|
199
202
|
write: denySurface(beforeTransactionDenied, "write"),
|
|
200
203
|
writeAs: denySurface(beforeTransactionDenied, "writeAs"),
|
|
201
204
|
scheduleAfterCommit: denySurface(beforeTransactionDenied, "scheduleAfterCommit"),
|
|
205
|
+
scheduleAfterRollback: denySurface(beforeTransactionDenied, "scheduleAfterRollback"),
|
|
202
206
|
...(handlerContext.notify && { notify: denySurface(beforeTransactionDenied, "notify") }),
|
|
203
207
|
...(handlerContext.jobRunner && {
|
|
204
208
|
jobRunner: denyingJobRunnerProxy(() => beforeTransactionDenied("jobRunner")),
|
|
@@ -212,9 +212,11 @@ export async function deliverEvents(consumer, events, context, maxAttempts, stat
|
|
|
212
212
|
resolve(row.id);
|
|
213
213
|
continue;
|
|
214
214
|
}
|
|
215
|
-
attempts += 1;
|
|
216
215
|
lastError = errMessage;
|
|
217
216
|
failedEventId = row.id;
|
|
217
|
+
if (consumer.errorPolicy?.isTransientError?.(e))
|
|
218
|
+
break;
|
|
219
|
+
attempts += 1;
|
|
218
220
|
if (attempts >= effectiveMaxAttempts)
|
|
219
221
|
deadLettered = true;
|
|
220
222
|
break;
|
|
@@ -7,6 +7,7 @@ export type EventConsumerBatchHandler = (events: readonly StoredEvent[], ctx: Ap
|
|
|
7
7
|
export type EventConsumerErrorPolicy = {
|
|
8
8
|
readonly skipApplyErrors?: boolean;
|
|
9
9
|
readonly maxAttempts?: number;
|
|
10
|
+
readonly isTransientError?: (error: unknown) => boolean;
|
|
10
11
|
};
|
|
11
12
|
export type EventConsumer = {
|
|
12
13
|
readonly name: string;
|
|
@@ -16,12 +16,10 @@ const PENDING_GAPS_WARN_THRESHOLD = 1000;
|
|
|
16
16
|
// Only these say the database socket itself is gone; timeouts, schema or pool errors do not
|
|
17
17
|
// imply the dedicated LISTEN connection dropped.
|
|
18
18
|
const CONNECTION_LOSS_CODES = new Set([
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"ECONNREFUSED",
|
|
24
|
-
"ECONNRESET",
|
|
19
|
+
"ERR_POSTGRES_CONNECTION_CLOSED",
|
|
20
|
+
"ERR_POSTGRES_CONNECTION_REFUSED",
|
|
21
|
+
"ERR_POSTGRES_CONNECTION_TIMEOUT",
|
|
22
|
+
"ERR_POSTGRES_CONNECTION_FAILED",
|
|
25
23
|
"57P01",
|
|
26
24
|
]);
|
|
27
25
|
function isConnectionLossError(e) {
|
|
@@ -111,7 +109,7 @@ export function createEventDispatcher(options) {
|
|
|
111
109
|
let pgSubscription = null;
|
|
112
110
|
// Bounds how many consumer turns hold a DB transaction at once. Each turn's
|
|
113
111
|
// db.begin() checks out one pool connection, plus any writes the handler
|
|
114
|
-
// itself makes via context.db — the app db pool defaults to
|
|
112
|
+
// itself makes via context.db — the app db pool defaults to Bun.SQL's
|
|
115
113
|
// max=10 (db/connection.ts, no DATABASE_POOL_MAX override) and is shared
|
|
116
114
|
// with HTTP request handlers, so turns can't be allowed to claim it all.
|
|
117
115
|
// A pool no larger than this limit can deadlock: every connection held by
|
|
@@ -493,8 +491,7 @@ export function createEventDispatcher(options) {
|
|
|
493
491
|
const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
|
|
494
492
|
// Fires on initial connect and again when the driver's own
|
|
495
493
|
// re-LISTEN lands after a drop; during an outage that re-LISTEN
|
|
496
|
-
// waits in
|
|
497
|
-
// after the DB returns), polling covers
|
|
494
|
+
// waits in the driver's reconnect backoff, polling covers
|
|
498
495
|
// delivery meanwhile. Never unlisten()
|
|
499
496
|
// + listen() by hand: that leaves duplicate listeners.
|
|
500
497
|
// ponytail: no own LISTEN reconnect, the backoff gap is polled;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WriteResult } from "../engine/types/index.ts";
|
|
2
|
+
import { type AfterRollbackHook } from "./after-rollback-sink.ts";
|
|
2
3
|
import { type HandlerTransactionScope } from "./dispatch-shared.ts";
|
|
3
4
|
import { type AfterCommitHook } from "./dispatcher-utils.ts";
|
|
4
5
|
import type { HandlerContextScope } from "./handler-context-scope.ts";
|
|
@@ -17,5 +18,6 @@ export declare function buildDispatchFacet(scope: HandlerContextScope, sinks: Co
|
|
|
17
18
|
};
|
|
18
19
|
scheduleFragment: {
|
|
19
20
|
scheduleAfterCommit?: ((hook: AfterCommitHook) => void) | undefined;
|
|
21
|
+
scheduleAfterRollback?: ((hook: AfterRollbackHook) => void) | undefined;
|
|
20
22
|
};
|
|
21
23
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { runWithWriteOrigin } from "../api/request-context.js";
|
|
1
2
|
import { runInSavepoint } from "../db/query.js";
|
|
2
3
|
import { writeFailure } from "../errors/index.js";
|
|
4
|
+
import { afterRollbackHooksOf, carrySavepointRollbackHooks, } from "./after-rollback-sink.js";
|
|
3
5
|
import { runBatch, unwrapSingle } from "./dispatch-batch.js";
|
|
4
6
|
import { executeQuery } from "./dispatch-query.js";
|
|
5
7
|
import { perCommandNestedDenied } from "./dispatch-shared.js";
|
|
@@ -40,9 +42,11 @@ async function runNestedWriteInSavepoint(scope, sinks, asUser, targetType, paylo
|
|
|
40
42
|
return innerResult;
|
|
41
43
|
});
|
|
42
44
|
bridgeSink.push(...innerAfterCommitHooks);
|
|
45
|
+
carrySavepointRollbackHooks(innerAfterCommitHooks, bridgeSink, false);
|
|
43
46
|
return result;
|
|
44
47
|
}
|
|
45
48
|
catch (e) {
|
|
49
|
+
carrySavepointRollbackHooks(innerAfterCommitHooks, bridgeSink, true);
|
|
46
50
|
if (e instanceof NestedWriteRollback)
|
|
47
51
|
return e.failure;
|
|
48
52
|
throw e;
|
|
@@ -64,6 +68,9 @@ export function buildDispatchFacet(scope, sinks) {
|
|
|
64
68
|
const scheduleAfterCommit = (hook) => {
|
|
65
69
|
sinks.bridgeSink.push(hook);
|
|
66
70
|
};
|
|
71
|
+
const scheduleAfterRollback = (hook) => {
|
|
72
|
+
afterRollbackHooksOf(sinks.bridgeSink).push(() => runWithWriteOrigin(origin, hook));
|
|
73
|
+
};
|
|
67
74
|
const executeNestedWriteInSavepoint = createSerializedNestedWriter(scope, sinks);
|
|
68
75
|
// Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
|
|
69
76
|
// executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
|
|
@@ -84,6 +91,11 @@ export function buildDispatchFacet(scope, sinks) {
|
|
|
84
91
|
write: (targetType, payload) => writeAsUser(user, targetType, payload),
|
|
85
92
|
writeAs: identitySwitch.writeAs,
|
|
86
93
|
},
|
|
87
|
-
scheduleFragment: {
|
|
94
|
+
scheduleFragment: {
|
|
95
|
+
...(sinks.hasLiveCommitSink && { scheduleAfterCommit }),
|
|
96
|
+
// perCommand and no-tx paths have earlier commands already committed: nothing to compensate on rollback.
|
|
97
|
+
...(sinks.hasLiveCommitSink &&
|
|
98
|
+
transactionScope === "inTransaction" && { scheduleAfterRollback }),
|
|
99
|
+
},
|
|
88
100
|
};
|
|
89
101
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AggregateStreamHandle, AppendEventArgs, AppendEventFn, FetchForWritingArgs } from "../engine/types/index.ts";
|
|
1
|
+
import type { AggregateStreamHandle, AppendEventArgs, AppendEventFn, FetchForWritingArgs, HandlerContext } from "../engine/types/index.ts";
|
|
2
2
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors.ts";
|
|
3
3
|
import { type StoredEvent } from "../event-store/event-store.ts";
|
|
4
4
|
import { type LoadAggregateWithSnapshotOptions, type LoadAggregateWithSnapshotResult, type SnapshotReducer } from "../event-store/snapshot.ts";
|
|
@@ -20,9 +20,7 @@ export declare function buildEventStoreFacet(scope: HandlerContextScope): {
|
|
|
20
20
|
conflict: EventStoreIdempotentAppendConflictError;
|
|
21
21
|
}>;
|
|
22
22
|
fetchForWriting: (args: FetchForWritingArgs) => Promise<AggregateStreamHandle>;
|
|
23
|
-
loadAggregate: (aggregateId: string, loadOptions?:
|
|
24
|
-
readonly asOf?: Temporal.Instant;
|
|
25
|
-
}) => Promise<readonly StoredEvent[]>;
|
|
23
|
+
loadAggregate: (aggregateId: string, loadOptions?: Parameters<HandlerContext["loadAggregate"]>[1]) => Promise<readonly StoredEvent[]>;
|
|
26
24
|
archiveStream: (aggregateId: string, archiveArgs: {
|
|
27
25
|
readonly aggregateType: string;
|
|
28
26
|
readonly reason?: string;
|
|
@@ -38,9 +38,9 @@ async function appendDomainEvent(ctx, args, user, tx, callerFeature) {
|
|
|
38
38
|
}, args);
|
|
39
39
|
}
|
|
40
40
|
// Savepoint-scoped append: catches a losing writer's VersionConflictError
|
|
41
|
-
// without poisoning the rest of the handler's transaction.
|
|
42
|
-
// aborts the WHOLE
|
|
43
|
-
// (SQLSTATE 25P02) even if the JS error is caught — runInSavepoint
|
|
41
|
+
// without poisoning the rest of the handler's transaction. Postgres
|
|
42
|
+
// aborts the WHOLE transaction on a statement error
|
|
43
|
+
// (SQLSTATE 25P02 until rollback) even if the JS error is caught — runInSavepoint
|
|
44
44
|
// wraps the append in a real SAVEPOINT so only that nested scope rolls
|
|
45
45
|
// back on conflict, and subsequent statements in the outer tx (e.g. a
|
|
46
46
|
// dedup-anchor insert) still succeed. Use when a handler must react to
|
|
@@ -127,7 +127,9 @@ function buildLoadAggregate(scope) {
|
|
|
127
127
|
const dbSource = requireDbSource(ctx, tx, `ctx.loadAggregate("${aggregateId}")`);
|
|
128
128
|
const events = loadOptions?.asOf
|
|
129
129
|
? await loadAggregateAsOf(dbSource, aggregateId, user.tenantId, loadOptions.asOf)
|
|
130
|
-
: await loadAggregate(dbSource, aggregateId, user.tenantId
|
|
130
|
+
: await loadAggregate(dbSource, aggregateId, user.tenantId, {
|
|
131
|
+
...(loadOptions?.atVersion !== undefined && { atVersion: loadOptions.atVersion }),
|
|
132
|
+
});
|
|
131
133
|
return upcastStoredEvents(events, registry.getEventUpcasters(), {
|
|
132
134
|
db: dbSource,
|
|
133
135
|
tenantId: user.tenantId,
|
|
@@ -22,4 +22,6 @@ export declare function createSseBroadcastEventConsumer(sseBroker: SseBroker): E
|
|
|
22
22
|
export declare const JOB_TRIGGER_CONSUMER_NAME = "system:consumer:job-trigger";
|
|
23
23
|
export declare function createJobTriggerEventConsumer(jobRunner: JobRunnerRef, registry: Registry): EventConsumer;
|
|
24
24
|
export declare const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
|
|
25
|
-
export declare function createAccessInvalidationEventConsumer(sseBroker: SseBroker
|
|
25
|
+
export declare function createAccessInvalidationEventConsumer(sseBroker: SseBroker, opts?: {
|
|
26
|
+
readonly now?: () => number;
|
|
27
|
+
}): EventConsumer;
|
|
@@ -552,9 +552,58 @@ function readPatRevokedScope(payload) {
|
|
|
552
552
|
const tokenIds = readNonEmptyStringArray(payload["tokenIds"]);
|
|
553
553
|
return tokenIds ? { kind: "pat-tokens", tokenIds } : { kind: "user" };
|
|
554
554
|
}
|
|
555
|
-
|
|
555
|
+
const ACCESS_INVALIDATION_BACKOFF_INITIAL_MS = 250;
|
|
556
|
+
const ACCESS_INVALIDATION_BACKOFF_MAX_MS = 10_000;
|
|
557
|
+
// Only the publish can fail because Redis is unreachable. The consumer must keep retrying those
|
|
558
|
+
// until Redis is back (dead-lettering would silently stop every later revocation reaching open
|
|
559
|
+
// streams), while any other failure keeps the dispatcher's attempt limit so one poison event
|
|
560
|
+
// cannot hold back every later revocation for good.
|
|
561
|
+
class AccessInvalidationTransportError extends Error {
|
|
562
|
+
constructor(cause) {
|
|
563
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
564
|
+
this.name = "AccessInvalidationTransportError";
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function isAccessInvalidationTransportError(error) {
|
|
568
|
+
return error instanceof AccessInvalidationTransportError;
|
|
569
|
+
}
|
|
570
|
+
// The gate rethrows without touching Redis until the backoff elapsed.
|
|
571
|
+
function withTransportBackoff(handle, now) {
|
|
572
|
+
let delayMs = ACCESS_INVALIDATION_BACKOFF_INITIAL_MS;
|
|
573
|
+
let retryNotBefore = 0;
|
|
574
|
+
let transportError = null;
|
|
575
|
+
return async (event) => {
|
|
576
|
+
if (transportError !== null && now() < retryNotBefore)
|
|
577
|
+
throw transportError;
|
|
578
|
+
try {
|
|
579
|
+
await handle(event);
|
|
580
|
+
delayMs = ACCESS_INVALIDATION_BACKOFF_INITIAL_MS;
|
|
581
|
+
transportError = null;
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
if (isAccessInvalidationTransportError(err)) {
|
|
585
|
+
transportError = err;
|
|
586
|
+
retryNotBefore = now() + delayMs;
|
|
587
|
+
delayMs = Math.min(delayMs * 2, ACCESS_INVALIDATION_BACKOFF_MAX_MS);
|
|
588
|
+
}
|
|
589
|
+
throw err;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
export function createAccessInvalidationEventConsumer(sseBroker, opts = {}) {
|
|
594
|
+
const now = opts.now ?? Date.now;
|
|
595
|
+
const publish = async (userId, scope) => {
|
|
596
|
+
try {
|
|
597
|
+
await sseBroker.publishAccessInvalidation(userId, scope);
|
|
598
|
+
}
|
|
599
|
+
catch (cause) {
|
|
600
|
+
throw new AccessInvalidationTransportError(cause);
|
|
601
|
+
}
|
|
602
|
+
};
|
|
556
603
|
return {
|
|
557
604
|
name: ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
605
|
+
// Count-based dead-lettering would end after a few seconds of Redis downtime.
|
|
606
|
+
errorPolicy: { isTransientError: isAccessInvalidationTransportError },
|
|
558
607
|
// Shared delivery, same reasoning as SSE broadcast: one
|
|
559
608
|
// cursor reads every event once and calls sseBroker.publishAccessInvalidation
|
|
560
609
|
// below, which a Redis-backed broker republishes to every replica's
|
|
@@ -565,7 +614,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
|
|
|
565
614
|
// existed has nothing to invalidate — replaying it would be a no-op at
|
|
566
615
|
// best and a spurious stream-close at worst.
|
|
567
616
|
startFrom: "now",
|
|
568
|
-
handler: async (event) => {
|
|
617
|
+
handler: withTransportBackoff(async (event) => {
|
|
569
618
|
if (event.type === SESSION_REVOKED_EVENT_TYPE) {
|
|
570
619
|
const userId = event.payload["userId"];
|
|
571
620
|
// skip: malformed session-revoked payload — fail open on this one
|
|
@@ -574,7 +623,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
|
|
|
574
623
|
// every user behind one bad row).
|
|
575
624
|
if (typeof userId !== "string" || userId.length === 0)
|
|
576
625
|
return;
|
|
577
|
-
await
|
|
626
|
+
await publish(userId, readSessionRevokedScope(event.payload));
|
|
578
627
|
}
|
|
579
628
|
if (event.type === PAT_REVOKED_EVENT_TYPE) {
|
|
580
629
|
const userId = event.payload["userId"];
|
|
@@ -582,7 +631,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
|
|
|
582
631
|
// session-revoked above.
|
|
583
632
|
if (typeof userId !== "string" || userId.length === 0)
|
|
584
633
|
return;
|
|
585
|
-
await
|
|
634
|
+
await publish(userId, readPatRevokedScope(event.payload));
|
|
586
635
|
}
|
|
587
636
|
if (event.type === TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE ||
|
|
588
637
|
event.type === TENANT_MEMBERSHIP_DELETED_EVENT_TYPE) {
|
|
@@ -591,8 +640,8 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
|
|
|
591
640
|
// reasoning as above.
|
|
592
641
|
if (userId === undefined)
|
|
593
642
|
return;
|
|
594
|
-
await
|
|
643
|
+
await publish(userId);
|
|
595
644
|
}
|
|
596
|
-
},
|
|
645
|
+
}, now),
|
|
597
646
|
};
|
|
598
647
|
}
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { maskWriteOnlyFields } from "../engine/field-access.js";
|
|
2
2
|
import { isLifecycleResult } from "./dispatcher-utils.js";
|
|
3
|
+
// The echoed event carries plaintext entity data; only hooks and projections may read it.
|
|
3
4
|
function maskLifecycleOrRow(entity, data) {
|
|
4
5
|
if (typeof data !== "object" || data === null || Array.isArray(data))
|
|
5
6
|
return data;
|
|
6
|
-
if (!isLifecycleResult(data))
|
|
7
|
-
return maskWriteOnlyFields(entity, data); // @cast-boundary engine-payload
|
|
8
|
-
|
|
7
|
+
if (!isLifecycleResult(data)) {
|
|
8
|
+
return entity ? maskWriteOnlyFields(entity, data) : data; // @cast-boundary engine-payload
|
|
9
|
+
}
|
|
10
|
+
const { event: _event, ...lifecycle } = data;
|
|
11
|
+
if (!entity)
|
|
12
|
+
return lifecycle;
|
|
13
|
+
if (lifecycle.kind === "save") {
|
|
9
14
|
return {
|
|
10
|
-
...
|
|
11
|
-
data: maskWriteOnlyFields(entity,
|
|
12
|
-
changes: maskWriteOnlyFields(entity,
|
|
13
|
-
previous: maskWriteOnlyFields(entity,
|
|
15
|
+
...lifecycle,
|
|
16
|
+
data: maskWriteOnlyFields(entity, lifecycle.data),
|
|
17
|
+
changes: maskWriteOnlyFields(entity, lifecycle.changes),
|
|
18
|
+
previous: maskWriteOnlyFields(entity, lifecycle.previous),
|
|
14
19
|
};
|
|
15
20
|
}
|
|
16
|
-
return { ...
|
|
21
|
+
return { ...lifecycle, data: maskWriteOnlyFields(entity, lifecycle.data) };
|
|
17
22
|
}
|
|
18
23
|
// Copy-on-mask: the original results still feed postSaveBatch hooks, which
|
|
19
24
|
// need the plaintext. Only what leaves the dispatcher is masked.
|
|
@@ -22,8 +27,6 @@ export function maskWriteResultForClient(registry, handlerType, result) {
|
|
|
22
27
|
return result;
|
|
23
28
|
const entityName = registry.getHandlerEntity(handlerType);
|
|
24
29
|
const entity = entityName ? registry.getEntity(entityName) : undefined;
|
|
25
|
-
if (!entity)
|
|
26
|
-
return result;
|
|
27
30
|
const data = maskLifecycleOrRow(entity, result.data);
|
|
28
31
|
return data === result.data ? result : { ...result, data };
|
|
29
32
|
}
|
|
@@ -10,7 +10,7 @@ export async function runBaselineCommand(ctx) {
|
|
|
10
10
|
return 1;
|
|
11
11
|
if (!migrationsDirExistsOrReport(ctx))
|
|
12
12
|
return 1;
|
|
13
|
-
const { db, close } = createDbConnection({ url: dbUrl });
|
|
13
|
+
const { db, close } = createDbConnection({ url: dbUrl, maxConnections: 1 });
|
|
14
14
|
try {
|
|
15
15
|
const result = await baselineMigrations(db, loadMigrationsFromDir(migrationsDir));
|
|
16
16
|
out.log("");
|
|
@@ -42,7 +42,7 @@ export async function runStatusCommand(ctx) {
|
|
|
42
42
|
return 0;
|
|
43
43
|
}
|
|
44
44
|
const local = loadMigrationsFromDir(migrationsDir);
|
|
45
|
-
const { db, close } = createDbConnection({ url: dbUrl });
|
|
45
|
+
const { db, close } = createDbConnection({ url: dbUrl, maxConnections: 1 });
|
|
46
46
|
try {
|
|
47
47
|
const applied = await fetchAppliedMigrationIds(db);
|
|
48
48
|
out.log("");
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// existing row and indexes it, same as a live write would.
|
|
7
7
|
import { resolveTableName } from "../db/entity-table-meta.js";
|
|
8
8
|
import { executeRawQueryRead } from "../db/queries/raw-sql.js";
|
|
9
|
-
import {
|
|
9
|
+
import { runInNewTransaction, runnerCanBeginTransaction } from "../db/query.js";
|
|
10
10
|
import { buildSearchDocument, decryptSearchableSubjectFields, hasErasedSearchableSubjectField, } from "../pipeline/system-hooks.js";
|
|
11
11
|
import { toSnakeCase } from "../utils/case.js";
|
|
12
12
|
function quoteIdent(name) {
|
|
@@ -26,10 +26,20 @@ function rowToState(row, fieldNames) {
|
|
|
26
26
|
}
|
|
27
27
|
return state;
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Server pools cap statement_timeout, and a batch scan over a large table can outlast it. The caller's
|
|
30
|
+
// runner (often a gated proxy around the pool) is used as is, so the cap is lifted per batch
|
|
31
|
+
// with a transaction-local set_config instead of moving the scan to an ungated sibling pool. A runner already inside a
|
|
32
|
+
// transaction keeps the caller's setting rather than leaking ours into its remaining statements.
|
|
33
|
+
async function readBatchWithoutStatementTimeout(db, sqlText, params) {
|
|
34
|
+
if (!runnerCanBeginTransaction(db)) {
|
|
35
|
+
return executeRawQueryRead(db, sqlText, params);
|
|
36
|
+
}
|
|
37
|
+
return runInNewTransaction(db, async (tx) => {
|
|
38
|
+
await executeRawQueryRead(tx, "SELECT set_config('statement_timeout', '0', true)", []);
|
|
39
|
+
return executeRawQueryRead(tx, sqlText, params);
|
|
40
|
+
});
|
|
31
41
|
}
|
|
32
|
-
async function
|
|
42
|
+
export async function reindexEntity(db, registry, searchAdapter, entityName, tenantId, options = {}) {
|
|
33
43
|
const entity = registry.getEntity(entityName);
|
|
34
44
|
if (!entity) {
|
|
35
45
|
throw new Error(`reindexEntity: unknown entity "${entityName}"`);
|
|
@@ -61,7 +71,7 @@ async function scanAndIndexEntity(db, registry, searchAdapter, entityName, tenan
|
|
|
61
71
|
for (;;) {
|
|
62
72
|
// Keyset, not OFFSET: rows deleted or changed while the loop runs must not shift the window.
|
|
63
73
|
const afterLastId = lastId === undefined ? "" : `AND ${quoteIdent("id")} > $3`;
|
|
64
|
-
const rows = await
|
|
74
|
+
const rows = await readBatchWithoutStatementTimeout(db, `SELECT * FROM ${quoteIdent(tableName)}
|
|
65
75
|
WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter} ${afterLastId}
|
|
66
76
|
ORDER BY ${quoteIdent("id")} ASC
|
|
67
77
|
LIMIT $2`, lastId === undefined ? [tenantId, batchSize] : [tenantId, batchSize, lastId]);
|
package/dist/ui-types/index.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
|
|
|
11
11
|
export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.ts";
|
|
12
12
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
|
|
13
13
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
|
|
14
|
-
export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart,
|
|
15
|
-
export { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
|
|
14
|
+
export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, ExpandableRowGrouping, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListBulkAction, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, ListTableDensity, ListTableOptions, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
|
|
15
|
+
export { MAX_BULK_SELECTION, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
|
|
16
16
|
export type { TargetRef } from "../engine/types/target-ref.ts";
|
|
17
17
|
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.ts";
|
|
18
18
|
export type { WorkspaceDefinition } from "../engine/types/workspace.ts";
|
package/dist/ui-types/index.js
CHANGED
|
@@ -25,7 +25,7 @@ export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperan
|
|
|
25
25
|
export { parseRefTarget } from "../engine/parse-ref-target.js";
|
|
26
26
|
export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
|
|
27
27
|
export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
|
|
28
|
-
export { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
|
|
28
|
+
export { MAX_BULK_SELECTION, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
|
|
29
29
|
export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
|
|
30
30
|
export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.js";
|
|
31
31
|
export { formalLocaleTag } from "./formality.js";
|
|
@@ -23,3 +23,5 @@ export type UpgradeChangelogSection = {
|
|
|
23
23
|
export declare function renderUpgradeSection(section: UpgradeChangelogSection): string;
|
|
24
24
|
/** Puts the section on top; a section for the same target version is replaced, so a re-run stays idempotent. */
|
|
25
25
|
export declare function mergeUpgradeSection(existing: string | undefined, section: UpgradeChangelogSection): string;
|
|
26
|
+
/** Pure string match on the rendered `- [open] <id> · ` prefix, in every section. */
|
|
27
|
+
export declare function setManualStatusInChangelog(content: string, ids: ReadonlySet<string>, resolution: "migrated" | "not-applicable"): string;
|