biz-a-cli 2.3.80-15367 → 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.
package/bin/app.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import fs from "fs";
package/bin/deleteApp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { logger } from "../logger.js";
package/bin/hub.js CHANGED
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- if (!process.env.NODE_ENV) {
4
- process.env.NODE_ENV = "production";
5
- }
6
-
3
+ import "../envs/defaultEnv.js";
7
4
  import yargs from "yargs";
8
5
  import { io as ioClient } from "socket.io-client";
9
6
  import {
package/bin/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { promises as fs } from "fs";
package/bin/proxy.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import express from 'express';
4
5
  import cors from 'cors';
5
6
  const app = express();
package/bin/uploadApp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import yargs from "yargs";
4
5
  import axios from "axios";
5
6
  import { promises as fs } from "fs";
package/bin/watcher.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import "../envs/defaultEnv.js";
3
4
  import express from 'express';
4
5
  import cors from 'cors';
5
6
  const app = express();
@@ -9,6 +9,11 @@ import {
9
9
  findRawColorsInStyles,
10
10
  describeRawColorFindings,
11
11
  } from "./styleColors.js";
12
+ import {
13
+ findImplicitNumericScale,
14
+ describeImplicitScaleFindings,
15
+ selectScaleRisks,
16
+ } from "./numericScale.js";
12
17
  import { json2List } from "../orm/index.js";
13
18
  import { toDbName } from "./naming.js";
14
19
  import { logger } from "../../logger.js";
@@ -3242,6 +3247,49 @@ export const publishConfigs = async (payload = {}, argv = {}) => {
3242
3247
  );
3243
3248
  }
3244
3249
 
3250
+ /*
3251
+ * ⚠️⚠️ WHICH `number` COLUMNS LET A DISPLAY HINT CHOOSE THEIR STORAGE PRECISION.
3252
+ *
3253
+ * `resolveColumnType` takes a column's scale from `uiIntent` when none is declared: 'currency'
3254
+ * gives 2, anything else gives 0. In Supertail that made `st_order`'s header BIGINT while the
3255
+ * order LINES it sums stayed NUMERIC(18,2), and made `tax_rate` unable to hold 11.5%.
3256
+ *
3257
+ * ⚠️ REPORT-ONLY, and scanned here beside the Style colours for the same reason: 204 `number`
3258
+ * attributes across biz-a-template declare no scale at all, so refusing a publish would make
3259
+ * the platform un-upgradable for every application that has the problem. The countable list is
3260
+ * the deliverable.
3261
+ *
3262
+ * ⚠️ Its own try/catch: a warning must never be the thing that fails a publish.
3263
+ */
3264
+ try {
3265
+ const implicitScale = findImplicitNumericScale(parseDomainConfig(domainConfigContent));
3266
+ const scaleRisks = selectScaleRisks(implicitScale);
3267
+ /*
3268
+ * ⚠️ ONLY THE RISKS ARE LISTED. The raw scan finds 158 columns in one real config and most
3269
+ * are legitimately whole (sequence_no, timeout_minutes, foreign keys) — printing them all
3270
+ * is how a check gets switched off. The rest is a count, so the number is never lost.
3271
+ */
3272
+ if (scaleRisks.length > 0) {
3273
+ logger.warn(
3274
+ `[AppConfig Publish] ${scaleRisks.length} money/rate column(s) in ` +
3275
+ `${domainName}@${domainVersion} resolve to a WHOLE-NUMBER type because no \`scale\` ` +
3276
+ "is declared and uiIntent decides it (REPORT ONLY). These cannot hold cents or a " +
3277
+ "fractional rate:",
3278
+ );
3279
+ describeImplicitScaleFindings(scaleRisks).forEach((line) => logger.warn(line));
3280
+ }
3281
+ if (implicitScale.length > 0) {
3282
+ logger.warn(
3283
+ `[AppConfig Publish] (${implicitScale.length} numeric column(s) in total take their ` +
3284
+ "scale from uiIntent rather than declaring it.)",
3285
+ );
3286
+ }
3287
+ } catch (error) {
3288
+ logger.warn(
3289
+ `[AppConfig Publish] could not scan numeric scales: ${error?.message ?? error}`,
3290
+ );
3291
+ }
3292
+
3245
3293
  /*
3246
3294
  * Doc 3 §11 — resolve the tenancy declaration across EVERY published domain, not just this one.
3247
3295
  *
@@ -71,7 +71,25 @@ export const mergeDomainNavigation = ({ applicationConfig, domainKey, menus } =
71
71
  let group = kept.find((g) => String(g?.caption ?? "") === groupCaption);
72
72
  if (!group) {
73
73
  group = { caption: groupCaption, link: [], subMenu: [] };
74
- kept.push(group);
74
+ /*
75
+ * ⚠️ WHERE a group the host does not have is placed. Appending is fine for one stray leaf
76
+ * and wrong for a domain that owns a whole group: "HR, beside Setup" appended after every
77
+ * group the host emits is a group at the BOTTOM of the menu, which is not what was asked
78
+ * for and not where anyone looks.
79
+ *
80
+ * ⚠️ ON CREATION ONLY. Once the group exists, `kept.find` above keeps its position, and so
81
+ * does the host's own injector, which concats into a group it finds rather than
82
+ * re-appending. Every entry of a multi-screen domain therefore carries the same hint and
83
+ * only the first one acts on it.
84
+ *
85
+ * ⚠️ AN ANCHOR THE HOST DOES NOT HAVE APPENDS. Declining to place the group would hide
86
+ * every screen the domain owns — far worse than placing it last, and a domain cannot know
87
+ * what groups its host happens to emit.
88
+ */
89
+ const anchor = String(entry?.groupAfter ?? "").trim();
90
+ const at = anchor ? kept.findIndex((g) => String(g?.caption ?? "") === anchor) : -1;
91
+ if (at >= 0) kept.splice(at + 1, 0, group);
92
+ else kept.push(group);
75
93
  }
