@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,267 @@
|
|
|
1
|
+
import { RemoteDatabaseService } from '../../services/database/remote';
|
|
2
|
+
import { LocalDatabaseService } from '../../services/database/local';
|
|
3
|
+
import { DataModule } from '../data/index';
|
|
4
|
+
import {
|
|
5
|
+
SchemaStructure,
|
|
6
|
+
AccessDefinition,
|
|
7
|
+
ColumnSchema,
|
|
8
|
+
TypeNameToTypeMap,
|
|
9
|
+
} from '@spooky-sync/query-builder';
|
|
10
|
+
import { Logger } from '../../services/logger/index';
|
|
11
|
+
import { encodeRecordId } from '../../utils/index';
|
|
12
|
+
export * from './events/index';
|
|
13
|
+
import { AuthEventTypes, createAuthEventSystem } from './events/index';
|
|
14
|
+
import { PersistenceClient } from '../../types';
|
|
15
|
+
|
|
16
|
+
// Helper to pretty print types
|
|
17
|
+
type Prettify<T> = {
|
|
18
|
+
[K in keyof T]: T[K];
|
|
19
|
+
} & {};
|
|
20
|
+
|
|
21
|
+
// Map ColumnSchema (value type string) to actual Typescript type
|
|
22
|
+
type MapColumnType<T extends ColumnSchema> = T['optional'] extends true
|
|
23
|
+
? TypeNameToTypeMap[T['type']] | undefined
|
|
24
|
+
: TypeNameToTypeMap[T['type']];
|
|
25
|
+
|
|
26
|
+
// Extract params object from SchemaStructure based on access name and method (signIn/signup)
|
|
27
|
+
type ExtractAccessParams<
|
|
28
|
+
S extends SchemaStructure,
|
|
29
|
+
Name extends keyof S['access'],
|
|
30
|
+
Method extends 'signIn' | 'signup',
|
|
31
|
+
> = S['access'] extends undefined
|
|
32
|
+
? never
|
|
33
|
+
: S['access'][Name] extends AccessDefinition
|
|
34
|
+
? Prettify<{
|
|
35
|
+
[K in keyof S['access'][Name][Method]['params']]: MapColumnType<
|
|
36
|
+
S['access'][Name][Method]['params'][K]
|
|
37
|
+
>;
|
|
38
|
+
}>
|
|
39
|
+
: never;
|
|
40
|
+
|
|
41
|
+
export class AuthService<S extends SchemaStructure> {
|
|
42
|
+
// State
|
|
43
|
+
public token: string | null = null;
|
|
44
|
+
public currentUser: any | null = null;
|
|
45
|
+
public isAuthenticated: boolean = false;
|
|
46
|
+
public isLoading: boolean = true;
|
|
47
|
+
|
|
48
|
+
private events = createAuthEventSystem();
|
|
49
|
+
|
|
50
|
+
public get eventSystem() {
|
|
51
|
+
return this.events;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private schema: S,
|
|
56
|
+
private remote: RemoteDatabaseService,
|
|
57
|
+
private persistenceClient: PersistenceClient,
|
|
58
|
+
private logger: Logger
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
async init() {
|
|
62
|
+
await this.check();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getAccessDefinition<Name extends keyof S['access']>(name: Name): AccessDefinition | undefined {
|
|
66
|
+
return this.schema.access?.[name as string];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subscribe to auth state changes.
|
|
71
|
+
* callback is called immediately with current value and whenever validation status changes.
|
|
72
|
+
*/
|
|
73
|
+
subscribe(cb: (userId: string | null) => void): () => void {
|
|
74
|
+
// Immediate callback
|
|
75
|
+
cb(this.currentUser?.id || null);
|
|
76
|
+
|
|
77
|
+
const id = this.events.subscribe(AuthEventTypes.AuthStateChanged, (event) => {
|
|
78
|
+
cb(event.payload);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
this.events.unsubscribe(id);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private notifyListeners() {
|
|
87
|
+
const userId = this.currentUser?.id || null;
|
|
88
|
+
this.events.emit(AuthEventTypes.AuthStateChanged, userId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check for existing session and validate
|
|
93
|
+
*/
|
|
94
|
+
async check(accessToken?: string) {
|
|
95
|
+
this.isLoading = true;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const token = accessToken || (await this.persistenceClient.get<string>('spooky_auth_token'));
|
|
99
|
+
|
|
100
|
+
if (!token) {
|
|
101
|
+
this.logger.debug(
|
|
102
|
+
{ Category: 'spooky-client::AuthService::check' },
|
|
103
|
+
'No token found in storage or arguments'
|
|
104
|
+
);
|
|
105
|
+
this.isLoading = false;
|
|
106
|
+
this.isAuthenticated = false;
|
|
107
|
+
this.notifyListeners();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Authenticate with the token
|
|
112
|
+
await this.remote.getClient().authenticate(token);
|
|
113
|
+
|
|
114
|
+
// Verify the session by fetching the full user record using $auth.id
|
|
115
|
+
const result = await this.remote.query('SELECT * FROM ONLY $auth.id');
|
|
116
|
+
|
|
117
|
+
const items = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result;
|
|
118
|
+
const user = Array.isArray(items) ? items[0] : items;
|
|
119
|
+
|
|
120
|
+
if (user && user.id) {
|
|
121
|
+
this.logger.info(
|
|
122
|
+
{ user, Category: 'spooky-client::AuthService::check' },
|
|
123
|
+
'Auth check complete (via $auth.id)'
|
|
124
|
+
);
|
|
125
|
+
await this.setSession(token, user);
|
|
126
|
+
} else {
|
|
127
|
+
this.logger.warn(
|
|
128
|
+
{ Category: 'spooky-client::AuthService::check' },
|
|
129
|
+
'$auth.id empty, attempting manual user fetch'
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const manualResult = await this.remote.query(
|
|
133
|
+
'SELECT * FROM user WHERE id = $auth.id LIMIT 1'
|
|
134
|
+
);
|
|
135
|
+
const manualItems =
|
|
136
|
+
Array.isArray(manualResult) && Array.isArray(manualResult[0])
|
|
137
|
+
? manualResult[0]
|
|
138
|
+
: manualResult;
|
|
139
|
+
const manualUser = Array.isArray(manualItems) ? manualItems[0] : manualItems;
|
|
140
|
+
|
|
141
|
+
if (manualUser && manualUser.id) {
|
|
142
|
+
this.logger.info(
|
|
143
|
+
{ user: manualUser, Category: 'spooky-client::AuthService::check' },
|
|
144
|
+
'Auth check complete (via manual fetch)'
|
|
145
|
+
);
|
|
146
|
+
await this.setSession(token, manualUser);
|
|
147
|
+
} else {
|
|
148
|
+
this.logger.warn(
|
|
149
|
+
{ Category: 'spooky-client::AuthService::check' },
|
|
150
|
+
'Token valid but user not found via fallback'
|
|
151
|
+
);
|
|
152
|
+
await this.signOut();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.logger.error(
|
|
157
|
+
{ error, stack: (error as Error).stack, Category: 'spooky-client::AuthService::check' },
|
|
158
|
+
'Auth check failed'
|
|
159
|
+
);
|
|
160
|
+
await this.signOut();
|
|
161
|
+
} finally {
|
|
162
|
+
this.isLoading = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Sign out and clear session
|
|
168
|
+
*/
|
|
169
|
+
async signOut() {
|
|
170
|
+
this.token = null;
|
|
171
|
+
this.currentUser = null;
|
|
172
|
+
this.isAuthenticated = false;
|
|
173
|
+
|
|
174
|
+
await this.persistenceClient.remove('spooky_auth_token');
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await this.remote.getClient().invalidate();
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Ignore invalidation errors
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.notifyListeners();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async setSession(token: string, user: any) {
|
|
186
|
+
this.token = token;
|
|
187
|
+
this.currentUser = user;
|
|
188
|
+
this.isAuthenticated = true;
|
|
189
|
+
await this.persistenceClient.set('spooky_auth_token', token);
|
|
190
|
+
this.notifyListeners();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async signUp<Name extends keyof S['access'] & string>(
|
|
194
|
+
accessName: Name,
|
|
195
|
+
params: ExtractAccessParams<S, Name, 'signup'>
|
|
196
|
+
) {
|
|
197
|
+
const def = this.getAccessDefinition(accessName);
|
|
198
|
+
if (!def) throw new Error(`Access definition '${accessName}' not found`);
|
|
199
|
+
|
|
200
|
+
// Verify all required params are present
|
|
201
|
+
// Safe cast params to Record<string, any> for runtime check
|
|
202
|
+
const runtimeParams = params as Record<string, any>;
|
|
203
|
+
|
|
204
|
+
const missingParams = Object.entries(def.signup.params)
|
|
205
|
+
.filter(([name, schema]) => !schema.optional && !(name in runtimeParams))
|
|
206
|
+
.map(([name]) => name);
|
|
207
|
+
|
|
208
|
+
if (missingParams.length > 0) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Missing required signup params for '${accessName}': ${missingParams.join(', ')}`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.logger.info(
|
|
215
|
+
{ accessName, runtimeParams, Category: 'spooky-client::AuthService::signUp' },
|
|
216
|
+
'Attempting signup'
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const { access } = await this.remote.getClient().signup({
|
|
220
|
+
access: accessName,
|
|
221
|
+
variables: runtimeParams,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
this.logger.info(
|
|
225
|
+
{ Category: 'spooky-client::AuthService::signUp' },
|
|
226
|
+
'Signup successful, token received'
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// After signup, we usually get a token.
|
|
230
|
+
// We should also fetch the user or trust the token works.
|
|
231
|
+
// For now, let's just trigger a check() to fully hydrate state
|
|
232
|
+
await this.check(access);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async signIn<Name extends keyof S['access'] & string>(
|
|
236
|
+
accessName: Name,
|
|
237
|
+
params: ExtractAccessParams<S, Name, 'signIn'>
|
|
238
|
+
) {
|
|
239
|
+
const def = this.getAccessDefinition(accessName);
|
|
240
|
+
if (!def) throw new Error(`Access definition '${accessName}' not found`);
|
|
241
|
+
|
|
242
|
+
const runtimeParams = params as Record<string, any>;
|
|
243
|
+
|
|
244
|
+
// Verify all required params are present
|
|
245
|
+
const missingParams = Object.entries(def.signIn.params)
|
|
246
|
+
.filter(([name, schema]) => !schema.optional && !(name in runtimeParams))
|
|
247
|
+
.map(([name]) => name);
|
|
248
|
+
|
|
249
|
+
if (missingParams.length > 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`Missing required signin params for '${accessName}': ${missingParams.join(', ')}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.logger.info(
|
|
256
|
+
{ accessName, Category: 'spooky-client::AuthService::signIn' },
|
|
257
|
+
'Attempting signin'
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const { access } = await this.remote.getClient().signin({
|
|
261
|
+
access: accessName,
|
|
262
|
+
variables: runtimeParams,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await this.check(access);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { LocalDatabaseService } from '../../services/database/index';
|
|
2
|
+
import {
|
|
3
|
+
StreamProcessorService,
|
|
4
|
+
StreamUpdate,
|
|
5
|
+
StreamUpdateReceiver,
|
|
6
|
+
} from '../../services/stream-processor/index';
|
|
7
|
+
import { Logger } from '../../services/logger/index';
|
|
8
|
+
import { parseRecordIdString, encodeRecordId, surql } from '../../utils/index';
|
|
9
|
+
import { CacheRecord, QueryConfig } from './types';
|
|
10
|
+
import { RecordVersionArray } from '../../types';
|
|
11
|
+
|
|
12
|
+
export * from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CacheModule - Centralized storage and DBSP ingestion
|
|
16
|
+
*
|
|
17
|
+
* Single responsibility: Handle all local storage operations and DBSP ingestion.
|
|
18
|
+
* This module acts as the bridge between data operations and persistence.
|
|
19
|
+
*/
|
|
20
|
+
export class CacheModule implements StreamUpdateReceiver {
|
|
21
|
+
private logger: Logger;
|
|
22
|
+
private streamUpdateCallback: (update: StreamUpdate) => void;
|
|
23
|
+
private versionLookups: Record<string, number> = {};
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private local: LocalDatabaseService,
|
|
27
|
+
private streamProcessor: StreamProcessorService,
|
|
28
|
+
streamUpdateCallback: (update: StreamUpdate) => void,
|
|
29
|
+
logger: Logger
|
|
30
|
+
) {
|
|
31
|
+
this.logger = logger.child({ service: 'CacheModule' });
|
|
32
|
+
this.streamUpdateCallback = streamUpdateCallback;
|
|
33
|
+
// Register as receiver for DBSP stream updates
|
|
34
|
+
this.streamProcessor.addReceiver(this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Implements StreamUpdateReceiver interface
|
|
39
|
+
* Called directly by StreamProcessor when views change
|
|
40
|
+
*/
|
|
41
|
+
onStreamUpdate(update: StreamUpdate): void {
|
|
42
|
+
this.logger.debug(
|
|
43
|
+
{
|
|
44
|
+
queryHash: update.queryHash,
|
|
45
|
+
arrayLength: update.localArray?.length,
|
|
46
|
+
Category: 'spooky-client::CacheModule::onStreamUpdate',
|
|
47
|
+
},
|
|
48
|
+
'Stream update received'
|
|
49
|
+
);
|
|
50
|
+
this.streamUpdateCallback(update);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public lookup(recordId: string): number {
|
|
54
|
+
return this.versionLookups[recordId] ?? 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Save a single record to local DB and ingest into DBSP
|
|
59
|
+
* Used by mutations (create/update)
|
|
60
|
+
*/
|
|
61
|
+
async save(cacheRecord: CacheRecord, skipDbInsert: boolean = false): Promise<void> {
|
|
62
|
+
return this.saveBatch([cacheRecord], skipDbInsert);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Save multiple records in a batch
|
|
67
|
+
* More efficient than calling save() multiple times
|
|
68
|
+
* Used by sync operations
|
|
69
|
+
*/
|
|
70
|
+
async saveBatch(records: CacheRecord[], skipDbInsert: boolean = false): Promise<void> {
|
|
71
|
+
if (records.length === 0) return;
|
|
72
|
+
|
|
73
|
+
this.logger.debug(
|
|
74
|
+
{
|
|
75
|
+
count: records.length,
|
|
76
|
+
Category: 'spooky-client::CacheModule::saveBatch',
|
|
77
|
+
},
|
|
78
|
+
'Saving record batch'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const populatedRecords = records.map((record) => {
|
|
83
|
+
if (!record.version) throw new Error('Record version is required');
|
|
84
|
+
return {
|
|
85
|
+
...record,
|
|
86
|
+
record: {
|
|
87
|
+
...record.record,
|
|
88
|
+
spooky_rv: record.version,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!skipDbInsert) {
|
|
94
|
+
const query = surql.seal<void>(
|
|
95
|
+
surql.tx(
|
|
96
|
+
populatedRecords.map((_, i) => {
|
|
97
|
+
return surql.upsert(`id${i}`, `content${i}`);
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const params = populatedRecords.reduce(
|
|
103
|
+
(acc, record, i) => {
|
|
104
|
+
const { id, ...content } = record.record;
|
|
105
|
+
return {
|
|
106
|
+
...acc,
|
|
107
|
+
[`id${i}`]: id,
|
|
108
|
+
[`content${i}`]: content,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
{} as Record<string, any>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await this.local.execute(query, params);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Batch ingest into DBSP (use populatedRecords which has spooky_rv set)
|
|
118
|
+
for (const record of populatedRecords) {
|
|
119
|
+
const recordId = encodeRecordId(record.record.id);
|
|
120
|
+
this.versionLookups[recordId] = record.version;
|
|
121
|
+
this.streamProcessor.ingest(record.table, record.op, recordId, record.record);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.logger.debug(
|
|
125
|
+
{ count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
|
|
126
|
+
'Batch saved successfully'
|
|
127
|
+
);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
this.logger.error(
|
|
130
|
+
{ err, count: records.length, Category: 'spooky-client::CacheModule::saveBatch' },
|
|
131
|
+
'Failed to save batch'
|
|
132
|
+
);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Delete a record from local DB and ingest deletion into DBSP
|
|
139
|
+
*/
|
|
140
|
+
async delete(table: string, id: string, skipDbDelete: boolean = false): Promise<void> {
|
|
141
|
+
this.logger.debug(
|
|
142
|
+
{ table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
143
|
+
'Deleting record'
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// 1. Delete from local database
|
|
148
|
+
if (!skipDbDelete) {
|
|
149
|
+
await this.local.query('DELETE $id', { id: parseRecordIdString(id) });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Ingest deletion into DBSP
|
|
153
|
+
delete this.versionLookups[id];
|
|
154
|
+
await this.streamProcessor.ingest(table, 'DELETE', id, {});
|
|
155
|
+
|
|
156
|
+
this.logger.debug(
|
|
157
|
+
{ table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
158
|
+
'Record deleted successfully'
|
|
159
|
+
);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
this.logger.error(
|
|
162
|
+
{ err, table, id, Category: 'spooky-client::CacheModule::delete' },
|
|
163
|
+
'Failed to delete record'
|
|
164
|
+
);
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Register a query with DBSP to create a materialized view
|
|
171
|
+
* Returns the initial result array
|
|
172
|
+
*/
|
|
173
|
+
registerQuery(config: QueryConfig): { localArray: RecordVersionArray } {
|
|
174
|
+
this.logger.debug(
|
|
175
|
+
{
|
|
176
|
+
queryHash: config.queryHash,
|
|
177
|
+
surql: config.surql,
|
|
178
|
+
Category: 'spooky-client::CacheModule::registerQuery',
|
|
179
|
+
},
|
|
180
|
+
'Registering query'
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const update = this.streamProcessor.registerQueryPlan({
|
|
185
|
+
queryHash: config.queryHash,
|
|
186
|
+
surql: config.surql,
|
|
187
|
+
params: config.params,
|
|
188
|
+
ttl: config.ttl,
|
|
189
|
+
lastActiveAt: config.lastActiveAt,
|
|
190
|
+
localArray: [],
|
|
191
|
+
remoteArray: [],
|
|
192
|
+
meta: {
|
|
193
|
+
tableName: '',
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!update) {
|
|
198
|
+
throw new Error('Failed to register query with DBSP');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.logger.debug(
|
|
202
|
+
{
|
|
203
|
+
queryHash: config.queryHash,
|
|
204
|
+
arrayLength: update.localArray?.length,
|
|
205
|
+
Category: 'spooky-client::CacheModule::registerQuery',
|
|
206
|
+
},
|
|
207
|
+
'Query registered successfully'
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return { localArray: update.localArray };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
this.logger.error(
|
|
213
|
+
{ err, queryHash: config.queryHash, Category: 'spooky-client::CacheModule::registerQuery' },
|
|
214
|
+
'Failed to register query'
|
|
215
|
+
);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Unregister a query from DBSP
|
|
222
|
+
*/
|
|
223
|
+
unregisterQuery(queryHash: string): void {
|
|
224
|
+
this.logger.debug(
|
|
225
|
+
{ queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
226
|
+
'Unregistering query'
|
|
227
|
+
);
|
|
228
|
+
try {
|
|
229
|
+
this.streamProcessor.unregisterQueryPlan(queryHash);
|
|
230
|
+
this.logger.debug(
|
|
231
|
+
{ queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
232
|
+
'Query unregistered successfully'
|
|
233
|
+
);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
this.logger.error(
|
|
236
|
+
{ err, queryHash, Category: 'spooky-client::CacheModule::unregisterQuery' },
|
|
237
|
+
'Failed to unregister query'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
+
import { QueryTimeToLive, RecordVersionArray } from '../../types';
|
|
3
|
+
|
|
4
|
+
export type RecordWithId = Record<string, any> & { id: RecordId<string> };
|
|
5
|
+
|
|
6
|
+
export interface QueryConfig {
|
|
7
|
+
queryHash: string;
|
|
8
|
+
surql: string;
|
|
9
|
+
params: Record<string, any>;
|
|
10
|
+
ttl: QueryTimeToLive | Duration;
|
|
11
|
+
lastActiveAt: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CacheRecord {
|
|
15
|
+
table: string;
|
|
16
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
17
|
+
record: RecordWithId;
|
|
18
|
+
version: number;
|
|
19
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseUpdateOptions } from './index';
|
|
3
|
+
|
|
4
|
+
describe('parseUpdateOptions', () => {
|
|
5
|
+
it('returns empty options when no debounce', () => {
|
|
6
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' });
|
|
7
|
+
expect(result).toEqual({});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns empty options when options is undefined', () => {
|
|
11
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' }, undefined);
|
|
12
|
+
expect(result).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns empty options when debounced is false', () => {
|
|
16
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: false });
|
|
17
|
+
expect(result).toEqual({});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('debounced: true uses default delay (200) and key = id', () => {
|
|
21
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: true });
|
|
22
|
+
expect(result).toEqual({
|
|
23
|
+
debounced: {
|
|
24
|
+
delay: 200,
|
|
25
|
+
key: 'user:1',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('supports custom delay', () => {
|
|
31
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' }, {
|
|
32
|
+
debounced: { delay: 500 },
|
|
33
|
+
});
|
|
34
|
+
expect(result.debounced?.delay).toBe(500);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('supports custom key type recordId', () => {
|
|
38
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice', age: 30 }, {
|
|
39
|
+
debounced: { key: 'recordId' },
|
|
40
|
+
});
|
|
41
|
+
expect(result.debounced?.key).toBe('user:1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('key: recordId_x_fields generates composite key from id + sorted field names', () => {
|
|
45
|
+
const result = parseUpdateOptions(
|
|
46
|
+
'user:1',
|
|
47
|
+
{ name: 'Alice', age: 30, email: 'a@b.com' },
|
|
48
|
+
{ debounced: { key: 'recordId_x_fields' } }
|
|
49
|
+
);
|
|
50
|
+
expect(result.debounced?.key).toBe('user:1::age#email#name');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses defaults when debounced is object with no key or delay', () => {
|
|
54
|
+
const result = parseUpdateOptions('user:1', { name: 'Alice' }, { debounced: {} });
|
|
55
|
+
expect(result.debounced?.delay).toBe(200);
|
|
56
|
+
expect(result.debounced?.key).toBe('user:1');
|
|
57
|
+
});
|
|
58
|
+
});
|