@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.
Files changed (194) hide show
  1. package/AGENTS.md +3 -0
  2. package/agents/examples/golden-path.md +13 -1
  3. package/agents/examples/lifecycle-hooks.md +55 -2
  4. package/agents/examples/list-table-options.md +466 -0
  5. package/agents/examples/pdf-audit-export.md +4 -2
  6. package/agents/examples/state-machine.md +3 -2
  7. package/agents/examples/tenant-isolation.md +73 -1
  8. package/agents/examples.md +1 -0
  9. package/agents/widgets.md +1 -0
  10. package/dist/api/api-constants.d.ts +1 -0
  11. package/dist/api/api-constants.js +2 -0
  12. package/dist/api/auth-routes-shared.d.ts +2 -0
  13. package/dist/api/auth-routes-shared.js +5 -0
  14. package/dist/api/client-error-route.js +2 -3
  15. package/dist/api/entity-read-access.d.ts +2 -1
  16. package/dist/api/entity-read-access.js +14 -0
  17. package/dist/api/export/export-cells-reference.d.ts +9 -0
  18. package/dist/api/export/export-cells-reference.js +21 -0
  19. package/dist/api/export/export-cells.d.ts +11 -0
  20. package/dist/api/export/export-cells.js +136 -0
  21. package/dist/api/export/export-ciphertext-guard.d.ts +2 -0
  22. package/dist/api/export/export-ciphertext-guard.js +27 -0
  23. package/dist/api/export/export-columns.d.ts +29 -0
  24. package/dist/api/export/export-columns.js +85 -0
  25. package/dist/api/export/export-errors.d.ts +4 -0
  26. package/dist/api/export/export-errors.js +14 -0
  27. package/dist/api/export/export-route.d.ts +4 -0
  28. package/dist/api/export/export-route.js +74 -0
  29. package/dist/api/export/export-rows.d.ts +13 -0
  30. package/dist/api/export/export-rows.js +62 -0
  31. package/dist/api/export/export-screen.d.ts +12 -0
  32. package/dist/api/export/export-screen.js +47 -0
  33. package/dist/api/export/export-xlsx-writer.d.ts +4 -0
  34. package/dist/api/export/export-xlsx-writer.js +21 -0
  35. package/dist/api/extra-route.d.ts +4 -0
  36. package/dist/api/pii-leak-guard.d.ts +3 -0
  37. package/dist/api/pii-leak-guard.js +3 -2
  38. package/dist/api/routes.d.ts +16 -0
  39. package/dist/api/routes.js +2 -2
  40. package/dist/api/server-extra-routes.js +3 -0
  41. package/dist/api/server-routes.d.ts +1 -1
  42. package/dist/api/server-routes.js +8 -2
  43. package/dist/api/server-wiring.js +2 -0
  44. package/dist/api/sse-route.d.ts +2 -1
  45. package/dist/api/sse-route.js +11 -1
  46. package/dist/config/index.d.ts +3 -1
  47. package/dist/crypto/pg-kms-adapter.d.ts +1 -0
  48. package/dist/crypto/pg-kms-adapter.js +12 -18
  49. package/dist/db/aggregate-sql.d.ts +2 -0
  50. package/dist/db/aggregate-sql.js +10 -0
  51. package/dist/db/closed-connection.js +12 -19
  52. package/dist/db/connection.d.ts +4 -5
  53. package/dist/db/connection.js +29 -20
  54. package/dist/db/driver-row-coercion.js +4 -4
  55. package/dist/db/driver-value-preparation.js +6 -4
  56. package/dist/db/event-store-executor-read-aggregate-currencies.d.ts +17 -0
  57. package/dist/db/event-store-executor-read-aggregate-currencies.js +40 -0
  58. package/dist/db/event-store-executor-read-aggregate.js +36 -5
  59. package/dist/db/event-store-executor-read-filter.js +6 -3
  60. package/dist/db/event-store-executor-write-atomic.d.ts +4 -0
  61. package/dist/db/event-store-executor-write-atomic.js +41 -0
  62. package/dist/db/event-store-executor-write-steps.d.ts +0 -3
  63. package/dist/db/event-store-executor-write-steps.js +0 -5
  64. package/dist/db/event-store-executor-write.js +15 -15
  65. package/dist/db/event-store-executor.js +2 -1
  66. package/dist/db/executor-managed-tables.js +1 -1
  67. package/dist/db/index.d.ts +1 -1
  68. package/dist/db/index.js +1 -1
  69. package/dist/db/migrate-runner.js +3 -3
  70. package/dist/db/pg-error.js +26 -21
  71. package/dist/db/queries/backfill-pii.js +3 -6
  72. package/dist/db/queries/database-now.js +2 -1
  73. package/dist/db/queries/shadow-swap.js +2 -3
  74. package/dist/db/query.d.ts +14 -0
  75. package/dist/db/query.js +68 -43
  76. package/dist/db/reference-data.js +14 -2
  77. package/dist/db/statement-timeout.js +2 -1
  78. package/dist/db/tenant-db-upsert.d.ts +5 -0
  79. package/dist/db/tenant-db-upsert.js +91 -0
  80. package/dist/db/tenant-db.js +11 -1
  81. package/dist/engine/boot-validator/bundled-lists-table.d.ts +2 -0
  82. package/dist/engine/boot-validator/bundled-lists-table.js +36 -0
  83. package/dist/engine/boot-validator/entity-list-aggregate-options.d.ts +3 -0
  84. package/dist/engine/boot-validator/entity-list-aggregate-options.js +23 -0
  85. package/dist/engine/boot-validator/list-bulk-actions.d.ts +3 -0
  86. package/dist/engine/boot-validator/list-bulk-actions.js +49 -0
  87. package/dist/engine/boot-validator/list-column-layout.d.ts +8 -0
  88. package/dist/engine/boot-validator/list-column-layout.js +41 -0
  89. package/dist/engine/boot-validator/list-table-aggregate-options.d.ts +24 -0
  90. package/dist/engine/boot-validator/list-table-aggregate-options.js +145 -0
  91. package/dist/engine/boot-validator/list-table-aggregate-query.d.ts +10 -0
  92. package/dist/engine/boot-validator/list-table-aggregate-query.js +31 -0
  93. package/dist/engine/boot-validator/list-table-export-options.d.ts +1 -0
  94. package/dist/engine/boot-validator/list-table-export-options.js +27 -0
  95. package/dist/engine/boot-validator/list-table-options.d.ts +3 -0
  96. package/dist/engine/boot-validator/list-table-options.js +77 -0
  97. package/dist/engine/boot-validator/list-table-reject-unknown-keys.d.ts +1 -0
  98. package/dist/engine/boot-validator/list-table-reject-unknown-keys.js +7 -0
  99. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  100. package/dist/engine/boot-validator/projection-list-screens.js +3 -13
  101. package/dist/engine/boot-validator/related-list-table-rules.d.ts +4 -0
  102. package/dist/engine/boot-validator/related-list-table-rules.js +42 -0
  103. package/dist/engine/boot-validator/screens-kind-entity-list.js +7 -0
  104. package/dist/engine/boot-validator/screens-kind-projection-list.js +4 -0
  105. package/dist/engine/boot-validator/screens-lists.js +12 -2
  106. package/dist/engine/build-app-schema.d.ts +2 -1
  107. package/dist/engine/build-app-schema.js +9 -1
  108. package/dist/engine/build-config-feature-schema.js +6 -1
  109. package/dist/engine/define-feature.js +4 -0
  110. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  111. package/dist/engine/feature-ast/extractors/index.js +1 -1
  112. package/dist/engine/feature-ast/extractors/round1.d.ts +2 -1
  113. package/dist/engine/feature-ast/extractors/round1.js +6 -0
  114. package/dist/engine/feature-ast/index.d.ts +1 -1
  115. package/dist/engine/feature-ast/parse.js +3 -1
  116. package/dist/engine/feature-ast/patch.d.ts +2 -0
  117. package/dist/engine/feature-ast/patch.js +1 -0
  118. package/dist/engine/feature-ast/pattern-change-schema.d.ts +7 -245
  119. package/dist/engine/feature-ast/pattern-change-schema.js +9 -163
  120. package/dist/engine/feature-ast/pattern-id-matchers.js +1 -0
  121. package/dist/engine/feature-ast/pattern-id-schema.d.ts +251 -0
  122. package/dist/engine/feature-ast/pattern-id-schema.js +166 -0
  123. package/dist/engine/feature-ast/patterns.d.ts +5 -1
  124. package/dist/engine/feature-ast/patterns.js +1 -0
  125. package/dist/engine/feature-ast/render.js +4 -0
  126. package/dist/engine/feature-builder-state.d.ts +1 -0
  127. package/dist/engine/feature-builder-state.js +1 -0
  128. package/dist/engine/feature-manifest.d.ts +1 -0
  129. package/dist/engine/feature-manifest.js +1 -0
  130. package/dist/engine/field-access.d.ts +1 -0
  131. package/dist/engine/field-access.js +12 -0
  132. package/dist/engine/index.d.ts +2 -2
  133. package/dist/engine/index.js +1 -1
  134. package/dist/engine/pattern-library/library.js +2 -1
  135. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  136. package/dist/engine/pattern-library/static-schemas.js +9 -0
  137. package/dist/engine/screen-filter-ops.js +2 -0
  138. package/dist/engine/strip-disabled-features-from-app-schema.js +7 -3
  139. package/dist/engine/types/feature.d.ts +1 -0
  140. package/dist/engine/types/index.d.ts +4 -3
  141. package/dist/engine/types/screen.d.ts +1 -1
  142. package/dist/engine/types/screen.js +1 -1
  143. package/dist/entrypoint/index.d.ts +2 -0
  144. package/dist/entrypoint/index.js +3 -0
  145. package/dist/event-store/event-store.d.ts +1 -0
  146. package/dist/event-store/event-store.js +7 -4
  147. package/dist/event-store/provenance-append.js +1 -1
  148. package/dist/files/create-file-ref-from-bytes.js +3 -0
  149. package/dist/files/create-file-ref.d.ts +3 -0
  150. package/dist/files/create-file-ref.js +11 -2
  151. package/dist/files/file-routes.d.ts +2 -0
  152. package/dist/files/file-routes.js +29 -2
  153. package/dist/files/provider-resolver.d.ts +1 -0
  154. package/dist/files/provider-resolver.js +1 -1
  155. package/dist/files/record-write-check.d.ts +6 -0
  156. package/dist/files/record-write-check.js +13 -0
  157. package/dist/files/upload-ledger.d.ts +2 -2
  158. package/dist/files/upload-ledger.js +4 -4
  159. package/dist/i18n/required-surface-keys.js +7 -2
  160. package/dist/internal/testing.js +1 -1
  161. package/dist/jobs/job-runner-scope.js +6 -6
  162. package/dist/jobs/job-runner.js +3 -0
  163. package/dist/observability/escape-hatch-report.js +9 -2
  164. package/dist/pipeline/after-rollback-sink.d.ts +6 -0
  165. package/dist/pipeline/after-rollback-sink.js +32 -0
  166. package/dist/pipeline/dispatch-batch.js +8 -1
  167. package/dist/pipeline/dispatch-shared.js +4 -0
  168. package/dist/pipeline/event-dispatcher-delivery.js +3 -1
  169. package/dist/pipeline/event-dispatcher.d.ts +1 -0
  170. package/dist/pipeline/event-dispatcher.js +6 -9
  171. package/dist/pipeline/handler-context-dispatch.d.ts +2 -0
  172. package/dist/pipeline/handler-context-dispatch.js +13 -1
  173. package/dist/pipeline/handler-context-events.d.ts +2 -4
  174. package/dist/pipeline/handler-context-events.js +6 -4
  175. package/dist/pipeline/system-hooks.d.ts +3 -1
  176. package/dist/pipeline/system-hooks.js +55 -6
  177. package/dist/pipeline/write-result-masking.js +13 -10
  178. package/dist/schema-cli/baseline-command.js +1 -1
  179. package/dist/schema-cli/status-command.js +1 -1
  180. package/dist/search/reindex-entity.js +15 -5
  181. package/dist/ui-types/index.d.ts +2 -2
  182. package/dist/ui-types/index.js +1 -1
  183. package/dist/upgrade-changelog.d.ts +2 -0
  184. package/dist/upgrade-changelog.js +14 -0
  185. package/dist/upgrade-cli.d.ts +3 -12
  186. package/dist/upgrade-cli.js +22 -76
  187. package/dist/upgrade-marker-parse.d.ts +32 -0
  188. package/dist/upgrade-marker-parse.js +73 -0
  189. package/package.json +9 -6
  190. package/src/changes.json +245 -0
  191. package/src/scripts/codemod/codemod-support.ts +1 -1
  192. package/src/scripts/codemod/db-factory.ts +1 -1
  193. package/dist/engine/provider-plugin-context.d.ts +0 -3
  194. 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
