@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -21,6 +21,7 @@ export type RequestContextData = {
21
21
  readonly risk: AgentRisk;
22
22
  };
23
23
  readonly chargedRateLimitBuckets?: Set<string>;
24
+ readonly agentToolCall?: true;
24
25
  };
25
26
  export declare const requestContext: {
26
27
  run<T>(data: RequestContextData, fn: () => T): T;
@@ -41,4 +42,5 @@ export declare function runWithOrigin<T>(origin: {
41
42
  };
42
43
  }, fn: () => T): T;
43
44
  export declare function runAsDirectCallEntry<T>(fn: () => T): T;
45
+ export declare function isAgentToolCallScope(): boolean;
44
46
  export declare function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T;
@@ -48,15 +48,24 @@ export function runWithOrigin(origin, fn) {
48
48
  : new Set(),
49
49
  }, fn);
50
50
  }
51
- // Clears entryHandler so the next dispatch inside fn becomes the new entry —
52
- // used by dispatchToolCall so the invoked tool handler, not the surrounding
53
- // turn/approve handler, is what the irreversible-operation gate sees.
51
+ // Clears entryHandler so the next dispatch inside fn becomes the new entry, letting
52
+ // the irreversible-operation gate see the invoked tool handler instead of the
53
+ // surrounding turn/approve handler. Reserved for dispatchToolCall, so it also marks
54
+ // the scope as an agent tool call; it always mints a scope, because an agent loop
55
+ // running outside any request must still carry that marker.
54
56
  export function runAsDirectCallEntry(fn) {
55
57
  const current = requestContext.get();
56
- if (!current)
57
- return fn();
58
- const { entryHandler: _entryHandler, ...rest } = current;
59
- return requestContext.run(rest, fn);
58
+ const requestId = current?.requestId ?? requestContext.generateId();
59
+ const { entryHandler: _entryHandler, ...rest } = current ?? {};
60
+ return requestContext.run({
61
+ ...rest,
62
+ requestId,
63
+ correlationId: current?.correlationId ?? requestId,
64
+ agentToolCall: true,
65
+ }, fn);
66
+ }
67
+ export function isAgentToolCallScope() {
68
+ return requestContext.get()?.agentToolCall === true;
60
69
  }
61
70
  // An ungated origin never mints a scope: seeds and boot writes keep their missing context.
