@genesislcap/mock-server 15.51.1 → 15.52.0
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/README.md +217 -31
- package/dist/db/criteria.d.ts +1 -0
- package/dist/db/criteria.js +29 -0
- package/dist/db/identity.d.ts +11 -0
- package/dist/db/identity.js +46 -0
- package/dist/db/schema.d.ts +46 -0
- package/dist/db/schema.js +375 -0
- package/dist/db/store.d.ts +13 -2
- package/dist/db/store.js +208 -34
- package/dist/handlers/commitEvent.js +20 -6
- package/dist/handlers/dataLogon.js +126 -6
- package/dist/handlers/jsonSchema.d.ts +2 -1
- package/dist/handlers/jsonSchema.js +216 -61
- package/dist/handlers/meta.d.ts +6 -1
- package/dist/handlers/meta.js +164 -21
- package/dist/handlers/requestReply.js +16 -5
- package/dist/handlers/resourceAuth.d.ts +1 -0
- package/dist/handlers/resourceAuth.js +14 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/protocol/connection.d.ts +2 -0
- package/dist/protocol/errors.d.ts +100 -2
- package/dist/protocol/errors.js +300 -12
- package/dist/protocol/fieldTypes.d.ts +9 -0
- package/dist/protocol/fieldTypes.js +101 -0
- package/dist/protocol/gsfSchemas.d.ts +9 -0
- package/dist/protocol/gsfSchemas.js +423 -0
- package/dist/protocol/messageTypes.d.ts +1 -0
- package/dist/protocol/messageTypes.js +1 -0
- package/dist/protocol/rowUpdate.d.ts +5 -3
- package/dist/protocol/rowUpdate.js +33 -15
- package/dist/server.js +90 -28
- package/dist/types.d.ts +40 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +26 -0
- package/src/db/identity.ts +53 -0
- package/src/db/schema.ts +523 -0
- package/src/db/store.ts +225 -32
- package/src/handlers/commitEvent.ts +20 -7
- package/src/handlers/dataLogon.ts +144 -7
- package/src/handlers/jsonSchema.ts +290 -69
- package/src/handlers/meta.ts +222 -23
- package/src/handlers/requestReply.ts +15 -6
- package/src/handlers/resourceAuth.ts +18 -0
- package/src/index.ts +16 -1
- package/src/protocol/connection.ts +10 -2
- package/src/protocol/errors.ts +361 -13
- package/src/protocol/fieldTypes.ts +108 -0
- package/src/protocol/gsfSchemas.ts +505 -0
- package/src/protocol/messageTypes.ts +1 -0
- package/src/protocol/rowUpdate.ts +40 -20
- package/src/server.ts +101 -27
- package/src/types.ts +145 -1
package/dist/db/store.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
// In-memory tables + views + sequence generation. No persistence, no external deps.
|
|
2
2
|
// This is the "database" — disposable and rebuilt from seed data on every server start.
|
|
3
|
+
import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from "./identity.js";
|
|
3
4
|
// Composite keys join the pk fields' values with '|' (so values must not
|
|
4
5
|
// contain '|'). Single-field keys stay the raw value, preserving
|
|
5
6
|
// getRow('TABLE', 'TR001')-style lookups.
|
|
6
7
|
const COMPOSITE_KEY_SEPARATOR = '|';
|
|
8
|
+
const MAX_REMOVED_PER_KEY = 16;
|
|
9
|
+
const MAX_REMOVED_KEYS = 10_000;
|
|
10
|
+
// IdStrategy.java: a 15-digit counter, the sequence id, the location
|
|
11
|
+
// (DbConfig's default 'LO') and the system id (always 1).
|
|
12
|
+
const GENESIS_SEQUENCE_DIGITS = 15;
|
|
13
|
+
const GENESIS_LOCATION = 'LO';
|
|
14
|
+
const GENESIS_SYSTEM = '1';
|
|
7
15
|
function normalizePkFields(pkField) {
|
|
8
16
|
return Array.isArray(pkField) ? pkField : [pkField];
|
|
9
17
|
}
|
|
@@ -12,36 +20,76 @@ function keyFor(pkFields, row) {
|
|
|
12
20
|
return row[pkFields[0]];
|
|
13
21
|
return pkFields.map((field) => String(row[field])).join(COMPOSITE_KEY_SEPARATOR);
|
|
14
22
|
}
|
|
23
|
+
function escapeRegExp(text) {
|
|
24
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
|
+
}
|
|
26
|
+
// The counter inside a generated value, or undefined when the value isn't
|
|
27
|
+
// one this table's sequence could have produced.
|
|
28
|
+
function sequenceNumber(config, value) {
|
|
29
|
+
const prefix = config.sequencePrefix;
|
|
30
|
+
if (!prefix || typeof value !== 'string')
|
|
31
|
+
return undefined;
|
|
32
|
+
const digits = config.sequenceFormat === 'genesis'
|
|
33
|
+
? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(value)?.[1]
|
|
34
|
+
: value.startsWith(prefix)
|
|
35
|
+
? value.slice(prefix.length)
|
|
36
|
+
: undefined;
|
|
37
|
+
if (digits === undefined)
|
|
38
|
+
return undefined;
|
|
39
|
+
const n = parseInt(digits, 10);
|
|
40
|
+
return Number.isFinite(n) ? n : undefined;
|
|
41
|
+
}
|
|
42
|
+
// Counters are shared by tables using the same prefix in the same format.
|
|
43
|
+
function counterKey(config) {
|
|
44
|
+
return config.sequenceFormat === 'genesis'
|
|
45
|
+
? `${config.sequencePrefix}\u0000genesis`
|
|
46
|
+
: config.sequencePrefix;
|
|
47
|
+
}
|
|
15
48
|
export class Store {
|
|
16
49
|
tables = new Map();
|
|
17
50
|
tableConfig = new Map();
|
|
18
51
|
sequenceCounters = new Map();
|
|
52
|
+
clock = new RecordClock();
|
|
19
53
|
// Immutable snapshot of each table's rows as registered, used only for field
|
|
20
54
|
// metadata derivation (see getMetadataRows) so deleting every live row can't
|
|
21
55
|
// collapse a resource's schema to zero fields.
|
|
22
56
|
seedRows = new Map();
|
|
57
|
+
// Per table and key, every record the key lost (deleted, or displaced by
|
|
58
|
+
// an insert or a key change landing on it) and no broadcast has announced
|
|
59
|
+
// yet, oldest first — see takeRemovedRows. Bounded: a key keeps its last
|
|
60
|
+
// MAX_REMOVED_PER_KEY records and a table its last MAX_REMOVED_KEYS keys,
|
|
61
|
+
// for handlers that delete without ever broadcasting.
|
|
62
|
+
removedRows = new Map();
|
|
23
63
|
views = new Map();
|
|
24
|
-
registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, rows = [] }) {
|
|
64
|
+
registerTable(tableName, { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [] }) {
|
|
25
65
|
const pkFields = normalizePkFields(pkField);
|
|
66
|
+
const config = { pkFields, sequencePrefix, sequenceWidth, sequenceFormat };
|
|
26
67
|
const table = new Map();
|
|
68
|
+
const seenIds = new Set();
|
|
69
|
+
let restamped = 0;
|
|
27
70
|
for (const row of rows) {
|
|
28
|
-
|
|
71
|
+
const stamped = this.stampSeedRow({ ...row }, seenIds);
|
|
72
|
+
if (stamped.restamped)
|
|
73
|
+
restamped += 1;
|
|
74
|
+
table.set(keyFor(pkFields, row), stamped.row);
|
|
75
|
+
}
|
|
76
|
+
if (restamped > 0) {
|
|
77
|
+
console.warn(`[mock-server] ${tableName}: re-stamped ${restamped} seed row(s) whose RECORD_ID was ` +
|
|
78
|
+
'not a safe integer or a string, or repeated another row, so every ROW_REF stays unique');
|
|
29
79
|
}
|
|
30
80
|
this.tables.set(tableName, table);
|
|
31
|
-
this.tableConfig.set(tableName,
|
|
81
|
+
this.tableConfig.set(tableName, config);
|
|
82
|
+
this.removedRows.delete(tableName);
|
|
32
83
|
this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
|
|
33
84
|
// Sequence generation only makes sense for a single-field pk.
|
|
34
85
|
if (sequencePrefix && pkFields.length === 1) {
|
|
35
86
|
let maxN = 0;
|
|
36
87
|
for (const row of rows) {
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
if (Number.isFinite(n))
|
|
41
|
-
maxN = Math.max(maxN, n);
|
|
42
|
-
}
|
|
88
|
+
const n = sequenceNumber(config, row[pkFields[0]]);
|
|
89
|
+
if (n !== undefined)
|
|
90
|
+
maxN = Math.max(maxN, n);
|
|
43
91
|
}
|
|
44
|
-
this.sequenceCounters.set(
|
|
92
|
+
this.sequenceCounters.set(counterKey(config), maxN + 1);
|
|
45
93
|
}
|
|
46
94
|
return this;
|
|
47
95
|
}
|
|
@@ -49,6 +97,53 @@ export class Store {
|
|
|
49
97
|
this.views.set(viewName, definition);
|
|
50
98
|
return this;
|
|
51
99
|
}
|
|
100
|
+
// Seed rows keep a RECORD_ID they bring (e.g. rows copied from a real
|
|
101
|
+
// server) when it is usable: a safe integer, or a string, not used by an
|
|
102
|
+
// earlier seed row. Anything else — a missing id, a number above 2^53 that
|
|
103
|
+
// JSON.parse already rounded onto its neighbour's, a repeat — gets a fresh
|
|
104
|
+
// stamp, in seed order, so no two rows share a ROW_REF. A missing or
|
|
105
|
+
// unusable TIMESTAMP becomes the RECORD_ID.
|
|
106
|
+
stampSeedRow(row, seenIds) {
|
|
107
|
+
const given = row[RECORD_ID];
|
|
108
|
+
const usable = (value) => (typeof value === 'number' && Number.isSafeInteger(value)) ||
|
|
109
|
+
(typeof value === 'string' && value !== '');
|
|
110
|
+
const missing = given === undefined || given === null;
|
|
111
|
+
const keep = !missing && usable(given) && !seenIds.has(String(given));
|
|
112
|
+
if (keep) {
|
|
113
|
+
this.clock.observe(given);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
row[RECORD_ID] = this.clock.next();
|
|
117
|
+
}
|
|
118
|
+
seenIds.add(String(row[RECORD_ID]));
|
|
119
|
+
if (!usable(row[TIMESTAMP]))
|
|
120
|
+
row[TIMESTAMP] = row[RECORD_ID];
|
|
121
|
+
this.clock.observe(row[TIMESTAMP]);
|
|
122
|
+
return { row, restamped: !missing && !keep };
|
|
123
|
+
}
|
|
124
|
+
// A newly inserted row gets RECORD_ID = TIMESTAMP = a fresh stamp, whatever
|
|
125
|
+
// it was given — as on the real server — appended after its own fields.
|
|
126
|
+
stampNewRow(row) {
|
|
127
|
+
delete row[RECORD_ID];
|
|
128
|
+
delete row[TIMESTAMP];
|
|
129
|
+
const stamp = this.clock.next();
|
|
130
|
+
row[RECORD_ID] = stamp;
|
|
131
|
+
row[TIMESTAMP] = stamp;
|
|
132
|
+
return row;
|
|
133
|
+
}
|
|
134
|
+
noteRemoved(tableName, key, row) {
|
|
135
|
+
let removed = this.removedRows.get(tableName);
|
|
136
|
+
if (!removed) {
|
|
137
|
+
removed = new Map();
|
|
138
|
+
this.removedRows.set(tableName, removed);
|
|
139
|
+
}
|
|
140
|
+
const records = removed.get(key) ?? [];
|
|
141
|
+
// Re-set so the key moves to the end: the oldest keys are dropped first.
|
|
142
|
+
removed.delete(key);
|
|
143
|
+
removed.set(key, [...records, row].slice(-MAX_REMOVED_PER_KEY));
|
|
144
|
+
if (removed.size > MAX_REMOVED_KEYS)
|
|
145
|
+
removed.delete(removed.keys().next().value);
|
|
146
|
+
}
|
|
52
147
|
// An explicitly-supplied pk inside the sequence's own range must advance the
|
|
53
148
|
// counter too. Without this, inserting e.g. 'TR002' by hand leaves the
|
|
54
149
|
// counter at 2, and the next auto-generated insert mints 'TR002' again and
|
|
@@ -57,21 +152,28 @@ export class Store {
|
|
|
57
152
|
observeSequenceValue(config, row) {
|
|
58
153
|
if (!config.sequencePrefix || config.pkFields.length !== 1)
|
|
59
154
|
return;
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
return;
|
|
63
|
-
const n = parseInt(pk.slice(config.sequencePrefix.length), 10);
|
|
64
|
-
if (!Number.isFinite(n))
|
|
155
|
+
const n = sequenceNumber(config, row[config.pkFields[0]]);
|
|
156
|
+
if (n === undefined)
|
|
65
157
|
return;
|
|
66
|
-
const
|
|
158
|
+
const key = counterKey(config);
|
|
159
|
+
const next = this.sequenceCounters.get(key) ?? 1;
|
|
67
160
|
if (n + 1 > next)
|
|
68
|
-
this.sequenceCounters.set(
|
|
161
|
+
this.sequenceCounters.set(key, n + 1);
|
|
69
162
|
}
|
|
70
163
|
nextSequenceValue(sequencePrefix, width) {
|
|
71
164
|
const n = this.sequenceCounters.get(sequencePrefix) ?? 1;
|
|
72
165
|
this.sequenceCounters.set(sequencePrefix, n + 1);
|
|
73
166
|
return `${sequencePrefix}${String(n).padStart(width, '0')}`;
|
|
74
167
|
}
|
|
168
|
+
generateSequenceValue(config) {
|
|
169
|
+
if (config.sequenceFormat !== 'genesis') {
|
|
170
|
+
return this.nextSequenceValue(config.sequencePrefix, config.sequenceWidth);
|
|
171
|
+
}
|
|
172
|
+
const key = counterKey(config);
|
|
173
|
+
const n = this.sequenceCounters.get(key) ?? 1;
|
|
174
|
+
this.sequenceCounters.set(key, n + 1);
|
|
175
|
+
return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${config.sequencePrefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
|
|
176
|
+
}
|
|
75
177
|
getAllRows(tableName) {
|
|
76
178
|
const table = this.tables.get(tableName);
|
|
77
179
|
if (!table)
|
|
@@ -112,6 +214,10 @@ export class Store {
|
|
|
112
214
|
}
|
|
113
215
|
return rows.find(matchesAllCriteria);
|
|
114
216
|
}
|
|
217
|
+
// Generates a missing (undefined or null) sequence pk and keeps a supplied
|
|
218
|
+
// one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already stored under
|
|
219
|
+
// the same key is replaced (the real database would reject the insert with
|
|
220
|
+
// DUPLICATE_KEY — check first and throw NackError.duplicateKey for that).
|
|
115
221
|
insertRow(tableName, record) {
|
|
116
222
|
const table = this.tables.get(tableName);
|
|
117
223
|
const config = this.tableConfig.get(tableName);
|
|
@@ -119,49 +225,85 @@ export class Store {
|
|
|
119
225
|
throw new Error(`Unknown table: ${tableName}`);
|
|
120
226
|
const row = { ...record };
|
|
121
227
|
if (config.pkFields.length === 1 &&
|
|
122
|
-
row[config.pkFields[0]] === undefined &&
|
|
228
|
+
(row[config.pkFields[0]] === undefined || row[config.pkFields[0]] === null) &&
|
|
123
229
|
config.sequencePrefix) {
|
|
124
|
-
row[config.pkFields[0]] = this.
|
|
230
|
+
row[config.pkFields[0]] = this.generateSequenceValue(config);
|
|
125
231
|
}
|
|
126
232
|
const missing = config.pkFields.filter((field) => row[field] === undefined);
|
|
127
233
|
if (missing.length > 0) {
|
|
128
234
|
throw new Error(`Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`);
|
|
129
235
|
}
|
|
130
236
|
this.observeSequenceValue(config, row);
|
|
131
|
-
|
|
237
|
+
this.stampNewRow(row);
|
|
238
|
+
const key = keyFor(config.pkFields, row);
|
|
239
|
+
const displaced = table.get(key);
|
|
240
|
+
if (displaced)
|
|
241
|
+
this.noteRemoved(tableName, key, displaced);
|
|
242
|
+
table.set(key, row);
|
|
132
243
|
return row;
|
|
133
244
|
}
|
|
245
|
+
// Merges the patch, moves TIMESTAMP on and keeps RECORD_ID (a patch can't
|
|
246
|
+
// change a row's identity).
|
|
134
247
|
updateRow(tableName, pk, patch) {
|
|
135
248
|
const table = this.tables.get(tableName);
|
|
136
249
|
const config = this.tableConfig.get(tableName);
|
|
137
250
|
if (!table || !config || !table.has(pk))
|
|
138
251
|
return undefined;
|
|
139
|
-
const
|
|
252
|
+
const existing = table.get(pk);
|
|
253
|
+
const updated = { ...existing, ...patch };
|
|
254
|
+
updated[RECORD_ID] = existing[RECORD_ID];
|
|
255
|
+
updated[TIMESTAMP] = this.clock.next();
|
|
140
256
|
// A patch that changes a pk field (e.g. an entity-management form posting
|
|
141
257
|
// its full DETAILS after the user edited the key column) must move the row
|
|
142
258
|
// to its new key. Leaving it under the old one makes the index disagree
|
|
143
259
|
// with the row itself: getRow(newKey) misses, while rowKey()/getRowRef()
|
|
144
260
|
// report the new key, so the matching broadcast finds nothing to push.
|
|
145
261
|
const nextKey = keyFor(config.pkFields, updated);
|
|
146
|
-
if (nextKey !== pk)
|
|
262
|
+
if (nextKey !== pk) {
|
|
147
263
|
table.delete(pk);
|
|
264
|
+
const displaced = table.get(nextKey);
|
|
265
|
+
if (displaced)
|
|
266
|
+
this.noteRemoved(tableName, nextKey, displaced);
|
|
267
|
+
}
|
|
148
268
|
table.set(nextKey, updated);
|
|
149
269
|
return updated;
|
|
150
270
|
}
|
|
151
271
|
deleteRow(tableName, pk) {
|
|
152
272
|
const table = this.tables.get(tableName);
|
|
153
|
-
|
|
273
|
+
const existing = table?.get(pk);
|
|
274
|
+
if (!table || !existing)
|
|
154
275
|
return false;
|
|
155
|
-
|
|
276
|
+
table.delete(pk);
|
|
277
|
+
this.noteRemoved(tableName, pk, existing);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
// Reads and clears every record a key lost that no broadcast announced
|
|
281
|
+
// yet, oldest first: what a DELETE broadcast names (their RECORD_IDs are
|
|
282
|
+
// the ROW_REFs a client may hold — held-rows tracking drops the ones it
|
|
283
|
+
// doesn't), including the old record when a key's row was replaced before
|
|
284
|
+
// one broadcast.
|
|
285
|
+
takeRemovedRows(tableName, key) {
|
|
286
|
+
const removed = this.removedRows.get(tableName);
|
|
287
|
+
const records = removed?.get(key) ?? [];
|
|
288
|
+
removed?.delete(key);
|
|
289
|
+
return records;
|
|
290
|
+
}
|
|
291
|
+
// The same, for a row of a table or view given by its (base) pk fields —
|
|
292
|
+
// what a public broadcast(resource, 'DELETE', pkStub) names.
|
|
293
|
+
takeRemovedRecordsFor(resourceName, row) {
|
|
294
|
+
const located = this.locateRecord(resourceName, row);
|
|
295
|
+
return located ? this.takeRemovedRows(located.tableName, located.key) : [];
|
|
156
296
|
}
|
|
157
297
|
// Materializes a view: base table left-joined to related tables (optionally
|
|
158
298
|
// narrowed by per-join `where` predicates), minus anti-joined rows, plus
|
|
159
|
-
// derived fields.
|
|
160
|
-
|
|
299
|
+
// derived fields. `tables` substitutes the rows of any table the view reads
|
|
300
|
+
// (its base or a joined one) — e.g. one base row, to build that row's view
|
|
301
|
+
// row, or a table's earlier state, to rebuild the view as it was.
|
|
302
|
+
getViewRows(viewName, tables = {}) {
|
|
161
303
|
const view = this.views.get(viewName);
|
|
162
304
|
if (!view)
|
|
163
305
|
throw new Error(`Unknown view: ${viewName}`);
|
|
164
|
-
return this.materializeView(view, (tableName) => this.getAllRows(tableName));
|
|
306
|
+
return this.materializeView(view, (tableName) => Object.hasOwn(tables, tableName) ? tables[tableName] : this.getAllRows(tableName));
|
|
165
307
|
}
|
|
166
308
|
materializeView(view, getRows) {
|
|
167
309
|
let rows = getRows(view.base).map((row) => ({ ...row }));
|
|
@@ -214,14 +356,17 @@ export class Store {
|
|
|
214
356
|
// deleting a table's last row would make the next metadata reply say the
|
|
215
357
|
// resource has zero fields — which entity-management renders as a form with
|
|
216
358
|
// no inputs and a delete button that can't build a payload, silently.
|
|
359
|
+
// RECORD_ID and TIMESTAMP are left out: they are not fields a dataserver
|
|
360
|
+
// row or an event's DETAILS carry.
|
|
217
361
|
getMetadataRows(resourceName) {
|
|
218
362
|
const live = this.getResourceRows(resourceName);
|
|
219
363
|
if (live.length > 0)
|
|
220
|
-
return live;
|
|
364
|
+
return live.map(withoutRecordFields);
|
|
221
365
|
const view = this.views.get(resourceName);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
366
|
+
const seed = view
|
|
367
|
+
? this.materializeView(view, (tableName) => this.seedRows.get(tableName) ?? [])
|
|
368
|
+
: (this.seedRows.get(resourceName) ?? []);
|
|
369
|
+
return seed.map(withoutRecordFields);
|
|
225
370
|
}
|
|
226
371
|
// Row-identity fields for a table or view; empty when unknown.
|
|
227
372
|
getPkFields(resourceName) {
|
|
@@ -233,17 +378,46 @@ export class Store {
|
|
|
233
378
|
}
|
|
234
379
|
return this.tableConfig.get(resourceName)?.pkFields ?? [];
|
|
235
380
|
}
|
|
236
|
-
// The
|
|
237
|
-
//
|
|
238
|
-
//
|
|
381
|
+
// The pk-based row reference: the pk field's value, or the composite key.
|
|
382
|
+
// This was DETAILS.ROW_REF up to 15.47 and still is under
|
|
383
|
+
// fidelity: 'legacy'; by default ROW_REF is getRecordRef's.
|
|
239
384
|
getRowRef(resourceName, row) {
|
|
240
385
|
const pkFields = this.getPkFields(resourceName);
|
|
241
386
|
if (pkFields.length === 0)
|
|
242
387
|
return undefined;
|
|
243
388
|
return keyFor(pkFields, row);
|
|
244
389
|
}
|
|
390
|
+
// The DETAILS.ROW_REF GSF stamps on dataserver rows: the row's RECORD_ID as
|
|
391
|
+
// a string (GenesisSetMaskingJsonSerializer: value.toString()). A view row
|
|
392
|
+
// carries its base row's RECORD_ID. A row passed without one (a hand-built
|
|
393
|
+
// row, or a pk-only stub) is matched to the stored record by its pk — a
|
|
394
|
+
// just-deleted record included — and only when that fails too does this
|
|
395
|
+
// fall back to the pk-based reference.
|
|
396
|
+
getRecordRef(resourceName, row) {
|
|
397
|
+
const id = recordKey(row) ?? recordKey(this.findStoredRecord(resourceName, row));
|
|
398
|
+
return id ?? this.getRowRef(resourceName, row);
|
|
399
|
+
}
|
|
400
|
+
locateRecord(resourceName, row) {
|
|
401
|
+
const view = this.views.get(resourceName);
|
|
402
|
+
const tableName = view ? view.base : resourceName;
|
|
403
|
+
const pkFields = this.tableConfig.get(tableName)?.pkFields ?? [];
|
|
404
|
+
if (pkFields.length === 0 || pkFields.some((field) => row[field] === undefined)) {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
return { tableName, key: keyFor(pkFields, row) };
|
|
408
|
+
}
|
|
409
|
+
// The stored record a pk-only row names: the live one, else the newest
|
|
410
|
+
// record removed from that key and not yet broadcast.
|
|
411
|
+
findStoredRecord(resourceName, row) {
|
|
412
|
+
const located = this.locateRecord(resourceName, row);
|
|
413
|
+
if (!located)
|
|
414
|
+
return undefined;
|
|
415
|
+
const { tableName, key } = located;
|
|
416
|
+
return (this.tables.get(tableName)?.get(key) ?? this.removedRows.get(tableName)?.get(key)?.at(-1));
|
|
417
|
+
}
|
|
245
418
|
// Rebuilds a DELETE stub row carrying the pk field(s) from a stored key, so
|
|
246
419
|
// subscribers can be told which row to drop after it's already gone.
|
|
420
|
+
// (The DELETE body under fidelity: 'legacy'; GSF sends DETAILS only.)
|
|
247
421
|
rowStubFromKey(resourceName, key) {
|
|
248
422
|
const pkFields = this.getPkFields(resourceName);
|
|
249
423
|
if (pkFields.length <= 1)
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { nackPayload, warningPayload } from "../protocol/errors.js";
|
|
2
2
|
import { isLegacyFidelity } from "../protocol/fidelity.js";
|
|
3
3
|
import { ERROR_CODE, MESSAGE_TYPE } from "../protocol/messageTypes.js";
|
|
4
4
|
import { resourceNotMappedNack } from "../protocol/msgNack.js";
|
|
5
5
|
// Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
|
|
6
6
|
// convention your project uses) are looked up by exact MESSAGE_TYPE against
|
|
7
7
|
// config.eventHandlers. A handler receives (details, ctx) and either returns
|
|
8
|
-
// { generated?: [...] } on success,
|
|
8
|
+
// { generated?: [...] } on success, { warnings: [...] } to NACK with warnings
|
|
9
|
+
// only, or throws NackError(errors) on failure.
|
|
9
10
|
export function handleCommitEvent(message, connection, config, store, ctx) {
|
|
10
11
|
const eventName = message.MESSAGE_TYPE;
|
|
11
12
|
// defaultEventHandler catches anything unregistered — lets projects with
|
|
@@ -33,17 +34,30 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
|
|
|
33
34
|
: resourceNotMappedNack(eventName));
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
// A handler's failure is an EVENT_NACK with GSF's error items (see
|
|
38
|
+
// protocol/errors.ts): a thrown NackError gives its own, anything else is
|
|
39
|
+
// StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
|
|
40
|
+
// ERROR — the IGNORE_WARNINGS/VALIDATE flow that would let them through is
|
|
41
|
+
// not modelled yet.
|
|
42
|
+
const legacy = isLegacyFidelity(config);
|
|
36
43
|
try {
|
|
37
44
|
const result = handler(message.DETAILS ?? {}, { connection, store, message, ...ctx }) ?? {};
|
|
45
|
+
if (result.warnings?.length) {
|
|
46
|
+
connection.reply(message, {
|
|
47
|
+
MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
|
|
48
|
+
...warningPayload(result.warnings, legacy),
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
38
52
|
connection.reply(message, {
|
|
39
53
|
MESSAGE_TYPE: MESSAGE_TYPE.EVENT_ACK,
|
|
40
54
|
GENERATED: result.generated ?? [],
|
|
41
55
|
});
|
|
42
56
|
}
|
|
43
57
|
catch (error) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
connection.reply(message, {
|
|
59
|
+
MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
|
|
60
|
+
...nackPayload(error, legacy),
|
|
61
|
+
});
|
|
48
62
|
}
|
|
49
63
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { compileCriteria, filterByCriteria } from "../db/criteria.js";
|
|
1
|
+
import { compileCriteria, criteriaFields, filterByCriteria } from "../db/criteria.js";
|
|
2
|
+
import { RECORD_ID } from "../db/identity.js";
|
|
3
|
+
import { queryFieldNames, querySchema } from "../db/schema.js";
|
|
2
4
|
import { isLegacyFidelity } from "../protocol/fidelity.js";
|
|
3
5
|
import { MESSAGE_TYPE, ERROR_CODE } from "../protocol/messageTypes.js";
|
|
4
6
|
import { nonExistentSubscriptionNack, resourceNotMappedNack, subscriptionAlreadyExistsNack, } from "../protocol/msgNack.js";
|
|
5
|
-
import { stampRow } from "../protocol/rowUpdate.js";
|
|
6
|
-
import {
|
|
7
|
+
import { dataserverRow, rowRefFor, stampRow } from "../protocol/rowUpdate.js";
|
|
8
|
+
import { permittedQueryRow, requestingUser } from "./resourceAuth.js";
|
|
7
9
|
// The real dataserver's defaults (genesis-pal-dataserver ClientOptions,
|
|
8
10
|
// GSF 8.15.x) — also what foundation-comms' DatasourceDefaults send.
|
|
9
11
|
const DEFAULT_MAX_ROWS = 250;
|
|
@@ -57,6 +59,55 @@ function takePage(subscription) {
|
|
|
57
59
|
}
|
|
58
60
|
return page;
|
|
59
61
|
}
|
|
62
|
+
// The dataserver's own refusal of a logon (genesis-pal-dataserver): a
|
|
63
|
+
// LOGON_NACK with a StandardError, sent instead of LOGON_ACK. No subscription
|
|
64
|
+
// is opened, so a later DATA_LOGOFF on it gets the router's 404.
|
|
65
|
+
function logonNack(code, text) {
|
|
66
|
+
return {
|
|
67
|
+
MESSAGE_TYPE: MESSAGE_TYPE.LOGON_NACK,
|
|
68
|
+
ERROR: [{ '@type': 'StandardError', CODE: code, TEXT: text, STATUS_CODE: '400 Bad Request' }],
|
|
69
|
+
WARNING: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// DETAILS.FIELDS: a space-separated projection of the query's columns, in the
|
|
73
|
+
// query's order. Names it doesn't expose are dropped, as the real dataserver
|
|
74
|
+
// drops them.
|
|
75
|
+
function requestedFieldNames(details, query, exposed, config, store) {
|
|
76
|
+
const requested = typeof details.FIELDS === 'string' && details.FIELDS.trim() !== ''
|
|
77
|
+
? details.FIELDS.trim().split(/\s+/)
|
|
78
|
+
: undefined;
|
|
79
|
+
if (!requested)
|
|
80
|
+
return exposed;
|
|
81
|
+
const available = exposed ?? querySchema(query, config, store).fields.map((field) => field.name);
|
|
82
|
+
return available.filter((name) => requested.includes(name));
|
|
83
|
+
}
|
|
84
|
+
// Ascending by the index's fields, nulls first. Ties are broken by RECORD_ID,
|
|
85
|
+
// as the real index breaks them, so REVERSE, which flips the whole order,
|
|
86
|
+
// puts the newest of equal rows first.
|
|
87
|
+
function indexComparator(index) {
|
|
88
|
+
const compareValues = (a, b) => {
|
|
89
|
+
if (a === b)
|
|
90
|
+
return 0;
|
|
91
|
+
if (a === undefined || a === null)
|
|
92
|
+
return -1;
|
|
93
|
+
if (b === undefined || b === null)
|
|
94
|
+
return 1;
|
|
95
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
96
|
+
return a - b;
|
|
97
|
+
if (typeof a === 'boolean' && typeof b === 'boolean')
|
|
98
|
+
return a ? 1 : -1;
|
|
99
|
+
const [left, right] = [String(a), String(b)];
|
|
100
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
101
|
+
};
|
|
102
|
+
return (a, b) => {
|
|
103
|
+
for (const field of index.fields) {
|
|
104
|
+
const result = compareValues(a[field], b[field]);
|
|
105
|
+
if (result !== 0)
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
return compareValues(a[RECORD_ID], b[RECORD_ID]);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
60
111
|
// DATA_LOGON opens a persistent subscription keyed by SOURCE_REF: reply with
|
|
61
112
|
// LOGON_ACK and the first page of the snapshot, then keep the SOURCE_REF
|
|
62
113
|
// registered so MORE_ROWS can page the rest and later inserts/amends/deletes
|
|
@@ -87,6 +138,32 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
87
138
|
}
|
|
88
139
|
const criteria = details.CRITERIA_MATCH;
|
|
89
140
|
const user = requestingUser(connection);
|
|
141
|
+
// The query's fields block (or the source's declared columns) is all a
|
|
142
|
+
// criteria may name and all a row carries; DETAILS.FIELDS narrows the rows.
|
|
143
|
+
// Legacy subscriptions ignore all of it, and ORDER_BY, as 15.47 did.
|
|
144
|
+
const exposed = queryFieldNames(query, config, store);
|
|
145
|
+
let compare;
|
|
146
|
+
let reverse = false;
|
|
147
|
+
if (!legacy) {
|
|
148
|
+
// The real dataserver lets a criteria read only the query's own fields
|
|
149
|
+
// (QueryClientFactory.kt). Unknown columns are only checked when the
|
|
150
|
+
// query's fields are known; an inferred query fails open as before.
|
|
151
|
+
const outside = exposed && criteriaFields(criteria)?.find((field) => !exposed.includes(field));
|
|
152
|
+
if (outside) {
|
|
153
|
+
connection.reply(message, logonNack('INVALID_CRITERIA', `GenericCriteriaValidationException : Criteria validation failed: Variable ${outside} is not allowed in criteria expression`));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// ORDER_BY names one of the query's own indexes (QueryDef.indexes).
|
|
157
|
+
if (details.ORDER_BY) {
|
|
158
|
+
const index = query.indexes?.find((candidate) => candidate.name === details.ORDER_BY);
|
|
159
|
+
if (!index) {
|
|
160
|
+
connection.reply(message, logonNack('INVALID_INDEX', `No such index ${details.ORDER_BY} for dataserver query ${resourceName}`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
compare = indexComparator(index);
|
|
164
|
+
reverse = details.REVERSE === true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
90
167
|
// Order matters: the query's own filter (the where-clause equivalent) and
|
|
91
168
|
// the user's row permissioning narrow the set BEFORE the client's
|
|
92
169
|
// CRITERIA_MATCH; hidden fields are stripped last, off the final rows.
|
|
@@ -101,20 +178,38 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
101
178
|
// the count is taken after it — an assumption, as no recording covers it.
|
|
102
179
|
const rowsCount = rows.length;
|
|
103
180
|
rows = filterByCriteria(rows, criteria);
|
|
181
|
+
if (compare) {
|
|
182
|
+
rows = [...rows].sort(compare);
|
|
183
|
+
if (reverse)
|
|
184
|
+
rows.reverse();
|
|
185
|
+
}
|
|
104
186
|
// Permissioning is applied per row so the ROW_REF can still be computed from
|
|
105
187
|
// the UNSTRIPPED row — pk fields may themselves be hidden from this user,
|
|
106
188
|
// and a snapshot row with no ROW_REF can never be matched to the later
|
|
107
189
|
// INSERT/MODIFY/DELETE pushes (which stamp the real ref, see server.ts).
|
|
190
|
+
// RECORD_ID (or, in legacy mode, the pk fields) is never sent unless the
|
|
191
|
+
// query's fields block names it.
|
|
192
|
+
const getRowRef = (row) => rowRefFor(config, store, query.source, row);
|
|
193
|
+
const fieldNames = legacy
|
|
194
|
+
? undefined
|
|
195
|
+
: requestedFieldNames(details, query, exposed, config, store);
|
|
108
196
|
const stampedRows = [];
|
|
197
|
+
// Each sent-or-waiting row as stored, by pending key, for ORDER_BY's live
|
|
198
|
+
// INSERT placement below.
|
|
199
|
+
const rawRows = new Map();
|
|
109
200
|
for (const row of rows) {
|
|
110
|
-
const
|
|
201
|
+
const permitted = permittedQueryRow(row, query.auth, user, fieldNames);
|
|
111
202
|
if (!permitted)
|
|
112
203
|
continue;
|
|
113
|
-
|
|
204
|
+
const rowRef = getRowRef(row);
|
|
205
|
+
const key = pendingKey(rowRef);
|
|
206
|
+
if (key !== undefined)
|
|
207
|
+
rawRows.set(key, row);
|
|
208
|
+
stampedRows.push(stampRow(dataserverRow(permitted, row, query.fields), rowRef, 'INSERT', legacy));
|
|
114
209
|
}
|
|
115
210
|
const subscription = {
|
|
116
211
|
resourceName,
|
|
117
|
-
getRowRef
|
|
212
|
+
getRowRef,
|
|
118
213
|
predicate: compileCriteria(criteria),
|
|
119
214
|
sequenceId: 1,
|
|
120
215
|
};
|
|
@@ -140,7 +235,32 @@ export function handleDataLogon(message, connection, config, store) {
|
|
|
140
235
|
held: new Set(),
|
|
141
236
|
maxRows,
|
|
142
237
|
maxView,
|
|
238
|
+
fieldNames,
|
|
143
239
|
};
|
|
240
|
+
// Under ORDER_BY a live INSERT that sorts among the rows still waiting for
|
|
241
|
+
// MORE_ROWS joins them in order rather than being pushed out of order; one
|
|
242
|
+
// that sorts before them lands in what the client has, and is pushed. (A
|
|
243
|
+
// MODIFY of a waiting row keeps its place.)
|
|
244
|
+
if (compare) {
|
|
245
|
+
const ascending = compare;
|
|
246
|
+
const sortCompare = reverse ? (a, b) => ascending(b, a) : ascending;
|
|
247
|
+
tracked.queueInsert = (key, stamped, row) => {
|
|
248
|
+
const waiting = [...tracked.pending];
|
|
249
|
+
const first = waiting[0] && rawRows.get(waiting[0][0]);
|
|
250
|
+
if (!first || sortCompare(row, first) < 0)
|
|
251
|
+
return false;
|
|
252
|
+
const at = waiting.findIndex(([waitingKey]) => {
|
|
253
|
+
const other = rawRows.get(waitingKey);
|
|
254
|
+
return other !== undefined && sortCompare(row, other) < 0;
|
|
255
|
+
});
|
|
256
|
+
waiting.splice(at < 0 ? waiting.length : at, 0, [key, stamped]);
|
|
257
|
+
tracked.pending.clear();
|
|
258
|
+
for (const [waitingKey, waitingRow] of waiting)
|
|
259
|
+
tracked.pending.set(waitingKey, waitingRow);
|
|
260
|
+
rawRows.set(key, row);
|
|
261
|
+
return true;
|
|
262
|
+
};
|
|
263
|
+
}
|
|
144
264
|
const firstPage = takePage(tracked);
|
|
145
265
|
const pending = tracked.pending;
|
|
146
266
|
connection.subscribe(sourceRef, tracked);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { inferEventTable } from '../db/schema.ts';
|
|
1
2
|
import type { Store } from '../db/store.ts';
|
|
2
3
|
import type { Connection } from '../protocol/connection.ts';
|
|
3
4
|
import type { GenesisMessage, MockServerConfig } from '../types.ts';
|
|
4
|
-
export
|
|
5
|
+
export { inferEventTable };
|
|
5
6
|
export declare function handleJsonSchemaRequest(message: GenesisMessage, connection: Connection, config: MockServerConfig, store: Store): void;
|