@cosmicdrift/kumiko-framework 0.382.0 → 0.383.1

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 (94) hide show
  1. package/agents/examples/api-worker-split.md +57 -0
  2. package/agents/examples/record-detail-layout.md +20 -18
  3. package/agents/examples/screens-nav.md +3 -2
  4. package/agents/examples/writeform-section.md +2 -1
  5. package/dist/api/api-constants.d.ts +1 -0
  6. package/dist/api/api-constants.js +2 -0
  7. package/dist/api/auth-middleware.d.ts +1 -0
  8. package/dist/api/auth-middleware.js +17 -0
  9. package/dist/api/auth-routes.d.ts +1 -0
  10. package/dist/api/auth-routes.js +14 -4
  11. package/dist/api/client-error-route.d.ts +12 -0
  12. package/dist/api/client-error-route.js +113 -0
  13. package/dist/api/request-context.d.ts +6 -0
  14. package/dist/api/request-context.js +9 -0
  15. package/dist/api/routes.js +7 -4
  16. package/dist/api/server.js +11 -1
  17. package/dist/db/connection.d.ts +11 -0
  18. package/dist/db/connection.js +63 -3
  19. package/dist/db/index.d.ts +2 -1
  20. package/dist/db/index.js +2 -1
  21. package/dist/db/migrate-runner.js +4 -0
  22. package/dist/db/queries/backfill-pii.js +5 -1
  23. package/dist/db/queries/event-store.js +9 -2
  24. package/dist/db/statement-timeout.d.ts +2 -0
  25. package/dist/db/statement-timeout.js +18 -0
  26. package/dist/db/tenant-db.js +5 -12
  27. package/dist/engine/boot-validator/access-declarations.js +17 -18
  28. package/dist/engine/boot-validator/query-output-columns.js +1 -2
  29. package/dist/engine/boot-validator/screens.js +12 -16
  30. package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
  31. package/dist/engine/build-config-feature-schema.js +4 -4
  32. package/dist/engine/entity-handlers.js +5 -7
  33. package/dist/engine/escape-hatch-declaration.d.ts +1 -1
  34. package/dist/engine/escape-hatch-declaration.js +5 -2
  35. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  36. package/dist/engine/escape-hatch-reason.js +35 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  38. package/dist/engine/feature-config-events-jobs.js +6 -4
  39. package/dist/engine/feature-ui-extensions.js +12 -6
  40. package/dist/engine/iban.d.ts +2 -0
  41. package/dist/engine/iban.js +19 -0
  42. package/dist/engine/index.d.ts +1 -1
  43. package/dist/engine/project-app-schema-for-roles.js +1 -1
  44. package/dist/engine/registry-validate.d.ts +1 -0
  45. package/dist/engine/registry-validate.js +8 -0
  46. package/dist/engine/registry.js +2 -1
  47. package/dist/engine/schema-builder.js +9 -0
  48. package/dist/engine/screen-helpers.d.ts +3 -3
  49. package/dist/engine/screen-helpers.js +1 -2
  50. package/dist/engine/steps/_read-source.js +2 -0
  51. package/dist/engine/types/index.d.ts +2 -2
  52. package/dist/engine/types/index.js +1 -1
  53. package/dist/engine/types/screen.d.ts +1 -1
  54. package/dist/engine/types/screen.js +1 -1
  55. package/dist/entrypoint/index.d.ts +1 -0
  56. package/dist/entrypoint/index.js +1 -0
  57. package/dist/event-store/event-store.d.ts +0 -3
  58. package/dist/event-store/event-store.js +1 -26
  59. package/dist/event-store/index.d.ts +1 -1
  60. package/dist/event-store/index.js +1 -1
  61. package/dist/event-store/snapshot.js +6 -1
  62. package/dist/files/file-ref-entity.d.ts +6 -6
  63. package/dist/files/file-ref-table.d.ts +6 -6
  64. package/dist/i18n/required-surface-keys.js +0 -1
  65. package/dist/jobs/job-runner.d.ts +1 -0
  66. package/dist/jobs/job-runner.js +74 -7
  67. package/dist/logging/index.d.ts +2 -1
  68. package/dist/logging/index.js +2 -1
  69. package/dist/logging/pino-logger.d.ts +1 -0
  70. package/dist/logging/pino-logger.js +35 -0
  71. package/dist/logging/request-log-fields.d.ts +1 -0
  72. package/dist/logging/request-log-fields.js +21 -0
  73. package/dist/logging/scrub.d.ts +2 -0
  74. package/dist/logging/scrub.js +21 -0
  75. package/dist/logging/utils.d.ts +1 -0
  76. package/dist/logging/utils.js +40 -6
  77. package/dist/migrations/pii-event-backfill.js +5 -1
  78. package/dist/observability/standard-metrics.d.ts +1 -0
  79. package/dist/observability/standard-metrics.js +9 -0
  80. package/dist/pipeline/msp-rebuild.js +5 -1
  81. package/dist/pipeline/projection-rebuild.js +5 -1
  82. package/dist/rate-limit/middleware.d.ts +8 -3
  83. package/dist/rate-limit/middleware.js +28 -10
  84. package/dist/rate-limit/resolver.js +10 -0
  85. package/dist/redis/client.d.ts +2 -0
  86. package/dist/redis/client.js +20 -0
  87. package/dist/redis/index.d.ts +1 -1
  88. package/dist/redis/index.js +1 -1
  89. package/dist/search/purge-subject.js +6 -8
  90. package/dist/search/reindex-entity.js +11 -10
  91. package/dist/ui-types/index.d.ts +1 -1
  92. package/dist/ui-types/index.js +1 -1
  93. package/package.json +3 -3
  94. package/src/changes.json +70 -0
