biz-a-cli 2.3.80-15365 → 2.3.80-15367
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/engine/ext/live.js +20 -0
- package/engine/orm/apiRoute.js +69 -0
- package/engine/orm/dialect.js +65 -0
- package/package.json +1 -1
package/engine/ext/live.js
CHANGED
|
@@ -29,12 +29,32 @@ import {
|
|
|
29
29
|
} from "./publicTheme.js";
|
|
30
30
|
import { createAllTenantScope } from "../orm/tenantScope.js";
|
|
31
31
|
import { execPostgres, isPostgresIndex } from "../domain/dialect.js";
|
|
32
|
+
import { nextGeneratorValue } from "../orm/dialect.js";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_FINA_URL = "http://127.0.0.1:212";
|
|
34
35
|
|
|
35
36
|
// Next value from a Firebird generator (for SYS$EXT_USER ids). Mirrors datalib.genId's
|
|
36
37
|
// endpoint without importing datalib (which pulls the worker pool + watcher).
|
|
38
|
+
//
|
|
39
|
+
// ⚠️ ON POSTGRESQL THERE IS NO GENERATOR AND NO FINA TO ASK. The migrations translate
|
|
40
|
+
// `<TABLE>_GEN` + BI trigger into an IDENTITY column, so this call could only ever fail — the same
|
|
41
|
+
// defect the admin client's `genId` hit live ("add user group" -> ECONNREFUSED 127.0.0.1:212), in a
|
|
42
|
+
// second hand-rolled copy. Shared with apiRoute.js through `nextGeneratorValue` so the two cannot
|
|
43
|
+
// drift; the Firebird branch below is untouched.
|
|
37
44
|
async function genId(cfg, genName) {
|
|
45
|
+
if (isPostgresIndex(cfg.dbindex)) {
|
|
46
|
+
const { sequence, value } = await nextGeneratorValue({
|
|
47
|
+
exec: execPostgres,
|
|
48
|
+
dbIndex: cfg.dbindex,
|
|
49
|
+
generator: genName,
|
|
50
|
+
});
|
|
51
|
+
if (sequence === null) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`no sequence backs generator ${genName} on database ${cfg.dbindex}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
38
58
|
const url = `${cfg.url}/fina/rest/TOrmMethod/genId/${genName}/${cfg.dbindex}`;
|
|
39
59
|
const res = await axios.get(url, {
|
|
40
60
|
headers: { "content-type": "text/plain" },
|
package/engine/orm/apiRoute.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* failure -> {"error":"..."} , which db/ds.js's dsReq already looks for.
|
|
21
21
|
*/
|
|
22
22
|
import { json2List, addTableNameToArray, lookupToSql, lookupTotalToSql, resolveDbIndex, buildWriteSql } from "./index.js";
|
|
23
|
+
import { nextGeneratorValue } from "./dialect.js";
|
|
23
24
|
import { createReaders } from "./changeInference.js";
|
|
24
25
|
import { CONFLICT_CODE, ROW_VERSION_KEY, refreshRowVersion } from "./concurrency.js";
|
|
25
26
|
import {
|
|
@@ -135,6 +136,27 @@ export const parseExecuteSqlRequest = (path, body) => {
|
|
|
135
136
|
}
|
|
136
137
|
};
|
|
137
138
|
|
|
139
|
+
/*
|
|
140
|
+
* `genId` carries EVERYTHING IN THE PATH and posts a null body:
|
|
141
|
+
* `/fina/rest/TOrmMethod/%22genId%22/SYS$USERGROUP_GEN/3` (data.service.ts genId).
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ Which is why `parseDataSnapRequest` never recognised it — that one needs `_parameters[0]` and
|
|
144
|
+
* quietly declines a body it cannot read. The call fell through to FINA, and on a tenant with no
|
|
145
|
+
* FINA the admin screen answered `502 ECONNREFUSED 127.0.0.1:212`.
|
|
146
|
+
*
|
|
147
|
+
* Returns { generator, dbIndex } or null.
|
|
148
|
+
*/
|
|
149
|
+
const GENID_PATH = /\/fina\/rest\/\w+\/(?:%22|")genId(?:%22|")\/([^/?#]+)\/(\d+)/i;
|
|
150
|
+
|
|
151
|
+
export const parseGenIdRequest = (path) => {
|
|
152
|
+
const match = GENID_PATH.exec(String(path ?? ""));
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
const generator = decodeURIComponent(match[1]).trim();
|
|
155
|
+
if (!generator) return null;
|
|
156
|
+
const dbIndex = Number(match[2]);
|
|
157
|
+
return { generator, dbIndex: Number.isFinite(dbIndex) ? dbIndex : null };
|
|
158
|
+
};
|
|
159
|
+
|
|
138
160
|
export const parseDataSnapRequest = (path, body) => {
|
|
139
161
|
const match = DATASNAP_PATH.exec(String(path ?? ""));
|
|
140
162
|
if (!match) return null;
|
|
@@ -580,6 +602,53 @@ export const routeApiRequest = async (
|
|
|
580
602
|
}
|
|
581
603
|
}
|
|
582
604
|
|
|
605
|
+
/*
|
|
606
|
+
* ⚠️⚠️ `genId` — a Firebird GENERATOR, answered from PostgreSQL's own counter.
|
|
607
|
+
*
|
|
608
|
+
* This used to fall through to FINA with the rest of the non-ORM surface, which was honest while
|
|
609
|
+
* the generator was merely SOMEWHERE ELSE. It is not: the PostgreSQL migrations translate
|
|
610
|
+
* `<TABLE>_GEN` + BI trigger into `GENERATED BY DEFAULT AS IDENTITY`, so on a PostgreSQL tenant
|
|
611
|
+
* the generator does not exist at all and the forward could only ever reach a Firebird counter
|
|
612
|
+
* for a different database — or, on a tenant with no FINA, `ECONNREFUSED`.
|
|
613
|
+
*
|
|
614
|
+
* ⚠️ Answering it from FINA was the WORSE outcome of the two. `buildUpsert` honours a supplied
|
|
615
|
+
* key, so a value drawn from one database and inserted into another collides with the identity
|
|
616
|
+
* column's own sequence as soon as the two counters cross. Resolving to the table's OWN sequence
|
|
617
|
+
* is what makes the pre-allocated key and the column default one counter again.
|
|
618
|
+
*
|
|
619
|
+
* ⚠️ A Firebird index still has a real generator, so it still forwards — the mixed tenant
|
|
620
|
+
* `finaEndpoint.js` protects keeps working untouched.
|
|
621
|
+
*/
|
|
622
|
+
const genIdRequest = parseGenIdRequest(path);
|
|
623
|
+
if (genIdRequest) {
|
|
624
|
+
if (!isPostgresIndex(genIdRequest.dbIndex)) return null;
|
|
625
|
+
try {
|
|
626
|
+
const { sequence, value } = await nextGeneratorValue({
|
|
627
|
+
exec,
|
|
628
|
+
dbIndex: genIdRequest.dbIndex,
|
|
629
|
+
generator: genIdRequest.generator,
|
|
630
|
+
});
|
|
631
|
+
/* ⚠️ A caller can act on "no sequence backs SYS$NOPE_GEN". It cannot act on a connection
|
|
632
|
+
error, and telling the two apart is most of what made this bug hard to place. */
|
|
633
|
+
if (sequence === null) {
|
|
634
|
+
return {
|
|
635
|
+
status: 400,
|
|
636
|
+
body: {
|
|
637
|
+
error:
|
|
638
|
+
`no sequence backs generator ${genIdRequest.generator} on database ` +
|
|
639
|
+
`${genIdRequest.dbIndex} — no sequence of that name, and no identity column on ` +
|
|
640
|
+
`${genIdRequest.generator.replace(/_GEN$/i, "")}`,
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/* ⚠️ A BARE NUMBER, captured from the live FINA rather than guessed: it answers `2`, not
|
|
645
|
+
`{data:…}` and not a string. data.service.ts hands the body straight to the caller. */
|
|
646
|
+
return { status: 200, body: value };
|
|
647
|
+
} catch (error) {
|
|
648
|
+
return { status: 500, body: { error: error?.message ?? String(error) } };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
583
652
|
const parsed = parseDataSnapRequest(path, body);
|
|
584
653
|
if (!parsed) return null;
|
|
585
654
|
|
package/engine/orm/dialect.js
CHANGED
|
@@ -25,6 +25,71 @@ export const buildPagingClause = (start, length) => {
|
|
|
25
25
|
export const buildSequenceNextValue = (sequenceName) =>
|
|
26
26
|
`SELECT nextval('${String(sequenceName).replace(/'/g, "''")}')`;
|
|
27
27
|
|
|
28
|
+
/*
|
|
29
|
+
* Doc 3 §5 item 11 — which PostgreSQL sequence stands in for a Firebird GENERATOR.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️⚠️ THE SECOND BRANCH IS THE WHOLE POINT. On Firebird a table's key comes from `<TABLE>_GEN`
|
|
32
|
+
* plus a BEFORE INSERT trigger; the PostgreSQL migrations translate that pair into
|
|
33
|
+
* `GENERATED BY DEFAULT AS IDENTITY`, so the generator does not exist here and cannot. Measured on
|
|
34
|
+
* both live PostgreSQL databases: not one `%_GEN` sequence.
|
|
35
|
+
*
|
|
36
|
+
* So `SYS$USERGROUP_GEN` resolves to the sequence `SYS$USERGROUP`'s identity column ALREADY OWNS.
|
|
37
|
+
* That is not a convenience — it is the correctness condition. A caller that pre-allocates a key
|
|
38
|
+
* and then inserts it explicitly (`admin/userGroup.component.ts`, because SYS$UGPRIV is written as
|
|
39
|
+
* an independent row carrying `usergroup_id`) must draw from the SAME counter the column's DEFAULT
|
|
40
|
+
* would use, or the two drift apart and collide on the primary key. That is exactly what was
|
|
41
|
+
* happening while `genId` was forwarded to a FINA whose own generator was a different number in a
|
|
42
|
+
* different database.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ The exact-name branch comes FIRST so an application that genuinely created `FOO_GEN` keeps
|
|
45
|
+
* working, and so a Firebird-shaped schema restored onto PostgreSQL is still answered correctly.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ The column is not assumed to be `id`: the lookup asks which of the table's columns owns a
|
|
48
|
+
* sequence. A table whose key is named otherwise resolves just the same.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ `pg_table_is_visible` keeps both branches inside the caller's search path — without it a
|
|
51
|
+
* same-named sequence in another schema could answer for a table this connection cannot even see.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ Both names are rendered as LITERALS, compared with `lower(...)`, never as identifiers. Nothing
|
|
54
|
+
* from the request reaches an identifier position: the `nextval` that follows uses the name the
|
|
55
|
+
* DATABASE returned, not the one the caller sent.
|
|
56
|
+
*/
|
|
57
|
+
export const buildGeneratorSequenceSql = (generator) => {
|
|
58
|
+
const name = String(generator ?? "");
|
|
59
|
+
const table = name.replace(/_GEN$/i, "");
|
|
60
|
+
return (
|
|
61
|
+
"SELECT COALESCE(" +
|
|
62
|
+
"(SELECT c.oid::regclass::text FROM pg_class c " +
|
|
63
|
+
`WHERE c.relkind = 'S' AND pg_table_is_visible(c.oid) AND lower(c.relname) = lower(${quoteLiteral(name)}) ` +
|
|
64
|
+
"LIMIT 1), " +
|
|
65
|
+
"(SELECT pg_get_serial_sequence(t.oid::regclass::text, a.attname) FROM pg_class t " +
|
|
66
|
+
"JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum > 0 AND NOT a.attisdropped " +
|
|
67
|
+
`WHERE t.relkind = 'r' AND pg_table_is_visible(t.oid) AND lower(t.relname) = lower(${quoteLiteral(table)}) ` +
|
|
68
|
+
"AND pg_get_serial_sequence(t.oid::regclass::text, a.attname) IS NOT NULL " +
|
|
69
|
+
"LIMIT 1)" +
|
|
70
|
+
") AS sequence_name"
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/*
|
|
75
|
+
* The next value of a Firebird-named generator, from PostgreSQL.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ ONE COPY, because there are two callers and they must not drift: `apiRoute.js` answers the
|
|
78
|
+
* admin client's `genId`, and `engine/ext/live.js` pre-generates SYS$EXT_USER ids. The second was
|
|
79
|
+
* a hand-rolled axios call to FINA and carried the identical latent failure — a PostgreSQL tenant
|
|
80
|
+
* has no FINA to ask and no generator to ask for.
|
|
81
|
+
*
|
|
82
|
+
* Resolves to `{ sequence, value }`, or `{ sequence: null, value: null }` when nothing backs the
|
|
83
|
+
* name. The caller decides what an unresolvable generator means; here it is simply a fact.
|
|
84
|
+
*/
|
|
85
|
+
export const nextGeneratorValue = async ({ exec, dbIndex, generator }) => {
|
|
86
|
+
const resolved = (await exec(buildGeneratorSequenceSql(generator), dbIndex)) ?? [];
|
|
87
|
+
const sequence = resolved[0]?.sequence_name ?? resolved[0]?.SEQUENCE_NAME ?? null;
|
|
88
|
+
if (!sequence) return { sequence: null, value: null };
|
|
89
|
+
const next = (await exec(buildSequenceNextValue(sequence), dbIndex)) ?? [];
|
|
90
|
+
return { sequence, value: Number(next[0]?.nextval ?? next[0]?.NEXTVAL) };
|
|
91
|
+
};
|
|
92
|
+
|
|
28
93
|
export const quoteLiteral = (value) => `'${String(value).replace(/'/g, "''")}'`;
|
|
29
94
|
|
|
30
95
|
/* A JS Date as a SQL literal. `String(date)` gives
|