@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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 (82) hide show
  1. package/agents/examples/custom-fields-basic.md +13 -3
  2. package/agents/examples/designer-demo.md +6 -2
  3. package/agents/examples/i18n.md +36 -1
  4. package/agents/features.md +4 -0
  5. package/dist/api/auth-rate-limiters.d.ts +8 -0
  6. package/dist/api/auth-rate-limiters.js +105 -0
  7. package/dist/api/auth-routes-context.d.ts +34 -0
  8. package/dist/api/auth-routes-context.js +192 -0
  9. package/dist/api/auth-routes-invite.d.ts +2 -0
  10. package/dist/api/auth-routes-invite.js +171 -0
  11. package/dist/api/auth-routes-login.d.ts +2 -0
  12. package/dist/api/auth-routes-login.js +103 -0
  13. package/dist/api/auth-routes-mfa.d.ts +4 -0
  14. package/dist/api/auth-routes-mfa.js +211 -0
  15. package/dist/api/auth-routes-session.d.ts +2 -0
  16. package/dist/api/auth-routes-session.js +144 -0
  17. package/dist/api/auth-routes-shared.d.ts +33 -0
  18. package/dist/api/auth-routes-shared.js +81 -0
  19. package/dist/api/auth-routes-signup.d.ts +2 -0
  20. package/dist/api/auth-routes-signup.js +75 -0
  21. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  22. package/dist/api/auth-routes-token-flows.js +91 -0
  23. package/dist/api/auth-routes-types.d.ts +76 -0
  24. package/dist/api/auth-routes-types.js +1 -0
  25. package/dist/api/auth-routes.d.ts +5 -85
  26. package/dist/api/auth-routes.js +20 -1095
  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/dialect.js +1 -1
  31. package/dist/db/distinct-on.d.ts +9 -0
  32. package/dist/db/distinct-on.js +28 -0
  33. package/dist/db/event-store-executor-write.js +2 -2
  34. package/dist/db/migrate-runner.js +4 -4
  35. package/dist/db/pg-error.js +0 -12
  36. package/dist/db/queries/event-consumer.js +3 -3
  37. package/dist/db/queries/postgres-array-literal.js +2 -2
  38. package/dist/db/query.js +48 -20
  39. package/dist/db/tenant-db.js +1 -1
  40. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
  41. package/dist/engine/boot-validator/i18n-keys.js +15 -1
  42. package/dist/engine/boot-validator/index.d.ts +4 -0
  43. package/dist/engine/boot-validator/index.js +3 -1
  44. package/dist/engine/boot-validator/pii-retention.js +6 -7
  45. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  46. package/dist/engine/boot-validator/screens-actions.js +488 -0
  47. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  48. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  49. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  50. package/dist/engine/boot-validator/screens-forms.js +516 -0
  51. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  52. package/dist/engine/boot-validator/screens-lists.js +319 -0
  53. package/dist/engine/boot-validator/screens.d.ts +4 -21
  54. package/dist/engine/boot-validator/screens.js +26 -1799
  55. package/dist/engine/build-app-schema.js +1 -1
  56. package/dist/engine/factories.d.ts +3 -3
  57. package/dist/engine/steps/read-find-one.js +1 -1
  58. package/dist/engine/types/index.d.ts +1 -1
  59. package/dist/event-store/provenance-append.js +1 -1
  60. package/dist/files/file-ref-entity.d.ts +6 -126
  61. package/dist/files/file-ref-table.d.ts +6 -126
  62. package/dist/files/file-routes.js +39 -16
  63. package/dist/i18n/required-surface-keys.d.ts +22 -2
  64. package/dist/i18n/required-surface-keys.js +91 -3
  65. package/dist/jobs/job-runner-helpers.d.ts +45 -0
  66. package/dist/jobs/job-runner-helpers.js +281 -0
  67. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  68. package/dist/jobs/job-runner-schedulers.js +106 -0
  69. package/dist/jobs/job-runner-types.d.ts +65 -0
  70. package/dist/jobs/job-runner-types.js +2 -0
  71. package/dist/jobs/job-runner.d.ts +4 -75
  72. package/dist/jobs/job-runner.js +7 -384
  73. package/dist/pipeline/dispatch-shared.js +2 -2
  74. package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
  75. package/dist/pipeline/event-dispatcher-admin.js +28 -7
  76. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  77. package/dist/pipeline/event-dispatcher.js +3 -4
  78. package/dist/pipeline/index.d.ts +2 -2
  79. package/dist/pipeline/index.js +1 -1
  80. package/dist/schema-cli.js +5 -0
  81. package/package.json +3 -3
  82. package/src/changes.json +39 -0
