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.
@@ -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" },
@@ -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
 
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "biz-a-cli",
3
- "version": "2.3.80-15365",
3
+ "version": "2.3.80-15367",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "type": "module",