@@ -4,6 +4,13 @@ import { readPositiveIntEnv } from "../utils/env-parse.js";
4
4
  // so a plain end() (no timeout = wait for queries to finish) hangs forever —
5
5
  // close() must always bound how long shutdown waits.
6
6
  export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
7
+ // Keyed by the pool so withoutStatementTimeout can open a sibling with identical connect options.
8
+ const unboundedSiblingFactories = new WeakMap();
9
+ export function openUnboundedSiblingPool(db) {
10
+ if (typeof db !== "function" && (typeof db !== "object" || db === null))
11
+ return undefined;
12
+ return unboundedSiblingFactories.get(db)?.();
13
+ }
7
14
  export function createDbConnection({ url, ...options }) {
8
15
  const pgOptions = {};
9
16
  if (options.maxConnections !== undefined)
@@ -18,13 +25,51 @@ export function createDbConnection({ url, ...options }) {
18
25
  pgOptions.ssl = options.ssl;
19
26
  if (options.onNotice !== undefined)
20
27
  pgOptions.onnotice = options.onNotice;
28
+ const serverParameters = {};
29
+ if (options.statementTimeoutMs !== undefined) {
30
+ serverParameters["statement_timeout"] = options.statementTimeoutMs;
31
+ }
32
+ if (options.idleInTransactionSessionTimeoutMs !== undefined) {
33
+ serverParameters["idle_in_transaction_session_timeout"] =
34
+ options.idleInTransactionSessionTimeoutMs;
35
+ }
36
+ if (options.lockTimeoutMs !== undefined)
37
+ serverParameters["lock_timeout"] = options.lockTimeoutMs;
38
+ if (Object.keys(serverParameters).length > 0)
39
+ pgOptions.connection = serverParameters;
21
40
  const client = postgres(url, pgOptions);
41
+ // A run that never settles (a caller gave up on it via its own timeout) never reaches the
42
+ // sibling's own close; close() ends it so shutdown and DROP DATABASE are not blocked by it.
43
+ const openSiblings = new Set();
44
+ unboundedSiblingFactories.set(client, () => {
45
+ // Two connections are enough for lock-in-tx plus DDL; the cap keeps parallel rebuilds from exhausting max_connections.
46
+ const sibling = postgres(url, {
47
+ ...pgOptions,
48
+ max: 4,
49
+ connection: { ...pgOptions.connection, statement_timeout: 0 },
50
+ });
51
+ openSiblings.add(sibling);
52
+ return {
53
+ client: sibling,
54
+ close: async (timeoutSeconds) => {
55
+ openSiblings.delete(sibling);
56
+ await sibling.end({ timeout: timeoutSeconds });
57
+ },
58
+ };
59
+ });
22
60
  return {
23
61
  db: client,
24
62
  client,
25
- close: () => client.end({
26
- timeout: options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS,
27
- }),
63
+ close: async () => {
64
+ const timeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
65
+ unboundedSiblingFactories.delete(client);
66
+ const pools = [client, ...openSiblings];
67
+ openSiblings.clear();
68
+ const results = await Promise.allSettled(pools.map((pool) => pool.end({ timeout })));
69
+ const failure = results.find((result) => result.status === "rejected");
70
+ if (failure !== undefined)
71
+ throw failure.reason;
72
+ },
28
73
  };
29
74
  }
30
75
  // DB_PROVIDER=bun selected the removed Bun.SQL driver; ignoring it would boot
@@ -49,6 +94,21 @@ export function dbConnectionOptionsFromEnv(env = process.env) {
49
94
  opts.connectTimeoutSeconds = connect;
50
95
  return opts;
51
96
  }
97
+ export const DEFAULT_SERVER_STATEMENT_TIMEOUT_MS = 60_000;
98
+ // Handlers that await external calls inside a tx must not be killed; five minutes still ends leaked transactions.
99
+ export const DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS = 300_000;
100
+ // Long-running servers (prod app, worker) bound runaway statements and tx held
101
+ // open by a stuck handler; one-shot CLIs keep the unbounded driver default.
102
+ export function serverTimeoutOptionsFromEnv(env = process.env) {
103
+ const lockTimeoutMs = readPositiveIntEnv(env, "DATABASE_LOCK_TIMEOUT_MS");
104
+ return {
105
+ statementTimeoutMs: readPositiveIntEnv(env, "DATABASE_STATEMENT_TIMEOUT_MS") ??
106
+ DEFAULT_SERVER_STATEMENT_TIMEOUT_MS,
107
+ idleInTransactionSessionTimeoutMs: readPositiveIntEnv(env, "DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS") ??
108
+ DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS,
109
+ ...(lockTimeoutMs !== undefined && { lockTimeoutMs }),
110
+ };
111
+ }
52
112
  // Outside-tx access runs on its own pool: sharing the write pool deadlocks once
53
113
  // pool-max concurrent writes each hold a tx and then wait for a second connection.
54
114
  export const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
@@ -5,7 +5,7 @@ export { collectTableMetas } from "./collect-table-metas.ts";
5
5
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.ts";
6
6
  export { seedConfigValues } from "./config-seed.ts";
7
7
  export type { CreateDbConnectionOptions, DbConnection, DbConnectionHandle, DbConnectionOptions, DbRow, DbRunner, DbTx, } from "./connection.ts";
8
- export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.ts";
8
+ export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "./connection.ts";
9
9
  export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor.ts";
10
10
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.ts";
11
11
  export type { SchemaTable, SelectQuery, TableColumns } from "./dialect.ts";
@@ -36,6 +36,7 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.ts";
36
36
  export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, type ReplayMismatch, replayMigrationsDir, } from "./replay-migration-sql.ts";
