@genesislcap/mock-server 15.52.0 → 15.53.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 +210 -27
- package/dist/db/criteria.d.ts +12 -0
- package/dist/db/criteria.js +39 -29
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +17 -1
- package/dist/db/schema.js +131 -16
- package/dist/db/store.d.ts +8 -2
- package/dist/db/store.js +120 -44
- package/dist/handlers/commitEvent.js +32 -8
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +2 -23
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +3 -2
- package/dist/handlers/jsonSchema.js +23 -17
- package/dist/handlers/meta.js +13 -9
- package/dist/handlers/requestReply.js +518 -40
- package/dist/handlers/resources.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/protocol/fieldTypes.d.ts +1 -0
- package/dist/protocol/fieldTypes.js +6 -0
- package/dist/protocol/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +5 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +52 -27
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +164 -15
- package/src/db/store.ts +146 -46
- package/src/handlers/commitEvent.ts +39 -8
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +2 -19
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +31 -19
- package/src/handlers/meta.ts +14 -9
- package/src/handlers/requestReply.ts +625 -41
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +1 -0
- package/src/protocol/fieldTypes.ts +7 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +5 -1
- package/src/types.ts +56 -18
package/dist/db/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FieldDef, MockServerConfig, QueryDef, RequestReplyDef, Row, TableDef } from '../types.ts';
|
|
1
|
+
import type { CrudEventVerb, FieldDef, IndexDef, MockServerConfig, QueryDef, RequestReplyDef, Row, TableDef } from '../types.ts';
|
|
2
2
|
import type { Store } from './store.ts';
|
|
3
3
|
export interface ResolvedField {
|
|
4
4
|
name: string;
|
|
@@ -18,18 +18,34 @@ export interface ResolvedSchema {
|
|
|
18
18
|
fields: ResolvedField[];
|
|
19
19
|
declared: boolean;
|
|
20
20
|
}
|
|
21
|
+
export declare function pkFieldsOf(def: TableDef): string[];
|
|
21
22
|
export declare function generatedFieldsOf(def: TableDef): Set<string>;
|
|
23
|
+
export declare function generatedFieldList(def: TableDef): string[];
|
|
24
|
+
export declare function isDeclared(source: string, config: MockServerConfig, store: Store): boolean;
|
|
22
25
|
export declare function resolveSourceSchema(source: string, config: MockServerConfig, store: Store): ResolvedSchema;
|
|
23
26
|
export declare function withTypeOverrides(schema: ResolvedSchema, fieldTypes: Record<string, string> | undefined): ResolvedSchema;
|
|
24
27
|
export declare function projectSchema(schema: ResolvedSchema, names: string[] | undefined): ResolvedSchema;
|
|
25
28
|
export declare function querySchema(query: QueryDef, config: MockServerConfig, store: Store): ResolvedSchema;
|
|
26
29
|
export declare function requestReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
|
|
27
30
|
export declare const RECORD_IDENTITY_FIELDS: ResolvedField[];
|
|
31
|
+
export declare function requestReplySourceSchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
|
|
32
|
+
export declare function requestReplyReplySchema(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): ResolvedSchema;
|
|
33
|
+
export declare function requestReplyFieldNames(requestReply: RequestReplyDef, config: MockServerConfig, store: Store): string[] | undefined;
|
|
28
34
|
export declare function requestFieldsOf(requestReply: RequestReplyDef, store: Store): string[];
|
|
29
35
|
export declare function knownValues(field: ResolvedField): string[] | undefined;
|
|
30
36
|
export declare function eventDefaultOf(field: ResolvedField): unknown;
|
|
31
37
|
export declare function queryFieldNames(query: QueryDef | undefined, config: MockServerConfig, store: Store): string[] | undefined;
|
|
32
38
|
export declare function projectRow(row: Row, names: string[] | undefined): Row;
|
|
39
|
+
export declare function defaultIndexName(table: string, fields: string[]): string;
|
|
40
|
+
export declare function primaryKeyIndexName(table: string, def: TableDef): string;
|
|
41
|
+
export declare function secondaryUniqueIndexes(def: TableDef): IndexDef[];
|
|
42
|
+
export declare const CRUD_EVENT_VERBS: readonly CrudEventVerb[];
|
|
43
|
+
export declare function crudEventName(table: string, verb: CrudEventVerb): string;
|
|
44
|
+
export declare function crudEventOf(name: string | undefined, config: MockServerConfig): {
|
|
45
|
+
table: string;
|
|
46
|
+
verb: CrudEventVerb;
|
|
47
|
+
} | undefined;
|
|
48
|
+
export declare function crudEventNames(config: MockServerConfig): string[];
|
|
33
49
|
export declare function isDeleteEventName(name: string): boolean;
|
|
34
50
|
export declare function inferEventTable(eventName: string | undefined, tableNames: string[]): string | undefined;
|
|
35
51
|
export interface EventTarget {
|
package/dist/db/schema.js
CHANGED
|
@@ -5,18 +5,29 @@
|
|
|
5
5
|
// existed, so a config that declares nothing keeps working.
|
|
6
6
|
import { isLegacyFidelity } from "../protocol/fidelity.js";
|
|
7
7
|
import { daoClassOf, titleCase } from "../protocol/fieldTypes.js";
|
|
8
|
+
import { RECORD_FIELDS, RECORD_ID, TIMESTAMP } from "./identity.js";
|
|
8
9
|
import { deriveFields } from "./metadata.js";
|
|
9
|
-
function pkFieldsOf(def) {
|
|
10
|
+
export function pkFieldsOf(def) {
|
|
10
11
|
return Array.isArray(def.pkField) ? def.pkField : [def.pkField];
|
|
11
12
|
}
|
|
12
13
|
// The declared generated fields, plus the key of a table that mints its own
|
|
13
14
|
// keys through sequencePrefix.
|
|
14
15
|
export function generatedFieldsOf(def) {
|
|
15
|
-
|
|
16
|
+
return new Set(generatedFieldList(def));
|
|
17
|
+
}
|
|
18
|
+
// The same fields in the order GSF lists a table's generated fields
|
|
19
|
+
// (EventHandlerUtils.kt generatedFields: the id sequences, then the
|
|
20
|
+
// auto-increment fields): the sequencePrefix key first, then the declared
|
|
21
|
+
// SEQUENCE fields, then the AUTO_INCREMENT ones.
|
|
22
|
+
export function generatedFieldList(def) {
|
|
16
23
|
const pkFields = pkFieldsOf(def);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
24
|
+
const declared = def.generated ?? [];
|
|
25
|
+
const fields = [
|
|
26
|
+
...(def.sequencePrefix && pkFields.length === 1 ? [pkFields[0]] : []),
|
|
27
|
+
...declared.filter((entry) => entry.kind === 'SEQUENCE').map((entry) => entry.field),
|
|
28
|
+
...declared.filter((entry) => entry.kind === 'AUTO_INCREMENT').map((entry) => entry.field),
|
|
29
|
+
];
|
|
30
|
+
return [...new Set(fields)];
|
|
20
31
|
}
|
|
21
32
|
function fromFieldDef(field, table, notNullByDefault, generated) {
|
|
22
33
|
return {
|
|
@@ -105,7 +116,10 @@ function pick(fields, names) {
|
|
|
105
116
|
return field ? [field] : [];
|
|
106
117
|
});
|
|
107
118
|
}
|
|
108
|
-
|
|
119
|
+
// Whether a table (or a view, through its base table) declares its fields.
|
|
120
|
+
// Checked without resolving the schema, so it costs nothing for a big
|
|
121
|
+
// undeclared table.
|
|
122
|
+
export function isDeclared(source, config, store) {
|
|
109
123
|
const table = config.tables?.[source];
|
|
110
124
|
if (table)
|
|
111
125
|
return !!table.fields;
|
|
@@ -180,11 +194,59 @@ export function requestReplySchema(requestReply, config, store) {
|
|
|
180
194
|
};
|
|
181
195
|
return withTypeOverrides(schema, requestReply.fieldTypes);
|
|
182
196
|
}
|
|
183
|
-
// The record identity
|
|
197
|
+
// The record identity every table entity carries after its own columns: what
|
|
198
|
+
// a request server with no reply block sends in each row and lists in
|
|
199
|
+
// REPLY_FIELD, and what a criteria-only one can sort and filter by.
|
|
184
200
|
export const RECORD_IDENTITY_FIELDS = [
|
|
185
|
-
{ name:
|
|
186
|
-
{ name:
|
|
201
|
+
{ name: RECORD_ID, type: 'LONG', nullable: false, generated: true },
|
|
202
|
+
{ name: TIMESTAMP, type: 'NANO_TIMESTAMP', nullable: false, generated: true },
|
|
203
|
+
];
|
|
204
|
+
const withIdentity = (fields) => [
|
|
205
|
+
...fields.filter((field) => !RECORD_FIELDS.includes(field.name)),
|
|
206
|
+
...RECORD_IDENTITY_FIELDS,
|
|
187
207
|
];
|
|
208
|
+
// What a request server's database entity has: its source's fields, then
|
|
209
|
+
// RECORD_ID and TIMESTAMP (a resolver's: its metadataRows). A criteria-only
|
|
210
|
+
// server sorts and filters by these (SORTABLE_FIELDS, CRITERIA_FIELDS), and
|
|
211
|
+
// REQUEST may name any of them, whatever the reply block says.
|
|
212
|
+
export function requestReplySourceSchema(requestReply, config, store) {
|
|
213
|
+
const schema = requestReplySchema(requestReply, config, store);
|
|
214
|
+
return requestReply.source && !requestReply.resolver
|
|
215
|
+
? { ...schema, fields: withIdentity(schema.fields) }
|
|
216
|
+
: schema;
|
|
217
|
+
}
|
|
218
|
+
// What a request server's REP_ rows carry (getReplyFields in
|
|
219
|
+
// RequestReplyServer.kt, GSF v8.15.29): the reply block (`fields`), else the
|
|
220
|
+
// source's fields followed by RECORD_ID and TIMESTAMP — a table entity's own
|
|
221
|
+
// field list, which the real server sends whether or not the server is
|
|
222
|
+
// criteria-only (auth's RIGHT sends no RECORD_ID only because its reply block
|
|
223
|
+
// names CODE and DESCRIPTION). REPLY_FIELD and the JSON schema's REPLY come
|
|
224
|
+
// from here. A resolver's fields are its metadataRows'; under fidelity:
|
|
225
|
+
// 'legacy' everything is the source schema, as up to 15.47.
|
|
226
|
+
export function requestReplyReplySchema(requestReply, config, store) {
|
|
227
|
+
if (isLegacyFidelity(config))
|
|
228
|
+
return requestReplySchema(requestReply, config, store);
|
|
229
|
+
const schema = requestReplySourceSchema(requestReply, config, store);
|
|
230
|
+
if (!requestReply.fields || !requestReply.source || requestReply.resolver)
|
|
231
|
+
return schema;
|
|
232
|
+
return projectSchema(schema, requestReply.fields);
|
|
233
|
+
}
|
|
234
|
+
// The columns a source-backed request server's REP_ rows carry, in order (see
|
|
235
|
+
// requestReplyReplySchema). undefined — rows as stored, which already end
|
|
236
|
+
// with their RECORD_ID and TIMESTAMP — for an undeclared source with no reply
|
|
237
|
+
// block, for a resolver, and under fidelity: 'legacy'.
|
|
238
|
+
export function requestReplyFieldNames(requestReply, config, store) {
|
|
239
|
+
if (isLegacyFidelity(config) || !requestReply.source || requestReply.resolver)
|
|
240
|
+
return undefined;
|
|
241
|
+
const declared = sourceFieldNames(requestReply.source, config, store);
|
|
242
|
+
const names = declared && [
|
|
243
|
+
...declared.filter((name) => !RECORD_FIELDS.includes(name)),
|
|
244
|
+
...RECORD_FIELDS,
|
|
245
|
+
];
|
|
246
|
+
if (!requestReply.fields)
|
|
247
|
+
return names;
|
|
248
|
+
return names ? requestReply.fields.filter((name) => names.includes(name)) : requestReply.fields;
|
|
249
|
+
}
|
|
188
250
|
// The request block's fields: requestFields, else the source's key.
|
|
189
251
|
export function requestFieldsOf(requestReply, store) {
|
|
190
252
|
if (requestReply.requestFields)
|
|
@@ -249,7 +311,56 @@ export function projectRow(row, names) {
|
|
|
249
311
|
return row;
|
|
250
312
|
return Object.fromEntries(names.map((name) => [name, row[name] ?? null]));
|
|
251
313
|
}
|
|
314
|
+
// --- indexes -----------------------------------------------------------------
|
|
315
|
+
// The name the dictionary gives an index declared without one
|
|
316
|
+
// (DslFieldContext.kt generateIndexName, GSF v8.15.29): '<TABLE>_BY_' and the
|
|
317
|
+
// fields, each without a leading '<TABLE>_', joined with '_' — so
|
|
318
|
+
// primaryKey("COUNTERPARTY_ID") is COUNTERPARTY_BY_ID and
|
|
319
|
+
// unique("INSTRUMENT_ID") on POSITION is POSITION_BY_INSTRUMENT_ID.
|
|
320
|
+
export function defaultIndexName(table, fields) {
|
|
321
|
+
const prefix = `${table}_`;
|
|
322
|
+
const names = fields.map((field) => (field.startsWith(prefix) && field.slice(prefix.length)) || field);
|
|
323
|
+
return `${prefix}BY_${names.join('_')}`;
|
|
324
|
+
}
|
|
325
|
+
const sameFields = (a, b) => a.length === b.length && a.every((field, index) => field === b[index]);
|
|
326
|
+
// The primary key's index name: a declared unique index over exactly the key
|
|
327
|
+
// fields names it, else the dictionary default. NACKs name it in FIELD.
|
|
328
|
+
export function primaryKeyIndexName(table, def) {
|
|
329
|
+
const pkFields = pkFieldsOf(def);
|
|
330
|
+
const declared = def.indexes?.find((index) => index.unique && sameFields(index.fields, pkFields));
|
|
331
|
+
return declared?.name ?? defaultIndexName(table, pkFields);
|
|
332
|
+
}
|
|
333
|
+
// The table's other unique indexes, each a constraint an insert or modify
|
|
334
|
+
// must not break.
|
|
335
|
+
export function secondaryUniqueIndexes(def) {
|
|
336
|
+
const pkFields = pkFieldsOf(def);
|
|
337
|
+
return (def.indexes ?? []).filter((index) => index.unique && !sameFields(index.fields, pkFields));
|
|
338
|
+
}
|
|
252
339
|
// --- events ------------------------------------------------------------------
|
|
340
|
+
export const CRUD_EVENT_VERBS = ['INSERT', 'MODIFY', 'DELETE'];
|
|
341
|
+
// A generic event's name: Create names the handler '<TABLE>_<VERB>'
|
|
342
|
+
// (as GSF's GenericInsertEventHandler.messageType() does too) and the event
|
|
343
|
+
// handler serves it as EVENT_<TABLE>_<VERB>.
|
|
344
|
+
export function crudEventName(table, verb) {
|
|
345
|
+
return `EVENT_${table}_${verb}`;
|
|
346
|
+
}
|
|
347
|
+
// The table and verb of a generic CRUD event a table registers through
|
|
348
|
+
// TableDef.events, or undefined.
|
|
349
|
+
export function crudEventOf(name, config) {
|
|
350
|
+
if (!name?.startsWith('EVENT_'))
|
|
351
|
+
return undefined;
|
|
352
|
+
for (const [table, def] of Object.entries(config.tables ?? {})) {
|
|
353
|
+
for (const verb of def.events ?? []) {
|
|
354
|
+
if (crudEventName(table, verb) === name)
|
|
355
|
+
return { table, verb };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
// Every generic CRUD event the tables register, in table then verb order.
|
|
361
|
+
export function crudEventNames(config) {
|
|
362
|
+
return Object.entries(config.tables ?? {}).flatMap(([table, def]) => CRUD_EVENT_VERBS.filter((verb) => def.events?.includes(verb)).map((verb) => crudEventName(table, verb)));
|
|
363
|
+
}
|
|
253
364
|
// Any event named .../_DELETE(_...)? takes only the key fields, which is what
|
|
254
365
|
// entity-management's delete confirmation copies from the selected row.
|
|
255
366
|
export function isDeleteEventName(name) {
|
|
@@ -278,20 +389,24 @@ export function inferEventTable(eventName, tableNames) {
|
|
|
278
389
|
candidates.sort((a, b) => b.length - a.length);
|
|
279
390
|
return candidates[0];
|
|
280
391
|
}
|
|
281
|
-
// The table an event writes: config.eventSchemas first, then the
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
392
|
+
// The table an event writes: config.eventSchemas first, then the table that
|
|
393
|
+
// registers it as a generic CRUD event, then the EVENT_<TABLE>_<VERB>
|
|
394
|
+
// convention. Only a registered event (an eventHandlers or eventSchemas
|
|
395
|
+
// entry, or a TableDef.events verb) has metadata, as only a deployed event
|
|
396
|
+
// handler has on the real server; anything else is the router's 404.
|
|
397
|
+
// `bareTable` also accepts a plain table name, which only JSON_SCHEMA_REQUEST
|
|
398
|
+
// does (see the README).
|
|
287
399
|
export function resolveEventTarget(name, config, { bareTable = false } = {}) {
|
|
288
400
|
if (!name)
|
|
289
401
|
return undefined;
|
|
290
402
|
const override = config.eventSchemas?.[name];
|
|
291
|
-
|
|
403
|
+
const crud = crudEventOf(name, config);
|
|
404
|
+
if (name.startsWith('EVENT_') && !override && !config.eventHandlers?.[name] && !crud) {
|
|
292
405
|
return undefined;
|
|
406
|
+
}
|
|
293
407
|
const tableNames = Object.keys(config.tables ?? {});
|
|
294
408
|
const table = override?.table ??
|
|
409
|
+
crud?.table ??
|
|
295
410
|
(bareTable || name.startsWith('EVENT_') ? inferEventTable(name, tableNames) : undefined);
|
|
296
411
|
const fieldTypes = override?.fieldTypes ? { fieldTypes: override.fieldTypes } : {};
|
|
297
412
|
if (override?.fields) {
|
package/dist/db/store.d.ts
CHANGED
|
@@ -4,22 +4,28 @@ export declare class Store {
|
|
|
4
4
|
private tables;
|
|
5
5
|
private tableConfig;
|
|
6
6
|
private sequenceCounters;
|
|
7
|
+
private autoIncrementCounters;
|
|
7
8
|
private clock;
|
|
8
9
|
private seedRows;
|
|
9
10
|
private removedRows;
|
|
10
11
|
readonly views: Map<string, ViewDef>;
|
|
11
|
-
registerTable(tableName: string,
|
|
12
|
+
registerTable(tableName: string, definition: TableDef): this;
|
|
13
|
+
private counterOf;
|
|
14
|
+
private countersFor;
|
|
15
|
+
private generatedNumber;
|
|
12
16
|
registerView(viewName: string, definition: ViewDef): this;
|
|
13
17
|
private stampSeedRow;
|
|
14
18
|
private stampNewRow;
|
|
15
19
|
private noteRemoved;
|
|
16
|
-
private
|
|
20
|
+
private observeGeneratedValues;
|
|
17
21
|
nextSequenceValue(sequencePrefix: string, width: number): string;
|
|
18
22
|
private generateSequenceValue;
|
|
23
|
+
private generateValues;
|
|
19
24
|
getAllRows(tableName: string): Row[];
|
|
20
25
|
getRow(tableName: string, pk: Pk): Row | undefined;
|
|
21
26
|
rowKey(tableName: string, row: Row): Pk;
|
|
22
27
|
findRow(tableName: string, criteria: Row): Row | undefined;
|
|
28
|
+
withGeneratedValues(tableName: string, record: Row): Row;
|
|
23
29
|
insertRow(tableName: string, record: Row): Row;
|
|
24
30
|
updateRow(tableName: string, pk: Pk, patch: Row): Row | undefined;
|
|
25
31
|
deleteRow(tableName: string, pk: Pk): boolean;
|
package/dist/db/store.js
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
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
|
import { RECORD_ID, RecordClock, recordKey, TIMESTAMP, withoutRecordFields } from "./identity.js";
|
|
4
|
+
// The generators of a table, the key's own sequence first. A SEQUENCE with
|
|
5
|
+
// neither its own prefix nor the table's can't mint anything and is left to
|
|
6
|
+
// the caller.
|
|
7
|
+
function generatorsOf(pkFields, { sequencePrefix, generated = [] }) {
|
|
8
|
+
const generators = [];
|
|
9
|
+
if (sequencePrefix && pkFields.length === 1) {
|
|
10
|
+
generators.push({ field: pkFields[0], kind: 'SEQUENCE', prefix: sequencePrefix });
|
|
11
|
+
}
|
|
12
|
+
for (const entry of generated) {
|
|
13
|
+
if (generators.some((generator) => generator.field === entry.field))
|
|
14
|
+
continue;
|
|
15
|
+
if (entry.kind === 'AUTO_INCREMENT') {
|
|
16
|
+
generators.push({ field: entry.field, kind: 'AUTO_INCREMENT' });
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const prefix = entry.prefix ?? sequencePrefix;
|
|
20
|
+
if (prefix)
|
|
21
|
+
generators.push({ field: entry.field, kind: 'SEQUENCE', prefix });
|
|
22
|
+
}
|
|
23
|
+
return generators;
|
|
24
|
+
}
|
|
25
|
+
const isMissing = (value) => value === undefined || value === null;
|
|
4
26
|
// Composite keys join the pk fields' values with '|' (so values must not
|
|
5
27
|
// contain '|'). Single-field keys stay the raw value, preserving
|
|
6
28
|
// getRow('TABLE', 'TR001')-style lookups.
|
|
@@ -24,12 +46,11 @@ function escapeRegExp(text) {
|
|
|
24
46
|
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
25
47
|
}
|
|
26
48
|
// The counter inside a generated value, or undefined when the value isn't
|
|
27
|
-
// one this
|
|
28
|
-
function sequenceNumber(
|
|
29
|
-
|
|
30
|
-
if (!prefix || typeof value !== 'string')
|
|
49
|
+
// one a sequence with this prefix and format could have produced.
|
|
50
|
+
function sequenceNumber(prefix, format, value) {
|
|
51
|
+
if (typeof value !== 'string')
|
|
31
52
|
return undefined;
|
|
32
|
-
const digits =
|
|
53
|
+
const digits = format === 'genesis'
|
|
33
54
|
? new RegExp(`^(\\d+)${escapeRegExp(prefix)}${GENESIS_LOCATION}${GENESIS_SYSTEM}$`).exec(value)?.[1]
|
|
34
55
|
: value.startsWith(prefix)
|
|
35
56
|
? value.slice(prefix.length)
|
|
@@ -40,15 +61,16 @@ function sequenceNumber(config, value) {
|
|
|
40
61
|
return Number.isFinite(n) ? n : undefined;
|
|
41
62
|
}
|
|
42
63
|
// Counters are shared by tables using the same prefix in the same format.
|
|
43
|
-
function counterKey(
|
|
44
|
-
return
|
|
45
|
-
? `${config.sequencePrefix}\u0000genesis`
|
|
46
|
-
: config.sequencePrefix;
|
|
64
|
+
function counterKey(prefix, format) {
|
|
65
|
+
return format === 'genesis' ? `${prefix}\u0000genesis` : prefix;
|
|
47
66
|
}
|
|
67
|
+
// An AUTO_INCREMENT counter belongs to its table's field.
|
|
68
|
+
const autoIncrementKey = (tableName, field) => `${tableName}\u0000${field}`;
|
|
48
69
|
export class Store {
|
|
49
70
|
tables = new Map();
|
|
50
71
|
tableConfig = new Map();
|
|
51
72
|
sequenceCounters = new Map();
|
|
73
|
+
autoIncrementCounters = new Map();
|
|
52
74
|
clock = new RecordClock();
|
|
53
75
|
// Immutable snapshot of each table's rows as registered, used only for field
|
|
54
76
|
// metadata derivation (see getMetadataRows) so deleting every live row can't
|
|
@@ -61,9 +83,16 @@ export class Store {
|
|
|
61
83
|
// for handlers that delete without ever broadcasting.
|
|
62
84
|
removedRows = new Map();
|
|
63
85
|
views = new Map();
|
|
64
|
-
registerTable(tableName,
|
|
86
|
+
registerTable(tableName, definition) {
|
|
87
|
+
const { pkField, sequencePrefix, sequenceWidth = 3, sequenceFormat = 'prefix', rows = [], } = definition;
|
|
65
88
|
const pkFields = normalizePkFields(pkField);
|
|
66
|
-
const config = {
|
|
89
|
+
const config = {
|
|
90
|
+
pkFields,
|
|
91
|
+
sequencePrefix,
|
|
92
|
+
sequenceWidth,
|
|
93
|
+
sequenceFormat,
|
|
94
|
+
generators: generatorsOf(pkFields, definition),
|
|
95
|
+
};
|
|
67
96
|
const table = new Map();
|
|
68
97
|
const seenIds = new Set();
|
|
69
98
|
let restamped = 0;
|
|
@@ -81,18 +110,41 @@ export class Store {
|
|
|
81
110
|
this.tableConfig.set(tableName, config);
|
|
82
111
|
this.removedRows.delete(tableName);
|
|
83
112
|
this.seedRows.set(tableName, rows.map((row) => ({ ...row })));
|
|
84
|
-
//
|
|
85
|
-
|
|
113
|
+
// Each counter starts after the highest value the seed rows carry (two
|
|
114
|
+
// fields of one table sharing a prefix share the counter). The key's
|
|
115
|
+
// sequence only exists for a single-field pk.
|
|
116
|
+
const starts = new Map();
|
|
117
|
+
for (const generator of config.generators) {
|
|
86
118
|
let maxN = 0;
|
|
87
119
|
for (const row of rows) {
|
|
88
|
-
const n =
|
|
120
|
+
const n = this.generatedNumber(config, generator, row[generator.field]);
|
|
89
121
|
if (n !== undefined)
|
|
90
122
|
maxN = Math.max(maxN, n);
|
|
91
123
|
}
|
|
92
|
-
this.
|
|
124
|
+
const counter = this.counterOf(tableName, config, generator);
|
|
125
|
+
starts.set(counter, Math.max(starts.get(counter) ?? 1, maxN + 1));
|
|
126
|
+
}
|
|
127
|
+
for (const generator of config.generators) {
|
|
128
|
+
const counter = this.counterOf(tableName, config, generator);
|
|
129
|
+
this.countersFor(generator).set(counter, starts.get(counter));
|
|
93
130
|
}
|
|
94
131
|
return this;
|
|
95
132
|
}
|
|
133
|
+
counterOf(tableName, config, generator) {
|
|
134
|
+
return generator.kind === 'SEQUENCE'
|
|
135
|
+
? counterKey(generator.prefix, config.sequenceFormat)
|
|
136
|
+
: autoIncrementKey(tableName, generator.field);
|
|
137
|
+
}
|
|
138
|
+
countersFor(generator) {
|
|
139
|
+
return generator.kind === 'SEQUENCE' ? this.sequenceCounters : this.autoIncrementCounters;
|
|
140
|
+
}
|
|
141
|
+
// The counter inside a value a generator could have produced.
|
|
142
|
+
generatedNumber(config, generator, value) {
|
|
143
|
+
if (generator.kind === 'SEQUENCE') {
|
|
144
|
+
return sequenceNumber(generator.prefix, config.sequenceFormat, value);
|
|
145
|
+
}
|
|
146
|
+
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
|
|
147
|
+
}
|
|
96
148
|
registerView(viewName, definition) {
|
|
97
149
|
this.views.set(viewName, definition);
|
|
98
150
|
return this;
|
|
@@ -144,35 +196,50 @@ export class Store {
|
|
|
144
196
|
if (removed.size > MAX_REMOVED_KEYS)
|
|
145
197
|
removed.delete(removed.keys().next().value);
|
|
146
198
|
}
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
// A supplied value inside a generator's own range must advance its counter
|
|
200
|
+
// too. Without this, inserting e.g. 'TR002' by hand leaves the counter at
|
|
201
|
+
// 2, and the next generated insert mints 'TR002' again and silently
|
|
202
|
+
// overwrites the hand-keyed row (Map.set, no error, event still acks
|
|
203
|
+
// success).
|
|
204
|
+
observeGeneratedValues(tableName, config, row) {
|
|
205
|
+
for (const generator of config.generators) {
|
|
206
|
+
const n = this.generatedNumber(config, generator, row[generator.field]);
|
|
207
|
+
if (n === undefined)
|
|
208
|
+
continue;
|
|
209
|
+
const counters = this.countersFor(generator);
|
|
210
|
+
const counter = this.counterOf(tableName, config, generator);
|
|
211
|
+
if (n + 1 > (counters.get(counter) ?? 1))
|
|
212
|
+
counters.set(counter, n + 1);
|
|
213
|
+
}
|
|
162
214
|
}
|
|
163
215
|
nextSequenceValue(sequencePrefix, width) {
|
|
164
216
|
const n = this.sequenceCounters.get(sequencePrefix) ?? 1;
|
|
165
217
|
this.sequenceCounters.set(sequencePrefix, n + 1);
|
|
166
218
|
return `${sequencePrefix}${String(n).padStart(width, '0')}`;
|
|
167
219
|
}
|
|
168
|
-
generateSequenceValue(config) {
|
|
220
|
+
generateSequenceValue(config, prefix) {
|
|
169
221
|
if (config.sequenceFormat !== 'genesis') {
|
|
170
|
-
return this.nextSequenceValue(
|
|
222
|
+
return this.nextSequenceValue(prefix, config.sequenceWidth);
|
|
171
223
|
}
|
|
172
|
-
const key = counterKey(
|
|
224
|
+
const key = counterKey(prefix, 'genesis');
|
|
173
225
|
const n = this.sequenceCounters.get(key) ?? 1;
|
|
174
226
|
this.sequenceCounters.set(key, n + 1);
|
|
175
|
-
return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${
|
|
227
|
+
return `${String(n).padStart(GENESIS_SEQUENCE_DIGITS, '0')}${prefix}${GENESIS_LOCATION}${GENESIS_SYSTEM}`;
|
|
228
|
+
}
|
|
229
|
+
// Fills every generated field the record leaves out (undefined or null).
|
|
230
|
+
generateValues(tableName, config, row) {
|
|
231
|
+
for (const generator of config.generators) {
|
|
232
|
+
if (!isMissing(row[generator.field]))
|
|
233
|
+
continue;
|
|
234
|
+
if (generator.kind === 'SEQUENCE') {
|
|
235
|
+
row[generator.field] = this.generateSequenceValue(config, generator.prefix);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const counter = autoIncrementKey(tableName, generator.field);
|
|
239
|
+
const n = this.autoIncrementCounters.get(counter) ?? 1;
|
|
240
|
+
this.autoIncrementCounters.set(counter, n + 1);
|
|
241
|
+
row[generator.field] = n;
|
|
242
|
+
}
|
|
176
243
|
}
|
|
177
244
|
getAllRows(tableName) {
|
|
178
245
|
const table = this.tables.get(tableName);
|
|
@@ -214,26 +281,35 @@ export class Store {
|
|
|
214
281
|
}
|
|
215
282
|
return rows.find(matchesAllCriteria);
|
|
216
283
|
}
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
284
|
+
// A copy of the record with its missing generated fields filled in, as the
|
|
285
|
+
// database does before checking its unique indexes. Each value used is
|
|
286
|
+
// gone even if the record is then rejected, as a real sequence's is.
|
|
287
|
+
withGeneratedValues(tableName, record) {
|
|
288
|
+
const config = this.tableConfig.get(tableName);
|
|
289
|
+
if (!config)
|
|
290
|
+
throw new Error(`Unknown table: ${tableName}`);
|
|
291
|
+
const row = { ...record };
|
|
292
|
+
this.generateValues(tableName, config, row);
|
|
293
|
+
return row;
|
|
294
|
+
}
|
|
295
|
+
// Generates every missing (undefined or null) sequence and auto-increment
|
|
296
|
+
// field — the sequencePrefix key and TableDef.generated — and keeps a
|
|
297
|
+
// supplied one, as GSF does; stamps RECORD_ID/TIMESTAMP. A row already
|
|
298
|
+
// stored under the same key is replaced (the real database would reject the
|
|
299
|
+
// insert with DUPLICATE_KEY — check first and throw NackError.duplicateKey
|
|
300
|
+
// for that, as the generic CRUD events do).
|
|
221
301
|
insertRow(tableName, record) {
|
|
222
302
|
const table = this.tables.get(tableName);
|
|
223
303
|
const config = this.tableConfig.get(tableName);
|
|
224
304
|
if (!table || !config)
|
|
225
305
|
throw new Error(`Unknown table: ${tableName}`);
|
|
226
306
|
const row = { ...record };
|
|
227
|
-
|
|
228
|
-
(row[config.pkFields[0]] === undefined || row[config.pkFields[0]] === null) &&
|
|
229
|
-
config.sequencePrefix) {
|
|
230
|
-
row[config.pkFields[0]] = this.generateSequenceValue(config);
|
|
231
|
-
}
|
|
307
|
+
this.generateValues(tableName, config, row);
|
|
232
308
|
const missing = config.pkFields.filter((field) => row[field] === undefined);
|
|
233
309
|
if (missing.length > 0) {
|
|
234
310
|
throw new Error(`Primary key field(s) '${missing.join("', '")}' are required for inserting into table '${tableName}'`);
|
|
235
311
|
}
|
|
236
|
-
this.
|
|
312
|
+
this.observeGeneratedValues(tableName, config, row);
|
|
237
313
|
this.stampNewRow(row);
|
|
238
314
|
const key = keyFor(config.pkFields, row);
|
|
239
315
|
const displaced = table.get(key);
|
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
import { nackPayload, warningPayload } from "../protocol/errors.js";
|
|
1
|
+
import { NackError, 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
|
+
import { crudEventHandler } from "./crudEvents.js";
|
|
6
|
+
import { validateEventMessage } from "./eventValidation.js";
|
|
7
|
+
// The handler serving an event: an eventHandlers entry, else the generic CRUD
|
|
8
|
+
// handler a table registers through TableDef.events, else
|
|
9
|
+
// defaultEventHandler.
|
|
10
|
+
function resolveEventHandler(eventName, config) {
|
|
11
|
+
return (config.eventHandlers?.[eventName] ??
|
|
12
|
+
crudEventHandler(eventName, config) ??
|
|
13
|
+
config.defaultEventHandler);
|
|
14
|
+
}
|
|
5
15
|
// Commit events (EVENT_<ENTITY>_INSERT/_AMEND/_DELETE, or whatever suffix
|
|
6
16
|
// convention your project uses) are looked up by exact MESSAGE_TYPE against
|
|
7
|
-
// config.eventHandlers
|
|
8
|
-
//
|
|
9
|
-
// only, or throws
|
|
17
|
+
// config.eventHandlers, then the tables' generic CRUD events. A handler
|
|
18
|
+
// receives (details, ctx) and either returns { generated?: [...] } on
|
|
19
|
+
// success, { warnings: [...] } to NACK with warnings only, or throws
|
|
20
|
+
// NackError(errors) on failure.
|
|
10
21
|
export function handleCommitEvent(message, connection, config, store, ctx) {
|
|
11
22
|
const eventName = message.MESSAGE_TYPE;
|
|
12
23
|
// defaultEventHandler catches anything unregistered — lets projects with
|
|
13
24
|
// dozens of commit events adopt incrementally (e.g. `() => ({})` to ack
|
|
14
25
|
// everything unhandled) instead of enumerating ack-only handlers.
|
|
15
|
-
const handler =
|
|
26
|
+
const handler = resolveEventHandler(eventName, config);
|
|
16
27
|
// Replies use the GENERIC EVENT_ACK/EVENT_NACK — correlation is purely via
|
|
17
28
|
// SOURCE_REF. See the note on MESSAGE_TYPE in protocol/messageTypes.ts:
|
|
18
29
|
// real consumers (foundation-comms' kv.service, entity-management) match
|
|
@@ -34,14 +45,27 @@ export function handleCommitEvent(message, connection, config, store, ctx) {
|
|
|
34
45
|
: resourceNotMappedNack(eventName));
|
|
35
46
|
return;
|
|
36
47
|
}
|
|
48
|
+
// Before any handler runs, the message is checked against the event's
|
|
49
|
+
// declared schema (eventValidation.ts), as GSF's decoder does; each failure
|
|
50
|
+
// is a FieldError in one EVENT_NACK. Legacy fidelity never checked.
|
|
37
51
|
// A handler's failure is an EVENT_NACK with GSF's error items (see
|
|
38
52
|
// protocol/errors.ts): a thrown NackError gives its own, anything else is
|
|
39
53
|
// StandardError INTERNAL_ERROR. Returned warnings NACK too, with an empty
|
|
40
|
-
// ERROR — the IGNORE_WARNINGS
|
|
41
|
-
//
|
|
54
|
+
// ERROR — the IGNORE_WARNINGS flow that would let them through is not
|
|
55
|
+
// modelled yet. VALIDATE: true reaches the handler as ctx.validate; a
|
|
56
|
+
// handler that honours it acks without writing.
|
|
42
57
|
const legacy = isLegacyFidelity(config);
|
|
43
58
|
try {
|
|
44
|
-
const
|
|
59
|
+
const schemaErrors = legacy ? [] : validateEventMessage(message, connection, config, store);
|
|
60
|
+
if (schemaErrors.length > 0)
|
|
61
|
+
throw new NackError(schemaErrors);
|
|
62
|
+
const result = handler(message.DETAILS ?? {}, {
|
|
63
|
+
connection,
|
|
64
|
+
store,
|
|
65
|
+
message,
|
|
66
|
+
...ctx,
|
|
67
|
+
validate: message.VALIDATE === true,
|
|
68
|
+
}) ?? {};
|
|
45
69
|
if (result.warnings?.length) {
|
|
46
70
|
connection.reply(message, {
|
|
47
71
|
MESSAGE_TYPE: MESSAGE_TYPE.EVENT_NACK,
|