@genesislcap/mock-server 15.51.1 → 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 +408 -39
- package/dist/db/criteria.d.ts +13 -0
- package/dist/db/criteria.js +60 -21
- package/dist/db/identity.d.ts +11 -0
- package/dist/db/identity.js +46 -0
- package/dist/db/ordering.d.ts +8 -0
- package/dist/db/ordering.js +49 -0
- package/dist/db/schema.d.ts +62 -0
- package/dist/db/schema.js +490 -0
- package/dist/db/store.d.ts +20 -3
- package/dist/db/store.js +300 -50
- package/dist/handlers/commitEvent.js +47 -9
- package/dist/handlers/crudEvents.d.ts +2 -0
- package/dist/handlers/crudEvents.js +138 -0
- package/dist/handlers/dataLogon.js +105 -6
- package/dist/handlers/eventValidation.d.ts +5 -0
- package/dist/handlers/eventValidation.js +29 -0
- package/dist/handlers/jsonSchema.d.ts +4 -2
- package/dist/handlers/jsonSchema.js +222 -61
- package/dist/handlers/meta.d.ts +6 -1
- package/dist/handlers/meta.js +168 -21
- package/dist/handlers/requestReply.js +519 -30
- package/dist/handlers/resourceAuth.d.ts +1 -0
- package/dist/handlers/resourceAuth.js +14 -0
- package/dist/handlers/resources.js +3 -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 +10 -0
- package/dist/protocol/fieldTypes.js +107 -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/msgNack.d.ts +2 -0
- package/dist/protocol/msgNack.js +10 -0
- package/dist/protocol/rowUpdate.d.ts +5 -3
- package/dist/protocol/rowUpdate.js +33 -15
- package/dist/protocol/schemaValidation.d.ts +11 -0
- package/dist/protocol/schemaValidation.js +186 -0
- package/dist/server.js +95 -29
- package/dist/types.d.ts +45 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +72 -21
- package/src/db/identity.ts +53 -0
- package/src/db/ordering.ts +56 -0
- package/src/db/schema.ts +672 -0
- package/src/db/store.ts +343 -50
- package/src/handlers/commitEvent.ts +54 -10
- package/src/handlers/crudEvents.ts +181 -0
- package/src/handlers/dataLogon.ts +127 -7
- package/src/handlers/eventValidation.ts +43 -0
- package/src/handlers/jsonSchema.ts +302 -69
- package/src/handlers/meta.ts +227 -23
- package/src/handlers/requestReply.ts +626 -33
- package/src/handlers/resourceAuth.ts +18 -0
- package/src/handlers/resources.ts +3 -0
- package/src/index.ts +17 -1
- package/src/protocol/connection.ts +10 -2
- package/src/protocol/errors.ts +361 -13
- package/src/protocol/fieldTypes.ts +115 -0
- package/src/protocol/gsfSchemas.ts +505 -0
- package/src/protocol/messageTypes.ts +1 -0
- package/src/protocol/msgNack.ts +12 -0
- package/src/protocol/rowUpdate.ts +40 -20
- package/src/protocol/schemaValidation.ts +208 -0
- package/src/server.ts +106 -28
- package/src/types.ts +187 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Inbound schema validation, as GSF runs it on every event before any handler
|
|
2
|
+
// sees it: GenesisMessageDecoder validates the whole message against the
|
|
3
|
+
// event's JSON schema (the INBOUND that JSON_SCHEMA_REQUEST serves) and
|
|
4
|
+
// answers each failure with a FieldError VALIDATION_ERROR. GSF 8.15.29 uses
|
|
5
|
+
// networknt json-schema-validator 2.0.1, configured by
|
|
6
|
+
// GenesisJsonSchemaFactory (draft 2019-09, PathType.LEGACY,
|
|
7
|
+
// losslessNarrowing). This is the subset of that validator the engine's own
|
|
8
|
+
// event schemas need: type, const, enum, oneOf, properties, required,
|
|
9
|
+
// additionalProperties, minLength / maxLength, minimum / maximum and pattern.
|
|
10
|
+
// Texts, locations and their order follow networknt's output, checked by
|
|
11
|
+
// running the real validator on the recorded showcase schemas
|
|
12
|
+
// (test/schemaValidation.test.ts pins the cases).
|
|
13
|
+
//
|
|
14
|
+
// Order: at each schema, `type` and then `properties` are checked first and
|
|
15
|
+
// every other keyword after them in the schema's key order; `properties` in
|
|
16
|
+
// their key order; unknown properties in the instance's order. A failed
|
|
17
|
+
// `type` doesn't stop the rest (an enum still reports its own error).
|
|
18
|
+
// `oneOf` with no valid branch reports itself, then every branch's errors.
|
|
19
|
+
|
|
20
|
+
import type { NackErrorInput } from './errors.ts';
|
|
21
|
+
|
|
22
|
+
type Schema = Record<string, any>;
|
|
23
|
+
|
|
24
|
+
export interface SchemaError {
|
|
25
|
+
keyword: string;
|
|
26
|
+
// PathType.LEGACY: '$', '$.DETAILS', '$.DETAILS.NAME'.
|
|
27
|
+
instanceLocation: string;
|
|
28
|
+
// The missing property, for `required`; the unknown one, for
|
|
29
|
+
// `additionalProperties`.
|
|
30
|
+
property?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// networknt's JsonType names. With losslessNarrowing a number with no
|
|
35
|
+
// fraction is an integer (1.0 included, which JSON.parse makes 1 anyway).
|
|
36
|
+
function jsonTypeOf(value: unknown): string {
|
|
37
|
+
if (value === null) return 'null';
|
|
38
|
+
if (Array.isArray(value)) return 'array';
|
|
39
|
+
if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
|
|
40
|
+
return typeof value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function matchesType(value: unknown, type: string): boolean {
|
|
44
|
+
const actual = jsonTypeOf(value);
|
|
45
|
+
return actual === type || (type === 'number' && actual === 'integer');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
49
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
50
|
+
|
|
51
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
52
|
+
if (a === b) return true;
|
|
53
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
54
|
+
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
55
|
+
}
|
|
56
|
+
if (isObject(a) && isObject(b)) {
|
|
57
|
+
const keys = Object.keys(a);
|
|
58
|
+
return (
|
|
59
|
+
keys.length === Object.keys(b).length &&
|
|
60
|
+
keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// networknt evaluates these before a schema's other keywords, whatever their
|
|
67
|
+
// position; the rest go in key order.
|
|
68
|
+
const FIRST_KEYWORDS = ['type', 'properties'];
|
|
69
|
+
|
|
70
|
+
// String lengths count code points, as Java's codePointCount does.
|
|
71
|
+
const codePoints = (text: string) => [...text].length;
|
|
72
|
+
|
|
73
|
+
interface KeywordCheck {
|
|
74
|
+
arg: any;
|
|
75
|
+
schema: Schema;
|
|
76
|
+
value: unknown;
|
|
77
|
+
location: string;
|
|
78
|
+
errors: SchemaError[];
|
|
79
|
+
fail(message: string, property?: string): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const NOT_DEFINED =
|
|
83
|
+
'is not defined in the schema and the schema does not allow additional properties';
|
|
84
|
+
|
|
85
|
+
// One check per keyword, each a no-op on values it doesn't apply to (string
|
|
86
|
+
// keywords on non-strings, object keywords on non-objects, ...).
|
|
87
|
+
const KEYWORDS: Record<string, (check: KeywordCheck) => void> = {
|
|
88
|
+
type({ arg, value, fail }) {
|
|
89
|
+
if (typeof arg === 'string' && !matchesType(value, arg)) {
|
|
90
|
+
fail(`${jsonTypeOf(value)} found, ${arg} expected`);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
const({ arg, value, fail }) {
|
|
94
|
+
if (!deepEqual(value, arg)) fail(`must be the constant value '${String(arg)}'`);
|
|
95
|
+
},
|
|
96
|
+
enum({ arg, value, fail }) {
|
|
97
|
+
if (Array.isArray(arg) && !arg.some((candidate) => deepEqual(value, candidate))) {
|
|
98
|
+
const values = arg.map((item) => JSON.stringify(item)).join(', ');
|
|
99
|
+
fail(`does not have a value in the enumeration [${values}]`);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
minLength({ arg, value, fail }) {
|
|
103
|
+
if (typeof value === 'string' && codePoints(value) < arg) {
|
|
104
|
+
fail(`must be at least ${arg} characters long`);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
maxLength({ arg, value, fail }) {
|
|
108
|
+
if (typeof value === 'string' && codePoints(value) > arg) {
|
|
109
|
+
fail(`must be at most ${arg} characters long`);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
minimum({ arg, value, fail }) {
|
|
113
|
+
if (typeof value === 'number' && value < arg) fail(`must have a minimum value of ${arg}`);
|
|
114
|
+
},
|
|
115
|
+
maximum({ arg, value, fail }) {
|
|
116
|
+
if (typeof value === 'number' && value > arg) fail(`must have a maximum value of ${arg}`);
|
|
117
|
+
},
|
|
118
|
+
// JSON Schema patterns are unanchored: '[0-9]+' accepts 'x1y'.
|
|
119
|
+
pattern({ arg, value, fail }) {
|
|
120
|
+
if (typeof value === 'string' && !new RegExp(arg, 'u').test(value)) {
|
|
121
|
+
fail(`does not match the regex pattern ${arg}`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
oneOf({ arg, value, location, errors, fail }) {
|
|
125
|
+
if (!Array.isArray(arg)) return;
|
|
126
|
+
const results = arg.map((branch: Schema) => validateSchema(branch, value, location));
|
|
127
|
+
const valid = results.flatMap((result, index) => (result.length === 0 ? [index] : []));
|
|
128
|
+
if (valid.length === 1) return;
|
|
129
|
+
if (valid.length > 1) {
|
|
130
|
+
fail(
|
|
131
|
+
`must be valid to one and only one schema, but ${valid.length} are valid with indexes '${valid.join(', ')}'`,
|
|
132
|
+
);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
fail('must be valid to one and only one schema, but 0 are valid');
|
|
136
|
+
errors.push(...results.flat());
|
|
137
|
+
},
|
|
138
|
+
properties({ arg, value, location, errors }) {
|
|
139
|
+
if (!isObject(value) || !isObject(arg)) return;
|
|
140
|
+
for (const [name, propertySchema] of Object.entries(arg)) {
|
|
141
|
+
if (!Object.hasOwn(value, name)) continue;
|
|
142
|
+
errors.push(...validateSchema(propertySchema as Schema, value[name], `${location}.${name}`));
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
required({ arg, value, fail }) {
|
|
146
|
+
if (!isObject(value) || !Array.isArray(arg)) return;
|
|
147
|
+
for (const name of arg) {
|
|
148
|
+
if (!Object.hasOwn(value, name)) fail(`required property '${name}' not found`, name);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
additionalProperties({ arg, schema, value, location, errors, fail }) {
|
|
152
|
+
if (!isObject(value) || (arg !== false && !isObject(arg))) return;
|
|
153
|
+
const known = isObject(schema.properties) ? schema.properties : {};
|
|
154
|
+
for (const name of Object.keys(value)) {
|
|
155
|
+
if (Object.hasOwn(known, name)) continue;
|
|
156
|
+
if (arg === false) fail(`property '${name}' ${NOT_DEFINED}`, name);
|
|
157
|
+
else errors.push(...validateSchema(arg, value[name], `${location}.${name}`));
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
// Anything else is an annotation (description, title, default, readOnly,
|
|
161
|
+
// genesisType, $schema, $defs, ...) and validates nothing; networknt
|
|
162
|
+
// doesn't enforce readOnly unless asked to.
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function validateSchema(schema: Schema, value: unknown, location = '$'): SchemaError[] {
|
|
166
|
+
const errors: SchemaError[] = [];
|
|
167
|
+
if (!isObject(schema)) return errors;
|
|
168
|
+
const keywords = Object.keys(schema);
|
|
169
|
+
const ordered = [
|
|
170
|
+
...FIRST_KEYWORDS.filter((keyword) => keywords.includes(keyword)),
|
|
171
|
+
...keywords.filter((keyword) => !FIRST_KEYWORDS.includes(keyword)),
|
|
172
|
+
];
|
|
173
|
+
for (const keyword of ordered) {
|
|
174
|
+
if (!Object.hasOwn(KEYWORDS, keyword)) continue;
|
|
175
|
+
KEYWORDS[keyword]({
|
|
176
|
+
arg: schema[keyword],
|
|
177
|
+
schema,
|
|
178
|
+
value,
|
|
179
|
+
location,
|
|
180
|
+
errors,
|
|
181
|
+
fail: (message, property) =>
|
|
182
|
+
errors.push({
|
|
183
|
+
keyword,
|
|
184
|
+
instanceLocation: location,
|
|
185
|
+
...(property !== undefined ? { property } : {}),
|
|
186
|
+
message,
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return errors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// GenesisMessageDecoder's FieldError for each failure: TEXT is the error's
|
|
194
|
+
// toString() ("<location>: <message>"), FIELD the missing property for
|
|
195
|
+
// `required` and otherwise the last segment of the location (so an unknown
|
|
196
|
+
// DETAILS property names 'DETAILS'), PATH the location.
|
|
197
|
+
export function schemaFieldErrors(errors: SchemaError[]): NackErrorInput[] {
|
|
198
|
+
return errors.map((error) => ({
|
|
199
|
+
'@type': 'FieldError',
|
|
200
|
+
CODE: 'VALIDATION_ERROR',
|
|
201
|
+
TEXT: `${error.instanceLocation}: ${error.message}`,
|
|
202
|
+
FIELD:
|
|
203
|
+
error.keyword === 'required'
|
|
204
|
+
? error.property
|
|
205
|
+
: error.instanceLocation.slice(error.instanceLocation.lastIndexOf('.') + 1),
|
|
206
|
+
PATH: error.instanceLocation,
|
|
207
|
+
}));
|
|
208
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { WebSocketServer, type WebSocket } from 'ws';
|
|
3
3
|
import { Store } from './db/store.ts';
|
|
4
|
+
import { crudEventHandler } from './handlers/crudEvents.ts';
|
|
4
5
|
import { pendingKey } from './handlers/dataLogon.ts';
|
|
5
6
|
import { createHttpRequestListener } from './handlers/http.ts';
|
|
6
|
-
import {
|
|
7
|
+
import { permittedQueryRow, requestingUser } from './handlers/resourceAuth.ts';
|
|
7
8
|
import { Connection, type Subscription } from './protocol/connection.ts';
|
|
8
9
|
import { isLegacyFidelity } from './protocol/fidelity.ts';
|
|
9
10
|
import { routeMessage } from './protocol/router.ts';
|
|
10
|
-
import { stampRow, type RowOperation } from './protocol/rowUpdate.ts';
|
|
11
|
+
import { dataserverRow, stampRow, type RowOperation } from './protocol/rowUpdate.ts';
|
|
11
12
|
import type {
|
|
12
13
|
BroadcastFn,
|
|
13
14
|
BroadcastTableChangeFn,
|
|
@@ -141,9 +142,10 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
141
142
|
const permitted =
|
|
142
143
|
operation === 'DELETE'
|
|
143
144
|
? undefined
|
|
144
|
-
:
|
|
145
|
+
: permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
|
|
145
146
|
if (pending.has(key)) {
|
|
146
|
-
if (permitted)
|
|
147
|
+
if (permitted)
|
|
148
|
+
pending.set(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
|
|
147
149
|
else pending.delete(key);
|
|
148
150
|
return null;
|
|
149
151
|
}
|
|
@@ -153,11 +155,22 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
153
155
|
return 'DELETE';
|
|
154
156
|
}
|
|
155
157
|
if (!permitted) return null;
|
|
158
|
+
// Under ORDER_BY, a new row that sorts among the waiting ones waits too,
|
|
159
|
+
// so MORE_ROWS pages stay in order.
|
|
160
|
+
if (
|
|
161
|
+
subscription.queueInsert?.(
|
|
162
|
+
key,
|
|
163
|
+
stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'),
|
|
164
|
+
row,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return null;
|
|
168
|
+
|
|
156
169
|
held.add(key);
|
|
157
170
|
return 'INSERT';
|
|
158
171
|
}
|
|
159
172
|
|
|
160
|
-
|
|
173
|
+
function pushChange(resourceName: string, operation: RowOperation, row: Row): void {
|
|
161
174
|
const query = config.queries?.[resourceName];
|
|
162
175
|
// The query's own filter (where-clause equivalent) applies to live pushes
|
|
163
176
|
// too — a row the snapshot would exclude must not leak in via broadcast.
|
|
@@ -188,19 +201,27 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
188
201
|
? trackClientView(subscription, connection, query, resolved, row, rowRef)
|
|
189
202
|
: resolved;
|
|
190
203
|
if (rowOperation === null) continue;
|
|
204
|
+
const legacy = isLegacyFidelity(config);
|
|
191
205
|
let outRow = row;
|
|
192
206
|
if (rowOperation === 'DELETE') {
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
if (
|
|
207
|
+
// GSF's DELETE row is DETAILS {OPERATION, ROW_REF} and nothing else.
|
|
208
|
+
// Legacy mode sent the pk fields: the stub it was given for a real
|
|
209
|
+
// DELETE, or one rebuilt from the key for a change that left the
|
|
210
|
+
// subscription (the full row may carry columns hideFields strips).
|
|
211
|
+
if (!legacy) outRow = {};
|
|
212
|
+
else if (operation !== 'DELETE') {
|
|
198
213
|
outRow = query ? store.rowStubFromKey(query.source, rowRef as string) : row;
|
|
199
214
|
}
|
|
200
|
-
} else
|
|
201
|
-
|
|
215
|
+
} else {
|
|
216
|
+
// Only the subscription's columns, minus the ones this user can't see.
|
|
217
|
+
const permitted = permittedQueryRow(
|
|
218
|
+
row,
|
|
219
|
+
query?.auth,
|
|
220
|
+
requestingUser(connection),
|
|
221
|
+
subscription.fieldNames,
|
|
222
|
+
);
|
|
202
223
|
if (!permitted) continue;
|
|
203
|
-
outRow = permitted;
|
|
224
|
+
outRow = dataserverRow(permitted, row, query?.fields);
|
|
204
225
|
}
|
|
205
226
|
subscription.sequenceId += 1;
|
|
206
227
|
// A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
|
|
@@ -208,12 +229,33 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
208
229
|
connection.send({
|
|
209
230
|
MESSAGE_TYPE: 'QUERY_UPDATE',
|
|
210
231
|
SOURCE_REF: sourceRef,
|
|
211
|
-
ROW: [stampRow(outRow, rowRef, rowOperation)],
|
|
212
|
-
...(
|
|
232
|
+
ROW: [stampRow(outRow, rowRef, rowOperation, legacy)],
|
|
233
|
+
...(legacy ? { ROWS_COUNT: 1 } : {}),
|
|
213
234
|
SEQUENCE_ID: subscription.sequenceId,
|
|
214
235
|
});
|
|
215
236
|
}
|
|
216
237
|
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Pushes an unsolicited row update to every subscription on the resource
|
|
241
|
+
// (see pushChange). A DELETE given as a pk-only row is resolved to the
|
|
242
|
+
// record(s) removed from that key that no broadcast has announced yet —
|
|
243
|
+
// their RECORD_IDs are the ROW_REFs a client may hold — and those are
|
|
244
|
+
// consumed, so a later delete of a re-inserted key names its own record.
|
|
245
|
+
const broadcast: BroadcastFn = (resourceName, operation, row) => {
|
|
246
|
+
const source = config.queries?.[resourceName]?.source;
|
|
247
|
+
if (operation !== 'DELETE' || !source) {
|
|
248
|
+
pushChange(resourceName, operation, row);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const removed = store.takeRemovedRecordsFor(source, row);
|
|
252
|
+
if (isLegacyFidelity(config)) {
|
|
253
|
+
pushChange(resourceName, operation, row);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const records = row.RECORD_ID === undefined || row.RECORD_ID === null ? removed : [row];
|
|
257
|
+
if (records.length === 0) records.push(row);
|
|
258
|
+
for (const record of records) pushChange(resourceName, 'DELETE', record);
|
|
217
259
|
};
|
|
218
260
|
|
|
219
261
|
// Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
|
|
@@ -221,8 +263,26 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
221
263
|
// query resource backed by that table — directly or via a view — and pushes
|
|
222
264
|
// the (re-joined, for views) row to each one's subscribers. `pk` is the
|
|
223
265
|
// store key: the single pk field's value, or store.rowKey(...) for
|
|
224
|
-
// composite-keyed tables.
|
|
266
|
+
// composite-keyed tables. ROW_REF is the record's RECORD_ID, so a DELETE is
|
|
267
|
+
// broadcast with every record the key lost that no broadcast announced yet
|
|
268
|
+
// (Store.takeRemovedRows; held-rows tracking drops the ones a client
|
|
269
|
+
// doesn't hold), and a record replaced under the same key (deleted, then
|
|
270
|
+
// re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
|
|
271
|
+
// the new one goes out. A view row is built from the changed base row
|
|
272
|
+
// alone, so it is found whatever the view's own pkField; a MODIFY that takes
|
|
273
|
+
// it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
|
|
274
|
+
// are the key itself, so there a DELETE is the pk stub and a replacement is
|
|
275
|
+
// the one INSERT/MODIFY, as before.
|
|
225
276
|
const broadcastTableChange: BroadcastTableChangeFn = (tableName, operation, pk) => {
|
|
277
|
+
const legacy = isLegacyFidelity(config);
|
|
278
|
+
const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
|
|
279
|
+
let removed = store
|
|
280
|
+
.takeRemovedRows(tableName, pk)
|
|
281
|
+
.filter((record) => record.RECORD_ID !== current?.RECORD_ID);
|
|
282
|
+
// A DELETE broadcast before the row is removed still names it.
|
|
283
|
+
const live = store.getRow(tableName, pk);
|
|
284
|
+
if (operation === 'DELETE' && removed.length === 0 && live) removed = [live];
|
|
285
|
+
if (legacy) removed = [];
|
|
226
286
|
for (const [queryName, query] of Object.entries(config.queries ?? {})) {
|
|
227
287
|
const isDirect = query.source === tableName;
|
|
228
288
|
const view = store.views.get(query.source);
|
|
@@ -242,23 +302,38 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
242
302
|
|
|
243
303
|
if (isViewJoiningTable) {
|
|
244
304
|
for (const viewRow of store.getViewRows(query.source)) {
|
|
245
|
-
|
|
305
|
+
pushChange(queryName, 'MODIFY', viewRow);
|
|
246
306
|
}
|
|
247
307
|
continue;
|
|
248
308
|
}
|
|
249
309
|
|
|
250
|
-
|
|
310
|
+
// The removed base records carry the RECORD_ID a view row's ROW_REF
|
|
311
|
+
// comes from too; the DELETE body is DETAILS only either way.
|
|
312
|
+
for (const record of removed) pushChange(queryName, 'DELETE', record);
|
|
251
313
|
if (operation === 'DELETE') {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
314
|
+
if (legacy) {
|
|
315
|
+
const stub = store.rowStubFromKey(query.source, pk);
|
|
316
|
+
if (Object.keys(stub).length > 0) pushChange(queryName, 'DELETE', stub);
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (!current) continue;
|
|
321
|
+
if (isDirect) {
|
|
322
|
+
pushChange(queryName, operation, current);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
|
|
326
|
+
if (viewRow) {
|
|
327
|
+
pushChange(queryName, operation, viewRow);
|
|
328
|
+
} else if (operation === 'MODIFY') {
|
|
329
|
+
pushChange(
|
|
330
|
+
queryName,
|
|
331
|
+
'DELETE',
|
|
332
|
+
legacy
|
|
333
|
+
? store.rowStubFromKey(query.source, store.getRowRef(query.source, current) as string)
|
|
334
|
+
: current,
|
|
335
|
+
);
|
|
260
336
|
}
|
|
261
|
-
if (row) broadcast(queryName, operation, row);
|
|
262
337
|
}
|
|
263
338
|
};
|
|
264
339
|
|
|
@@ -310,8 +385,11 @@ export function createMockServer(config: MockServerConfig): MockServer {
|
|
|
310
385
|
return config.requestReplies?.[resourceName];
|
|
311
386
|
}
|
|
312
387
|
|
|
388
|
+
// An eventHandlers entry, else the generic CRUD handler a table registers
|
|
389
|
+
// (TableDef.events) — so extendEventHandler can wrap a generic event; the
|
|
390
|
+
// wrapper is then an explicit handler, which wins.
|
|
313
391
|
function getEventHandler(eventName: string): EventHandler | undefined {
|
|
314
|
-
return config.eventHandlers?.[eventName];
|
|
392
|
+
return config.eventHandlers?.[eventName] ?? crudEventHandler(eventName, config);
|
|
315
393
|
}
|
|
316
394
|
|
|
317
395
|
// augment(current) => replacement. `current` is undefined if nothing was
|