@@ -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()) {
@@ -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(",")}}`;
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";
@@ -147,7 +148,17 @@ function assertNotTenantScoped(db, fnName) {
147
148
  "explicit cross-tenant op, or use a scoped TenantDb method.");
148
149
  }
149
150
  }
150
- const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like", "jsonText"];
151
+ const WHERE_OPERATOR_KEYS = [
152
+ "gt",
153
+ "gte",
154
+ "lt",
155
+ "lte",
156
+ "ne",
157
+ "in",
158
+ "notIn",
159
+ "like",
160
+ "jsonText",
161
+ ];
151
162
  const _whereOperatorKeysExhaustive = true;
152
163
  void _whereOperatorKeysExhaustive;
153
164
  function isJsonTextMatchList(value) {
@@ -664,6 +675,24 @@ function buildWhereClause(info, where, startIndex, qualifier) {
664
675
  conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
665
676
  }
666
677
  }
678
+ const notInVal = value.notIn;
679
+ if (Array.isArray(notInVal)) {
680
+ if (pgType === "jsonb") {
681
+ throw new Error(`where: notIn is not supported on jsonb column "${col}"`);
682
+ }
683
+ const parts = [];
684
+ for (const v of notInVal) {
685
+ const p = prepareValue(v, pgType);
686
+ if (p.kind === "literal") {
687
+ parts.push(p.literal);
688
+ }
689
+ else {
690
+ parts.push(`$${idx++}${p.sql}`);
691
+ values.push(p.bound);
692
+ }
693
+ }
694
+ conditions.push(parts.length === 0 ? "TRUE" : `${ref(col)} NOT IN (${parts.join(", ")})`);
695
+ }
667
696
  }
668
697
  else if (pgType === "jsonb" && isJsonbScalar(value)) {
669
698
  conditions.push(jsonbContainsAny(col, [value]));
@@ -702,7 +731,6 @@ function buildWhereClause(info, where, startIndex, qualifier) {
702
731
  // Each failed attempt discards one, so retries are bounded by pool size.
703
732
  const CLOSED_CONNECTION_CODES = new Set([
704
733
  "CONNECTION_CLOSED", // postgres-js
705
- "ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
706
734
  "57P01", // PG SQLSTATE: admin_shutdown / terminated backend
707
735
  ]);
708
736
  export function isClosedConnectionError(err) {
@@ -728,14 +756,10 @@ function isDriverClosedAbort(err) {
728
756
  function isHandleLike(raw) {
729
757
  return raw !== null && (typeof raw === "object" || typeof raw === "function");
730
758
  }
731
- // Bun.SQL is not instanceof-checkable (Bun.SQL.prototype is invalid) and its tx
732
- // handles are plain function objects, so discriminate by members only one driver
733
- // 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`.
734
760
  function driverOf(raw) {
735
761
  if (!isHandleLike(raw))
736
762
  return "unknown";
737
- if (typeof raw["flush"] === "function")
738
- return "bun-sql";
739
763
  if (typeof raw["typed"] === "function")
740
764
  return "postgres-js";
741
765
  return "unknown";
@@ -743,13 +767,12 @@ function driverOf(raw) {
743
767
  function positiveNumber(v) {
744
768
  return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
745
769
  }
746
- // postgres-js configures timeouts in seconds, Bun.SQL in milliseconds.
747
- function timeoutOptionMs(options, driver, bunName, postgresJsName) {
748
- if (driver === "postgres-js") {
749
- const seconds = positiveNumber(options[postgresJsName]);
750
- return seconds === undefined ? undefined : seconds * 1000;
751
- }
752
- 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;
753
776
  }
754
777
  function errorProp(err, name) {
755
778
  return isHandleLike(err) ? err[name] : undefined;
@@ -766,9 +789,9 @@ export function describeClosedConnectionFailure(raw, err, attempts) {
766
789
  pooled: isPooledClient(raw),
767
790
  attempts,
768
791
  poolMax: positiveNumber(options["max"]),
769
- idleTimeoutMs: timeoutOptionMs(options, driver, "idleTimeout", "idle_timeout"),
770
- maxLifetimeMs: timeoutOptionMs(options, driver, "maxLifetime", "max_lifetime"),
771
- 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"),
772
795
  errorName: errorProp(err, "name"),
773
796
  errorCode: extractPgError(err)?.code ?? errorProp(err, "code"),
774
797
  errorErrno: errorProp(err, "errno"),
@@ -861,15 +884,20 @@ export async function selectMany(db, table, where, options) {
861
884
  return scoped.selectMany(table, where, options);
862
885
  }
863
886
  const info = extractTableInfo(table);
864
- 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)}`;
865
892
  let values = [];
866
893
  if (where && Object.keys(where).length > 0) {
867
894
  const w = buildWhereClause(info, where, 1);
868
895
  sqlText += ` WHERE ${w.sqlText}`;
869
896
  values = w.values;
870
897
  }
871
- if (options?.orderBy) {
872
- 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];
873
901
  const parts = clauses.map((c) => {
874
902
  const col = info.columnOf(c.col);
875
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,2 +1,4 @@
1
1
  import type { FeatureDefinition } from "../types/index.ts";
2
2
  export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void;
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, 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";
@@ -86,3 +86,17 @@ export function validateI18nSurfaceKeys(features) {
86
86
  }
87
87
  }
88
88
  }
89
+ export function validateTranslationLocaleConsistency(features) {
90
+ const gaps = findTranslationLocaleGaps(features);
91
+ if (gaps.length > 0) {
92
+ const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing locale(s) ${gap.missingLocales.join(", ")}.`);
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
+ }
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 } 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";
@@ -218,6 +218,8 @@ export function validateBoot(rawFeatures, options) {
218
218
  }
