@spooky-sync/core 0.0.1-canary.7 → 0.0.1-canary.71

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 (64) hide show
  1. package/AGENTS.md +56 -0
  2. package/dist/index.d.ts +299 -369
  3. package/dist/index.js +2278 -399
  4. package/dist/otel/index.d.ts +21 -0
  5. package/dist/otel/index.js +86 -0
  6. package/dist/types.d.ts +460 -0
  7. package/package.json +37 -7
  8. package/skills/sp00ky-core/SKILL.md +258 -0
  9. package/skills/sp00ky-core/references/auth.md +98 -0
  10. package/skills/sp00ky-core/references/config.md +76 -0
  11. package/src/build-globals.d.ts +12 -0
  12. package/src/events/events.test.ts +2 -1
  13. package/src/events/index.ts +3 -0
  14. package/src/index.ts +3 -2
  15. package/src/modules/auth/events/index.ts +2 -1
  16. package/src/modules/auth/index.ts +17 -20
  17. package/src/modules/cache/index.ts +41 -29
  18. package/src/modules/cache/types.ts +2 -2
  19. package/src/modules/crdt/crdt-field.ts +281 -0
  20. package/src/modules/crdt/crdt-hydration.test.ts +206 -0
  21. package/src/modules/crdt/index.ts +352 -0
  22. package/src/modules/data/data.status.test.ts +108 -0
  23. package/src/modules/data/index.ts +662 -108
  24. package/src/modules/data/window-query.test.ts +52 -0
  25. package/src/modules/data/window-query.ts +130 -0
  26. package/src/modules/devtools/index.ts +89 -21
  27. package/src/modules/devtools/versions.test.ts +74 -0
  28. package/src/modules/devtools/versions.ts +81 -0
  29. package/src/modules/ref-tables.test.ts +56 -0
  30. package/src/modules/ref-tables.ts +57 -0
  31. package/src/modules/sync/engine.ts +97 -37
  32. package/src/modules/sync/events/index.ts +3 -2
  33. package/src/modules/sync/queue/queue-down.ts +5 -4
  34. package/src/modules/sync/queue/queue-up.ts +14 -13
  35. package/src/modules/sync/scheduler.ts +2 -2
  36. package/src/modules/sync/sync.ts +553 -58
  37. package/src/modules/sync/utils.test.ts +239 -2
  38. package/src/modules/sync/utils.ts +166 -17
  39. package/src/otel/index.ts +127 -0
  40. package/src/services/database/database.ts +11 -11
  41. package/src/services/database/events/index.ts +2 -1
  42. package/src/services/database/local-migrator.ts +21 -21
  43. package/src/services/database/local.test.ts +33 -0
  44. package/src/services/database/local.ts +192 -32
  45. package/src/services/database/remote.ts +13 -13
  46. package/src/services/logger/index.ts +6 -101
  47. package/src/services/persistence/localstorage.ts +2 -2
  48. package/src/services/persistence/resilient.ts +41 -0
  49. package/src/services/persistence/surrealdb.ts +9 -9
  50. package/src/services/stream-processor/index.ts +205 -36
  51. package/src/services/stream-processor/permissions.test.ts +47 -0
  52. package/src/services/stream-processor/permissions.ts +53 -0
  53. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  54. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  55. package/src/services/stream-processor/wasm-types.ts +18 -2
  56. package/src/sp00ky.auth-order.test.ts +65 -0
  57. package/src/sp00ky.ts +582 -0
  58. package/src/types.ts +132 -13
  59. package/src/utils/index.ts +35 -13
  60. package/src/utils/parser.ts +3 -2
  61. package/src/utils/surql.ts +24 -15
  62. package/src/utils/withRetry.test.ts +1 -1
  63. package/tsdown.config.ts +55 -1
  64. package/src/spooky.ts +0 -392
@@ -1,6 +1,6 @@
1
- import type { Surreal } from 'surrealdb';
2
- import { Logger, createLogger } from '../logger/index';
3
- import { LocalDatabaseService } from './local';
1
+ import type { Logger} from '../logger/index';
2
+ import { createLogger } from '../logger/index';
3
+ import type { LocalDatabaseService } from './local';
4
4
 
