@cosmicdrift/kumiko-framework 0.374.0 → 0.378.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 (89) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/apex-landing.md +1 -1
  3. package/agents/examples/apex-surface-auth.md +3 -3
  4. package/agents/examples/compose-stacks.md +15 -8
  5. package/agents/examples/delivery-notifications.md +25 -2
  6. package/agents/examples/embedded-entity-form.md +1 -1
  7. package/agents/examples/embedded.md +191 -0
  8. package/agents/examples/notes-history-basic.md +1 -1
  9. package/agents/examples/tags-basic.md +1 -1
  10. package/agents/examples/user-profile.md +1 -1
  11. package/agents/examples/wizard-form.md +4 -4
  12. package/agents/features.md +2 -2
  13. package/agents/widgets.md +4 -3
  14. package/dist/api/index.d.ts +1 -1
  15. package/dist/api/index.js +1 -1
  16. package/dist/api/routes.js +2 -2
  17. package/dist/api/server.js +2 -1
  18. package/dist/config/index.d.ts +4 -0
  19. package/dist/db/index.d.ts +2 -2
  20. package/dist/db/index.js +1 -1
  21. package/dist/db/migrate-runner.js +12 -4
  22. package/dist/db/money.d.ts +2 -1
  23. package/dist/db/money.js +3 -8
  24. package/dist/db/query.js +14 -14
  25. package/dist/db/replay-migration-sql.d.ts +1 -0
  26. package/dist/db/replay-migration-sql.js +115 -11
  27. package/dist/db/tenant-db.d.ts +7 -0
  28. package/dist/db/tenant-db.js +21 -5
  29. package/dist/engine/boot-validator/entity-handler.js +3 -0
  30. package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
  31. package/dist/engine/boot-validator/form-derivations.js +82 -0
  32. package/dist/engine/boot-validator/screens.js +23 -4
  33. package/dist/engine/build-config-feature-schema.js +33 -10
  34. package/dist/engine/embedded-derived.d.ts +1 -1
  35. package/dist/engine/embedded-derived.js +7 -1
  36. package/dist/engine/form-derivations.d.ts +21 -0
  37. package/dist/engine/form-derivations.js +175 -0
  38. package/dist/engine/index.d.ts +5 -1
  39. package/dist/engine/index.js +3 -0
  40. package/dist/engine/minor-units.d.ts +2 -0
  41. package/dist/engine/minor-units.js +8 -0
  42. package/dist/engine/parse-ref-target.js +1 -1
  43. package/dist/engine/provider-plugin-context.d.ts +3 -0
  44. package/dist/engine/provider-plugin-context.js +28 -0
  45. package/dist/engine/schema-builder.js +7 -0
  46. package/dist/engine/template-variables.d.ts +2 -1
  47. package/dist/engine/template-variables.js +4 -0
  48. package/dist/engine/types/index.d.ts +3 -3
  49. package/dist/engine/with-form-derivations.d.ts +9 -0
  50. package/dist/engine/with-form-derivations.js +16 -0
  51. package/dist/env/index.js +1 -1
  52. package/dist/errors/zod-bridge.js +1 -1
  53. package/dist/files/file-ref-entity.d.ts +6 -6
  54. package/dist/files/file-ref-table.d.ts +6 -6
  55. package/dist/files/index.d.ts +1 -1
  56. package/dist/files/index.js +1 -1
  57. package/dist/files/provider-resolver.d.ts +17 -17
  58. package/dist/files/provider-resolver.js +1 -1
  59. package/dist/http/egress.d.ts +14 -0
  60. package/dist/http/egress.js +144 -0
  61. package/dist/http/index.d.ts +3 -2
  62. package/dist/http/index.js +2 -1
  63. package/dist/http/policy.d.ts +30 -0
  64. package/dist/http/policy.js +299 -0
  65. package/dist/i18n/required-surface-keys.js +2 -0
  66. package/dist/i18n/settings-hub-keys.js +1 -0
  67. package/dist/jobs/job-runner.js +4 -2
  68. package/dist/pipeline/config-accessor-factory.d.ts +3 -0
  69. package/dist/pipeline/config-accessor-factory.js +3 -0
  70. package/dist/pipeline/dispatch-shared.js +7 -5
  71. package/dist/pipeline/index.d.ts +1 -0
  72. package/dist/pipeline/index.js +1 -0
  73. package/dist/search/configure-tenant-index.d.ts +4 -0
  74. package/dist/search/configure-tenant-index.js +8 -0
  75. package/dist/search/index.d.ts +1 -0
  76. package/dist/search/index.js +1 -0
  77. package/dist/ui-types/index.d.ts +3 -1
  78. package/dist/ui-types/index.js +1 -0
  79. package/dist/upgrade-changelog.d.ts +25 -0
  80. package/dist/upgrade-changelog.js +48 -0
  81. package/dist/upgrade-cli.js +78 -13
  82. package/package.json +3 -5
  83. package/presets/biome-app.json +8 -0
  84. package/src/changes.json +103 -0
  85. package/src/scripts/codemod/README.md +25 -0
  86. package/src/scripts/codemod/codemod-support.ts +64 -6
  87. package/src/scripts/codemod/package-merge.ts +260 -0
  88. package/src/scripts/codemod/s3-imports.ts +4 -42
  89. package/src/scripts/codemod/testing-injection.ts +135 -0