- // postgres.js clients are callable (tagged templates), so a runner can be a function.
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;
@@ -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 postgres-js Sql or drizzle DbConnection)");
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 postgres-js .unsafe().
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 postgres-js Sql)");
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
  }
@@ -1,28 +1,33 @@
1
- // Drizzle wraps postgres-js errors in `DrizzleQueryError`; the original PG
2
- // error (with SQLSTATE `code` and `constraint_name`) lives in `.cause`. We
3
- // unwrap both layers so callers don't have to know which layer produced the
4
- // error. Used by the event-store to distinguish a unique-violation on the
5
- // aggregate-version index (optimistic-concurrency conflict) from the one on
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
- while (typeof current === "object" && current !== null && layers.length < MAX_CAUSE_DEPTH) {
15
- layers.push(current);
16
- // @cast-boundary error-details — DrizzleQueryError wraps PG-error in .cause
17
- current = current.cause;
18
- }
19
- for (const layer of layers) {
20
- // @cast-boundary error-details — postgres-js error shape (code, constraint_name)
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 fieldDef = target.entity.fields[target.field];
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
- if (typeof e !== "object" || e === null || !("code" in e))
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";
@@ -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 postgres-js driver: EntityTableMeta in, rows out.
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: postgres-js Sql / TransactionSql. Both
43
- // expose `.unsafe`; only Sql has `.begin` — TransactionSql uses
44
- // `.savepoint` for nested-tx. We only require `.unsafe` here; callers that
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 = postgres-js Sql.
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 = postgres-js Sql.
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 postgres-js Sql or a transaction handle.");
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 (postgres.js TransactionSql).");
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 === "25P01" || code === "ERR_POSTGRES_CONNECTION_CLOSED")) {
114
+ if (!fnStarted && isSavepointOnDeadTransactionCode(code)) {
94
115
  throw new InternalError({
95
- message: code === "25P01"
96
- ? "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
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
- // postgres-js reports the affected-row count of a DELETE on the result, no RETURNING needed.
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
- function buildInsertSql(info, entries) {
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 existing = await fetchOne(db, table, {
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
+ }
@@ -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
- // postgres-js hands back a raw connection; re-wrap it so the gate survives.
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,2 @@
1
+ export declare const BUNDLED_LISTS_OPTION_KEYS: readonly ["table", "pinFirstColumn"];
2
+ export declare function validateBundledListsOptions(options: unknown): void;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { EntityDefinition } from "../types/index.ts";
2
+ import type { EntityListScreenDefinition } from "../types/screen.ts";
3
+ export declare function validateEntityListAggregateOptions(where: string, screen: EntityListScreenDefinition, entity: EntityDefinition): void;