@spooky-sync/core 0.0.0-canary.1
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 +21 -0
- package/dist/index.d.ts +590 -0
- package/dist/index.js +3082 -0
- package/package.json +46 -0
- package/src/events/events.test.ts +242 -0
- package/src/events/index.ts +261 -0
- package/src/index.ts +3 -0
- package/src/modules/auth/events/index.ts +18 -0
- package/src/modules/auth/index.ts +267 -0
- package/src/modules/cache/index.ts +241 -0
- package/src/modules/cache/types.ts +19 -0
- package/src/modules/data/data.test.ts +58 -0
- package/src/modules/data/index.ts +777 -0
- package/src/modules/devtools/index.ts +364 -0
- package/src/modules/sync/engine.ts +163 -0
- package/src/modules/sync/events/index.ts +77 -0
- package/src/modules/sync/index.ts +3 -0
- package/src/modules/sync/queue/index.ts +2 -0
- package/src/modules/sync/queue/queue-down.ts +89 -0
- package/src/modules/sync/queue/queue-up.ts +223 -0
- package/src/modules/sync/scheduler.ts +84 -0
- package/src/modules/sync/sync.ts +407 -0
- package/src/modules/sync/utils.test.ts +311 -0
- package/src/modules/sync/utils.ts +171 -0
- package/src/services/database/database.ts +108 -0
- package/src/services/database/events/index.ts +32 -0
- package/src/services/database/index.ts +5 -0
- package/src/services/database/local-migrator.ts +203 -0
- package/src/services/database/local.ts +99 -0
- package/src/services/database/remote.ts +110 -0
- package/src/services/logger/index.ts +118 -0
- package/src/services/persistence/localstorage.ts +26 -0
- package/src/services/persistence/surrealdb.ts +62 -0
- package/src/services/stream-processor/index.ts +364 -0
- package/src/services/stream-processor/stream-processor.test.ts +140 -0
- package/src/services/stream-processor/wasm-types.ts +31 -0
- package/src/spooky.ts +346 -0
- package/src/types.ts +237 -0
- package/src/utils/error-classification.ts +28 -0
- package/src/utils/index.ts +172 -0
- package/src/utils/parser.test.ts +125 -0
- package/src/utils/parser.ts +46 -0
- package/src/utils/surql.ts +182 -0
- package/src/utils/utils.test.ts +152 -0
- package/src/utils/withRetry.test.ts +153 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import init, { SpookyProcessor } from '@spooky-sync/ssp-wasm';
|
|
2
|
+
import { EventDefinition, EventSystem } from '../../events/index';
|
|
3
|
+
import { Logger } from 'pino';
|
|
4
|
+
import { LocalDatabaseService } from '../database/index';
|
|
5
|
+
import { WasmProcessor, WasmStreamUpdate } from './wasm-types';
|
|
6
|
+
import { Duration } from 'surrealdb';
|
|
7
|
+
import { PersistenceClient, QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
8
|
+
|
|
9
|
+
// Simple interface for query plan registration (replaces Incantation class)
|
|
10
|
+
interface QueryPlanConfig {
|
|
11
|
+
queryHash: string;
|
|
12
|
+
surql: string;
|
|
13
|
+
params: Record<string, any>;
|
|
14
|
+
ttl: QueryTimeToLive | Duration;
|
|
15
|
+
lastActiveAt: Date;
|
|
16
|
+
localArray: RecordVersionArray;
|
|
17
|
+
remoteArray: RecordVersionArray;
|
|
18
|
+
meta: {
|
|
19
|
+
tableName: string;
|
|
20
|
+
involvedTables?: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Define the shape of an update from the Wasm module
|
|
25
|
+
// Matches MaterializedViewUpdate struct
|
|
26
|
+
export interface StreamUpdate {
|
|
27
|
+
queryHash: string;
|
|
28
|
+
localArray: RecordVersionArray;
|
|
29
|
+
op?: 'CREATE' | 'UPDATE' | 'DELETE'; // Operation type for conditional debouncing
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Define events map (kept for DevTools compatibility)
|
|
33
|
+
export type StreamProcessorEvents = {
|
|
34
|
+
stream_update: EventDefinition<'stream_update', StreamUpdate[]>;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Interface for receiving stream updates directly.
|
|
38
|
+
* Implemented by DataManager and DevToolsService for direct coupling.
|
|
39
|
+
*/
|
|
40
|
+
export interface StreamUpdateReceiver {
|
|
41
|
+
onStreamUpdate(update: StreamUpdate): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class StreamProcessorService {
|
|
45
|
+
private logger: Logger;
|
|
46
|
+
private processor: WasmProcessor | undefined;
|
|
47
|
+
private isInitialized = false;
|
|
48
|
+
private receivers: StreamUpdateReceiver[] = [];
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
public events: EventSystem<StreamProcessorEvents>,
|
|
52
|
+
private db: LocalDatabaseService,
|
|
53
|
+
private persistenceClient: PersistenceClient,
|
|
54
|
+
logger: Logger
|
|
55
|
+
) {
|
|
56
|
+
this.logger = logger.child({ name: 'StreamProcessorService' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add a receiver for stream updates.
|
|
61
|
+
* Multiple receivers can be registered (DataManager, DevTools, etc.)
|
|
62
|
+
*/
|
|
63
|
+
addReceiver(receiver: StreamUpdateReceiver) {
|
|
64
|
+
this.receivers.push(receiver);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private notifyUpdates(updates: StreamUpdate[]) {
|
|
68
|
+
for (const update of updates) {
|
|
69
|
+
for (const receiver of this.receivers) {
|
|
70
|
+
receiver.onStreamUpdate(update);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Initialize the WASM module and processor.
|
|
77
|
+
* This must be called before using other methods.
|
|
78
|
+
*/
|
|
79
|
+
async init() {
|
|
80
|
+
if (this.isInitialized) return;
|
|
81
|
+
|
|
82
|
+
this.logger.info(
|
|
83
|
+
{ Category: 'spooky-client::StreamProcessorService::init' },
|
|
84
|
+
'Initializing WASM...'
|
|
85
|
+
);
|
|
86
|
+
try {
|
|
87
|
+
await init(); // Initialize the WASM module (web target)
|
|
88
|
+
// We cast the generated SpookyProcessor to our interface which is safer
|
|
89
|
+
this.processor = new SpookyProcessor() as unknown as WasmProcessor;
|
|
90
|
+
|
|
91
|
+
// Try to load state
|
|
92
|
+
await this.loadState();
|
|
93
|
+
|
|
94
|
+
this.isInitialized = true;
|
|
95
|
+
this.logger.info(
|
|
96
|
+
{ Category: 'spooky-client::StreamProcessorService::init' },
|
|
97
|
+
'Initialized successfully'
|
|
98
|
+
);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
this.logger.error(
|
|
101
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::init' },
|
|
102
|
+
'Failed to initialize'
|
|
103
|
+
);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async loadState() {
|
|
109
|
+
if (!this.processor) return;
|
|
110
|
+
try {
|
|
111
|
+
const result = await this.persistenceClient.get('_spooky_stream_processor_state');
|
|
112
|
+
|
|
113
|
+
// Check if we have a valid result from the query
|
|
114
|
+
if (
|
|
115
|
+
Array.isArray(result) &&
|
|
116
|
+
result.length > 0 &&
|
|
117
|
+
Array.isArray(result[0]) &&
|
|
118
|
+
result[0].length > 0 &&
|
|
119
|
+
result[0][0]?.state
|
|
120
|
+
) {
|
|
121
|
+
const state = result[0][0].state;
|
|
122
|
+
this.logger.info(
|
|
123
|
+
{
|
|
124
|
+
stateLength: state.length,
|
|
125
|
+
Category: 'spooky-client::StreamProcessorService::loadState',
|
|
126
|
+
},
|
|
127
|
+
'Loading state from DB'
|
|
128
|
+
);
|
|
129
|
+
// Assuming processor has a load_state method matching the save_state behavior
|
|
130
|
+
// If not, we might need to adjust based on the actual WASM API
|
|
131
|
+
if (typeof (this.processor as any).load_state === 'function') {
|
|
132
|
+
(this.processor as any).load_state(state);
|
|
133
|
+
} else {
|
|
134
|
+
this.logger.warn(
|
|
135
|
+
{ Category: 'spooky-client::StreamProcessorService::loadState' },
|
|
136
|
+
'load_state method not found on processor'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
this.logger.info(
|
|
141
|
+
{ Category: 'spooky-client::StreamProcessorService::loadState' },
|
|
142
|
+
'No saved state found'
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
this.logger.error(
|
|
147
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::loadState' },
|
|
148
|
+
'Failed to load state'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async saveState() {
|
|
154
|
+
if (!this.processor) return;
|
|
155
|
+
try {
|
|
156
|
+
// Assuming processor has a save_state method that returns the state string/bytes
|
|
157
|
+
if (typeof (this.processor as any).save_state === 'function') {
|
|
158
|
+
const state = (this.processor as any).save_state();
|
|
159
|
+
if (state) {
|
|
160
|
+
await this.persistenceClient.set('_spooky_stream_processor_state', state);
|
|
161
|
+
this.logger.trace(
|
|
162
|
+
{ Category: 'spooky-client::StreamProcessorService::saveState' },
|
|
163
|
+
'State saved'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
this.logger.error(
|
|
169
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::saveState' },
|
|
170
|
+
'Failed to save state'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Ingest a record change into the processor.
|
|
177
|
+
* Emits 'stream_update' event if materialized views are affected.
|
|
178
|
+
* @param isOptimistic true = local mutation (increment versions), false = remote sync (keep versions)
|
|
179
|
+
*/
|
|
180
|
+
ingest(
|
|
181
|
+
table: string,
|
|
182
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE',
|
|
183
|
+
id: string,
|
|
184
|
+
record: any
|
|
185
|
+
): WasmStreamUpdate[] {
|
|
186
|
+
this.logger.debug(
|
|
187
|
+
{
|
|
188
|
+
table,
|
|
189
|
+
op,
|
|
190
|
+
id,
|
|
191
|
+
Category: 'spooky-client::StreamProcessorService::ingest',
|
|
192
|
+
},
|
|
193
|
+
'Ingesting into ssp'
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!this.processor) {
|
|
197
|
+
this.logger.warn(
|
|
198
|
+
{ Category: 'spooky-client::StreamProcessorService::ingest' },
|
|
199
|
+
'Not initialized, skipping ingest'
|
|
200
|
+
);
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const normalizedRecord = this.normalizeValue(record);
|
|
206
|
+
|
|
207
|
+
const rawUpdates = this.processor.ingest(table, op, id, normalizedRecord);
|
|
208
|
+
this.logger.debug(
|
|
209
|
+
{
|
|
210
|
+
table,
|
|
211
|
+
op,
|
|
212
|
+
id,
|
|
213
|
+
rawUpdates: rawUpdates.length,
|
|
214
|
+
Category: 'spooky-client::StreamProcessorService::ingest',
|
|
215
|
+
},
|
|
216
|
+
'Ingesting into ssp done'
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (rawUpdates && Array.isArray(rawUpdates) && rawUpdates.length > 0) {
|
|
220
|
+
const updates: StreamUpdate[] = rawUpdates.map((u: WasmStreamUpdate) => ({
|
|
221
|
+
queryHash: u.query_id,
|
|
222
|
+
localArray: u.result_data,
|
|
223
|
+
op: op,
|
|
224
|
+
}));
|
|
225
|
+
// Direct handler call instead of event
|
|
226
|
+
this.notifyUpdates(updates);
|
|
227
|
+
}
|
|
228
|
+
this.saveState();
|
|
229
|
+
return rawUpdates;
|
|
230
|
+
} catch (e) {
|
|
231
|
+
this.logger.error(
|
|
232
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::ingest' },
|
|
233
|
+
'Ingesting into ssp failed'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Register a new query plan.
|
|
241
|
+
* Emits 'stream_update' with the initial result.
|
|
242
|
+
*/
|
|
243
|
+
registerQueryPlan(queryPlan: QueryPlanConfig) {
|
|
244
|
+
if (!this.processor) {
|
|
245
|
+
this.logger.warn(
|
|
246
|
+
{ Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
|
|
247
|
+
'Not initialized, skipping registration'
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.logger.debug(
|
|
253
|
+
{
|
|
254
|
+
queryHash: queryPlan.queryHash,
|
|
255
|
+
surql: queryPlan.surql,
|
|
256
|
+
params: queryPlan.params,
|
|
257
|
+
Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
|
|
258
|
+
},
|
|
259
|
+
'Registering query plan'
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const normalizedParams = this.normalizeValue(queryPlan.params);
|
|
264
|
+
|
|
265
|
+
const initialUpdate = this.processor.register_view({
|
|
266
|
+
id: queryPlan.queryHash,
|
|
267
|
+
surql: queryPlan.surql,
|
|
268
|
+
params: normalizedParams,
|
|
269
|
+
clientId: 'local',
|
|
270
|
+
ttl: queryPlan.ttl.toString(),
|
|
271
|
+
lastActiveAt: new Date().toISOString(),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
this.logger.debug(
|
|
275
|
+
{ initialUpdate, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
|
|
276
|
+
'register_view result'
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (!initialUpdate) {
|
|
280
|
+
throw new Error('Failed to register query plan');
|
|
281
|
+
}
|
|
282
|
+
const update: StreamUpdate = {
|
|
283
|
+
queryHash: initialUpdate.query_id,
|
|
284
|
+
localArray: initialUpdate.result_data,
|
|
285
|
+
};
|
|
286
|
+
this.saveState();
|
|
287
|
+
this.logger.debug(
|
|
288
|
+
{
|
|
289
|
+
queryHash: queryPlan.queryHash,
|
|
290
|
+
surql: queryPlan.surql,
|
|
291
|
+
params: queryPlan.params,
|
|
292
|
+
Category: 'spooky-client::StreamProcessorService::registerQueryPlan',
|
|
293
|
+
},
|
|
294
|
+
'Registered query plan'
|
|
295
|
+
);
|
|
296
|
+
return update;
|
|
297
|
+
} catch (e) {
|
|
298
|
+
this.logger.error(
|
|
299
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::registerQueryPlan' },
|
|
300
|
+
'Error registering query plan'
|
|
301
|
+
);
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Unregister a query plan by ID.
|
|
308
|
+
*/
|
|
309
|
+
unregisterQueryPlan(queryHash: string) {
|
|
310
|
+
if (!this.processor) return;
|
|
311
|
+
try {
|
|
312
|
+
this.processor.unregister_view(queryHash);
|
|
313
|
+
this.saveState();
|
|
314
|
+
} catch (e) {
|
|
315
|
+
this.logger.error(
|
|
316
|
+
{ error: e, Category: 'spooky-client::StreamProcessorService::unregisterQueryPlan' },
|
|
317
|
+
'Error unregistering query plan'
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private normalizeValue(value: any): any {
|
|
323
|
+
if (value === null || value === undefined) return value;
|
|
324
|
+
|
|
325
|
+
if (typeof value === 'object') {
|
|
326
|
+
// RecordId detection using duck typing (constructor.name may be minified)
|
|
327
|
+
// SurrealDB's RecordId has: table (getter returning Table), id, and toString()
|
|
328
|
+
// Check for table getter that has its own toString AND id property
|
|
329
|
+
const hasTable = 'table' in value && typeof value.table?.toString === 'function';
|
|
330
|
+
const hasId = 'id' in value;
|
|
331
|
+
const hasToString = typeof value.toString === 'function';
|
|
332
|
+
const isNotPlainObject = value.constructor !== Object;
|
|
333
|
+
|
|
334
|
+
if (hasTable && hasId && hasToString && isNotPlainObject) {
|
|
335
|
+
const result = value.toString();
|
|
336
|
+
this.logger.trace(
|
|
337
|
+
{ result, Category: 'spooky-client::StreamProcessorService::normalizeValue' },
|
|
338
|
+
'RecordId detected'
|
|
339
|
+
);
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Fallback: old check for objects with tb and id (some internal representations)
|
|
344
|
+
if ('tb' in value && 'id' in value && !('table' in value)) {
|
|
345
|
+
return `${value.tb}:${value.id}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Handle arrays recursively
|
|
349
|
+
if (Array.isArray(value)) {
|
|
350
|
+
return value.map((v) => this.normalizeValue(v));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Handle plain objects recursively
|
|
354
|
+
if (value.constructor === Object) {
|
|
355
|
+
const out: any = {};
|
|
356
|
+
for (const k in value) {
|
|
357
|
+
out[k] = this.normalizeValue(value[k]);
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests for StreamProcessor WASM module integration.
|
|
5
|
+
* Verifies RecordId normalization and view update mechanics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Mock RecordId class that mimics surrealdb's RecordId behavior
|
|
9
|
+
class MockRecordId {
|
|
10
|
+
private _table: string;
|
|
11
|
+
private _id: string;
|
|
12
|
+
|
|
13
|
+
constructor(table: string, id: string) {
|
|
14
|
+
this._table = table;
|
|
15
|
+
this._id = id;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get table() {
|
|
19
|
+
return { toString: () => this._table };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get id() {
|
|
23
|
+
return this._id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
toString() {
|
|
27
|
+
return `${this._table}:⟨${this._id}⟩`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Helper: Mock the normalizeValue logic for testing
|
|
32
|
+
function normalizeValue(value: any): any {
|
|
33
|
+
if (value === null || value === undefined) return value;
|
|
34
|
+
|
|
35
|
+
if (typeof value === 'object') {
|
|
36
|
+
// RecordId detection: check constructor name or presence of table + id getters
|
|
37
|
+
if (
|
|
38
|
+
value.constructor?.name === 'MockRecordId' ||
|
|
39
|
+
value.constructor?.name === 'RecordId' ||
|
|
40
|
+
(typeof value.toString === 'function' &&
|
|
41
|
+
'table' in value &&
|
|
42
|
+
'id' in value &&
|
|
43
|
+
value.constructor?.name !== 'Object')
|
|
44
|
+
) {
|
|
45
|
+
return value.toString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback: old check for objects with tb and id
|
|
49
|
+
if ('tb' in value && 'id' in value) {
|
|
50
|
+
return `${value.tb}:${value.id}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.map((v) => normalizeValue(v));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (value.constructor === Object) {
|
|
58
|
+
const out: any = {};
|
|
59
|
+
for (const k in value) {
|
|
60
|
+
out[k] = normalizeValue(value[k]);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('RecordId Normalization', () => {
|
|
69
|
+
it('should convert RecordId to string using toString()', () => {
|
|
70
|
+
const recordId = new MockRecordId('user', '123');
|
|
71
|
+
const normalized = normalizeValue(recordId);
|
|
72
|
+
|
|
73
|
+
expect(normalized).toBe('user:⟨123⟩');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should convert RecordId with complex id to string', () => {
|
|
77
|
+
const recordId = new MockRecordId('thread', 'f442770c628647999b7d1e188787dac8');
|
|
78
|
+
const normalized = normalizeValue(recordId);
|
|
79
|
+
|
|
80
|
+
expect(typeof normalized).toBe('string');
|
|
81
|
+
expect(normalized).toContain('thread:');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should normalize RecordId in nested object', () => {
|
|
85
|
+
const params = {
|
|
86
|
+
id: new MockRecordId('user', '456'),
|
|
87
|
+
other: 'value',
|
|
88
|
+
};
|
|
89
|
+
const normalized = normalizeValue(params);
|
|
90
|
+
|
|
91
|
+
expect(typeof normalized.id).toBe('string');
|
|
92
|
+
expect(normalized.id).toContain('user:');
|
|
93
|
+
expect(normalized.other).toBe('value');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should normalize RecordId in arrays', () => {
|
|
97
|
+
const params = {
|
|
98
|
+
ids: [new MockRecordId('user', '1'), new MockRecordId('user', '2')],
|
|
99
|
+
};
|
|
100
|
+
const normalized = normalizeValue(params);
|
|
101
|
+
|
|
102
|
+
expect(Array.isArray(normalized.ids)).toBe(true);
|
|
103
|
+
expect(normalized.ids.every((id: any) => typeof id === 'string')).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should not modify plain objects without RecordId-like structure', () => {
|
|
107
|
+
const plainObject = { name: 'test', value: 123 };
|
|
108
|
+
const normalized = normalizeValue(plainObject);
|
|
109
|
+
|
|
110
|
+
expect(normalized).toEqual(plainObject);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle null and undefined', () => {
|
|
114
|
+
expect(normalizeValue(null)).toBe(null);
|
|
115
|
+
expect(normalizeValue(undefined)).toBe(undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should pass through primitive values', () => {
|
|
119
|
+
expect(normalizeValue('string')).toBe('string');
|
|
120
|
+
expect(normalizeValue(123)).toBe(123);
|
|
121
|
+
expect(normalizeValue(true)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('StreamProcessor Ingest Behavior', () => {
|
|
126
|
+
it('should match ingested record when param is normalized string', async () => {
|
|
127
|
+
const params = { id: new MockRecordId('user', '2dng4ngbicbl0scod87i') };
|
|
128
|
+
const normalizedParams = normalizeValue(params);
|
|
129
|
+
|
|
130
|
+
const ingestedRecord = {
|
|
131
|
+
id: 'user:2dng4ngbicbl0scod87i',
|
|
132
|
+
username: 'sara',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// The key assertion: after normalization, both should be strings
|
|
136
|
+
expect(typeof normalizedParams.id).toBe('string');
|
|
137
|
+
expect(normalizedParams.id).toContain('user:');
|
|
138
|
+
expect(normalizedParams.id).toContain('2dng4ngbicbl0scod87i');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { RecordVersionArray } from '../../types';
|
|
2
|
+
|
|
3
|
+
export interface WasmStreamUpdate {
|
|
4
|
+
query_id: string;
|
|
5
|
+
result_hash: string;
|
|
6
|
+
result_data: RecordVersionArray; // Match Rust 'result_data' field
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WasmQueryConfig {
|
|
10
|
+
id: string;
|
|
11
|
+
surql: string;
|
|
12
|
+
params?: Record<string, any>;
|
|
13
|
+
clientId: string;
|
|
14
|
+
ttl: string;
|
|
15
|
+
lastActiveAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WasmIngestItem {
|
|
19
|
+
table: string;
|
|
20
|
+
op: string;
|
|
21
|
+
id: string;
|
|
22
|
+
record: any;
|
|
23
|
+
version?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Interface matching the SpookyProcessor class from WASM
|
|
27
|
+
export interface WasmProcessor {
|
|
28
|
+
ingest(table: string, op: string, id: string, record: any): WasmStreamUpdate[];
|
|
29
|
+
register_view(config: WasmQueryConfig): WasmStreamUpdate | undefined;
|
|
30
|
+
unregister_view(id: string): void;
|
|
31
|
+
}
|