@genesislcap/mock-server 15.51.1 → 15.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -31
- package/dist/db/criteria.d.ts +1 -0
- package/dist/db/criteria.js +29 -0
- package/dist/db/identity.d.ts +11 -0
- package/dist/db/identity.js +46 -0
- package/dist/db/schema.d.ts +46 -0
- package/dist/db/schema.js +375 -0
- package/dist/db/store.d.ts +13 -2
- package/dist/db/store.js +208 -34
- package/dist/handlers/commitEvent.js +20 -6
- package/dist/handlers/dataLogon.js +126 -6
- package/dist/handlers/jsonSchema.d.ts +2 -1
- package/dist/handlers/jsonSchema.js +216 -61
- package/dist/handlers/meta.d.ts +6 -1
- package/dist/handlers/meta.js +164 -21
- package/dist/handlers/requestReply.js +16 -5
- package/dist/handlers/resourceAuth.d.ts +1 -0
- package/dist/handlers/resourceAuth.js +14 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/protocol/connection.d.ts +2 -0
- package/dist/protocol/errors.d.ts +100 -2
- package/dist/protocol/errors.js +300 -12
- package/dist/protocol/fieldTypes.d.ts +9 -0
- package/dist/protocol/fieldTypes.js +101 -0
- package/dist/protocol/gsfSchemas.d.ts +9 -0
- package/dist/protocol/gsfSchemas.js +423 -0
- package/dist/protocol/messageTypes.d.ts +1 -0
- package/dist/protocol/messageTypes.js +1 -0
- package/dist/protocol/rowUpdate.d.ts +5 -3
- package/dist/protocol/rowUpdate.js +33 -15
- package/dist/server.js +90 -28
- package/dist/types.d.ts +40 -0
- package/package.json +1 -1
- package/src/db/criteria.ts +26 -0
- package/src/db/identity.ts +53 -0
- package/src/db/schema.ts +523 -0
- package/src/db/store.ts +225 -32
- package/src/handlers/commitEvent.ts +20 -7
- package/src/handlers/dataLogon.ts +144 -7
- package/src/handlers/jsonSchema.ts +290 -69
- package/src/handlers/meta.ts +222 -23
- package/src/handlers/requestReply.ts +15 -6
- package/src/handlers/resourceAuth.ts +18 -0
- package/src/index.ts +16 -1
- package/src/protocol/connection.ts +10 -2
- package/src/protocol/errors.ts +361 -13
- package/src/protocol/fieldTypes.ts +108 -0
- package/src/protocol/gsfSchemas.ts +505 -0
- package/src/protocol/messageTypes.ts +1 -0
- package/src/protocol/rowUpdate.ts +40 -20
- package/src/server.ts +101 -27
- package/src/types.ts +145 -1
package/dist/server.js
CHANGED
|
@@ -3,11 +3,11 @@ import { WebSocketServer } from 'ws';
|
|
|
3
3
|
import { Store } from "./db/store.js";
|
|
4
4
|
import { pendingKey } from "./handlers/dataLogon.js";
|
|
5
5
|
import { createHttpRequestListener } from "./handlers/http.js";
|
|
6
|
-
import {
|
|
6
|
+
import { permittedQueryRow, requestingUser } from "./handlers/resourceAuth.js";
|
|
7
7
|
import { Connection } from "./protocol/connection.js";
|
|
8
8
|
import { isLegacyFidelity } from "./protocol/fidelity.js";
|
|
9
9
|
import { routeMessage } from "./protocol/router.js";
|
|
10
|
-
import { stampRow } from "./protocol/rowUpdate.js";
|
|
10
|
+
import { dataserverRow, stampRow } from "./protocol/rowUpdate.js";
|
|
11
11
|
export function createMockServer(config) {
|
|
12
12
|
const store = new Store();
|
|
13
13
|
for (const [tableName, tableDef] of Object.entries(config.tables ?? {})) {
|
|
@@ -80,10 +80,10 @@ export function createMockServer(config) {
|
|
|
80
80
|
return null;
|
|
81
81
|
const permitted = operation === 'DELETE'
|
|
82
82
|
? undefined
|
|
83
|
-
:
|
|
83
|
+
: permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
|
|
84
84
|
if (pending.has(key)) {
|
|
85
85
|
if (permitted)
|
|
86
|
-
pending.set(key, stampRow(permitted, rowRef, 'INSERT'));
|
|
86
|
+
pending.set(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'));
|
|
87
87
|
else
|
|
88
88
|
pending.delete(key);
|
|
89
89
|
return null;
|
|
@@ -96,10 +96,14 @@ export function createMockServer(config) {
|
|
|
96
96
|
}
|
|
97
97
|
if (!permitted)
|
|
98
98
|
return null;
|
|
99
|
+
// Under ORDER_BY, a new row that sorts among the waiting ones waits too,
|
|
100
|
+
// so MORE_ROWS pages stay in order.
|
|
101
|
+
if (subscription.queueInsert?.(key, stampRow(dataserverRow(permitted, row, query?.fields), rowRef, 'INSERT'), row))
|
|
102
|
+
return null;
|
|
99
103
|
held.add(key);
|
|
100
104
|
return 'INSERT';
|
|
101
105
|
}
|
|
102
|
-
|
|
106
|
+
function pushChange(resourceName, operation, row) {
|
|
103
107
|
const query = config.queries?.[resourceName];
|
|
104
108
|
// The query's own filter (where-clause equivalent) applies to live pushes
|
|
105
109
|
// too — a row the snapshot would exclude must not leak in via broadcast.
|
|
@@ -129,21 +133,25 @@ export function createMockServer(config) {
|
|
|
129
133
|
: resolved;
|
|
130
134
|
if (rowOperation === null)
|
|
131
135
|
continue;
|
|
136
|
+
const legacy = isLegacyFidelity(config);
|
|
132
137
|
let outRow = row;
|
|
133
138
|
if (rowOperation === 'DELETE') {
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
if (
|
|
139
|
+
// GSF's DELETE row is DETAILS {OPERATION, ROW_REF} and nothing else.
|
|
140
|
+
// Legacy mode sent the pk fields: the stub it was given for a real
|
|
141
|
+
// DELETE, or one rebuilt from the key for a change that left the
|
|
142
|
+
// subscription (the full row may carry columns hideFields strips).
|
|
143
|
+
if (!legacy)
|
|
144
|
+
outRow = {};
|
|
145
|
+
else if (operation !== 'DELETE') {
|
|
139
146
|
outRow = query ? store.rowStubFromKey(query.source, rowRef) : row;
|
|
140
147
|
}
|
|
141
148
|
}
|
|
142
|
-
else
|
|
143
|
-
|
|
149
|
+
else {
|
|
150
|
+
// Only the subscription's columns, minus the ones this user can't see.
|
|
151
|
+
const permitted = permittedQueryRow(row, query?.auth, requestingUser(connection), subscription.fieldNames);
|
|
144
152
|
if (!permitted)
|
|
145
153
|
continue;
|
|
146
|
-
outRow = permitted;
|
|
154
|
+
outRow = dataserverRow(permitted, row, query?.fields);
|
|
147
155
|
}
|
|
148
156
|
subscription.sequenceId += 1;
|
|
149
157
|
// A live push carries neither MORE_ROWS nor ROWS_COUNT on the real
|
|
@@ -151,20 +159,62 @@ export function createMockServer(config) {
|
|
|
151
159
|
connection.send({
|
|
152
160
|
MESSAGE_TYPE: 'QUERY_UPDATE',
|
|
153
161
|
SOURCE_REF: sourceRef,
|
|
154
|
-
ROW: [stampRow(outRow, rowRef, rowOperation)],
|
|
155
|
-
...(
|
|
162
|
+
ROW: [stampRow(outRow, rowRef, rowOperation, legacy)],
|
|
163
|
+
...(legacy ? { ROWS_COUNT: 1 } : {}),
|
|
156
164
|
SEQUENCE_ID: subscription.sequenceId,
|
|
157
165
|
});
|
|
158
166
|
}
|
|
159
167
|
}
|
|
168
|
+
}
|
|
169
|
+
// Pushes an unsolicited row update to every subscription on the resource
|
|
170
|
+
// (see pushChange). A DELETE given as a pk-only row is resolved to the
|
|
171
|
+
// record(s) removed from that key that no broadcast has announced yet —
|
|
172
|
+
// their RECORD_IDs are the ROW_REFs a client may hold — and those are
|
|
173
|
+
// consumed, so a later delete of a re-inserted key names its own record.
|
|
174
|
+
const broadcast = (resourceName, operation, row) => {
|
|
175
|
+
const source = config.queries?.[resourceName]?.source;
|
|
176
|
+
if (operation !== 'DELETE' || !source) {
|
|
177
|
+
pushChange(resourceName, operation, row);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const removed = store.takeRemovedRecordsFor(source, row);
|
|
181
|
+
if (isLegacyFidelity(config)) {
|
|
182
|
+
pushChange(resourceName, operation, row);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const records = row.RECORD_ID === undefined || row.RECORD_ID === null ? removed : [row];
|
|
186
|
+
if (records.length === 0)
|
|
187
|
+
records.push(row);
|
|
188
|
+
for (const record of records)
|
|
189
|
+
pushChange(resourceName, 'DELETE', record);
|
|
160
190
|
};
|
|
161
191
|
// Event handlers mutate one table (e.g. TRADE) but grids may be subscribed to
|
|
162
192
|
// a view built on top of it (e.g. ALL_TRADES -> TRADE_VIEW). This finds every
|
|
163
193
|
// query resource backed by that table — directly or via a view — and pushes
|
|
164
194
|
// the (re-joined, for views) row to each one's subscribers. `pk` is the
|
|
165
195
|
// store key: the single pk field's value, or store.rowKey(...) for
|
|
166
|
-
// composite-keyed tables.
|
|
196
|
+
// composite-keyed tables. ROW_REF is the record's RECORD_ID, so a DELETE is
|
|
197
|
+
// broadcast with every record the key lost that no broadcast announced yet
|
|
198
|
+
// (Store.takeRemovedRows; held-rows tracking drops the ones a client
|
|
199
|
+
// doesn't hold), and a record replaced under the same key (deleted, then
|
|
200
|
+
// re-inserted, before one broadcast) gets a DELETE of its old ROW_REF before
|
|
201
|
+
// the new one goes out. A view row is built from the changed base row
|
|
202
|
+
// alone, so it is found whatever the view's own pkField; a MODIFY that takes
|
|
203
|
+
// it out of the view (an anti-join) is pushed as a DELETE. Legacy ROW_REFs
|
|
204
|
+
// are the key itself, so there a DELETE is the pk stub and a replacement is
|
|
205
|
+
// the one INSERT/MODIFY, as before.
|
|
167
206
|
const broadcastTableChange = (tableName, operation, pk) => {
|
|
207
|
+
const legacy = isLegacyFidelity(config);
|
|
208
|
+
const current = operation === 'DELETE' ? undefined : store.getRow(tableName, pk);
|
|
209
|
+
let removed = store
|
|
210
|
+
.takeRemovedRows(tableName, pk)
|
|
211
|
+
.filter((record) => record.RECORD_ID !== current?.RECORD_ID);
|
|
212
|
+
// A DELETE broadcast before the row is removed still names it.
|
|
213
|
+
const live = store.getRow(tableName, pk);
|
|
214
|
+
if (operation === 'DELETE' && removed.length === 0 && live)
|
|
215
|
+
removed = [live];
|
|
216
|
+
if (legacy)
|
|
217
|
+
removed = [];
|
|
168
218
|
for (const [queryName, query] of Object.entries(config.queries ?? {})) {
|
|
169
219
|
const isDirect = query.source === tableName;
|
|
170
220
|
const view = store.views.get(query.source);
|
|
@@ -183,25 +233,37 @@ export function createMockServer(config) {
|
|
|
183
233
|
continue;
|
|
184
234
|
if (isViewJoiningTable) {
|
|
185
235
|
for (const viewRow of store.getViewRows(query.source)) {
|
|
186
|
-
|
|
236
|
+
pushChange(queryName, 'MODIFY', viewRow);
|
|
187
237
|
}
|
|
188
238
|
continue;
|
|
189
239
|
}
|
|
190
|
-
|
|
240
|
+
// The removed base records carry the RECORD_ID a view row's ROW_REF
|
|
241
|
+
// comes from too; the DELETE body is DETAILS only either way.
|
|
242
|
+
for (const record of removed)
|
|
243
|
+
pushChange(queryName, 'DELETE', record);
|
|
191
244
|
if (operation === 'DELETE') {
|
|
192
|
-
|
|
193
|
-
|
|
245
|
+
if (legacy) {
|
|
246
|
+
const stub = store.rowStubFromKey(query.source, pk);
|
|
247
|
+
if (Object.keys(stub).length > 0)
|
|
248
|
+
pushChange(queryName, 'DELETE', stub);
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!current)
|
|
253
|
+
continue;
|
|
254
|
+
if (isDirect) {
|
|
255
|
+
pushChange(queryName, operation, current);
|
|
256
|
+
continue;
|
|
194
257
|
}
|
|
195
|
-
|
|
196
|
-
|
|
258
|
+
const viewRow = store.getViewRows(query.source, { [tableName]: [current] })[0];
|
|
259
|
+
if (viewRow) {
|
|
260
|
+
pushChange(queryName, operation, viewRow);
|
|
197
261
|
}
|
|
198
|
-
else {
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
262
|
+
else if (operation === 'MODIFY') {
|
|
263
|
+
pushChange(queryName, 'DELETE', legacy
|
|
264
|
+
? store.rowStubFromKey(query.source, store.getRowRef(query.source, current))
|
|
265
|
+
: current);
|
|
202
266
|
}
|
|
203
|
-
if (row)
|
|
204
|
-
broadcast(queryName, operation, row);
|
|
205
267
|
}
|
|
206
268
|
};
|
|
207
269
|
const httpSessions = new Map(); // sessionId -> username, for the HTTP login transport (see handlers/http.ts)
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import type { Store } from './db/store.ts';
|
|
3
|
+
import type { NackErrorInput } from './protocol/errors.ts';
|
|
3
4
|
export type Row = Record<string, any>;
|
|
4
5
|
export interface GenesisMessage {
|
|
5
6
|
MESSAGE_TYPE: string;
|
|
@@ -7,12 +8,40 @@ export interface GenesisMessage {
|
|
|
7
8
|
DETAILS?: Row;
|
|
8
9
|
[key: string]: any;
|
|
9
10
|
}
|
|
11
|
+
export type GenesisFieldType = 'STRING' | 'ENUM' | 'INT' | 'SHORT' | 'LONG' | 'DOUBLE' | 'BIGDECIMAL' | 'BOOLEAN' | 'DATE' | 'DATETIME' | 'NANO_TIMESTAMP' | 'RAW';
|
|
12
|
+
export interface FieldDef {
|
|
13
|
+
name: string;
|
|
14
|
+
type: GenesisFieldType;
|
|
15
|
+
nullable?: boolean;
|
|
16
|
+
values?: string[];
|
|
17
|
+
default?: unknown;
|
|
18
|
+
maxSize?: number;
|
|
19
|
+
minLength?: number;
|
|
20
|
+
description?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
optional?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface GeneratedFieldDef {
|
|
25
|
+
field: string;
|
|
26
|
+
kind: 'SEQUENCE' | 'AUTO_INCREMENT';
|
|
27
|
+
prefix?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface IndexDef {
|
|
30
|
+
name: string;
|
|
31
|
+
fields: string[];
|
|
32
|
+
unique?: boolean;
|
|
33
|
+
}
|
|
10
34
|
export interface TableDef {
|
|
11
35
|
pkField: string | string[];
|
|
12
36
|
sequencePrefix?: string;
|
|
13
37
|
sequenceWidth?: number;
|
|
38
|
+
sequenceFormat?: SequenceFormat;
|
|
14
39
|
rows?: Row[];
|
|
40
|
+
fields?: FieldDef[];
|
|
41
|
+
generated?: GeneratedFieldDef[];
|
|
42
|
+
indexes?: IndexDef[];
|
|
15
43
|
}
|
|
44
|
+
export type SequenceFormat = 'prefix' | 'genesis';
|
|
16
45
|
export interface JoinDef {
|
|
17
46
|
table: string;
|
|
18
47
|
on: [string, string];
|
|
@@ -30,6 +59,8 @@ export interface ViewDef {
|
|
|
30
59
|
joins?: JoinDef[];
|
|
31
60
|
antiJoins?: AntiJoinDef[];
|
|
32
61
|
derived?: Record<string, (row: Row) => any>;
|
|
62
|
+
derivedTypes?: Record<string, GenesisFieldType | Omit<FieldDef, 'name'>>;
|
|
63
|
+
fields?: string[];
|
|
33
64
|
}
|
|
34
65
|
export interface RequestingUser {
|
|
35
66
|
userName?: string;
|
|
@@ -43,6 +74,8 @@ export interface ResourceAuthDef {
|
|
|
43
74
|
export interface QueryDef {
|
|
44
75
|
source: string;
|
|
45
76
|
fieldTypes?: Record<string, string>;
|
|
77
|
+
fields?: string[];
|
|
78
|
+
indexes?: IndexDef[];
|
|
46
79
|
filter?: (row: Row) => boolean;
|
|
47
80
|
auth?: ResourceAuthDef;
|
|
48
81
|
}
|
|
@@ -59,11 +92,15 @@ export interface RequestReplyDef {
|
|
|
59
92
|
fieldTypes?: Record<string, string>;
|
|
60
93
|
filter?: (row: Row) => boolean;
|
|
61
94
|
auth?: ResourceAuthDef;
|
|
95
|
+
criteriaOnly?: boolean;
|
|
96
|
+
requestFields?: string[];
|
|
62
97
|
}
|
|
63
98
|
export interface EventSchemaOverride {
|
|
64
99
|
table?: string;
|
|
65
100
|
pkOnly?: boolean;
|
|
66
101
|
fieldTypes?: Record<string, string>;
|
|
102
|
+
fields?: FieldDef[];
|
|
103
|
+
className?: string;
|
|
67
104
|
}
|
|
68
105
|
export interface ResourceItem {
|
|
69
106
|
RESOURCE_NAME: string;
|
|
@@ -104,6 +141,7 @@ export interface EventHandlerCtx {
|
|
|
104
141
|
}
|
|
105
142
|
export type EventHandlerResult = {
|
|
106
143
|
generated?: Row[];
|
|
144
|
+
warnings?: NackErrorInput[];
|
|
107
145
|
} | void;
|
|
108
146
|
export type EventHandler = (details: Row, ctx: EventHandlerCtx) => EventHandlerResult;
|
|
109
147
|
export interface HttpRouteDef {
|
|
@@ -133,10 +171,12 @@ export interface MockServerConfig {
|
|
|
133
171
|
httpRoutes?: HttpRouteDef[];
|
|
134
172
|
onMessage?: OnMessageHook;
|
|
135
173
|
productName?: string;
|
|
174
|
+
appName?: string;
|
|
136
175
|
}
|
|
137
176
|
export interface FieldMetadata {
|
|
138
177
|
NAME: string;
|
|
139
178
|
TYPE: string;
|
|
179
|
+
VALID_VALUES?: string[];
|
|
140
180
|
}
|
|
141
181
|
export interface SocketLike {
|
|
142
182
|
readyState: number;
|
package/package.json
CHANGED
package/src/db/criteria.ts
CHANGED
|
@@ -188,6 +188,9 @@ function compareByTime(
|
|
|
188
188
|
class Parser {
|
|
189
189
|
private tokens: string[];
|
|
190
190
|
private pos = 0;
|
|
191
|
+
// Every field the expression reads: bare identifiers in comparisons, and
|
|
192
|
+
// function-call arguments and receivers.
|
|
193
|
+
readonly fields = new Set<string>();
|
|
191
194
|
|
|
192
195
|
constructor(tokens: string[]) {
|
|
193
196
|
this.tokens = tokens;
|
|
@@ -273,6 +276,8 @@ class Parser {
|
|
|
273
276
|
throw new Error('Expected operand on the right side of comparison');
|
|
274
277
|
}
|
|
275
278
|
|
|
279
|
+
this.noteField(leftToken);
|
|
280
|
+
this.noteField(rightToken);
|
|
276
281
|
const resolveLeft = (record: Row) => this.resolveValue(leftToken, record);
|
|
277
282
|
const resolveRight = (record: Row) => this.resolveValue(rightToken, record);
|
|
278
283
|
|
|
@@ -300,6 +305,10 @@ class Parser {
|
|
|
300
305
|
};
|
|
301
306
|
}
|
|
302
307
|
|
|
308
|
+
noteField(token: string): void {
|
|
309
|
+
if (parseLiteral(token) === undefined) this.fields.add(token);
|
|
310
|
+
}
|
|
311
|
+
|
|
303
312
|
resolveValue(token: string, record: Row): unknown {
|
|
304
313
|
const literal = parseLiteral(token);
|
|
305
314
|
if (literal !== undefined) return literal;
|
|
@@ -331,6 +340,7 @@ class Parser {
|
|
|
331
340
|
const handler = EXPR_FUNCTIONS[fnName];
|
|
332
341
|
if (!handler) throw new Error(`Unsupported criteria function: ${name}`);
|
|
333
342
|
const allArgs = receiver === 'Expr' ? args : [receiver, ...args];
|
|
343
|
+
for (const arg of allArgs) this.noteField(arg);
|
|
334
344
|
|
|
335
345
|
const resolve: ExprResolver = (token, record) => this.resolveValue(token, record);
|
|
336
346
|
return (record: Row) => handler(record, allArgs, resolve);
|
|
@@ -364,6 +374,22 @@ export function compileCriteria(criteria?: string | null): Predicate {
|
|
|
364
374
|
}
|
|
365
375
|
}
|
|
366
376
|
|
|
377
|
+
// The fields a CRITERIA_MATCH reads, or undefined when it doesn't parse
|
|
378
|
+
// (compileCriteria then fails open). The real dataserver refuses a criteria
|
|
379
|
+
// naming a field its query doesn't expose (INVALID_CRITERIA).
|
|
380
|
+
export function criteriaFields(criteria?: string | null): string[] | undefined {
|
|
381
|
+
if (!criteria || typeof criteria !== 'string' || criteria.trim() === '') return [];
|
|
382
|
+
try {
|
|
383
|
+
const tokens = tokenize(criteria.trim());
|
|
384
|
+
const parser = new Parser(tokens);
|
|
385
|
+
parser.parseExpression();
|
|
386
|
+
if (parser.position !== tokens.length) return undefined;
|
|
387
|
+
return [...parser.fields];
|
|
388
|
+
} catch {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
367
393
|
export function filterByCriteria(rows: Row[], criteria?: string | null): Row[] {
|
|
368
394
|
const predicate = compileCriteria(criteria);
|
|
369
395
|
return rows.filter((row) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Record identity: the RECORD_ID and TIMESTAMP every Genesis table row carries.
|
|
2
|
+
//
|
|
3
|
+
// GSF sets TIMESTAMP from a high-resolution clock on every write and
|
|
4
|
+
// RECORD_ID = TIMESTAMP on insert (genesis-db InsertOperation.kt); a modify
|
|
5
|
+
// moves TIMESTAMP on and keeps RECORD_ID. The real values are around 7.5e18:
|
|
6
|
+
// epoch millis shifted left 22 bits, plus a counter. That is far above 2^53,
|
|
7
|
+
// so a JavaScript client's JSON.parse rounds neighbouring rows onto the same
|
|
8
|
+
// number. The mock deliberately stays below that: its stamps are a counter
|
|
9
|
+
// seeded from epoch millis that only goes up, so they stay exact, strictly
|
|
10
|
+
// increasing safe integers, and a client that reads TIMESTAMP as a date (the
|
|
11
|
+
// AI assistant shows a NANO_TIMESTAMP as epoch millis) still gets roughly the
|
|
12
|
+
// write time. The same order, a different magnitude.
|
|
13
|
+
|
|
14
|
+
import type { Row } from '../types.ts';
|
|
15
|
+
|
|
16
|
+
export const RECORD_ID = 'RECORD_ID';
|
|
17
|
+
export const TIMESTAMP = 'TIMESTAMP';
|
|
18
|
+
export const RECORD_FIELDS: readonly string[] = [RECORD_ID, TIMESTAMP];
|
|
19
|
+
|
|
20
|
+
// Hands out RECORD_ID/TIMESTAMP values: the current epoch millis, or one more
|
|
21
|
+
// than the last value when that is not ahead of it (seeding a thousand rows
|
|
22
|
+
// runs the counter a second or so ahead of the clock).
|
|
23
|
+
export class RecordClock {
|
|
24
|
+
private last = 0;
|
|
25
|
+
|
|
26
|
+
next(): number {
|
|
27
|
+
this.last = Math.max(Date.now(), this.last + 1);
|
|
28
|
+
return this.last;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Keeps later stamps above a value a seed row brought with it.
|
|
32
|
+
observe(value: unknown): void {
|
|
33
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value > this.last) {
|
|
34
|
+
this.last = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A row without its record identity — what a dataserver sends (the real
|
|
40
|
+
// dataserver never puts RECORD_ID or TIMESTAMP in a row, only
|
|
41
|
+
// DETAILS.ROW_REF) and what field metadata is derived from.
|
|
42
|
+
export function withoutRecordFields(row: Row): Row {
|
|
43
|
+
if (!(RECORD_ID in row) && !(TIMESTAMP in row)) return row;
|
|
44
|
+
const { [RECORD_ID]: _recordId, [TIMESTAMP]: _timestamp, ...rest } = row;
|
|
45
|
+
return rest;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The Map key a record is tracked under. RECORD_IDs are numbers here, but a
|
|
49
|
+
// seed may bring a recorded string, so they are compared as strings.
|
|
50
|
+
export function recordKey(row: Row | undefined | null): string | undefined {
|
|
51
|
+
const id = row?.[RECORD_ID];
|
|
52
|
+
return id === undefined || id === null ? undefined : String(id);
|
|
53
|
+
}
|