@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.
- package/AGENTS.md +56 -0
- package/dist/index.d.ts +299 -369
- package/dist/index.js +2278 -399
- package/dist/otel/index.d.ts +21 -0
- package/dist/otel/index.js +86 -0
- package/dist/types.d.ts +460 -0
- package/package.json +37 -7
- package/skills/sp00ky-core/SKILL.md +258 -0
- package/skills/sp00ky-core/references/auth.md +98 -0
- package/skills/sp00ky-core/references/config.md +76 -0
- package/src/build-globals.d.ts +12 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +3 -2
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +17 -20
- package/src/modules/cache/index.ts +41 -29
- package/src/modules/cache/types.ts +2 -2
- package/src/modules/crdt/crdt-field.ts +281 -0
- package/src/modules/crdt/crdt-hydration.test.ts +206 -0
- package/src/modules/crdt/index.ts +352 -0
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +662 -108
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +130 -0
- package/src/modules/devtools/index.ts +89 -21
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/ref-tables.test.ts +56 -0
- package/src/modules/ref-tables.ts +57 -0
- package/src/modules/sync/engine.ts +97 -37
- package/src/modules/sync/events/index.ts +3 -2
- package/src/modules/sync/queue/queue-down.ts +5 -4
- package/src/modules/sync/queue/queue-up.ts +14 -13
- package/src/modules/sync/scheduler.ts +2 -2
- package/src/modules/sync/sync.ts +553 -58
- package/src/modules/sync/utils.test.ts +239 -2
- package/src/modules/sync/utils.ts +166 -17
- package/src/otel/index.ts +127 -0
- package/src/services/database/database.ts +11 -11
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/local-migrator.ts +21 -21
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +192 -32
- package/src/services/database/remote.ts +13 -13
- package/src/services/logger/index.ts +6 -101
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +41 -0
- package/src/services/persistence/surrealdb.ts +9 -9
- package/src/services/stream-processor/index.ts +205 -36
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +18 -2
- package/src/sp00ky.auth-order.test.ts +65 -0
- package/src/sp00ky.ts +582 -0
- package/src/types.ts +132 -13
- package/src/utils/index.ts +35 -13
- package/src/utils/parser.ts +3 -2
- package/src/utils/surql.ts +24 -15
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +55 -1
- package/src/spooky.ts +0 -392
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
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: '
|
|
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
|
|
47
|
-
DEFINE TABLE IF NOT EXISTS
|
|
48
|
-
DEFINE TABLE IF NOT EXISTS
|
|
49
|
-
DEFINE TABLE IF NOT EXISTS
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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 (
|
|
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
|
|
105
|
-
} catch (
|
|
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
|
|
111
|
+
USE DB _00_temp;
|
|
112
112
|
REMOVE DATABASE ${database};
|
|
113
113
|
`);
|
|
114
|
-
} catch (
|
|
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
|
|
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 {
|
|
1
|
+
import type { Diagnostic} from 'surrealdb';
|
|
2
|
+
import { applyDiagnostics, DateTime, RecordId, Surreal } from 'surrealdb';
|
|
2
3
|
import { createWasmWorkerEngines } from '@surrealdb/wasm';
|
|
3
|
-
import {
|
|
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
|
|
8
|
+
import { encodeRecordId } from '../../utils/index';
|
|
8
9
|
|
|
9
10
|
export class LocalDatabaseService extends AbstractDatabaseService {
|
|
10
|
-
private config:
|
|
11
|
+
private config: Sp00kyConfig<any>['database'];
|
|
11
12
|
protected eventType = DatabaseEventTypes.LocalQuery;
|
|
12
13
|
|
|
13
|
-
constructor(config:
|
|
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: '
|
|
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():
|
|
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: '
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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: '
|
|
89
|
-
'
|
|
137
|
+
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
138
|
+
'Reconnected to local database after clearing the corrupt store'
|
|
90
139
|
);
|
|
91
|
-
} catch (
|
|
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: '
|
|
94
|
-
'
|
|
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 {
|
|
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:
|
|
14
|
+
private config: Sp00kyConfig<any>['database'];
|
|
15
15
|
protected eventType = DatabaseEventTypes.RemoteQuery;
|
|
16
16
|
|
|
17
|
-
constructor(config:
|
|
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: '
|
|
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():
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
42
|
-
|
|
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
|
+
}
|