76
94
  if (!Array.isArray(group.subMenu)) group.subMenu = [];
77
95
 
@@ -0,0 +1,113 @@
1
+ /*
2
+ * ⚠️⚠️ WHICH `number` COLUMNS LET A PRESENTATION HINT CHOOSE THEIR STORAGE.
3
+ *
4
+ * `appConfig.js resolveColumnType` infers a column's scale from `uiIntent`: 'currency' gives 2, ANY
5
+ * other value gives 0 — so a money column labelled for DISPLAY silently becomes an integer, and the
6
+ * rounding is invisible until something produces a fraction.
7
+ *
8
+ * Found in Supertail, 2026-09-24: `st_order`'s header (`subtotal_amount`, `tax_amount`,
9
+ * `service_charge_amount`, `final_amount`) carried `uiIntent: 'label'` and became BIGINT, while the
10
+ * order LINES they sum kept NUMERIC(18,2) — one order, money at two precisions. And
11
+ * `st_finalisasi_profile.tax_rate` was BIGINT: 11% expressible, 11.5% silently truncated.
12
+ *
13
+ * ⚠️ WHY A REPORT AND NOT A RULE CHANGE. Measured across `biz-a-template`: 204 `type: 'number'`
14
+ * attributes, and NOT ONE declares an explicit `scale`. Flipping the inference today would drop the
15
+ * decimals on the 66 that currently rely on `uiIntent: 'currency'`. So the gap is made countable
16
+ * first, the configs state their storage over time, and only then can the default move. The same
17
+ * staging the raw-colour warning used (§10 point 3).
18
+ *
19
+ * ⚠️ REPORT-ONLY, ALWAYS. Refusing a publish would make the platform un-upgradable for exactly the
20
+ * applications that most need the fix — the argument styleColors.js already makes, and this file
21
+ * deliberately mirrors its shape so the two read as one idea.
22
+ */
23
+
24
+ /* Everything here fails SOFT and returns a list: a warning must never fail a publish. */
25
+ const entitiesOf = (config) => {
26
+ const entities = config?.model?.entities;
27
+ if (!entities || typeof entities !== "object" || Array.isArray(entities)) return [];
28
+ return Object.entries(entities);
29
+ };
30
+
31
+ /**
32
+ * Every `type: 'number'` attribute whose scale is INFERRED rather than declared.
33
+ *
34
+ * ⚠️ `uiIntent: 'currency'` is included too, deliberately. Those columns get the right storage for
35
+ * the wrong reason — a display hint — which is the coupling being retired. `resolved` says what each
36
+ * one becomes, so the two groups stay countable apart.
37
+ *
38
+ * Returns `[{ table, column, uiIntent, resolved }]`, or `[]` for anything it cannot read.
39
+ */
40
+ export const findImplicitNumericScale = (config) => {
41
+ const findings = [];
42
+ for (const [entityName, entity] of entitiesOf(config)) {
43
+ const attributes = entity?.attributes;
44
+ if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) continue;
45
+ /* An entity may name no table; the entity key is what a reader would search for. */
46
+ const table = String(entity?.table ?? entityName);
47
+
48
+ for (const [column, attribute] of Object.entries(attributes)) {
49
+ if (!attribute || typeof attribute !== "object") continue;
50
+ if (String(attribute.type ?? "").toLowerCase() !== "number") continue;
51
+ /* Declared scale is the answer this check exists to encourage — including scale 0, which
52
+ is a perfectly good statement that a column holds whole units. */
53
+ if (typeof attribute.scale === "number" && Number.isFinite(attribute.scale)) continue;
54
+
55
+ const uiIntent = String(attribute.uiIntent ?? "");
56
+ findings.push({
57
+ table,
58
+ column,
59
+ uiIntent: uiIntent || "(none)",
60
+ resolved:
61
+ uiIntent.toLowerCase() === "currency"
62
+ ? `NUMERIC(${attribute.precision ?? attribute.size ?? 18},2)`
63
+ : "INTEGER/BIGINT",
64
+ });
65
+ }
66
+ }
67
+ return findings;
68
+ };
69
+
70
+ /*
71
+ * ⚠️⚠️ WHAT IS WORTH PRINTING AT EVERY PUBLISH — a much smaller set than what the scan finds.
72
+ *
73
+ * The raw scan reports 158 columns in Supertail alone, and most are legitimately whole: `sequence_no`,
74
+ * `timeout_minutes`, foreign-key `_id`s, `user_id`. A report that long is noise, and styleColors.js
75
+ * already records where noise leads: "false positives will make people switch the check off, along
76
+ * with the findings that were real". Named colours are excluded there for exactly this reason.
77
+ *
78
+ * So the publish prints the defect SHAPE that was actually found: a column whose NAME says money or
79
+ * rate, which nonetheless resolved to an integer. Everything else stays available from the scan and
80
+ * is summarised as a count.
81
+ *
82
+ * ⚠️ A column ending `_id` is a key however it is named — `payment_amount_id` is not money.
83
+ */
84
+ const MONEY_OR_RATE = /(amount|price|total|subtotal|cost|fee|discount|paid|change|balance|tax|charge|rate|percent|pct)/i;
85
+ const LOOKS_LIKE_KEY = /(^id$|_id$)/i;
86
+
87
+ export const selectScaleRisks = (findings) =>
88
+ (Array.isArray(findings) ? findings : []).filter(
89
+ (f) =>
90
+ f?.resolved === "INTEGER/BIGINT" &&
91
+ MONEY_OR_RATE.test(String(f.column)) &&
92
+ !LOOKS_LIKE_KEY.test(String(f.column)),
93
+ );
94
+
95
+ /* ⚠️ CAPPED. A report that dumps 204 lines is one nobody reads, and the point is that somebody
96
+ reads it. The tail says how many were hidden so the number is never lost. */
97
+ const MAX_LINES = 20;
98
+
99
+ export const describeImplicitScaleFindings = (findings) => {
100
+ const list = Array.isArray(findings) ? findings : [];
101
+ if (list.length === 0) return [];
102
+
103
+ const lines = list
104
+ .slice(0, MAX_LINES)
105
+ .map(
106
+ (f) =>
107
+ ` ${f.table}.${f.column} uiIntent=${f.uiIntent} -> ${f.resolved}`,
108
+ );
109
+ if (list.length > MAX_LINES) {
110
+ lines.push(` ... and ${list.length - MAX_LINES} more`);
111
+ }
112
+ return lines;
113
+ };
@@ -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;
@@ -21,6 +21,7 @@
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
+ import { logger } from "../../logger.js";
24
25
 
