@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.
Files changed (47) hide show
  1. package/README.md +21 -0
  2. package/dist/index.d.ts +590 -0
  3. package/dist/index.js +3082 -0
  4. package/package.json +46 -0
  5. package/src/events/events.test.ts +242 -0
  6. package/src/events/index.ts +261 -0
  7. package/src/index.ts +3 -0
  8. package/src/modules/auth/events/index.ts +18 -0
  9. package/src/modules/auth/index.ts +267 -0
  10. package/src/modules/cache/index.ts +241 -0
  11. package/src/modules/cache/types.ts +19 -0
  12. package/src/modules/data/data.test.ts +58 -0
  13. package/src/modules/data/index.ts +777 -0
  14. package/src/modules/devtools/index.ts +364 -0
  15. package/src/modules/sync/engine.ts +163 -0
  16. package/src/modules/sync/events/index.ts +77 -0
  17. package/src/modules/sync/index.ts +3 -0
  18. package/src/modules/sync/queue/index.ts +2 -0
  19. package/src/modules/sync/queue/queue-down.ts +89 -0
  20. package/src/modules/sync/queue/queue-up.ts +223 -0
  21. package/src/modules/sync/scheduler.ts +84 -0
  22. package/src/modules/sync/sync.ts +407 -0
  23. package/src/modules/sync/utils.test.ts +311 -0
  24. package/src/modules/sync/utils.ts +171 -0
  25. package/src/services/database/database.ts +108 -0
  26. package/src/services/database/events/index.ts +32 -0
  27. package/src/services/database/index.ts +5 -0
  28. package/src/services/database/local-migrator.ts +203 -0
  29. package/src/services/database/local.ts +99 -0
  30. package/src/services/database/remote.ts +110 -0
  31. package/src/services/logger/index.ts +118 -0
  32. package/src/services/persistence/localstorage.ts +26 -0
  33. package/src/services/persistence/surrealdb.ts +62 -0
  34. package/src/services/stream-processor/index.ts +364 -0
  35. package/src/services/stream-processor/stream-processor.test.ts +140 -0
  36. package/src/services/stream-processor/wasm-types.ts +31 -0
  37. package/src/spooky.ts +346 -0
  38. package/src/types.ts +237 -0
  39. package/src/utils/error-classification.ts +28 -0
  40. package/src/utils/index.ts +172 -0
  41. package/src/utils/parser.test.ts +125 -0
  42. package/src/utils/parser.ts +46 -0
  43. package/src/utils/surql.ts +182 -0
  44. package/src/utils/utils.test.ts +152 -0
  45. package/src/utils/withRetry.test.ts +153 -0
  46. package/tsconfig.json +14 -0
  47. 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
+ });