@spooky-sync/core 0.0.1-canary.69 → 0.0.1-canary.70
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/dist/index.d.ts +102 -2
- package/dist/index.js +1066 -129
- package/dist/otel/index.d.ts +1 -1
- package/dist/types.d.ts +74 -1
- package/package.json +2 -2
- package/src/build-globals.d.ts +6 -0
- package/src/events/index.ts +3 -0
- package/src/modules/cache/index.ts +13 -6
- package/src/modules/crdt/index.ts +9 -1
- package/src/modules/data/data.status.test.ts +108 -0
- package/src/modules/data/index.ts +436 -56
- 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 +54 -1
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +81 -0
- package/src/modules/sync/engine.ts +43 -29
- package/src/modules/sync/sync.ts +247 -55
- package/src/modules/sync/utils.test.ts +80 -0
- package/src/modules/sync/utils.ts +72 -0
- package/src/services/database/local.test.ts +33 -0
- package/src/services/database/local.ts +182 -23
- package/src/services/persistence/resilient.ts +8 -1
- package/src/services/stream-processor/index.ts +148 -5
- 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/wasm-types.ts +16 -0
- package/src/sp00ky.ts +86 -6
- package/src/types.ts +85 -0
- package/src/utils/index.ts +13 -3
- package/tsdown.config.ts +54 -0
|
@@ -59,42 +59,201 @@ export class LocalDatabaseService extends AbstractDatabaseService {
|
|
|
59
59
|
|
|
60
60
|
async connect(): Promise<void> {
|
|
61
61
|
const { namespace, database } = this.getConfig();
|
|
62
|
+
const store = this.getConfig().store ?? 'memory';
|
|
63
|
+
const storeUrl = store === 'memory' ? 'mem://' : 'indxdb://sp00ky';
|
|
62
64
|
this.logger.info(
|
|
63
|
-
{ namespace, database, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
65
|
+
{ namespace, database, storeUrl, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
64
66
|
'Connecting to local database'
|
|
65
67
|
);
|
|
68
|
+
|
|
69
|
+
this.registerUnloadClose();
|
|
70
|
+
|
|
66
71
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'[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'
|
|
72
76
|
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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'
|
|
77
93
|
);
|
|
94
|
+
}
|
|
78
95
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
}
|
|
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);
|
|
87
133
|
|
|
134
|
+
try {
|
|
135
|
+
await this.openStore(storeUrl, namespace, database);
|
|
88
136
|
this.logger.info(
|
|
89
137
|
{ Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
90
|
-
'
|
|
138
|
+
'Reconnected to local database after clearing the corrupt store'
|
|
91
139
|
);
|
|
92
|
-
} 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.
|
|
93
144
|
this.logger.error(
|
|
94
|
-
{ err, Category: 'sp00ky-client::LocalDatabaseService::connect' },
|
|
95
|
-
'
|
|
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)'
|
|
96
157
|
);
|
|
97
|
-
throw err;
|
|
98
158
|
}
|
|
99
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
|
+
}
|
|
100
259
|
}
|
|
@@ -23,7 +23,14 @@ export class ResilientPersistenceClient implements PersistenceClient {
|
|
|
23
23
|
{ key, error: e, Category: 'sp00ky-client::ResilientPersistenceClient::get' },
|
|
24
24
|
'Persistence read failed, dropping key'
|
|
25
25
|
);
|
|
26
|
-
|
|
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
|
+
});
|
|
27
34
|
return null;
|
|
28
35
|
}
|
|
29
36
|
}
|
|
@@ -34,6 +34,15 @@ export interface StreamUpdate {
|
|
|
34
34
|
* for the initial register_view snapshot.
|
|
35
35
|
*/
|
|
36
36
|
materializationTimeMs?: number;
|
|
37
|
+
/** SSP internal sub-phase timings (ms) for this ingest, from the WASM binding. */
|
|
38
|
+
storeApplyMs?: number;
|
|
39
|
+
circuitStepMs?: number;
|
|
40
|
+
transformMs?: number;
|
|
41
|
+
/**
|
|
42
|
+
* One-shot registration timings (ms). Only set on the StreamUpdate returned
|
|
43
|
+
* by `registerQueryPlan` (the register_view snapshot), not on ingest updates.
|
|
44
|
+
*/
|
|
45
|
+
registration?: { parseMs: number; planMs: number; snapshotMs: number };
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// Define events map (kept for DevTools compatibility)
|
|
@@ -53,6 +62,12 @@ export class StreamProcessorService {
|
|
|
53
62
|
private processor: WasmProcessor | undefined;
|
|
54
63
|
private isInitialized = false;
|
|
55
64
|
private receivers: StreamUpdateReceiver[] = [];
|
|
65
|
+
// When true, `notifyUpdates` coalesces updates into `batchBuffer` (keyed by
|
|
66
|
+
// queryHash) instead of dispatching them. Used to collapse the per-record
|
|
67
|
+
// stream updates produced by a batched ingest into a single notification per
|
|
68
|
+
// query, so the UI updates once after the whole batch rather than row-by-row.
|
|
69
|
+
private batching = false;
|
|
70
|
+
private batchBuffer: Map<string, StreamUpdate> = new Map();
|
|
56
71
|
|
|
57
72
|
constructor(
|
|
58
73
|
public events: EventSystem<StreamProcessorEvents>,
|
|
@@ -72,6 +87,32 @@ export class StreamProcessorService {
|
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
private notifyUpdates(updates: StreamUpdate[]) {
|
|
90
|
+
if (this.batching) {
|
|
91
|
+
// Coalesce by queryHash instead of dispatching. The WASM `result_data`
|
|
92
|
+
// (localArray) is the full materialized array, so last-write-wins
|
|
93
|
+
// already reflects every prior ingest in the batch. We sum the
|
|
94
|
+
// materialization times so the single recorded sample reflects the
|
|
95
|
+
// batch's total work, and emit `op: 'CREATE'` on flush so the coalesced
|
|
96
|
+
// update takes DataModule's immediate (non-debounced) path.
|
|
97
|
+
for (const update of updates) {
|
|
98
|
+
const prev = this.batchBuffer.get(update.queryHash);
|
|
99
|
+
const sum = (a?: number, b?: number) => (a ?? 0) + (b ?? 0);
|
|
100
|
+
this.batchBuffer.set(update.queryHash, {
|
|
101
|
+
...update,
|
|
102
|
+
op: 'CREATE',
|
|
103
|
+
materializationTimeMs: sum(prev?.materializationTimeMs, update.materializationTimeMs),
|
|
104
|
+
storeApplyMs: sum(prev?.storeApplyMs, update.storeApplyMs),
|
|
105
|
+
circuitStepMs: sum(prev?.circuitStepMs, update.circuitStepMs),
|
|
106
|
+
transformMs: sum(prev?.transformMs, update.transformMs),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.dispatchUpdates(updates);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private dispatchUpdates(updates: StreamUpdate[]) {
|
|
75
116
|
for (const update of updates) {
|
|
76
117
|
for (const receiver of this.receivers) {
|
|
77
118
|
receiver.onStreamUpdate(update);
|
|
@@ -79,6 +120,72 @@ export class StreamProcessorService {
|
|
|
79
120
|
}
|
|
80
121
|
}
|
|
81
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Ingest a batch of record changes as a single bulk operation, firing only
|
|
125
|
+
* one coalesced `StreamUpdate` per affected query once every record has been
|
|
126
|
+
* ingested (instead of one update per record). Use this whenever multiple
|
|
127
|
+
* records land at once — e.g. sync fetching N missing rows — so a list query
|
|
128
|
+
* re-runs and the UI re-renders once for the whole batch rather than
|
|
129
|
+
* row-by-row.
|
|
130
|
+
*
|
|
131
|
+
* Internally opens a coalescing window, ingests each record, then flushes;
|
|
132
|
+
* processor state is persisted once for the whole batch. No-op for an empty
|
|
133
|
+
* batch.
|
|
134
|
+
*/
|
|
135
|
+
ingestMany(
|
|
136
|
+
records: Array<{
|
|
137
|
+
table: string;
|
|
138
|
+
op: 'CREATE' | 'UPDATE' | 'DELETE';
|
|
139
|
+
id: string;
|
|
140
|
+
record: any;
|
|
141
|
+
}>
|
|
142
|
+
): void {
|
|
143
|
+
if (records.length === 0) return;
|
|
144
|
+
|
|
145
|
+
this.beginCoalescing();
|
|
146
|
+
try {
|
|
147
|
+
for (const record of records) {
|
|
148
|
+
this.ingest(record.table, record.op, record.id, record.record);
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
this.flushCoalescing();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Open a coalescing window. While open, the per-record stream updates
|
|
157
|
+
* emitted by `ingest` are buffered (one entry per queryHash) instead of
|
|
158
|
+
* dispatched. Always paired with `flushCoalescing()` in a try/finally by
|
|
159
|
+
* `ingestMany` so the window always closes — otherwise the processor stays
|
|
160
|
+
* stuck buffering forever.
|
|
161
|
+
*
|
|
162
|
+
* No-op if a window is already open (nested batches aren't expected here).
|
|
163
|
+
*/
|
|
164
|
+
private beginCoalescing() {
|
|
165
|
+
if (this.batching) return;
|
|
166
|
+
this.batching = true;
|
|
167
|
+
this.batchBuffer.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Close the coalescing window and flush: dispatch one coalesced
|
|
172
|
+
* `StreamUpdate` per buffered queryHash, then persist processor state once
|
|
173
|
+
* for the whole batch (instead of once per ingest).
|
|
174
|
+
*/
|
|
175
|
+
private flushCoalescing() {
|
|
176
|
+
if (!this.batching) return;
|
|
177
|
+
this.batching = false;
|
|
178
|
+
const buffered = Array.from(this.batchBuffer.values());
|
|
179
|
+
this.batchBuffer.clear();
|
|
180
|
+
if (buffered.length > 0) {
|
|
181
|
+
this.dispatchUpdates(buffered);
|
|
182
|
+
}
|
|
183
|
+
// The processor state after the last ingest is cumulative, so a single
|
|
184
|
+
// snapshot covers the whole batch. Kept fire-and-forget like the per-ingest
|
|
185
|
+
// call it replaces.
|
|
186
|
+
this.saveState();
|
|
187
|
+
}
|
|
188
|
+
|
|
82
189
|
/**
|
|
83
190
|
* Initialize the WASM module and processor.
|
|
84
191
|
* This must be called before using other methods.
|
|
@@ -135,8 +242,8 @@ export class StreamProcessorService {
|
|
|
135
242
|
);
|
|
136
243
|
// Assuming processor has a load_state method matching the save_state behavior
|
|
137
244
|
// If not, we might need to adjust based on the actual WASM API
|
|
138
|
-
if (typeof
|
|
139
|
-
|
|
245
|
+
if (typeof this.processor.load_state === 'function') {
|
|
246
|
+
this.processor.load_state(state);
|
|
140
247
|
} else {
|
|
141
248
|
this.logger.warn(
|
|
142
249
|
{ Category: 'sp00ky-client::StreamProcessorService::loadState' },
|
|
@@ -157,12 +264,36 @@ export class StreamProcessorService {
|
|
|
157
264
|
}
|
|
158
265
|
}
|
|
159
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Seed per-table `select` permission predicates ({ [table]: whereText }).
|
|
269
|
+
* Must run after the processor exists and before any `register_view`, else
|
|
270
|
+
* non-`_00_` tables are default-denied and registration fails.
|
|
271
|
+
*/
|
|
272
|
+
setPermissions(permissions: Record<string, string>) {
|
|
273
|
+
if (!this.processor) return;
|
|
274
|
+
if (typeof this.processor.set_permissions !== 'function') {
|
|
275
|
+
this.logger.warn(
|
|
276
|
+
{ Category: 'sp00ky-client::StreamProcessorService::setPermissions' },
|
|
277
|
+
'set_permissions not found on processor (stale WASM build?)'
|
|
278
|
+
);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
this.processor.set_permissions(permissions);
|
|
282
|
+
this.logger.info(
|
|
283
|
+
{
|
|
284
|
+
tables: Object.keys(permissions).length,
|
|
285
|
+
Category: 'sp00ky-client::StreamProcessorService::setPermissions',
|
|
286
|
+
},
|
|
287
|
+
'Seeded table permissions'
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
160
291
|
async saveState() {
|
|
161
292
|
if (!this.processor) return;
|
|
162
293
|
try {
|
|
163
294
|
// Assuming processor has a save_state method that returns the state string/bytes
|
|
164
|
-
if (typeof
|
|
165
|
-
const state =
|
|
295
|
+
if (typeof this.processor.save_state === 'function') {
|
|
296
|
+
const state = this.processor.save_state();
|
|
166
297
|
if (state) {
|
|
167
298
|
await this.persistenceClient.set('_00_stream_processor_state', state);
|
|
168
299
|
this.logger.trace(
|
|
@@ -232,11 +363,18 @@ export class StreamProcessorService {
|
|
|
232
363
|
localArray: u.result_data,
|
|
233
364
|
op: op,
|
|
234
365
|
materializationTimeMs,
|
|
366
|
+
storeApplyMs: u.timing_store_apply_ms,
|
|
367
|
+
circuitStepMs: u.timing_circuit_step_ms,
|
|
368
|
+
transformMs: u.timing_transform_ms,
|
|
235
369
|
}));
|
|
236
370
|
// Direct handler call instead of event
|
|
237
371
|
this.notifyUpdates(updates);
|
|
238
372
|
}
|
|
239
|
-
|
|
373
|
+
// While batching (inside `ingestMany`), `flushCoalescing` persists once
|
|
374
|
+
// for the whole batch — skip the redundant per-record snapshot here.
|
|
375
|
+
if (!this.batching) {
|
|
376
|
+
this.saveState();
|
|
377
|
+
}
|
|
240
378
|
return rawUpdates;
|
|
241
379
|
} catch (e) {
|
|
242
380
|
this.logger.error(
|
|
@@ -293,6 +431,11 @@ export class StreamProcessorService {
|
|
|
293
431
|
const update: StreamUpdate = {
|
|
294
432
|
queryHash: initialUpdate.query_id,
|
|
295
433
|
localArray: initialUpdate.result_data,
|
|
434
|
+
registration: {
|
|
435
|
+
parseMs: initialUpdate.timing_parse_ms ?? 0,
|
|
436
|
+
planMs: initialUpdate.timing_plan_ms ?? 0,
|
|
437
|
+
snapshotMs: initialUpdate.timing_snapshot_ms ?? 0,
|
|
438
|
+
},
|
|
296
439
|
};
|
|
297
440
|
this.saveState();
|
|
298
441
|
this.logger.debug(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractSelectPermissions } from './permissions';
|
|
3
|
+
|
|
4
|
+
describe('extractSelectPermissions', () => {
|
|
5
|
+
it('maps a permissive multi-action table to true', () => {
|
|
6
|
+
const surql = `DEFINE TABLE game SCHEMAFULL
|
|
7
|
+
PERMISSIONS FOR select, create, update, delete WHERE true;`;
|
|
8
|
+
expect(extractSelectPermissions(surql)).toEqual({ game: 'true' });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('handles FULL and NONE', () => {
|
|
12
|
+
const surql = `
|
|
13
|
+
DEFINE TABLE a PERMISSIONS FULL;
|
|
14
|
+
DEFINE TABLE b PERMISSIONS NONE;`;
|
|
15
|
+
expect(extractSelectPermissions(surql)).toEqual({ a: 'true', b: 'false' });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('extracts the select predicate from multiple FOR groups', () => {
|
|
19
|
+
const surql = `DEFINE TABLE doc SCHEMAFULL
|
|
20
|
+
PERMISSIONS
|
|
21
|
+
FOR create, update, delete WHERE owner = $auth.id
|
|
22
|
+
FOR select WHERE owner = $auth.id OR public = true;`;
|
|
23
|
+
expect(extractSelectPermissions(surql)).toEqual({
|
|
24
|
+
doc: 'owner = $auth.id OR public = true',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns false when permissions exist but none grant select', () => {
|
|
29
|
+
const surql = `DEFINE TABLE log PERMISSIONS FOR create WHERE true;`;
|
|
30
|
+
expect(extractSelectPermissions(surql)).toEqual({ log: 'false' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('treats a missing PERMISSIONS clause as default-deny', () => {
|
|
34
|
+
const surql = `DEFINE TABLE bare SCHEMALESS;`;
|
|
35
|
+
expect(extractSelectPermissions(surql)).toEqual({ bare: 'false' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores commented-out clauses and handles OVERWRITE / IF NOT EXISTS', () => {
|
|
39
|
+
const surql = `
|
|
40
|
+
-- DEFINE TABLE ghost PERMISSIONS FULL;
|
|
41
|
+
DEFINE TABLE OVERWRITE u PERMISSIONS FOR select WHERE true;
|
|
42
|
+
DEFINE TABLE IF NOT EXISTS v PERMISSIONS FULL;`;
|
|
43
|
+
const perms = extractSelectPermissions(surql);
|
|
44
|
+
expect(perms).toEqual({ u: 'true', v: 'true' });
|
|
45
|
+
expect(perms.ghost).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract per-table `select` permission predicates from a raw `.surql` schema.
|
|
3
|
+
*
|
|
4
|
+
* The in-browser SSP default-denies any non-`_00_` table that has no permission
|
|
5
|
+
* predicate registered (see `permission_inject::build_predicate`). The client
|
|
6
|
+
* already holds the full schema text (`config.schemaSurql`), so we parse each
|
|
7
|
+
* `DEFINE TABLE … PERMISSIONS …` clause and hand the `select` predicate to the
|
|
8
|
+
* processor via `set_permissions` at boot — mirroring the native path that
|
|
9
|
+
* seeds the same map from `INFO FOR DB`.
|
|
10
|
+
*
|
|
11
|
+
* Returns `{ [table]: whereText }` where `whereText` is the raw SurrealQL
|
|
12
|
+
* expression (`'true'` for `FULL`, `'false'` for `NONE` or a table with no
|
|
13
|
+
* `select` permission).
|
|
14
|
+
*/
|
|
15
|
+
export function extractSelectPermissions(schemaSurql: string): Record<string, string> {
|
|
16
|
+
const out: Record<string, string> = {};
|
|
17
|
+
if (!schemaSurql) return out;
|
|
18
|
+
|
|
19
|
+
// Drop line comments so a `--` comment can't contain a stray PERMISSIONS/FOR.
|
|
20
|
+
const cleaned = schemaSurql.replace(/--[^\n]*/g, '');
|
|
21
|
+
|
|
22
|
+
// One entry per `DEFINE TABLE … ;` statement.
|
|
23
|
+
const tableStmt = /DEFINE\s+TABLE\s+(?:OVERWRITE\s+|IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][\w]*)\b([^;]*);/gi;
|
|
24
|
+
let m: RegExpExecArray | null;
|
|
25
|
+
while ((m = tableStmt.exec(cleaned)) !== null) {
|
|
26
|
+
const table = m[1];
|
|
27
|
+
const body = m[2];
|
|
28
|
+
out[table] = selectPredicateFromBody(body);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectPredicateFromBody(body: string): string {
|
|
34
|
+
const permIdx = body.search(/\bPERMISSIONS\b/i);
|
|
35
|
+
if (permIdx === -1) return 'false'; // no clause → SurrealDB default-deny
|
|
36
|
+
const perms = body.slice(permIdx + 'PERMISSIONS'.length).trim();
|
|
37
|
+
|
|
38
|
+
if (/^FULL\b/i.test(perms)) return 'true';
|
|
39
|
+
if (/^NONE\b/i.test(perms)) return 'false';
|
|
40
|
+
|
|
41
|
+
// `FOR <actions> WHERE <expr>` groups, possibly several. Split on the FOR
|
|
42
|
+
// boundary; the leading empty segment (before the first FOR) is ignored.
|
|
43
|
+
const groups = perms.split(/\bFOR\b/i).map((g) => g.trim()).filter(Boolean);
|
|
44
|
+
for (const group of groups) {
|
|
45
|
+
const where = group.search(/\bWHERE\b/i);
|
|
46
|
+
if (where === -1) continue;
|
|
47
|
+
const actions = group.slice(0, where).toLowerCase();
|
|
48
|
+
if (/\bselect\b/.test(actions)) {
|
|
49
|
+
return group.slice(where + 'WHERE'.length).trim();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return 'false'; // permissions present but none grant select
|
|
53
|
+
}
|