@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
|
@@ -20,7 +20,7 @@ export function registerExecutorManagedWriteRunner(runner) {
|
|
|
20
20
|
executorManagedWriteRunners.add(runner);
|
|
21
21
|
}
|
|
22
22
|
// WeakSet.has never reads a property off runner, so a fail-closed guard Proxy stays untouched.
|
|
23
|
-
//
|
|
23
|
+
// Bun.SQL clients are callable (tagged templates), so a runner can be a function.
|
|
24
24
|
export function hasExecutorManagedWriteCapability(runner) {
|
|
25
25
|
if (typeof runner !== "object" && typeof runner !== "function")
|
|
26
26
|
return false;
|
package/dist/db/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { flattenLocatedTimestamp, type LocatedTimestampRead, rehydrateLocatedTim
|
|
|
26
26
|
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, type GenerateMigrationInput, type GenerateMigrationOutput, generateMigration, loadSnapshotJson, renderMigrationSql, type SchemaDiff, type Snapshot, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.ts";
|
|
27
27
|
export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, type Migration, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.ts";
|
|
28
28
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.ts";
|
|
29
|
-
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.ts";
|
|
29
|
+
export { constraintOf, extractPgError, isTableAlreadyExists, isUndefinedTable, isUniqueViolation, type PgErrorInfo, } from "./pg-error.ts";
|
|
30
30
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.ts";
|
|
31
31
|
export { readDatabaseNow } from "./queries/database-now.ts";
|
|
32
32
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.ts";
|
package/dist/db/index.js
CHANGED
|
@@ -20,7 +20,7 @@ export { flattenLocatedTimestamp, rehydrateLocatedTimestamp, } from "./located-t
|
|
|
20
20
|
export { assertUniqueTableNames, assertValidMigrationName, diffSnapshots, generateMigration, loadSnapshotJson, renderMigrationSql, snapshotFromMetas, writeSnapshotJson, } from "./migrate-generator.js";
|
|
21
21
|
export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, MigrationChecksumMismatchError, readMigrationSqlTexts, runMigrations, runMigrationsFromDir, splitSqlStatements, } from "./migrate-runner.js";
|
|
22
22
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
23
|
-
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
23
|
+
export { constraintOf, extractPgError, isTableAlreadyExists, isUndefinedTable, isUniqueViolation, } from "./pg-error.js";
|
|
24
24
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
25
25
|
export { readDatabaseNow } from "./queries/database-now.js";
|
|
26
26
|
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
@@ -188,16 +188,16 @@ function migrationClient(db) {
|
|
|
188
188
|
if (hasBegin(client))
|
|
189
189
|
return client;
|
|
190
190
|
}
|
|
191
|
-
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need a
|
|
191
|
+
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need a Bun.SQL pool or a DbConnection)");
|
|
192
192
|
}
|
|
193
|
-
// Raw-SQL via
|
|
193
|
+
// Raw-SQL via Bun.SQL .unsafe().
|
|
194
194
|
async function executeRaw(db, sqlText) {
|
|
195
195
|
await migrationClient(db).unsafe(sqlText);
|
|
196
196
|
}
|
|
197
197
|
async function reserveConnection(db) {
|
|
198
198
|
const client = migrationClient(db);
|
|
199
199
|
if (typeof client.reserve !== "function") {
|
|
200
|
-
throw new Error("migrate-runner: db argument has no .reserve() (need a
|
|
200
|
+
throw new Error("migrate-runner: db argument has no .reserve() (need a Bun.SQL pool)");
|
|
201
201
|
}
|
|
202
202
|
return client.reserve();
|
|
203
203
|
}
|
package/dist/db/pg-error.js
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// the idempotency-key index (caller-side replay signal).
|
|
1
|
+
// Bun.SQL server errors carry the SQLSTATE in `errno` and the constraint in
|
|
2
|
+
// `constraint` (`code` is the generic ERR_POSTGRES_SERVER_ERROR). Client-side
|
|
3
|
+
// failures have no `errno`, so their `code` (e.g. ERR_POSTGRES_CONNECTION_CLOSED)
|
|
4
|
+
// is returned instead. The `.cause` chain is walked so wrapped errors resolve too.
|
|
5
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
6
|
const MAX_CAUSE_DEPTH = 8;
|
|
7
|
+
function stringOrUndefined(value) {
|
|
8
|
+
return typeof value === "string" ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
function infoOfLayer(layer) {
|
|
11
|
+
const sqlState = stringOrUndefined(propertyOf(layer, "errno"));
|
|
12
|
+
if (sqlState !== undefined) {
|
|
13
|
+
return {
|
|
14
|
+
code: sqlState,
|
|
15
|
+
constraint_name: stringOrUndefined(propertyOf(layer, "constraint")),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const code = stringOrUndefined(propertyOf(layer, "code"));
|
|
19
|
+
return code === undefined ? null : { code, constraint_name: undefined };
|
|
20
|
+
}
|
|
8
21
|
export function extractPgError(e) {
|
|
9
|
-
if (typeof e !== "object" || e === null)
|
|
10
|
-
return null;
|
|
11
|
-
const layers = [];
|
|
12
22
|
let current = e;
|
|
13
23
|
// Depth cap doubles as cycle protection for self-referential causes.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const code = layer.code;
|
|
22
|
-
const constraintName = layer.constraint_name; // @cast-boundary error-details
|
|
23
|
-
if (code !== undefined || constraintName !== undefined) {
|
|
24
|
-
return { code, constraint_name: constraintName };
|
|
25
|
-
}
|
|
24
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
|
|
25
|
+
if (typeof current !== "object" || current === null)
|
|
26
|
+
return null;
|
|
27
|
+
const info = infoOfLayer(current);
|
|
28
|
+
if (info !== null)
|
|
29
|
+
return info;
|
|
30
|
+
current = propertyOf(current, "cause");
|
|
26
31
|
}
|
|
27
32
|
return null;
|
|
28
33
|
}
|
|
@@ -499,7 +499,7 @@ async function rekeyTenantKeyedValue(deps, target) {
|
|
|
499
499
|
const userSubject = keyedUnder?.kind === "tenant" && keyedUnder.tenantId === target.tenantId
|
|
500
500
|
? rekeyTargetOfTenantKeyedValue(target)
|
|
501
501
|
: keyedUnder?.kind === "user"
|
|
502
|
-
? currentProjectionOwnerIfOther(target, keyedUnder.userId)
|
|
502
|
+
? currentProjectionOwnerIfOther(target, fieldDef.userOwned.ownerField, keyedUnder.userId)
|
|
503
503
|
: null;
|
|
504
504
|
if (userSubject === null)
|
|
505
505
|
return { kind: "unchanged" };
|
|
@@ -536,11 +536,8 @@ function rekeyTargetOfTenantKeyedValue(target) {
|
|
|
536
536
|
// A value keyed under a previous owner moves to the owner the row has now. The
|
|
537
537
|
// projection of the event's own tenant is authoritative here: the payload of
|
|
538
538
|
// an old created event still names the owner the row had back then.
|
|
539
|
-
function currentProjectionOwnerIfOther(target, keyedUserId) {
|
|
540
|
-
const
|
|
541
|
-
if (fieldDef === undefined || !("userOwned" in fieldDef) || !fieldDef.userOwned)
|
|
542
|
-
return null;
|
|
543
|
-
const owner = nonEmptyString(target.projectionOwnerRow?.[fieldDef.userOwned.ownerField]);
|
|
539
|
+
function currentProjectionOwnerIfOther(target, ownerField, keyedUserId) {
|
|
540
|
+
const owner = nonEmptyString(target.projectionOwnerRow?.[ownerField]);
|
|
544
541
|
return owner === null || owner === keyedUserId ? null : { kind: "user", userId: owner };
|
|
545
542
|
}
|
|
546
543
|
// Only the row's own tenant key or a user key counts: another tenant's key is
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { executeRawQueryRead } from "./raw-sql.js";
|
|
2
2
|
// Called by createTenantDb with its inner runner; keeps raw SQL out of consumers.
|
|
3
|
+
// timestamptz(3): events.created_at has millisecond precision, a microsecond now() would compare later.
|
|
3
4
|
export async function queryDatabaseNow(runner) {
|
|
4
|
-
const [row] = await executeRawQueryRead(runner, "SELECT now() AS now");
|
|
5
|
+
const [row] = await executeRawQueryRead(runner, "SELECT now()::timestamptz(3) AS now");
|
|
5
6
|
if (!row)
|
|
6
7
|
throw new Error("SELECT now() returned no row");
|
|
7
8
|
return row.now;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
20
|
import { isUuid, parseTenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
21
21
|
import { configuredBlindIndexKey } from "../../crypto/index.js";
|
|
22
|
+
import { extractPgError } from "../pg-error.js";
|
|
22
23
|
import { asEntityTableMeta, asRawClient } from "../query.js";
|
|
23
24
|
import { renderTableDdl } from "../render-ddl.js";
|
|
24
25
|
import { columnNamesOf, tableExists } from "../schema-inspection.js";
|
|
@@ -27,9 +28,7 @@ import { quoteTableIdent } from "./table-ops.js";
|
|
|
27
28
|
export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
|
|
28
29
|
const SCHEMA_IDENT = quoteTableIdent(PROJECTION_REBUILD_SCHEMA);
|
|
29
30
|
function isDuplicateSchemaError(e) {
|
|
30
|
-
|
|
31
|
-
return false;
|
|
32
|
-
const { code } = e;
|
|
31
|
+
const code = extractPgError(e)?.code;
|
|
33
32
|
// 42P06 duplicate_schema, 23505 unique_violation on pg_namespace — both
|
|
34
33
|
// surface from the well-known CREATE SCHEMA IF NOT EXISTS race.
|
|
35
34
|
return code === "42P06" || code === "23505";
|
package/dist/db/query.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import type { AggregateRow, AggregateSpec } from "@cosmicdrift/kumiko-types/aggr
|
|
|
2
2
|
import type { DbRunner, DbTx } from "@cosmicdrift/kumiko-types/db-connection";
|
|
3
3
|
import type { ExecutorManagedRunner, ExecutorManagedWriteCapability, WritableTable } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
4
4
|
import type { SelectOptions, WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
5
|
+
import { type ClosedConnectionContext } from "./closed-connection.ts";
|
|
6
|
+
import { type PreparedValue } from "./driver-value-preparation.ts";
|
|
5
7
|
import type { EntityTableMeta } from "./entity-table-meta.ts";
|
|
6
8
|
import { type TableInfo } from "./table-info.ts";
|
|
7
9
|
import { asEntityTableMeta, requireEntityTableMeta } from "./table-introspection.ts";
|
|
@@ -32,6 +34,7 @@ type AnyRow = any;
|
|
|
32
34
|
export type { JsonTextMatch, OrderByClause, SelectOptions, WhereObject, WhereOperator, WhereValue, } from "@cosmicdrift/kumiko-types/where-clause-types";
|
|
33
35
|
export type TableLike = EntityTableMeta | any;
|
|
34
36
|
export declare function coerceRow<TOut = Record<string, unknown>>(row: Record<string, unknown>, info: TableInfo): TOut;
|
|
37
|
+
export declare function unsafeLoggingClosedConnection<TRow = unknown>(db: AnyDb, sqlText: string, params: readonly unknown[], context: ClosedConnectionContext): Promise<readonly TRow[]>;
|
|
35
38
|
export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
|
|
36
39
|
export declare function selectMany<TRow = AnyRow>(db: AnyDb, table: TableLike, where?: WhereObject, options?: SelectOptions): Promise<readonly TRow[]>;
|
|
37
40
|
export declare function fetchOne<TRow = AnyRow>(db: AnyDb, table: TableLike, where: WhereObject): Promise<TRow | undefined>;
|
|
@@ -63,7 +66,18 @@ export declare function updateMany<TRow = AnyRow>(db: AnyDb, table: WritableTabl
|
|
|
63
66
|
export declare function updateMany<TRow = AnyRow>(db: ExecutorManagedWriteCapability, table: EntityTableMeta, set: Record<string, unknown>, where: WhereObject): Promise<readonly TRow[]>;
|
|
64
67
|
export declare function deleteMany(db: AnyDb, table: WritableTable, where: WhereObject): Promise<number>;
|
|
65
68
|
export declare function deleteMany(db: ExecutorManagedWriteCapability, table: EntityTableMeta, where: WhereObject): Promise<number>;
|
|
69
|
+
export type InsertEntry = {
|
|
70
|
+
readonly field: string;
|
|
71
|
+
readonly col: string;
|
|
72
|
+
readonly prepared: PreparedValue;
|
|
73
|
+
};
|
|
74
|
+
export declare function insertEntries(info: TableInfo, values: Record<string, unknown>): InsertEntry[];
|
|
66
75
|
export declare function resolveConflictColumns(table: TableLike, info: TableInfo, conflictKeys: readonly string[] | undefined): readonly string[];
|
|
76
|
+
export declare function conflictUpdateParts(info: TableInfo, entries: readonly InsertEntry[], update: Record<string, unknown> | undefined, skipCols: ReadonlySet<string>, params: unknown[]): string[];
|
|
77
|
+
export declare function buildInsertSql(info: TableInfo, entries: readonly InsertEntry[]): {
|
|
78
|
+
sqlPrefix: string;
|
|
79
|
+
params: unknown[];
|
|
80
|
+
};
|
|
67
81
|
export type UpsertOnConflictOptions = {
|
|
68
82
|
readonly conflictKeys: readonly string[];
|
|
69
83
|
/** Columns to set on conflict. Default: EXCLUDED for every inserted non-key column. */
|
package/dist/db/query.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Typed query helpers over the
|
|
1
|
+
// Typed query helpers over the Bun.SQL driver: EntityTableMeta in, rows out.
|
|
2
2
|
//
|
|
3
3
|
// API:
|
|
4
4
|
// selectMany<T>(db, table, where?, opts?) → readonly T[]
|
|
@@ -39,9 +39,9 @@ function isRawClient(value) {
|
|
|
39
39
|
return typeof propertyOf(value, "unsafe") === "function";
|
|
40
40
|
}
|
|
41
41
|
export function asRawClient(db) {
|
|
42
|
-
// Direct:
|
|
43
|
-
// expose `.unsafe`; only
|
|
44
|
-
// `.savepoint` for
|
|
42
|
+
// Direct: Bun.SQL pool / transaction handle. Both
|
|
43
|
+
// expose `.unsafe`; only the pool starts a transaction — a transaction handle uses
|
|
44
|
+
// `.savepoint` for nesting. We only require `.unsafe` here; callers that
|
|
45
45
|
// need `.begin` (e.g. `transaction()`) verify it themselves.
|
|
46
46
|
if (isRawClient(db))
|
|
47
47
|
return db;
|
|
@@ -52,15 +52,15 @@ export function asRawClient(db) {
|
|
|
52
52
|
"tenant filter — use a ctx.db.<method>, or ctx.db.unsafeRaw() with escapeHatch: " +
|
|
53
53
|
"{ reason } on the handler/hook (r.systemScope() features: ctx.systemDb.unsafeRaw(reason)).");
|
|
54
54
|
}
|
|
55
|
-
// Wrapper handle: $client =
|
|
55
|
+
// Wrapper handle: $client = Bun.SQL pool.
|
|
56
56
|
const $client = propertyOf(db, "$client");
|
|
57
57
|
if (isRawClient($client))
|
|
58
58
|
return $client;
|
|
59
|
-
// Wrapper transaction handle: session.client =
|
|
59
|
+
// Wrapper transaction handle: session.client = Bun.SQL pool.
|
|
60
60
|
const sessionClient = propertyOf(propertyOf(db, "session"), "client");
|
|
61
61
|
if (isRawClient(sessionClient))
|
|
62
62
|
return sessionClient;
|
|
63
|
-
throw new Error("asRawClient: db argument has no .unsafe() — pass a
|
|
63
|
+
throw new Error("asRawClient: db argument has no .unsafe() — pass a Bun.SQL pool or a transaction handle.");
|
|
64
64
|
}
|
|
65
65
|
export function runnerCanBeginTransaction(db) {
|
|
66
66
|
return hasBegin(asRawClient(db));
|
|
@@ -72,10 +72,31 @@ function savepointRunnerOf(sp) {
|
|
|
72
72
|
export async function runInSavepoint(tx, fn) {
|
|
73
73
|
const raw = asRawClient(tx);
|
|
74
74
|
if (typeof raw.savepoint !== "function") {
|
|
75
|
-
throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (
|
|
75
|
+
throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (Bun.SQL TransactionSQL).");
|
|
76
76
|
}
|
|
77
77
|
return raw.savepoint((sp) => fn(savepointRunnerOf(sp)));
|
|
78
78
|
}
|
|
79
|
+
// Same error-confinement as runInSavepoint, but for call sites that don't
|
|
80
|
+
// know whether `db` is a bare pool connection or an active transaction
|
|
81
|
+
// (e.g. the CRUD executor, invoked both from a dispatcher tx and directly
|
|
82
|
+
// against the pool by seeds/tests). A pool connection has no savepoint() —
|
|
83
|
+
// and doesn't need one, since each statement there is its own auto-committed
|
|
84
|
+
// unit and a failed statement can't poison anything downstream.
|
|
85
|
+
//
|
|
86
|
+
// `.savepoint` being present isn't proof the transaction is still open: a
|
|
87
|
+
// committed transaction handle still exposes `.savepoint`, and calling it
|
|
88
|
+
// fails (Bun.SQL: ERR_POSTGRES_INVALID_TRANSACTION_STATE, Postgres: 25P01)
|
|
89
|
+
// since there's no BEGIN left to nest into. That is now a programming error, not something
|
|
90
|
+
// to paper over by re-running `fn` without the savepoint's isolation.
|
|
91
|
+
const CONNECTION_CLOSED_CODE = "ERR_POSTGRES_CONNECTION_CLOSED";
|
|
92
|
+
const SAVEPOINT_ON_COMMITTED_CODES = new Set([
|
|
93
|
+
"25P01",
|
|
94
|
+
"ERR_POSTGRES_INVALID_TRANSACTION_STATE",
|
|
95
|
+
]);
|
|
96
|
+
function isSavepointOnDeadTransactionCode(code) {
|
|
97
|
+
return (code !== undefined &&
|
|
98
|
+
(code === CONNECTION_CLOSED_CODE || SAVEPOINT_ON_COMMITTED_CODES.has(code)));
|
|
99
|
+
}
|
|
79
100
|
export async function runInSavepointIfSupported(db, fn) {
|
|
80
101
|
const raw = asRawClient(db);
|
|
81
102
|
if (typeof raw.savepoint !== "function")
|
|
@@ -90,17 +111,17 @@ export async function runInSavepointIfSupported(db, fn) {
|
|
|
90
111
|
}
|
|
91
112
|
catch (e) {
|
|
92
113
|
const code = extractPgError(e)?.code;
|
|
93
|
-
if (!fnStarted && (code
|
|
114
|
+
if (!fnStarted && isSavepointOnDeadTransactionCode(code)) {
|
|
94
115
|
throw new InternalError({
|
|
95
|
-
message: code ===
|
|
96
|
-
? "runInSavepointIfSupported:
|
|
97
|
-
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
98
|
-
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
99
|
-
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
|
|
100
|
-
: "runInSavepointIfSupported: the transaction handle's connection is gone — the transaction " +
|
|
116
|
+
message: code === CONNECTION_CLOSED_CODE
|
|
117
|
+
? "runInSavepointIfSupported: the transaction handle's connection is gone — the transaction " +
|
|
101
118
|
"already committed (almost always database work in an afterCommit hook) or the connection dropped. " +
|
|
102
119
|
"afterCommit is the default phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction " +
|
|
103
|
-
"for writes that must roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
|
|
120
|
+
"for writes that must roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
|
|
121
|
+
: "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
|
|
122
|
+
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
123
|
+
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
124
|
+
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
|
|
104
125
|
cause: e instanceof Error ? e : undefined,
|
|
105
126
|
});
|
|
106
127
|
}
|
|
@@ -131,7 +152,8 @@ function assertNotTenantScoped(db, fnName) {
|
|
|
131
152
|
if (tenantDbDelegate(db) !== undefined) {
|
|
132
153
|
throw new Error(`${fnName}: received a tenant-scoped db but this helper does not apply the tenant filter. ` +
|
|
133
154
|
"Pass ctx.db.unsafeRaw() (with escapeHatch: { reason } on the handler/hook) for an " +
|
|
134
|
-
"explicit cross-tenant op, or use a scoped TenantDb method
|
|
155
|
+
"explicit cross-tenant op, or use a scoped TenantDb method " +
|
|
156
|
+
"(for an upsert: ctx.db.upsert(table, values, { conflictTarget })).");
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
159
|
export function coerceRow(row, info) {
|
|
@@ -143,7 +165,7 @@ function coerceRows(rows, info) {
|
|
|
143
165
|
}
|
|
144
166
|
// Write and other non-retried paths: log when a closed-connection error leaves
|
|
145
167
|
// the db layer, rethrow unchanged.
|
|
146
|
-
async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
|
|
168
|
+
export async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
|
|
147
169
|
const raw = asRawClient(db);
|
|
148
170
|
try {
|
|
149
171
|
return await raw.unsafe(sqlText, params);
|
|
@@ -453,14 +475,14 @@ export async function deleteMany(db, table, where) {
|
|
|
453
475
|
operation: "deleteMany",
|
|
454
476
|
table: info.name,
|
|
455
477
|
});
|
|
456
|
-
//
|
|
478
|
+
// Bun.SQL reports the affected-row count of a DELETE on the result, no RETURNING needed.
|
|
457
479
|
const count = propertyOf(result, "count");
|
|
458
480
|
if (typeof count !== "number") {
|
|
459
481
|
throw new Error(`deleteMany(${info.name}): the driver result carries no affected-row count`);
|
|
460
482
|
}
|
|
461
483
|
return count;
|
|
462
484
|
}
|
|
463
|
-
function insertEntries(info, values) {
|
|
485
|
+
export function insertEntries(info, values) {
|
|
464
486
|
return Object.entries(values)
|
|
465
487
|
.filter(([k]) => info.hasColumn(k))
|
|
466
488
|
.map(([k, v]) => {
|
|
@@ -486,7 +508,31 @@ export function resolveConflictColumns(table, info, conflictKeys) {
|
|
|
486
508
|
return [info.columnOf("id")];
|
|
487
509
|
throw new Error("upsert: cannot infer conflict keys — pass conflictKeys explicitly");
|
|
488
510
|
}
|
|
489
|
-
|
|
511
|
+
// Appends bound values to `params`; `skipCols` only applies to the default EXCLUDED set.
|
|
512
|
+
export function conflictUpdateParts(info, entries, update, skipCols, params) {
|
|
513
|
+
const parts = [];
|
|
514
|
+
if (update === undefined) {
|
|
515
|
+
for (const e of entries) {
|
|
516
|
+
if (skipCols.has(e.col))
|
|
517
|
+
continue;
|
|
518
|
+
parts.push(`${quoteIdent(e.col)} = EXCLUDED.${quoteIdent(e.col)}`);
|
|
519
|
+
}
|
|
520
|
+
return parts;
|
|
521
|
+
}
|
|
522
|
+
for (const [field, value] of Object.entries(update)) {
|
|
523
|
+
const col = info.columnOf(field);
|
|
524
|
+
const p = prepareValue(value, info.pgTypeOf(col));
|
|
525
|
+
if (p.kind === "literal") {
|
|
526
|
+
parts.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
params.push(p.bound);
|
|
530
|
+
parts.push(`${quoteIdent(col)} = $${params.length}${p.sql}`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return parts;
|
|
534
|
+
}
|
|
535
|
+
export function buildInsertSql(info, entries) {
|
|
490
536
|
if (entries.length === 0)
|
|
491
537
|
throw new Error("insert: empty values object");
|
|
492
538
|
const cols = entries.map((e) => quoteIdent(e.col)).join(", ");
|
|
@@ -556,28 +602,7 @@ async function upsertOnConflictChecked(db, table, values, options) {
|
|
|
556
602
|
const conflictCols = resolveConflictColumns(table, info, options.conflictKeys);
|
|
557
603
|
const conflictSet = new Set(conflictCols);
|
|
558
604
|
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
559
|
-
const updateParts =
|
|
560
|
-
let idx = params.length + 1;
|
|
561
|
-
if (options.update !== undefined) {
|
|
562
|
-
for (const [field, value] of Object.entries(options.update)) {
|
|
563
|
-
const col = info.columnOf(field);
|
|
564
|
-
const p = prepareValue(value, info.pgTypeOf(col));
|
|
565
|
-
if (p.kind === "literal") {
|
|
566
|
-
updateParts.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
567
|
-
}
|
|
568
|
-
else {
|
|
569
|
-
updateParts.push(`${quoteIdent(col)} = $${idx++}${p.sql}`);
|
|
570
|
-
params.push(p.bound);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
for (const e of entries) {
|
|
576
|
-
if (conflictSet.has(e.col))
|
|
577
|
-
continue;
|
|
578
|
-
updateParts.push(`${quoteIdent(e.col)} = EXCLUDED.${quoteIdent(e.col)}`);
|
|
579
|
-
}
|
|
580
|
-
}
|
|
605
|
+
const updateParts = conflictUpdateParts(info, entries, options.update, conflictSet, params);
|
|
581
606
|
if (updateParts.length === 0) {
|
|
582
607
|
throw new Error("upsertOnConflict: nothing to update on conflict");
|
|
583
608
|
}
|
|
@@ -2,8 +2,10 @@ import { fetchOne } from "../db/query.js";
|
|
|
2
2
|
import { createSystemUser } from "../engine/system-user.js";
|
|
3
3
|
import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
|
|
4
4
|
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
5
|
+
import { getStreamVersion } from "../event-store/event-store.js";
|
|
5
6
|
import { entityTableFromRegistry } from "./entity-table-from-registry.js";
|
|
6
7
|
import { createEventStoreExecutor } from "./event-store-executor.js";
|
|
8
|
+
import { deleteRowsWithoutReplayableStream } from "./queries/event-store-orphan-rows.js";
|
|
7
9
|
import { createTenantDb } from "./tenant-db.js";
|
|
8
10
|
/**
|
|
9
11
|
* Seed reference data at boot time.
|
|
@@ -38,11 +40,21 @@ export async function seedReferenceData(defs, registry, db) {
|
|
|
38
40
|
if (keyValue === undefined)
|
|
39
41
|
continue;
|
|
40
42
|
// Soft-deleted rows are invisible to executor.update, so treat them as missing and re-create.
|
|
41
|
-
const
|
|
43
|
+
const existingRow = await fetchOne(db, table, {
|
|
42
44
|
[upsertKey]: keyValue,
|
|
43
45
|
tenantId: SYSTEM_TENANT_ID,
|
|
44
46
|
...(entity.softDelete && { isDeleted: false }),
|
|
45
47
|
});
|
|
48
|
+
// Rows seeded before reference data went through the executor have no events: update would
|
|
49
|
+
// hit a stream-version conflict and a rebuild would drop them. Drop the row and re-create
|
|
50
|
+
// it under the same id so its stream starts now.
|
|
51
|
+
let existing = existingRow;
|
|
52
|
+
let recreateId;
|
|
53
|
+
if (existingRow && (await getStreamVersion(db, existingRow.id, SYSTEM_TENANT_ID)) === 0) {
|
|
54
|
+
await deleteRowsWithoutReplayableStream(tdb, { table, ids: [existingRow.id] });
|
|
55
|
+
existing = undefined;
|
|
56
|
+
recreateId = existingRow.id;
|
|
57
|
+
}
|
|
46
58
|
if (existing) {
|
|
47
59
|
const changes = {};
|
|
48
60
|
for (const [field, value] of Object.entries(row)) {
|
|
@@ -60,7 +72,7 @@ export async function seedReferenceData(defs, registry, db) {
|
|
|
60
72
|
}
|
|
61
73
|
}
|
|
62
74
|
else {
|
|
63
|
-
const result = await executor.create({ ...row, tenantId: SYSTEM_TENANT_ID }, by, tdb);
|
|
75
|
+
const result = await executor.create({ ...row, ...(recreateId && { id: recreateId }), tenantId: SYSTEM_TENANT_ID }, by, tdb);
|
|
64
76
|
if (!result.isSuccess)
|
|
65
77
|
throw reraiseAsKumikoError(result.error);
|
|
66
78
|
inserted++;
|
|
@@ -4,7 +4,8 @@ const UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS = 5;
|
|
|
4
4
|
// PII backfill and projection rebuilds legitimately run longer. The cap is a connect-time session
|
|
5
5
|
// setting, so the run gets its own short-lived pool with the same options minus the cap: one shared
|
|
6
6
|
// connection would deadlock code that holds a lock in a tx while its DDL runs on another connection.
|
|
7
|
-
// Handles not created by createDbConnection (tx, savepoint, siblings) are passed through
|
|
7
|
+
// Handles not created by createDbConnection (tx, savepoint, siblings, gated runner proxies) are passed through
|
|
8
|
+
// unchanged and keep the cap; a caller holding such a runner lifts it itself (SET LOCAL in its own transaction).
|
|
8
9
|
export async function withoutStatementTimeout(db, run) {
|
|
9
10
|
const unbounded = openUnboundedSiblingPool(db);
|
|
10
11
|
if (unbounded === undefined)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TenantDbMode, TenantDbUpsertOptions } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import { AccessDeniedError } from "../errors/index.ts";
|
|
3
|
+
import { type AnyDb, type TableLike } from "./query.ts";
|
|
4
|
+
export declare function upsertTableDenied(table: TableLike, mode: TenantDbMode): AccessDeniedError | undefined;
|
|
5
|
+
export declare function upsertTenantRow<TRow>(db: AnyDb, table: TableLike, values: Record<string, unknown>, options: TenantDbUpsertOptions): Promise<TRow | undefined>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { quoteIdent } from "../crypto/ciphertext-pattern.js";
|
|
2
|
+
import { AccessDeniedError } from "../errors/index.js";
|
|
3
|
+
import { isExecutorManagedTable } from "./executor-managed-tables.js";
|
|
4
|
+
import { buildInsertSql, coerceRow, conflictUpdateParts, insertEntries, unsafeLoggingClosedConnection, } from "./query.js";
|
|
5
|
+
import { extractTableInfo } from "./table-info.js";
|
|
6
|
+
import { asEntityTableMeta, tableNameOf } from "./table-introspection.js";
|
|
7
|
+
const TENANT_COLUMN = "tenant_id";
|
|
8
|
+
// Upsert is never allowed on event-fed tables (even with the write capability) and has no tenant-mode meaning for "global" ones.
|
|
9
|
+
export function upsertTableDenied(table, mode) {
|
|
10
|
+
if (isExecutorManagedTable(table)) {
|
|
11
|
+
return new AccessDeniedError({
|
|
12
|
+
message: `ctx.db.upsert(${tableNameOf(table)}): table is fed by events; upsert is not supported on it.`,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (mode === "tenant" && asEntityTableMeta(table)?.tenancy === "global") {
|
|
16
|
+
return new AccessDeniedError({
|
|
17
|
+
message: `ctx.db.upsert(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
|
|
18
|
+
"use db.global(table) with escapeHatch instead.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function sameColumnSet(a, b) {
|
|
24
|
+
return a.length === b.length && new Set(a).size === a.length && a.every((c) => b.includes(c));
|
|
25
|
+
}
|
|
26
|
+
function declaredUniqueColumnSets(meta) {
|
|
27
|
+
const sets = meta.indexes
|
|
28
|
+
.filter((i) => i.unique === true && i.whereSql === undefined && i.needsManualWhere !== true)
|
|
29
|
+
.map((i) => i.columns);
|
|
30
|
+
if (meta.compositePrimaryKey)
|
|
31
|
+
sets.push(meta.compositePrimaryKey.columns);
|
|
32
|
+
const primaryKeyColumns = meta.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
33
|
+
if (primaryKeyColumns.length > 0)
|
|
34
|
+
sets.push(primaryKeyColumns);
|
|
35
|
+
return sets;
|
|
36
|
+
}
|
|
37
|
+
function primaryKeyColumnsOf(meta) {
|
|
38
|
+
return [
|
|
39
|
+
...meta.columns.filter((c) => c.primaryKey).map((c) => c.name),
|
|
40
|
+
...(meta.compositePrimaryKey?.columns ?? []),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
function assertTenantSafeConflictTarget(tableName, meta, conflictCols) {
|
|
44
|
+
if (conflictCols.length === 0 || !conflictCols.includes(TENANT_COLUMN)) {
|
|
45
|
+
throw new Error(`tenantDb.upsert(${tableName}): conflictTarget must include "tenantId" — a target without it ` +
|
|
46
|
+
"could match, and overwrite, another tenant's row.");
|
|
47
|
+
}
|
|
48
|
+
if (!declaredUniqueColumnSets(meta).some((set) => sameColumnSet(set, conflictCols))) {
|
|
49
|
+
throw new Error(`tenantDb.upsert(${tableName}): conflictTarget (${conflictCols.join(", ")}) matches no ` +
|
|
50
|
+
"declared unique index (without a WHERE clause) or primary key.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Handles a tenant's own row only: values carry the tenant already, and the DO UPDATE guard
|
|
54
|
+
// compares against the tenant being inserted, so a conflict on another tenant's row updates nothing.
|
|
55
|
+
export async function upsertTenantRow(db, table, values, options) {
|
|
56
|
+
const info = extractTableInfo(table);
|
|
57
|
+
const meta = asEntityTableMeta(table);
|
|
58
|
+
if (!info.hasColumn("tenantId")) {
|
|
59
|
+
throw new Error(`tenantDb.upsert(${info.name}): table has no tenant column — use the free upsertOnConflict ` +
|
|
60
|
+
"helper on an unsafeRaw runner instead.");
|
|
61
|
+
}
|
|
62
|
+
if (!meta) {
|
|
63
|
+
throw new Error(`tenantDb.upsert(${info.name}): needs an EntityTableMeta table (declared unique indexes and primary key), got a table without meta.`);
|
|
64
|
+
}
|
|
65
|
+
const conflictCols = options.conflictTarget.map((field) => info.columnOf(field));
|
|
66
|
+
assertTenantSafeConflictTarget(info.name, meta, conflictCols);
|
|
67
|
+
if (options.update &&
|
|
68
|
+
Object.keys(options.update).some((f) => info.columnOf(f) === TENANT_COLUMN)) {
|
|
69
|
+
throw new Error(`tenantDb.upsert(${info.name}): update must not set "tenantId" — it would move the row into another tenant.`);
|
|
70
|
+
}
|
|
71
|
+
const entries = insertEntries(info, values);
|
|
72
|
+
if (!entries.some((e) => e.col === TENANT_COLUMN)) {
|
|
73
|
+
throw new Error(`tenantDb.upsert(${info.name}): values carry no tenantId.`);
|
|
74
|
+
}
|
|
75
|
+
const { sqlPrefix, params } = buildInsertSql(info, entries);
|
|
76
|
+
const skipCols = new Set([...conflictCols, ...primaryKeyColumnsOf(meta), TENANT_COLUMN]);
|
|
77
|
+
const setParts = conflictUpdateParts(info, entries, options.update, skipCols, params);
|
|
78
|
+
if (setParts.length === 0) {
|
|
79
|
+
throw new Error(`tenantDb.upsert(${info.name}): nothing to update on conflict`);
|
|
80
|
+
}
|
|
81
|
+
const tenantRef = quoteIdent(TENANT_COLUMN);
|
|
82
|
+
const sqlText = `${sqlPrefix} ON CONFLICT (${conflictCols.map((c) => quoteIdent(c)).join(", ")}) ` +
|
|
83
|
+
`DO UPDATE SET ${setParts.join(", ")} ` +
|
|
84
|
+
`WHERE ${quoteIdent(info.name)}.${tenantRef} = EXCLUDED.${tenantRef} RETURNING *`;
|
|
85
|
+
const rows = await unsafeLoggingClosedConnection(db, sqlText, params, {
|
|
86
|
+
operation: "tenantDb.upsert",
|
|
87
|
+
table: info.name,
|
|
88
|
+
});
|
|
89
|
+
const first = rows[0];
|
|
90
|
+
return first ? coerceRow(first, info) : undefined;
|
|
91
|
+
}
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -11,6 +11,7 @@ import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
|
11
11
|
import { queryDatabaseNow } from "./queries/database-now.js";
|
|
12
12
|
import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
|
|
13
13
|
import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, tenantDbWritesDeniedReason, } from "./tenant-db-runner.js";
|
|
14
|
+
import { upsertTableDenied, upsertTenantRow } from "./tenant-db-upsert.js";
|
|
14
15
|
export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
15
16
|
const declaredUnsafeRawRunners = new WeakMap();
|
|
16
17
|
// The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
|
|
@@ -36,7 +37,7 @@ function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
|
|
|
36
37
|
if (typeof value !== "function")
|
|
37
38
|
return value;
|
|
38
39
|
if (prop === "reserve") {
|
|
39
|
-
//
|
|
40
|
+
// The driver hands back a raw connection; re-wrap it so the gate survives.
|
|
40
41
|
return async (...args) => {
|
|
41
42
|
const reserved = await Reflect.apply(value, target, args);
|
|
42
43
|
return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
|
|
@@ -662,6 +663,15 @@ function buildTenantDb(options) {
|
|
|
662
663
|
const data = insertValues(table, values);
|
|
663
664
|
return withDbSpan("insert", table, async () => insertRow(table, data));
|
|
664
665
|
},
|
|
666
|
+
upsert(table, values, options) {
|
|
667
|
+
const denied = writesDenied("ctx.db.upsert") ??
|
|
668
|
+
upsertTableDenied(table, mode) ??
|
|
669
|
+
personalDataDenied(table, [...Object.keys(values), ...Object.keys(options.update ?? {})]);
|
|
670
|
+
if (denied)
|
|
671
|
+
return Promise.reject(denied);
|
|
672
|
+
const data = insertValues(table, values);
|
|
673
|
+
return withDbSpan("insert", table, () => upsertTenantRow(db, table, data, options));
|
|
674
|
+
},
|
|
665
675
|
updateMany(table, set, where) {
|
|
666
676
|
const writeDenied = writesDenied("ctx.db.updateMany");
|
|
667
677
|
if (writeDenied)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { LIST_TABLE_OPTION_KEYS } from "../types/screen.js";
|
|
3
|
+
import { validateListTableOptions } from "./list-table-options.js";
|
|
4
|
+
export const BUNDLED_LISTS_OPTION_KEYS = ["table", "pinFirstColumn"];
|
|
5
|
+
// grouping and totals name fields of one list, so they cannot be shared across lists
|
|
6
|
+
const SCREEN_ONLY_TABLE_KEYS = ["grouping", "totals"];
|
|
7
|
+
export function validateBundledListsOptions(options) {
|
|
8
|
+
if (!isPlainObject(options)) {
|
|
9
|
+
throw new Error("bundledLists must be an object like { table: { columnPicker: true } }.");
|
|
10
|
+
}
|
|
11
|
+
const unknownKeys = Object.keys(options).filter((key) => !BUNDLED_LISTS_OPTION_KEYS.some((allowed) => allowed === key));
|
|
12
|
+
if (unknownKeys.length > 0) {
|
|
13
|
+
throw new Error(`bundledLists has unknown key(s) ${unknownKeys.map((k) => `"${k}"`).join(", ")}. ` +
|
|
14
|
+
`Allowed keys: ${BUNDLED_LISTS_OPTION_KEYS.join(", ")}.`);
|
|
15
|
+
}
|
|
16
|
+
const table = options["table"];
|
|
17
|
+
if (!isPlainObject(table)) {
|
|
18
|
+
const allowed = LIST_TABLE_OPTION_KEYS.filter((key) => !SCREEN_ONLY_TABLE_KEYS.some((screenOnly) => screenOnly === key));
|
|
19
|
+
throw new Error(`bundledLists.table must be an object like { columnPicker: true }. ` +
|
|
20
|
+
`Allowed keys: ${allowed.join(", ")}.`);
|
|
21
|
+
}
|
|
22
|
+
for (const key of SCREEN_ONLY_TABLE_KEYS) {
|
|
23
|
+
if (table[key] !== undefined) {
|
|
24
|
+
throw new Error(`bundledLists.table.${key} is not allowed: ${key} depends on the fields of one list. ` +
|
|
25
|
+
`Set it in the table block of that screen.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
validateListTableOptions("bundledLists", table, [], true);
|
|
29
|
+
const pinFirstColumn = options["pinFirstColumn"];
|
|
30
|
+
if (pinFirstColumn !== undefined && typeof pinFirstColumn !== "boolean") {
|
|
31
|
+
throw new Error("bundledLists.pinFirstColumn must be a boolean.");
|
|
32
|
+
}
|
|
33
|
+
if (pinFirstColumn === true && table["pinning"] !== true) {
|
|
34
|
+
throw new Error("bundledLists.pinFirstColumn needs bundledLists.table.pinning: true.");
|
|
35
|
+
}
|
|
36
|
+
}
|