@spooky-sync/core 0.0.1-canary.21 → 0.0.1-canary.210
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 +57 -0
- package/dist/index.d.ts +2514 -58
- package/dist/index.js +12561 -2449
- package/dist/otel/index.d.ts +2 -2
- package/dist/otel/index.js +6 -6
- package/dist/sqlite-open.js +303 -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 +751 -11
- package/package.json +11 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -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/bucket-blurhash.test.ts +148 -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 +36 -2
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +201 -0
- package/src/modules/auth/auth.local-first.test.ts +101 -0
- package/src/modules/auth/events/index.ts +2 -1
- package/src/modules/auth/index.ts +127 -24
- package/src/modules/cache/cache.relay.test.ts +95 -0
- package/src/modules/cache/index.ts +163 -43
- package/src/modules/cache/types.ts +2 -2
- 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/data/data.hydration.test.ts +142 -0
- package/src/modules/data/data.membership.test.ts +523 -0
- package/src/modules/data/data.notify-table.test.ts +41 -0
- package/src/modules/data/data.pending-ids.test.ts +199 -0
- package/src/modules/data/data.rebind.test.ts +170 -0
- package/src/modules/data/data.rematerialize.test.ts +114 -0
- package/src/modules/data/data.run.test.ts +113 -0
- package/src/modules/data/data.settled-writes.test.ts +206 -0
- package/src/modules/data/data.status.test.ts +249 -0
- package/src/modules/data/id-set-plan.test.ts +122 -0
- package/src/modules/data/index.ts +1815 -151
- package/src/modules/data/mutation-id.test.ts +25 -0
- package/src/modules/data/mutation-id.ts +35 -0
- package/src/modules/data/window-query.test.ts +52 -0
- package/src/modules/data/window-query.ts +194 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +450 -46
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +146 -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 +308 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/sync/engine.ts +164 -82
- package/src/modules/sync/events/index.ts +9 -2
- package/src/modules/sync/queue/queue-down.test.ts +180 -0
- package/src/modules/sync/queue/queue-down.ts +80 -13
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
- package/src/modules/sync/queue/queue-up.ts +241 -57
- package/src/modules/sync/scheduler.pause.test.ts +109 -0
- package/src/modules/sync/scheduler.retry.test.ts +237 -0
- package/src/modules/sync/scheduler.ts +215 -13
- package/src/modules/sync/sync.cleanup.test.ts +116 -0
- package/src/modules/sync/sync.health.test.ts +149 -0
- package/src/modules/sync/sync.heartbeat.test.ts +80 -0
- package/src/modules/sync/sync.live-removal.test.ts +175 -0
- package/src/modules/sync/sync.reconnect.test.ts +145 -0
- package/src/modules/sync/sync.subquery.test.ts +82 -0
- package/src/modules/sync/sync.tabs.test.ts +249 -0
- package/src/modules/sync/sync.ts +1726 -99
- package/src/modules/sync/utils.test.ts +269 -2
- package/src/modules/sync/utils.ts +201 -17
- package/src/otel/index.ts +13 -10
- 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 +193 -0
- package/src/services/database/connection-supervisor.test.ts +289 -0
- package/src/services/database/connection-supervisor.ts +415 -0
- package/src/services/database/database.query-timeout.test.ts +83 -0
- package/src/services/database/database.ts +41 -12
- 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 +484 -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 +413 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +110 -14
- package/src/services/database/sqlite-cache-engine.test.ts +616 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1358 -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 +138 -0
- package/src/services/database/sqlite-projection.test.ts +99 -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 +258 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.auth-order.test.ts +92 -0
- package/src/sp00ky.init-query.test.ts +183 -0
- package/src/sp00ky.local-first.test.ts +60 -0
- package/src/sp00ky.ts +1693 -0
- package/src/types.ts +528 -13
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/error-classification.test.ts +44 -0
- package/src/utils/error-classification.ts +7 -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/withRetry.test.ts +1 -1
- package/tsdown.config.ts +86 -1
- package/src/spooky.ts +0 -395
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
import { RecordId, Duration } from 'surrealdb';
|
|
2
|
-
import {
|
|
2
|
+
import type {
|
|
3
3
|
SchemaStructure,
|
|
4
4
|
TableNames,
|
|
5
5
|
BackendNames,
|
|
6
6
|
BackendRoutes,
|
|
7
7
|
RoutePayload,
|
|
8
|
+
QueryPlan,
|
|
8
9
|
} from '@spooky-sync/query-builder';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
10
|
+
import type { LocalStore } from '../../services/database/index';
|
|
11
|
+
import { StaleEpochError } from '../../services/database/index';
|
|
12
|
+
import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
|
|
13
|
+
import type { Logger } from '../../services/logger/index';
|
|
14
|
+
import type { StreamUpdate } from '../../services/stream-processor/index';
|
|
15
|
+
import type {
|
|
15
16
|
QueryConfig,
|
|
16
17
|
QueryHash,
|
|
17
18
|
QueryState,
|
|
19
|
+
QueryStatus,
|
|
20
|
+
QueryStatusCallback,
|
|
18
21
|
QueryTimeToLive,
|
|
19
22
|
QueryUpdateCallback,
|
|
20
23
|
MutationCallback,
|
|
21
24
|
RecordVersionArray,
|
|
22
25
|
QueryConfigRecord,
|
|
23
26
|
UpdateOptions,
|
|
27
|
+
QueryTimings,
|
|
28
|
+
PhaseStat,
|
|
29
|
+
RegistrationTimings,
|
|
24
30
|
RunOptions,
|
|
25
31
|
} from '../../types';
|
|
32
|
+
import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
|
|
26
33
|
import {
|
|
27
34
|
parseRecordIdString,
|
|
28
35
|
extractIdPart,
|
|
@@ -31,11 +38,44 @@ import {
|
|
|
31
38
|
withRetry,
|
|
32
39
|
surql,
|
|
33
40
|
parseParams,
|
|
41
|
+
parseQueryParams,
|
|
42
|
+
cleanRecord,
|
|
34
43
|
extractTablePart,
|
|
35
44
|
generateId,
|
|
36
45
|
} from '../../utils/index';
|
|
37
|
-
import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
38
|
-
import { PushEventOptions } from '../../events/index';
|
|
46
|
+
import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
|
|
47
|
+
import type { PushEventOptions } from '../../events/index';
|
|
48
|
+
import {
|
|
49
|
+
buildIdSetPlan,
|
|
50
|
+
buildIdSetSurql,
|
|
51
|
+
buildWindowMaterialization,
|
|
52
|
+
buildWindowMaterializationPlan,
|
|
53
|
+
} from './window-query';
|
|
54
|
+
import { mintMutationId } from './mutation-id';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How many consecutive empty `_00_list_ref` reads it takes to believe a query
|
|
58
|
+
* really is empty, before any non-empty set has been seen this session. One
|
|
59
|
+
* read is the registration race (the SSP flushes a view's initial edges
|
|
60
|
+
* asynchronously); the second comes from the poll a cycle later.
|
|
61
|
+
*/
|
|
62
|
+
const EMPTY_MEMBERSHIP_CONFIRMATIONS = 2;
|
|
63
|
+
|
|
64
|
+
/** Push a timing sample (ms) into a rolling window, capped at the sample window. */
|
|
65
|
+
function pushSample(samples: number[], ms: number): void {
|
|
66
|
+
samples.push(ms);
|
|
67
|
+
if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
|
|
71
|
+
function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
|
|
72
|
+
if (samples.length === 0) {
|
|
73
|
+
return { lastMs, p50: null, p90: null, p99: null, count: 0 };
|
|
74
|
+
}
|
|
75
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
76
|
+
const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
|
|
77
|
+
return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
|
|
78
|
+
}
|
|
39
79
|
|
|
40
80
|
/**
|
|
41
81
|
* DataModule - Unified query and mutation management
|
|
@@ -43,26 +83,125 @@ import { PushEventOptions } from '../../events/index';
|
|
|
43
83
|
* Merges the functionality of QueryManager and MutationManager.
|
|
44
84
|
* Uses CacheModule for all storage operations.
|
|
45
85
|
*/
|
|
86
|
+
/** A `_00_window` row as read back: the id-set and whether the server vouched
|
|
87
|
+
* for it (which is what allows an empty set to count as known membership). */
|
|
88
|
+
export interface DurableMembership {
|
|
89
|
+
ids: RecordVersionArray;
|
|
90
|
+
confirmed: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
46
93
|
export class DataModule<S extends SchemaStructure> {
|
|
94
|
+
/** Tab identity baked into mutation ids (shared-tabs rollback routing);
|
|
95
|
+
* undefined in solo mode, where mutation-id falls back to a session id. */
|
|
96
|
+
private tabId: string | undefined;
|
|
47
97
|
private activeQueries: Map<QueryHash, QueryState> = new Map();
|
|
48
98
|
private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
|
|
49
99
|
private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
|
|
100
|
+
private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
|
|
50
101
|
private mutationCallbacks: Set<MutationCallback> = new Set();
|
|
51
102
|
private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
|
|
103
|
+
// The update each debounce timer would process on its trailing edge. Kept in a
|
|
104
|
+
// map (not just the timer closure) so `flushPendingStreamUpdate` can process
|
|
105
|
+
// it early — the sync engine flushes before flipping a query to `idle`, so
|
|
106
|
+
// subscribers never observe idle status with stale (partial-window) rows.
|
|
107
|
+
private pendingStreamUpdates: Map<QueryHash, StreamUpdate> = new Map();
|
|
108
|
+
// Refcount of in-flight fetch cycles per query (registration + concurrent
|
|
109
|
+
// poll/LIVE sync rounds can overlap). Status flips to `fetching` on 0→1 and
|
|
110
|
+
// back to `idle` only on 1→0, so an inner cycle finishing can't emit a
|
|
111
|
+
// premature idle mid-registration.
|
|
112
|
+
private fetchDepth: Map<QueryHash, number> = new Map();
|
|
52
113
|
private logger: Logger;
|
|
114
|
+
/**
|
|
115
|
+
* Optional observer notified whenever a query's fetch status changes.
|
|
116
|
+
* Wired by Sp00kyClient to push status changes into DevTools. Kept as a
|
|
117
|
+
* settable field (rather than a constructor arg) because DevTools is
|
|
118
|
+
* constructed after DataModule.
|
|
119
|
+
*/
|
|
120
|
+
public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
|
|
121
|
+
/**
|
|
122
|
+
* Optional observer invoked when a still-subscribed query's TTL heartbeat
|
|
123
|
+
* fires (~90% of the TTL). Wired by Sp00kyClient to
|
|
124
|
+
* `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
|
|
125
|
+
* row's `lastActiveAt` so an actively-watched query never expires. Settable
|
|
126
|
+
* field (not a constructor arg) because the sync engine is wired after
|
|
127
|
+
* DataModule is constructed — mirrors `onQueryStatusChange`.
|
|
128
|
+
*/
|
|
129
|
+
public onHeartbeat?: (hash: QueryHash) => void;
|
|
130
|
+
/**
|
|
131
|
+
* Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
|
|
132
|
+
* viewport-windowed list cancelling an off-screen window) loses its last
|
|
133
|
+
* subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
|
|
134
|
+
* tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
|
|
135
|
+
* instead of leaving it for the TTL sweep. The local view + state are freed in
|
|
136
|
+
* {@link finalizeDeregister} only after that remote delete, so a fast
|
|
137
|
+
* re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
|
|
138
|
+
*/
|
|
139
|
+
public onDeregister?: (hash: QueryHash) => void;
|
|
140
|
+
// Salt for query-id hashing. Set from SurrealDB's session::id() so two
|
|
141
|
+
// browser sessions registering the same logical query (same surql + params)
|
|
142
|
+
// don't collide on the same `_00_query` row — each session gets its own.
|
|
143
|
+
// Empty string until init(sessionId) is called.
|
|
144
|
+
private sessionId: string = '';
|
|
145
|
+
// Authenticated user record id (e.g. `"user:abc"`). Updated by
|
|
146
|
+
// `setCurrentUserId` from the auth subscription. null when
|
|
147
|
+
// unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
|
|
148
|
+
// poll and LIVE subscription target the same per-user
|
|
149
|
+
// `_00_list_ref_user_<id>` table the sync engine writes to.
|
|
150
|
+
private currentUserId: string | null = null;
|
|
53
151
|
|
|
54
152
|
constructor(
|
|
55
153
|
private cache: CacheModule,
|
|
56
|
-
private local:
|
|
154
|
+
private local: LocalStore,
|
|
57
155
|
private schema: S,
|
|
58
156
|
logger: Logger,
|
|
59
|
-
|
|
157
|
+
// Client-side SSP aggregation throttle: coalesces the in-browser
|
|
158
|
+
// StreamProcessor's per-record stream updates per query before notifying
|
|
159
|
+
// readers, so a burst of synced records repaints the UI once per window
|
|
160
|
+
// rather than row-by-row. 50ms keeps it responsive while still batching the
|
|
161
|
+
// initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
|
|
162
|
+
private streamDebounceTime: number = 50
|
|
60
163
|
) {
|
|
61
164
|
this.logger = logger.child({ service: 'DataModule' });
|
|
62
165
|
}
|
|
63
166
|
|
|
64
|
-
async init(): Promise<void> {
|
|
65
|
-
this.
|
|
167
|
+
async init(sessionId: string): Promise<void> {
|
|
168
|
+
this.sessionId = sessionId;
|
|
169
|
+
this.logger.info(
|
|
170
|
+
{ sessionId, Category: 'sp00ky-client::DataModule::init' },
|
|
171
|
+
'DataModule initialized'
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Update the session salt used in query-id hashing. Call this when the
|
|
177
|
+
* SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
|
|
178
|
+
* registered queries will get fresh, session-scoped IDs.
|
|
179
|
+
*/
|
|
180
|
+
setSessionId(sessionId: string): void {
|
|
181
|
+
this.sessionId = sessionId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
|
|
185
|
+
* a follower's mutation routes back to the tab that made it. */
|
|
186
|
+
setTabId(tabId: string): void {
|
|
187
|
+
this.tabId = tabId;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Update the authenticated user record id. Pass `null` on sign-out.
|
|
192
|
+
* Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
|
|
193
|
+
* the poll route to the same per-user `_00_list_ref_user_<id>` the
|
|
194
|
+
* SSP writes to.
|
|
195
|
+
*/
|
|
196
|
+
setCurrentUserId(userId: string | null): void {
|
|
197
|
+
this.currentUserId = userId;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read-only view of the authenticated user id used for per-user
|
|
201
|
+
* `_00_list_ref` routing. Other modules consult this so they pick the
|
|
202
|
+
* same table name DataModule does. */
|
|
203
|
+
getCurrentUserId(): string | null {
|
|
204
|
+
return this.currentUserId;
|
|
66
205
|
}
|
|
67
206
|
|
|
68
207
|
// ==================== QUERY MANAGEMENT ====================
|
|
@@ -74,19 +213,22 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
74
213
|
tableName: T,
|
|
75
214
|
surqlString: string,
|
|
76
215
|
params: Record<string, any>,
|
|
77
|
-
ttl: QueryTimeToLive
|
|
216
|
+
ttl: QueryTimeToLive,
|
|
217
|
+
plan?: QueryPlan
|
|
78
218
|
): Promise<QueryHash> {
|
|
79
219
|
const hash = await this.calculateHash({ surql: surqlString, params });
|
|
80
220
|
this.logger.debug(
|
|
81
|
-
{ hash, Category: '
|
|
221
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
82
222
|
'Query Initialization: started'
|
|
83
223
|
);
|
|
84
224
|
|
|
85
|
-
|
|
225
|
+
// `_00_query` stays the single shared registration table in both
|
|
226
|
+
// ref-modes; the per-user split happens only on `_00_list_ref`.
|
|
227
|
+
const recordId = new RecordId('_00_query', hash);
|
|
86
228
|
|
|
87
229
|
if (this.activeQueries.has(hash)) {
|
|
88
230
|
this.logger.debug(
|
|
89
|
-
{ hash, Category: '
|
|
231
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
90
232
|
'Query Initialization: exists, returning'
|
|
91
233
|
);
|
|
92
234
|
return hash;
|
|
@@ -95,7 +237,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
95
237
|
// Another call is already creating this query — wait for it
|
|
96
238
|
if (this.pendingQueries.has(hash)) {
|
|
97
239
|
this.logger.debug(
|
|
98
|
-
{ hash, Category: '
|
|
240
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
99
241
|
'Query Initialization: pending, waiting for existing creation'
|
|
100
242
|
);
|
|
101
243
|
await this.pendingQueries.get(hash);
|
|
@@ -103,12 +245,21 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
103
245
|
}
|
|
104
246
|
|
|
105
247
|
this.logger.debug(
|
|
106
|
-
{ hash, Category: '
|
|
248
|
+
{ hash, Category: 'sp00ky-client::DataModule::query' },
|
|
107
249
|
'Query Initialization: not found, creating new query'
|
|
108
250
|
);
|
|
109
251
|
|
|
110
252
|
// Create the query and track the pending promise
|
|
111
|
-
const promise = this.createAndRegisterQuery<T>(
|
|
253
|
+
const promise = this.createAndRegisterQuery<T>(
|
|
254
|
+
hash,
|
|
255
|
+
recordId,
|
|
256
|
+
surqlString,
|
|
257
|
+
params,
|
|
258
|
+
ttl,
|
|
259
|
+
tableName,
|
|
260
|
+
plan,
|
|
261
|
+
await this.calculateMembershipKey({ surql: surqlString, params })
|
|
262
|
+
);
|
|
112
263
|
this.pendingQueries.set(hash, promise);
|
|
113
264
|
try {
|
|
114
265
|
await promise;
|
|
@@ -147,11 +298,95 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
147
298
|
subs.delete(callback);
|
|
148
299
|
if (subs.size === 0) {
|
|
149
300
|
this.subscriptions.delete(queryHash);
|
|
301
|
+
// NOTE: intentionally do NOT tear down the query / free its in-browser
|
|
302
|
+
// SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
|
|
303
|
+
// already self-stops once subscribers hit 0, so an abandoned query
|
|
304
|
+
// stops being kept alive and the server's TTL sweep removes it. Freeing
|
|
305
|
+
// the local view on every last-unsubscribe caused re-registration churn
|
|
306
|
+
// on navigation (open A → leave → open A again re-registers), which is
|
|
307
|
+
// a flakiness risk for no real benefit — keep the local view resident.
|
|
150
308
|
}
|
|
151
309
|
}
|
|
152
310
|
};
|
|
153
311
|
}
|
|
154
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Subscribe to a query's fetch-status changes (idle/fetching).
|
|
315
|
+
* With `{ immediate: true }` the callback fires synchronously with the
|
|
316
|
+
* current status (defaults to `idle` if the query isn't registered yet).
|
|
317
|
+
*/
|
|
318
|
+
subscribeStatus(
|
|
319
|
+
queryHash: string,
|
|
320
|
+
callback: QueryStatusCallback,
|
|
321
|
+
options: { immediate?: boolean } = {}
|
|
322
|
+
): () => void {
|
|
323
|
+
if (!this.statusSubscriptions.has(queryHash)) {
|
|
324
|
+
this.statusSubscriptions.set(queryHash, new Set());
|
|
325
|
+
}
|
|
326
|
+
this.statusSubscriptions.get(queryHash)?.add(callback);
|
|
327
|
+
|
|
328
|
+
if (options.immediate) {
|
|
329
|
+
callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return () => {
|
|
333
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
334
|
+
if (subs) {
|
|
335
|
+
subs.delete(callback);
|
|
336
|
+
if (subs.size === 0) {
|
|
337
|
+
this.statusSubscriptions.delete(queryHash);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Set a query's fetch status and notify status observers (DevTools +
|
|
345
|
+
* `subscribeStatus` listeners). No-op when the status is unchanged or the
|
|
346
|
+
* query is unknown.
|
|
347
|
+
*/
|
|
348
|
+
setQueryStatus(queryHash: string, status: QueryStatus): void {
|
|
349
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
350
|
+
if (!queryState || queryState.status === status) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
queryState.status = status;
|
|
354
|
+
|
|
355
|
+
this.onQueryStatusChange?.(queryHash, status);
|
|
356
|
+
|
|
357
|
+
const subs = this.statusSubscriptions.get(queryHash);
|
|
358
|
+
if (subs) {
|
|
359
|
+
for (const callback of subs) {
|
|
360
|
+
callback(status);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Enter a fetch cycle for a query. Refcounted: registration and concurrent
|
|
367
|
+
* poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
|
|
368
|
+
* cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
|
|
369
|
+
* emits `idle`. Always pair with `endFetching` in a `finally`.
|
|
370
|
+
*/
|
|
371
|
+
beginFetching(queryHash: string): void {
|
|
372
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
373
|
+
this.fetchDepth.set(queryHash, depth + 1);
|
|
374
|
+
if (depth === 0) {
|
|
375
|
+
this.setQueryStatus(queryHash, 'fetching');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
|
|
380
|
+
endFetching(queryHash: string): void {
|
|
381
|
+
const depth = this.fetchDepth.get(queryHash) ?? 0;
|
|
382
|
+
if (depth <= 1) {
|
|
383
|
+
this.fetchDepth.delete(queryHash);
|
|
384
|
+
this.setQueryStatus(queryHash, 'idle');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.fetchDepth.set(queryHash, depth - 1);
|
|
388
|
+
}
|
|
389
|
+
|
|
155
390
|
/**
|
|
156
391
|
* Subscribe to mutations (for sync)
|
|
157
392
|
*/
|
|
@@ -168,67 +403,469 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
168
403
|
async onStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
169
404
|
const { queryHash, op } = update;
|
|
170
405
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
406
|
+
// DELETE propagates immediately — a removed row should disappear without
|
|
407
|
+
// waiting on a debounce.
|
|
408
|
+
//
|
|
409
|
+
// CREATE and UPDATE are coalesced per query on a trailing timer. A list's
|
|
410
|
+
// rows stream in from sync as many small `_00_list_ref` diffs, each its own
|
|
411
|
+
// `cache.saveBatch` → one stream update per chunk. `ingestMany` only
|
|
412
|
+
// coalesces records ingested synchronously together, so chunks spread over
|
|
413
|
+
// time each re-materialize and re-notify — a 50-row window can fire 30+
|
|
414
|
+
// updates as it fills. Each StreamUpdate carries the full materialized
|
|
415
|
+
// `localArray`, so the latest one already reflects every prior chunk: keep
|
|
416
|
+
// only it and fire once on the trailing edge, settling the query in a couple
|
|
417
|
+
// of notifications instead of one per chunk.
|
|
418
|
+
if (op === 'DELETE') {
|
|
419
|
+
const existing = this.debounceTimers.get(queryHash);
|
|
420
|
+
if (existing) {
|
|
421
|
+
clearTimeout(existing);
|
|
422
|
+
this.debounceTimers.delete(queryHash);
|
|
177
423
|
}
|
|
424
|
+
// The DELETE update carries the full latest localArray, so the coalesced
|
|
425
|
+
// CREATE/UPDATE it supersedes is already reflected — drop it.
|
|
426
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
427
|
+
await this.processStreamUpdate(update);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
178
430
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
this.debounceTimers.delete(queryHash);
|
|
182
|
-
await this.processStreamUpdate(update);
|
|
183
|
-
}, this.streamDebounceTime);
|
|
431
|
+
this.queueStreamUpdate(update);
|
|
432
|
+
}
|
|
184
433
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
434
|
+
/** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
|
|
435
|
+
private queueStreamUpdate(update: StreamUpdate): void {
|
|
436
|
+
const { queryHash } = update;
|
|
437
|
+
// Clear existing timer if any
|
|
438
|
+
if (this.debounceTimers.has(queryHash)) {
|
|
439
|
+
// oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
|
|
440
|
+
clearTimeout(this.debounceTimers.get(queryHash)!);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Set new timer
|
|
444
|
+
this.pendingStreamUpdates.set(queryHash, update);
|
|
445
|
+
const timer = setTimeout(async () => {
|
|
446
|
+
this.debounceTimers.delete(queryHash);
|
|
447
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
188
448
|
await this.processStreamUpdate(update);
|
|
449
|
+
}, this.streamDebounceTime);
|
|
450
|
+
|
|
451
|
+
this.debounceTimers.set(queryHash, timer);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Re-materialize + notify a query whose MEMBERSHIP changed without any row
|
|
456
|
+
* needing to be fetched, i.e. without the SSP stream update that normally
|
|
457
|
+
* carries the notify. That is every row this client wrote itself: the local
|
|
458
|
+
* CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
|
|
459
|
+
* sync engine rightly fetches nothing - and then nobody told the subscribers
|
|
460
|
+
* that `remoteArray` now holds the id. The row appeared on reload only.
|
|
461
|
+
*
|
|
462
|
+
* Routed through the same per-query debounce as a real stream update, so it
|
|
463
|
+
* cannot race one: a pending real update already materializes against the
|
|
464
|
+
* current `remoteArray` and wins. The synthetic update re-uses the circuit's
|
|
465
|
+
* last `localArray` and skips the persist/metrics that describe an ingest.
|
|
466
|
+
*/
|
|
467
|
+
scheduleRematerialize(queryHash: string): void {
|
|
468
|
+
if (this.debounceTimers.has(queryHash)) return;
|
|
469
|
+
const queryState = this.activeQueries.get(queryHash);
|
|
470
|
+
if (!queryState) return;
|
|
471
|
+
this.queueStreamUpdate({
|
|
472
|
+
queryHash,
|
|
473
|
+
localArray: queryState.config.localArray ?? [],
|
|
474
|
+
op: 'UPDATE',
|
|
475
|
+
synthetic: true,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Process a query's pending (debounced) stream update NOW instead of on the
|
|
481
|
+
* trailing edge. Called by the sync engine before it flips a query back to
|
|
482
|
+
* `idle`, so the status change never races ahead of the rows it fetched.
|
|
483
|
+
* No-op when nothing is pending. The pending entry is removed before the
|
|
484
|
+
* await so a concurrently-firing timer can't process it twice.
|
|
485
|
+
*/
|
|
486
|
+
async flushPendingStreamUpdate(queryHash: string): Promise<void> {
|
|
487
|
+
const timer = this.debounceTimers.get(queryHash);
|
|
488
|
+
if (timer) {
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
this.debounceTimers.delete(queryHash);
|
|
491
|
+
}
|
|
492
|
+
const pending = this.pendingStreamUpdates.get(queryHash);
|
|
493
|
+
if (!pending) return;
|
|
494
|
+
this.pendingStreamUpdates.delete(queryHash);
|
|
495
|
+
await this.processStreamUpdate(pending);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Materialize a query's result rows from the local store.
|
|
500
|
+
*
|
|
501
|
+
* A query's rows are its MEMBERSHIP — the id-set the server put in
|
|
502
|
+
* `_00_list_ref` (`remoteArray`) — not "every local body that matches the
|
|
503
|
+
* WHERE". Those two disagree, and the disagreement was the bug: when a row
|
|
504
|
+
* leaves a query's window but still exists upstream, `handleRemovedRecords`
|
|
505
|
+
* keeps its local body and never re-fetches it, so a predicate re-scan finds
|
|
506
|
+
* that stale body still matching and keeps rendering the row. Selecting the
|
|
507
|
+
* id-set directly is also the only correct thing for a windowed query, where
|
|
508
|
+
* re-applying `START m` against the shared local store skips the window's own
|
|
509
|
+
* rows entirely (sparse windowing) and returns nothing.
|
|
510
|
+
*
|
|
511
|
+
* The rendered set is:
|
|
512
|
+
*
|
|
513
|
+
* (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
|
|
514
|
+
*
|
|
515
|
+
* The middle term keeps optimistic writes visible without re-admitting stale
|
|
516
|
+
* rows. Every local write is fed to the SSP (`cache.saveBatch` →
|
|
517
|
+
* `ingestMany`), so `localArray` answers "does this row match the predicate
|
|
518
|
+
* per LOCAL truth". A pending write that moves a row into the window is in
|
|
519
|
+
* `localArray` and shows; one that moves a row out is absent and does not; a
|
|
520
|
+
* stale body the server dropped has no pending write at all, so it stays out.
|
|
521
|
+
* `pendingDeletes` covers the reverse lag — the server still lists a row whose
|
|
522
|
+
* DELETE is sitting in our outbox.
|
|
523
|
+
*
|
|
524
|
+
* Falls back to the predicate scan only when membership has never been
|
|
525
|
+
* established (a query first run on this device), so an offline first paint
|
|
526
|
+
* still shows something.
|
|
527
|
+
*/
|
|
528
|
+
private async materializeRecords(
|
|
529
|
+
queryState: QueryState,
|
|
530
|
+
sspArray?: Array<[string, number]>
|
|
531
|
+
): Promise<Record<string, any>[]> {
|
|
532
|
+
const t0 = performance.now();
|
|
533
|
+
const records = await this.materializeFromConfig(queryState.config, sspArray);
|
|
534
|
+
// Local SurrealDB record-fetch time → DevTools "localFetch" phase.
|
|
535
|
+
this.recordPhase(queryState, 'localFetch', performance.now() - t0);
|
|
536
|
+
return records;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The materialization itself, without the DevTools timing wrapper. Split out so
|
|
541
|
+
* cold-start seeding can use it before a `QueryState` exists.
|
|
542
|
+
*/
|
|
543
|
+
private async materializeFromConfig(
|
|
544
|
+
config: QueryConfig,
|
|
545
|
+
sspArray?: Array<[string, number]>
|
|
546
|
+
): Promise<Record<string, any>[]> {
|
|
547
|
+
const plan = config.plan;
|
|
548
|
+
const membership = this.resolveMembership(config, sspArray);
|
|
549
|
+
let records: Record<string, any>[];
|
|
550
|
+
|
|
551
|
+
if (membership) {
|
|
552
|
+
const ids = await this.buildRenderIds(config, membership, sspArray);
|
|
553
|
+
if (plan) {
|
|
554
|
+
records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
|
|
555
|
+
} else {
|
|
556
|
+
const idSetSurql = buildIdSetSurql(config.surql);
|
|
557
|
+
if (idSetSurql) {
|
|
558
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(idSetSurql.query, {
|
|
559
|
+
...config.params,
|
|
560
|
+
__win: ids,
|
|
561
|
+
});
|
|
562
|
+
records = rows || [];
|
|
563
|
+
} else {
|
|
564
|
+
// Unparseable shape (no top-level FROM): the predicate scan is the
|
|
565
|
+
// only option left. Rare — the query-builder always supplies a plan.
|
|
566
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(
|
|
567
|
+
config.surql,
|
|
568
|
+
config.params
|
|
569
|
+
);
|
|
570
|
+
records = rows || [];
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} else if (plan) {
|
|
574
|
+
records = await this.local.select(plan, config.params);
|
|
575
|
+
} else {
|
|
576
|
+
const [rows] = await this.local.query<[Record<string, any>[]]>(config.surql, config.params);
|
|
577
|
+
records = rows || [];
|
|
578
|
+
}
|
|
579
|
+
return records;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The authoritative membership list to render from, or `null` when membership
|
|
584
|
+
* has never been established and the caller must fall back to a scan.
|
|
585
|
+
*
|
|
586
|
+
* A windowed query has no usable fallback — re-running its `START m` locally
|
|
587
|
+
* returns the wrong rows — so it renders from whatever id-set is on hand
|
|
588
|
+
* (SSP's included) rather than degrading to a scan. That is the pre-existing
|
|
589
|
+
* behavior for windows and is preserved.
|
|
590
|
+
*/
|
|
591
|
+
private resolveMembership(
|
|
592
|
+
config: QueryConfig,
|
|
593
|
+
sspArray?: Array<[string, number]>
|
|
594
|
+
): RecordVersionArray | null {
|
|
595
|
+
if (config.membershipKnown) return config.remoteArray ?? [];
|
|
596
|
+
if (buildWindowMaterialization(config.surql) !== null) {
|
|
597
|
+
return (
|
|
598
|
+
(config.remoteArray?.length && config.remoteArray) ||
|
|
599
|
+
(sspArray?.length && sspArray) ||
|
|
600
|
+
config.localArray ||
|
|
601
|
+
[]
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---- Settled-write grace window ----------------------------------------
|
|
608
|
+
//
|
|
609
|
+
// A local write is visible because it is in the outbox: the render set is
|
|
610
|
+
// `(membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes`. The outbox
|
|
611
|
+
// row is deleted the moment the remote push succeeds (see
|
|
612
|
+
// `UpQueue.next` / `SyncScheduler` — the delete is deliberately tied to the
|
|
613
|
+
// push, not to anything downstream), but the row does not enter `membership`
|
|
614
|
+
// until the SSP has ingested it, materialized the view and written the
|
|
615
|
+
// `_00_list_ref` edge, and this client has read that back.
|
|
616
|
+
//
|
|
617
|
+
// Between those two moments the row is in neither term, so it is rendered,
|
|
618
|
+
// then disappears, then returns — reported as "the comment shows on every
|
|
619
|
+
// other client but not on the one that wrote it". Other clients don't blink
|
|
620
|
+
// because a client that never established membership renders from the local
|
|
621
|
+
// predicate scan instead.
|
|
622
|
+
//
|
|
623
|
+
// So a settled write keeps its place in the union for a short grace period,
|
|
624
|
+
// until membership catches up or the deadline passes. Kept in memory only:
|
|
625
|
+
// it covers a round trip, and a reload re-derives membership anyway.
|
|
626
|
+
private readonly settledWrites = new Map<string, number>();
|
|
627
|
+
private readonly settledDeletes = new Map<string, number>();
|
|
628
|
+
|
|
629
|
+
// ---- Outbox id cache -----------------------------------------------------
|
|
630
|
+
//
|
|
631
|
+
// `getPendingRecordIds` runs a full `SELECT ... FROM _00_pending_mutations`,
|
|
632
|
+
// and `buildRenderIds` calls it on EVERY materialization of EVERY query. That
|
|
633
|
+
// is a round trip down the local engine's single-flight op queue, so with tens
|
|
634
|
+
// of live queries one ingest fanned out into tens of them — in a 4-second
|
|
635
|
+
// capture, 11 of 25 local queries were that one statement, against an outbox
|
|
636
|
+
// holding zero rows.
|
|
637
|
+
//
|
|
638
|
+
// Cached, and invalidated at the two points that change the outbox: enqueueing
|
|
639
|
+
// a mutation (below) and `noteWriteSettled` (the row is deleted before it is
|
|
640
|
+
// called). The TTL is a BACKSTOP, not the mechanism: should some other path
|
|
641
|
+
// ever remove a row without telling us, this bounds the staleness to one tick
|
|
642
|
+
// instead of the session. Well under the grace windows the render set already
|
|
643
|
+
// tolerates deliberately (SETTLED_WRITE_GRACE_MS is 10s).
|
|
644
|
+
private pendingIds: { writes: Set<string>; deletes: Set<string> } | null = null;
|
|
645
|
+
private pendingIdsAt = 0;
|
|
646
|
+
private pendingIdsInflight: Promise<{ writes: Set<string>; deletes: Set<string> }> | null = null;
|
|
647
|
+
private static readonly PENDING_IDS_TTL_MS = 250;
|
|
648
|
+
// Bumped by every invalidation. A read carries the generation it started
|
|
649
|
+
// under, and a result from an older generation is neither cached nor
|
|
650
|
+
// returned to a caller that asked after the invalidation: it was taken
|
|
651
|
+
// before the outbox row it needs existed. Without this, a materialization
|
|
652
|
+
// that joined a read already in flight when `create()` invalidated saw the
|
|
653
|
+
// pre-create set, found "no change", and skipped its notify - the one
|
|
654
|
+
// stream update a CREATE gets, so the row stayed invisible until reload.
|
|
655
|
+
private pendingIdsGen = 0;
|
|
656
|
+
private static readonly PENDING_IDS_MAX_REREADS = 3;
|
|
657
|
+
|
|
658
|
+
/** Drop the cached outbox ids. Cheap; call it on anything that could change
|
|
659
|
+
* `_00_pending_mutations`. */
|
|
660
|
+
private invalidatePendingIds(): void {
|
|
661
|
+
this.pendingIds = null;
|
|
662
|
+
this.pendingIdsGen++;
|
|
663
|
+
// The in-flight read (if any) predates this invalidation; whoever joins
|
|
664
|
+
// next starts a fresh one. Its own joiners re-read via the generation check.
|
|
665
|
+
this.pendingIdsInflight = null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Grace period for a settled write. Long enough to cover an SSP round trip
|
|
670
|
+
* that is running slowly (seconds, not milliseconds, when the edge path is
|
|
671
|
+
* backed up), short enough that a write the server silently dropped cannot
|
|
672
|
+
* linger misleadingly.
|
|
673
|
+
*
|
|
674
|
+
* The rejection case does NOT rely on this expiring: an application error
|
|
675
|
+
* rolls the mutation back and never reports it settled, so it vanishes at
|
|
676
|
+
* once. This deadline only bounds the case where the write succeeded and its
|
|
677
|
+
* membership never arrived at all.
|
|
678
|
+
*/
|
|
679
|
+
private static readonly SETTLED_WRITE_GRACE_MS = 10_000;
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Report that a mutation was accepted by the server and its outbox row
|
|
683
|
+
* removed. Called only on the SUCCESS path — a rolled-back mutation must
|
|
684
|
+
* disappear immediately, which is what makes this safe.
|
|
685
|
+
*/
|
|
686
|
+
noteWriteSettled(recordId: string, mutationType: string): void {
|
|
687
|
+
// The outbox row is already gone by the time this is called — that is what
|
|
688
|
+
// makes it the delete-side invalidation point for the cache above.
|
|
689
|
+
this.invalidatePendingIds();
|
|
690
|
+
const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
|
|
691
|
+
if (mutationType === 'delete') this.settledDeletes.set(recordId, until);
|
|
692
|
+
else this.settledWrites.set(recordId, until);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Drop entries past their deadline. */
|
|
696
|
+
private pruneSettled(now: number): void {
|
|
697
|
+
for (const [id, until] of this.settledWrites) {
|
|
698
|
+
if (until <= now) this.settledWrites.delete(id);
|
|
699
|
+
}
|
|
700
|
+
for (const [id, until] of this.settledDeletes) {
|
|
701
|
+
if (until <= now) this.settledDeletes.delete(id);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Apply the pending-write union and pending-delete subtraction, and map to
|
|
706
|
+
* RecordIds for the engines' id-set path. */
|
|
707
|
+
private async buildRenderIds(
|
|
708
|
+
config: QueryConfig,
|
|
709
|
+
membership: RecordVersionArray,
|
|
710
|
+
sspArray?: Array<[string, number]>
|
|
711
|
+
): Promise<unknown[]> {
|
|
712
|
+
const { writes, deletes } = await this.getPendingRecordIds();
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
this.pruneSettled(now);
|
|
715
|
+
// A settled write counts as pending until membership catches up. Merged
|
|
716
|
+
// into the same sets so the union/subtraction below is unchanged — the
|
|
717
|
+
// grace window changes WHEN an id leaves the render set, never how the
|
|
718
|
+
// set is composed.
|
|
719
|
+
if (this.settledWrites.size > 0) {
|
|
720
|
+
for (const id of this.settledWrites.keys()) writes.add(id);
|
|
721
|
+
}
|
|
722
|
+
if (this.settledDeletes.size > 0) {
|
|
723
|
+
for (const id of this.settledDeletes.keys()) deletes.add(id);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const ordered: string[] = [];
|
|
727
|
+
const seen = new Set<string>();
|
|
728
|
+
for (const [id] of membership) {
|
|
729
|
+
// Membership has caught up with this write: the grace window has done
|
|
730
|
+
// its job and ends here rather than at its deadline. Doing it inline
|
|
731
|
+
// keeps the common case (nothing settled) free of extra passes.
|
|
732
|
+
if (this.settledWrites.size > 0) this.settledWrites.delete(id);
|
|
733
|
+
if (deletes.has(id) || seen.has(id)) continue;
|
|
734
|
+
seen.add(id);
|
|
735
|
+
ordered.push(id);
|
|
736
|
+
}
|
|
737
|
+
// A settled DELETE is the mirror case: membership still lists the row
|
|
738
|
+
// until the SSP publishes its removal, so the id stays subtracted until
|
|
739
|
+
// membership stops naming it.
|
|
740
|
+
if (this.settledDeletes.size > 0) {
|
|
741
|
+
const stillListed = new Set(membership.map(([id]) => id));
|
|
742
|
+
for (const id of [...this.settledDeletes.keys()]) {
|
|
743
|
+
if (!stillListed.has(id)) this.settledDeletes.delete(id);
|
|
744
|
+
}
|
|
189
745
|
}
|
|
746
|
+
if (writes.size > 0) {
|
|
747
|
+
// Only pending writes the SSP agrees currently match this query — see the
|
|
748
|
+
// formula in `materializeRecords`. `sspArray` is the fresher signal when a
|
|
749
|
+
// stream update triggered this pass; `localArray` is the persisted one.
|
|
750
|
+
const localView = (sspArray?.length && sspArray) || config.localArray || [];
|
|
751
|
+
for (const [id] of localView) {
|
|
752
|
+
if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
|
|
753
|
+
seen.add(id);
|
|
754
|
+
ordered.push(id);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// `_00_list_ref` is selected without an ORDER BY, so this id-set arrives in
|
|
758
|
+
// whatever order the server happened to return. For a query with its own
|
|
759
|
+
// ORDER BY that does not matter (the engine sorts), and for a window the
|
|
760
|
+
// id-set order IS the window's slice order and must be preserved. Anything
|
|
761
|
+
// else renders in server order while its first paint came from the local
|
|
762
|
+
// scan in id order — the same rows, visibly reshuffled a second later.
|
|
763
|
+
// Sorting here is what makes the two paints agree.
|
|
764
|
+
const hasExplicitOrder = (config.plan?.orderBy?.length ?? 0) > 0;
|
|
765
|
+
const isWindow = buildWindowMaterialization(config.surql) !== null;
|
|
766
|
+
if (!hasExplicitOrder && !isWindow) {
|
|
767
|
+
ordered.sort();
|
|
768
|
+
}
|
|
769
|
+
return ordered.map((id) => parseRecordIdString(id));
|
|
190
770
|
}
|
|
191
771
|
|
|
192
772
|
private async processStreamUpdate(update: StreamUpdate): Promise<void> {
|
|
193
|
-
const { queryHash, localArray } = update;
|
|
773
|
+
const { queryHash, localArray, materializationTimeMs } = update;
|
|
194
774
|
const queryState = this.activeQueries.get(queryHash);
|
|
195
775
|
if (!queryState) {
|
|
196
776
|
this.logger.warn(
|
|
197
|
-
{ queryHash, Category: '
|
|
777
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
198
778
|
'Received update for unknown query. Skipping...'
|
|
199
779
|
);
|
|
200
780
|
return;
|
|
201
781
|
}
|
|
202
782
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
)
|
|
783
|
+
// Update the rolling materialization-sample window before the work that
|
|
784
|
+
// could throw, so the percentiles still move when the downstream local
|
|
785
|
+
// query fails (the materialization step itself ran).
|
|
786
|
+
if (typeof materializationTimeMs === 'number') {
|
|
787
|
+
queryState.materializationSamples.push(materializationTimeMs);
|
|
788
|
+
if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
|
|
789
|
+
queryState.materializationSamples.shift();
|
|
790
|
+
}
|
|
791
|
+
queryState.lastIngestLatencyMs = materializationTimeMs;
|
|
792
|
+
}
|
|
793
|
+
// Record the SSP internal sub-phase timings (from the WASM binding) so
|
|
794
|
+
// DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
|
|
795
|
+
if (typeof update.storeApplyMs === 'number')
|
|
796
|
+
this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
|
|
797
|
+
if (typeof update.circuitStepMs === 'number')
|
|
798
|
+
this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
|
|
799
|
+
if (typeof update.transformMs === 'number')
|
|
800
|
+
this.recordPhase(queryState, 'sspTransform', update.transformMs);
|
|
801
|
+
const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
|
|
802
|
+
|
|
803
|
+
// Fence against bucket switches: this update's `localArray` came from the
|
|
804
|
+
// pre-switch SSP circuit; applying it after a switch would show (and
|
|
805
|
+
// persist) the previous user's ids in the new bucket.
|
|
806
|
+
const epoch = this.local.epoch;
|
|
209
807
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
808
|
+
try {
|
|
809
|
+
// Materialize the query's rows. For a windowed (offset) query, re-running
|
|
810
|
+
// the original surql would re-apply `START n` against the shared local DB
|
|
811
|
+
// and skip the window's rows entirely; instead select the SSP's
|
|
812
|
+
// materialized window id-set (`localArray`) directly, re-applying the
|
|
813
|
+
// original ORDER BY for stable display order. Non-offset queries keep the
|
|
814
|
+
// normal re-query path.
|
|
815
|
+
const newRecords = await this.materializeRecords(queryState, localArray);
|
|
816
|
+
if (epoch !== this.local.epoch) return;
|
|
817
|
+
if (!update.synthetic) queryState.config.localArray = localArray;
|
|
217
818
|
|
|
218
|
-
// Skip notification if records haven't changed
|
|
219
819
|
const prevJson = JSON.stringify(queryState.records);
|
|
220
820
|
const newJson = JSON.stringify(newRecords);
|
|
221
821
|
queryState.records = newRecords;
|
|
222
|
-
|
|
822
|
+
const recordsChanged = prevJson !== newJson;
|
|
823
|
+
|
|
824
|
+
// updateCount counts user-visible updates (matches the prior semantic),
|
|
825
|
+
// while the materialization sample/percentiles already moved above for
|
|
826
|
+
// every observed engine step.
|
|
827
|
+
if (recordsChanged) {
|
|
828
|
+
queryState.updateCount++;
|
|
829
|
+
queryState.lastUpdatedAt = Date.now();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// A synthetic re-materialize did not come from an ingest: nothing about
|
|
833
|
+
// the circuit's view changed, so there is nothing to persist for it.
|
|
834
|
+
if (!update.synthetic) {
|
|
835
|
+
await this.local.query(
|
|
836
|
+
surql.seal(
|
|
837
|
+
surql.updateSet('id', [
|
|
838
|
+
'localArray',
|
|
839
|
+
'rowCount',
|
|
840
|
+
'updateCount',
|
|
841
|
+
'lastIngestLatency',
|
|
842
|
+
'materializationP55',
|
|
843
|
+
'materializationP90',
|
|
844
|
+
'materializationP99',
|
|
845
|
+
])
|
|
846
|
+
),
|
|
847
|
+
{
|
|
848
|
+
id: queryState.config.id,
|
|
849
|
+
localArray,
|
|
850
|
+
rowCount: localArray.length,
|
|
851
|
+
updateCount: queryState.updateCount,
|
|
852
|
+
lastIngestLatency: queryState.lastIngestLatencyMs,
|
|
853
|
+
materializationP55: percentiles.p55,
|
|
854
|
+
materializationP90: percentiles.p90,
|
|
855
|
+
materializationP99: percentiles.p99,
|
|
856
|
+
},
|
|
857
|
+
{ epoch }
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!recordsChanged) {
|
|
223
862
|
this.logger.debug(
|
|
224
|
-
{ queryHash, Category: '
|
|
863
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
225
864
|
'Query records unchanged, skipping notification'
|
|
226
865
|
);
|
|
227
866
|
return;
|
|
228
867
|
}
|
|
229
868
|
|
|
230
|
-
queryState.updateCount++;
|
|
231
|
-
|
|
232
869
|
// Notify subscribers
|
|
233
870
|
const subscribers = this.subscriptions.get(queryHash);
|
|
234
871
|
if (subscribers) {
|
|
@@ -240,19 +877,110 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
240
877
|
this.logger.debug(
|
|
241
878
|
{
|
|
242
879
|
queryHash,
|
|
243
|
-
recordCount:
|
|
244
|
-
Category: '
|
|
880
|
+
recordCount: newRecords?.length,
|
|
881
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
245
882
|
},
|
|
246
883
|
'Query updated from stream'
|
|
247
884
|
);
|
|
248
885
|
} catch (err) {
|
|
886
|
+
if (err instanceof StaleEpochError) {
|
|
887
|
+
this.logger.debug(
|
|
888
|
+
{ queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
889
|
+
'Dropped stream update from before a bucket switch'
|
|
890
|
+
);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
queryState.errorCount++;
|
|
249
894
|
this.logger.error(
|
|
250
|
-
{ err, queryHash, Category: '
|
|
895
|
+
{ err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
|
|
251
896
|
'Failed to fetch records for stream update'
|
|
252
897
|
);
|
|
898
|
+
// Best-effort persist of the bumped errorCount; swallow secondary
|
|
899
|
+
// failures to avoid masking the original error in logs.
|
|
900
|
+
try {
|
|
901
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
|
|
902
|
+
id: queryState.config.id,
|
|
903
|
+
errorCount: queryState.errorCount,
|
|
904
|
+
});
|
|
905
|
+
} catch (persistErr) {
|
|
906
|
+
this.logger.warn(
|
|
907
|
+
{
|
|
908
|
+
err: persistErr,
|
|
909
|
+
queryHash,
|
|
910
|
+
Category: 'sp00ky-client::DataModule::onStreamUpdate',
|
|
911
|
+
},
|
|
912
|
+
'Failed to persist incremented errorCount'
|
|
913
|
+
);
|
|
914
|
+
}
|
|
253
915
|
}
|
|
254
916
|
}
|
|
255
917
|
|
|
918
|
+
/**
|
|
919
|
+
* Compute p55/p90/p99 from a rolling window of materialization samples.
|
|
920
|
+
* Returns nulls for any percentile that has no samples yet so SurrealDB
|
|
921
|
+
* `option<float>` columns stay NONE rather than 0 before the first ingest.
|
|
922
|
+
*/
|
|
923
|
+
private computeMaterializationPercentiles(samples: number[]): {
|
|
924
|
+
p55: number | null;
|
|
925
|
+
p90: number | null;
|
|
926
|
+
p99: number | null;
|
|
927
|
+
} {
|
|
928
|
+
if (samples.length === 0) {
|
|
929
|
+
return { p55: null, p90: null, p99: null };
|
|
930
|
+
}
|
|
931
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
932
|
+
const pick = (q: number) => {
|
|
933
|
+
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
|
|
934
|
+
return sorted[idx]!;
|
|
935
|
+
};
|
|
936
|
+
return { p55: pick(0.55), p90: pick(0.9), p99: pick(0.99) };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** Record a per-phase timing sample (ms) on a query's rolling window. */
|
|
940
|
+
private recordPhase(qs: QueryState, phase: string, ms: number): void {
|
|
941
|
+
if (!Number.isFinite(ms)) return;
|
|
942
|
+
const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
|
|
943
|
+
pushSample(arr, ms);
|
|
944
|
+
qs.phaseLast[phase] = ms;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
|
|
948
|
+
recordRemoteFetch(hash: string, ms: number): void {
|
|
949
|
+
const qs = this.activeQueries.get(hash);
|
|
950
|
+
if (qs) this.recordPhase(qs, 'remoteFetch', ms);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Record the frontend reconcile time (ms) for a query. Called from `useQuery`
|
|
955
|
+
* via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
|
|
956
|
+
*/
|
|
957
|
+
recordFrontendTiming(hash: string, ms: number): void {
|
|
958
|
+
const qs = this.activeQueries.get(hash);
|
|
959
|
+
if (qs) this.recordPhase(qs, 'frontend', ms);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Build the per-query processing-time breakdown surfaced to the DevTools panel
|
|
964
|
+
* and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
|
|
965
|
+
* the rest come from the per-phase rolling windows + one-shot registration timings.
|
|
966
|
+
*/
|
|
967
|
+
phaseTimings(q: QueryState): QueryTimings {
|
|
968
|
+
const stat = (phase: string) =>
|
|
969
|
+
phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
|
|
970
|
+
return {
|
|
971
|
+
ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
|
|
972
|
+
sspStoreApply: stat('sspStoreApply'),
|
|
973
|
+
sspCircuitStep: stat('sspCircuitStep'),
|
|
974
|
+
sspTransform: stat('sspTransform'),
|
|
975
|
+
localFetch: stat('localFetch'),
|
|
976
|
+
remoteFetch: stat('remoteFetch'),
|
|
977
|
+
frontend: stat('frontend'),
|
|
978
|
+
registration: q.registrationTimings,
|
|
979
|
+
updateCount: q.updateCount,
|
|
980
|
+
errorCount: q.errorCount,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
256
984
|
/**
|
|
257
985
|
* Get query state (for sync and devtools)
|
|
258
986
|
*/
|
|
@@ -260,6 +988,417 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
260
988
|
return this.activeQueries.get(hash);
|
|
261
989
|
}
|
|
262
990
|
|
|
991
|
+
/**
|
|
992
|
+
* Cold-query guard for instant-hydrate: true when the query exists, hasn't been
|
|
993
|
+
* hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
|
|
994
|
+
* We gate on `remoteArray`, not local `records`: a windowed query is often
|
|
995
|
+
* partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
|
|
996
|
+
* but it still hasn't loaded its own full window from the server — so it should
|
|
997
|
+
* still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
|
|
998
|
+
*/
|
|
999
|
+
isCold(hash: string): boolean {
|
|
1000
|
+
const qs = this.activeQueries.get(hash);
|
|
1001
|
+
return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Walk a hydrated record's fields and append any EMBEDDED child records to
|
|
1006
|
+
* `batch` (recursing for nested related fields). An embedded child is a
|
|
1007
|
+
* value that is itself a record — a non-null object whose `id` is a
|
|
1008
|
+
* `RecordId` — or an array of such records (one-to-many vs one-to-one). A
|
|
1009
|
+
* bare `RecordId` (a foreign-key reference) or any other value is skipped,
|
|
1010
|
+
* so this never mistakes a FK column for an embedded body. Children are
|
|
1011
|
+
* keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
|
|
1012
|
+
* to their table's real columns (which strips the alias/related fields).
|
|
1013
|
+
* `seen` dedupes within the batch.
|
|
1014
|
+
*/
|
|
1015
|
+
private collectEmbeddedChildren(
|
|
1016
|
+
record: Record<string, any>,
|
|
1017
|
+
batch: CacheRecord[],
|
|
1018
|
+
seen: Set<string>
|
|
1019
|
+
): void {
|
|
1020
|
+
const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
|
|
1021
|
+
!!v &&
|
|
1022
|
+
typeof v === 'object' &&
|
|
1023
|
+
!(v instanceof RecordId) &&
|
|
1024
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
1025
|
+
|
|
1026
|
+
for (const value of Object.values(record)) {
|
|
1027
|
+
const children = Array.isArray(value)
|
|
1028
|
+
? value.filter(isEmbeddedRecord)
|
|
1029
|
+
: isEmbeddedRecord(value)
|
|
1030
|
+
? [value]
|
|
1031
|
+
: [];
|
|
1032
|
+
for (const child of children) {
|
|
1033
|
+
const key = encodeRecordId(child.id);
|
|
1034
|
+
if (seen.has(key)) continue;
|
|
1035
|
+
seen.add(key);
|
|
1036
|
+
// Recurse FIRST so nested grandchildren are captured before `cleanRecord`
|
|
1037
|
+
// strips this child's alias fields.
|
|
1038
|
+
this.collectEmbeddedChildren(child, batch, seen);
|
|
1039
|
+
const table = child.id.table.toString();
|
|
1040
|
+
const tableSchema = this.schema.tables.find((t) => t.name === table);
|
|
1041
|
+
batch.push({
|
|
1042
|
+
table,
|
|
1043
|
+
op: 'CREATE',
|
|
1044
|
+
// Flatten the child too: a nested comment carries its own embedded
|
|
1045
|
+
// `author` object which the schemafull `record<user>` field would
|
|
1046
|
+
// reject. (Its author is captured as a separate row by the recursion
|
|
1047
|
+
// above.)
|
|
1048
|
+
record: this.flattenRelationsForStorage(child, tableSchema),
|
|
1049
|
+
version: (child._00_rv as number) || 1,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Prepare a subquery-bearing row (preload / hydration) for the schemafull
|
|
1057
|
+
* local store: replace an embedded FORWARD-relation object (`author = { id, … }`)
|
|
1058
|
+
* with its RecordId so a `record<…>` field coerces, and DROP reverse-subquery
|
|
1059
|
+
* ARRAYS (`comments = [ … ]`) since their rows are cached separately as their
|
|
1060
|
+
* own bodies. A flat record — as the live `SELECT * FROM $ids` sync returns,
|
|
1061
|
+
* with relations already RecordIds — passes through unchanged.
|
|
1062
|
+
*/
|
|
1063
|
+
private flattenRelationsForStorage(
|
|
1064
|
+
record: Record<string, any>,
|
|
1065
|
+
tableSchema?: { columns: Record<string, any> }
|
|
1066
|
+
): RecordWithId {
|
|
1067
|
+
const isEmbeddedRecord = (v: unknown): boolean =>
|
|
1068
|
+
!!v &&
|
|
1069
|
+
typeof v === 'object' &&
|
|
1070
|
+
!(v instanceof RecordId) &&
|
|
1071
|
+
(v as { id?: unknown }).id instanceof RecordId;
|
|
1072
|
+
const flat: Record<string, any> = {};
|
|
1073
|
+
for (const [k, v] of Object.entries(record)) {
|
|
1074
|
+
if (isEmbeddedRecord(v)) {
|
|
1075
|
+
flat[k] = (v as { id: unknown }).id;
|
|
1076
|
+
} else if (Array.isArray(v) && v.some(isEmbeddedRecord)) {
|
|
1077
|
+
continue;
|
|
1078
|
+
} else {
|
|
1079
|
+
flat[k] = v;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return (tableSchema ? cleanRecord(tableSchema.columns, flat) : flat) as RecordWithId;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
|
|
1087
|
+
* surql run directly) so the query DISPLAYS immediately, while the full realtime
|
|
1088
|
+
* registration proceeds in the background. Ingests with versions (`_00_rv`) so the
|
|
1089
|
+
* later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
|
|
1090
|
+
* `remoteArray` so windowed queries materialize the correct window (no sparse
|
|
1091
|
+
* local-circuit issue). Runs at most once per query (the `hydrated` flag).
|
|
1092
|
+
*/
|
|
1093
|
+
async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
|
|
1094
|
+
const queryState = this.activeQueries.get(hash);
|
|
1095
|
+
if (!queryState) return;
|
|
1096
|
+
queryState.hydrated = true; // run-once, even when the remote returns nothing
|
|
1097
|
+
if (rows.length === 0) return;
|
|
1098
|
+
|
|
1099
|
+
const epoch = this.local.epoch;
|
|
1100
|
+
const tableName = queryState.config.tableName;
|
|
1101
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
1102
|
+
// Bucket switched while we persisted: these rows were fetched under the
|
|
1103
|
+
// previous auth context — don't let them prime the new bucket's query
|
|
1104
|
+
// state; the rebind's re-registration refills it. (saveBatch's own epoch
|
|
1105
|
+
// fence usually catches this, but the switch can land between it and here.)
|
|
1106
|
+
if (epoch !== this.local.epoch) return;
|
|
1107
|
+
|
|
1108
|
+
// Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
|
|
1109
|
+
// renders from it and it feeds the version dedup. Registration later
|
|
1110
|
+
// overwrites it with the authoritative `_00_list_ref`.
|
|
1111
|
+
queryState.config.remoteArray = rows.map(
|
|
1112
|
+
(r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
|
|
1113
|
+
);
|
|
1114
|
+
// These rows ARE the server's answer to this query, so membership is now
|
|
1115
|
+
// known and rendering can stop falling back to a predicate scan. The durable
|
|
1116
|
+
// `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
|
|
1117
|
+
// single write point) — the registration that follows lands there anyway.
|
|
1118
|
+
queryState.config.membershipKnown = true;
|
|
1119
|
+
|
|
1120
|
+
queryState.records = await this.materializeRecords(queryState);
|
|
1121
|
+
const subscribers = this.subscriptions.get(hash);
|
|
1122
|
+
if (subscribers) {
|
|
1123
|
+
for (const cb of subscribers) cb(queryState.records);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Build the cache batch for a set of one-shot rows and persist it to the
|
|
1129
|
+
* local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
|
|
1130
|
+
* and extracts EMBEDDED related children (any nesting depth) as their own
|
|
1131
|
+
* records — a `.related()` query returns its children embedded, and a later
|
|
1132
|
+
* correlated re-materialization needs them present as standalone rows.
|
|
1133
|
+
* Shared by `applyHydration` (live registration) and `persistSnapshot`
|
|
1134
|
+
* (preload).
|
|
1135
|
+
*/
|
|
1136
|
+
private async buildAndSaveCacheBatch(tableName: string, rows: RecordWithId[]): Promise<void> {
|
|
1137
|
+
const tableSchema = this.schema.tables.find((t) => t.name === tableName);
|
|
1138
|
+
const batch: CacheRecord[] = rows.map((record) => ({
|
|
1139
|
+
table: tableName,
|
|
1140
|
+
op: 'CREATE' as const,
|
|
1141
|
+
// Flatten embedded relations first: a preload/hydration row can carry a
|
|
1142
|
+
// forward relation as a full nested OBJECT (`author = { id, … }`) and a
|
|
1143
|
+
// reverse subquery as an array of objects (`comments = [ … ]`). The
|
|
1144
|
+
// schemafull local field is `record<user>`, which rejects an object
|
|
1145
|
+
// (`Couldn't coerce … found { id: …, username: … }`) and would throw the
|
|
1146
|
+
// WHOLE batch. Store the parent with `author` as its RecordId and drop the
|
|
1147
|
+
// subquery arrays — the children are cached as their own rows below.
|
|
1148
|
+
record: this.flattenRelationsForStorage(record, tableSchema),
|
|
1149
|
+
version: (record._00_rv as number) || 1,
|
|
1150
|
+
}));
|
|
1151
|
+
|
|
1152
|
+
const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
|
|
1153
|
+
for (const record of rows) {
|
|
1154
|
+
this.collectEmbeddedChildren(record, batch, seen);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
await this.cache.saveBatch(batch);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Preload/prewarm: persist one-shot rows (and their embedded related children)
|
|
1162
|
+
* into the local cache WITHOUT registering a query — no `activeQueries` entry,
|
|
1163
|
+
* no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
|
|
1164
|
+
* ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
|
|
1165
|
+
* first paint from them instantly, then registers a live view to freshen.
|
|
1166
|
+
*/
|
|
1167
|
+
async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
|
|
1168
|
+
if (rows.length === 0) return;
|
|
1169
|
+
await this.buildAndSaveCacheBatch(tableName, rows);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Read the durable preload freshness marker for a query hash, or null if this
|
|
1174
|
+
* query was never preloaded in the current bucket. Co-located with the cached
|
|
1175
|
+
* rows (per-bucket `_00_preload` table) so a bucket switch that clears the
|
|
1176
|
+
* data also clears the marker — a stale marker can't claim "warm" when the
|
|
1177
|
+
* rows are gone. Any read error is treated as cold.
|
|
1178
|
+
*/
|
|
1179
|
+
async getPreloadMarker(hash: string): Promise<{ fetchedAt: number; rowCount: number } | null> {
|
|
1180
|
+
try {
|
|
1181
|
+
// Pass a real RecordId: a bare string id hits the SurrealDB engine's
|
|
1182
|
+
// `FROM ONLY $__id` as a plain string, which "selects" the string itself
|
|
1183
|
+
// (a truthy non-row) instead of the record — misread as a warm marker.
|
|
1184
|
+
const row = await this.local.getById('_00_preload', new RecordId('_00_preload', hash));
|
|
1185
|
+
if (!row || typeof row !== 'object') return null;
|
|
1186
|
+
return {
|
|
1187
|
+
fetchedAt: Number((row as any).fetchedAt) || 0,
|
|
1188
|
+
rowCount: Number((row as any).rowCount) || 0,
|
|
1189
|
+
};
|
|
1190
|
+
} catch {
|
|
1191
|
+
return null;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Stamp the preload freshness marker after a successful snapshot fetch. */
|
|
1196
|
+
async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
|
|
1197
|
+
// RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
|
|
1198
|
+
// and `UPSERT <string>` is an InternalError — the marker silently never
|
|
1199
|
+
// landed (the write is awaited inside preload's best-effort catch).
|
|
1200
|
+
await this.local.upsert(
|
|
1201
|
+
'_00_preload',
|
|
1202
|
+
new RecordId('_00_preload', hash),
|
|
1203
|
+
{ fetchedAt: Date.now(), rowCount },
|
|
1204
|
+
'replace'
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// ---- Durable membership (`_00_window`) ---------------------------------
|
|
1209
|
+
//
|
|
1210
|
+
// A query's authoritative membership is the id-set the server put in
|
|
1211
|
+
// `_00_list_ref` — `config.remoteArray`. It is persisted on the `_00_query`
|
|
1212
|
+
// row, but that row's id is salted with `session::id()`, which is new on every
|
|
1213
|
+
// page load (and `''` offline), and `doSwitchBucket` wipes the table outright.
|
|
1214
|
+
// So membership never survived a reload: the first paint fell back to a
|
|
1215
|
+
// predicate scan over whatever bodies were still cached, which re-included
|
|
1216
|
+
// rows the server had already dropped from the window.
|
|
1217
|
+
//
|
|
1218
|
+
// `_00_window` fixes that: same data, keyed by a session-independent hash, in
|
|
1219
|
+
// a table nothing wipes. Mirrors the durable `_00_preload` marker above.
|
|
1220
|
+
//
|
|
1221
|
+
// Row shape: `{ ids, confirmed, updatedAt }`. `confirmed` is the one bit that
|
|
1222
|
+
// lets an EMPTY row be trusted on the next boot (see `getWindowMembership`).
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Read the durable membership row, or `null` if this query has never had
|
|
1226
|
+
* authoritative membership on this device. Any read error is treated as
|
|
1227
|
+
* "unknown" so a broken row degrades to the predicate scan rather than
|
|
1228
|
+
* rendering an empty list.
|
|
1229
|
+
*
|
|
1230
|
+
* `confirmed` is true only for rows written after the server itself vouched
|
|
1231
|
+
* for the set (a non-empty id-set, or an empty one it reported a row count of
|
|
1232
|
+
* zero for, or an empty one that followed a non-empty one in the same
|
|
1233
|
+
* session). Rows written before the marker existed, including the `[]` rows a
|
|
1234
|
+
* pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
|
|
1235
|
+
*/
|
|
1236
|
+
async getWindowMembership(key: string): Promise<DurableMembership | null> {
|
|
1237
|
+
try {
|
|
1238
|
+
const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
|
|
1239
|
+
if (!row || typeof row !== 'object') return null;
|
|
1240
|
+
const ids = (row as any).ids;
|
|
1241
|
+
if (!Array.isArray(ids)) return null;
|
|
1242
|
+
return { ids: ids as RecordVersionArray, confirmed: (row as any).confirmed === true };
|
|
1243
|
+
} catch {
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Persist the durable membership row. Best-effort: callers must not fail a
|
|
1250
|
+
* sync round because the mirror write failed.
|
|
1251
|
+
*
|
|
1252
|
+
* `confirmed` says whether a cold start may trust this row even when it is
|
|
1253
|
+
* empty. A confirmed empty is a real answer ("the server says this query has
|
|
1254
|
+
* no rows") and stays empty across a reload; an unconfirmed empty is the
|
|
1255
|
+
* retry budget's guess and falls back to the predicate scan on the next boot,
|
|
1256
|
+
* exactly as every empty row did before the marker existed.
|
|
1257
|
+
*/
|
|
1258
|
+
async writeWindowMembership(
|
|
1259
|
+
key: string,
|
|
1260
|
+
ids: RecordVersionArray,
|
|
1261
|
+
confirmed: boolean
|
|
1262
|
+
): Promise<void> {
|
|
1263
|
+
try {
|
|
1264
|
+
await this.local.upsert(
|
|
1265
|
+
'_00_window',
|
|
1266
|
+
new RecordId('_00_window', key),
|
|
1267
|
+
{ ids, confirmed, updatedAt: Date.now() },
|
|
1268
|
+
'replace'
|
|
1269
|
+
);
|
|
1270
|
+
} catch (err) {
|
|
1271
|
+
this.logger.debug(
|
|
1272
|
+
{ err, key, Category: 'sp00ky-client::DataModule::writeWindowMembership' },
|
|
1273
|
+
'Failed to persist window membership; it will be re-derived on next register'
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Record ids with a mutation still in the outbox, split by direction.
|
|
1280
|
+
*
|
|
1281
|
+
* Both halves feed {@link materializeRecords}: `writes` keeps optimistic
|
|
1282
|
+
* creates/updates visible before the server has acknowledged them, and
|
|
1283
|
+
* `deletes` suppresses rows the server still lists because our DELETE hasn't
|
|
1284
|
+
* been processed yet. Reading `_00_pending_mutations` (rather than tracking
|
|
1285
|
+
* ids in memory) is what makes both survive a reload.
|
|
1286
|
+
*
|
|
1287
|
+
* On failure returns empty sets: membership alone then decides, which can
|
|
1288
|
+
* briefly hide an optimistic write but never resurrects a deleted row.
|
|
1289
|
+
*/
|
|
1290
|
+
async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
|
|
1291
|
+
const now = Date.now();
|
|
1292
|
+
const cached = this.pendingIds;
|
|
1293
|
+
if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) {
|
|
1294
|
+
// COPIES: `buildRenderIds` merges the settled-write ids into these sets,
|
|
1295
|
+
// and handing out the cached instances would let it grow them permanently.
|
|
1296
|
+
return { writes: new Set(cached.writes), deletes: new Set(cached.deletes) };
|
|
1297
|
+
}
|
|
1298
|
+
// Single-flight: one ingest fans out to many queries materializing at once,
|
|
1299
|
+
// and without this they all queue their own identical read behind each other.
|
|
1300
|
+
// Bounded re-read: a result from a generation older than the one current
|
|
1301
|
+
// by the time it lands was taken before some outbox row existed, so it is
|
|
1302
|
+
// re-issued rather than returned. Bounded, because a write on every tick
|
|
1303
|
+
// must not spin this forever - the last read is returned then.
|
|
1304
|
+
let fresh: { writes: Set<string>; deletes: Set<string> } | null = null;
|
|
1305
|
+
for (let attempt = 0; attempt < DataModule.PENDING_IDS_MAX_REREADS; attempt++) {
|
|
1306
|
+
const gen = this.pendingIdsGen;
|
|
1307
|
+
if (!this.pendingIdsInflight) {
|
|
1308
|
+
const read = this.readPendingRecordIds(gen).finally(() => {
|
|
1309
|
+
// Only clear the slot this read still owns; an invalidation in the
|
|
1310
|
+
// meantime has replaced it (with null) for a newer read to fill.
|
|
1311
|
+
if (this.pendingIdsInflight === read) this.pendingIdsInflight = null;
|
|
1312
|
+
});
|
|
1313
|
+
this.pendingIdsInflight = read;
|
|
1314
|
+
}
|
|
1315
|
+
fresh = await this.pendingIdsInflight;
|
|
1316
|
+
if (gen === this.pendingIdsGen) break;
|
|
1317
|
+
}
|
|
1318
|
+
// oxlint-disable-next-line no-non-null-assertion -- the loop runs at least once
|
|
1319
|
+
return { writes: new Set(fresh!.writes), deletes: new Set(fresh!.deletes) };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** The uncached read. Also the reload path after an invalidation, so the ids
|
|
1323
|
+
* still survive a reload exactly as before. `gen` is the generation the read
|
|
1324
|
+
* was issued under; the result is cached only if it is still current. */
|
|
1325
|
+
private async readPendingRecordIds(gen: number): Promise<{
|
|
1326
|
+
writes: Set<string>;
|
|
1327
|
+
deletes: Set<string>;
|
|
1328
|
+
}> {
|
|
1329
|
+
const writes = new Set<string>();
|
|
1330
|
+
const deletes = new Set<string>();
|
|
1331
|
+
try {
|
|
1332
|
+
const [rows] = await this.local.query<
|
|
1333
|
+
[{ recordId: RecordId<string>; mutationType: string }[]]
|
|
1334
|
+
>('SELECT recordId, mutationType FROM _00_pending_mutations');
|
|
1335
|
+
for (const row of rows ?? []) {
|
|
1336
|
+
if (!row?.recordId) continue;
|
|
1337
|
+
const id = encodeRecordId(row.recordId);
|
|
1338
|
+
if (row.mutationType === 'delete') deletes.add(id);
|
|
1339
|
+
else writes.add(id);
|
|
1340
|
+
}
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
this.logger.warn(
|
|
1343
|
+
{ err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
|
|
1344
|
+
'Failed to read pending mutations; optimistic writes may be briefly hidden'
|
|
1345
|
+
);
|
|
1346
|
+
// Do NOT cache a failed read: the empty sets are a fallback for this call,
|
|
1347
|
+
// not a statement that the outbox is empty.
|
|
1348
|
+
return { writes, deletes };
|
|
1349
|
+
}
|
|
1350
|
+
if (gen === this.pendingIdsGen) {
|
|
1351
|
+
this.pendingIds = { writes, deletes };
|
|
1352
|
+
this.pendingIdsAt = Date.now();
|
|
1353
|
+
}
|
|
1354
|
+
return { writes, deletes };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** True while ≥1 live subscriber is watching this query (refcount guard). */
|
|
1358
|
+
hasSubscribers(hash: string): boolean {
|
|
1359
|
+
return (this.subscriptions.get(hash)?.size ?? 0) > 0;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Opt-in eager teardown for a query whose LAST subscriber just left — used by
|
|
1364
|
+
* viewport-windowed lists to cancel off-screen windows instead of leaving
|
|
1365
|
+
* their remote views to expire on the TTL sweep. No-op while any subscriber
|
|
1366
|
+
* remains (refcount). Only enqueues the remote cleanup here; the local WASM
|
|
1367
|
+
* view + in-memory state are freed in {@link finalizeDeregister} after the
|
|
1368
|
+
* remote delete completes, so a re-subscribe in between aborts/heals it.
|
|
1369
|
+
*
|
|
1370
|
+
* NOTE: most queries should NOT use this — the default keep-alive on
|
|
1371
|
+
* unsubscribe avoids re-registration churn on navigation.
|
|
1372
|
+
*/
|
|
1373
|
+
deregisterQuery(hash: string): void {
|
|
1374
|
+
if (this.hasSubscribers(hash)) return;
|
|
1375
|
+
if (!this.activeQueries.has(hash)) return;
|
|
1376
|
+
this.onDeregister?.(hash);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Final local teardown after the remote `_00_query` row was deleted: free the
|
|
1381
|
+
* WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
|
|
1382
|
+
* (`cleanupQuery`) guarantees no subscriber remains.
|
|
1383
|
+
*/
|
|
1384
|
+
finalizeDeregister(hash: string): void {
|
|
1385
|
+
const qs = this.activeQueries.get(hash);
|
|
1386
|
+
if (qs?.ttlTimer) {
|
|
1387
|
+
clearTimeout(qs.ttlTimer);
|
|
1388
|
+
qs.ttlTimer = null;
|
|
1389
|
+
}
|
|
1390
|
+
const debounce = this.debounceTimers.get(hash);
|
|
1391
|
+
if (debounce) {
|
|
1392
|
+
clearTimeout(debounce);
|
|
1393
|
+
this.debounceTimers.delete(hash);
|
|
1394
|
+
}
|
|
1395
|
+
this.pendingStreamUpdates.delete(hash);
|
|
1396
|
+
this.fetchDepth.delete(hash);
|
|
1397
|
+
this.cache.unregisterQuery(hash);
|
|
1398
|
+
this.activeQueries.delete(hash);
|
|
1399
|
+
this.subscriptions.delete(hash);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
263
1402
|
/**
|
|
264
1403
|
* Get query state by id (for sync and devtools)
|
|
265
1404
|
*/
|
|
@@ -274,36 +1413,259 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
274
1413
|
return Array.from(this.activeQueries.values());
|
|
275
1414
|
}
|
|
276
1415
|
|
|
1416
|
+
getActiveQueryHashes(): QueryHash[] {
|
|
1417
|
+
return Array.from(this.activeQueries.keys());
|
|
1418
|
+
}
|
|
1419
|
+
|
|
277
1420
|
async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
|
|
278
1421
|
const queryState = this.activeQueries.get(id);
|
|
279
1422
|
if (!queryState) {
|
|
280
1423
|
this.logger.warn(
|
|
281
|
-
{ id, Category: '
|
|
1424
|
+
{ id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
|
|
282
1425
|
'Query to update local array not found'
|
|
283
1426
|
);
|
|
284
1427
|
return;
|
|
285
1428
|
}
|
|
1429
|
+
const epoch = this.local.epoch;
|
|
286
1430
|
queryState.config.localArray = localArray;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
1431
|
+
try {
|
|
1432
|
+
await this.local.query(
|
|
1433
|
+
surql.seal(surql.updateSet('id', ['localArray'])),
|
|
1434
|
+
{
|
|
1435
|
+
id: queryState.config.id,
|
|
1436
|
+
localArray,
|
|
1437
|
+
},
|
|
1438
|
+
{ epoch }
|
|
1439
|
+
);
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err instanceof StaleEpochError) return;
|
|
1442
|
+
throw err;
|
|
1443
|
+
}
|
|
291
1444
|
}
|
|
292
1445
|
|
|
293
|
-
async updateQueryRemoteArray(
|
|
1446
|
+
async updateQueryRemoteArray(
|
|
1447
|
+
hash: string,
|
|
1448
|
+
remoteArray: RecordVersionArray,
|
|
1449
|
+
opts?: {
|
|
1450
|
+
/** `_00_query.rowCount` read in the same round trip; `null` = unknown. */
|
|
1451
|
+
serverRowCount?: number | null;
|
|
1452
|
+
}
|
|
1453
|
+
): Promise<void> {
|
|
294
1454
|
const queryState = this.getQueryByHash(hash);
|
|
295
1455
|
if (!queryState) {
|
|
296
1456
|
this.logger.warn(
|
|
297
|
-
{ hash, Category: '
|
|
1457
|
+
{ hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
|
|
298
1458
|
'Query to update remote array not found'
|
|
299
1459
|
);
|
|
300
1460
|
return;
|
|
301
1461
|
}
|
|
1462
|
+
// An empty id-set is only believable once a real one has arrived in this
|
|
1463
|
+
// session. `_00_list_ref` is published asynchronously — the SSP queues a
|
|
1464
|
+
// view's initial edges to a coalescing flusher and returns from
|
|
1465
|
+
// `fn::query::register` before they land — so an empty read right after
|
|
1466
|
+
// registration is routinely just "not flushed yet", for a query that has
|
|
1467
|
+
// rows. Taking it at face value latched `membershipKnown` on an empty set,
|
|
1468
|
+
// which renders nothing (no scan fallback), and mirrored `[]` into the
|
|
1469
|
+
// durable `_00_window` row, which kept the list blank across reloads.
|
|
1470
|
+
//
|
|
1471
|
+
// Ignoring it entirely (rather than storing `[]` with the latch withheld)
|
|
1472
|
+
// is deliberate: on a cold start `remoteArray` is seeded from the durable
|
|
1473
|
+
// row, and overwriting that with `[]` would blank the very rows the seed
|
|
1474
|
+
// exists to paint. The `_00_list_ref` poll re-reads within ~500ms and
|
|
1475
|
+
// delivers the real set.
|
|
1476
|
+
// `serverRowCount` is what makes the two cases separable. The SSP writes it
|
|
1477
|
+
// onto the `_00_query` row in the same statement that registers the view,
|
|
1478
|
+
// before it queues the view's initial edges — so `> 0` with no edges is the
|
|
1479
|
+
// flush window and `=== 0` is a genuinely empty query. `null`/undefined
|
|
1480
|
+
// means we could not read it (older server, row not visible yet).
|
|
1481
|
+
//
|
|
1482
|
+
// Retry counting cannot substitute for this: the poll runs 500ms after
|
|
1483
|
+
// registration, well inside the flush window for a real collection, so
|
|
1484
|
+
// "believe it the second time" blanked exactly the lists this guard exists
|
|
1485
|
+
// to protect — reported as rows vanishing ~2s after a page load.
|
|
1486
|
+
// Whether this write may be trusted by the NEXT session. Non-empty sets
|
|
1487
|
+
// always; an empty set only when the server stood behind it (a zero row
|
|
1488
|
+
// count, or a real set was seen this session and it is now gone). The
|
|
1489
|
+
// retry-budget path below accepts an empty without that backing and must
|
|
1490
|
+
// stay non-durable, or the poisoned-device self-heal is lost.
|
|
1491
|
+
let confirmed = remoteArray.length > 0 || queryState.config.remoteSeen === true;
|
|
1492
|
+
if (remoteArray.length === 0 && !queryState.config.remoteSeen) {
|
|
1493
|
+
const serverRowCount = opts?.serverRowCount;
|
|
1494
|
+
const knownEmpty = serverRowCount === 0;
|
|
1495
|
+
confirmed = knownEmpty;
|
|
1496
|
+
if (!knownEmpty) {
|
|
1497
|
+
// Unknown row count still gets a bounded escape hatch, so a server that
|
|
1498
|
+
// cannot report one never strands a device on a durable seed forever.
|
|
1499
|
+
const emptyReads = (queryState.config.emptyReads ?? 0) + 1;
|
|
1500
|
+
queryState.config.emptyReads = emptyReads;
|
|
1501
|
+
const exhausted =
|
|
1502
|
+
serverRowCount === null || serverRowCount === undefined
|
|
1503
|
+
? emptyReads >= EMPTY_MEMBERSHIP_CONFIRMATIONS
|
|
1504
|
+
: false; // a positive row count is never "confirmed empty"
|
|
1505
|
+
if (!exhausted) {
|
|
1506
|
+
this.logger.debug(
|
|
1507
|
+
{
|
|
1508
|
+
hash,
|
|
1509
|
+
emptyReads,
|
|
1510
|
+
serverRowCount,
|
|
1511
|
+
Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
|
|
1512
|
+
},
|
|
1513
|
+
'Ignoring empty membership: the server still reports rows for this query'
|
|
1514
|
+
);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const epoch = this.local.epoch;
|
|
302
1521
|
queryState.config.remoteArray = remoteArray;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
1522
|
+
// The single point where authoritative membership arrives (registration and
|
|
1523
|
+
// the `_00_list_ref` poll both land here), so it is where "we now know the
|
|
1524
|
+
// membership" is latched and where the durable mirror is written.
|
|
1525
|
+
queryState.config.membershipKnown = true;
|
|
1526
|
+
if (remoteArray.length > 0) {
|
|
1527
|
+
// Earns the right to believe a later empty set — that transition is a
|
|
1528
|
+
// genuine removal and must be honoured, or removed rows resurrect.
|
|
1529
|
+
queryState.config.remoteSeen = true;
|
|
1530
|
+
queryState.config.emptyReads = 0;
|
|
1531
|
+
}
|
|
1532
|
+
if (queryState.config.membershipKey) {
|
|
1533
|
+
await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, confirmed);
|
|
1534
|
+
}
|
|
1535
|
+
try {
|
|
1536
|
+
await this.local.query(
|
|
1537
|
+
surql.seal(surql.updateSet('id', ['remoteArray'])),
|
|
1538
|
+
{
|
|
1539
|
+
id: queryState.config.id,
|
|
1540
|
+
remoteArray,
|
|
1541
|
+
},
|
|
1542
|
+
{ epoch }
|
|
1543
|
+
);
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
if (err instanceof StaleEpochError) return;
|
|
1546
|
+
throw err;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Cancel every armed timer ahead of a local-bucket switch: stream-update
|
|
1552
|
+
* debounce timers (their pending updates carry the OLD bucket's id-sets) and
|
|
1553
|
+
* per-query TTL heartbeats (they'd refresh the previous user's remote
|
|
1554
|
+
* `_00_query` rows under the new session). The rebind re-arms heartbeats.
|
|
1555
|
+
*/
|
|
1556
|
+
quiesce(): void {
|
|
1557
|
+
for (const timer of this.debounceTimers.values()) {
|
|
1558
|
+
clearTimeout(timer);
|
|
1559
|
+
}
|
|
1560
|
+
this.debounceTimers.clear();
|
|
1561
|
+
this.pendingStreamUpdates.clear();
|
|
1562
|
+
this.fetchDepth.clear();
|
|
1563
|
+
for (const queryState of this.activeQueries.values()) {
|
|
1564
|
+
if (queryState.ttlTimer) {
|
|
1565
|
+
clearTimeout(queryState.ttlTimer);
|
|
1566
|
+
queryState.ttlTimer = null;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Re-home every active query in a freshly-opened bucket, KEEPING its hash —
|
|
1573
|
+
* `useQuery` subscriptions are keyed by hash and don't re-register on auth
|
|
1574
|
+
* changes, so the hooks must stay attached. Per query:
|
|
1575
|
+
* 1. reset the sync arrays + hydration flag and drop the previous user's
|
|
1576
|
+
* records, notifying subscribers with the new-bucket materialization
|
|
1577
|
+
* (usually empty) so their rows leave the UI immediately;
|
|
1578
|
+
* 2. recreate the `_00_query` row in the new bucket;
|
|
1579
|
+
* 3. re-register the SSP view on the (fresh, post-reset) processor — this
|
|
1580
|
+
* also rebinds the view to the NEW `$auth` context;
|
|
1581
|
+
* 4. restart the TTL heartbeat.
|
|
1582
|
+
* Returns the hashes so the caller can enqueue remote re-registration, which
|
|
1583
|
+
* refills records from the server via the normal register→sync→notify path.
|
|
1584
|
+
*/
|
|
1585
|
+
async rebindAfterBucketSwitch(): Promise<QueryHash[]> {
|
|
1586
|
+
const hashes: QueryHash[] = [];
|
|
1587
|
+
for (const [hash, queryState] of this.activeQueries.entries()) {
|
|
1588
|
+
const config = queryState.config;
|
|
1589
|
+
config.localArray = [];
|
|
1590
|
+
config.remoteArray = [];
|
|
1591
|
+
config.subqueryRemoteArray = undefined;
|
|
1592
|
+
// Membership belongs to the bucket we just left. Re-seed from the NEW
|
|
1593
|
+
// bucket's durable row if it has one (a returning user), otherwise mark it
|
|
1594
|
+
// unknown so the first paint falls back to a scan instead of rendering an
|
|
1595
|
+
// empty list until the server answers.
|
|
1596
|
+
config.membershipKnown = false;
|
|
1597
|
+
// The new bucket's server sets have not been seen yet — an empty read
|
|
1598
|
+
// must be re-confirmed there too.
|
|
1599
|
+
config.remoteSeen = false;
|
|
1600
|
+
config.emptyReads = 0;
|
|
1601
|
+
if (config.membershipKey) {
|
|
1602
|
+
const durable = await this.getWindowMembership(config.membershipKey);
|
|
1603
|
+
// Same rule as the cold-start read: a non-empty row, or an empty one
|
|
1604
|
+
// the server confirmed, is membership; an unmarked empty row is not.
|
|
1605
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
1606
|
+
config.remoteArray = durable.ids;
|
|
1607
|
+
config.membershipKnown = true;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
queryState.hydrated = false;
|
|
1611
|
+
queryState.syncNotified = false;
|
|
1612
|
+
queryState.records = [];
|
|
1613
|
+
// Via setQueryStatus (not a bare assignment) so status observers see the
|
|
1614
|
+
// flip back to a loading state.
|
|
1615
|
+
this.setQueryStatus(hash, 'fetching');
|
|
1616
|
+
|
|
1617
|
+
try {
|
|
1618
|
+
await withRetry(this.logger, () =>
|
|
1619
|
+
this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
|
|
1620
|
+
id: config.id,
|
|
1621
|
+
data: {
|
|
1622
|
+
surql: config.surql,
|
|
1623
|
+
params: config.params,
|
|
1624
|
+
localArray: [],
|
|
1625
|
+
remoteArray: [],
|
|
1626
|
+
lastActiveAt: new Date(),
|
|
1627
|
+
createdAt: new Date(),
|
|
1628
|
+
ttl: config.ttl,
|
|
1629
|
+
tableName: config.tableName,
|
|
1630
|
+
updateCount: queryState.updateCount,
|
|
1631
|
+
rowCount: 0,
|
|
1632
|
+
errorCount: queryState.errorCount,
|
|
1633
|
+
},
|
|
1634
|
+
})
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
const { localArray } = this.cache.registerQuery({
|
|
1638
|
+
queryHash: hash,
|
|
1639
|
+
surql: config.surql,
|
|
1640
|
+
params: config.params,
|
|
1641
|
+
ttl: new Duration(config.ttl),
|
|
1642
|
+
lastActiveAt: new Date(),
|
|
1643
|
+
});
|
|
1644
|
+
config.localArray = localArray;
|
|
1645
|
+
await this.local.query(surql.seal(surql.updateSet('id', ['localArray', 'rowCount'])), {
|
|
1646
|
+
id: config.id,
|
|
1647
|
+
localArray,
|
|
1648
|
+
rowCount: localArray.length,
|
|
1649
|
+
});
|
|
1650
|
+
} catch (err) {
|
|
1651
|
+
this.logger.error(
|
|
1652
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::rebindAfterBucketSwitch' },
|
|
1653
|
+
'Failed to rebind query after bucket switch; remote re-registration will retry'
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Notify AFTER the SSP re-registration so a subscriber that re-reads
|
|
1658
|
+
// synchronously sees consistent (empty) state.
|
|
1659
|
+
const subscribers = this.subscriptions.get(hash);
|
|
1660
|
+
if (subscribers) {
|
|
1661
|
+
for (const callback of subscribers) {
|
|
1662
|
+
callback(queryState.records);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
1666
|
+
hashes.push(hash);
|
|
1667
|
+
}
|
|
1668
|
+
return hashes;
|
|
307
1669
|
}
|
|
308
1670
|
|
|
309
1671
|
/**
|
|
@@ -313,20 +1675,29 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
313
1675
|
async notifyQuerySynced(queryHash: string): Promise<void> {
|
|
314
1676
|
const queryState = this.activeQueries.get(queryHash);
|
|
315
1677
|
if (!queryState) return;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
|
|
1678
|
+
const epoch = this.local.epoch;
|
|
1679
|
+
|
|
1680
|
+
// Re-query local DB for latest data (windowed queries materialize from the
|
|
1681
|
+
// list_ref window so they resolve even if the in-browser SSP never emits —
|
|
1682
|
+
// it can't compute a high offset whose preceding rows aren't resident).
|
|
1683
|
+
const newRecords = await this.materializeRecords(queryState);
|
|
1684
|
+
// Bucket switched while we materialized: these rows mix old-bucket reads
|
|
1685
|
+
// with new-bucket state — drop them; the rebind/re-registration re-emits.
|
|
1686
|
+
if (epoch !== this.local.epoch) return;
|
|
323
1687
|
const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
|
|
324
1688
|
queryState.records = newRecords;
|
|
325
1689
|
|
|
326
|
-
// Notify if data changed OR if this
|
|
327
|
-
// The latter handles "query truly has no
|
|
328
|
-
|
|
1690
|
+
// Notify if data changed OR if this registration lifetime hasn't emitted a
|
|
1691
|
+
// post-sync notification yet. The latter handles "query truly has no
|
|
1692
|
+
// results" so the UI can stop loading — gated on the in-memory
|
|
1693
|
+
// `syncNotified` flag rather than `updateCount === 0`, because updateCount
|
|
1694
|
+
// is PERSISTED across deregister/re-register: a re-registered empty window
|
|
1695
|
+
// (updateCount > 0, records unchanged) would otherwise never emit and its
|
|
1696
|
+
// subscribers would show a loading state forever.
|
|
1697
|
+
if (changed || !queryState.syncNotified) {
|
|
1698
|
+
queryState.syncNotified = true;
|
|
329
1699
|
queryState.updateCount++;
|
|
1700
|
+
queryState.lastUpdatedAt = Date.now();
|
|
330
1701
|
const subscribers = this.subscriptions.get(queryHash);
|
|
331
1702
|
if (subscribers) {
|
|
332
1703
|
for (const callback of subscribers) {
|
|
@@ -344,6 +1715,24 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
344
1715
|
data: RoutePayload<S, B, R>,
|
|
345
1716
|
options?: RunOptions
|
|
346
1717
|
): Promise<void> {
|
|
1718
|
+
const { tableName, record } = this.buildJobRecord(backend, path, data, options);
|
|
1719
|
+
const recordId = `${tableName}:${generateId()}`;
|
|
1720
|
+
await this.create(recordId, record);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Build the outbox job record + resolve its table for a backend route.
|
|
1725
|
+
*
|
|
1726
|
+
* Every job is a single execution. Recurring work is declared server-side
|
|
1727
|
+
* (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
|
|
1728
|
+
* so nothing here needs to know about schedules.
|
|
1729
|
+
*/
|
|
1730
|
+
private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
1731
|
+
backend: B,
|
|
1732
|
+
path: R,
|
|
1733
|
+
data: RoutePayload<S, B, R>,
|
|
1734
|
+
options?: RunOptions
|
|
1735
|
+
): { tableName: string; record: Record<string, unknown> } {
|
|
347
1736
|
const route = this.schema.backends?.[backend]?.routes?.[path];
|
|
348
1737
|
if (!route) {
|
|
349
1738
|
throw new Error(`Route ${backend}.${path} not found`);
|
|
@@ -366,16 +1755,28 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
366
1755
|
const record: Record<string, unknown> = {
|
|
367
1756
|
path,
|
|
368
1757
|
payload: JSON.stringify(payload),
|
|
1758
|
+
// Set explicitly: the schema's DEFAULT ALWAYS "pending" is server-side
|
|
1759
|
+
// only. An optimistic local create without it surfaces with status
|
|
1760
|
+
// undefined, so in-flight indicators keyed on pending/processing stay
|
|
1761
|
+
// off until the first server echo (seconds on a delayed job).
|
|
1762
|
+
status: 'pending',
|
|
369
1763
|
max_retries: options?.max_retries ?? 3,
|
|
370
1764
|
retry_strategy: options?.retry_strategy ?? 'linear',
|
|
371
1765
|
};
|
|
372
1766
|
|
|
1767
|
+
if (options?.timeout != null) {
|
|
1768
|
+
record.timeout = options.timeout;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (options?.delay != null) {
|
|
1772
|
+
record.delay = options.delay;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
373
1775
|
if (options?.assignedTo) {
|
|
374
1776
|
record.assigned_to = options.assignedTo;
|
|
375
1777
|
}
|
|
376
1778
|
|
|
377
|
-
|
|
378
|
-
await this.create(recordId, record);
|
|
1779
|
+
return { tableName, record };
|
|
379
1780
|
}
|
|
380
1781
|
|
|
381
1782
|
// ==================== MUTATION MANAGEMENT ====================
|
|
@@ -392,7 +1793,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
392
1793
|
|
|
393
1794
|
const rid = parseRecordIdString(id);
|
|
394
1795
|
const params = parseParams(tableSchema.columns, data);
|
|
395
|
-
const mutationId = parseRecordIdString(
|
|
1796
|
+
const mutationId = parseRecordIdString(mintMutationId(this.tabId));
|
|
396
1797
|
|
|
397
1798
|
const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
|
|
398
1799
|
const prefixedParams = Object.fromEntries(
|
|
@@ -410,22 +1811,46 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
410
1811
|
this.local.execute(query, {
|
|
411
1812
|
id: rid,
|
|
412
1813
|
mid: mutationId,
|
|
1814
|
+
// The record itself is written with per-key `data_<field>` vars
|
|
1815
|
+
// (`createSet`), but the OUTBOX row needs the whole payload in one
|
|
1816
|
+
// `data` field so the mutation can be replayed later. Without this the
|
|
1817
|
+
// row is written with `data` unbound: the create becomes unsendable and
|
|
1818
|
+
// blocks the queue behind it.
|
|
1819
|
+
data: params,
|
|
413
1820
|
...prefixedParams,
|
|
414
1821
|
})
|
|
415
1822
|
);
|
|
416
1823
|
|
|
1824
|
+
// The local tx has committed: the row and its outbox entry exist. The
|
|
1825
|
+
// cached pending-id sets no longer describe the outbox, so drop them HERE,
|
|
1826
|
+
// before the ingest below fans out to every view's materialization (which
|
|
1827
|
+
// is exactly when they are read).
|
|
1828
|
+
this.invalidatePendingIds();
|
|
1829
|
+
|
|
417
1830
|
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
418
1831
|
|
|
419
|
-
// Save to cache (which handles DBSP ingestion)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
1832
|
+
// Save to cache (which handles DBSP ingestion). Best-effort, like
|
|
1833
|
+
// `delete()`: the local write is durable and the outbox row exists, so a
|
|
1834
|
+
// throw from the in-browser ingest must not abort the rest of this method -
|
|
1835
|
+
// it used to, and then the mutation was never handed to sync, the row sat
|
|
1836
|
+
// in the outbox until the next leader promotion re-scanned it, and the
|
|
1837
|
+
// caller's promise rejected after the write had already happened.
|
|
1838
|
+
try {
|
|
1839
|
+
await this.cache.save(
|
|
1840
|
+
{
|
|
1841
|
+
table: tableName,
|
|
1842
|
+
op: 'CREATE',
|
|
1843
|
+
record: parsedRecord,
|
|
1844
|
+
version: 1,
|
|
1845
|
+
},
|
|
1846
|
+
true
|
|
1847
|
+
);
|
|
1848
|
+
} catch (err) {
|
|
1849
|
+
this.logger.error(
|
|
1850
|
+
{ err, id, Category: 'sp00ky-client::DataModule::create' },
|
|
1851
|
+
'SSP create-ingest failed; the row is written and queued, but views only see it once membership arrives'
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
429
1854
|
|
|
430
1855
|
// Emit mutation event for sync
|
|
431
1856
|
const mutationEvent: CreateEvent = {
|
|
@@ -441,7 +1866,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
441
1866
|
callback([mutationEvent]);
|
|
442
1867
|
}
|
|
443
1868
|
|
|
444
|
-
this.logger.debug({ id, Category: '
|
|
1869
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
|
|
445
1870
|
|
|
446
1871
|
return target;
|
|
447
1872
|
}
|
|
@@ -463,7 +1888,10 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
463
1888
|
|
|
464
1889
|
const rid = parseRecordIdString(id);
|
|
465
1890
|
const params = parseParams(tableSchema.columns, data);
|
|
466
|
-
const mutationId = parseRecordIdString(
|
|
1891
|
+
const mutationId = parseRecordIdString(mintMutationId(this.tabId));
|
|
1892
|
+
|
|
1893
|
+
// Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
|
|
1894
|
+
// NOT through the record update pipeline. This keeps the record data clean.
|
|
467
1895
|
|
|
468
1896
|
// Capture current record state before mutation for rollback support
|
|
469
1897
|
const [beforeRecord] = await withRetry(this.logger, () =>
|
|
@@ -472,7 +1900,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
472
1900
|
|
|
473
1901
|
const query = surql.seal<{ target: T }>(
|
|
474
1902
|
surql.tx([
|
|
475
|
-
surql.updateSet('id', [{ statement: '
|
|
1903
|
+
surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
|
|
476
1904
|
surql.let('updated', surql.updateMerge('id', 'data')),
|
|
477
1905
|
surql.createMutation('update', 'mid', 'id', 'data'),
|
|
478
1906
|
surql.returnObject([{ key: 'target', variable: 'updated' }]),
|
|
@@ -496,23 +1924,33 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
496
1924
|
updatedFields[key] = (target as Record<string, any>)[key];
|
|
497
1925
|
}
|
|
498
1926
|
}
|
|
499
|
-
if ('
|
|
500
|
-
updatedFields.
|
|
1927
|
+
if ('_00_rv' in (target as Record<string, any>)) {
|
|
1928
|
+
updatedFields._00_rv = (target as Record<string, any>)._00_rv;
|
|
501
1929
|
}
|
|
502
1930
|
this.replaceRecordInQueries(updatedFields);
|
|
503
1931
|
|
|
1932
|
+
// Committed: see create() for why this precedes the ingest.
|
|
1933
|
+
this.invalidatePendingIds();
|
|
1934
|
+
|
|
504
1935
|
const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
|
|
505
1936
|
|
|
506
|
-
// Save to cache
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1937
|
+
// Save to cache. Best-effort for the same reason as in create().
|
|
1938
|
+
try {
|
|
1939
|
+
await this.cache.save(
|
|
1940
|
+
{
|
|
1941
|
+
table: table,
|
|
1942
|
+
op: 'UPDATE',
|
|
1943
|
+
record: parsedRecord,
|
|
1944
|
+
version: target._00_rv as number,
|
|
1945
|
+
},
|
|
1946
|
+
true
|
|
1947
|
+
);
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
this.logger.error(
|
|
1950
|
+
{ err, id, Category: 'sp00ky-client::DataModule::update' },
|
|
1951
|
+
'SSP update-ingest failed; the row is written and queued'
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
516
1954
|
|
|
517
1955
|
const pushEventOptions = parseUpdateOptions(id, data, options);
|
|
518
1956
|
|
|
@@ -531,7 +1969,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
531
1969
|
callback([mutationEvent]);
|
|
532
1970
|
}
|
|
533
1971
|
|
|
534
|
-
this.logger.debug({ id, Category: '
|
|
1972
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
|
|
535
1973
|
|
|
536
1974
|
return target;
|
|
537
1975
|
}
|
|
@@ -547,14 +1985,45 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
547
1985
|
}
|
|
548
1986
|
|
|
549
1987
|
const rid = parseRecordIdString(id);
|
|
550
|
-
const mutationId = parseRecordIdString(
|
|
1988
|
+
const mutationId = parseRecordIdString(mintMutationId(this.tabId));
|
|
1989
|
+
|
|
1990
|
+
// Fetch the record before deleting so DBSP can match it against query predicates
|
|
1991
|
+
const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
|
|
1992
|
+
'SELECT * FROM ONLY $id',
|
|
1993
|
+
{ id: rid }
|
|
1994
|
+
);
|
|
1995
|
+
const beforeRecord = beforeRecords ?? {};
|
|
551
1996
|
|
|
552
1997
|
const query = surql.seal<void>(
|
|
553
1998
|
surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
|
|
554
1999
|
);
|
|
555
2000
|
|
|
556
2001
|
await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
|
|
557
|
-
|
|
2002
|
+
|
|
2003
|
+
// Committed: the outbox row exists, drop the cached pending-id sets before
|
|
2004
|
+
// the re-materialize below reads them (see create()).
|
|
2005
|
+
this.invalidatePendingIds();
|
|
2006
|
+
|
|
2007
|
+
// The local DELETE has now committed. Everything below must reflect that in
|
|
2008
|
+
// active live queries — so the deleted row disappears optimistically without
|
|
2009
|
+
// a reload — even if the optimistic SSP-view ingest below fails. Previously a
|
|
2010
|
+
// throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
|
|
2011
|
+
// commit, so the manual notify loop never ran and the row lingered on screen
|
|
2012
|
+
// until reload. Ingesting the delete into the in-browser SSP view is
|
|
2013
|
+
// best-effort: the manual re-materialize reads the local DB (which already
|
|
2014
|
+
// excludes the row), so the result is correct regardless.
|
|
2015
|
+
try {
|
|
2016
|
+
await this.cache.delete(table, id, true, beforeRecord);
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
this.logger.error(
|
|
2019
|
+
{ err, id, Category: 'sp00ky-client::DataModule::delete' },
|
|
2020
|
+
'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// DBSP may not emit view updates for DELETE ops — manually notify all queries
|
|
2025
|
+
// that reference this table.
|
|
2026
|
+
await this.notifyTableQueries(tableName);
|
|
558
2027
|
|
|
559
2028
|
// Emit mutation event
|
|
560
2029
|
const mutationEvent: DeleteEvent = {
|
|
@@ -567,7 +2036,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
567
2036
|
callback([mutationEvent]);
|
|
568
2037
|
}
|
|
569
2038
|
|
|
570
|
-
this.logger.debug({ id, Category: '
|
|
2039
|
+
this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
|
|
571
2040
|
}
|
|
572
2041
|
|
|
573
2042
|
// ==================== ROLLBACK METHODS ====================
|
|
@@ -579,19 +2048,17 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
579
2048
|
const id = encodeRecordId(recordId);
|
|
580
2049
|
|
|
581
2050
|
try {
|
|
582
|
-
await withRetry(this.logger, () =>
|
|
583
|
-
this.local.query('DELETE $id', { id: recordId })
|
|
584
|
-
);
|
|
2051
|
+
await withRetry(this.logger, () => this.local.query('DELETE $id', { id: recordId }));
|
|
585
2052
|
await this.cache.delete(tableName, id, true);
|
|
586
2053
|
this.removeRecordFromQueries(recordId);
|
|
587
2054
|
|
|
588
2055
|
this.logger.info(
|
|
589
|
-
{ id, tableName, Category: '
|
|
2056
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
590
2057
|
'Rolled back optimistic create'
|
|
591
2058
|
);
|
|
592
2059
|
} catch (err) {
|
|
593
2060
|
this.logger.error(
|
|
594
|
-
{ err, id, tableName, Category: '
|
|
2061
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
|
|
595
2062
|
'Failed to rollback create'
|
|
596
2063
|
);
|
|
597
2064
|
}
|
|
@@ -626,7 +2093,7 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
626
2093
|
table: tableName,
|
|
627
2094
|
op: 'UPDATE',
|
|
628
2095
|
record: parsedRecord,
|
|
629
|
-
version: (beforeRecord.
|
|
2096
|
+
version: (beforeRecord._00_rv as number) || 1,
|
|
630
2097
|
},
|
|
631
2098
|
true
|
|
632
2099
|
);
|
|
@@ -635,17 +2102,40 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
635
2102
|
await this.replaceRecordInQueries(beforeRecord);
|
|
636
2103
|
|
|
637
2104
|
this.logger.info(
|
|
638
|
-
{ id, tableName, Category: '
|
|
2105
|
+
{ id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
639
2106
|
'Rolled back optimistic update'
|
|
640
2107
|
);
|
|
641
2108
|
} catch (err) {
|
|
642
2109
|
this.logger.error(
|
|
643
|
-
{ err, id, tableName, Category: '
|
|
2110
|
+
{ err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
|
|
644
2111
|
'Failed to rollback update'
|
|
645
2112
|
);
|
|
646
2113
|
}
|
|
647
2114
|
}
|
|
648
2115
|
|
|
2116
|
+
/**
|
|
2117
|
+
* Force a re-materialize + notify of every active query on `tableName`.
|
|
2118
|
+
* Used after a DELETE landed in the local store (this tab's own, or one
|
|
2119
|
+
* relayed from another tab): the SSP may not emit a view update for a
|
|
2120
|
+
* DELETE ingest, and the re-materialize reads the store, which already
|
|
2121
|
+
* excludes the row. Each query is isolated so one failing re-materialize
|
|
2122
|
+
* can't stop the others.
|
|
2123
|
+
*/
|
|
2124
|
+
async notifyTableQueries(tableName: string): Promise<void> {
|
|
2125
|
+
for (const [queryHash, queryState] of this.activeQueries) {
|
|
2126
|
+
if (queryState.config.tableName === tableName) {
|
|
2127
|
+
try {
|
|
2128
|
+
await this.notifyQuerySynced(queryHash);
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
this.logger.error(
|
|
2131
|
+
{ err, queryHash, tableName, Category: 'sp00ky-client::DataModule::notifyTableQueries' },
|
|
2132
|
+
'notifyQuerySynced failed after delete'
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
649
2139
|
/**
|
|
650
2140
|
* Remove a record from all active query states and notify subscribers
|
|
651
2141
|
*/
|
|
@@ -678,7 +2168,9 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
678
2168
|
surqlString: string,
|
|
679
2169
|
params: Record<string, any>,
|
|
680
2170
|
ttl: QueryTimeToLive,
|
|
681
|
-
tableName: T
|
|
2171
|
+
tableName: T,
|
|
2172
|
+
plan?: QueryPlan,
|
|
2173
|
+
membershipKey?: string
|
|
682
2174
|
): Promise<QueryHash> {
|
|
683
2175
|
const queryState = await this.createNewQuery<T>({
|
|
684
2176
|
recordId,
|
|
@@ -686,31 +2178,77 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
686
2178
|
params,
|
|
687
2179
|
ttl,
|
|
688
2180
|
tableName,
|
|
2181
|
+
plan,
|
|
2182
|
+
membershipKey,
|
|
689
2183
|
});
|
|
690
2184
|
|
|
691
|
-
const
|
|
2185
|
+
const t0 = performance.now();
|
|
2186
|
+
const { localArray, registrationTimings } = this.cache.registerQuery({
|
|
692
2187
|
queryHash: hash,
|
|
693
2188
|
surql: surqlString,
|
|
694
2189
|
params,
|
|
695
2190
|
ttl: new Duration(ttl),
|
|
696
2191
|
lastActiveAt: new Date(),
|
|
697
2192
|
});
|
|
2193
|
+
const registrationTime = performance.now() - t0;
|
|
2194
|
+
|
|
2195
|
+
// Record the one-shot SSP registration timings (parse/plan/snapshot from the
|
|
2196
|
+
// WASM binding) + the register_view wall time for DevTools.
|
|
2197
|
+
queryState.registrationTimings = {
|
|
2198
|
+
parseMs: registrationTimings?.parseMs ?? null,
|
|
2199
|
+
planMs: registrationTimings?.planMs ?? null,
|
|
2200
|
+
snapshotMs: registrationTimings?.snapshotMs ?? null,
|
|
2201
|
+
wallMs: registrationTime,
|
|
2202
|
+
};
|
|
698
2203
|
|
|
699
2204
|
await withRetry(this.logger, () =>
|
|
700
|
-
this.local.query(
|
|
701
|
-
id
|
|
702
|
-
|
|
703
|
-
|
|
2205
|
+
this.local.query(
|
|
2206
|
+
surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
|
|
2207
|
+
{
|
|
2208
|
+
id: recordId,
|
|
2209
|
+
localArray,
|
|
2210
|
+
registrationTime,
|
|
2211
|
+
rowCount: localArray.length,
|
|
2212
|
+
}
|
|
2213
|
+
)
|
|
704
2214
|
);
|
|
705
2215
|
|
|
2216
|
+
// Windowed (`START n`) queries skipped the raw initial load in
|
|
2217
|
+
// createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
|
|
2218
|
+
// initial rows now from the SSP's window id-set (`localArray`) via the same
|
|
2219
|
+
// window-materialization path the stream updates use — O(window), and the
|
|
2220
|
+
// ids are already the correct window — so the first paint isn't empty while
|
|
2221
|
+
// the remote `_00_list_ref` syncs in.
|
|
2222
|
+
const windowMat = buildWindowMaterialization(surqlString);
|
|
2223
|
+
if (windowMat && localArray.length > 0) {
|
|
2224
|
+
try {
|
|
2225
|
+
const winIds = localArray.map(([id]) => parseRecordIdString(id));
|
|
2226
|
+
if (plan) {
|
|
2227
|
+
const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
|
|
2228
|
+
queryState.records = await this.local.select(winPlan, params);
|
|
2229
|
+
} else {
|
|
2230
|
+
const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
|
|
2231
|
+
...params,
|
|
2232
|
+
__win: winIds,
|
|
2233
|
+
});
|
|
2234
|
+
queryState.records = seeded || [];
|
|
2235
|
+
}
|
|
2236
|
+
} catch (err) {
|
|
2237
|
+
this.logger.warn(
|
|
2238
|
+
{ err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
|
|
2239
|
+
'Failed to seed windowed initial records from localArray'
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
706
2244
|
this.activeQueries.set(hash, queryState);
|
|
707
|
-
this.startTTLHeartbeat(queryState);
|
|
2245
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
708
2246
|
this.logger.debug(
|
|
709
2247
|
{
|
|
710
2248
|
hash,
|
|
711
2249
|
tableName,
|
|
712
2250
|
recordCount: queryState.records.length,
|
|
713
|
-
Category: '
|
|
2251
|
+
Category: 'sp00ky-client::DataModule::query',
|
|
714
2252
|
},
|
|
715
2253
|
'Query registered'
|
|
716
2254
|
);
|
|
@@ -724,14 +2262,27 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
724
2262
|
params,
|
|
725
2263
|
ttl,
|
|
726
2264
|
tableName,
|
|
2265
|
+
plan,
|
|
2266
|
+
membershipKey,
|
|
727
2267
|
}: {
|
|
728
2268
|
recordId: RecordId;
|
|
729
2269
|
surql: string;
|
|
730
2270
|
params: Record<string, any>;
|
|
731
2271
|
ttl: QueryTimeToLive;
|
|
732
2272
|
tableName: T;
|
|
2273
|
+
plan?: QueryPlan;
|
|
2274
|
+
membershipKey?: string;
|
|
733
2275
|
}): Promise<QueryState> {
|
|
734
|
-
|
|
2276
|
+
// `_00_*` meta tables (feature flags, app releases) are framework-owned:
|
|
2277
|
+
// they exist in the client db schema by construction but are never part of
|
|
2278
|
+
// the app's generated `schema.tables`, so give them an empty column map
|
|
2279
|
+
// instead of the not-found error (which silently broke every meta-table
|
|
2280
|
+
// live query, e.g. feature flags never updating on this path).
|
|
2281
|
+
const tableSchema =
|
|
2282
|
+
this.schema.tables.find((t) => t.name === tableName) ??
|
|
2283
|
+
(String(tableName).startsWith('_00_')
|
|
2284
|
+
? ({ name: tableName, columns: {} } as any)
|
|
2285
|
+
: undefined);
|
|
735
2286
|
if (!tableSchema) {
|
|
736
2287
|
throw new Error(`Table ${tableName} not found`);
|
|
737
2288
|
}
|
|
@@ -752,8 +2303,12 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
752
2303
|
localArray: [],
|
|
753
2304
|
remoteArray: [],
|
|
754
2305
|
lastActiveAt: new Date(),
|
|
2306
|
+
createdAt: new Date(),
|
|
755
2307
|
ttl,
|
|
756
2308
|
tableName,
|
|
2309
|
+
updateCount: 0,
|
|
2310
|
+
rowCount: 0,
|
|
2311
|
+
errorCount: 0,
|
|
757
2312
|
},
|
|
758
2313
|
})
|
|
759
2314
|
);
|
|
@@ -763,62 +2318,171 @@ export class DataModule<S extends SchemaStructure> {
|
|
|
763
2318
|
const config: QueryConfig = {
|
|
764
2319
|
...configRecord,
|
|
765
2320
|
id: recordId,
|
|
766
|
-
|
|
2321
|
+
// In-memory only — carries the engine-neutral plan so non-SurrealQL local
|
|
2322
|
+
// engines materialize via `select(plan)` instead of parsing `surql`.
|
|
2323
|
+
plan,
|
|
2324
|
+
// The CALLER's params, not the ones read back out of the local store: the
|
|
2325
|
+
// store returns a RecordId as a plain `'table:id'` string, and
|
|
2326
|
+
// `parseQueryParams` can only turn it back into a RecordId for a column
|
|
2327
|
+
// the schema marks `recordId`. A union column (`string | record<x>`)
|
|
2328
|
+
// codegens as a plain string, so its param stayed a string and the view
|
|
2329
|
+
// compared a string against a record and matched nothing. The record id is
|
|
2330
|
+
// the same query either way - `recordId` is the hash of surql + vars - so
|
|
2331
|
+
// the in-memory value is the same one, with its types intact.
|
|
2332
|
+
params: parseQueryParams(tableSchema.columns, params ?? configRecord.params),
|
|
2333
|
+
membershipKey,
|
|
767
2334
|
};
|
|
768
2335
|
|
|
2336
|
+
// The `_00_query` row we just read is session-salted, so on a reload it is
|
|
2337
|
+
// always a fresh row with empty arrays. Recover the last authoritative
|
|
2338
|
+
// membership from the durable `_00_window` row instead — this is what stops a
|
|
2339
|
+
// removed row reappearing after a reload, and it works with no network.
|
|
2340
|
+
if (membershipKey && !config.remoteArray?.length) {
|
|
2341
|
+
const durable = await this.getWindowMembership(membershipKey);
|
|
2342
|
+
// An empty durable row is trusted only when it carries the `confirmed`
|
|
2343
|
+
// marker, i.e. the server itself reported the query empty. Without it an
|
|
2344
|
+
// empty row cannot be told apart from one written before this device ever
|
|
2345
|
+
// saw a real id-set (or by the pre-`ea56f50e` client that mirrored
|
|
2346
|
+
// unflushed reads), and treating it as known would paint an empty list
|
|
2347
|
+
// with no scan fallback. A confirmed empty is the opposite case: the
|
|
2348
|
+
// server said "no rows", so a reload must stay empty rather than re-admit
|
|
2349
|
+
// every cached body until the next poll blanks it again.
|
|
2350
|
+
if (durable && (durable.ids.length > 0 || durable.confirmed)) {
|
|
2351
|
+
config.remoteArray = durable.ids;
|
|
2352
|
+
config.membershipKnown = true;
|
|
2353
|
+
}
|
|
2354
|
+
} else if (config.remoteArray?.length) {
|
|
2355
|
+
config.membershipKnown = true;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
769
2358
|
let records: Record<string, any>[] = [];
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
2359
|
+
// Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
|
|
2360
|
+
// `… LIMIT n START m` against the shared local store is O(m) — it sorts and
|
|
2361
|
+
// skips m rows on every window open — AND returns the wrong rows for sparse
|
|
2362
|
+
// windows (the reason `buildWindowMaterialization` exists). Those windows are
|
|
2363
|
+
// seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
|
|
2364
|
+
//
|
|
2365
|
+
// With known membership the same reasoning applies to EVERY query, windowed
|
|
2366
|
+
// or not: the predicate scan below would re-admit rows the server has already
|
|
2367
|
+
// dropped from the window (their bodies are still cached and still match the
|
|
2368
|
+
// WHERE). Seed from membership instead.
|
|
2369
|
+
if (config.membershipKnown) {
|
|
2370
|
+
try {
|
|
2371
|
+
records = await this.materializeFromConfig(config);
|
|
2372
|
+
} catch (err) {
|
|
2373
|
+
this.logger.warn(
|
|
2374
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
2375
|
+
'Failed to seed records from durable membership'
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
} else if (buildWindowMaterialization(surqlString) === null) {
|
|
2379
|
+
try {
|
|
2380
|
+
// Prefer the engine-neutral plan (required for non-SurrealQL engines);
|
|
2381
|
+
// fall back to running the raw surql on the SurrealDB engine.
|
|
2382
|
+
const result = plan
|
|
2383
|
+
? await this.local.select(plan, params)
|
|
2384
|
+
: (await this.local.query<[Record<string, any>[]]>(surqlString, params))[0];
|
|
2385
|
+
records = result || [];
|
|
2386
|
+
} catch (err) {
|
|
2387
|
+
this.logger.warn(
|
|
2388
|
+
{ err, Category: 'sp00ky-client::DataModule::createNewQuery' },
|
|
2389
|
+
'Failed to load initial cached records'
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
778
2392
|
}
|
|
779
2393
|
|
|
2394
|
+
// Persisted counters survive a restart even though the rolling
|
|
2395
|
+
// sample window is rebuilt from scratch in memory.
|
|
2396
|
+
const persistedUpdateCount =
|
|
2397
|
+
typeof (configRecord as any)?.updateCount === 'number'
|
|
2398
|
+
? (configRecord as any).updateCount
|
|
2399
|
+
: 0;
|
|
2400
|
+
const persistedErrorCount =
|
|
2401
|
+
typeof (configRecord as any)?.errorCount === 'number' ? (configRecord as any).errorCount : 0;
|
|
2402
|
+
|
|
780
2403
|
return {
|
|
781
2404
|
config,
|
|
782
2405
|
records,
|
|
783
2406
|
ttlTimer: null,
|
|
784
2407
|
ttlDurationMs: parseDuration(ttl),
|
|
785
|
-
updateCount:
|
|
2408
|
+
updateCount: persistedUpdateCount,
|
|
2409
|
+
lastUpdatedAt: null,
|
|
2410
|
+
materializationSamples: [],
|
|
2411
|
+
lastIngestLatencyMs: null,
|
|
2412
|
+
errorCount: persistedErrorCount,
|
|
2413
|
+
// Born `fetching`, not `idle`: every cold registration is followed by a
|
|
2414
|
+
// `register` down-event whose lifecycle (Sp00kySync.registerQuery) resolves
|
|
2415
|
+
// the status to `idle` once the initial sync completed. Starting idle left
|
|
2416
|
+
// a gap where a fresh windowed query looked settled while still empty.
|
|
2417
|
+
status: 'fetching',
|
|
2418
|
+
phaseSamples: {},
|
|
2419
|
+
phaseLast: {},
|
|
2420
|
+
registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
|
|
786
2421
|
};
|
|
787
2422
|
}
|
|
788
2423
|
|
|
789
2424
|
private async calculateHash(data: any): Promise<string> {
|
|
790
|
-
|
|
2425
|
+
// sessionId is part of the hash so the same logical query from two
|
|
2426
|
+
// sessions (e.g. two browser tabs of the same user) lands on different
|
|
2427
|
+
// `_00_query` rows and doesn't fight over a shared one.
|
|
2428
|
+
return this.sha256(JSON.stringify({ ...data, sessionId: this.sessionId }));
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/**
|
|
2432
|
+
* Session-independent counterpart of {@link calculateHash}: the key for a
|
|
2433
|
+
* query's durable `_00_window` membership row.
|
|
2434
|
+
*
|
|
2435
|
+
* Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
|
|
2436
|
+
* can never drift apart. The salt is right for `_00_query` (two tabs must not
|
|
2437
|
+
* fight over one row) and wrong for membership, which has to be recognizable
|
|
2438
|
+
* after a reload — a reload mints a new session id, and offline the salt is
|
|
2439
|
+
* `''`, so a salted key can never match what the previous session wrote.
|
|
2440
|
+
*/
|
|
2441
|
+
private async calculateMembershipKey(data: any): Promise<string> {
|
|
2442
|
+
return this.sha256(JSON.stringify(data));
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
private async sha256(content: string): Promise<string> {
|
|
791
2446
|
const msgBuffer = new TextEncoder().encode(content);
|
|
792
2447
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
793
2448
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
794
2449
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
795
2450
|
}
|
|
796
2451
|
|
|
797
|
-
private startTTLHeartbeat(queryState: QueryState): void {
|
|
2452
|
+
private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
|
|
798
2453
|
if (queryState.ttlTimer) return;
|
|
799
2454
|
|
|
800
2455
|
const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
|
|
801
2456
|
|
|
802
2457
|
queryState.ttlTimer = setTimeout(() => {
|
|
803
|
-
|
|
2458
|
+
queryState.ttlTimer = null;
|
|
2459
|
+
// Only keep the remote query alive while something is actually watching
|
|
2460
|
+
// it. With the server now sweeping ALL expired views (not just in-circuit
|
|
2461
|
+
// ones), an un-refreshed query WOULD be swept after its TTL — so a live
|
|
2462
|
+
// subscriber must heartbeat. An abandoned query (no subscribers) is left
|
|
2463
|
+
// to expire and get swept; its local view was already torn down at the
|
|
2464
|
+
// last unsubscribe, so we just stop the timer here.
|
|
2465
|
+
const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
|
|
2466
|
+
if (subscriberCount === 0) {
|
|
2467
|
+
this.logger.debug(
|
|
2468
|
+
{ hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
|
|
2469
|
+
'TTL heartbeat: no subscribers, stopping'
|
|
2470
|
+
);
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
this.onHeartbeat?.(hash);
|
|
804
2474
|
this.logger.debug(
|
|
805
2475
|
{
|
|
2476
|
+
hash,
|
|
806
2477
|
id: encodeRecordId(queryState.config.id),
|
|
807
|
-
Category: '
|
|
2478
|
+
Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
|
|
808
2479
|
},
|
|
809
|
-
'TTL heartbeat'
|
|
2480
|
+
'TTL heartbeat sent'
|
|
810
2481
|
);
|
|
811
|
-
this.startTTLHeartbeat(queryState);
|
|
2482
|
+
this.startTTLHeartbeat(queryState, hash);
|
|
812
2483
|
}, heartbeatTime);
|
|
813
2484
|
}
|
|
814
2485
|
|
|
815
|
-
private stopTTLHeartbeat(queryState: QueryState): void {
|
|
816
|
-
if (queryState.ttlTimer) {
|
|
817
|
-
clearTimeout(queryState.ttlTimer);
|
|
818
|
-
queryState.ttlTimer = null;
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
|
|
822
2486
|
private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
|
|
823
2487
|
for (const [queryHash, queryState] of this.activeQueries.entries()) {
|
|
824
2488
|
const index = queryState.records.findIndex((r) => r.id === record.id);
|
|
@@ -851,7 +2515,7 @@ export function parseUpdateOptions(
|
|
|
851
2515
|
const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
|
|
852
2516
|
const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
|
|
853
2517
|
const key =
|
|
854
|
-
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).
|
|
2518
|
+
keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
|
|
855
2519
|
|
|
856
2520
|
pushEventOptions = {
|
|
857
2521
|
debounced: {
|