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