219
219
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
220
220
  validateI18nSurfaceKeys(features);
221
+ validateTranslationLocaleConsistency(features);
222
+ validateRequiredLocales(features, options?.requiredLocales);
221
223
  validateEntityListScreens(features);
222
224
  validateSubmitPrefilledHiddenFields(features);
223
225
  validateCardMetaOverflow(features);
@@ -1,6 +1,4 @@
1
- import { createFallbackLogger } from "../../logging/utils.js";
2
1
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
3
- const log = createFallbackLogger("kumiko:boot");
4
2
  // Framework-managed Timestamp-Spalten — dürfen als retention.reference
5
3
  // genutzt werden auch wenn nicht in entity.fields deklariert.
6
4
  const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
@@ -146,6 +144,12 @@ export function validatePiiAndRetention(feature) {
146
144
  if (annotCount > 1) {
147
145
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
148
146
  }
147
+ if ((field.type === "text" || field.type === "longText") &&
148
+ annotCount === 0 &&
149
+ !annot.allowPlaintext &&
150
+ !annot.subjectRef) {
151
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
152
+ }
149
153
  // lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
150
154
  // Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
151
155
  // zweite (deterministische!) Kopie ohne Nutzen.
@@ -247,11 +251,6 @@ export function validatePiiAndRetention(feature) {
247
251
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
248
252
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
249
253
  }
250
- else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
251
- // The factories refuse a missing stance, but a raw literal such as
252
- // { type: "text" } still compiles and reaches the entity unchecked.
253
- log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } via createTextField / createLongTextField; "false" additionally needs { reason: "..." } stating why the value is not personal data.`);
254
- }
255
254
  }
256
255
  }
257
256
  // --- Entity-level retention ---
@@ -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 {};