biz-a-cli 2.3.80-15372 → 2.3.80-15375

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.
@@ -19,6 +19,9 @@
19
19
  * TOrmMethod/"orm" -> {"data":[{...}],"success":"OK"}
20
20
  * failure -> {"error":"..."} , which db/ds.js's dsReq already looks for.
21
21
  */
22
+ import { UNSAFE_SQL_CODE } from "./sqlGuard.js";
23
+ import { INCOMPLETE_COLLECTION_CODE } from "./completeness.js";
24
+ import { isGraphRead, assertGraphRequest, attachCollections, GRAPH_READ_KEY_REQUIRED_CODE } from "./graphRead.js";
22
25
  import { json2List, addTableNameToArray, lookupToSql, lookupTotalToSql, resolveDbIndex, buildWriteSql } from "./index.js";
23
26
  import { nextGeneratorValue } from "./dialect.js";
24
27
  import { createReaders } from "./changeInference.js";
@@ -364,9 +367,33 @@ const lookupResponse = async (param, exec, dbIndex, isTotal, tenantIds) => {
364
367
  /* `param.version` selects the persistence semantics (Doc 3 §6.1). The envelope already reached here
365
368
  whole — `parseDataSnapRequest` passes `param` through untouched — so carrying it needed no
366
369
  wire-format change, only for this function to stop discarding the key. */
370
+ /*
371
+ * Doc 2 section 5.2 item 5 - a delete the database refuses because another row still REFERENCES the record
372
+ * (a shared relationship, e.g. a category using a template) is a caller-visible state, not a server fault:
373
+ * answered 409 IN_USE naming the referencing table. Only for a DELETE - an FK violation on a save means a
374
+ * bad reference, which is a different thing. PostgreSQL reports the referencing table as error.table.
375
+ */
376
+ export const IN_USE_CODE = "IN_USE";
377
+ const asInUse = (error, param) => {
378
+ if (error?.code !== "23503" || String(param?.method ?? "").toLowerCase() !== "delete") return error;
379
+ /* ⚠️ The transaction runner (dialect.js decorate) wraps the driver error to add the failing statement
380
+ and copies only `code`, so the table survives on `cause` - or only in PostgreSQL's detail text. */
381
+ const original = error.cause ?? {};
382
+ const table =
383
+ error.table ??
384
+ original.table ??
385
+ /referenced from table "([^"]+)"/.exec(String(original.detail ?? error.detail ?? ""))?.[1];
386
+ const by = table ? ` by ${table}` : "";
387
+ return Object.assign(new Error(`This record cannot be deleted: it is still used${by}.`), { code: IN_USE_CODE });
388
+ };
389
+
367
390
  const ormResponse = async (param, exec, execStatements, dbIndex, principal) => {
368
391
  const tenantIds = principal?.tenantIds;
369
392
  const readers = createReaders(exec, dbIndex, tenantIds);
393
+ /* Doc 2 section 5.2 item 1 - a v2 get that names collections loads the whole graph. Validated
394
+ BEFORE anything is read; see graphRead.js for why the collections use `readers.readRows`. */
395
+ const graph = isGraphRead(param);
396
+ if (graph) assertGraphRequest(param.object, dbIndex);
370
397
  const sql = await buildWriteSql({
371
398
  method: param.method,
372
399
  object: param.object,
@@ -381,7 +408,12 @@ const ormResponse = async (param, exec, execStatements, dbIndex, principal) => {
381
408
  tenantIds,
382
409
  ...readers,
383
410
  });
384
- const rows = await runStatements(exec, execStatements, sql, dbIndex);
411
+ const rows = await runStatements(exec, execStatements, sql, dbIndex).catch((error) => {
412
+ throw asInUse(error, param);
413
+ });
414
+ if (graph) await attachCollections({ object: param.object, masterRows: rows, readRows: readers.readRows, dbIndex });
415
+ /* Shallow on purpose: the master keeps the key casing the client has always seen, and the rows
416
+ inside a collection keep PostgreSQL columns - the shape the save takes straight back. */
385
417
  const body = { data: upperCaseKeys(rows), success: "OK" };
386
418
 
387
419
  /*
@@ -829,6 +861,9 @@ export const routeApiRequest = async (
829
861
  /* Doc 3 §6.7 — a concurrency conflict is not a server fault, and must be distinguishable from
830
862
  one WITHOUT parsing a message, so an application can offer reload/retry instead of showing a
831
863
  generic failure. */
864
+ if (error?.code === IN_USE_CODE) {
865
+ return { status: 409, body: { error: error.message, code: IN_USE_CODE } };
866
+ }
832
867
  if (error?.code === CONFLICT_CODE) {
833
868
  return { status: 409, body: { error: error.message, code: CONFLICT_CODE } };
834
869
  }
@@ -844,6 +879,16 @@ export const routeApiRequest = async (
844
879
  if (error?.code === AMBIGUOUS_TENANT_CODE) {
845
880
  return { status: 400, body: { error: error.message, code: AMBIGUOUS_TENANT_CODE } };
846
881
  }
882
+ /* Request input the ORM will not put into SQL text (sqlGuard.js) - a caller error, named. */
883
+ if (error?.code === GRAPH_READ_KEY_REQUIRED_CODE) {
884
+ return { status: 400, body: { error: error.message, code: GRAPH_READ_KEY_REQUIRED_CODE } };
885
+ }
886
+ if (error?.code === INCOMPLETE_COLLECTION_CODE) {
887
+ return { status: 400, body: { error: error.message, code: INCOMPLETE_COLLECTION_CODE } };
888
+ }
889
+ if (error?.code === UNSAFE_SQL_CODE) {
890
+ return { status: 400, body: { error: error.message, code: UNSAFE_SQL_CODE } };
891
+ }
847
892
  if (error?.code === SHARED_RELATIONSHIP_CODE) {
848
893
  return { status: 400, body: { error: error.message, code: SHARED_RELATIONSHIP_CODE } };
849
894
  }
@@ -20,11 +20,34 @@
20
20
  * enforcing completeness is the APPLICATION's responsibility — the ORM cannot distinguish a partial
21
21
  * load from a legitimate delete-all-but-these, and deliberately does not try.
22
22
  */
23
- import { hasRealKey, scalarEntries, detailEntries, foreignKeyColumn, renderValue } from "./masterDetail.js";
23
+ import { hasRealKey, scalarEntries, detailEntries, renderValue } from "./masterDetail.js";
24
24
  import { PRIMARY_KEY_COLUMN } from "./crudQuery.js";
25
25
  import { projectRowVersion } from "./concurrency.js";
26
- import { assertOwnedCollection, tenantFilter } from "./entityConfig.js";
26
+ import { assertOwnedCollection, tenantFilter, softDeleteFilter, foreignKeyFor } from "./entityConfig.js";
27
27
  import { runTrusted } from "./tenantContext.js";
28
+ import { assertIdentifier } from "./sqlGuard.js";
29
+
30
+ /*
31
+ * `col [ASC|DESC], ...` checked as identifiers and rendered, always followed by `id` - rows with
32
+ * equal or null sort values would otherwise come back in heap order, which an UPDATE changes on
33
+ * PostgreSQL. tests/orm-v2-order-by.test.js.
34
+ */
35
+ const renderOrderBy = (orderBy) => {
36
+ const terms = String(orderBy ?? "")
37
+ .split(",")
38
+ .map((term) => term.trim())
39
+ .filter(Boolean)
40
+ .map((term) => {
41
+ const [column, direction, ...rest] = term.split(/\s+/);
42
+ const dir = String(direction ?? "").toUpperCase();
43
+ if (rest.length > 0 || (direction !== undefined && dir !== "ASC" && dir !== "DESC")) {
44
+ assertIdentifier(term, "orderBy term");
45
+ }
46
+ assertIdentifier(column, "orderBy column");
47
+ return direction === undefined ? column : `${column} ${dir}`;
48
+ });
49
+ return ` ORDER BY ${[...terms, PRIMARY_KEY_COLUMN].join(", ")}`;
50
+ };
28
51
 
29
52
  /*
30
53
  * The two reads the inference needs, defined ONCE and shared by both entrances (apiRoute.js and
@@ -41,16 +64,29 @@ export const createReaders = (exec, dbIndex, tenantIds) => {
41
64
  dangerous half of the filter, not the cosmetic one. Unfiltered, another tenant's rows are drawn
42
65
  into the comparison; filtered here while the write stays unfiltered, rows the caller cannot see
43
66
  are inferred `Deleted` and destroyed. Both halves move together or neither does. */
67
+ const hideDeleted = (table) => {
68
+ const predicate = softDeleteFilter(table, dbIndex);
69
+ return predicate ? ` AND ${predicate}` : "";
70
+ };
44
71
  const scope = (table) => {
45
72
  const predicate = tenantFilter(table, dbIndex, tenantIds);
46
73
  return predicate ? ` AND ${predicate}` : "";
47
74
  };
48
75
  return {
49
- readRows: async (table, fkColumn, parentKeyValue) =>
50
- (await exec(
51
- `SELECT * FROM ${table} WHERE ${fkColumn} = ${renderValue(parentKeyValue)}${scope(table)}`,
52
- dbIndex,
53
- )) ?? [],
76
+ /* ⚠️ Soft-deleted rows are EXCLUDED here, and the reason is not tidiness. Deletion is inferred from
77
+ absence, and the display read already hides soft-deleted rows (Doc 3 section 6.6 rule 2), so no
78
+ client ever sends them back. Included here, every one of them was "missing", therefore Deleted,
79
+ therefore stamped again on EVERY save - overwriting who deleted it and when. The load and the
80
+ comparison must see the same rows. tests/orm-soft-delete-resave.test.js. */
81
+ readRows: async (table, fkColumn, parentKeyValue, orderBy = null) => {
82
+ const order = renderOrderBy(orderBy);
83
+ return (
84
+ (await exec(
85
+ `SELECT * FROM ${table} WHERE ${fkColumn} = ${renderValue(parentKeyValue)}${scope(table)}${hideDeleted(table)}${order}`,
86
+ dbIndex,
87
+ )) ?? []
88
+ );
89
+ },
54
90
  /* ⚠️ `xmin` is named explicitly because it is a SYSTEM column and `SELECT *` does not include it.
55
91
  Without it the §6.7 comparison reads `undefined` on every save and silently never conflicts —
56
92
  a security-blanket failure that looks exactly like the feature working. */
@@ -219,7 +255,8 @@ const planNested = async ({ parentTable, parentRow, parentPlan, readRows, dbInde
219
255
  const collection = await planCollection({
220
256
  table,
221
257
  rows,
222
- fkColumn: foreignKeyColumn(parentTable),
258
+ /* Declared first, the convention otherwise - the SAME resolver the graph read uses. */
259
+ fkColumn: foreignKeyFor(parentTable, table, dbIndex),
223
260
  parentKeyValue,
224
261
  readRows,
225
262
  });
@@ -231,6 +268,9 @@ const planNested = async ({ parentTable, parentRow, parentPlan, readRows, dbInde
231
268
  parentRow: rows[index],
232
269
  parentPlan: rowPlan,
233
270
  readRows,
271
+ /* ⚠️ Passed down. Dropped here, every level below the first looked up an unprimed index,
272
+ so a SHARED subdetail was never refused. tests/orm-v2-foreign-key.test.js. */
273
+ dbIndex,
234
274
  });
235
275
  }
236
276
  }
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Doc 2 section 5.5, ruling 5 (2026-09-26) - an INCOMPLETE detail collection is rejected, never read as
3
+ * deletions.
4
+ *
5
+ * Deletion is inferred from a row's absence (Doc 3 section 6.2), and the ORM cannot tell a partially
6
+ * loaded collection from "delete all but these". Doc 3 made completeness the application's
7
+ * responsibility; the application meets it by MARKING what it loaded partially, on the parent row:
8
+ *
9
+ * { st_order: { id: 7, _incomplete: ["st_order_line"], st_order_line: [ ...one page... ] } }
10
+ *
11
+ * and this refuses a save that carries a marked collection. A marked collection that is ABSENT is fine
12
+ * - absent already means "leave alone". tests/orm-incomplete-collection.test.js.
13
+ */
14
+ export const INCOMPLETE_KEY = "_incomplete";
15
+ export const INCOMPLETE_COLLECTION_CODE = "INCOMPLETE_COLLECTION";
16
+
17
+ const isIncompleteKey = (key) => String(key).toLowerCase() === INCOMPLETE_KEY;
18
+
19
+ const refuse = (message) => Object.assign(new Error(message), { code: INCOMPLETE_COLLECTION_CODE });
20
+
21
+ /* An unreadable mark refuses: it is not proof that the collections are complete. */
22
+ const markedTables = (mark) => {
23
+ if (!Array.isArray(mark) || !mark.every((t) => typeof t === "string" && t.trim() !== "")) {
24
+ throw refuse(`${INCOMPLETE_KEY} must be a list of detail table names`);
25
+ }
26
+ return mark.map((t) => t.trim().toLowerCase());
27
+ };
28
+
29
+ const checkRow = (row) => {
30
+ const out = {};
31
+ let marked = [];
32
+ for (const [key, value] of Object.entries(row)) {
33
+ if (isIncompleteKey(key)) {
34
+ marked = markedTables(value);
35
+ continue;
36
+ }
37
+ out[key] = check(value);
38
+ }
39
+ for (const table of marked) {
40
+ const present = Object.keys(row).find((key) => key.toLowerCase() === table && Array.isArray(row[key]));
41
+ if (present) {
42
+ throw refuse(
43
+ `${present} was loaded incompletely and cannot be saved: its missing rows would be read as deletions. ` +
44
+ "Load the whole collection, or leave it out of the save.",
45
+ );
46
+ }
47
+ }
48
+ return out;
49
+ };
50
+
51
+ const check = (value) => {
52
+ if (Array.isArray(value)) return value.map(check);
53
+ if (value === null || typeof value !== "object" || value instanceof Date) return value;
54
+ return checkRow(value);
55
+ };
56
+
57
+ /*
58
+ * Throw if any row, at any depth, marks a collection it also carries; otherwise return a copy with every
59
+ * mark removed. ⚠️ The strip is not tidiness: masterDetail.js scalarEntries turns every non-detail key
60
+ * into a column, so an unstripped mark would become `INSERT INTO ... (_incomplete)`.
61
+ */
62
+ export const assertCompleteCollections = (object) => check(object);
@@ -0,0 +1,28 @@
1
+ /*
2
+ * Doc 2 section 2 - what a template config's `version` means to the ORM.
3
+ *
4
+ * Absent means 1 (section 2.1 rule 4). 2 OR HIGHER selects the new behaviour (rule 2) - today that is
5
+ * change inference in place of delete-and-replace (Doc 3 section 6.1).
6
+ *
7
+ * WARNING THE CLIENT HAS A TWIN OF THIS (client/src/app/services/config-version.ts) and the two must
8
+ * never disagree - a screen would render as v2 and persist as v1, each side correct on its own. Both
9
+ * are held to shared/domain/config-version-cases.json; change the rule there, and both sides fail
10
+ * until both agree.
11
+ *
12
+ * WARNING THIS IS NOT metadata.version. That key is a required semver identifying the domain package
13
+ * ("0.1.0"), and it reads here as undeclared, not as 0.1.
14
+ */
15
+
16
+ /* Anything that is not a finite number, or a string holding one, was never a version. */
17
+ export const configVersion = (value) => {
18
+ if (typeof value === "number") return Number.isFinite(value) ? value : 1;
19
+ if (typeof value === "string") {
20
+ const trimmed = value.trim();
21
+ if (trimmed === "") return 1;
22
+ const parsed = Number(trimmed);
23
+ return Number.isFinite(parsed) ? parsed : 1;
24
+ }
25
+ return 1;
26
+ };
27
+
28
+ export const isConfigV2 = (value) => configVersion(value) >= 2;
@@ -5,6 +5,7 @@
5
5
  * string becomes a quoted literal, a JSON number stays bare, JSON null becomes `null`. Callers
6
6
  * quote their own values (the same contract db/ds.js already documents for filter values).
7
7
  */
8
+ import { assertQualifiedIdentifier, assertKeyLiteral, assertSqlFragment, assertPayloadIdentifiers } from "./sqlGuard.js";
8
9
  import { buildSequenceNextValue, renderTimestamp } from "./dialect.js";
9
10
  import { buildOrderClause } from "./listQuery.js";
10
11
  import { projectRowVersion } from "./concurrency.js";
@@ -76,8 +77,9 @@ const renderValue = (value, isBoolean = false) => {
76
77
  /* `get` and `delete` interpolate the value VERBATIM — the caller quotes its own, which is the
77
78
  contract orm.pas set and db/ds.js documents (`cuti.id=-1`, unquoted). A Date has no verbatim
78
79
  form that PostgreSQL accepts, so it alone is rendered; everything else is untouched. */
80
+ /* Verbatim by contract (a GUID arrives pre-quoted) - but only ever ONE literal. sqlGuard.js. */
79
81
  const renderKeyValue = (value) =>
80
- value instanceof Date ? `'${renderTimestamp(value)}'` : `${value}`;
82
+ value instanceof Date ? `'${renderTimestamp(value)}'` : `${assertKeyLiteral(value, "key value")}`;
81
83
 
82
84
  const entriesOf = (row) => Object.entries(row ?? {});
83
85
 
@@ -123,13 +125,16 @@ const buildDelete = (tableName, row, dbIndex, deletedBy, tenantIds) => {
123
125
 
124
126
  /* A row whose primary key is null is unambiguously new -> a plain INSERT with the key omitted, so
125
127
  the sequence/GUID default supplies it. Otherwise an upsert on the primary key. */
126
- const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
128
+ const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext, returningKey = false) => {
127
129
  /* Doc 3 §11 — resolve the tenant ONCE for the whole statement: the INSERT branches stamp it and
128
130
  the UPDATE branch is constrained by it. Null when there is nothing to enforce, in which case
129
131
  every branch below emits exactly what it emitted before tenancy existed. */
130
132
  const rawEntries = entriesOf(row);
131
133
  const primaryKeyRaw = rawEntries.find(([column]) => column.toLowerCase() === PRIMARY_KEY_COLUMN);
132
- const isNew = Boolean(primaryKeyRaw) && (primaryKeyRaw[1] === null || primaryKeyRaw[1] === undefined);
134
+ /* ⚠️ A row with NO key column is new too - there is nothing to upsert on. It used to fall into the
135
+ upsert branch and crash on the missing key (a 500); a v2 master saved new with no collections sends
136
+ exactly that. tests/orm-keyless-insert.test.js. */
137
+ const isNew = !primaryKeyRaw || primaryKeyRaw[1] === null || primaryKeyRaw[1] === undefined;
133
138
  /* ⚠️ `isNew` decides whether an absent tenant is fatal. A keyed row is an UPDATE as far as this
134
139
  statement is concerned; the case where it turns out NOT to exist yet is caught by the async
135
140
  authority check in index.js, which is the only layer that can know. */
@@ -154,9 +159,11 @@ const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
154
159
 
155
160
  if (isNew) {
156
161
  const insertable = entries.filter(([column]) => column.toLowerCase() !== PRIMARY_KEY_COLUMN);
162
+ /* v2 hands a new master its key back, as the master-detail path does - without it the client reloads
163
+ the record as NEW and a second save inserts a duplicate. tests/orm-keyless-insert.test.js. */
157
164
  return `INSERT INTO ${tableName} (${insertable.map(([c]) => c).join(", ")}) VALUES (${insertable
158
165
  .map(([c, v]) => renderValue(v, isBool(c)))
159
- .join(", ")})`;
166
+ .join(", ")})${returningKey ? ` RETURNING ${PRIMARY_KEY_COLUMN}` : ""}`;
160
167
  }
161
168
 
162
169
  /* UPDATE first, INSERT only if it matched nothing.
@@ -210,7 +217,9 @@ const buildUpsert = (tableName, row, dbIndex, tenantIds, tenantContext) => {
210
217
  keep working. TestORM.pas TestPutArray. */
211
218
  export const STATEMENT_SEPARATOR = ";eof ";
212
219
 
213
- export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tenantContext) => {
220
+ export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tenantContext, { returningKey = false } = {}) => {
221
+ /* Every table and column NAME the caller supplied, before any of them reaches SQL text. */
222
+ assertPayloadIdentifiers(object);
214
223
  const [tableName, payload] = Object.entries(object ?? {})[0] ?? [];
215
224
  if (!tableName) {
216
225
  return "";
@@ -239,7 +248,7 @@ export const buildCrudSql = (method, object, dbIndex, deletedBy, tenantIds, tena
239
248
  .map((row) => buildUpsert(tableName, row, dbIndex, tenantIds, tenantContext))
240
249
  .join(STATEMENT_SEPARATOR);
241
250
  }
242
- return buildUpsert(tableName, payload, dbIndex, tenantIds, tenantContext);
251
+ return buildUpsert(tableName, payload, dbIndex, tenantIds, tenantContext, returningKey);
243
252
  };
244
253
 
245
254
  /* orm.pas:1695-1745 (lookupToSQLCustom). Both forms honour `lookup.filter`; only the page form
@@ -251,7 +260,9 @@ const buildLookupWhere = (lookup) => {
251
260
  if (filter === undefined || filter === null || String(filter) === "null" || String(filter) === "") {
252
261
  return "";
253
262
  }
254
- return ` where ${filter}`;
263
+ /* A raw where string by contract; refused only for structure that would escape the parentheses
264
+ the platform wraps it in (sqlGuard.assertSqlFragment). */
265
+ return ` where ${assertSqlFragment(filter, "lookup filter")}`;
255
266
  };
256
267
 
257
268
  /*
@@ -272,6 +283,7 @@ const lookupWhereWithSoftDelete = (lookup, dbIndex, tenantIds) => {
272
283
  };
273
284
 
274
285
  export const buildLookupSql = (lookup, dbIndex, tenantIds) => {
286
+ assertLookupShape(lookup);
275
287
  const order = Array.isArray(lookup?.order) && lookup.order.length > 0
276
288
  ? buildOrderClause(lookup.order).replace(/^ ORDER BY /, " order by ")
277
289
  : " order by 2";
@@ -282,7 +294,16 @@ export const buildLookupSql = (lookup, dbIndex, tenantIds) => {
282
294
  );
283
295
  };
284
296
 
285
- export const buildLookupTotalSql = (lookup, dbIndex, tenantIds) =>
286
- `select count(*) total from ${lookup.table}${lookupWhereWithSoftDelete(lookup, dbIndex, tenantIds)}`;
297
+ /* key and display may be expressions (`name || ' - ' || code`); the table may not. */
298
+ const assertLookupShape = (lookup) => {
299
+ assertQualifiedIdentifier(lookup?.table, "lookup table");
300
+ assertSqlFragment(lookup?.key, "lookup key");
301
+ assertSqlFragment(lookup?.display, "lookup display");
302
+ };
303
+
304
+ export const buildLookupTotalSql = (lookup, dbIndex, tenantIds) => {
305
+ assertLookupShape(lookup);
306
+ return `select count(*) total from ${lookup.table}${lookupWhereWithSoftDelete(lookup, dbIndex, tenantIds)}`;
307
+ };
287
308
 
288
309
  export const buildGenIdSql = (sequenceName) => buildSequenceNextValue(sequenceName);
@@ -125,6 +125,111 @@ export const buildRelationshipMap = (parsedDomainConfigs = []) => {
125
125
  return map;
126
126
  };
127
127
 
128
+ /*
129
+ * The FK a one-to-many relationship DECLARES, keyed parentTable|childTable.
130
+ *
131
+ * ⚠️ Entity keys are resolved to their TABLES here. The ownership map above keys on the raw names,
132
+ * which works only because real configs happen to name entities after their tables.
133
+ */
134
+ export const buildForeignKeyMap = (parsedDomainConfigs = []) => {
135
+ const map = new Map();
136
+ for (const parsed of parsedDomainConfigs) {
137
+ const entities = parsed?.model?.entities ?? {};
138
+ const relationships = parsed?.model?.relationships;
139
+ if (!relationships || typeof relationships !== "object") continue;
140
+ const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
141
+ for (const relationship of Object.values(relationships)) {
142
+ const foreignKey = String(relationship?.foreignKey ?? "").trim();
143
+ const from = tableOf(relationship?.from);
144
+ const to = tableOf(relationship?.to);
145
+ if (!foreignKey || !from || !to) continue;
146
+ map.set(`${from}|${to}`, foreignKey);
147
+ }
148
+ }
149
+ return map;
150
+ };
151
+
152
+ /*
153
+ * The ORDER a one-to-many relationship DECLARES for its rows (`orderBy: 'sequence_no'`), keyed
154
+ * parentTable|childTable like the FK map and resolved to tables the same way. Doc 2 section 5: a
155
+ * v2 screen shows a collection in the order the graph read returns it, and nothing else orders it.
156
+ */
157
+ export const buildOrderByMap = (parsedDomainConfigs = []) => {
158
+ const map = new Map();
159
+ for (const parsed of parsedDomainConfigs) {
160
+ const entities = parsed?.model?.entities ?? {};
161
+ const relationships = parsed?.model?.relationships;
162
+ if (!relationships || typeof relationships !== "object") continue;
163
+ const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
164
+ for (const relationship of Object.values(relationships)) {
165
+ const orderBy = String(relationship?.orderBy ?? "").trim();
166
+ const from = tableOf(relationship?.from);
167
+ const to = tableOf(relationship?.to);
168
+ if (!orderBy || !from || !to) continue;
169
+ map.set(`${from}|${to}`, orderBy);
170
+ }
171
+ }
172
+ return map;
173
+ };
174
+
175
+ /* The declared order of a parent's collection, or null. Unchecked here - readRows checks it as
176
+ identifiers before it reaches SQL (sqlGuard.js). */
177
+ /*
178
+ * Doc 2 section 5.2 item 5 - the OWNED children of each table: one-to-many relationships declared
179
+ * ownership: parent, keyed by parent TABLE, each child resolved to its table, in declaration order.
180
+ * A v2 delete cascades through exactly these (masterDetail.buildGraphDeleteSql) - never through a
181
+ * shared or independent relationship, whose rows only REFERENCE the parent.
182
+ */
183
+ export const buildOwnedChildrenMap = (parsedDomainConfigs = []) => {
184
+ const map = new Map();
185
+ for (const parsed of parsedDomainConfigs) {
186
+ const entities = parsed?.model?.entities ?? {};
187
+ const relationships = parsed?.model?.relationships;
188
+ if (!relationships || typeof relationships !== "object") continue;
189
+ const tableOf = (name) => String(entities?.[name]?.table ?? name ?? "").trim().toLowerCase();
190
+ for (const relationship of Object.values(relationships)) {
191
+ if (String(relationship?.type ?? "").trim().toLowerCase() !== "one-to-many") continue;
192
+ if (String(relationship?.ownership ?? "").trim().toLowerCase() !== "parent") continue;
193
+ const from = tableOf(relationship?.from);
194
+ const to = tableOf(relationship?.to);
195
+ if (!from || !to) continue;
196
+ const children = map.get(from) ?? [];
197
+ if (!children.includes(to)) children.push(to);
198
+ map.set(from, children);
199
+ }
200
+ }
201
+ return map;
202
+ };
203
+
204
+ /* A table's owned children with the FK each is found by - the SAME foreignKeyFor the graph load and
205
+ save use, so a delete can never correlate through a column the save does not. */
206
+ export const ownedChildrenOf = (parentTable, dbIndex) =>
207
+ (byDbIndex.get(Number(dbIndex))?.ownedChildren?.get(String(parentTable ?? "").trim().toLowerCase()) ?? []).map(
208
+ (table) => ({ table, foreignKey: foreignKeyFor(parentTable, table, dbIndex) }),
209
+ );
210
+
211
+ export const orderByFor = (parentTable, childTable, dbIndex) =>
212
+ byDbIndex
213
+ .get(Number(dbIndex))
214
+ ?.orderBy?.get(
215
+ `${String(parentTable ?? "").trim().toLowerCase()}|${String(childTable ?? "").trim().toLowerCase()}`,
216
+ ) ?? null;
217
+
218
+ /*
219
+ * ⚠️⚠️ THE FK A v2 GRAPH USES - load (graphRead.js) AND save (changeInference.js), through this one
220
+ * function so the two can never disagree. Declared first; the orm.pas `<parent>_ID` convention only
221
+ * when nothing is declared. It must be declared-first because the convention cannot always hold:
222
+ * Supertail's rel_item_add_info_options uses st_add_info_tmpl_det_id, since the conventional name is
223
+ * one character over Firebird 2.5's 31-character limit. v1 (Delphi parity) keeps the convention.
224
+ * The convention is spelled exactly as masterDetail.foreignKeyColumn spells it (fenced in the test).
225
+ */
226
+ export const foreignKeyFor = (parentTable, childTable, dbIndex) =>
227
+ byDbIndex
228
+ .get(Number(dbIndex))
229
+ ?.foreignKeys?.get(
230
+ `${String(parentTable ?? "").trim().toLowerCase()}|${String(childTable ?? "").trim().toLowerCase()}`,
231
+ ) ?? `${parentTable}_ID`;
232
+
128
233
  /*
129
234
  * Doc 3 §11 — which tables are scoped by a tenant, and by which column.
130
235
  *
@@ -238,9 +343,12 @@ export const primeEntityConfig = async ({ dbIndex, loadDomainConfigs }) => {
238
343
  const configs = await loadDomainConfigs();
239
344
  const map = buildEntityMap(configs);
240
345
  const relationships = buildRelationshipMap(configs);
346
+ const foreignKeys = buildForeignKeyMap(configs);
347
+ const orderBy = buildOrderByMap(configs);
348
+ const ownedChildren = buildOwnedChildrenMap(configs);
241
349
  const tenancy = buildTenancyMap(configs);
242
350
  const booleanColumns = buildBooleanColumnMap(configs);
243
- byDbIndex.set(Number(dbIndex), { entities: map, relationships, tenancy, booleanColumns });
351
+ byDbIndex.set(Number(dbIndex), { entities: map, relationships, foreignKeys, orderBy, ownedChildren, tenancy, booleanColumns });
244
352
 
245
353
  /* ⚠️ Report the COUNT, not just success. "Everything is hard" is the correct answer when no
246
354
  entity declares `soft`, and it is also exactly what a silently empty map returns — the two
@@ -0,0 +1,94 @@
1
+ /*
2
+ * Doc 2 section 5.2 item 1 - load a master WITH its details and subdetails, in one request.
3
+ *
4
+ * The request is the shape the save already takes, collections left empty to mean "include":
5
+ *
6
+ * get, version 2, { st_customer: { id: "'{C1}'", name: "", st_customer_identity: [] } }
7
+ *
8
+ * and a subdetail is named inside the collection's first row. The master is read by the ordinary
9
+ * `get` (so its projection, soft-delete and tenant scoping and its section 6.7 concurrency token are
10
+ * exactly what they always were); only the collections are new.
11
+ *
12
+ * ⚠️⚠️ EVERY COLLECTION IS READ THROUGH THE SAVE'S OWN COMPARISON READER (changeInference
13
+ * createReaders.readRows) - passed in, never re-implemented. Deletion on save is inferred from
14
+ * absence (Doc 3 section 6.2), so if the load and the comparison ever see different rows, the next
15
+ * save deletes the difference silently. Sharing the reader makes them the same set by construction:
16
+ * tenant scope, soft delete, and anything added to that reader later.
17
+ *
18
+ * ⚠️ The FK is found the way the save finds it - entityConfig.foreignKeyFor, the declared foreignKey
19
+ * first and the orm.pas `<parent>_ID` convention otherwise. A load that followed a different rule
20
+ * from the save would be the same drift by another route.
21
+ */
22
+ import { detailEntries } from "./masterDetail.js";
23
+ import { PRIMARY_KEY_COLUMN } from "./crudQuery.js";
24
+ import { assertOwnedCollection, foreignKeyFor, orderByFor } from "./entityConfig.js";
25
+ import { isConfigV2 } from "./configVersion.js";
26
+
27
+ export const GRAPH_READ_KEY_REQUIRED_CODE = "GRAPH_READ_KEY_REQUIRED";
28
+
29
+ /* A get at version 2 that names at least one collection, and is not the Delphi `getd` (`detail` key). */
30
+ export const isGraphRead = ({ method, version, object }) =>
31
+ String(method ?? "").toLowerCase() === "get" &&
32
+ isConfigV2(version) &&
33
+ object?.detail === undefined &&
34
+ Object.entries(object ?? {}).some(([, payload]) =>
35
+ (Array.isArray(payload) ? payload : [payload]).some((row) => detailEntries(row).length > 0),
36
+ );
37
+
38
+ const keyOf = (row) => {
39
+ const column = Object.keys(row ?? {}).find((k) => k.toLowerCase() === PRIMARY_KEY_COLUMN);
40
+ return column === undefined ? undefined : row[column];
41
+ };
42
+
43
+ const templateOf = (rows) =>
44
+ Array.isArray(rows) && rows[0] && typeof rows[0] === "object" ? rows[0] : {};
45
+
46
+ /*
47
+ * Refuse the WHOLE shape before anything is read. The save will not manage a shared collection
48
+ * (Doc 3 section 6.3); a load that handed one out would give a screen a graph it can never save.
49
+ */
50
+ const assertShape = (parentTable, templateRow, dbIndex) => {
51
+ for (const [table, rows] of detailEntries(templateRow)) {
52
+ assertOwnedCollection(parentTable, table, dbIndex);
53
+ assertShape(table, templateOf(rows), dbIndex);
54
+ }
55
+ };
56
+
57
+ /*
58
+ * A graph read is the load of ONE record. Without a key the master read is every row, and each
59
+ * collection would multiply that - so it is refused by name instead of issuing N+1 reads.
60
+ */
61
+ export const assertGraphRequest = (object, dbIndex) => {
62
+ for (const [table, payload] of Object.entries(object ?? {})) {
63
+ if (table === "detail") continue;
64
+ for (const row of Array.isArray(payload) ? payload : [payload]) {
65
+ const key = keyOf(row);
66
+ if (key === undefined || key === null || String(key).trim() === "") {
67
+ throw Object.assign(
68
+ new Error(`a graph read of ${table} needs its key - it loads one record, with its collections`),
69
+ { code: GRAPH_READ_KEY_REQUIRED_CODE },
70
+ );
71
+ }
72
+ assertShape(table, row, dbIndex);
73
+ }
74
+ }
75
+ };
76
+
77
+ const attach = async (parentTable, dbRow, templateRow, readRows, dbIndex) => {
78
+ for (const [table, templateRows] of detailEntries(templateRow)) {
79
+ const key = keyOf(dbRow);
80
+ const rows =
81
+ key === undefined || key === null ? [] : (await readRows(table, foreignKeyFor(parentTable, table, dbIndex), key, orderByFor(parentTable, table, dbIndex))) ?? [];
82
+ for (const row of rows) await attach(table, row, templateOf(templateRows), readRows, dbIndex);
83
+ dbRow[table] = rows;
84
+ }
85
+ return dbRow;
86
+ };
87
+
88
+ /* `masterRows` are what the ordinary get returned for the master table. Mutated and returned. */
89
+ export const attachCollections = async ({ object, masterRows, readRows, dbIndex }) => {
90
+ const [masterTable, payload] = Object.entries(object ?? {}).find(([key]) => key !== "detail") ?? [];
91
+ const template = Array.isArray(payload) ? templateOf(payload) : payload ?? {};
92
+ for (const row of masterRows ?? []) await attach(masterTable, row, template, readRows, dbIndex);
93
+ return masterRows;
94
+ };
@@ -20,9 +20,11 @@ import {
20
20
  buildGenIdSql,
21
21
  PRIMARY_KEY_COLUMN,
22
22
  } from "./crudQuery.js";
23
- import { buildMasterDetailChangeSql, hasDetailCollection } from "./masterDetail.js";
23
+ import { buildMasterDetailChangeSql, buildGraphDeleteSql, hasDetailCollection } from "./masterDetail.js";
24
24
  import { inferChanges } from "./changeInference.js";
25
+ import { isConfigV2 } from "./configVersion.js";
25
26
  import { checkRowVersion } from "./concurrency.js";
27
+ import { assertCompleteCollections } from "./completeness.js";
26
28
  import { tenantColumnFor, resolveWriteTenant, CROSS_TENANT_CODE } from "./entityConfig.js";
27
29
 
28
30
  const parseRequest = (param) => (typeof param === "string" ? JSON.parse(param) : (param ?? {}));
@@ -208,15 +210,30 @@ export const buildWriteSql = async ({
208
210
  "conflict" would also confirm to the caller that a row it may not see exists and has changed.
209
211
  This is also the only place a cross-tenant write gets a NAMED refusal: the SQL constraints
210
212
  downstream make such a write do nothing, which is safe but silent. */
211
- const tenantContext = await assertTenantAuthority({ object, dbIndex, tenantIds, readTenant });
213
+ /* Doc 2 section 5.5 ruling 5 - FIRST, before anything is read: a collection marked incomplete would
214
+ otherwise have its missing rows inferred as Deleted. Returns the payload with every mark stripped. */
215
+ const complete = assertCompleteCollections(object);
216
+
217
+ const tenantContext = await assertTenantAuthority({ object: complete, dbIndex, tenantIds, readTenant });
212
218
 
213
219
  /* Doc 3 §6.7 — reject a save whose data has moved since it was loaded, BEFORE any SQL is built,
214
220
  so a conflict writes nothing. This sits above the version split on purpose: the token is an
215
221
  opt-in property of the payload, not of the persistence semantics, so a v1 save made from a
216
222
  freshly-loaded form is protected too. A payload with no token reads nothing and is unchanged. */
217
- const checked = await checkRowVersion({ object, readMaster });
223
+ const checked = await checkRowVersion({ object: complete, readMaster });
224
+
225
+ /* ⚠️⚠️ `put` ONLY - change inference is the SAVE semantic. This used to admit every method except
226
+ `delete`, so a `get` at version 2 carrying an empty collection was planned as a save: blank master
227
+ fields became an UPDATE and the empty array meant every detail row was Deleted. A read that
228
+ deletes. Unreachable only until a client sent `version` on a get, which Doc 2 section 5 does.
229
+ tests/orm-graph-read.test.js. */
230
+ /* Doc 2 section 5.2 item 5 - a v2 delete cascades by CONFIG (ownership: parent, declared FKs), not by
231
+ what the client names. v1 delete stays one row. tests/orm-v2-delete.test.js. */
232
+ if (isConfigV2(version) && String(method ?? "").toLowerCase() === "delete") {
233
+ return buildGraphDeleteSql(checked, dbIndex, deletedBy, tenantIds);
234
+ }
218
235
 
219
- const wantsInference = Number(version) >= 2 && String(method ?? "").toLowerCase() !== "delete";
236
+ const wantsInference = isConfigV2(version) && String(method ?? "").toLowerCase() === "put";
220
237
  if (wantsInference && hasDetailCollection(checked)) {
221
238
  return buildMasterDetailChangeSql(
222
239
  await inferChanges({ object: checked, readRows, readMaster, dbIndex }),
@@ -229,7 +246,7 @@ export const buildWriteSql = async ({
229
246
  /* ⚠️ `tenantIds` reaches buildCrudSql here as well as through jsonToSql. This is the FOURTH place
230
247
  today that quietly dropped a parameter it never declared; the `get` path runs through here, so
231
248
  omitting it left reads unfiltered while every unit test that called buildCrudSql directly passed. */
232
- return buildCrudSql(method, checked, dbIndex, deletedBy, tenantIds, tenantContext);
249
+ return buildCrudSql(method, checked, dbIndex, deletedBy, tenantIds, tenantContext, { returningKey: isConfigV2(version) });
233
250
  };
234
251
 
235
252
  /* Delphi: TOrm.lookupToSql / lookupTotalToSql (Doc 3 §5 item 1). */
@@ -3,6 +3,7 @@
3
3
  * orm.pas jsonToListSQL. Produces both the page query and the record-count query, matching the
4
4
  * Delphi TListRecord (QuerySQL / recordsTotalSQL).
5
5
  */
6
+ import { assertSqlFragment, assertSearchLiteral, normaliseFilterOperator } from "./sqlGuard.js";
6
7
  import { buildPagingClause } from "./dialect.js";
7
8
  import { isPostgresIndex } from "../domain/dialect.js";
8
9
  import { softDeleteFilter, tenantFilter, isBooleanColumn } from "./entityConfig.js";
@@ -176,9 +177,9 @@ const buildCondition = (filter, registry, dbIndex) => {
176
177
  }
177
178
 
178
179
  const junction = String(filter?.junction ?? "");
179
- const rawColumn = String(filter?.column ?? "");
180
+ const rawColumn = String(assertSqlFragment(filter?.column ?? "", "filter column"));
180
181
  const column = rawColumn === "" || isRawExpression(rawColumn) ? rawColumn : registry.resolve(rawColumn) ?? rawColumn;
181
- const operator = String(filter?.operator ?? "");
182
+ const operator = String(normaliseFilterOperator(filter?.operator ?? ""));
182
183
 
183
184
  const rendered = `${junction ? ` ${junction}` : ""} ${column} ${operator}`;
184
185
 
@@ -192,13 +193,13 @@ const buildCondition = (filter, registry, dbIndex) => {
192
193
  const lowered = operator.toLowerCase().replace(/\s+/g, " ").trim();
193
194
  if ((lowered === CONTAINING || lowered === NOT_CONTAINING) && isPostgresIndex(dbIndex)) {
194
195
  const rendered2 = `${junction ? ` ${junction}` : ""} ${column} ${lowered === NOT_CONTAINING ? "NOT ILIKE" : "ILIKE"}`;
195
- return `${rendered2} ${toContainingPattern(filter.value1)}`;
196
+ return `${rendered2} ${toContainingPattern(assertSearchLiteral(filter.value1, "containing value"))}`;
196
197
  }
197
198
 
198
199
  if (operator.toLowerCase() === "between") {
199
- return `${rendered} ${filter.value1} AND ${filter.value2}`;
200
+ return `${rendered} ${assertSqlFragment(filter.value1, "value1")} AND ${assertSqlFragment(filter.value2, "value2")}`;
200
201
  }
201
- return `${rendered} ${coerceBooleanFilterValue(rawColumn, filter.value1, dbIndex)}`;
202
+ return `${rendered} ${coerceBooleanFilterValue(rawColumn, assertSqlFragment(filter.value1, "value1"), dbIndex)}`;
202
203
  };
203
204
 
204
205
  const buildWhereClause = (filters, registry, dbIndex) => {
@@ -215,7 +216,7 @@ export const buildOrderClause = (orders) => {
215
216
  const parts = (Array.isArray(orders) ? orders : []).map((order) => {
216
217
  const dir = String(order?.dir ?? "").toLowerCase();
217
218
  const direction = dir === "asc" || dir === "desc" ? ` ${dir.toUpperCase()}` : "";
218
- return `${order?.column ?? ""}${direction}`;
219
+ return `${assertSqlFragment(order?.column ?? "", "order column")}${direction}`;
219
220
  });
220
221
  return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
221
222
  };
@@ -265,7 +266,10 @@ export const buildListSql = (object = {}, dbIndex, tenantIds) => {
265
266
  extra.length === 0
266
267
  ? baseWhere
267
268
  : baseWhere
268
- ? `${baseWhere} AND ${extra.join(" AND ")}`
269
+ /* ⚠️⚠️ The caller's conditions are PARENTHESISED before the platform's predicates. Appended
270
+ bare, an ordinary OR filter (`a = 1 or a = 2`) bound tighter than nothing and returned
271
+ soft-deleted rows - no attacker needed. Lookup already did this. tests/orm-sql-guard. */
272
+ ? ` WHERE (${baseWhere.replace(/^ WHERE */, "")}) AND ${extra.join(" AND ")}`
269
273
  : ` WHERE ${extra.join(" AND ")}`;
270
274
  const from = ` FROM ${registry.baseTable}${registry.renderJoins()}`;
271
275
  /* orm.pas:1504 compares case-INsensitively (CompareText), and 1521-1523 sits outside the
@@ -20,6 +20,7 @@
20
20
  * discarded. The FK is always the parent's real id, threaded from the parent CTE — never a value
21
21
  * the payload asserts.
22
22
  */
23
+ import { assertKeyLiteral } from "./sqlGuard.js";
23
24
  import { PRIMARY_KEY_COLUMN, STATEMENT_SEPARATOR } from "./crudQuery.js";
24
25
  import {
25
26
  softDeleteFilter,
@@ -30,6 +31,7 @@ import {
30
31
  stampTenant,
31
32
  withTenant,
32
33
  isBooleanColumn,
34
+ ownedChildrenOf,
33
35
  } from "./entityConfig.js";
34
36
  import { renderTimestamp } from "./dialect.js";
35
37
 
@@ -342,7 +344,10 @@ const collectAddedSubtree = (rowPlan, tableName, parentRef, ctx) => {
342
344
  const plainInsert = (rowPlan, tableName, parentRef, ctx) => {
343
345
  const { columnList, values } = insertColumns(rowPlan, parentRef, tableName, ctx);
344
346
  const valueList = [...values, ...(parentRef.fkColumn ? [parentRef.keyValue] : [])].join(", ");
345
- return `INSERT INTO ${tableName} (${columnList}) VALUES (${valueList})`;
347
+ /* A MASTER returns its key: the caller has to be able to find the record it just created (Doc 2
348
+ section 5 reloads it). A detail stays plain - nothing downstream needs its key back. */
349
+ const returning = parentRef.fkColumn ? "" : ` RETURNING ${PRIMARY_KEY_COLUMN}`;
350
+ return `INSERT INTO ${tableName} (${columnList}) VALUES (${valueList})${returning}`;
346
351
  };
347
352
 
348
353
  const hasAddedDescendants = (rowPlan) =>
@@ -416,8 +421,11 @@ const walkPlan = (rowPlan, tableName, parentRef, out) => {
416
421
  const softDeleteOrDelete = (table, where, dbIndex, deletedBy, tenantIds) => {
417
422
  const scopeTenant = tenantFilter(table, dbIndex, tenantIds);
418
423
  const scoped = scopeTenant ? `${where} AND ${scopeTenant}` : where;
424
+ /* ⚠️ A soft delete touches only LIVE rows. Without this, deleting an already-deleted row stamped it
425
+ again - `deleted_at` and `deleted_by` rewritten to now and to whoever asked, on every v2 save and
426
+ every repeated deletemd. Soft delete exists to keep that record; this is what keeps it. */
419
427
  return isSoftDelete(table, dbIndex)
420
- ? `UPDATE ${table} SET ${buildSoftDeleteSet(deletedBy)} WHERE ${scoped}`
428
+ ? `UPDATE ${table} SET ${buildSoftDeleteSet(deletedBy)} WHERE ${scoped} AND ${softDeleteFilter(table, dbIndex)}`
421
429
  : `DELETE FROM ${table} WHERE ${scoped}`;
422
430
  };
423
431
 
@@ -440,6 +448,37 @@ export const buildMasterDetailChangeSql = (plan, dbIndex, deletedBy, tenantIds,
440
448
  return [...out.deletes, ...out.updates, ...out.inserts].join(STATEMENT_SEPARATOR);
441
449
  };
442
450
 
451
+ /*
452
+ * Doc 2 section 5.2 item 5 - a v2 DELETE of a master, cascading by CONFIG.
453
+ *
454
+ * Unlike deletemd, the client names nothing: the cascade is every ownership:parent relationship at any
455
+ * depth (entityConfig.ownedChildrenOf), through the DECLARED foreign keys. Deepest first, each table by
456
+ * its OWN strategy (softDeleteOrDelete - soft/hard and tenant scope, Doc 3 sections 6.6 and 11), the
457
+ * master last. A table already on the path is not re-entered, so an owned self-reference terminates.
458
+ * tests/orm-v2-delete.test.js.
459
+ */
460
+ export const buildGraphDeleteSql = (object, dbIndex, deletedBy, tenantIds) => {
461
+ const [masterTable, masterRow] = Object.entries(object ?? {})[0] ?? [];
462
+ const keyEntry = Object.entries(masterRow ?? {}).find(([column]) => isPrimaryKey(column));
463
+ if (!masterTable || !keyEntry || !hasRealKey(masterRow)) {
464
+ throw new Error(`a v2 delete of ${masterTable ?? "a table"} needs the key of the record to delete`);
465
+ }
466
+ const statements = [];
467
+ const walk = (parentTable, correlation, path) => {
468
+ for (const { table, foreignKey } of ownedChildrenOf(parentTable, dbIndex)) {
469
+ if (path.includes(table.toLowerCase())) continue;
470
+ const here = `${foreignKey} ${correlation}`;
471
+ walk(table, `IN (SELECT ${PRIMARY_KEY_COLUMN} FROM ${table} WHERE ${here})`, [...path, table.toLowerCase()]);
472
+ statements.push(softDeleteOrDelete(table, here, dbIndex, deletedBy, tenantIds));
473
+ }
474
+ };
475
+ walk(masterTable, `= ${renderValue(keyEntry[1])}`, [String(masterTable).toLowerCase()]);
476
+ statements.push(
477
+ softDeleteOrDelete(masterTable, `${PRIMARY_KEY_COLUMN} = ${renderValue(keyEntry[1])}`, dbIndex, deletedBy, tenantIds),
478
+ );
479
+ return statements.join(STATEMENT_SEPARATOR);
480
+ };
481
+
443
482
  /* deletemd — deepest detail first, each correlated to the master through its parent chain. */
444
483
  export const buildDeleteMasterDetailSql = (object, dbIndex, deletedBy, tenantIds) => {
445
484
  const [masterTable, masterRow] = Object.entries(object ?? {})[0] ?? [];
@@ -508,7 +547,8 @@ export const buildDetailSelectSql = (object, dbIndex, tenantIds) => {
508
547
  const columns = Object.keys(detailRow)
509
548
  .filter((column) => column.toLowerCase() !== masterKey.toLowerCase())
510
549
  .map((column) => `${detailTable}.${column}`);
511
- const masterValue = (masterRow ?? {})[masterValueKey];
550
+ /* Verbatim by contract, like every get key - so exactly one literal (sqlGuard.js). */
551
+ const masterValue = assertKeyLiteral((masterRow ?? {})[masterValueKey], `${masterTable}.${masterValueKey}`);
512
552
  /* Doc 3 §6.6 rule 2 — a soft-deleted detail must not reappear when its master is read. */
513
553
  const hideDeleted = softDeleteFilter(detailTable, dbIndex);
514
554
  /* Doc 3 §11 — and a detail belonging to another tenant must not reappear either. A detail is
@@ -0,0 +1,172 @@
1
+ /*
2
+ * What request input may reach the ORM's SQL text, and in what shape.
3
+ *
4
+ * The ported ORM keeps FINA's contract: table names, column names, key values and filter fragments
5
+ * arrive from the caller and are interpolated AS WRITTEN (crudQuery.renderKeyValue, listQuery
6
+ * buildCondition). A GUID key arrives pre-quoted; a filter value may be a subquery. That contract is
7
+ * kept - every function here passes a legitimate value through unchanged, byte for byte - and what it
8
+ * refuses is the STRUCTURE no legitimate value needs.
9
+ *
10
+ * ⚠️⚠️ WHAT THIS DEFENDS, HONESTLY. Any authenticated user can already run arbitrary SQL through the
11
+ * `block` endpoint - by design, relied on by the applications. So this is NOT a defence against a
12
+ * malicious logged-in user. It defends against DATA-BORNE injection: a value somebody else controlled
13
+ * (an external member, an import) flowing into a key or filter position, where whoever planted it
14
+ * cannot call `block`.
15
+ *
16
+ * Every refusal throws with `code: UNSAFE_SQL_CODE`, which apiRoute answers with a 400 naming the
17
+ * field - a caller can fix a request it can see is wrong; it cannot fix a bare 500.
18
+ * tests/orm-sql-guard.test.js pins every shape found in real application source.
19
+ */
20
+ export const UNSAFE_SQL_CODE = "UNSAFE_SQL";
21
+
22
+ const refuse = (what, value, why) =>
23
+ Object.assign(
24
+ new Error(`the ORM refuses ${what} ${JSON.stringify(String(value)).slice(0, 80)}: ${why}`),
25
+ { code: UNSAFE_SQL_CODE },
26
+ );
27
+
28
+ /* A plain SQL identifier. `$` stays legal - Firebird-era system tables are SYS$USERS, SYS$CONFIG. */
29
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
30
+
31
+ export const assertIdentifier = (name, what = "identifier") => {
32
+ const text = String(name ?? "");
33
+ if (!IDENTIFIER.test(text)) throw refuse(what, text, "not a plain SQL identifier");
34
+ return name;
35
+ };
36
+
37
+ /* `schema.table` - each part a plain identifier. The platform itself rewrites a Firebird catalog
38
+ lookup (RDB$RELATIONS) to information_schema.tables, so a lookup table may be qualified. */
39
+ export const assertQualifiedIdentifier = (name, what = "identifier") => {
40
+ const parts = String(name ?? "").split(".");
41
+ if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part))) {
42
+ throw refuse(what, name, "not a plain or schema-qualified SQL identifier");
43
+ }
44
+ return name;
45
+ };
46
+
47
+ /* Exactly one single-quoted SQL string, inner quotes doubled - the pre-quoted form the contract uses. */
48
+ const ONE_QUOTED_LITERAL = /^'(?:[^']|'')*'$/;
49
+ const NUMERIC = /^-?\d+(\.\d+)?$/;
50
+
51
+ /*
52
+ * A `get` / `delete` / `getd` key: interpolated verbatim by contract, so it must be ONE literal.
53
+ * Returns the value itself - the caller renders it exactly as before.
54
+ */
55
+ export const assertKeyLiteral = (value, what = "key") => {
56
+ if (typeof value === "number") {
57
+ if (!Number.isFinite(value)) throw refuse(what, value, "not a finite number");
58
+ return value;
59
+ }
60
+ if (typeof value === "boolean" || value instanceof Date) return value;
61
+ const text = String(value ?? "").trim();
62
+ if (NUMERIC.test(text) || ONE_QUOTED_LITERAL.test(text)) return value;
63
+ throw refuse(what, value, "a key value must be one literal - a number, or one quoted string");
64
+ };
65
+
66
+ /*
67
+ * A filter or order fragment. The filter language IS an expression language in production use
68
+ * (Trimandiri appends `1 AND NOT EXISTS (SELECT ...)` through value1; Supertail sends DATEADD(...)),
69
+ * so expressions pass. Refused, outside string literals only: a statement terminator, a comment, an
70
+ * unbalanced parenthesis, an unterminated string. None of those appears in any surveyed filter, and
71
+ * each is what turns a filter into something that escapes it - a second statement, a truncated tail
72
+ * that drops the tenant predicate, or a `)` closing the group the platform wraps conditions in.
73
+ */
74
+ export const assertSqlFragment = (value, what = "fragment") => {
75
+ if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") {
76
+ return value;
77
+ }
78
+ const text = String(value);
79
+ let depth = 0;
80
+ let quoted = false;
81
+ for (let i = 0; i < text.length; i += 1) {
82
+ const ch = text[i];
83
+ if (quoted) {
84
+ if (ch === "'") {
85
+ if (text[i + 1] === "'") {
86
+ i += 1;
87
+ continue;
88
+ }
89
+ quoted = false;
90
+ }
91
+ continue;
92
+ }
93
+ if (ch === "'") {
94
+ quoted = true;
95
+ continue;
96
+ }
97
+ if (ch === ";") throw refuse(what, text, "a statement terminator");
98
+ if (ch === "-" && text[i + 1] === "-") throw refuse(what, text, "a comment");
99
+ if (ch === "/" && text[i + 1] === "*") throw refuse(what, text, "a comment");
100
+ if (ch === "(") depth += 1;
101
+ if (ch === ")") {
102
+ depth -= 1;
103
+ if (depth < 0) throw refuse(what, text, "a parenthesis closed that was never opened");
104
+ }
105
+ }
106
+ if (quoted) throw refuse(what, text, "an unterminated string");
107
+ if (depth !== 0) throw refuse(what, text, "an unbalanced parenthesis");
108
+ return value;
109
+ };
110
+
111
+ /*
112
+ * A `containing` search value is always a SEARCH STRING, never an expression: listQuery unwraps its
113
+ * quotes and escapes wildcards but not quotes, so an embedded quote closed the pattern early. One
114
+ * quoted literal, or plain text with no quote at all.
115
+ */
116
+ export const assertSearchLiteral = (value, what = "search value") => {
117
+ if (value === null || value === undefined) return value;
118
+ const text = String(value).trim();
119
+ if (ONE_QUOTED_LITERAL.test(text) || !text.includes("'")) return value;
120
+ throw refuse(what, value, "a search value must be one quoted string");
121
+ };
122
+
123
+ /*
124
+ * Every operator found across biz-a-template (5,787 filters) and the client filter-list component,
125
+ * plus their natural complements. Compared lower-cased with whitespace collapsed, so "Not In" and
126
+ * "CONTAINING" are the operators they read as.
127
+ */
128
+ const OPERATORS = new Set([
129
+ "", "=", "<>", "!=", "<", "<=", ">", ">=",
130
+ "in", "not in", "like", "not like", "similar to", "not similar to",
131
+ "containing", "not containing", "starting", "starting with", "not starting", "not starting with",
132
+ "is", "is not", "is null", "is not null", "between", "not between", "exists", "not exists",
133
+ ]);
134
+
135
+ export const normaliseFilterOperator = (operator) => {
136
+ const text = String(operator ?? "").toLowerCase().replace(/\s+/g, " ").trim();
137
+ if (!OPERATORS.has(text)) throw refuse("filter operator", operator, "not a recognised operator");
138
+ return operator;
139
+ };
140
+
141
+ /*
142
+ * Every table and column NAME in a crud payload: the table keys, every row's keys, nested detail
143
+ * collections, and the `detail` spec of a getd. Values are not this function's business.
144
+ */
145
+ export const assertPayloadIdentifiers = (object) => {
146
+ for (const [table, payload] of Object.entries(object ?? {})) {
147
+ if (table === "detail") {
148
+ const detail = payload;
149
+ if (typeof detail === "string") assertIdentifier(detail, "detail table");
150
+ else if (detail && typeof detail === "object") {
151
+ /* Only when PRESENT: a missing key is buildDetailSelectSql's to name (the Delphi contract's
152
+ own error, TestORMMasterDetail.pas GetDetail_DetailObject_invalid*). */
153
+ if ("tableName" in detail) assertIdentifier(detail.tableName, "detail table");
154
+ if ("masterKey" in detail) assertIdentifier(detail.masterKey, "detail master key");
155
+ }
156
+ continue;
157
+ }
158
+ assertIdentifier(table, "table");
159
+ assertRowIdentifiers(table, payload);
160
+ }
161
+ };
162
+
163
+ const assertRowIdentifiers = (table, payload) => {
164
+ for (const row of Array.isArray(payload) ? payload : [payload]) {
165
+ if (!row || typeof row !== "object") continue;
166
+ for (const [column, value] of Object.entries(row)) {
167
+ /* `*` is the contract's "every column" - ormGet sends it, and the ESB kiosk path relies on it. */
168
+ if (column !== "*") assertIdentifier(column, `column of ${table}`);
169
+ if (Array.isArray(value)) assertRowIdentifiers(column, value);
170
+ }
171
+ }
172
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15372",
3
+ "version": "2.3.80-15375",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",