@@ -1,5 +1,6 @@
1
+ import { toMinorUnits } from "../engine/minor-units.ts";
1
2
  import type { EntityDefinition } from "../engine/types/index.ts";
2
- export declare function toMinorUnits(amount: number): number;
3
+ export { toMinorUnits };
3
4
  export declare function moneyPayloadToMinorUnits(raw: unknown): number | undefined;
4
5
  export declare function flattenMoney(payload: Record<string, unknown>, entity: EntityDefinition): Record<string, unknown>;
5
6
  /** Shape of a single rehydrated money field — {amount major, currency,
package/dist/db/money.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // round-tripping the float amount through /100·*100.
11
11
  // `amountMinor` stays as a @deprecated alias of the same
12
12
  // value until #1976/4 and #1976/5 migrate their remaining
13
- // consumers (renderer-web/primitives/index.tsx,
13
+ // consumers (renderer/web/primitives/index.tsx,
14
14
  // renderer/components/render-field.tsx) off the old name.
15
15
  //
16
16
  // table-builder.ts's moneyAmount column has always documented BIGINT as
@@ -30,15 +30,10 @@
30
30
  // Anders als locatedTimestamp behalten wir den Field-Namen `<name>` als
31
31
  // amount-Spalte (Legacy DB-Convention für Money — `SUM(buying_price)` bleibt
32
32
  // idiomatisch). `<name>Currency` ist die zusätzliche Spalte.
33
+ import { MINOR_UNIT_SCALE, toMinorUnits } from "../engine/minor-units.js";
33
34
  import { DEFAULT_CURRENCIES } from "../engine/types/index.js";
34
35
  const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
35
- // 2 decimal places (cents) — covers every currently-supported currency
36
- // (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
37
- // upgrade path once a currency needing a different scale actually lands.
38
- const MINOR_UNIT_SCALE = 100;
39
- export function toMinorUnits(amount) {
40
- return Math.round(amount * MINOR_UNIT_SCALE);
41
- }
36
+ export { toMinorUnits };
42
37
  function toMajorUnits(amountScaled) {
43
38
  return amountScaled / MINOR_UNIT_SCALE;
44
39
  }
package/dist/db/query.js CHANGED
@@ -44,7 +44,7 @@ function snakeToCamel(key) {
44
44
  // the only collision-safe path.
45
45
  export { asEntityTableMeta, requireEntityTableMeta };
46
46
  export function hasBegin(client) {
47
- // Bun.SQL tx/savepoint handles keep a begin() that throws; only savepoint() tells them apart from a pool.
47
+ // Tx/savepoint handles can keep a begin() that throws; only savepoint() tells them apart from a pool.
48
48
  return typeof client.begin === "function" && typeof client.savepoint !== "function";
49
49
  }
50
50
  function isRawClient(value) {
@@ -77,7 +77,7 @@ export function asRawClient(db) {
77
77
  export function runnerCanBeginTransaction(db) {
78
78
  return hasBegin(asRawClient(db));
79
79
  }
80
- // postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
80
+ // postgres.js rejects the WHOLE begin() when any statement inside
81
81
  // it errored — even if the JS error was caught (the driver tracks per-block
82
82
  // query failures). Error confinement therefore must go through the driver's
83
83
  // own savepoint(), not manual SAVEPOINT statements. The callback receives
@@ -85,7 +85,7 @@ export function runnerCanBeginTransaction(db) {
85
85
  export async function runInSavepoint(tx, fn) {
86
86
  const raw = asRawClient(tx);
87
87
  if (typeof raw.savepoint !== "function") {
88
- throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).");
88
+ throw new Error("runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql).");
89
89
  }
90
90
  return raw.savepoint(fn);
91
91
  }
@@ -180,7 +180,7 @@ function isWhereOperator(v) {
180
180
  export function extractTableInfo(table) {
181
181
  const meta = asEntityTableMeta(table);
182
182
  if (!meta) {
183
- throw new Error("bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
183
+ throw new Error("extractTableInfo: table is not a kumiko EntityTableMeta — " +
184
184
  "build it via buildEntityTable / deriveEntityTableMeta / table().");
185
185
  }
186
186
  const colByField = new Map();
@@ -282,7 +282,7 @@ function isTemporalPlainDateTimeLike(v) {
282
282
  "toPlainDate" in v &&
283
283
  typeof v.toPlainDate === "function");
284
284
  }
285
- // Verified empirically against Bun.SQL (kumiko-framework#1924): a `date`
285
+ // Verified empirically (kumiko-framework#1924): a `date`
286
286
  // column comes back as a native JS Date, but ALWAYS anchored at UTC
287
287
  // midnight regardless of process TZ — epochMilliseconds is stable across
288
288
  // zones, only the local wall-clock read of that Date would drift the day
@@ -296,7 +296,7 @@ function isTemporalPlainDateTimeLike(v) {
296
296
  // the day, so say so once per column instead of returning the wrong day quietly.
297
297
  const warnedNonMidnightDateColumns = new Set();
298
298
  const MS_PER_DAY = 86_400_000;
299
- const dateColumnDriftLog = createFallbackLogger("bun-db");
299
+ const dateColumnDriftLog = createFallbackLogger("postgres-js");
300
300
  function warnIfNotDateColumn(epochMs, column) {
301
301
  // skip: midnight value is a correct date column, and an already-warned column is deduped
302
302
  if (epochMs % MS_PER_DAY === 0 || warnedNonMidnightDateColumns.has(column))
@@ -384,7 +384,7 @@ function coerceDriverRow(row, info) {
384
384
  }
385
385
  else if ((pgType === "integer" || pgType === "int4" || pgType === "smallint" || pgType === "int2") &&
386
386
  (typeof value === "bigint" || typeof value === "string")) {
387
- // Bun.SQL / some drivers return int4 as bigint or numeric string.
387
+ // Drivers may return int4 as bigint or numeric string.
388
388
  // Drizzle coerced to number for numberField columns — match that.
389
389
  const n = Number(value);
390
390
  if (!Number.isNaN(n))
@@ -469,8 +469,8 @@ const INTEGER_PG_TYPES = new Set([
469
469
  "serial",
470
470
  "bigserial",
471
471
  ]);
472
- // postgres.js truncates timestamptz params to ms via Date; Bun.SQL sends µs and Postgres
473
- // rounds into timestamptz(3). Truncating here keeps both drivers storing the same instant.
472
+ // Postgres rounds sub-millisecond params into timestamptz(3); truncating to ms here keeps the
473
+ // stored instant identical across drivers and equal to what postgres.js sends via Date.
474
474
  export function instantToTimestamptzParam(instant) {
475
475
  return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
476
476
  }
@@ -478,7 +478,7 @@ function prepareDriverNeutralScalar(value, pgType) {
478
478
  if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
479
479
  return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
480
480
  }
481
- // Bun.SQL binds JS numbers typed, so Postgres would round 3.5 into an integer column; postgres.js sends text and rejects it.
481
+ // postgres.js sends text, so Postgres rejects 3.5 for an integer column instead of rounding it.
482
482
  if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
483
483
  return { kind: "param", sql: "", bound: String(value) };
484
484
  }
@@ -489,7 +489,7 @@ function prepareValue(value, pgType) {
489
489
  return { kind: "literal", literal: value.text };
490
490
  }
491
491
  // A PII-encrypted jsonb field is a JSON string scalar; a raw string bound to
492
- // ::jsonb would be parsed as JSON text (or double-encoded under Bun.SQL).
492
+ // ::jsonb would be parsed as JSON text (or double-encoded).
493
493
  if (pgType === "jsonb" && isPiiCiphertext(value)) {
494
494
  return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
495
495
  }
@@ -732,7 +732,7 @@ function isPooledClient(raw) {
732
732
  typeof propertyOf(raw, "savepoint") !== "function" &&
733
733
  typeof propertyOf(raw, "release") !== "function");
734
734
  }
735
- const closedConnectionLog = createFallbackLogger("bun-db");
735
+ const closedConnectionLog = createFallbackLogger("postgres-js");
736
736
  // The driver rejects in-flight queries with this AbortError (no code) when the
737
737
  // pool closes under them, so isClosedConnectionError can't see it.
738
738
  const DRIVER_CLOSED_ABORT_MESSAGE = "The connection was closed.";
@@ -805,7 +805,7 @@ function logClosedConnection(raw, err, attempts, context) {
805
805
  });
806
806
  }
807
807
  // Write and other non-retried paths: log when a closed-connection error leaves
808
- // bun-db, rethrow unchanged.
808
+ // the db layer, rethrow unchanged.
809
809
  async function unsafeLoggingClosedConnection(db, sqlText, params, context) {
810
810
  const raw = asRawClient(db);
811
811
  try {
@@ -821,7 +821,7 @@ function poolMaxOf(raw) {
821
821
  const max = propertyOf(propertyOf(raw, "options"), "max");
822
822
  return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : DEFAULT_POOL_MAX;
823
823
  }
824
- // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
824
+ // Exported so raw-SQL query modules outside the db layer (e.g. bundled-features'
825
825
  // db/queries/*.ts) can opt into the same closed-connection retry instead of
826
826
  // calling asRawClient(db).unsafe(...) directly and losing it.
827
827
  // READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
@@ -1,6 +1,7 @@
1
1
  import type { Snapshot } from "./migrate-generator.ts";
2
2
  export type ReplayedTable = {
3
3
  readonly columns: ReadonlySet<string>;
4
+ readonly columnsUnknown?: boolean;
4
5
  };
5
6
  export type ReplayedSchema = ReadonlyMap<string, ReplayedTable>;
6
7
  export declare function replayMigrationsDir(migrationsDir: string): ReplayedSchema;
@@ -67,18 +67,106 @@ function parseColumnNames(body) {
67
67
  // Clauses that can appear inside an ALTER TABLE body which do NOT change the
68
68
  // table/column shape this replay tracks (presence only, not types/
69
69
  // constraints/RLS/ownership) — recognized explicitly so they don't fall
70
- // through to the fail-loud check as "unparsed". Deliberately does NOT
71
- // include RENAME TO / RENAME COLUMN: those DO change identity in a way this
72
- // replay can't track, so they must keep failing loud.
70
+ // through to the fail-loud check as "unparsed". RENAME TO / RENAME COLUMN
71
+ // are not listed here: they change identity and are applied explicitly.
73
72
  const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp([
74
73
  `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
75
74
  `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
76
75
  `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
77
76
  `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
77
+ `RENAME\\s+CONSTRAINT\\s+${IDENT}\\s+TO\\s+${IDENT}`,
78
78
  `\\b(ENABLE|DISABLE|(NO\\s+)?FORCE)\\s+ROW LEVEL SECURITY\\b`,
79
79
  `^OWNER TO\\b`,
80
80
  ].join("|"), "gi");
81
+ function lastIdentifierSegment(expression) {
82
+ const match = expression.trim().match(new RegExp(`(?:^|\\.)${IDENT}$`));
83
+ return match?.[1];
84
+ }
85
+ // Column names of a `SELECT <list> FROM <table>` as CREATE TABLE AS materializes them: the
86
+ // alias, else the last segment of a plain column reference. `*` copies the single FROM table
87
+ // when the replay knows it; anything else makes the column set unknown.
88
+ function selectListColumns(schema, query) {
89
+ const columns = new Set();
90
+ let columnsUnknown = false;
91
+ const select = query.match(/^\s*SELECT\s+(?:DISTINCT\s+)?([\s\S]*)$/i);
92
+ if (!select?.[1])
93
+ return { columns, columnsUnknown: true };
94
+ let list = select[1];
95
+ const fromAt = topLevelKeywordIndex(list, "FROM");
96
+ const fromClause = fromAt === -1 ? "" : list.slice(fromAt + 4);
97
+ if (fromAt !== -1)
98
+ list = list.slice(0, fromAt);
99
+ for (const item of splitTopLevel(list)) {
100
+ const trimmed = item.trim();
101
+ if (/(^|\.)\*$/.test(trimmed)) {
102
+ const source = fromClause.match(new RegExp(`^\\s*${IDENT}\\s*(?:;|$)`))?.[1];
103
+ const sourceTable = source === undefined ? undefined : schema.get(source);
104
+ if (sourceTable && !sourceTable.columnsUnknown) {
105
+ for (const column of sourceTable.columns)
106
+ columns.add(column);
107
+ }
108
+ else {
109
+ columnsUnknown = true;
110
+ }
111
+ continue;
112
+ }
113
+ const alias = trimmed.match(new RegExp(`\\bAS\\s+${IDENT}\\s*$`, "i"))?.[1];
114
+ const name = alias ?? lastIdentifierSegment(trimmed);
115
+ if (name === undefined)
116
+ columnsUnknown = true;
117
+ else
118
+ columns.add(name);
119
+ }
120
+ return { columns, columnsUnknown };
121
+ }
122
+ function topLevelKeywordIndex(text, keyword) {
123
+ let depth = 0;
124
+ const keywordRe = new RegExp(`^${keyword}\\b`, "i");
125
+ for (let i = 0; i < text.length; i++) {
126
+ const ch = text.charAt(i);
127
+ if (ch === "(")
128
+ depth++;
129
+ else if (ch === ")")
130
+ depth--;
131
+ else if (depth === 0 && /\s/.test(text.charAt(i - 1) || " ") && keywordRe.test(text.slice(i))) {
132
+ return i;
133
+ }
134
+ }
135
+ return -1;
136
+ }
137
+ // A DO block's conditions can't be evaluated here; its table DDL is applied as written, since
138
+ // guarded blocks (IF NOT EXISTS ... THEN ALTER TABLE ...) express the intended end state.
139
+ function doBlockTableStatements(statement, file) {
140
+ const body = statement.match(/^DO\s+(?:LANGUAGE\s+\w+\s+)?(\$(?:[A-Za-z_]\w*)?\$)([\s\S]*)\1/i)?.[2];
141
+ if (body === undefined) {
142
+ throw new Error(`${file}: DO block without a recognizable dollar-quoted body — refusing to guess its table DDL`);
143
+ }
144
+ return body
145
+ .split(";")
146
+ .map((chunk) => chunk.match(/\b(?:CREATE|ALTER|DROP)\s+TABLE\b[\s\S]*$/i)?.[0])
147
+ .filter((ddl) => ddl !== undefined)
148
+ .map((ddl) => `${ddl.trim()};`);
149
+ }
81
150
  function applyStatement(schema, statement, context) {
151
+ if (/^DO\b/i.test(statement)) {
152
+ for (const nested of doBlockTableStatements(statement, context.file))
153
+ applyStatement(schema, nested, context);
154
+ // skip: DO block fully handled above
155
+ return;
156
+ }
157
+ const createAs = statement.match(new RegExp(`^CREATE\\s+(?:UNLOGGED\\s+)?TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*(?:\\(([^)]*)\\)\\s*)?AS\\b([\\s\\S]*)$`, "i"));
158
+ if (createAs?.[2] !== undefined && createAs[4] !== undefined) {
159
+ if (!(createAs[1] !== undefined && schema.has(createAs[2]))) {
160
+ const explicitColumns = createAs[3] === undefined ? undefined : parseColumnNames(createAs[3]);
161
+ const selected = selectListColumns(schema, createAs[4]);
162
+ schema.set(createAs[2], {
163
+ columns: explicitColumns ?? selected.columns,
164
+ ...(explicitColumns === undefined && selected.columnsUnknown && { columnsUnknown: true }),
165
+ });
166
+ }
167
+ // skip: CREATE TABLE AS fully handled above
168
+ return;
169
+ }
82
170
  const create = statement.match(new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"));
83
171
  if (create?.[2] !== undefined && create[3] !== undefined) {
84
172
  const hasIfNotExists = create[1] !== undefined;
@@ -113,6 +201,23 @@ function applyStatement(schema, statement, context) {
113
201
  const alterTableName = alterTable?.[1];
114
202
  const alterBody = alterTable?.[2];
115
203
  if (alterTableName !== undefined && alterBody !== undefined) {
204
+ const renameTable = alterBody.match(new RegExp(`^RENAME\\s+TO\\s+${IDENT}\\s*;?$`, "i"));
205
+ if (renameTable?.[1] !== undefined) {
206
+ const renamed = schema.get(alterTableName) ?? { columns: new Set() };
207
+ schema.delete(alterTableName);
208
+ schema.set(renameTable[1], renamed);
209
+ // skip: ALTER TABLE RENAME TO fully handled above
210
+ return;
211
+ }
212
+ const renameColumn = alterBody.match(new RegExp(`^RENAME\\s+(?:COLUMN\\s+)?${IDENT}\\s+TO\\s+${IDENT}\\s*;?$`, "i"));
213
+ if (renameColumn?.[1] !== undefined && renameColumn[2] !== undefined) {
214
+ const renamedIn = schema.get(alterTableName) ?? { columns: new Set() };
215
+ schema.set(alterTableName, renamedIn);
216
+ renamedIn.columns.delete(renameColumn[1]);
217
+ renamedIn.columns.add(renameColumn[2]);
218
+ // skip: ALTER TABLE RENAME COLUMN fully handled above
219
+ return;
220
+ }
116
221
  const table = schema.get(alterTableName) ?? { columns: new Set() };
117
222
  schema.set(alterTableName, table);
118
223
  const clauseRe = new RegExp(`(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`, "gi");
@@ -133,8 +238,8 @@ function applyStatement(schema, statement, context) {
133
238
  if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE))
134
239
  matchedAClause = true;
135
240
  // An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
136
- // but whose body contains no recognized clause (e.g. RENAME TO/RENAME
137
- // COLUMN, which DO change identity) would otherwise silently no-op here
241
+ // but whose body contains no recognized clause (e.g. SET SCHEMA)
242
+ // would otherwise silently no-op here
138
243
  // — fall through to the fail-loud check below instead of returning, so
139
244
  // it's reported rather than vanishing.
140
245
  // skip: at least one recognized clause matched — this ALTER TABLE is
@@ -146,19 +251,16 @@ function applyStatement(schema, statement, context) {
146
251
  // shape this replay tracks — but a statement that clearly INTENDED to
147
252
  // touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
148
253
  // none of the recognized patterns above must fail loud, not vanish
149
- // silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
150
- // this replay can't track) or genuinely unparsed hand-written DDL — either
151
- // way a misleading missing-table/column-drift report is worse than
254
+ // silently. Concretely this is genuinely unparsed hand-written DDL — a misleading missing-table/column-drift report is worse than
152
255
  // pointing at the actual unparsed statement.
153
256
  if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
154
257
  const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
155
258
  throw new Error(`replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
156
259
  `starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
157
260
  `recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
158
- `COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
261
+ `COLUMN, RENAME TO/COLUMN, CREATE TABLE AS, DO blocks, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
159
262
  `ADD/DROP CONSTRAINT, ENABLE/DISABLE/[NO] FORCE ROW LEVEL SECURITY, OWNER TO — ` +
160
- `optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
161
- `identity change, not trackable here) or genuinely unparsed hand-written ` +
263
+ `optionally-quoted identifiers). Likely genuinely unparsed hand-written ` +
162
264
  `DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`);
163
265
  }
164
266
  }
@@ -194,6 +296,8 @@ export function diffReplayAgainstSnapshot(replayed, snapshot) {
194
296
  });
195
297
  continue;
196
298
  }
299
+ if (table.columnsUnknown)
300
+ continue;
197
301
  const expected = new Set(meta.columns.map((c) => c.name));
198
302
  const missing = [...expected].filter((c) => !table.columns.has(c));
199
303
  const extra = [...table.columns].filter((c) => !expected.has(c));
@@ -36,6 +36,13 @@ export declare function acknowledgeConventionCrossTenant(tenantDb: TenantDb, rea
36
36
  readonly deferReport?: boolean;
37
37
  }): TenantDb;
38
38
  export declare function reportConventionCrossTenantUse(acknowledged: TenantDb, target?: EscapeHatchTarget): void;
39
+ export type OutsideTransactionContext = {
40
+ readonly dbOutsideTransaction: TenantDb | undefined;
41
+ };
42
+ export declare function hasOutsideTransactionDb(ctx: OutsideTransactionContext): ctx is OutsideTransactionContext & {
43
+ readonly dbOutsideTransaction: TenantDb;
44
+ };
45
+ export declare function requireOutsideTransactionDb(ctx: OutsideTransactionContext): TenantDb;
39
46
  export declare function runInOwnTransaction<T>(tenantDb: TenantDb, fn: (txDb: TenantDb) => Promise<T>): Promise<T>;
40
47
  export type CreateTenantDbOptions = {
41
48
  readonly db: DbRunner;
@@ -5,6 +5,7 @@ import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
5
  import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
6
6
  import { emitDbQuery, fallbackEscapeHatchReporter, registerStandardMetrics, } from "../observability/index.js";
7
7
  import { bindProjectionRegistry, projectionRegistryOf } from "./projection-registry-binding.js";
8
+ import { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
8
9
  import { hasFieldProperty, tableNameOf } from "./table-introspection.js";
9
10
  import { bindTenantDbRunner, denyTenantDbWrites, tenantDbRunner, tenantDbWritesDeniedError, } from "./tenant-db-runner.js";
10
11
  export { SYSTEM_SCOPE_CHECK_BRAND, } from "@cosmicdrift/kumiko-types/tenant-db-types";
@@ -103,7 +104,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
103
104
  // AccessDeniedError) because this is a dispatch wiring fault, not a
104
105
  // tenant-access denial — mirrors the "no database connection configured"
105
106
  // case in dispatch-shared.ts's appendDomainEvent.
106
- function requireOutsideTransactionDb() {
107
+ function requireSystemOutsideTransactionDb() {
107
108
  if (!dbOutsideTransaction) {
108
109
  throw new InternalError({
109
110
  message: "systemScope() outsideTransaction check failed: no outside-transaction database " +
@@ -173,13 +174,13 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
173
174
  message: `systemScope() outsideTransaction tenant self-check failed: expected "${db.tenantId}", got "${tenantId}"`,
174
175
  });
175
176
  }
176
- return requireOutsideTransactionDb();
177
+ return requireSystemOutsideTransactionDb();
177
178
  },
178
179
  acknowledgeCrossTenant(reason) {
179
180
  if (reason.trim().length === 0) {
180
181
  throw new Error("acknowledgeCrossTenant requires a non-empty reason");
181
182
  }
182
- const result = requireOutsideTransactionDb();
183
+ const result = requireSystemOutsideTransactionDb();
183
184
  report("acknowledge-cross-tenant", reason);
184
185
  return result;
185
186
  },
@@ -301,12 +302,24 @@ export function reportConventionCrossTenantUse(acknowledged, target) {
301
302
  reporter(target);
302
303
  }
303
304
  const ownTransactionRebinders = new WeakMap();
305
+ export function hasOutsideTransactionDb(ctx) {
306
+ return ctx.dbOutsideTransaction !== undefined;
307
+ }
308
+ export function requireOutsideTransactionDb(ctx) {
309
+ if (!hasOutsideTransactionDb(ctx)) {
310
+ throw new InternalError({
311
+ message: "ctx.dbOutsideTransaction is not available in this dispatch phase (member-resolution, before-transaction denial) or metering tenant mode.",
312
+ });
313
+ }
314
+ return ctx.dbOutsideTransaction;
315
+ }
304
316
  export async function runInOwnTransaction(tenantDb, fn) {
305
317
  const rebind = ownTransactionRebinders.get(tenantDb);
306
318
  if (!rebind) {
307
319
  throw new InternalError({
308
- message: "runInOwnTransaction received a TenantDb not built by createTenantDb — " +
309
- "no transaction rebinder bound.",
320
+ message: "runInOwnTransaction received a TenantDb without a transaction rebinder: either it was not " +
321
+ "built by createTenantDb, or it is the fail-closed ctx.db/ctx.dbOutsideTransaction of an " +
322
+ "r.systemScope() handler (use ctx.systemDb.outsideTransaction there).",
310
323
  });
311
324
  }
312
325
  return rebind(fn);
@@ -543,6 +556,9 @@ function buildTenantDb(options) {
543
556
  const filter = readWhere(table, where);
544
557
  return withDbSpan("select", table, async () => bunCountWhere(db, table, filter));
545
558
  },
559
+ advisoryXactLock(namespace, key) {
560
+ return acquireNamespacedAdvisoryLock(db, namespace, `${tenantId}:${key}`);
561
+ },
546
562
  aggregate(table, spec, where) {
547
563
  const filter = readWhere(table, where);
548
564
  return withDbSpan("select", table, async () => bunAggregateWhere(db, table, spec, filter));
@@ -642,6 +642,9 @@ function validateEmbeddedDerivedCells(fieldName, entityName, field) {
642
642
  if (!(derivedName in field.schema)) {
643
643
  throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema. Add the sub-field to the schema or remove the derived cell.`);
644
644
  }
645
+ if (derivedDef.op === "percent" && derivedDef.from.length !== 2) {
646
+ throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" with op "percent", which needs exactly two sources (base, rate).`);
647
+ }
645
648
  for (const sourceName of derivedDef.from) {
646
649
  if (!(sourceName in field.schema)) {
647
650
  throw new Error(`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}". Read only sub-fields that exist in the schema.`);
@@ -0,0 +1,4 @@
1
+ import type { FeatureDefinition } from "../types/feature.ts";
2
+ import type { ActionFormScreenDefinition } from "../types/screen.ts";
3
+ export declare function validateFormDerivations(featureName: string, screenId: string, screen: ActionFormScreenDefinition): void;
4
+ export declare function validateFormDerivationsEnforced(featureName: string, screenId: string, screen: ActionFormScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
@@ -0,0 +1,82 @@
1
+ import { orderFormDerivations } from "../form-derivations.js";
2
+ import { parseQn } from "../qualified-name.js";
3
+ import { formDerivationsOfSchema } from "../with-form-derivations.js";
4
+ const NUMERIC_FORM_FIELD_TYPES = ["money", "number", "decimal"];
5
+ const NUMERIC_SUB_FIELD_TYPES = ["money", "number", "decimal"];
6
+ function validateFromList({ prefix, screen, target }, derivation) {
7
+ if (derivation.op === "percent" && derivation.from.length !== 2) {
8
+ throw new Error(`${prefix}.${target}: "percent" needs exactly two sources (base, rate).`);
9
+ }
10
+ for (const source of derivation.from) {
11
+ if (!(source in screen.fields)) {
12
+ throw new Error(`${prefix}.${target}: unknown source field "${source}". Name a field of this screen.`);
13
+ }
14
+ }
15
+ }
16
+ function validateListSum(check, derivation) {
17
+ const { prefix, screen, target } = check;
18
+ const listField = screen.fields[derivation.list];
19
+ if (listField === undefined) {
20
+ throw new Error(`${prefix}.${target}: unknown list field "${derivation.list}".`);
21
+ }
22
+ if (listField.type !== "embedded" || listField.multiple !== true) {
23
+ throw new Error(`${prefix}.${target}: "${derivation.list}" must be an embedded list field (multiple: true).`);
24
+ }
25
+ const cellType = listField.schema[derivation.cell]?.type;
26
+ if (cellType === undefined || !NUMERIC_SUB_FIELD_TYPES.includes(cellType)) {
27
+ throw new Error(`${prefix}.${target}: "${derivation.cell}" must be a money, number or decimal sub-field of "${derivation.list}".`);
28
+ }
29
+ const { whenEmpty } = derivation;
30
+ // skip: whenEmpty is optional
31
+ if (whenEmpty === undefined)
32
+ return;
33
+ // Type forbids a nested listSum; JS/cast callers can still pass one.
34
+ if (!("from" in whenEmpty)) {
35
+ throw new Error(`${prefix}.${target}: whenEmpty must not be a listSum.`);
36
+ }
37
+ validateFromList(check, whenEmpty);
38
+ }
39
+ // actionForm `derive`: every name must resolve, targets must be numeric, a
40
+ // listSum must point at a numeric cell of an embedded list, no cycles.
41
+ export function validateFormDerivations(featureName, screenId, screen) {
42
+ const derive = screen.derive;
43
+ // skip: the screen derives nothing
44
+ if (derive === undefined)
45
+ return;
46
+ const prefix = `[Feature ${featureName}] Screen "${screenId}" (actionForm) derive`;
47
+ for (const [target, derivation] of Object.entries(derive)) {
48
+ const targetField = screen.fields[target];
49
+ if (targetField === undefined) {
50
+ throw new Error(`${prefix}: key "${target}" is not a field of this screen.`);
51
+ }
52
+ if (!NUMERIC_FORM_FIELD_TYPES.includes(targetField.type)) {
53
+ throw new Error(`${prefix}.${target}: a derived field must be money, number or decimal, not "${targetField.type}".`);
54
+ }
55
+ const check = { prefix, screen, target };
56
+ if (derivation.op === "listSum")
57
+ validateListSum(check, derivation);
58
+ else
59
+ validateFromList(check, derivation);
60
+ }
61
+ try {
62
+ orderFormDerivations(derive);
63
+ }
64
+ catch (error) {
65
+ throw new Error(`${prefix}: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ }
68
+ // Secure by default: a derive the handler schema doesn't recompute would let a
69
+ // client send its own totals, so the handler must wrap with this very screen.
70
+ export function validateFormDerivationsEnforced(featureName, screenId, screen, featureMap) {
71
+ // skip: the screen derives nothing
72
+ if (screen.derive === undefined)
73
+ return;
74
+ const { scope, name } = parseQn(screen.handler);
75
+ const schema = featureMap.get(scope)?.writeHandlers[name]?.schema;
76
+ // skip: wrapped with this screen
77
+ if (formDerivationsOfSchema(schema) === screen.derive)
78
+ return;
79
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (actionForm) declares derive, but handler ` +
80
+ `"${screen.handler}" does not recompute it on the server. Wrap the handler schema: ` +
81
+ `withFormDerivations(schema, screen) with this screen object.`);
82
+ }
@@ -18,6 +18,7 @@ import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js"
18
18
  import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
19
19
  import { metricField } from "../types/index.js";
20
20
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
21
+ import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
21
22
  import { isSelectOptionTone } from "./select-option-tone.js";
22
23
  function isRowClickAction(action) {
23
24
  return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
@@ -319,7 +320,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
319
320
  // typed IconKey, a closed union — but that only protects call sites this
320
321
  // repo's own `tsc --build` actually covers (samples and external consumers
321
322
  // aren't in that project graph). The renderer's actionIconFor
322
- // (renderer-web/src/primitives/index.tsx) already falls back to "no icon"
323
+ // (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
323
324
  // for an unregistered key instead of crashing — this check makes the same
324
325
  // gap fail loudly at boot instead of silently at render.
325
326
  function isRegisteredIcon(icon) {
@@ -909,6 +910,12 @@ function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups)
909
910
  if (screen.redirect !== undefined) {
910
911
  validateRedirectTarget(feature, screenId, kind, screen.redirect, lookups);
911
912
  }
913
+ if (screen.type === "actionForm" &&
914
+ screen.successFacts !== undefined &&
915
+ screen.redirect !== undefined) {
916
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successFacts — ` +
917
+ `redirect leaves the screen, successFacts are shown on it. Use one of them.`);
918
+ }
912
919
  if (screen.type === "actionForm" && screen.successLinks !== undefined) {
913
920
  if (screen.redirect !== undefined) {
914
921
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
@@ -939,6 +946,10 @@ function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandl
939
946
  const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
940
947
  validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
941
948
  validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
949
+ if (screen.type === "actionForm") {
950
+ validateFormDerivations(feature.name, screenId, screen);
951
+ validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
952
+ }
942
953
  validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
943
954
  }
944
955
  // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
@@ -1281,17 +1292,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1281
1292
  else if (action.kind === "clientAction") {
1282
1293
  validateClientActionName(feature.name, screenId, "projectionList", "rowAction", action);
1283
1294
  }
1295
+ else if (!allWriteHandlerQns.has(action.handler)) {
1296
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
1297
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
1298
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
1299
+ }
1284
1300
  }
1285
1301
  validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
1286
1302
  }
1287
- // Only drawer-kind and navigate actions that set tab are validated
1288
- // here — plain navigate (no tab) and writeHandler toolbarActions on
1289
- // projectionList have no boot check yet (pre-existing gap).
1303
+ // Plain navigate toolbarActions (no tab) have no boot check yet.
1290
1304
  if (screen.toolbarActions !== undefined) {
1291
1305
  for (const action of screen.toolbarActions) {
1292
1306
  if (action.kind === "drawer") {
1293
1307
  validateDrawerTargetAction(feature.name, screenId, "projectionList", "toolbarAction", action, feature.screens);
1294
1308
  }
1309
+ else if (action.kind === "writeHandler" && !allWriteHandlerQns.has(action.handler)) {
1310
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) toolbarAction "${action.id}" ` +
1311
+ `handler "${action.handler}" is not a registered write-handler. Check the QN spelling ` +
1312
+ `(expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
1313
+ }
1295
1314
  if (action.kind === "navigate" && action.tab !== undefined) {
1296
1315
  resolveRowActionNavigateTarget(feature.name, screenId, "projectionList", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1297
1316
  }