5
5
  export interface SchemaRecord {
6
6
  hash: string;
@@ -23,8 +23,8 @@ export class LocalMigrator {
23
23
  logger: Logger
24
24
  ) {
25
25
  this.logger = logger.child({ service: 'LocalMigrator' });
26
- logger?.child({ service: 'LocalMigrator' }) ??
27
- createLogger('info').child({ service: 'LocalMigrator' });
26
+ void (logger?.child({ service: 'LocalMigrator' }) ??
27
+ createLogger('info').child({ service: 'LocalMigrator' }));
28
28
  }
29
29
 
30
30
  async provision(schemaSurql: string): Promise<void> {
@@ -34,7 +34,7 @@ export class LocalMigrator {
34
34
 
35
35
  if (await this.isSchemaUpToDate(hash)) {
36
36
  this.logger.info(
37
- { Category: 'spooky-client::LocalMigrator::provision' },
37
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
38
38
  '[Provisioning] Schema is up to date, skipping migration'
39
39
  );
40
40
  return;
@@ -43,10 +43,10 @@ export class LocalMigrator {
43
43
  await this.recreateDatabase(database);
44
44
 
45
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;
46
+ DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
+ DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
+ DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
+ DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
50
  `;
51
51
  const fullSchema = schemaSurql + '\n' + systemSchema;
52
52
 
@@ -60,7 +60,7 @@ export class LocalMigrator {
60
60
  // SKIP INDEXES: WASM engine hangs on DEFINE INDEX (confirmed)
61
61
  if (cleanStatement.toUpperCase().startsWith('DEFINE INDEX')) {
62
62
  this.logger.warn(
63
- { Category: 'spooky-client::LocalMigrator::provision' },
63
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
64
64
  `[Provisioning] Skipping index definition (WASM hang avoidance): ${cleanStatement.substring(0, 50)}...`
65
65
  );
66
66
  continue;
@@ -68,17 +68,17 @@ export class LocalMigrator {
68
68
 
69
69
  try {
70
70
  this.logger.info(
71
- { Category: 'spooky-client::LocalMigrator::provision' },
71
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
72
72
  `[Provisioning] (${i + 1}/${statements.length}) Executing: ${statement.substring(0, 50)}...`
73
73
  );
74
74
  await this.localDb.query(statement);
75
75
  this.logger.info(
76
- { Category: 'spooky-client::LocalMigrator::provision' },
76
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
77
77
  `[Provisioning] (${i + 1}/${statements.length}) Done`
78
78
  );
79
79
  } catch (e) {
80
80
  this.logger.error(
81
- { Category: 'spooky-client::LocalMigrator::provision' },
81
+ { Category: 'sp00ky-client::LocalMigrator::provision' },
82
82
  `[Provisioning] (${i + 1}/${statements.length}) Error executing statement: ${statement}`
83
83
  );
84
84
  throw e;
@@ -91,27 +91,27 @@ export class LocalMigrator {
91
91
  private async isSchemaUpToDate(hash: string): Promise<boolean> {
92
92
  try {
93
93
  const [lastSchemaRecord] = await this.localDb.query<any>(
94
- `SELECT hash, created_at FROM ONLY _spooky_schema ORDER BY created_at DESC LIMIT 1;`
94
+ `SELECT hash, created_at FROM ONLY _00_schema ORDER BY created_at DESC LIMIT 1;`
95
95
  );
96
96
  return lastSchemaRecord?.hash === hash;
97
- } catch (error) {
97
+ } catch (_error) {
98
98
  return false;
99
99
  }
100
100
  }
101
101
 
102
102
  private async recreateDatabase(database: string) {
103
103
  try {
104
- await this.localDb.query(`DEFINE DATABASE _spooky_temp;`);
105
- } catch (e) {
104
+ await this.localDb.query(`DEFINE DATABASE _00_temp;`);
105
+ } catch (_e) {
106
106
  // Ignore if exists
107
107
  }
108
108
 
109
109
  try {
110
110
  await this.localDb.query(`
111
- USE DB _spooky_temp;
111
+ USE DB _00_temp;
112
112
  REMOVE DATABASE ${database};
113
113
  `);
114
- } catch (e) {
114
+ } catch (_e) {
115
115
  // Ignore error if database doesn't exist
116
116
  }
117
117
 
@@ -196,7 +196,7 @@ export class LocalMigrator {
196
196
 
197
197
  private async createHashRecord(hash: string) {
198
198
  await this.localDb.query(
199
- `UPSERT _spooky_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
199
+ `UPSERT _00_schema SET hash = $hash, created_at = time::now() WHERE hash = $hash;`,
200
200
  { hash }
201
201
  );
202
202
  }
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isLocalStoreOpenError } from './local';
3
+
4
+ // `LocalDatabaseService.connect` recovers (drops the store + reconnects, falling
5
+ // back to in-memory) only when the failure is the SurrealDB-WASM IndexedDB
6
+ // open/transaction error — NOT for unrelated errors. This pins the message match
7
+ // against the real error the engine throws so a recovery path doesn't silently
8
+ // stop triggering (or start swallowing unrelated failures).
9
+ describe('isLocalStoreOpenError', () => {
10
+ it('matches the real SurrealDB-WASM IndexedDB key-value store error', () => {
11
+ const real = new Error(
12
+ 'There was a problem with the key-value store: There was a problem with a ' +
13
+ 'transaction: An IndexedDB error occured: idb error'
14
+ );
15
+ expect(isLocalStoreOpenError(real)).toBe(true);
16
+ });
17
+
18
+ it('matches on the individual signals (indexeddb / idb error / key-value store)', () => {
19
+ expect(isLocalStoreOpenError(new Error('IndexedDB is not available'))).toBe(true);
20
+ expect(isLocalStoreOpenError(new Error('idb error'))).toBe(true);
21
+ expect(isLocalStoreOpenError(new Error('problem with the key-value store'))).toBe(true);
22
+ // Non-Error inputs are stringified.
23
+ expect(isLocalStoreOpenError('An IndexedDB error occured')).toBe(true);
24
+ });
25
+
26
+ it('does NOT match unrelated errors (so we never clear the store spuriously)', () => {
27
+ expect(isLocalStoreOpenError(new Error('WebSocket connection refused'))).toBe(false);
28
+ expect(isLocalStoreOpenError(new Error('Parse error: unexpected token'))).toBe(false);
29
+ expect(isLocalStoreOpenError(new Error('permission denied'))).toBe(false);
30
+ expect(isLocalStoreOpenError(null)).toBe(false);
31
+ expect(isLocalStoreOpenError(undefined)).toBe(false);
32
+ });
33
+ });
@@ -1,16 +1,17 @@
1
- import { applyDiagnostics, DateTime, Diagnostic, RecordId, Surreal } from 'surrealdb';
1
+ import type { Diagnostic} from 'surrealdb';
2
+ import { applyDiagnostics, DateTime, RecordId, Surreal } from 'surrealdb';
2
3
  import { createWasmWorkerEngines } from '@surrealdb/wasm';
3
- import { SpookyConfig } from '../../types';
4
- import { Logger } from '../logger/index';
4
+ import type { Sp00kyConfig } from '../../types';
5
+ import type { Logger } from '../logger/index';
5
6
  import { AbstractDatabaseService } from './database';
6
7
  import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
7
- import { encodeRecordId, parseRecordIdString, surql } from '../../utils/index';
8
+ import { encodeRecordId } from '../../utils/index';
8
9
 
9
10
  export class LocalDatabaseService extends AbstractDatabaseService {
10
- private config: SpookyConfig<any>['database'];
11
+ private config: Sp00kyConfig<any>['database'];
11
12
  protected eventType = DatabaseEventTypes.LocalQuery;
12
13
 
13
- constructor(config: SpookyConfig<any>['database'], logger: Logger) {
14
+ constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
14
15
  const events = createDatabaseEventSystem();
15
16
  super(
16
17
  new Surreal({
@@ -38,7 +39,7 @@ export class LocalDatabaseService extends AbstractDatabaseService {
38
39
  type,
39
40
  phase,
40
41
  service: 'surrealdb:local',
41
- Category: 'spooky-client::LocalDatabaseService::diagnostics',
42
+ Category: 'sp00ky-client::LocalDatabaseService::diagnostics',
42
43
  },
43
44
  `Local SurrealDB diagnostics captured ${type}:${phase}`
44
45
  );
@@ -52,48 +53,207 @@ export class LocalDatabaseService extends AbstractDatabaseService {
52
53
  this.config = config;
53
54
  }
54
55
 
55
- getConfig(): SpookyConfig<any>['database'] {
56
+ getConfig(): Sp00kyConfig<any>['database'] {
56
57
  return this.config;
57
58
  }
58
59
 
59
60
  async connect(): Promise<void> {
60
61
  const { namespace, database } = this.getConfig();
62
+ const store = this.getConfig().store ?? 'memory';
63
+ const storeUrl = store === 'memory' ? 'mem://' : 'indxdb://sp00ky';
61
64
  this.logger.info(
62
- { namespace, database, Category: 'spooky-client::LocalDatabaseService::connect' },
65
+ { namespace, database, storeUrl, Category: 'sp00ky-client::LocalDatabaseService::connect' },
63
66
  'Connecting to local database'
64
67
  );
68
+
69
+ this.registerUnloadClose();
70
+
65
71
  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'
72
+ await this.openStore(storeUrl, namespace, database);
73
+ this.logger.info(
74
+ { Category: 'sp00ky-client::LocalDatabaseService::connect' },
75
+ 'Connected to local database'
71
76
  );
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'
77
+ return;
78
+ } catch (err) {
79
+ // A persistent (IndexedDB) local store can fail to open if it was left
80
+ // corrupt or version-incompatible by a prior session/crash/engine bump.
81
+ // The local store is only a cache (everything re-syncs from the server),
82
+ // so recover by dropping it and reconnecting rather than bricking startup.
83
+ if (store === 'memory' || !isLocalStoreOpenError(err)) {
84
+ this.logger.error(
85
+ { err, Category: 'sp00ky-client::LocalDatabaseService::connect' },
86
+ 'Failed to connect to local database'
87
+ );
88
+ throw err;
89
+ }
90
+ this.logger.warn(
91
+ { err, Category: 'sp00ky-client::LocalDatabaseService::connect' },
92
+ 'Local IndexedDB store failed to open; retrying before clearing'
76
93
  );
94
+ }
77
95
 
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
- );
96
+ // Tier 1 — RETRY the SAME store WITHOUT dropping. The idb open/`use` failure
97
+ // is often transient (a not-yet-released handle from the previous page, or a
98
+ // first-open WAL-recovery race), not real corruption. Closing and reopening
99
+ // frequently succeeds — and crucially PRESERVES the cache, so a warm load
100
+ // stays warm. Dropping the store every time (the old behavior) silently wiped
101
+ // the cache on every reload, making warm loads as slow as cold ones.
102
+ for (let attempt = 1; attempt <= 2; attempt++) {
103
+ try {
104
+ await this.client.close();
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ await delay(150 * attempt);
109
+ try {
110
+ await this.openStore(storeUrl, namespace, database);
111
+ this.logger.info(
112
+ { attempt, Category: 'sp00ky-client::LocalDatabaseService::connect' },
113
+ 'Connected to local database on retry (cache preserved)'
114
+ );
115
+ return;
116
+ } catch (retryErr) {
117
+ this.logger.warn(
118
+ { err: retryErr, attempt, Category: 'sp00ky-client::LocalDatabaseService::connect' },
119
+ 'Local store retry failed'
120
+ );
121
+ }
122
+ }
86
123
 
124
+ // Tier 2 — the store is genuinely unopenable; drop it and reconnect fresh.
125
+ // This loses the cache (re-syncs from the server), so it's the last resort
126
+ // before in-memory.
127
+ try {
128
+ await this.client.close();
129
+ } catch {
130
+ /* ignore — closing a half-open connection is best-effort */
131
+ }
132
+ await dropLocalIndexedDbStores(this.logger);
133
+
134
+ try {
135
+ await this.openStore(storeUrl, namespace, database);
87
136
  this.logger.info(
88
- { Category: 'spooky-client::LocalDatabaseService::connect' },
89
- 'Connected to local database'
137
+ { Category: 'sp00ky-client::LocalDatabaseService::connect' },
138
+ 'Reconnected to local database after clearing the corrupt store'
90
139
  );
91
- } catch (err) {
140
+ } catch (retryErr) {
141
+ // Last resort: run in-memory so the app still loads. No local persistence
142
+ // this session; the freshly-dropped IndexedDB is recreated cleanly next
143
+ // load, and all data re-syncs from the server regardless.
92
144
  this.logger.error(
93
- { err, Category: 'spooky-client::LocalDatabaseService::connect' },
94
- 'Failed to connect to local database'
145
+ { err: retryErr, Category: 'sp00ky-client::LocalDatabaseService::connect' },
146
+ 'Local store still failing after clear; falling back to in-memory'
147
+ );
148
+ try {
149
+ await this.client.close();
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ await this.openStore('mem://', namespace, database);
154
+ this.logger.warn(
155
+ { Category: 'sp00ky-client::LocalDatabaseService::connect' },
156
+ 'Connected to local database (in-memory fallback)'
95
157
  );
96
- throw err;
97
158
  }
98
159
  }
160
+
161
+ private unloadCloseRegistered = false;
162
+
163
+ /**
164
+ * Close the local DB on page unload so the SurrealDB-WASM worker releases its
165
+ * IndexedDB connection cleanly. Without this, the previous page's connection
166
+ * lingers; the next load's `client.connect` opens the store but the first
167
+ * write transaction in `client.use` hits an "IndexedDB error" — which then
168
+ * (mis)triggered the corrupt-store recovery and WIPED the cache on every
169
+ * reload, making warm loads as slow as cold ones. `pagehide` is the reliable
170
+ * unload signal (fires on bfcache + normal navigation); `close()` is async but
171
+ * the WASM worker initiates the IndexedDB connection teardown synchronously.
172
+ */
173
+ private registerUnloadClose(): void {
174
+ if (this.unloadCloseRegistered || typeof window === 'undefined') return;
175
+ this.unloadCloseRegistered = true;
176
+ const close = () => {
177
+ try {
178
+ void this.client.close();
179
+ } catch {
180
+ /* best-effort */
181
+ }
182
+ };
183
+ window.addEventListener('pagehide', close);
184
+ window.addEventListener('beforeunload', close);
185
+ }
186
+
187
+ private async openStore(storeUrl: string, namespace: string, database: string): Promise<void> {
188
+ this.logger.debug(
189
+ { storeUrl, Category: 'sp00ky-client::LocalDatabaseService::connect' },
190
+ '[LocalDatabaseService] Calling client.connect'
191
+ );
192
+ await this.client.connect(storeUrl, {});
193
+ this.logger.debug(
194
+ { namespace, database, Category: 'sp00ky-client::LocalDatabaseService::connect' },
195
+ '[LocalDatabaseService] client.connect returned. Calling client.use'
196
+ );
197
+ await this.client.use({ namespace, database });
198
+ this.logger.debug(
199
+ { Category: 'sp00ky-client::LocalDatabaseService::connect' },
200
+ '[LocalDatabaseService] client.use returned'
201
+ );
202
+ }
203
+ }
204
+
205
+ function delay(ms: number): Promise<void> {
206
+ return new Promise((resolve) => setTimeout(resolve, ms));
207
+ }
208
+
209
+ /** True for the SurrealDB-WASM error raised when its IndexedDB-backed key-value
210
+ * store can't be opened (corrupt / version-incompatible / blocked). Exported
211
+ * for unit testing the error-message match. */
212
+ export function isLocalStoreOpenError(err: unknown): boolean {
213
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
214
+ return (
215
+ msg.includes('indexeddb') ||
216
+ msg.includes('idb error') ||
217
+ msg.includes('key-value store')
218
+ );
219
+ }
220
+
221
+ /** Best-effort delete of this client's IndexedDB store(s). The persistent local
222
+ * DB lives at `indxdb://sp00ky`; SurrealDB-WASM backs it with one or more
223
+ * IndexedDB databases whose names include `sp00ky`. Resolves even on
224
+ * error/blocked so startup can proceed. No-op outside a browser. */
225
+ async function dropLocalIndexedDbStores(logger: Logger): Promise<void> {
226
+ if (typeof indexedDB === 'undefined') return;
227
+ const remove = (name: string): Promise<void> =>
228
+ new Promise((resolve) => {
229
+ try {
230
+ const req = indexedDB.deleteDatabase(name);
231
+ req.onsuccess = () => resolve();
232
+ req.onerror = () => resolve();
233
+ req.onblocked = () => resolve();
234
+ } catch {
235
+ resolve();
236
+ }
237
+ });
238
+ try {
239
+ let names: string[] = [];
240
+ if (typeof indexedDB.databases === 'function') {
241
+ const dbs = await indexedDB.databases();
242
+ names = dbs
243
+ .map((d) => d.name)
244
+ .filter((n): n is string => !!n && n.toLowerCase().includes('sp00ky'));
245
+ }
246
+ // Fall back to the known store name if enumeration is unavailable/empty.
247
+ if (names.length === 0) names = ['sp00ky'];
248
+ await Promise.all(names.map(remove));
249
+ logger.info(
250
+ { names, Category: 'sp00ky-client::LocalDatabaseService::connect' },
251
+ 'Cleared local IndexedDB store(s)'
252
+ );
253
+ } catch (e) {
254
+ logger.warn(
255
+ { err: e, Category: 'sp00ky-client::LocalDatabaseService::connect' },
256
+ 'Failed to enumerate/clear IndexedDB; proceeding anyway'
257
+ );
258
+ }
99
259
  }
@@ -1,20 +1,20 @@
1
+ import type {
2
+ Diagnostic} from 'surrealdb';
1
3
  import {
2
4
  applyDiagnostics,
3
5
  createRemoteEngines,
4
- Diagnostic,
5
6
  Surreal,
6
- SurrealTransaction,
7
7
  } from 'surrealdb';
8
- import { SpookyConfig } from '../../types';
9
- import { Logger } from '../logger/index';
8
+ import type { Sp00kyConfig } from '../../types';
9
+ import type { Logger } from '../logger/index';
10
10
  import { AbstractDatabaseService } from './database';
11
11
  import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
12
12
 
13
13
  export class RemoteDatabaseService extends AbstractDatabaseService {
14
- private config: SpookyConfig<any>['database'];
14
+ private config: Sp00kyConfig<any>['database'];
15
15
  protected eventType = DatabaseEventTypes.RemoteQuery;
16
16
 
17
- constructor(config: SpookyConfig<any>['database'], logger: Logger) {
17
+ constructor(config: Sp00kyConfig<any>['database'], logger: Logger) {
18
18
  const events = createDatabaseEventSystem();
19
19
  super(
20
20
  new Surreal({
@@ -29,7 +29,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
29
29
  type,
30
30
  phase,
31
31
  service: 'surrealdb:remote',
32
- Category: 'spooky-client::RemoteDatabaseService::diagnostics',
32
+ Category: 'sp00ky-client::RemoteDatabaseService::diagnostics',
33
33
  },
34
34
  `Remote SurrealDB diagnostics captured ${type}:${phase}`
35
35
  );
@@ -43,7 +43,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
43
43
  this.config = config;
44
44
  }
45
45
 
46
- getConfig(): SpookyConfig<any>['database'] {
46
+ getConfig(): Sp00kyConfig<any>['database'] {
47
47
  return this.config;
48
48
  }
49
49
 
@@ -55,7 +55,7 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
55
55
  endpoint,
56
56
  namespace,
57
57
  database,
58
- Category: 'spooky-client::RemoteDatabaseService::connect',
58
+ Category: 'sp00ky-client::RemoteDatabaseService::connect',
59
59
  },
60
60
  'Connecting to remote database'
61
61
  );
@@ -68,25 +68,25 @@ export class RemoteDatabaseService extends AbstractDatabaseService {
68
68
 
69
69
  if (token) {
70
70
  this.logger.debug(
71
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
71
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
72
72
  'Authenticating with token'
73
73
  );
74
74
  await this.client.authenticate(token);
75
75
  }
76
76
  this.logger.info(
77
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
77
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
78
78
  'Connected to remote database'
79
79
  );
80
80
  } catch (err) {
81
81
  this.logger.error(
82
- { err, Category: 'spooky-client::RemoteDatabaseService::connect' },
82
+ { err, Category: 'sp00ky-client::RemoteDatabaseService::connect' },
83
83
  'Failed to connect to remote database'
84
84
  );
85
85
  throw err;
86
86
  }
87
87
  } else {
88
88
  this.logger.warn(
89
- { Category: 'spooky-client::RemoteDatabaseService::connect' },
89
+ { Category: 'sp00ky-client::RemoteDatabaseService::connect' },
90
90
  'No endpoint configured for remote database'
91
91
  );
92
92
  }
@@ -1,114 +1,19 @@
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');
1
+ import pino, { type Level, type Logger as PinoLogger, type LoggerOptions } from 'pino';
2
+ import type { PinoTransmit } from '../../types';
9
3
 
10
4
  export type Logger = PinoLogger;
11
5
 
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 {
6
+ export function createLogger(level: Level = 'info', transmit?: PinoTransmit): Logger {
34
7
  const browserConfig: LoggerOptions['browser'] = {
35
8
  asObject: true,
36
9
  write: (o: any) => {
10
+ // oxlint-disable-next-line no-console
37
11
  console.log(JSON.stringify(o));
38
12
  },
39
13
  };
40
14
 
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
- };
15
+ if (transmit) {
16
+ browserConfig.transmit = transmit;
112
17
  }
113
18
 
114
19
  return pino({
@@ -1,5 +1,5 @@
1
- import { Logger } from 'pino';
2
- import { PersistenceClient } from '../../types';
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
3
 
4
4
  export class LocalStoragePersistenceClient implements PersistenceClient {
5
5
  private logger: Logger;
@@ -0,0 +1,41 @@
1
+ import type { Logger } from 'pino';
2
+ import type { PersistenceClient } from '../../types';
3
+
4
+ export class ResilientPersistenceClient implements PersistenceClient {
5
+ private logger: Logger;
6
+
7
+ constructor(
8
+ private inner: PersistenceClient,
9
+ logger: Logger
10
+ ) {
11
+ this.logger = logger.child({ service: 'ResilientPersistenceClient' });
12
+ }
13
+
14
+ set<T>(key: string, value: T): Promise<void> {
15
+ return this.inner.set(key, value);
16
+ }
17
+
18
+ async get<T>(key: string): Promise<T | null> {
19
+ try {
20
+ return await this.inner.get<T>(key);
21
+ } catch (e) {
22
+ this.logger.warn(
23
+ { key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
24
+ 'Persistence read failed, dropping key'
25
+ );
26
+ // Best-effort cleanup of the corrupt key; if removal also fails, the key
27
+ // just gets dropped again on the next failed read, so the failure is safe.
28
+ await this.inner.remove(key).catch((removeErr) => {
29
+ this.logger.debug(
30
+ { key, error: removeErr, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
31
+ 'Failed to drop corrupt persistence key'
32
+ );
33
+ });
34
+ return null;
35
+ }
36
+ }
37
+
38
+ remove(key: string): Promise<void> {
39
+ return this.inner.remove(key);
40
+ }
41
+ }