37
37
  export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.ts";
38
38
  export { tableExists } from "./schema-inspection.ts";
39
+ export { withoutStatementTimeout } from "./statement-timeout.ts";
39
40
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityRow, type EntityTable, type ExecutorManagedRunner, type ExecutorManagedWriteCapability, physicalColumnName, toSnakeCase, toTableName, type Writable, type WritableSchemaTable, type WritableTable, } from "./table-builder.ts";
40
41
  export type { CreateTenantDbOptions, ExecutorManagedTenantDb, OutsideTransactionContext, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.ts";
41
42
  export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.ts";
package/dist/db/index.js CHANGED
@@ -4,7 +4,7 @@ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recor
4
4
  export { collectTableMetas } from "./collect-table-metas.js";
5
5
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
6
6
  export { seedConfigValues } from "./config-seed.js";
7
- export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
7
+ export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "./connection.js";
8
8
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
9
9
  export { bigint, bigserial, boolean, extractTableName, index, instant, instantToDriver, integer, jsonb, moneyAmount, numeric, primaryKey, serial, sql, table, text, timestamp, uniqueIndex, uuid, } from "./dialect.js";
10
10
  export { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "./eagerload.js";
@@ -29,5 +29,6 @@ export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
29
29
  export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migration-sql.js";
30
30
  export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
31
31
  export { tableExists } from "./schema-inspection.js";
32
+ export { withoutStatementTimeout } from "./statement-timeout.js";
32
33
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
33
34
  export { castTenantRows, createSystemDbView, createTenantDataDestroyDb, createTenantDb, hasOutsideTransactionDb, requireOutsideTransactionDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
@@ -239,6 +239,8 @@ export async function runMigrations(db, migrations) {
239
239
  // which would leave the lock held on a session nobody unlocks.
240
240
  const conn = await reserveConnection(db);
241
241
  try {
242
+ // Server pools cap statement_timeout; a migration (backfill, index build) must not hit it.
243
+ await conn.unsafe("SET statement_timeout = 0");
242
244
  await conn.unsafe(MIGRATIONS_TABLE_DDL);
243
245
  await conn.unsafe(`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`);
244
246
  try {
@@ -282,6 +284,8 @@ export async function runMigrations(db, migrations) {
282
284
  }
283
285
  }
284
286
  finally {
287
+ // A dead connection fails RESET; the pool drops it, so the migration's own outcome wins.
288
+ await conn.unsafe("RESET statement_timeout").catch(() => undefined);
285
289
  await conn.release();
286
290
  }
287
291
  }
@@ -67,6 +67,7 @@ import { collectPiiEncryptedFields, nonEmptyString, resolveEventSubject, resolve
67
67
  import { resolveTableName } from "../entity-table-meta.js";
68
68
  import { asRawClient } from "../query.js";
69
69
  import { tableExists } from "../schema-inspection.js";
70
+ import { withoutStatementTimeout } from "../statement-timeout.js";
70
71
  import { toSnakeCase } from "../table-builder.js";
71
72
  import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
72
73
  const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
@@ -90,7 +91,10 @@ function requireLocalKeySubjectKms(caller) {
90
91
  }
91
92
  return kms;
92
93
  }
93
- export async function backfillEventPiiEncryption(db, registry, options = {}) {
94
+ export function backfillEventPiiEncryption(db, registry, options = {}) {
95
+ return withoutStatementTimeout(db, (unboundedDb) => backfillAllEventBatches(unboundedDb, registry, options));
96
+ }
97
+ async function backfillAllEventBatches(db, registry, options) {
94
98
  requireLocalKeySubjectKms("backfillEventPiiEncryption");
95
99
  const total = {
96
100
  scannedEvents: 0,
@@ -1,5 +1,6 @@
1
1
  import { constraintOf, isLockNotAvailable, isTableAlreadyExists, isUniqueViolation, } from "../pg-error.js";
2
2
  import { asRawClient, runInNewTransaction, runnerCanBeginTransaction, unsafeReadRetrying, } from "../query.js";
3
+ import { withoutStatementTimeout } from "../statement-timeout.js";
3
4
  import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
4
5
  // Gap-finality (event-dispatcher pending_gaps) needs every holder of an
5
6
  // event id to already have a *real* xact id by the time it inserts — Postgres
@@ -48,7 +49,10 @@ async function indexValidity(client, indexName = IDEMPOTENCY_INDEX_NAME) {
48
49
  `WHERE c.relname = $1 AND i.indrelid = '"kumiko_events"'::regclass`, [indexName]);
49
50
  return rows[0]?.indisvalid;
50
51
  }
51
- export async function ensureIdempotencyKeyIndex(db) {
52
+ export function ensureIdempotencyKeyIndex(db) {
53
+ return withoutStatementTimeout(db, buildIdempotencyKeyIndex);
54
+ }
55
+ async function buildIdempotencyKeyIndex(db) {
52
56
  const client = asRawClient(db);
53
57
  try {
54
58
  // 1) A prior CONCURRENTLY build that got killed mid-flight (crash, deploy
@@ -89,7 +93,10 @@ export async function ensureIdempotencyKeyIndex(db) {
89
93
  // tenant_id column — cross-tenant SystemAdmin adds land under the actor's
90
94
  // tenant), which no other index covers: without this it scans every event.
91
95
  // Same CONCURRENTLY + INVALID-rebuild handling as the idempotency index.
92
- export async function ensureMembershipPayloadTenantIndex(db) {
96
+ export function ensureMembershipPayloadTenantIndex(db) {
97
+ return withoutStatementTimeout(db, buildMembershipPayloadTenantIndex);
98
+ }
99
+ async function buildMembershipPayloadTenantIndex(db) {
93
100
  const client = asRawClient(db);
94
101
  try {
95
102
  if ((await indexValidity(client, MEMBERSHIP_PAYLOAD_TENANT_INDEX_NAME)) === false) {
@@ -0,0 +1,2 @@
1
+ import { type PgClient } from "./connection.ts";
2
+ export declare function withoutStatementTimeout<R, T>(db: R, run: (db: R | PgClient) => Promise<T>): Promise<T>;
@@ -0,0 +1,18 @@
1
+ import { openUnboundedSiblingPool } from "./connection.js";
2
+ const UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS = 5;
3
+ // Server pools cap statement_timeout (see serverTimeoutOptionsFromEnv); index builds, reindex,
4
+ // PII backfill and projection rebuilds legitimately run longer. The cap is a connect-time session
5
+ // setting, so the run gets its own short-lived pool with the same options minus the cap: one shared
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.
8
+ export async function withoutStatementTimeout(db, run) {
9
+ const unbounded = openUnboundedSiblingPool(db);
10
+ if (unbounded === undefined)
11
+ return run(db);
12
+ try {
13
+ return await run(unbounded.client);
14
+ }
15
+ finally {
16
+ await unbounded.close(UNBOUNDED_POOL_CLOSE_TIMEOUT_SECONDS);
17
+ }
18
+ }
@@ -1,6 +1,7 @@
1
1
  import { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
2
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { asEntityTableMeta, asRawClient, aggregateWhere as bunAggregateWhere, countWhere as bunCountWhere, deleteMany as bunDeleteMany, fetchOne as bunFetchOne, insertOne as bunInsertOne, selectMany as bunSelectMany, updateMany as bunUpdateMany, runInNewTransaction, } from "../db/query.js";
4
+ import { assertReason } from "../engine/escape-hatch-reason.js";
4
5
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
6
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
6
7
  import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
@@ -152,9 +153,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
152
153
  }
153
154
  return sourceRunner(forwardedDeclaredStep ? reason : undefined);
154
155
  }
155
- if (reason.trim().length === 0) {
156
- throw new Error("unsafeRaw requires a non-empty reason");
157
- }
156
+ assertReason("ctx.systemDb.unsafeRaw(reason)", reason);
158
157
  if (gate && !hasGrant(gate.grant)) {
159
158
  throw new AccessDeniedError({
160
159
  message: 'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
@@ -189,9 +188,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
189
188
  return rows;
190
189
  },
191
190
  acknowledgeCrossTenant(reason) {
192
- if (reason.trim().length === 0) {
193
- throw new Error("acknowledgeCrossTenant requires a non-empty reason");
194
- }
191
+ assertReason("acknowledgeCrossTenant(reason)", reason);
195
192
  report("acknowledge-cross-tenant", reason);
196
193
  return db;
197
194
  },
@@ -206,9 +203,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
206
203
  return requireSystemOutsideTransactionDb();
207
204
  },
208
205
  acknowledgeCrossTenant(reason) {
209
- if (reason.trim().length === 0) {
210
- throw new Error("acknowledgeCrossTenant requires a non-empty reason");
211
- }
206
+ assertReason("acknowledgeCrossTenant(reason)", reason);
212
207
  const result = requireSystemOutsideTransactionDb();
213
208
  report("acknowledge-cross-tenant", reason);
214
209
  return result;
@@ -306,9 +301,7 @@ const crossTenantUseReporters = new WeakMap();
306
301
  // `deferReport` leaves the audit entry to reportConventionCrossTenantUse, so a caller that learns
307
302
  // which foreign row it touches can attach that row as the entry's target.
308
303
  export function acknowledgeConventionCrossTenant(tenantDb, reason, options) {
309
- if (reason.trim().length === 0) {
310
- throw new Error("acknowledgeConventionCrossTenant requires a non-empty reason");
311
- }
304
+ assertReason("acknowledgeConventionCrossTenant", reason);
312
305
  const rebind = crossTenantRebinders.get(tenantDb);
313
306
  if (!rebind) {
314
307
  throw new InternalError({
@@ -1,5 +1,5 @@
1
1
  import { escapeHatchGrantsProblem, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
- import { codemodPlaceholderReasonProblem } from "../escape-hatch-reason.js";
2
+ import { reasonProblem } from "../escape-hatch-reason.js";
3
3
  import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
4
4
  import { ANONYMOUS_ROLE } from "../system-user.js";
5
5
  import { collectZodObjectKeys } from "./zod-shape.js";
@@ -24,16 +24,16 @@ function candidatePersonalFieldNames(feature, handlerName, handler, honorOwnerBi
24
24
  }
25
25
  return names;
26
26
  }
27
- // Not an object with a non-empty string `reason` (incl. deprecated `openToAll: true`) counts as "empty".
28
- function openToAllReasonIsEmpty(access) {
27
+ // Not an object with a string `reason` (incl. deprecated `openToAll: true`) counts as malformed.
28
+ function openToAllReasonOf(access) {
29
29
  if (!("openToAll" in access))
30
- return false;
30
+ return undefined;
31
31
  const openToAll = access.openToAll;
32
32
  if (typeof openToAll !== "object" || openToAll === null)
33
- return true;
33
+ return "";
34
34
  if (!("reason" in openToAll) || typeof openToAll.reason !== "string")
35
- return true;
36
- return openToAll.reason.trim().length === 0;
35
+ return "";
36
+ return openToAll.reason;
37
37
  }
38
38
  function hasOpenToAll(access) {
39
39
  return "openToAll" in access;
@@ -42,11 +42,16 @@ function declaresTenantMembersPersonalData(access) {
42
42
  return declaredPersonalData(access) === "tenant-members";
43
43
  }
44
44
  function validateOpenToAllReason(feature, kind, handlerName, access) {
45
+ const reason = openToAllReasonOf(access);
46
+ // skip: no openToAll declared
47
+ if (reason === undefined)
48
+ return;
49
+ const problem = reasonProblem(reason);
45
50
  // skip: reason is well-formed, nothing to validate
46
- if (!openToAllReasonIsEmpty(access))
51
+ if (problem === undefined)
47
52
  return;
48
53
  throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
49
- '{ openToAll: ... } — must be { reason: "<non-empty explanation>" } for why any ' +
54
+ `{ openToAll: ... } — ${problem}. Must be { reason: "<explanation>" } for why any ` +
50
55
  "authenticated user may call this handler. `{ openToAll: true }` is no longer accepted.");
51
56
  }
52
57
  function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
@@ -55,15 +60,9 @@ function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
55
60
  if (grantsProblem !== undefined) {
56
61
  throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${grantsProblem}`);
57
62
  }
58
- if (escapeHatch.reason.trim().length === 0) {
59
- throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
60
- `{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
61
- "explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
62
- "or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
63
- }
64
- const placeholderProblem = codemodPlaceholderReasonProblem(escapeHatch.reason);
65
- if (placeholderProblem !== undefined) {
66
- throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${placeholderProblem}`);
63
+ const problem = reasonProblem(escapeHatch.reason);
64
+ if (problem !== undefined) {
65
+ throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${problem}`);
67
66
  }
68
67
  }
69
68
  }
@@ -1,6 +1,5 @@
1
1
  import { isPagedQueryHandler } from "../define-handler.js";
2
2
  import { normalizeListColumn, sectionFieldSpecs } from "../screen-helpers.js";
3
- import { metricField } from "../types/index.js";
4
3
  import { buildQueryHandlerMap } from "./projection-list-screens.js";
5
4
  import { validateActionFieldRefs } from "./screens.js";
6
5
  import { getZodObjectShape, getZodRowShape } from "./zod-shape.js";
@@ -106,7 +105,7 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
106
105
  checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
107
106
  }
108
107
  for (const metric of screen.metrics ?? []) {
109
- const field = metricField(metric);
108
+ const field = metric.field;
110
109
  checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema. Check the field name for a typo or add the field to the outputSchema.`);
111
110
  }
112
111
  checkDetailSectionFields(recordShape, prefix, screen);
@@ -16,7 +16,6 @@ import { parseRefTarget } from "../parse-ref-target.js";
16
16
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
17
17
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
18
18
  import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
19
- import { metricField } from "../types/index.js";
20
19
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
21
20
  import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
22
21
  import { isSelectOptionTone } from "./select-option-tone.js";
@@ -719,11 +718,11 @@ function validateFormMoneyCurrency(featureName, screenId, context, fieldName, fd
719
718
  // `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
720
719
  // every EditLayout-walking screen type instead of four hand-rolled copies.
721
720
  function validateFieldsXorGroups(errorPrefix, section) {
722
- if (section.fields.length > 0 && section.groups !== undefined) {
723
- throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
724
- `when using groups.`);
721
+ if (section.fields !== undefined && section.groups !== undefined) {
722
+ throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — remove ` +
723
+ `\`fields: []\` when using groups.`);
725
724
  }
726
- if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
725
+ if ((section.fields?.length ?? 0) === 0 && (section.groups?.length ?? 0) === 0) {
727
726
  throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
728
727
  `fields (or groups) to it.`);
729
728
  }
@@ -1363,8 +1362,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1363
1362
  if (screen.layout.mode === "tabs") {
1364
1363
  validateTabSections(feature.name, screenId, "projectionDetail", screen.layout.sections);
1365
1364
  for (const section of screen.layout.sections) {
1366
- const isFieldsSection = section.kind === undefined || section.kind === "fields";
1367
- if (isFieldsSection && section.groups !== undefined && section.actions !== undefined) {
1365
+ if (isFieldsEditSection(section) &&
1366
+ "groups" in section &&
1367
+ section.actions !== undefined) {
1368
1368
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.id}" ` +
1369
1369
  `declares both groups and actions in tabs mode — actions has no single group's Card to sit ` +
1370
1370
  `in, and an extra title-less Card around the group cards would nest a Card inside a Card. ` +
@@ -1374,14 +1374,13 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1374
1374
  }
1375
1375
  if (screen.metrics !== undefined) {
1376
1376
  for (const metric of screen.metrics) {
1377
- const field = metricField(metric);
1378
- const hasOwnLabel = typeof metric !== "string" && metric.label !== undefined;
1379
- if (!hasOwnLabel && screen.fieldLabels?.[field] === undefined) {
1377
+ const field = metric.field;
1378
+ if (typeof metric.label !== "string" || metric.label.length === 0) {
1380
1379
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" has ` +
1381
- `no entry in fieldLabels and no own "label" — every metrics field needs one of the two, ` +
1380
+ `no "label" — every metric needs an i18n label key, ` +
1382
1381
  `there is no fallback to the raw column name.`);
1383
1382
  }
1384
- const navigate = typeof metric === "string" ? undefined : metric.navigate;
1383
+ const navigate = metric.navigate;
1385
1384
  if (navigate !== undefined) {
1386
1385
  if (navigate.screen === undefined && navigate.entity === undefined) {
1387
1386
  if (navigate.tab !== undefined &&
@@ -1525,14 +1524,11 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1525
1524
  `(writeForm) has zero fields — drop the section or add fields to it.`);
1526
1525
  }
1527
1526
  // A ref to a fieldDef the form never renders would never resolve.
1528
- const writeFormFieldNames = new Set(
1529
- // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1530
- section.fields.map((f) => normalizeEditField(f).field));
1527
+ const writeFormFieldNames = new Set(section.fields.map((f) => normalizeEditField(f).field));
1531
1528
  for (const [defName, fdef] of Object.entries(section.fieldDefs)) {
1532
1529
  validateFormSelectOptions(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef, writeFormFieldNames);
1533
1530
  rejectWriteOnlyFormField(feature.name, screenId, `projectionDetail section "${section.title}" writeForm`, defName, fdef);
1534
1531
  }
1535
- // kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
1536
1532
  for (const f of section.fields) {
1537
1533
  const fieldName = normalizeEditField(f).field;
1538
1534
  if (section.fieldDefs[fieldName] === undefined) {
@@ -29,9 +29,7 @@ function projectionDetailNavigateActions(screen) {
29
29
  pick: [relatedListParentParamField(section)],
30
30
  }),
31
31
  ]),
32
- ...(screen.metrics ?? []).flatMap((metric) => typeof metric === "string" || metric.navigate === undefined
33
- ? []
34
- : scopeActions([metric.navigate], undefined)),
32
+ ...(screen.metrics ?? []).flatMap((metric) => metric.navigate === undefined ? [] : scopeActions([metric.navigate], undefined)),
35
33
  ...subtitlePartNavigates(screen).flatMap((navigate) => scopeActions([navigate], undefined)),
36
34
  ];
37
35
  }
@@ -20,7 +20,7 @@ import { isEncryptedAtRest } from "./config-helpers.js";
20
20
  import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
21
21
  import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
22
22
  import { isKebabSegment, toKebab } from "./qualified-name.js";
23
- import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
23
+ import { isFieldsEditSection, sectionFieldSpecs, } from "./screen-helpers.js";
24
24
  import { isOpenToAllGranted } from "./types/handlers.js";
25
25
  // Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
26
26
  // (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
@@ -349,9 +349,10 @@ class ProviderFormBuilder {
349
349
  const section = pluginScreen.layout.sections.find(isFieldsEditSection);
350
350
  if (section === undefined)
351
351
  return undefined;
352
- const { groups: _groups, ...flatSection } = section;
352
+ const widened = section;
353
+ const { groups: _groups, fields: _fields, ...sectionBase } = widened;
353
354
  const gated = {
354
- ...flatSection,
355
+ ...sectionBase,
355
356
  fields: gateFields(sectionFieldSpecs(section), visible),
356
357
  };
357
358
  this.sections.push(gated);
@@ -374,7 +375,6 @@ class ProviderFormBuilder {
374
375
  this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
375
376
  const secretsTitleKey = `${feature}.settings.secrets`;
376
377
  for (const section of secretsScreen.sections) {
377
- // kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
378
378
  const fields = gateFields(section.fields, visible);
379
379
  const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
380
380
  if (ownSection) {
@@ -8,7 +8,7 @@ import { InternalError } from "../errors/index.js";
8
8
  import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
11
- import { codemodPlaceholderReasonProblem } from "./escape-hatch-reason.js";
11
+ import { assertReason } from "./escape-hatch-reason.js";
12
12
  import { dropEmptyWriteOnlyValues } from "./field-access.js";
13
13
  import { instructionFieldNames } from "./instruction-fields.js";
14
14
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
@@ -60,12 +60,7 @@ function isSystemAdminOnly(access) {
60
60
  function resolveCrossTenantReason(name, options, kind) {
61
61
  if (options.escapeHatch === undefined)
62
62
  return undefined;
63
- if (options.escapeHatch.reason.trim().length === 0) {
64
- throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
65
- }
66
- const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
67
- if (placeholderProblem !== undefined)
68
- throw new Error(`"${name}": ${placeholderProblem}`);
63
+ assertReason(`"${name}": escapeHatch`, options.escapeHatch.reason);
69
64
  assertEscapeHatchAccessIsSystemAdminOnly(name, kind, options.access);
70
65
  return options.escapeHatch.reason;
71
66
  }
@@ -528,6 +523,9 @@ export function createEntityExecutor(entityName, entity) {
528
523
  // For anything more involved (filters, joins, custom shaping), write the
529
524
  // query-handler explicitly with ctx.queryProjection or a raw select.
530
525
  export function defineProjectionQueryHandler(name, projectionQualifiedName, options) {
526
+ if (typeof options.unsafeAllTenants === "object") {
527
+ assertReason(`"${name}": unsafeAllTenants`, options.unsafeAllTenants.reason);
528
+ }
531
529
  const escapeHatch = options.escapeHatch ??
532
530
  (typeof options.unsafeAllTenants === "object"
533
531
  ? { reason: options.unsafeAllTenants.reason }
@@ -1,2 +1,2 @@
1
1
  import type { EscapeHatchDeclaration } from "@cosmicdrift/kumiko-types/handlers";
2
- export declare function declareEscapeHatch(_declaration: EscapeHatchDeclaration): void;
2
+ export declare function declareEscapeHatch(declaration: EscapeHatchDeclaration): void;
@@ -1,5 +1,8 @@
1
+ import { assertReason } from "./escape-hatch-reason.js";
1
2
  // Marks a standalone helper that escalates on a HandlerContext handed to it
2
3
  // by its caller. The caller's registration carries the escapeHatch that
3
4
  // authorizes it; this call makes the escalation visible at the site where it
4
- // happens, where the guard and a reviewer both see it.
5
- export function declareEscapeHatch(_declaration) { }
5
+ // happens and rejects an empty or placeholder reason.
6
+ export function declareEscapeHatch(declaration) {
7
+ assertReason("declareEscapeHatch", declaration.reason);
8
+ }
@@ -1,2 +1,4 @@
1
1
  export declare const CODEMOD_PLACEHOLDER_REASON_MARKER = "state the operator use case here";
2
2
  export declare function codemodPlaceholderReasonProblem(reason: string): string | undefined;
3
+ export declare function reasonProblem(reason: string): string | undefined;
4
+ export declare function assertReason(callerLabel: string, reason: string): void;
@@ -6,3 +6,38 @@ export function codemodPlaceholderReasonProblem(reason) {
6
6
  return undefined;
7
7
  return `the reason still contains the migrate-cross-tenant codemod placeholder ("${CODEMOD_PLACEHOLDER_REASON_MARKER}") — replace the codemod placeholder with the real operator use case`;
8
8
  }
9
+ // Compared after trim, lowercase and stripping trailing punctuation.
10
+ const PLACEHOLDER_REASONS = new Set([
11
+ "todo",
12
+ "tbd",
13
+ "fixme",
14
+ "legacy",
15
+ "temp",
16
+ "temporary",
17
+ "hack",
18
+ "wip",
19
+ "n/a",
20
+ "na",
21
+ "none",
22
+ "-",
23
+ "?",
24
+ "reason",
25
+ "xxx",
26
+ ]);
27
+ const PLACEHOLDER_REASON_PREFIXES = ["todo:", "fixme:", "tbd:"];
28
+ export function reasonProblem(reason) {
29
+ const lowered = reason.trim().toLowerCase();
30
+ if (lowered.length === 0)
31
+ return "the reason must be a non-empty explanation";
32
+ const stripped = lowered.replace(/[.!:;,]+$/, "").trim();
33
+ if (PLACEHOLDER_REASON_PREFIXES.some((prefix) => lowered.startsWith(prefix)) ||
34
+ PLACEHOLDER_REASONS.has(stripped)) {
35
+ return `the reason "${reason.trim()}" is a placeholder — state why this access is needed`;
36
+ }
37
+ return codemodPlaceholderReasonProblem(reason);
38
+ }
39
+ export function assertReason(callerLabel, reason) {
40
+ const problem = reasonProblem(reason);
41
+ if (problem !== undefined)
42
+ throw new Error(`${callerLabel}: ${problem}`);
43
+ }
@@ -4,6 +4,7 @@
4
4
  // empty reason, but options stays `Record<string, unknown>`, so orchestrators
5
5
  // re-narrow at read time instead of trusting the shape.
6
6
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
7
+ import { reasonProblem } from "../escape-hatch-reason.js";
7
8
  export function extensionUsageEscapeHatchReason(usage) {
8
9
  const escapeHatch = usage.options?.["escapeHatch"];
9
10
  if (!isPlainObject(escapeHatch))
@@ -11,5 +12,5 @@ export function extensionUsageEscapeHatchReason(usage) {
11
12
  const { reason, grants } = escapeHatch;
12
13
  if (Array.isArray(grants) && !grants.includes("unsafeRaw"))
13
14
  return undefined;
14
- return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined;
15
+ return typeof reason === "string" && reasonProblem(reason) === undefined ? reason : undefined;
15
16
  }
@@ -1,6 +1,7 @@
1
1
  import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
2
2
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { isValidIanaTimeZone } from "../time/iana.js";
4
+ import { reasonProblem } from "./escape-hatch-reason.js";
4
5
  import { createEventDef } from "./event-def.js";
5
6
  import { resolveName } from "./handler-helpers.js";
6
7
  import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
@@ -79,10 +80,11 @@ export function buildConfigEventsJobsMethods(state, name) {
79
80
  const { name: definedJobName, handler: h, ...rest } = jobNameOrDefinition;
80
81
  return [definedJobName, rest, h];
81
82
  })();
82
- if (jobOptions.escapeHatch !== undefined &&
83
- jobOptions.escapeHatch.reason.trim().length === 0) {
84
- throw new Error(`[Feature ${name}] r.job("${jobName}") declares { escapeHatch: { reason: "" } } — ` +
85
- "the reason must be a non-empty string explaining why this job needs unfiltered database access.");
83
+ const jobReasonProblem = jobOptions.escapeHatch !== undefined
84
+ ? reasonProblem(jobOptions.escapeHatch.reason)
85
+ : undefined;
86
+ if (jobReasonProblem !== undefined) {
87
+ throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobReasonProblem}`);
86
88
  }
87
89
  const jobGrantsProblem = jobOptions.escapeHatch && escapeHatchGrantsProblem(jobOptions.escapeHatch);
88
90
  if (jobGrantsProblem) {