62
71
  export function runWithWriteOrigin(origin, fn) {
@@ -21,7 +21,7 @@ export type AppClientConfig = {
21
21
  readonly entries: readonly string[];
22
22
  };
23
23
  /** The boot-validator options an app owns; `env` and the cipher are injected by the runners. */
24
- export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "warnOnUniqueAccessRoles">;
24
+ export type AppValidateBootOptions = Pick<ValidateBootOptions, "navAllowlist" | "navAllowlistExempt" | "requiredLocales" | "warnOnUniqueAccessRoles">;
25
25
  export type AppConfig = {
26
26
  /** Features mounted in every environment: dev, tests, prod. */
27
27
  readonly features: readonly FeatureDefinition[];
@@ -1,4 +1,4 @@
1
- // Leaf module (no engine imports) so bun-db and the executors can sniff PII
1
+ // Leaf module (no engine imports) so the query helpers and the executors can sniff PII
2
2
  // ciphertext without pulling the KMS/pipeline graph into an import cycle.
3
3
  export const PII_CIPHERTEXT_PREFIX_V1 = "kumiko-pii:v1:";
4
4
  export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v2:";
@@ -77,7 +77,7 @@ async function existingTableNames(db, tableNames) {
77
77
  export async function subjectRowExistsInTenant(db, features, subjectId, tenantId) {
78
78
  // Prefetch which self-PII projection tables actually exist — probing a
79
79
  // missing relation used to throw (and get swallowed), which both hid
80
- // real schema bugs and risked poisoning the Bun.SQL connection for the
80
+ // real schema bugs and risked poisoning the driver connection for the
81
81
  // rest of the forget TX (fw#2348 / framework#356).
82
82
  const candidateTables = [];
83
83
  for (const feature of features.values()) {
@@ -0,0 +1,8 @@
1
+ import type { CrossTenantReads } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import type { EscapeHatchReporter } from "../engine/types/index.ts";
3
+ import { type CreateTenantDbOptions, type TenantDb } from "./tenant-db.ts";
4
+ export declare function createCrossTenantReads(systemModeDb: TenantDb, reason: string, report: EscapeHatchReporter): CrossTenantReads;
5
+ export declare function createHandlerCrossTenantReads(options: Omit<CreateTenantDbOptions, "mode"> & {
6
+ readonly reason: string;
7
+ readonly report: EscapeHatchReporter;
8
+ }): CrossTenantReads;
@@ -0,0 +1,23 @@
1
+ import { createTenantDb } from "./tenant-db.js";
2
+ export function createCrossTenantReads(systemModeDb, reason, report) {
3
+ return {
4
+ selectMany(table, where, options) {
5
+ report("cross-tenant-read", reason);
6
+ return systemModeDb.selectMany(table, where, options);
7
+ },
8
+ fetchOne(table, where) {
9
+ report("cross-tenant-read", reason);
10
+ return systemModeDb.fetchOne(table, where);
11
+ },
12
+ count(table, where) {
13
+ report("cross-tenant-read", reason);
14
+ return systemModeDb.count(table, where);
15
+ },
16
+ };
17
+ }
18
+ // Own system-mode TenantDb without unsafeRaw/globalWrites: the handler gets the read surface only.
19
+ export function createHandlerCrossTenantReads(options) {
20
+ const { reason, report, ...dbOptions } = options;
21
+ const systemModeDb = createTenantDb({ ...dbOptions, mode: "system" });
22
+ return createCrossTenantReads(systemModeDb, reason, report);
23
+ }
@@ -1,7 +1,7 @@
1
1
  // Native dialect — replaces drizzle-orm/pg-core re-exports.
2
2
  //
3
3
  // Produces table objects that simultaneously expose:
4
- // 1. EntityTableMeta shape (source/columns/indexes) for bun-db's
4
+ // 1. EntityTableMeta shape (source/columns/indexes) for the query helpers'
5
5
  // extractTableInfo + the migrate-runner's renderTableDdl
6
6
  // 2. drizzle-compatible Symbol metadata so callers that introspect
7
7
  // Symbol.for("kumiko:schema:Name") / Symbol.for("kumiko:schema:Columns") keep
@@ -0,0 +1,9 @@
1
+ import type { OrderByClause } from "@cosmicdrift/kumiko-types/where-clause-types";
2
+ import type { TableInfo } from "./query.ts";
3
+ type DistinctOnClause = {
4
+ /** Physical column names, unquoted. */
5
+ readonly columns: readonly string[];
6
+ readonly orderBy: readonly OrderByClause[];
7
+ };
8
+ export declare function buildDistinctOnClause(info: TableInfo, distinctOn: readonly string[] | undefined, orderBy: OrderByClause | readonly OrderByClause[] | undefined): DistinctOnClause | undefined;
9
+ export {};
@@ -0,0 +1,28 @@
1
+ // Postgres rejects DISTINCT ON unless ORDER BY starts with the same
2
+ // expressions, so a mismatch is caught here with a readable message instead of
3
+ // surfacing as a driver error.
4
+ export function buildDistinctOnClause(info, distinctOn, orderBy) {
5
+ if (distinctOn === undefined)
6
+ return undefined;
7
+ if (distinctOn.length === 0) {
8
+ throw new Error(`selectMany: distinctOn must not be empty for "${info.name}"`);
9
+ }
10
+ for (const field of distinctOn) {
11
+ if (!info.hasColumn(field)) {
12
+ throw new Error(`selectMany: distinctOn entry "${field}" is not a column of "${info.name}"`);
13
+ }
14
+ }
15
+ const columns = distinctOn.map((field) => info.columnOf(field));
16
+ if (orderBy === undefined) {
17
+ return { columns, orderBy: distinctOn.map((col) => ({ col })) };
18
+ }
19
+ const clauses = Array.isArray(orderBy) ? orderBy : [orderBy];
20
+ const startsWithDistinctOn = columns.every((column, i) => {
21
+ const clause = clauses[i];
22
+ return (clause !== undefined && info.hasColumn(clause.col) && info.columnOf(clause.col) === column);
23
+ });
24
+ if (!startsWithDistinctOn) {
25
+ throw new Error(`selectMany: orderBy must start with the distinctOn fields [${distinctOn.join(", ")}] in the same order for "${info.name}"`);
26
+ }
27
+ return { columns, orderBy: clauses };
28
+ }
@@ -290,7 +290,7 @@ export function createWriteVerbs(ctx) {
290
290
  // selben catch (siehe line 493+).
291
291
  let event;
292
292
  try {
293
- // Savepoint-scoped: postgres.js/Bun.SQL poison the WHOLE surrounding
293
+ // Savepoint-scoped: postgres.js poisons the WHOLE surrounding
294
294
  // begin() once any statement inside it errors, even if the JS error
295
295
  // is caught (kumiko-framework#1778) — a losing concurrent create's
296
296
  // unique-violation would otherwise abort the caller's outer
@@ -336,7 +336,7 @@ export function createWriteVerbs(ctx) {
336
336
  // UniqueViolationError 409 damit Designer/Frontend einen sauberen
337
337
  // "duplicate" zeigen können statt cryptic "internal server error".
338
338
  // Savepoint like the append above: an unclassified DB error out of the
339
- // projection INSERT would otherwise poison the enclosing tx (Bun.SQL
339
+ // projection INSERT would otherwise poison the enclosing tx (postgres-js
340
340
  // rejects the whole begin() once any statement in it failed), so a later
341
341
  // write sharing that tx — a sibling write after a failed nested one —
342
342
  // throws raw as a 500 instead of failing cleanly. The savepoint rolls the
@@ -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 Bun.SQL or drizzle DbConnection)");
191
+ throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need a postgres-js Sql or drizzle DbConnection)");
192
192
  }
193
- // Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
193
+ // Raw-SQL via postgres-js .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 Bun.SQL or postgres-js Sql)");
200
+ throw new Error("migrate-runner: db argument has no .reserve() (need a postgres-js Sql)");
201
201
  }
202
202
  return client.reserve();
203
203
  }
@@ -235,7 +235,7 @@ export class MigrationChecksumMismatchError extends Error {
235
235
  // Advisory-Lock verhindert concurrent-apply-races.
236
236
  export async function runMigrations(db, migrations) {
237
237
  // Session-level advisory lock and per-migration BEGIN/COMMIT must share one
238
- // session. Pool calls may land on different connections (Bun.SQL round-robins),
238
+ // session. Pool calls may land on different connections (the pool round-robins),
239
239
  // which would leave the lock held on a session nobody unlocks.
240
240
  const conn = await reserveConnection(db);
241
241
  try {
@@ -20,18 +20,6 @@ export function extractPgError(e) {
20
20
  // @cast-boundary error-details — postgres-js error shape (code, constraint_name)
21
21
  const code = layer.code;
22
22
  const constraintName = layer.constraint_name; // @cast-boundary error-details
23
- // Bun.SQL carries the SQLSTATE in `errno` (string) and the constraint name
24
- // in `constraint`, not in postgres-js's `code`/`constraint_name`.
25
- if (code === "ERR_POSTGRES_SERVER_ERROR") {
26
- const errno = layer.errno; // @cast-boundary error-details
27
- const constraint = layer.constraint; // @cast-boundary error-details
28
- if (typeof errno === "string") {
29
- return {
30
- code: errno,
31
- constraint_name: constraintName ?? (typeof constraint === "string" ? constraint : undefined),
32
- };
33
- }
34
- }
35
23
  if (code !== undefined || constraintName !== undefined) {
36
24
  return { code, constraint_name: constraintName };
37
25
  }
@@ -178,7 +178,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
178
178
  "status" = $3,
179
179
  "last_error" = $4,
180
180
  "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
181
- -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
181
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
182
182
  "pending_gaps" = $6::text::jsonb,
183
183
  "last_failed_event_id" = $9,
184
184
  "updated_at" = now()
@@ -210,7 +210,7 @@ pendingGaps) {
210
210
  "last_error" = NULL,
211
211
  "rearm_count" = 0,
212
212
  "last_failed_event_id" = NULL,
213
- -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
213
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
214
214
  "pending_gaps" = $4::text::jsonb,
215
215
  "updated_at" = now()
216
216
  WHERE "name" = $2 AND "instance_id" = $3
@@ -239,7 +239,7 @@ export async function updateConsumerRebuildCursor(db, name, instanceId, lastProc
239
239
  "status" = 'idle',
240
240
  "attempts" = 0,
241
241
  "last_error" = NULL,
242
- -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
242
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under the driver
243
243
  "pending_gaps" = $2::text::jsonb,
244
244
  "last_failed_event_id" = NULL,
245
245
  "updated_at" = now()
@@ -1,5 +1,5 @@
1
- // Bun.SQL stringifies JS arrays via toString, so array params travel as a
2
- // Postgres array-literal string (`$1::text[]`) — identical on postgres-js and Bun.SQL.
1
+ // Array params travel as a Postgres array-literal string (`$1::text[]`) so the
2
+ // binding does not depend on how the driver infers JS array types.
3
3
  export function toPostgresArrayLiteral(values) {
4
4
  const elements = values.map((value) => value === null ? "NULL" : `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
5
5
  return `{${elements.join(",")}}`;
@@ -2,5 +2,8 @@ import type { AnyDb } from "../query.ts";
2
2
  export { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement } from "./ddl.ts";
3
3
  export declare function truncateTablesRestartIdentity(db: AnyDb, tableNames: readonly string[]): Promise<void>;
4
4
  export declare function databaseExists(db: AnyDb, dbName: string): Promise<boolean>;
5
- export declare function createDatabase(db: AnyDb, dbName: string): Promise<void>;
5
+ export declare function createDatabase(db: AnyDb, dbName: string, templateDbName?: string): Promise<void>;
6
+ export declare function renameDatabase(db: AnyDb, fromName: string, toName: string): Promise<void>;
7
+ export declare function listDatabaseNamesWithPrefix(db: AnyDb, prefix: string): Promise<readonly string[]>;
8
+ export declare function acquireSessionAdvisoryLock(db: AnyDb, key: string): Promise<void>;
6
9
  export declare function dropDatabaseIfExists(db: AnyDb, dbName: string): Promise<void>;
@@ -12,8 +12,21 @@ export async function databaseExists(db, dbName) {
12
12
  const rows = await asRawClient(db).unsafe(`SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1) AS exists`, [dbName]);
13
13
  return rows[0]?.exists === true;
14
14
  }
15
- export async function createDatabase(db, dbName) {
16
- await asRawClient(db).unsafe(`CREATE DATABASE ${quoteTableIdent(dbName)}`);
15
+ export async function createDatabase(db, dbName, templateDbName) {
16
+ const template = templateDbName === undefined ? "" : ` TEMPLATE ${quoteTableIdent(templateDbName)}`;
17
+ await asRawClient(db).unsafe(`CREATE DATABASE ${quoteTableIdent(dbName)}${template}`);
18
+ }
19
+ export async function renameDatabase(db, fromName, toName) {
20
+ await asRawClient(db).unsafe(`ALTER DATABASE ${quoteTableIdent(fromName)} RENAME TO ${quoteTableIdent(toName)}`);
21
+ }
22
+ export async function listDatabaseNamesWithPrefix(db, prefix) {
23
+ const rows = await asRawClient(db).unsafe(`SELECT datname FROM pg_database WHERE starts_with(datname, $1) ORDER BY datname`, [prefix]);
24
+ return rows.map((row) => row.datname);
25
+ }
26
+ // Session-scoped: CREATE DATABASE cannot run inside a transaction, so the
27
+ // xact-scoped variant cannot guard it. Released when the session closes.
28
+ export async function acquireSessionAdvisoryLock(db, key) {
29
+ await asRawClient(db).unsafe(`SELECT pg_advisory_lock(hashtext($1))`, [key]);
17
30
  }
18
31
  export async function dropDatabaseIfExists(db, dbName) {
19
32
  await asRawClient(db).unsafe(`DROP DATABASE IF EXISTS ${quoteTableIdent(dbName)}`);
package/dist/db/query.js CHANGED
@@ -24,6 +24,7 @@ import { InternalError } from "../errors/index.js";
24
24
  import { createFallbackLogger } from "../logging/index.js";
25
25
  import { parseJsonSafe } from "../utils/safe-json.js";
26
26
  import { SQL_EXPR_BRAND } from "./dialect.js";
27
+ import { buildDistinctOnClause } from "./distinct-on.js";
27
28
  import { executorManagedWriteDenied } from "./executor-managed-tables.js";
28
29
  import { extractPgError } from "./pg-error.js";
29
30
  import { toSnakeCase } from "./table-builder.js";
@@ -730,7 +731,6 @@ function buildWhereClause(info, where, startIndex, qualifier) {
730
731
  // Each failed attempt discards one, so retries are bounded by pool size.
731
732
  const CLOSED_CONNECTION_CODES = new Set([
732
733
  "CONNECTION_CLOSED", // postgres-js
733
- "ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
734
734
  "57P01", // PG SQLSTATE: admin_shutdown / terminated backend
735
735
  ]);
736
736
  export function isClosedConnectionError(err) {
@@ -756,14 +756,10 @@ function isDriverClosedAbort(err) {
756
756
  function isHandleLike(raw) {
757
757
  return raw !== null && (typeof raw === "object" || typeof raw === "function");
758
758
  }
759
- // Bun.SQL is not instanceof-checkable (Bun.SQL.prototype is invalid) and its tx
760
- // handles are plain function objects, so discriminate by members only one driver
761
- // has: `flush` on Bun.SQL (pool and tx), `typed` on postgres-js (pool and tx).
759
+ // postgres-js pool and tx handles both carry `typed`.
762
760
  function driverOf(raw) {
763
761
  if (!isHandleLike(raw))
764
762
  return "unknown";
765
- if (typeof raw["flush"] === "function")
766
- return "bun-sql";
767
763
  if (typeof raw["typed"] === "function")
768
764
  return "postgres-js";
769
765
  return "unknown";
@@ -771,13 +767,12 @@ function driverOf(raw) {
771
767
  function positiveNumber(v) {
772
768
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
773
769
  }
774
- // postgres-js configures timeouts in seconds, Bun.SQL in milliseconds.
775
- function timeoutOptionMs(options, driver, bunName, postgresJsName) {
776
- if (driver === "postgres-js") {
777
- const seconds = positiveNumber(options[postgresJsName]);
778
- return seconds === undefined ? undefined : seconds * 1000;
779
- }
780
- return positiveNumber(options[bunName]);
770
+ // postgres-js configures timeouts in seconds.
771
+ function timeoutOptionMs(options, driver, optionName) {
772
+ if (driver !== "postgres-js")
773
+ return undefined;
774
+ const seconds = positiveNumber(options[optionName]);
775
+ return seconds === undefined ? undefined : seconds * 1000;
781
776
  }
782
777
  function errorProp(err, name) {
783
778
  return isHandleLike(err) ? err[name] : undefined;
@@ -794,9 +789,9 @@ export function describeClosedConnectionFailure(raw, err, attempts) {
794
789
  pooled: isPooledClient(raw),
795
790
  attempts,
796
791
  poolMax: positiveNumber(options["max"]),
797
- idleTimeoutMs: timeoutOptionMs(options, driver, "idleTimeout", "idle_timeout"),
798
- maxLifetimeMs: timeoutOptionMs(options, driver, "maxLifetime", "max_lifetime"),
799
- connectionTimeoutMs: timeoutOptionMs(options, driver, "connectionTimeout", "connect_timeout"),
792
+ idleTimeoutMs: timeoutOptionMs(options, driver, "idle_timeout"),
793
+ maxLifetimeMs: timeoutOptionMs(options, driver, "max_lifetime"),
794
+ connectionTimeoutMs: timeoutOptionMs(options, driver, "connect_timeout"),
800
795
  errorName: errorProp(err, "name"),
801
796
  errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
802
797
  errorErrno: errorProp(err, "errno"),
@@ -889,15 +884,20 @@ export async function selectMany(db, table, where, options) {
889
884
  return scoped.selectMany(table, where, options);
890
885
  }
891
886
  const info = extractTableInfo(table);
892
- let sqlText = `SELECT ${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
887
+ const distinctOn = buildDistinctOnClause(info, options?.distinctOn, options?.orderBy);
888
+ const distinctPrefix = distinctOn === undefined
889
+ ? ""
890
+ : `DISTINCT ON (${distinctOn.columns.map(quoteIdent).join(", ")}) `;
891
+ let sqlText = `SELECT ${distinctPrefix}${selectColumnList(info, options?.fields)} FROM ${quoteIdent(info.name)}`;
893
892
  let values = [];
894
893
  if (where && Object.keys(where).length > 0) {
895
894
  const w = buildWhereClause(info, where, 1);
896
895
  sqlText += ` WHERE ${w.sqlText}`;
897
896
  values = w.values;
898
897
  }
899
- if (options?.orderBy) {
900
- const clauses = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
898
+ const orderBy = distinctOn?.orderBy ?? options?.orderBy;
899
+ if (orderBy) {
900
+ const clauses = Array.isArray(orderBy) ? orderBy : [orderBy];
901
901
  const parts = clauses.map((c) => {
902
902
  const col = info.columnOf(c.col);
903
903
  const dir = c.direction === "desc" ? "DESC" : "ASC";
@@ -33,7 +33,7 @@ function boundRunner(runner, gate, projectionRegistry, executorManagedWrites) {
33
33
  if (typeof value !== "function")
34
34
  return value;
35
35
  if (prop === "reserve") {
36
- // Bun.SQL and postgres.js hand back a raw connection; re-wrap it so the gate survives.
36
+ // postgres-js hands back a raw connection; re-wrap it so the gate survives.
37
37
  return async (...args) => {
38
38
  const reserved = await Reflect.apply(value, target, args);
39
39
  return boundRunner(reserved, gate, projectionRegistry, executorManagedWrites);
@@ -1,3 +1,4 @@
1
1
  import type { FeatureDefinition } from "../types/index.ts";
2
2
  export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void;
3
3
  export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
4
+ export declare function validateRequiredLocales(features: readonly FeatureDefinition[], requiredLocales: readonly string[] | undefined): void;
@@ -1,4 +1,4 @@
1
- import { buildEffectiveTranslationKeys, featureHasI18nSurface, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
1
+ import { buildEffectiveTranslationKeys, featureHasI18nSurface, findRequiredLocaleGaps, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
2
2
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
3
3
  import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
4
4
  import { createRegistry } from "../registry.js";
@@ -93,3 +93,10 @@ export function validateTranslationLocaleConsistency(features) {
93
93
  throw new Error(`${lines.join("\n")}\nEvery locale a feature uses must be maintained for every one of its translation keys; otherwise move that locale into a locale package.`);
94
94
  }
95
95
  }
96
+ export function validateRequiredLocales(features, requiredLocales) {
97
+ const gaps = findRequiredLocaleGaps(features, requiredLocales ?? []);
98
+ if (gaps.length > 0) {
99
+ const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing required locale(s) ${gap.missingLocales.join(", ")}.`);
100
+ throw new Error(`${lines.join("\n")}\nThe app requires locales [${(requiredLocales ?? []).join(", ")}] for every translation key; load the matching locale package or add the missing translations.`);
101
+ }
102
+ }
@@ -32,6 +32,10 @@ export type ValidateBootOptions = {
32
32
  * `masterKey` or the env keyring). Satisfies the availability check for
33
33
  * `encrypted: true` fields without an env KEK. */
34
34
  readonly entityFieldCipher?: EnvelopeCipher;
35
+ /** Locales every translation key must carry across all mounted features
36
+ * (locale packages count). Without it a key can ship in one language and
37
+ * silently fall back at runtime for users of the others. */
38
+ readonly requiredLocales?: readonly string[];
35
39
  };
36
40
  /**
37
41
  * Subset-safe slice of validateBoot for test stacks: nav references (screen,
@@ -15,7 +15,7 @@ import { validateEntityListScreens } from "./entity-list-screens.js";
15
15
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
16
16
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
17
17
  import { validateHandlerFieldReferences } from "./handler-field-references.js";
18
- import { validateI18nSurfaceKeys, validateTranslationLocaleConsistency } from "./i18n-keys.js";
18
+ import { validateI18nSurfaceKeys, validateRequiredLocales, validateTranslationLocaleConsistency, } from "./i18n-keys.js";
19
19
  import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
20
20
  import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
21
21
  import { validateParentRefs } from "./parent-ref.js";
@@ -219,6 +219,7 @@ export function validateBoot(rawFeatures, options) {
219
219
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
220
220
  validateI18nSurfaceKeys(features);
221
221
  validateTranslationLocaleConsistency(features);
222
+ validateRequiredLocales(features, options?.requiredLocales);
222
223
  validateEntityListScreens(features);
223
224
  validateSubmitPrefilledHiddenFields(features);
224
225
  validateCardMetaOverflow(features);
@@ -0,0 +1,67 @@
1
+ import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
2
+ import type { FeatureDefinition } from "../types/index.ts";
3
+ import type { ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessLink, ProjectionDetailScreenDefinition, RelatedListToolbarAction, RowAction, RowActionNavigateBase, RowFieldExtractor, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction } from "../types/screen.ts";
4
+ export declare function isRowClickAction(action: RowAction): boolean;
5
+ export declare function validateAtMostOneRowClick(featureName: string, screenId: string, screenType: "entityList" | "projectionList", rowActions: readonly RowAction[]): void;
6
+ export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
7
+ export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
8
+ export type NavigateSourceScreenType = "dashboard" | "entityList" | "projectionList" | "projectionDetail" | "entityEdit" | "actionForm" | "secretMint" | "entityList expandableRow";
9
+ export declare function validateRowActionNavigateParams(featureName: string, screenId: string, screenType: NavigateSourceScreenType, screenEntity: string | undefined, action: RowAction, target: {
10
+ readonly featureName: string;
11
+ readonly screen: ScreenDefinition;
12
+ } | undefined, actionLabel?: "rowAction" | "emptyState action"): void;
13
+ export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList" | "dashboard", context: string, action: {
14
+ readonly id: string;
15
+ readonly icon?: IconKey;
16
+ }): void;
17
+ export declare function validateActionFieldRefs(featureName: string, screenId: string, actionKind: "rowAction" | "toolbarAction", actionId: string, action: RowAction | ToolbarAction | RelatedListToolbarAction, fieldNames: ReadonlySet<string>, rowMeta: ReadonlySet<string>): void;
18
+ export declare function validateScreenShortIdCollisions(screensByShortId: ReadonlyMap<string, ReadonlyArray<{
19
+ readonly featureName: string;
20
+ readonly screen: ScreenDefinition;
21
+ }>>): void;
22
+ export declare function resolveScreenTargetQn(featureName: string, target: string): string;
23
+ export declare function validateScreenNavTarget(featureName: string, screenId: string, screenKind: string, fieldName: string, value: string, allScreenQns: ReadonlySet<string>, screens: FeatureDefinition["screens"]): void;
24
+ export declare function validateDrawerTargetAction(featureName: string, screenId: string, screenKind: string, actionLabel: "toolbarAction" | "rowAction" | "emptyState action" | "action", action: {
25
+ readonly id: string;
26
+ readonly screen: string;
27
+ readonly params?: RowFieldExtractor;
28
+ }, screens: FeatureDefinition["screens"]): void;
29
+ export declare function isSensitiveOrPasswordField(field: object | undefined): boolean;
30
+ export declare function validateDrawerPrefillKeys(where: string, targetLabel: "drawer-target" | "navigate-target", targetScreenId: string, target: ActionFormScreenDefinition | SecretMintScreenDefinition, params: RowFieldExtractor): void;
31
+ type RowActionNavigateRuntime = RowActionNavigateBase & {
32
+ readonly screen?: string;
33
+ readonly entity?: string;
34
+ };
35
+ export declare function resolveRowActionNavigateTarget(featureName: string, screenId: string, screenType: NavigateSourceScreenType, actionLabel: string, action: RowActionNavigateRuntime, allScreenQns: ReadonlySet<string>, navTargetShortIds: ReadonlySet<string>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
36
+ readonly featureName: string;
37
+ readonly screen: ScreenDefinition;
38
+ }>>, detailForScreens: ReadonlyMap<string, {
39
+ readonly featureName: string;
40
+ readonly screen: ScreenDefinition;
41
+ }>): {
42
+ readonly featureName: string;
43
+ readonly screen: ScreenDefinition;
44
+ } | undefined;
45
+ export declare function validateRedirectTarget(feature: FeatureDefinition, screenId: string, screenKind: "actionForm" | "secretMint" | "entityEdit" | "projectionDetail", redirect: string | ActionFormRedirect, lookups: NavigateTargetLookups, propertyPath?: string): void;
46
+ export declare function validateIdlessSuccessLink(feature: FeatureDefinition, screenId: string, screenKind: "actionForm", link: ActionFormSuccessLink, lookups: NavigateTargetLookups, propertyPath: string): void;
47
+ export declare function validateClientActionName(featureName: string, screenId: string, screenKind: string, actionLabel: "rowAction" | "emptyState action" | "action", action: Extract<RowAction, {
48
+ readonly kind: "clientAction";
49
+ }>): void;
50
+ export declare function rejectRedirectOnListRowAction(featureName: string, screenId: string, screenKind: string, action: RowAction): void;
51
+ export declare function validateRecordActionRedirect(feature: FeatureDefinition, screenId: string, screenKind: "projectionDetail" | "entityEdit", action: RowAction, lookups: NavigateTargetLookups): void;
52
+ export declare function validatePrimaryActionTarget(featureName: string, screenId: string, screen: ProjectionDetailScreenDefinition): void;
53
+ export declare function validateScreenVisibleWhen(feature: FeatureDefinition, screenId: string, screen: ScreenDefinition, allScreenQns: ReadonlySet<string>, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
54
+ export type NavigateTargetLookups = {
55
+ readonly allScreenQns: ReadonlySet<string>;
56
+ readonly navTargetShortIds: ReadonlySet<string>;
57
+ readonly screensByShortId: ReadonlyMap<string, ReadonlyArray<{
58
+ readonly featureName: string;
59
+ readonly screen: ScreenDefinition;
60
+ }>>;
61
+ readonly detailForScreens: ReadonlyMap<string, {
62
+ readonly featureName: string;
63
+ readonly screen: ScreenDefinition;
64
+ }>;
65
+ };
66
+ export declare function validateNavigateActionTab(featureName: string, screenId: string, screenType: NavigateSourceScreenType, actionLabel: string, action: RowAction, lookups: NavigateTargetLookups): void;
67
+ export {};