@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.270
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 +78 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2279 -81
- package/dist/index.js +13490 -2585
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +331 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +939 -46
- package/package.json +16 -8
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
- package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
- package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +131 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +56 -0
- package/src/client/router.ts +96 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +386 -0
- package/src/client/sp00ky-client.test.ts +264 -0
- package/src/client/sp00ky-client.ts +528 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +37 -2
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +61 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +56 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +344 -27
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +529 -47
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/otel/index.ts +13 -10
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +115 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +322 -0
- package/src/query/membership.saga.ts +262 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +171 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +145 -62
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/local-migrator.ts +30 -27
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +491 -67
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +222 -15
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1366 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +104 -0
- package/src/services/database/sqlite-plan-sql.ts +153 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +11 -4
- package/src/services/persistence/surrealdb.ts +10 -10
- package/src/services/stream-processor/index.ts +796 -84
- 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 +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +59 -3
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +365 -0
- package/src/services/tabs/coordinator.ts +633 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -0
- package/src/state/client-state.ts +191 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +283 -0
- package/src/state/reducers.ts +488 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +74 -0
- package/src/sync/tabs.saga.ts +93 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +34 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +603 -14
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +79 -13
- package/src/utils/parser.test.ts +49 -120
- package/src/utils/parser.ts +32 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/src/utils/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
- package/src/spooky.ts +0 -395
|
@@ -1,879 +0,0 @@
|
|
|
1
|
-
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
-
import {
|
|
3
|
-
SchemaStructure,
|
|
4
|
-
TableNames,
|
|
5
|
-
BackendNames,
|
|
6
|
-
BackendRoutes,
|
|
7
|
-
RoutePayload,
|
|
8
|
-
} from '@spooky-sync/query-builder';
|
|
9
|
-
import { LocalDatabaseService } from '../../services/database/index';
|
|
10
|
-
import { CacheModule, RecordWithId } from '../cache/index';
|
|
11
|
-
import { Logger } from '../../services/logger/index';
|
|
12
|
-
import { StreamUpdate } from '../../services/stream-processor/index';
|
|
13
|
-
import {
|
|
14
|
-
MutationEvent,
|
|
15
|
-
QueryConfig,
|
|
16
|
-
QueryHash,
|
|
17
|
-
QueryState,
|
|
18
|
-
QueryTimeToLive,
|
|
19
|
-
QueryUpdateCallback,
|
|
20
|
-
MutationCallback,
|
|
21
|
-
RecordVersionArray,
|
|
22
|
-
QueryConfigRecord,
|
|
23
|
-
UpdateOptions,
|
|
24
|
-
RunOptions,
|
|
25
|
-
} from '../../types';
|
|
26
|
-
import {
|
|
27
|
-
parseRecordIdString,
|
|
28
|
-
extractIdPart,
|
|
29
|
-
encodeRecordId,
|
|
30
|
-
parseDuration,
|
|
31
|
-
withRetry,
|
|
32
|
-
surql,
|
|
33
|
-
parseParams,
|
|
34
|
-
extractTablePart,
|
|
35
|
-
generateId,
|
|
36
|
-
} from '../../utils/index';
|
|
37
|
-
import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
38
|
-
import { PushEventOptions } from '../../events/index';
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* DataModule - Unified query and mutation management
|
|
42
|
-
*
|
|
43
|
-
* Merges the functionality of QueryManager and MutationManager.
|
|
44
|
-
* Uses CacheModule for all storage operations.
|
|
45
|
-
*/
|
|
46
|
-
export class DataModule<S extends SchemaStructure> {
|
|
47
|
-
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
48
|
-
private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
|
|
49
|
-
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
50
|
-
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
51
|
-
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
52
|
-
private logger: Logger;
|
|
53
|
-
|
|
54
|
-
constructor(
|
|
55
|
-
private cache: CacheModule,
|
|
56
|
-
private local: LocalDatabaseService,
|
|
57
|
-
private schema: S,
|
|
58
|
-
logger: Logger,
|
|
59
|
-
private streamDebounceTime: number = 100
|
|
60
|
-
) {
|
|
61
|
-
this.logger = logger.child({ service: 'DataModule' });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async init(): Promise<void> {
|
|
65
|
-
this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ==================== QUERY MANAGEMENT ====================
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Register a query and return its hash for subscriptions
|
|
72
|
-
*/
|
|
73
|
-
async query<T extends TableNames<S>>(
|
|
74
|
-
tableName: T,
|
|
75
|
-
surqlString: string,
|
|
76
|
-
params: Record<string, any>,
|
|
77
|
-
ttl: QueryTimeToLive
|
|
78
|
-
): Promise<QueryHash> {
|
|
79
|
-
const hash = await this.calculateHash({ surql: surqlString, params });
|
|
80
|
-
this.logger.debug(
|
|
81
|
-
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
82
|
-
'Query Initialization: started'
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const recordId = new RecordId('_spooky_query', hash);
|
|
86
|
-
|
|
87
|
-
if (this.activeQueries.has(hash)) {
|
|
88
|
-
this.logger.debug(
|
|
89
|
-
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
90
|
-
'Query Initialization: exists, returning'
|
|
91
|
-
);
|
|
92
|
-
return hash;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Another call is already creating this query — wait for it
|
|
96
|
-
if (this.pendingQueries.has(hash)) {
|
|
97
|
-
this.logger.debug(
|
|
98
|
-
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
99
|
-
'Query Initialization: pending, waiting for existing creation'
|
|
100
|
-
);
|
|
101
|
-
await this.pendingQueries.get(hash);
|
|
102
|
-
return hash;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
this.logger.debug(
|
|
106
|
-
{ hash, Category: 'spooky-client::DataModule::query' },
|
|
107
|
-
'Query Initialization: not found, creating new query'
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
// Create the query and track the pending promise
|
|
111
|
-
const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
|
|
112
|
-
this.pendingQueries.set(hash, promise);
|
|
113
|
-
try {
|
|
114
|
-
await promise;
|
|
115
|
-
} finally {
|
|
116
|
-
this.pendingQueries.delete(hash);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return hash;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Subscribe to query updates
|
|
124
|
-
*/
|
|
125
|
-
subscribe(
|
|
126
|
-
queryHash: string,
|
|
127
|
-
callback: QueryUpdateCallback,
|
|
128
|
-
options: { immediate?: boolean } = {}
|
|
129
|
-
): () => void {
|
|
130
|
-
if (!this.subscriptions.has(queryHash)) {
|
|
131
|
-
this.subscriptions.set(queryHash, new Set());
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
this.subscriptions.get(queryHash)?.add(callback);
|
|
135
|
-
|
|
136
|
-
if (options.immediate) {
|
|
137
|
-
const query = this.activeQueries.get(queryHash);
|
|
138
|
-
if (query) {
|
|
139
|
-
callback(query.records);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Return unsubscribe function
|
|
144
|
-
return () => {
|
|
145
|
-
const subs = this.subscriptions.get(queryHash);
|
|
146
|
-
if (subs) {
|
|
147
|
-
subs.delete(callback);
|
|
148
|
-
if (subs.size === 0) {
|
|
149
|
-
this.subscriptions.delete(queryHash);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Subscribe to mutations (for sync)
|
|
157
|
-
*/
|
|
158
|
-
onMutation(callback: MutationCallback): () => void {
|
|
159
|
-
this.mutationCallbacks.add(callback);
|
|
160
|
-
return () => {
|
|
161
|
-
this.mutationCallbacks.delete(callback);
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Handle stream updates from DBSP (via CacheModule)
|
|
167
|
-
*/
|
|
168
|
-
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
169
|
-
const { queryHash, op } = update;
|
|
170
|
-
|
|
171
|
-
// Only debounce UPDATE operations
|
|
172
|
-
// CREATE and DELETE should propagate immediately
|
|
173
|
-
if (op === 'UPDATE') {
|
|
174
|
-
// Clear existing timer if any
|
|
175
|
-
if (this.debounceTimers.has(queryHash)) {
|
|
176
|
-
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Set new timer
|
|
180
|
-
const timer = setTimeout(async () => {
|
|
181
|
-
this.debounceTimers.delete(queryHash);
|
|
182
|
-
await this.processStreamUpdate(update);
|
|
183
|
-
}, this.streamDebounceTime);
|
|
184
|
-
|
|
185
|
-
this.debounceTimers.set(queryHash, timer);
|
|
186
|
-
} else {
|
|
187
|
-
// CREATE and DELETE - process immediately
|
|
188
|
-
await this.processStreamUpdate(update);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
193
|
-
const { queryHash, localArray } = update;
|
|
194
|
-
const queryState = this.activeQueries.get(queryHash);
|
|
195
|
-
if (!queryState) {
|
|
196
|
-
this.logger.warn(
|
|
197
|
-
{ queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
|
|
198
|
-
'Received update for unknown query. Skipping...'
|
|
199
|
-
);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
// Fetch updated records
|
|
205
|
-
const [records] = await this.local.query<[Record<string, any>[]]>(
|
|
206
|
-
queryState.config.surql,
|
|
207
|
-
queryState.config.params
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
// Update state
|
|
211
|
-
const newRecords = records || [];
|
|
212
|
-
queryState.config.localArray = localArray;
|
|
213
|
-
await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
214
|
-
id: queryState.config.id,
|
|
215
|
-
localArray,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// Skip notification if records haven't changed
|
|
219
|
-
const prevJson = JSON.stringify(queryState.records);
|
|
220
|
-
const newJson = JSON.stringify(newRecords);
|
|
221
|
-
queryState.records = newRecords;
|
|
222
|
-
if (prevJson === newJson) {
|
|
223
|
-
this.logger.debug(
|
|
224
|
-
{ queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
|
|
225
|
-
'Query records unchanged, skipping notification'
|
|
226
|
-
);
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
queryState.updateCount++;
|
|
231
|
-
|
|
232
|
-
// Notify subscribers
|
|
233
|
-
const subscribers = this.subscriptions.get(queryHash);
|
|
234
|
-
if (subscribers) {
|
|
235
|
-
for (const callback of subscribers) {
|
|
236
|
-
callback(queryState.records);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
this.logger.debug(
|
|
241
|
-
{
|
|
242
|
-
queryHash,
|
|
243
|
-
recordCount: records?.length,
|
|
244
|
-
Category: 'spooky-client::DataModule::onStreamUpdate',
|
|
245
|
-
},
|
|
246
|
-
'Query updated from stream'
|
|
247
|
-
);
|
|
248
|
-
} catch (err) {
|
|
249
|
-
this.logger.error(
|
|
250
|
-
{ err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
|
|
251
|
-
'Failed to fetch records for stream update'
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Get query state (for sync and devtools)
|
|
258
|
-
*/
|
|
259
|
-
getQueryByHash(hash: string): QueryState | undefined {
|
|
260
|
-
return this.activeQueries.get(hash);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Get query state by id (for sync and devtools)
|
|
265
|
-
*/
|
|
266
|
-
getQueryById(id: RecordId<string>): QueryState | undefined {
|
|
267
|
-
return this.activeQueries.get(extractIdPart(id));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Get all active queries (for devtools)
|
|
272
|
-
*/
|
|
273
|
-
getActiveQueries(): QueryState[] {
|
|
274
|
-
return Array.from(this.activeQueries.values());
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
|
|
278
|
-
const queryState = this.activeQueries.get(id);
|
|
279
|
-
if (!queryState) {
|
|
280
|
-
this.logger.warn(
|
|
281
|
-
{ id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
|
|
282
|
-
'Query to update local array not found'
|
|
283
|
-
);
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
queryState.config.localArray = localArray;
|
|
287
|
-
await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
288
|
-
id: queryState.config.id,
|
|
289
|
-
localArray,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
|
|
294
|
-
const queryState = this.getQueryByHash(hash);
|
|
295
|
-
if (!queryState) {
|
|
296
|
-
this.logger.warn(
|
|
297
|
-
{ hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
|
|
298
|
-
'Query to update remote array not found'
|
|
299
|
-
);
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
queryState.config.remoteArray = remoteArray;
|
|
303
|
-
await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
|
|
304
|
-
id: queryState.config.id,
|
|
305
|
-
remoteArray,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Called after a query's initial sync completes.
|
|
311
|
-
* Ensures subscribers are notified even if no stream updates fired (e.g. empty result set).
|
|
312
|
-
*/
|
|
313
|
-
async notifyQuerySynced(queryHash: string): Promise<void> {
|
|
314
|
-
const queryState = this.activeQueries.get(queryHash);
|
|
315
|
-
if (!queryState) return;
|
|
316
|
-
|
|
317
|
-
// Re-query local DB for latest data
|
|
318
|
-
const [records] = await this.local.query<[Record<string, any>[]]>(
|
|
319
|
-
queryState.config.surql,
|
|
320
|
-
queryState.config.params
|
|
321
|
-
);
|
|
322
|
-
const newRecords = records || [];
|
|
323
|
-
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
324
|
-
queryState.records = newRecords;
|
|
325
|
-
|
|
326
|
-
// Notify if data changed OR if this is the first sync (updateCount === 0)
|
|
327
|
-
// The latter handles "query truly has no results" so UI can stop loading
|
|
328
|
-
if (changed || queryState.updateCount === 0) {
|
|
329
|
-
queryState.updateCount++;
|
|
330
|
-
const subscribers = this.subscriptions.get(queryHash);
|
|
331
|
-
if (subscribers) {
|
|
332
|
-
for (const callback of subscribers) {
|
|
333
|
-
callback(queryState.records);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ==================== RUN JOBS ====================
|
|
340
|
-
|
|
341
|
-
async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
342
|
-
backend: B,
|
|
343
|
-
path: R,
|
|
344
|
-
data: RoutePayload<S, B, R>,
|
|
345
|
-
options?: RunOptions
|
|
346
|
-
): Promise<void> {
|
|
347
|
-
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
348
|
-
if (!route) {
|
|
349
|
-
throw new Error(`Route ${backend}.${path} not found`);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const tableName = this.schema.backends?.[backend]?.outboxTable;
|
|
353
|
-
if (!tableName) {
|
|
354
|
-
throw new Error(`Outbox table for backend ${backend} not found`);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const payload: Record<string, unknown> = {};
|
|
358
|
-
for (const argName of Object.keys(route.args)) {
|
|
359
|
-
const arg = route.args[argName];
|
|
360
|
-
if ((data as Record<string, unknown>)[argName] === undefined && arg.optional === false) {
|
|
361
|
-
throw new Error(`Missing required argument ${argName}`);
|
|
362
|
-
}
|
|
363
|
-
payload[argName] = (data as Record<string, unknown>)[argName];
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const record: Record<string, unknown> = {
|
|
367
|
-
path,
|
|
368
|
-
payload: JSON.stringify(payload),
|
|
369
|
-
max_retries: options?.max_retries ?? 3,
|
|
370
|
-
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
if (options?.assignedTo) {
|
|
374
|
-
record.assigned_to = options.assignedTo;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const recordId = `${tableName}:${generateId()}`;
|
|
378
|
-
await this.create(recordId, record);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ==================== MUTATION MANAGEMENT ====================
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Create a new record
|
|
385
|
-
*/
|
|
386
|
-
async create<T extends Record<string, unknown>>(id: string, data: T): Promise<T> {
|
|
387
|
-
const tableName = extractTablePart(id);
|
|
388
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
389
|
-
if (!tableSchema) {
|
|
390
|
-
throw new Error(`Table ${tableName} not found`);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const rid = parseRecordIdString(id);
|
|
394
|
-
const params = parseParams(tableSchema.columns, data);
|
|
395
|
-
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
396
|
-
|
|
397
|
-
const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
|
|
398
|
-
const prefixedParams = Object.fromEntries(
|
|
399
|
-
dataKeys.map(({ key, variable }) => [variable, params[key]])
|
|
400
|
-
);
|
|
401
|
-
const query = surql.seal<T>(
|
|
402
|
-
surql.tx([
|
|
403
|
-
surql.createSet('id', dataKeys),
|
|
404
|
-
surql.createMutation('create', 'mid', 'id', 'data'),
|
|
405
|
-
]),
|
|
406
|
-
{ resultIndex: 0 }
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
const target = await withRetry(this.logger, () =>
|
|
410
|
-
this.local.execute(query, {
|
|
411
|
-
id: rid,
|
|
412
|
-
mid: mutationId,
|
|
413
|
-
...prefixedParams,
|
|
414
|
-
})
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
418
|
-
|
|
419
|
-
// Save to cache (which handles DBSP ingestion)
|
|
420
|
-
await this.cache.save(
|
|
421
|
-
{
|
|
422
|
-
table: tableName,
|
|
423
|
-
op: 'CREATE',
|
|
424
|
-
record: parsedRecord,
|
|
425
|
-
version: 1,
|
|
426
|
-
},
|
|
427
|
-
true
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
// Emit mutation event for sync
|
|
431
|
-
const mutationEvent: CreateEvent = {
|
|
432
|
-
type: 'create',
|
|
433
|
-
mutation_id: mutationId,
|
|
434
|
-
record_id: rid,
|
|
435
|
-
data: params,
|
|
436
|
-
record: target,
|
|
437
|
-
tableName,
|
|
438
|
-
};
|
|
439
|
-
|
|
440
|
-
for (const callback of this.mutationCallbacks) {
|
|
441
|
-
callback([mutationEvent]);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
|
|
445
|
-
|
|
446
|
-
return target;
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Update an existing record
|
|
451
|
-
*/
|
|
452
|
-
async update<T extends Record<string, unknown>>(
|
|
453
|
-
table: string,
|
|
454
|
-
id: string,
|
|
455
|
-
data: Partial<T>,
|
|
456
|
-
options?: UpdateOptions
|
|
457
|
-
): Promise<T> {
|
|
458
|
-
const tableName = extractTablePart(id);
|
|
459
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
460
|
-
if (!tableSchema) {
|
|
461
|
-
throw new Error(`Table ${tableName} not found`);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
const rid = parseRecordIdString(id);
|
|
465
|
-
const params = parseParams(tableSchema.columns, data);
|
|
466
|
-
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
467
|
-
|
|
468
|
-
// Capture current record state before mutation for rollback support
|
|
469
|
-
const [beforeRecord] = await withRetry(this.logger, () =>
|
|
470
|
-
this.local.query<[Record<string, any>]>('SELECT * FROM ONLY $id', { id: rid })
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
const query = surql.seal<{ target: T }>(
|
|
474
|
-
surql.tx([
|
|
475
|
-
surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
|
|
476
|
-
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
477
|
-
surql.createMutation('update', 'mid', 'id', 'data'),
|
|
478
|
-
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
479
|
-
])
|
|
480
|
-
);
|
|
481
|
-
|
|
482
|
-
const { target } = await withRetry(this.logger, () =>
|
|
483
|
-
this.local.execute(query, {
|
|
484
|
-
id: rid,
|
|
485
|
-
mid: mutationId,
|
|
486
|
-
data: params,
|
|
487
|
-
})
|
|
488
|
-
);
|
|
489
|
-
|
|
490
|
-
// Build a partial record with only the fields the user actually changed
|
|
491
|
-
// This avoids overwriting rich relation objects (e.g. author: {id, name, ...})
|
|
492
|
-
// with flat RecordIds from the UPDATE...MERGE result
|
|
493
|
-
const updatedFields: Record<string, any> = { id: target.id };
|
|
494
|
-
for (const key of Object.keys(data)) {
|
|
495
|
-
if (key in target) {
|
|
496
|
-
updatedFields[key] = (target as Record<string, any>)[key];
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if ('spooky_rv' in (target as Record<string, any>)) {
|
|
500
|
-
updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
|
|
501
|
-
}
|
|
502
|
-
this.replaceRecordInQueries(updatedFields);
|
|
503
|
-
|
|
504
|
-
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
505
|
-
|
|
506
|
-
// Save to cache
|
|
507
|
-
await this.cache.save(
|
|
508
|
-
{
|
|
509
|
-
table: table,
|
|
510
|
-
op: 'UPDATE',
|
|
511
|
-
record: parsedRecord,
|
|
512
|
-
version: target.spooky_rv as number,
|
|
513
|
-
},
|
|
514
|
-
true
|
|
515
|
-
);
|
|
516
|
-
|
|
517
|
-
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
518
|
-
|
|
519
|
-
// Emit mutation event
|
|
520
|
-
const mutationEvent: UpdateEvent = {
|
|
521
|
-
type: 'update',
|
|
522
|
-
mutation_id: mutationId,
|
|
523
|
-
record_id: rid,
|
|
524
|
-
data: params,
|
|
525
|
-
record: target,
|
|
526
|
-
beforeRecord: beforeRecord || undefined,
|
|
527
|
-
options: pushEventOptions,
|
|
528
|
-
};
|
|
529
|
-
|
|
530
|
-
for (const callback of this.mutationCallbacks) {
|
|
531
|
-
callback([mutationEvent]);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
|
|
535
|
-
|
|
536
|
-
return target;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Delete a record
|
|
541
|
-
*/
|
|
542
|
-
async delete(table: string, id: string): Promise<void> {
|
|
543
|
-
const tableName = extractTablePart(id);
|
|
544
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
545
|
-
if (!tableSchema) {
|
|
546
|
-
throw new Error(`Table ${tableName} not found`);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const rid = parseRecordIdString(id);
|
|
550
|
-
const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
|
|
551
|
-
|
|
552
|
-
// Fetch the record before deleting so DBSP can match it against query predicates
|
|
553
|
-
const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
|
|
554
|
-
'SELECT * FROM ONLY $id',
|
|
555
|
-
{ id: rid }
|
|
556
|
-
);
|
|
557
|
-
const beforeRecord = beforeRecords ?? {};
|
|
558
|
-
|
|
559
|
-
const query = surql.seal<void>(
|
|
560
|
-
surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
564
|
-
await this.cache.delete(table, id, true, beforeRecord);
|
|
565
|
-
|
|
566
|
-
// DBSP may not emit view updates for DELETE ops —
|
|
567
|
-
// manually notify all queries that reference this table
|
|
568
|
-
for (const [queryHash, queryState] of this.activeQueries) {
|
|
569
|
-
if (queryState.config.tableName === tableName) {
|
|
570
|
-
await this.notifyQuerySynced(queryHash);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// Emit mutation event
|
|
575
|
-
const mutationEvent: DeleteEvent = {
|
|
576
|
-
type: 'delete',
|
|
577
|
-
mutation_id: mutationId,
|
|
578
|
-
record_id: rid,
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
for (const callback of this.mutationCallbacks) {
|
|
582
|
-
callback([mutationEvent]);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// ==================== ROLLBACK METHODS ====================
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Rollback a failed optimistic create by deleting the record locally
|
|
592
|
-
*/
|
|
593
|
-
async rollbackCreate(recordId: RecordId, tableName: string): Promise<void> {
|
|
594
|
-
const id = encodeRecordId(recordId);
|
|
595
|
-
|
|
596
|
-
try {
|
|
597
|
-
await withRetry(this.logger, () =>
|
|
598
|
-
this.local.query('DELETE $id', { id: recordId })
|
|
599
|
-
);
|
|
600
|
-
await this.cache.delete(tableName, id, true);
|
|
601
|
-
this.removeRecordFromQueries(recordId);
|
|
602
|
-
|
|
603
|
-
this.logger.info(
|
|
604
|
-
{ id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
|
|
605
|
-
'Rolled back optimistic create'
|
|
606
|
-
);
|
|
607
|
-
} catch (err) {
|
|
608
|
-
this.logger.error(
|
|
609
|
-
{ err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
|
|
610
|
-
'Failed to rollback create'
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Rollback a failed optimistic update by restoring the previous record state
|
|
617
|
-
*/
|
|
618
|
-
async rollbackUpdate(
|
|
619
|
-
recordId: RecordId,
|
|
620
|
-
tableName: string,
|
|
621
|
-
beforeRecord: Record<string, unknown>
|
|
622
|
-
): Promise<void> {
|
|
623
|
-
const id = encodeRecordId(recordId);
|
|
624
|
-
|
|
625
|
-
try {
|
|
626
|
-
const { id: _recordId, ...content } = beforeRecord;
|
|
627
|
-
await withRetry(this.logger, () =>
|
|
628
|
-
this.local.query(surql.seal(surql.upsert('id', 'content')), {
|
|
629
|
-
id: recordId,
|
|
630
|
-
content,
|
|
631
|
-
})
|
|
632
|
-
);
|
|
633
|
-
|
|
634
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
635
|
-
const parsedRecord = tableSchema
|
|
636
|
-
? (parseParams(tableSchema.columns, beforeRecord) as RecordWithId)
|
|
637
|
-
: (beforeRecord as RecordWithId);
|
|
638
|
-
|
|
639
|
-
await this.cache.save(
|
|
640
|
-
{
|
|
641
|
-
table: tableName,
|
|
642
|
-
op: 'UPDATE',
|
|
643
|
-
record: parsedRecord,
|
|
644
|
-
version: (beforeRecord.spooky_rv as number) || 1,
|
|
645
|
-
},
|
|
646
|
-
true
|
|
647
|
-
);
|
|
648
|
-
|
|
649
|
-
// Replace in active queries for immediate UI update
|
|
650
|
-
await this.replaceRecordInQueries(beforeRecord);
|
|
651
|
-
|
|
652
|
-
this.logger.info(
|
|
653
|
-
{ id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
|
|
654
|
-
'Rolled back optimistic update'
|
|
655
|
-
);
|
|
656
|
-
} catch (err) {
|
|
657
|
-
this.logger.error(
|
|
658
|
-
{ err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
|
|
659
|
-
'Failed to rollback update'
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Remove a record from all active query states and notify subscribers
|
|
666
|
-
*/
|
|
667
|
-
private removeRecordFromQueries(recordId: RecordId): void {
|
|
668
|
-
const encodedId = encodeRecordId(recordId);
|
|
669
|
-
|
|
670
|
-
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
671
|
-
const index = queryState.records.findIndex((r) => {
|
|
672
|
-
const rId = r.id instanceof RecordId ? encodeRecordId(r.id) : String(r.id);
|
|
673
|
-
return rId === encodedId;
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
if (index !== -1) {
|
|
677
|
-
queryState.records.splice(index, 1);
|
|
678
|
-
const subscribers = this.subscriptions.get(queryHash);
|
|
679
|
-
if (subscribers) {
|
|
680
|
-
for (const callback of subscribers) {
|
|
681
|
-
callback(queryState.records);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// ==================== PRIVATE HELPERS ====================
|
|
689
|
-
|
|
690
|
-
private async createAndRegisterQuery<T extends TableNames<S>>(
|
|
691
|
-
hash: QueryHash,
|
|
692
|
-
recordId: RecordId,
|
|
693
|
-
surqlString: string,
|
|
694
|
-
params: Record<string, any>,
|
|
695
|
-
ttl: QueryTimeToLive,
|
|
696
|
-
tableName: T
|
|
697
|
-
): Promise<QueryHash> {
|
|
698
|
-
const queryState = await this.createNewQuery<T>({
|
|
699
|
-
recordId,
|
|
700
|
-
surql: surqlString,
|
|
701
|
-
params,
|
|
702
|
-
ttl,
|
|
703
|
-
tableName,
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
const { localArray } = this.cache.registerQuery({
|
|
707
|
-
queryHash: hash,
|
|
708
|
-
surql: surqlString,
|
|
709
|
-
params,
|
|
710
|
-
ttl: new Duration(ttl),
|
|
711
|
-
lastActiveAt: new Date(),
|
|
712
|
-
});
|
|
713
|
-
|
|
714
|
-
await withRetry(this.logger, () =>
|
|
715
|
-
this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
|
|
716
|
-
id: recordId,
|
|
717
|
-
localArray,
|
|
718
|
-
})
|
|
719
|
-
);
|
|
720
|
-
|
|
721
|
-
this.activeQueries.set(hash, queryState);
|
|
722
|
-
this.startTTLHeartbeat(queryState);
|
|
723
|
-
this.logger.debug(
|
|
724
|
-
{
|
|
725
|
-
hash,
|
|
726
|
-
tableName,
|
|
727
|
-
recordCount: queryState.records.length,
|
|
728
|
-
Category: 'spooky-client::DataModule::query',
|
|
729
|
-
},
|
|
730
|
-
'Query registered'
|
|
731
|
-
);
|
|
732
|
-
|
|
733
|
-
return hash;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
private async createNewQuery<T extends TableNames<S>>({
|
|
737
|
-
recordId,
|
|
738
|
-
surql: surqlString,
|
|
739
|
-
params,
|
|
740
|
-
ttl,
|
|
741
|
-
tableName,
|
|
742
|
-
}: {
|
|
743
|
-
recordId: RecordId;
|
|
744
|
-
surql: string;
|
|
745
|
-
params: Record<string, any>;
|
|
746
|
-
ttl: QueryTimeToLive;
|
|
747
|
-
tableName: T;
|
|
748
|
-
}): Promise<QueryState> {
|
|
749
|
-
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
750
|
-
if (!tableSchema) {
|
|
751
|
-
throw new Error(`Table ${tableName} not found`);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
let [configRecord] = await withRetry(this.logger, () =>
|
|
755
|
-
this.local.query<[QueryConfigRecord]>('SELECT * FROM ONLY $id', {
|
|
756
|
-
id: recordId,
|
|
757
|
-
})
|
|
758
|
-
);
|
|
759
|
-
|
|
760
|
-
if (!configRecord) {
|
|
761
|
-
const [createdRecord] = await withRetry(this.logger, () =>
|
|
762
|
-
this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
|
|
763
|
-
id: recordId,
|
|
764
|
-
data: {
|
|
765
|
-
surql: surqlString,
|
|
766
|
-
params: params,
|
|
767
|
-
localArray: [],
|
|
768
|
-
remoteArray: [],
|
|
769
|
-
lastActiveAt: new Date(),
|
|
770
|
-
ttl,
|
|
771
|
-
tableName,
|
|
772
|
-
},
|
|
773
|
-
})
|
|
774
|
-
);
|
|
775
|
-
configRecord = createdRecord;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
const config: QueryConfig = {
|
|
779
|
-
...configRecord,
|
|
780
|
-
id: recordId,
|
|
781
|
-
params: parseParams(tableSchema.columns, configRecord.params),
|
|
782
|
-
};
|
|
783
|
-
|
|
784
|
-
let records: Record<string, any>[] = [];
|
|
785
|
-
try {
|
|
786
|
-
const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
|
|
787
|
-
records = result || [];
|
|
788
|
-
} catch (err) {
|
|
789
|
-
this.logger.warn(
|
|
790
|
-
{ err, Category: 'spooky-client::DataModule::createNewQuery' },
|
|
791
|
-
'Failed to load initial cached records'
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
return {
|
|
796
|
-
config,
|
|
797
|
-
records,
|
|
798
|
-
ttlTimer: null,
|
|
799
|
-
ttlDurationMs: parseDuration(ttl),
|
|
800
|
-
updateCount: 0,
|
|
801
|
-
};
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
private async calculateHash(data: any): Promise<string> {
|
|
805
|
-
const content = JSON.stringify(data);
|
|
806
|
-
const msgBuffer = new TextEncoder().encode(content);
|
|
807
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
808
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
809
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
private startTTLHeartbeat(queryState: QueryState): void {
|
|
813
|
-
if (queryState.ttlTimer) return;
|
|
814
|
-
|
|
815
|
-
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
816
|
-
|
|
817
|
-
queryState.ttlTimer = setTimeout(() => {
|
|
818
|
-
// TODO: Emit heartbeat event for sync
|
|
819
|
-
this.logger.debug(
|
|
820
|
-
{
|
|
821
|
-
id: encodeRecordId(queryState.config.id),
|
|
822
|
-
Category: 'spooky-client::DataModule::startTTLHeartbeat',
|
|
823
|
-
},
|
|
824
|
-
'TTL heartbeat'
|
|
825
|
-
);
|
|
826
|
-
this.startTTLHeartbeat(queryState);
|
|
827
|
-
}, heartbeatTime);
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
831
|
-
if (queryState.ttlTimer) {
|
|
832
|
-
clearTimeout(queryState.ttlTimer);
|
|
833
|
-
queryState.ttlTimer = null;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
838
|
-
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
839
|
-
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
840
|
-
if (index !== -1) {
|
|
841
|
-
queryState.records[index] = { ...queryState.records[index], ...record };
|
|
842
|
-
// Notify subscribers so UI updates immediately
|
|
843
|
-
const subscribers = this.subscriptions.get(queryHash);
|
|
844
|
-
if (subscribers) {
|
|
845
|
-
for (const callback of subscribers) {
|
|
846
|
-
callback(queryState.records);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// ==================== HELPER FUNCTIONS ====================
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Parse update options to generate push event options
|
|
858
|
-
*/
|
|
859
|
-
export function parseUpdateOptions(
|
|
860
|
-
id: string,
|
|
861
|
-
data: any,
|
|
862
|
-
options?: UpdateOptions
|
|
863
|
-
): PushEventOptions {
|
|
864
|
-
let pushEventOptions: PushEventOptions = {};
|
|
865
|
-
if (options?.debounced) {
|
|
866
|
-
const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
|
|
867
|
-
const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
|
|
868
|
-
const key =
|
|
869
|
-
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
|
|
870
|
-
|
|
871
|
-
pushEventOptions = {
|
|
872
|
-
debounced: {
|
|
873
|
-
delay,
|
|
874
|
-
key,
|
|
875
|
-
},
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
return pushEventOptions;
|
|
879
|
-
}
|