@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,203 @@
1
+ import type { Surreal } from 'surrealdb';
2
+ import { Logger, createLogger } from '../logger/index';
3
+ import { LocalDatabaseService } from './local';
4
+
5
+ export interface SchemaRecord {
6
+ hash: string;
7
+ created_at: string;
8
+ }
9
+
10
+ export const sha1 = async (str: string): Promise<string> => {
11
+ const enc = new TextEncoder();
12
+ const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
13
+ return Array.from(new Uint8Array(hash))
14
+ .map((v) => v.toString(16).padStart(2, '0'))
15
+ .join('');
16
+ };
17
+
18
+ export class LocalMigrator {
19
+ private logger: Logger;
20
+
21
+ constructor(
22
+ private localDb: LocalDatabaseService,
23
+ logger: Logger
24
+ ) {
25
+ this.logger = logger.child({ service: 'LocalMigrator' });
26
+ logger?.child({ service: 'LocalMigrator' }) ??
27
+ createLogger('info').child({ service: 'LocalMigrator' });
28
+ }
29
+
30
+ async provision(schemaSurql: string): Promise<void> {
31
+ const hash = await sha1(schemaSurql);
32
+
33
+ const { database } = this.localDb.getConfig();
34
+
35
+ if (await this.isSchemaUpToDate(hash)) {
36
+ this.logger.info(
37
+ { Category: 'spooky-client::LocalMigrator::provision' },
38
+ '[Provisioning] Schema is up to date, skipping migration'
39
+ );
40
+ return;
41
+ }
42
+
43
+ await this.recreateDatabase(database);
44
+
45
+ const systemSchema = `
46
+ DEFINE TABLE IF NOT EXISTS _spooky_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
+ DEFINE TABLE IF NOT EXISTS _spooky_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
+ DEFINE TABLE IF NOT EXISTS _spooky_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
+ DEFINE TABLE IF NOT EXISTS _spooky_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
+ `;
51
+ const fullSchema = schemaSurql + '\n' + systemSchema;
52
+
53
+ const statements = this.splitStatements(fullSchema);
54
+
55
+ for (let i = 0; i < statements.length; i++) {
56
+ const statement = statements[i];
57
+ // Strip comments to check if it's an index definition
58
+ const cleanStatement = statement.replace(/--.*/g, '').trim();
59
+
60
+ // SKIP INDEXES: WASM engine hangs on DEFINE INDEX (confirmed)
61
+ if (cleanStatement.toUpperCase().startsWith('DEFINE INDEX')) {
62
+ this.logger.warn(
63
+ { Category: 'spooky-client::LocalMigrator::provision' },
64
+ `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`
65
+ );
66
+ continue;
67
+ }
68
+
69
+ try {
70
+ this.logger.info(
71
+ { Category: 'spooky-client::LocalMigrator::provision' },
72
+ `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
+ );
74
+ await this.localDb.query(statement);
75
+ this.logger.info(
76
+ { Category: 'spooky-client::LocalMigrator::provision' },
77
+ `[Provisioning] (${i + 1}/${statements.length}) Done`
78
+ );
79
+ } catch (e) {
80
+ this.logger.error(
81
+ { Category: 'spooky-client::LocalMigrator::provision' },
82
+ `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`
83
+ );
84
+ throw e;
85
+ }
86
+ }
87
+
88
+ await this.createHashRecord(hash);
89
+ }
90
+
91
+ private async isSchemaUpToDate(hash: string): Promise<boolean> {
92
+ try {
93
+ const [lastSchemaRecord] = await this.localDb.query<any>(
94
+ `SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`
95
+ );
96
+ return lastSchemaRecord?.hash === hash;
97
+ } catch (error) {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ private async recreateDatabase(database: string) {
103
+ try {
104
+ await this.localDb.query(`DEFINE DATABASE _spooky_temp;`);
105
+ } catch (e) {
106
+ // Ignore if exists
107
+ }
108
+
109
+ try {
110
+ await this.localDb.query(`
111
+ USE DB _spooky_temp;
112
+ REMOVE DATABASE ${database};
113
+ `);
114
+ } catch (e) {
115
+ // Ignore error if database doesn't exist
116
+ }
117
+
118
+ await this.localDb.query(`
119
+ DEFINE DATABASE ${database};
120
+ USE DB ${database};
121
+ `);
122
+ }
123
+
124
+ private splitStatements(schema: string): string[] {
125
+ const statements: string[] = [];
126
+ let current = '';
127
+ let depth = 0;
128
+ let inQuote = false;
129
+ let quoteChar = '';
130
+ let inComment = false;
131
+
132
+ for (let i = 0; i < schema.length; i++) {
133
+ const char = schema[i];
134
+ const nextChar = schema[i + 1];
135
+
136
+ // Handle Comments
137
+ if (inComment) {
138
+ current += char;
139
+ if (char === '\n') {
140
+ inComment = false;
141
+ }
142
+ continue;
143
+ }
144
+
145
+ // Start of comment
146
+ if (!inQuote && char === '-' && nextChar === '-') {
147
+ inComment = true;
148
+ current += char;
149
+ continue;
150
+ }
151
+
152
+ if (inQuote) {
153
+ current += char;
154
+ if (char === quoteChar && schema[i - 1] !== '\\') {
155
+ inQuote = false;
156
+ }
157
+ continue;
158
+ }
159
+
160
+ if (char === '"' || char === "'") {
161
+ inQuote = true;
162
+ quoteChar = char;
163
+ current += char;
164
+ continue;
165
+ }
166
+
167
+ if (char === '{') {
168
+ depth++;
169
+ current += char;
170
+ continue;
171
+ }
172
+
173
+ if (char === '}') {
174
+ depth--;
175
+ current += char;
176
+ continue;
177
+ }
178
+
179
+ if (char === ';' && depth === 0) {
180
+ if (current.trim().length > 0) {
181
+ statements.push(current.trim());
182
+ }
183
+ current = '';
184
+ continue;
185
+ }
186
+
187
+ current += char;
188
+ }
189
+
190
+ if (current.trim().length > 0) {
191
+ statements.push(current.trim());
192
+ }
193
+
194
+ return statements;
195
+ }
196
+
197
+ private async createHashRecord(hash: string) {
198
+ await this.localDb.query(
199
+ `UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
200
+ { hash }
201
+ );
202
+ }
203
+ }
@@ -0,0 +1,99 @@
1
+ import { applyDiagnostics, DateTime, Diagnostic, RecordId, Surreal } from 'surrealdb';
2
+ import { createWasmWorkerEngines } from '@surrealdb/wasm';
3
+ import { SpookyConfig } from '../../types';
4
+ import { Logger } from '../logger/index';
5
+ import { AbstractDatabaseService } from './database';
6
+ import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
7
+ import { encodeRecordId, parseRecordIdString, surql } from '../../utils/index';
8
+
9
+ export class LocalDatabaseService extends AbstractDatabaseService {
10
+ private config: SpookyConfig<any>['database'];
11
+ protected eventType = DatabaseEventTypes.LocalQuery;
12
+
13
+ constructor(config: SpookyConfig<any>['database'], logger: Logger) {
14
+ const events = createDatabaseEventSystem();
15
+ super(
16
+ new Surreal({
17
+ codecOptions: {
18
+ valueDecodeVisitor(value) {
19
+ if (value instanceof RecordId) {
20
+ return encodeRecordId(value);
21
+ }
22
+
23
+ if (value instanceof DateTime) {
24
+ return value.toDate();
25
+ }
26
+
27
+ return value;
28
+ },
29
+ },
30
+ engines: applyDiagnostics(
31
+ createWasmWorkerEngines(),
32
+ ({ key, type, phase, ...other }: Diagnostic) => {
33
+ if (phase === 'progress' || phase === 'after') {
34
+ logger.trace(
35
+ {
36
+ ...other,
37
+ key,
38
+ type,
39
+ phase,
40
+ service: 'surrealdb:local',
41
+ Category: 'spooky-client::LocalDatabaseService::diagnostics',
42
+ },
43
+ `Local SurrealDB diagnostics captured ${type}:${phase}`
44
+ );
45
+ }
46
+ }
47
+ ),
48
+ }),
49
+ logger,
50
+ events
51
+ );
52
+ this.config = config;
53
+ }
54
+
55
+ getConfig(): SpookyConfig<any>['database'] {
56
+ return this.config;
57
+ }
58
+
59
+ async connect(): Promise<void> {
60
+ const { namespace, database } = this.getConfig();
61
+ this.logger.info(
62
+ { namespace, database, Category: 'spooky-client::LocalDatabaseService::connect' },
63
+ 'Connecting to local database'
64
+ );
65
+ try {
66
+ const store = this.getConfig().store ?? 'memory';
67
+ const storeUrl = store === 'memory' ? 'mem://' : 'indxdb://spooky';
68
+ this.logger.debug(
69
+ { storeUrl, Category: 'spooky-client::LocalDatabaseService::connect' },
70
+ '[LocalDatabaseService] Calling client.connect'
71
+ );
72
+ await this.client.connect(storeUrl, {});
73
+ this.logger.debug(
74
+ { namespace, database, Category: 'spooky-client::LocalDatabaseService::connect' },
75
+ '[LocalDatabaseService] client.connect returned. Calling client.use'
76
+ );
77
+
78
+ await this.client.use({
79
+ namespace,
80
+ database,
81
+ });
82
+ this.logger.debug(
83
+ { Category: 'spooky-client::LocalDatabaseService::connect' },
84
+ '[LocalDatabaseService] client.use returned'
85
+ );
86
+
87
+ this.logger.info(
88
+ { Category: 'spooky-client::LocalDatabaseService::connect' },
89
+ 'Connected to local database'
90
+ );
91
+ } catch (err) {
92
+ this.logger.error(
93
+ { err, Category: 'spooky-client::LocalDatabaseService::connect' },
94
+ 'Failed to connect to local database'
95
+ );
96
+ throw err;
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,110 @@
1
+ import {
2
+ applyDiagnostics,
3
+ createRemoteEngines,
4
+ Diagnostic,
5
+ Surreal,
6
+ SurrealTransaction,
7
+ } from 'surrealdb';
8
+ import { SpookyConfig } from '../../types';
9
+ import { Logger } from '../logger/index';
10
+ import { AbstractDatabaseService } from './database';
11
+ import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
12
+
13
+ export class RemoteDatabaseService extends AbstractDatabaseService {
14
+ private config: SpookyConfig<any>['database'];
15
+ protected eventType = DatabaseEventTypes.RemoteQuery;
16
+
17
+ constructor(config: SpookyConfig<any>['database'], logger: Logger) {
18
+ const events = createDatabaseEventSystem();
19
+ super(
20
+ new Surreal({
21
+ engines: applyDiagnostics(
22
+ createRemoteEngines(),
23
+ ({ key, type, phase, ...other }: Diagnostic) => {
24
+ if (phase === 'progress' || phase === 'after') {
25
+ logger.trace(
26
+ {
27
+ ...other,
28
+ key,
29
+ type,
30
+ phase,
31
+ service: 'surrealdb:remote',
32
+ Category: 'spooky-client::RemoteDatabaseService::diagnostics',
33
+ },
34
+ `Remote SurrealDB diagnostics captured ${type}:${phase}`
35
+ );
36
+ }
37
+ }
38
+ ),
39
+ }),
40
+ logger,
41
+ events
42
+ );
43
+ this.config = config;
44
+ }
45
+
46
+ getConfig(): SpookyConfig<any>['database'] {
47
+ return this.config;
48
+ }
49
+
50
+ async connect(): Promise<void> {
51
+ const { endpoint, token, namespace, database } = this.getConfig();
52
+ if (endpoint) {
53
+ this.logger.info(
54
+ {
55
+ endpoint,
56
+ namespace,
57
+ database,
58
+ Category: 'spooky-client::RemoteDatabaseService::connect',
59
+ },
60
+ 'Connecting to remote database'
61
+ );
62
+ try {
63
+ await this.client.connect(endpoint);
64
+ await this.client.use({
65
+ namespace,
66
+ database,
67
+ });
68
+
69
+ if (token) {
70
+ this.logger.debug(
71
+ { Category: 'spooky-client::RemoteDatabaseService::connect' },
72
+ 'Authenticating with token'
73
+ );
74
+ await this.client.authenticate(token);
75
+ }
76
+ this.logger.info(
77
+ { Category: 'spooky-client::RemoteDatabaseService::connect' },
78
+ 'Connected to remote database'
79
+ );
80
+ } catch (err) {
81
+ this.logger.error(
82
+ { err, Category: 'spooky-client::RemoteDatabaseService::connect' },
83
+ 'Failed to connect to remote database'
84
+ );
85
+ throw err;
86
+ }
87
+ } else {
88
+ this.logger.warn(
89
+ { Category: 'spooky-client::RemoteDatabaseService::connect' },
90
+ 'No endpoint configured for remote database'
91
+ );
92
+ }
93
+ }
94
+
95
+ async signin(params: any): Promise<any> {
96
+ return this.client.signin(params);
97
+ }
98
+
99
+ async signup(params: any): Promise<any> {
100
+ return this.client.signup(params);
101
+ }
102
+
103
+ async authenticate(token: string): Promise<any> {
104
+ return this.client.authenticate(token);
105
+ }
106
+
107
+ async invalidate(): Promise<void> {
108
+ return this.client.invalidate();
109
+ }
110
+ }
@@ -0,0 +1,118 @@
1
+ import pino, { Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
+ import { LoggerProvider, BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
3
+ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto';
4
+ import { resourceFromAttributes } from '@opentelemetry/resources';
5
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
6
+ import { createContextKey } from '@opentelemetry/api';
7
+
8
+ const CATEGORY_KEY = createContextKey('Category');
9
+
10
+ export type Logger = PinoLogger;
11
+
12
+ // Map pino levels to OTEL severity numbers
13
+ // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/logs/data-model.md#severity-fields
14
+ function mapLevelToSeverityNumber(level: string): number {
15
+ switch (level) {
16
+ case 'trace':
17
+ return 1;
18
+ case 'debug':
19
+ return 5;
20
+ case 'info':
21
+ return 9;
22
+ case 'warn':
23
+ return 13;
24
+ case 'error':
25
+ return 17;
26
+ case 'fatal':
27
+ return 21;
28
+ default:
29
+ return 9;
30
+ }
31
+ }
32
+
33
+ export function createLogger(level: Level = 'info', otelEndpoint?: string): Logger {
34
+ const browserConfig: LoggerOptions['browser'] = {
35
+ asObject: true,
36
+ write: (o: any) => {
37
+ console.log(JSON.stringify(o));
38
+ },
39
+ };
40
+
41
+ if (otelEndpoint) {
42
+ // Initialize OTEL LoggerProvider
43
+ const resource = resourceFromAttributes({
44
+ [ATTR_SERVICE_NAME]: 'spooky-client',
45
+ });
46
+
47
+ const exporter = new OTLPLogExporter({
48
+ url: otelEndpoint,
49
+ });
50
+
51
+ // Pass processors in constructor as this SDK version requires it
52
+ const loggerProvider = new LoggerProvider({
53
+ resource,
54
+ processors: [new BatchLogRecordProcessor(exporter)],
55
+ });
56
+
57
+ const otelLogger: Record<string, ReturnType<typeof loggerProvider.getLogger>> = {};
58
+
59
+ const getOtelLogger = (category: string) => {
60
+ if (!otelLogger[category]) {
61
+ otelLogger[category] = loggerProvider.getLogger(category);
62
+ }
63
+ return otelLogger[category];
64
+ };
65
+
66
+ browserConfig.transmit = {
67
+ level: level,
68
+ send: (levelLabel: string, logEvent: any) => {
69
+ try {
70
+ const messages = [...logEvent.messages];
71
+ const severityNumber = mapLevelToSeverityNumber(levelLabel);
72
+
73
+ // Construct the message body
74
+ let body = '';
75
+ const msg = messages.pop();
76
+
77
+ if (typeof msg === 'string') {
78
+ body = msg;
79
+ } else if (msg) {
80
+ body = JSON.stringify(msg);
81
+ }
82
+
83
+ let category = 'spooky-client::unknown';
84
+
85
+ const attributes = {};
86
+ for (const msg of messages) {
87
+ if (typeof msg === 'object') {
88
+ if (msg.Category) {
89
+ category = msg.Category;
90
+ delete msg.Category;
91
+ }
92
+ Object.assign(attributes, msg);
93
+ }
94
+ }
95
+
96
+ // Emit to OTEL SDK
97
+ getOtelLogger(category).emit({
98
+ severityNumber: severityNumber,
99
+ severityText: levelLabel.toUpperCase(),
100
+ body: body,
101
+ attributes: {
102
+ ...logEvent.bindings[0],
103
+ ...attributes,
104
+ },
105
+ timestamp: new Date(logEvent.ts),
106
+ });
107
+ } catch (e) {
108
+ console.warn('Failed to transmit log to OTEL endpoint', e);
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ return pino({
115
+ level,
116
+ browser: browserConfig,
117
+ });
118
+ }
@@ -0,0 +1,26 @@
1
+ import { Logger } from 'pino';
2
+ import { PersistenceClient } from '../../types';
3
+
4
+ export class LocalStoragePersistenceClient implements PersistenceClient {
5
+ private logger: Logger;
6
+
7
+ constructor(logger: Logger) {
8
+ this.logger = logger.child({ service: 'PersistenceClient:LocalStorage' });
9
+ }
10
+
11
+ set<T>(key: string, value: T): Promise<void> {
12
+ localStorage.setItem(key, JSON.stringify(value));
13
+ return Promise.resolve();
14
+ }
15
+
16
+ get<T>(key: string): Promise<T | null> {
17
+ const value = localStorage.getItem(key);
18
+ if (!value) return Promise.resolve(null);
19
+ return Promise.resolve(JSON.parse(value));
20
+ }
21
+
22
+ remove(key: string): Promise<void> {
23
+ localStorage.removeItem(key);
24
+ return Promise.resolve();
25
+ }
26
+ }
@@ -0,0 +1,62 @@
1
+ import { PersistenceClient } from '../../types';
2
+ import { parseRecordIdString, surql } from '../../utils/index';
3
+ import { Logger } from 'pino';
4
+ import { AbstractDatabaseService } from '../database/database';
5
+
6
+ export class SurrealDBPersistenceClient implements PersistenceClient {
7
+ private logger: Logger;
8
+
9
+ constructor(
10
+ private db: AbstractDatabaseService,
11
+ logger: Logger
12
+ ) {
13
+ this.logger = logger.child({ service: 'PersistenceClient:SurrealDb' });
14
+ }
15
+
16
+ async set<T>(key: string, val: T) {
17
+ try {
18
+ const id = parseRecordIdString(`_spooky_kv:${key}`);
19
+ await this.db.query(surql.seal(surql.upsert('id', 'data')), { id, data: { val } });
20
+ } catch (error) {
21
+ this.logger.error(
22
+ { error, Category: 'spooky-client::SurrealDBPersistenceClient::set' },
23
+ 'Failed to set KV'
24
+ );
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async get<T>(key: string) {
30
+ try {
31
+ const id = parseRecordIdString(`_spooky_kv:${key}`);
32
+ const [result] = await this.db.query<[{ val: T }]>(
33
+ surql.seal(surql.selectById('id', ['val'])),
34
+ {
35
+ id,
36
+ }
37
+ );
38
+ if (!result?.val) {
39
+ return null;
40
+ }
41
+ return result.val;
42
+ } catch (error) {
43
+ this.logger.warn(
44
+ { error, Category: 'spooky-client::SurrealDBPersistenceClient::get' },
45
+ 'Failed to get KV'
46
+ );
47
+ return null;
48
+ }
49
+ }
50
+
51
+ async remove(key: string) {
52
+ try {
53
+ const id = parseRecordIdString(`_spooky_kv:${key}`);
54
+ await this.db.query(surql.seal(surql.delete('id')), { id });
55
+ } catch (err) {
56
+ this.logger.info(
57
+ { err, Category: 'spooky-client::SurrealDBPersistenceClient::remove' },
58
+ 'Failed to delete KV'
59
+ );
60
+ }
61
+ }
62
+ }