25
26
  /* ABSOLUTE, resolved from this module — NOT process.cwd(). The hub is routinely launched from
26
27
  another directory (a global `hub` binary, a service wrapper, `node cli/bin/hub.js` from the repo
@@ -257,11 +258,61 @@ export const PG_DATE_OID = 1082;
257
258
  */
258
259
  export const PG_BOOL_OID = 16;
259
260
 
261
+ /*
262
+ * ⚠️⚠️ NUMERIC AND BIGINT MUST ARRIVE AS NUMBERS — the same argument as DATE and BOOL above, and the
263
+ * one that cost a real mis-priced screen.
264
+ *
265
+ * node-postgres hands arbitrary-precision types back as STRINGS so precision is never silently lost
266
+ * to IEEE-754. That is the right default for a general driver and the wrong one for an application
267
+ * written against what FINA sent, which was numbers. Left as strings, `a + b` CONCATENATES and
268
+ * `row.qty > 5` compares text.
269
+ *
270
+ * ⚠️ Live, 2026-09-23 (company bkd): `NUMERIC(18,2)` arrived as `"18000.00"` and the client read it
271
+ * as LOCALE text — Indonesian groups with `.` — so an 18,000 item rendered as Rp 1.800.000 on the
272
+ * screen used to cancel, void and refund. The display edge was fixed then; this fixes the shape.
273
+ */
274
+ export const PG_NUMERIC_OID = 1700;
275
+ export const PG_INT8_OID = 20;
276
+
277
+ /*
278
+ * ⚠️⚠️ THE PRECISION RISK IS REAL, SO IT IS MADE LOUD RATHER THAN ASSUMED AWAY.
279
+ *
280
+ * A double represents every integer exactly only up to 2^53-1; `NUMERIC(18,2)` can hold more, which
281
+ * is exactly why node-pg returns strings. Measured on db 3 the largest real amount is 900,000 —
282
+ * roughly ten million times of headroom — but "today's data fits" is not a guarantee, and a value
283
+ * that does not fit must NOT be rounded in silence. That would be the same class of defect this
284
+ * change exists to remove, one layer down.
285
+ *
286
+ * ⚠️ IT WARNS, IT DOES NOT THROW. A type parser runs inside every row of every read; throwing would
287
+ * turn one oversized value into a failed query with no obvious cause.
288
+ *
289
+ * ⚠️ AND AN UNPARSEABLE VALUE IS HANDED BACK UNTOUCHED, never NaN. PostgreSQL's `numeric` accepts
290
+ * 'NaN' and the infinities; coercing those would spread a JS NaN through every sum that touched them
291
+ * with nothing to trace it to.
292
+ */
293
+ const parseNumeric = (value) => {
294
+ if (value === null || value === undefined) return value;
295
+ const text = String(value);
296
+ const parsed = Number(text);
297
+ if (!Number.isFinite(parsed)) return value;
298
+
299
+ if (Math.abs(parsed) > Number.MAX_SAFE_INTEGER) {
300
+ logger.warn(
301
+ `[pg] ${text} exceeds the range a JavaScript number represents exactly ` +
302
+ `(±${Number.MAX_SAFE_INTEGER}); it has been read as ${parsed} and is no longer exact. ` +
303
+ "Handle this column as a string at the call site.",
304
+ );
305
+ }
306
+ return parsed;
307
+ };
308
+
260
309
  export const registerPgTypeParsers = (pg) => {
261
310
  pg.types.setTypeParser(PG_DATE_OID, (value) => value);
262
311
  pg.types.setTypeParser(PG_BOOL_OID, (value) =>
263
312
  value === null || value === undefined ? value : value === "t" ? 1 : 0,
264
313
  );
314
+ pg.types.setTypeParser(PG_NUMERIC_OID, parseNumeric);
315
+ pg.types.setTypeParser(PG_INT8_OID, parseNumeric);
265
316
  };
266
317
 
267
318
  /* Registered once per process, at the same lazy point `pg` itself is first loaded — the parser is