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 +1 -0
- package/bin/deleteApp.js +1 -0
- package/bin/hub.js +1 -4
- package/bin/index.js +1 -0
- package/bin/proxy.js +1 -0
- package/bin/uploadApp.js +1 -0
- package/bin/watcher.js +1 -0
- package/engine/domain/appConfig.js +48 -0
- package/engine/domain/domainNavigation.js +19 -1
- package/engine/domain/numericScale.js +113 -0
- package/engine/orm/apiRoute.js +46 -1
- package/engine/orm/changeInference.js +48 -8
- package/engine/orm/completeness.js +62 -0
- package/engine/orm/configVersion.js +28 -0
- package/engine/orm/connection.js +51 -0
- package/engine/orm/crudQuery.js +30 -9
- package/engine/orm/entityConfig.js +109 -1
- package/engine/orm/executeBlock.js +96 -3
- package/engine/orm/executor.js +6 -1
- package/engine/orm/graphRead.js +94 -0
- package/engine/orm/index.js +22 -5
- package/engine/orm/listQuery.js +11 -7
- package/engine/orm/masterDetail.js +43 -3
- package/engine/orm/sqlGuard.js +172 -0
- package/engine/orm/tenantApi.js +20 -2
- package/engine/orm/userGroups.js +39 -0
- package/envs/defaultEnv.js +3 -0
- package/logger.js +13 -2
- package/package.json +1 -1
- package/scheduler/datalib.js +9 -0
- package/worker/cliScriptWorker.js +9 -1
package/bin/app.js
CHANGED
package/bin/deleteApp.js
CHANGED
package/bin/hub.js
CHANGED
package/bin/index.js
CHANGED
package/bin/proxy.js
CHANGED
package/bin/uploadApp.js
CHANGED
package/bin/watcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/engine/orm/apiRoute.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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;
|
package/engine/orm/connection.js
CHANGED
|
@@ -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
|