@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.
- package/AGENTS.md +2 -2
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +3 -3
- package/agents/examples/compose-stacks.md +15 -8
- package/agents/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/embedded.md +191 -0
- package/agents/examples/notes-history-basic.md +1 -1
- package/agents/examples/tags-basic.md +1 -1
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/wizard-form.md +4 -4
- package/agents/features.md +2 -2
- package/agents/widgets.md +4 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +2 -1
- package/dist/config/index.d.ts +4 -0
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +12 -4
- package/dist/db/money.d.ts +2 -1
- package/dist/db/money.js +3 -8
- package/dist/db/query.js +14 -14
- package/dist/db/replay-migration-sql.d.ts +1 -0
- package/dist/db/replay-migration-sql.js +115 -11
- package/dist/db/tenant-db.d.ts +7 -0
- package/dist/db/tenant-db.js +21 -5
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/screens.js +23 -4
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +5 -1
- package/dist/engine/index.js +3 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- package/dist/engine/parse-ref-target.js +1 -1
- package/dist/engine/provider-plugin-context.d.ts +3 -0
- package/dist/engine/provider-plugin-context.js +28 -0
- package/dist/engine/schema-builder.js +7 -0
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +3 -3
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- package/dist/env/index.js +1 -1
- package/dist/errors/zod-bridge.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/provider-resolver.d.ts +17 -17
- package/dist/files/provider-resolver.js +1 -1
- package/dist/http/egress.d.ts +14 -0
- package/dist/http/egress.js +144 -0
- package/dist/http/index.d.ts +3 -2
- package/dist/http/index.js +2 -1
- package/dist/http/policy.d.ts +30 -0
- package/dist/http/policy.js +299 -0
- package/dist/i18n/required-surface-keys.js +2 -0
- package/dist/i18n/settings-hub-keys.js +1 -0
- package/dist/jobs/job-runner.js +4 -2
- package/dist/pipeline/config-accessor-factory.d.ts +3 -0
- package/dist/pipeline/config-accessor-factory.js +3 -0
- package/dist/pipeline/dispatch-shared.js +7 -5
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +3 -1
- package/dist/ui-types/index.js +1 -0
- package/dist/upgrade-changelog.d.ts +25 -0
- package/dist/upgrade-changelog.js +48 -0
- package/dist/upgrade-cli.js +78 -13
- package/package.json +3 -5
- package/presets/biome-app.json +8 -0
- package/src/changes.json +103 -0
- package/src/scripts/codemod/README.md +25 -0
- package/src/scripts/codemod/codemod-support.ts +64 -6
- package/src/scripts/codemod/package-merge.ts +260 -0
- package/src/scripts/codemod/s3-imports.ts +4 -42
- package/src/scripts/codemod/testing-injection.ts +135 -0
package/dist/db/money.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { toMinorUnits } from "../engine/minor-units.ts";
|
|
1
2
|
import type { EntityDefinition } from "../engine/types/index.ts";
|
|
2
|
-
export
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
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
|
-
//
|
|
473
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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("
|
|
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
|
-
//
|
|
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
|
|
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".
|
|
71
|
-
//
|
|
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.
|
|
137
|
-
//
|
|
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
|
|
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
|
|
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));
|
package/dist/db/tenant-db.d.ts
CHANGED
|
@@ -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;
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
|
309
|
-
"
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|