@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.242
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 +34 -15
- package/README.md +56 -16
- package/dist/index.d.ts +1136 -1846
- package/dist/index.js +10168 -11267
- package/dist/sqlite-open.js +10 -1
- package/dist/types.d.ts +22 -79
- package/package.json +8 -4
- package/skills/sp00ky-core/SKILL.md +18 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +151 -0
- package/src/boot/boot.saga.ts +128 -0
- package/src/boot/bucket-switch.saga.test.ts +158 -0
- package/src/boot/bucket-switch.saga.ts +115 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +56 -0
- package/src/client/router.ts +94 -0
- package/src/client/runtime.test.ts +192 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +349 -0
- package/src/client/sp00ky-client.test.ts +241 -0
- package/src/client/sp00ky-client.ts +482 -0
- package/src/index.ts +1 -0
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +54 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +159 -0
- package/src/kernel/events.ts +55 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +143 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/modules/app-release/index.ts +13 -5
- package/src/modules/devtools/index.ts +10 -3
- package/src/modules/devtools/state-shape.test.ts +4 -1
- package/src/modules/feature-flag/index.ts +13 -5
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +215 -0
- package/src/mutation/rows.test.ts +201 -0
- package/src/mutation/rows.ts +377 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +163 -0
- package/src/mutation/write.saga.ts +166 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +156 -0
- package/src/query/fetch.saga.ts +109 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
- package/src/query/lifecycle.saga.test.ts +122 -0
- package/src/query/lifecycle.saga.ts +101 -0
- package/src/query/materialize.saga.test.ts +82 -0
- package/src/query/materialize.saga.ts +75 -0
- package/src/query/materialize.test.ts +50 -0
- package/src/query/materialize.ts +44 -0
- package/src/query/membership.saga.test.ts +246 -0
- package/src/query/membership.saga.ts +208 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +158 -0
- package/src/query/register.saga.test.ts +251 -0
- package/src/query/register.saga.ts +190 -0
- package/src/query/render-set.test.ts +36 -0
- package/src/query/render-set.ts +60 -0
- package/src/query/sql.test.ts +62 -0
- package/src/query/sql.ts +79 -0
- package/src/{modules/data → query}/window-query.test.ts +18 -0
- package/src/{modules/data → query}/window-query.ts +2 -1
- package/src/services/database/connection-supervisor.test.ts +29 -0
- package/src/services/database/connection-supervisor.ts +7 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +25 -10
- package/src/services/database/local-migrator.ts +2 -2
- package/src/services/database/plan-render.test.ts +1 -1
- package/src/services/database/sqlite-cache-engine.test.ts +4 -3
- package/src/services/database/sqlite-cache-engine.ts +2 -11
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
- package/src/services/database/sqlite-plan-sql.ts +16 -1
- package/src/services/database/sqlite-projection.test.ts +25 -3
- package/src/services/tabs/protocol.ts +7 -3
- package/src/sp00ky.ts +5 -1854
- package/src/state/client-state.ts +184 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +280 -0
- package/src/state/reducers.ts +476 -0
- package/src/state/selectors.test.ts +149 -0
- package/src/state/selectors.ts +184 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +69 -0
- package/src/sync/live.saga.ts +49 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
- package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +74 -0
- package/src/sync/tabs.saga.ts +92 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +61 -0
- package/src/testing/fake-services.ts +34 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +8 -7
- package/src/utils/utils.test.ts +2 -1
- package/vitest.config.ts +48 -0
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -476
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
- package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
- package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
- package/coverage/lcov-report/src/boot/index.html +0 -161
- package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
- package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
- package/coverage/lcov-report/src/client/index.html +0 -161
- package/coverage/lcov-report/src/client/router.ts.html +0 -367
- package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
- package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
- package/coverage/lcov-report/src/events/index.html +0 -116
- package/coverage/lcov-report/src/events/index.ts.html +0 -877
- package/coverage/lcov-report/src/index.html +0 -146
- package/coverage/lcov-report/src/index.ts.html +0 -202
- package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
- package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
- package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
- package/coverage/lcov-report/src/kernel/index.html +0 -176
- package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
- package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
- package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
- package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
- package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
- package/coverage/lcov-report/src/modules/auth/index.html +0 -116
- package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
- package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
- package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
- package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
- package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
- package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
- package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
- package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
- package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
- package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
- package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
- package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
- package/coverage/lcov-report/src/modules/index.html +0 -116
- package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
- package/coverage/lcov-report/src/mutation/index.html +0 -191
- package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
- package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
- package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
- package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
- package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
- package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
- package/coverage/lcov-report/src/otel/index.html +0 -116
- package/coverage/lcov-report/src/otel/index.ts.html +0 -466
- package/coverage/lcov-report/src/query/env.ts.html +0 -253
- package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
- package/coverage/lcov-report/src/query/hash.ts.html +0 -139
- package/coverage/lcov-report/src/query/index.html +0 -281
- package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
- package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
- package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
- package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
- package/coverage/lcov-report/src/query/membership.ts.html +0 -559
- package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
- package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
- package/coverage/lcov-report/src/query/sql.ts.html +0 -322
- package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
- package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
- package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
- package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
- package/coverage/lcov-report/src/services/blobs/index.html +0 -161
- package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
- package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
- package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
- package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
- package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
- package/coverage/lcov-report/src/services/database/events/index.html +0 -116
- package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
- package/coverage/lcov-report/src/services/database/index.html +0 -386
- package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
- package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
- package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
- package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
- package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
- package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
- package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
- package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
- package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
- package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
- package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
- package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
- package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
- package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
- package/coverage/lcov-report/src/services/logger/index.html +0 -116
- package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
- package/coverage/lcov-report/src/services/persistence/index.html +0 -146
- package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
- package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
- package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
- package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
- package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
- package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
- package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
- package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
- package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
- package/coverage/lcov-report/src/services/tabs/index.html +0 -191
- package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
- package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
- package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
- package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
- package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
- package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
- package/coverage/lcov-report/src/state/index.html +0 -161
- package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
- package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
- package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
- package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
- package/coverage/lcov-report/src/sync/index.html +0 -176
- package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
- package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
- package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
- package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
- package/coverage/lcov-report/src/types.ts.html +0 -2419
- package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
- package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
- package/coverage/lcov-report/src/utils/index.html +0 -191
- package/coverage/lcov-report/src/utils/index.ts.html +0 -820
- package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
- package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
- package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
- package/coverage/lcov.info +0 -19079
- package/src/modules/cache/cache.relay.test.ts +0 -95
- package/src/modules/cache/index.ts +0 -361
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.activity.test.ts +0 -109
- package/src/modules/data/data.authority.test.ts +0 -122
- package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
- package/src/modules/data/data.hydration.test.ts +0 -142
- package/src/modules/data/data.membership.test.ts +0 -623
- package/src/modules/data/data.notify-table.test.ts +0 -41
- package/src/modules/data/data.pending-ids.test.ts +0 -199
- package/src/modules/data/data.rebind.test.ts +0 -170
- package/src/modules/data/data.rematerialize.test.ts +0 -114
- package/src/modules/data/data.run.test.ts +0 -113
- package/src/modules/data/data.settled-writes.test.ts +0 -235
- package/src/modules/data/data.status.test.ts +0 -249
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -2745
- package/src/modules/sync/engine.ts +0 -250
- package/src/modules/sync/events/index.ts +0 -84
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.test.ts +0 -228
- package/src/modules/sync/queue/queue-down.ts +0 -184
- package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
- package/src/modules/sync/queue/queue-up.ts +0 -407
- package/src/modules/sync/scheduler.pause.test.ts +0 -109
- package/src/modules/sync/scheduler.retry.test.ts +0 -237
- package/src/modules/sync/scheduler.ts +0 -286
- package/src/modules/sync/sync.cleanup.test.ts +0 -116
- package/src/modules/sync/sync.health.test.ts +0 -167
- package/src/modules/sync/sync.heartbeat.test.ts +0 -80
- package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
- package/src/modules/sync/sync.live-removal.test.ts +0 -289
- package/src/modules/sync/sync.reconnect.test.ts +0 -145
- package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
- package/src/modules/sync/sync.settled-poll.test.ts +0 -58
- package/src/modules/sync/sync.subquery.test.ts +0 -82
- package/src/modules/sync/sync.tabs.test.ts +0 -249
- package/src/modules/sync/sync.ts +0 -2626
- package/src/sp00ky.auth-order.test.ts +0 -112
- package/src/sp00ky.init-query.test.ts +0 -183
- package/src/sp00ky.local-first.test.ts +0 -60
- /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
package/dist/sqlite-open.js
CHANGED
|
@@ -205,6 +205,15 @@ function reviveRow(json) {
|
|
|
205
205
|
* than as text. `COALESCE` covers a row sharing none of the requested keys,
|
|
206
206
|
* where the subquery yields NULL and `reviveRow` would throw.
|
|
207
207
|
*
|
|
208
|
+
* The one value `je.value` does NOT carry back is a TOP-LEVEL boolean. SQLite
|
|
209
|
+
* has no boolean type, so `json_each` hands `true`/`false` out as the integers
|
|
210
|
+
* 1/0 and `json_group_object` writes integers back. A projected row therefore
|
|
211
|
+
* read `published: 1` where the same row read whole read `published: true`,
|
|
212
|
+
* and every `=== true` in an app turned false the moment a screen painted from
|
|
213
|
+
* the local cache. (A boolean NESTED in an object or array survives: those come
|
|
214
|
+
* back as whole JSON documents, untouched.) `je.type` still knows what it was,
|
|
215
|
+
* so the two boolean types are rebuilt as JSON literals on the way out.
|
|
216
|
+
*
|
|
208
217
|
* Deliberately unaliased, so the emitted statement keeps the exact shape the
|
|
209
218
|
* callers already produce (`FROM "t" WHERE id IN (…)`).
|
|
210
219
|
*
|
|
@@ -214,7 +223,7 @@ function reviveRow(json) {
|
|
|
214
223
|
function projectedDataSql(fields, bind) {
|
|
215
224
|
const keys = ["id", ...fields];
|
|
216
225
|
for (const k of keys) bind.push(k);
|
|
217
|
-
return `COALESCE((SELECT json_group_object(je.key, je.value) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
|
|
226
|
+
return `COALESCE((SELECT json_group_object(je.key, CASE je.type WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') ELSE je.value END) FROM json_each(data) je WHERE je.key IN (${keys.map(() => "?").join(", ")})), '{}') AS data`;
|
|
218
227
|
}
|
|
219
228
|
function project(row, fields) {
|
|
220
229
|
const out = {};
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { RecordId } from "
|
|
2
|
-
import {
|
|
3
|
-
import { Level, Level as Level$1, Logger, LoggerOptions } from "pino";
|
|
1
|
+
import { QueryPlan, QueryPlan as QueryPlan$1, RecordId, SchemaStructure, WhereNode } from "@spooky-sync/query-builder";
|
|
2
|
+
import { Level as Level$1, LoggerOptions } from "pino";
|
|
4
3
|
|
|
5
4
|
//#region src/events/index.d.ts
|
|
6
5
|
/**
|
|
@@ -123,36 +122,11 @@ declare class EventSystem<E extends EventTypeMap> {
|
|
|
123
122
|
private broadcastEvent;
|
|
124
123
|
}
|
|
125
124
|
//#endregion
|
|
126
|
-
//#region src/
|
|
127
|
-
|
|
128
|
-
readonly
|
|
129
|
-
readonly
|
|
130
|
-
|
|
131
|
-
readonly SyncHealthChanged: "SYNC_HEALTH_CHANGED";
|
|
132
|
-
};
|
|
133
|
-
type SyncEventTypeMap = {
|
|
134
|
-
[SyncEventTypes.QueryUpdated]: EventDefinition<typeof SyncEventTypes.QueryUpdated, {
|
|
135
|
-
queryId: any;
|
|
136
|
-
localHash?: string;
|
|
137
|
-
localArray?: RecordVersionArray;
|
|
138
|
-
remoteHash?: string;
|
|
139
|
-
remoteArray?: RecordVersionArray;
|
|
140
|
-
records: Record<string, any>[];
|
|
141
|
-
}>;
|
|
142
|
-
[SyncEventTypes.RemoteDataIngested]: EventDefinition<typeof SyncEventTypes.RemoteDataIngested, {
|
|
143
|
-
records: Record<string, any>[];
|
|
144
|
-
}>;
|
|
145
|
-
[SyncEventTypes.MutationRolledBack]: EventDefinition<typeof SyncEventTypes.MutationRolledBack, {
|
|
146
|
-
eventType: string;
|
|
147
|
-
recordId: string;
|
|
148
|
-
error: string;
|
|
149
|
-
}>;
|
|
150
|
-
[SyncEventTypes.SyncHealthChanged]: EventDefinition<typeof SyncEventTypes.SyncHealthChanged, SyncHealth>;
|
|
151
|
-
};
|
|
152
|
-
type SyncEventSystem = EventSystem<SyncEventTypeMap>;
|
|
153
|
-
//#endregion
|
|
154
|
-
//#region src/services/logger/index.d.ts
|
|
155
|
-
type Logger$1 = Logger;
|
|
125
|
+
//#region src/utils/surql.d.ts
|
|
126
|
+
interface SealedQuery<T = void> {
|
|
127
|
+
readonly sql: string;
|
|
128
|
+
readonly extract: (results: unknown[]) => T;
|
|
129
|
+
}
|
|
156
130
|
//#endregion
|
|
157
131
|
//#region src/services/database/events/index.d.ts
|
|
158
132
|
declare const DatabaseEventTypes: {
|
|
@@ -173,12 +147,6 @@ type DatabaseEventTypeMap = {
|
|
|
173
147
|
};
|
|
174
148
|
type DatabaseEventSystem = EventSystem<DatabaseEventTypeMap>;
|
|
175
149
|
//#endregion
|
|
176
|
-
//#region src/utils/surql.d.ts
|
|
177
|
-
interface SealedQuery<T = void> {
|
|
178
|
-
readonly sql: string;
|
|
179
|
-
readonly extract: (results: unknown[]) => T;
|
|
180
|
-
}
|
|
181
|
-
//#endregion
|
|
182
150
|
//#region src/modules/devtools/storage-info.d.ts
|
|
183
151
|
/** Engine-side numbers only the engine can produce (worker round-trips). */
|
|
184
152
|
interface EngineStorageDiagnostics {
|
|
@@ -371,33 +339,6 @@ type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
|
371
339
|
/** Thrown when relation decomposition nests past {@link MAX_RELATION_DEPTH} —
|
|
372
340
|
* a guard against a cyclic schema producing unbounded fan-out. */
|
|
373
341
|
//#endregion
|
|
374
|
-
//#region src/modules/sync/queue/queue-up.d.ts
|
|
375
|
-
type CreateEvent = {
|
|
376
|
-
type: 'create';
|
|
377
|
-
mutation_id: RecordId;
|
|
378
|
-
record_id: RecordId;
|
|
379
|
-
data: Record<string, unknown>;
|
|
380
|
-
record?: Record<string, unknown>;
|
|
381
|
-
tableName?: string;
|
|
382
|
-
options?: PushEventOptions;
|
|
383
|
-
};
|
|
384
|
-
type UpdateEvent = {
|
|
385
|
-
type: 'update';
|
|
386
|
-
mutation_id: RecordId;
|
|
387
|
-
record_id: RecordId;
|
|
388
|
-
data: Record<string, unknown>;
|
|
389
|
-
record?: Record<string, unknown>;
|
|
390
|
-
beforeRecord?: Record<string, unknown>;
|
|
391
|
-
options?: PushEventOptions;
|
|
392
|
-
};
|
|
393
|
-
type DeleteEvent = {
|
|
394
|
-
type: 'delete';
|
|
395
|
-
mutation_id: RecordId;
|
|
396
|
-
record_id: RecordId;
|
|
397
|
-
options?: PushEventOptions;
|
|
398
|
-
};
|
|
399
|
-
type UpEvent = CreateEvent | UpdateEvent | DeleteEvent;
|
|
400
|
-
//#endregion
|
|
401
342
|
//#region src/types.d.ts
|
|
402
343
|
/**
|
|
403
344
|
* A pino browser transmit object for forwarding logs to an external sink (e.g. OpenTelemetry).
|
|
@@ -448,9 +389,11 @@ type QueryTimeToLive = '1m' | '5m' | '10m' | '15m' | '20m' | '25m' | '30m' | '1h
|
|
|
448
389
|
*/
|
|
449
390
|
type PreloadRefresh = 'onUse' | 'background' | 'stale';
|
|
450
391
|
interface PreloadOptions {
|
|
451
|
-
/**
|
|
392
|
+
/** Abort a cold preload that is still waiting for the server. */
|
|
393
|
+
signal?: AbortSignal;
|
|
394
|
+
/** @deprecated Preload is a registered query now; a warm preload never refetches by itself. Ignored. */
|
|
452
395
|
refresh?: PreloadRefresh;
|
|
453
|
-
/**
|
|
396
|
+
/** @deprecated Ignored. */
|
|
454
397
|
staleTime?: QueryTimeToLive;
|
|
455
398
|
}
|
|
456
399
|
/**
|
|
@@ -834,16 +777,16 @@ type RecordVersionArray = Array<[string, number]>;
|
|
|
834
777
|
interface RecordVersionDiff {
|
|
835
778
|
/** List of records added. */
|
|
836
779
|
added: Array<{
|
|
837
|
-
id: RecordId
|
|
780
|
+
id: RecordId<string>;
|
|
838
781
|
version: number;
|
|
839
782
|
}>;
|
|
840
783
|
/** List of records updated. */
|
|
841
784
|
updated: Array<{
|
|
842
|
-
id: RecordId
|
|
785
|
+
id: RecordId<string>;
|
|
843
786
|
version: number;
|
|
844
787
|
}>;
|
|
845
788
|
/** List of record IDs removed. */
|
|
846
|
-
removed: RecordId
|
|
789
|
+
removed: RecordId<string>[];
|
|
847
790
|
}
|
|
848
791
|
/**
|
|
849
792
|
* Configuration for a specific query instance.
|
|
@@ -851,7 +794,7 @@ interface RecordVersionDiff {
|
|
|
851
794
|
*/
|
|
852
795
|
interface QueryConfig {
|
|
853
796
|
/** The unique ID of the query config record. */
|
|
854
|
-
id: RecordId
|
|
797
|
+
id: RecordId<string>;
|
|
855
798
|
/** The SURQL query string. */
|
|
856
799
|
surql: string;
|
|
857
800
|
/**
|
|
@@ -878,21 +821,21 @@ interface QueryConfig {
|
|
|
878
821
|
/**
|
|
879
822
|
* Whether authoritative membership (`remoteArray`) has ever been established
|
|
880
823
|
* for this query — either fetched from `_00_list_ref` this session, or read
|
|
881
|
-
* back from the durable `
|
|
824
|
+
* back from the durable `_00_view` row on a cold start.
|
|
882
825
|
*
|
|
883
826
|
* Tri-state matters: "known and empty" must render an empty list, while
|
|
884
827
|
* "never established" has to fall back to a predicate scan of the local store
|
|
885
828
|
* so a query first run on this device still paints offline. A
|
|
886
829
|
* `remoteArray.length === 0` check cannot tell those apart.
|
|
887
830
|
*
|
|
888
|
-
* On a cold start it is seeded from the durable `
|
|
831
|
+
* On a cold start it is seeded from the durable `_00_view` row when that
|
|
889
832
|
* row is non-empty, or empty but `confirmed` (the server reported zero rows
|
|
890
833
|
* for the query). An unconfirmed empty row is ignored, so a device poisoned
|
|
891
834
|
* by an old client that mirrored unflushed reads still self-heals.
|
|
892
835
|
*/
|
|
893
836
|
membershipKnown?: boolean;
|
|
894
837
|
/**
|
|
895
|
-
* Key of this query's durable `
|
|
838
|
+
* Key of this query's durable `_00_view` membership row: a hash of
|
|
896
839
|
* `{surql, params}` WITHOUT the `session::id()` salt that `id` carries, so it
|
|
897
840
|
* survives a reload (which mints a new session id) and a bucket switch.
|
|
898
841
|
* In-memory only.
|
|
@@ -1054,7 +997,7 @@ type QueryStatusCallback = (status: QueryStatus) => void;
|
|
|
1054
997
|
* (bucket switch). What the bindings' `isAuthoritative()` mirrors.
|
|
1055
998
|
*/
|
|
1056
999
|
type QueryAuthorityCallback = (known: boolean) => void;
|
|
1057
|
-
type MutationCallback = (mutations:
|
|
1000
|
+
type MutationCallback = (mutations: MutationEvent[]) => void;
|
|
1058
1001
|
type MutationEventType = 'create' | 'update' | 'delete';
|
|
1059
1002
|
/**
|
|
1060
1003
|
* Represents a mutation event (create, update, delete) to be synchronized.
|
|
@@ -1063,9 +1006,9 @@ interface MutationEvent {
|
|
|
1063
1006
|
/** Example: 'create', 'update', or 'delete'. */
|
|
1064
1007
|
type: MutationEventType;
|
|
1065
1008
|
/** unique id of the mutation */
|
|
1066
|
-
mutation_id: RecordId
|
|
1009
|
+
mutation_id: RecordId<string>;
|
|
1067
1010
|
/** The ID of the record being mutated. */
|
|
1068
|
-
record_id: RecordId
|
|
1011
|
+
record_id: RecordId<string>;
|
|
1069
1012
|
/** The data payload for create/update operations. */
|
|
1070
1013
|
data?: any;
|
|
1071
1014
|
/** The full record data (optional context). */
|
|
@@ -1114,4 +1057,4 @@ interface DebounceOptions {
|
|
|
1114
1057
|
delay?: number;
|
|
1115
1058
|
}
|
|
1116
1059
|
//#endregion
|
|
1117
|
-
export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F,
|
|
1060
|
+
export { ServerViewMeta as A, TimingPhase as B, QueryTimings as C, RecordVersionDiff as D, RecordVersionArray as E, StorageHealthStatus as F, SealedQuery as G, LocalStore as H, StoreType as I, EventDefinition as K, SyncHealth as L, Sp00kyQueryResult as M, Sp00kyQueryResultPromise as N, RegistrationTimings as O, StorageHealth as P, SyncHealthConfig as R, QueryTimeToLive as S, ReconnectConfig as T, DatabaseEventSystem as U, UpdateOptions as V, DatabaseEventTypes as W, QueryConfigRecord as _, MATERIALIZATION_SAMPLE_WINDOW as a, QueryStatus as b, MutationEvent as c, PhaseStat as d, PinoTransmit as f, QueryConfig as g, QueryAuthorityCallback as h, Level$1 as i, Sp00kyConfig as j, RunOptions as k, MutationEventType as l, PreloadRefresh as m, DebounceOptions as n, MembershipOutcome as o, PreloadOptions as p, EventSystem as q, EventSubscriptionOptions as r, MutationCallback as s, ConnectionState as t, PersistenceClient as u, QueryHash as v, QueryUpdateCallback as w, QueryStatusCallback as x, QueryState as y, SyncHealthStatus as z };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.242",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"dev": "tsdown --watch",
|
|
20
20
|
"dev:example": "pnpm dev",
|
|
21
21
|
"build": "tsdown && node scripts/check-broker-bundle.mjs",
|
|
22
|
-
"test": "vitest"
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"test:run": "vitest run",
|
|
24
|
+
"test:coverage": "vitest run --coverage",
|
|
25
|
+
"type-check": "tsc --noEmit"
|
|
23
26
|
},
|
|
24
27
|
"repository": {
|
|
25
28
|
"type": "git",
|
|
@@ -60,8 +63,8 @@
|
|
|
60
63
|
}
|
|
61
64
|
},
|
|
62
65
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
66
|
+
"@spooky-sync/query-builder": "0.0.1-canary.242",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.242",
|
|
65
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
70
|
"blurhash": "^2.0.5",
|
|
@@ -72,6 +75,7 @@
|
|
|
72
75
|
},
|
|
73
76
|
"devDependencies": {
|
|
74
77
|
"@types/node": "^22.5.2",
|
|
78
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
75
79
|
"tsdown": "^0.12.4",
|
|
76
80
|
"typescript": "^5.6.2",
|
|
77
81
|
"vitest": "^3.2.0"
|
|
@@ -82,6 +82,15 @@ unsubscribe();
|
|
|
82
82
|
|
|
83
83
|
Queries have a time-to-live. Supported values: `'1m'`, `'5m'`, `'10m'` (default), `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`–`'12h'`, `'1d'`.
|
|
84
84
|
|
|
85
|
+
### Preloading
|
|
86
|
+
|
|
87
|
+
`db.preload(query, { signal? })` registers a query without subscribing to it. If
|
|
88
|
+
the server answered this query on this device before (a durable `_00_view` row
|
|
89
|
+
exists) it resolves at once and the rows paint from the local store; otherwise
|
|
90
|
+
it resolves once the server's membership and every record are local. Nothing
|
|
91
|
+
to tear down: the entry is evicted a ttl after registration unless a view mounts
|
|
92
|
+
the same query. `refresh` / `staleTime` are accepted but ignored.
|
|
93
|
+
|
|
85
94
|
## Mutations
|
|
86
95
|
|
|
87
96
|
Mutations are optimistic and automatically synced to the remote SurrealDB instance.
|
|
@@ -112,6 +121,15 @@ Use `debounced` option for frequent updates (e.g., live typing):
|
|
|
112
121
|
- `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
|
|
113
122
|
- `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
|
|
114
123
|
|
|
124
|
+
### Failed writes
|
|
125
|
+
|
|
126
|
+
A push the server rejects (permission, validation) is rolled back locally
|
|
127
|
+
(create, update and delete) and moved to the `_00_failed_mutations` tray in the
|
|
128
|
+
same local transaction. Network errors never do this; they retry. Inspect and
|
|
129
|
+
act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb)`,
|
|
130
|
+
`db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
|
|
131
|
+
write) and `db.discardFailedMutation(id)`.
|
|
132
|
+
|
|
115
133
|
## Authentication
|
|
116
134
|
|
|
117
135
|
See [references/auth.md](references/auth.md) for the full auth API.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { fakeServices } from '../testing/services';
|
|
5
|
+
import { buildEntry, buildState } from '../testing/build';
|
|
6
|
+
import * as R from '../state/reducers';
|
|
7
|
+
import { defaultEnv } from '../query/env';
|
|
8
|
+
import { authFlip, persistVerifiedUser } from './auth-flip.saga';
|
|
9
|
+
|
|
10
|
+
const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
|
|
11
|
+
|
|
12
|
+
describe('authFlip', () => {
|
|
13
|
+
it('sets identity and $auth before the bucket work, writes the hint, switches, rotates the salt for a new principal, persists the user row', async () => {
|
|
14
|
+
const released: string[] = [];
|
|
15
|
+
const svc = fakeServices({
|
|
16
|
+
'auth.sessionAuthId': () => 'user:abc',
|
|
17
|
+
'auth.access': () => 'account',
|
|
18
|
+
'local.currentBucketId': () => 'anon',
|
|
19
|
+
'local.beginSwitch': () => () => void released.push('gate'),
|
|
20
|
+
'local.usesSurqlSchema': () => false,
|
|
21
|
+
'auth.token': () => 'jwt',
|
|
22
|
+
'auth.currentUser': () => ({ id: new RecordId('user', 'abc'), email_verified: true }),
|
|
23
|
+
});
|
|
24
|
+
const state = { ...buildState(), sessionId: 'old', saltUserId: null };
|
|
25
|
+
const out = await runPure(authFlip(env, 'user:abc'), {
|
|
26
|
+
state,
|
|
27
|
+
handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
28
|
+
});
|
|
29
|
+
const names = svc.names();
|
|
30
|
+
expect(names.slice(0, 5)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'hint.write', 'local.currentBucketId']);
|
|
31
|
+
expect(svc.calls[3]).toEqual(['hint.write', ['abc']]);
|
|
32
|
+
expect(names).toContain('local.switchStore');
|
|
33
|
+
expect(names).toContain('crdt.setSessionId');
|
|
34
|
+
expect(released).toEqual(['gate']);
|
|
35
|
+
expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
|
|
36
|
+
expect(out.state.versions.get('user:abc')).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
it('same principal on the same bucket: no switch, no salt rotation', async () => {
|
|
39
|
+
const svc = fakeServices({
|
|
40
|
+
'auth.sessionAuthId': () => 'user:abc',
|
|
41
|
+
'auth.access': () => 'account',
|
|
42
|
+
'local.currentBucketId': () => 'abc',
|
|
43
|
+
'auth.currentUser': () => null,
|
|
44
|
+
});
|
|
45
|
+
const state = { ...buildState(), sessionId: 'keep', saltUserId: 'user:abc', bucketId: 'abc' };
|
|
46
|
+
const out = await runPure(authFlip(env, 'user:abc'), { state, handlers: { service: svc.handler } });
|
|
47
|
+
expect(svc.names()).not.toContain('local.switchStore');
|
|
48
|
+
expect(svc.names()).not.toContain('local.beginSwitch');
|
|
49
|
+
expect(out.state.sessionId).toBe('keep');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('persistVerifiedUser', () => {
|
|
54
|
+
it('skips when there is no verified row, an unknown table, or a bare id; writes otherwise and logs failures', async () => {
|
|
55
|
+
for (const row of [null, { id: 'user:x', a: 1 }, { id: new RecordId('user', 'x') }, { id: new RecordId('nope', 'x'), a: 1 }]) {
|
|
56
|
+
const svc = fakeServices({ 'auth.currentUser': () => row as any });
|
|
57
|
+
const out = await runPure(persistVerifiedUser(env), { state: buildState(), handlers: { service: svc.handler } });
|
|
58
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
|
|
59
|
+
}
|
|
60
|
+
const svc = fakeServices({ 'auth.currentUser': () => ({ id: new RecordId('user', 'x'), email_verified: true, junk: 1 }) });
|
|
61
|
+
const s = buildState([buildEntry({ def: { hash: 'q', tableName: 'user' } })], R.setVersions([['user:x', 4]]));
|
|
62
|
+
const out = await runPure(persistVerifiedUser(env), {
|
|
63
|
+
state: s,
|
|
64
|
+
handlers: {
|
|
65
|
+
service: svc.handler,
|
|
66
|
+
'local.execute': (e: any) => expect(e.vars.content0).toEqual({ email_verified: true, _00_rv: 4 }),
|
|
67
|
+
'ssp.ingest': (e: any) => expect(e.records[0].record.junk).toBeUndefined(),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
expect(out.state.dirty.has('q')).toBe(true);
|
|
71
|
+
const failing = await runPure(persistVerifiedUser(env), {
|
|
72
|
+
state: s,
|
|
73
|
+
handlers: {
|
|
74
|
+
service: svc.handler,
|
|
75
|
+
'local.execute': () => {
|
|
76
|
+
throw new Error('locked');
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(failing.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RecordId } from 'surrealdb';
|
|
2
|
+
import type { Saga } from '../kernel/saga';
|
|
3
|
+
import { fx } from '../kernel/effects';
|
|
4
|
+
import * as R from '../state/reducers';
|
|
5
|
+
import { bucketIdForUser } from '../modules/ref-tables';
|
|
6
|
+
import type { SagaEnv } from '../query/env';
|
|
7
|
+
import { columnsFor } from '../query/env';
|
|
8
|
+
import * as sql from '../query/sql';
|
|
9
|
+
import { encodeRecordId } from '../utils/index';
|
|
10
|
+
import { cleanRecord } from '../utils/parser';
|
|
11
|
+
import { bucketSwitch } from './bucket-switch.saga';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The signed-in principal changed (sign-in, sign-out, boot verification).
|
|
15
|
+
* Identity first (routing and `$auth` for the local SSP), then the bucket,
|
|
16
|
+
* then the salt if the principal really changed, then the verified user row.
|
|
17
|
+
*/
|
|
18
|
+
export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
|
|
19
|
+
const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
|
|
20
|
+
const access = (yield fx.service('auth.access')) as string | null;
|
|
21
|
+
yield fx.state.update(R.setIdentity({ userId }));
|
|
22
|
+
yield fx.service('ssp.setSessionAuth', authId, access);
|
|
23
|
+
const target = bucketIdForUser(userId);
|
|
24
|
+
yield fx.service('hint.write', target);
|
|
25
|
+
yield fx.state.update(R.setIdentity({ pendingBucket: target }));
|
|
26
|
+
const current = (yield fx.service('local.currentBucketId')) as string;
|
|
27
|
+
const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
|
|
28
|
+
yield* bucketSwitch(env, target, release);
|
|
29
|
+
const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
|
|
30
|
+
if (authId !== saltUserId) {
|
|
31
|
+
const salt = (yield fx.id('salt')) as string;
|
|
32
|
+
yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
|
|
33
|
+
yield fx.service('crdt.setSessionId', salt);
|
|
34
|
+
}
|
|
35
|
+
yield* persistVerifiedUser(env);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write the user's own row, as the server just returned it, into the local
|
|
40
|
+
* store so `user` queries see `email_verified` and friends without waiting
|
|
41
|
+
* for membership. A field refresh, not a version claim.
|
|
42
|
+
*/
|
|
43
|
+
export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
|
|
44
|
+
const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
|
|
45
|
+
if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
|
|
46
|
+
const rid = row.id as RecordId<string>;
|
|
47
|
+
const table = String(rid.table);
|
|
48
|
+
const columns = columnsFor(env, table);
|
|
49
|
+
if (!columns) return;
|
|
50
|
+
const id = encodeRecordId(rid);
|
|
51
|
+
const version = ((yield fx.state.read((s) => s.versions.get(id))) as number | undefined) || 1;
|
|
52
|
+
const cleaned = cleanRecord(columns as never, row);
|
|
53
|
+
const { id: _id, ...content } = cleaned;
|
|
54
|
+
try {
|
|
55
|
+
const tx = sql.upsertBodiesTx([{ id: rid, content: { ...content, _00_rv: version } }]);
|
|
56
|
+
yield fx.local.execute(tx.query, tx.vars);
|
|
57
|
+
yield fx.ssp.ingest([{ table, op: 'UPDATE', id, record: { ...cleaned, _00_rv: version } }]);
|
|
58
|
+
yield fx.state.update(R.compose(R.setVersions([[id, version]]), R.markTableDirty(table)));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'could not persist the verified user row', data: { error } });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { fakeServices } from '../testing/services';
|
|
5
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
6
|
+
import * as R from '../state/reducers';
|
|
7
|
+
import { defaultEnv } from '../query/env';
|
|
8
|
+
import { boot, migrateWindowToView, pageHide, primeCircuit, startRemote, versionsPrimed, warmBlobs } from './boot.saga';
|
|
9
|
+
|
|
10
|
+
const env = defaultEnv({ tables: [] } as any);
|
|
11
|
+
|
|
12
|
+
describe('boot', () => {
|
|
13
|
+
it('solo: opens the hinted bucket, provisions, starts the SSP, restores the session, mirrors the outbox, flips localReady, hands off to the network', async () => {
|
|
14
|
+
const svc = fakeServices({
|
|
15
|
+
'hint.read': () => 'u1',
|
|
16
|
+
'local.usesSurqlSchema': () => true,
|
|
17
|
+
'auth.restoreSession': async () => 'user:u1',
|
|
18
|
+
'auth.sessionAuthId': () => 'user:u1',
|
|
19
|
+
'auth.access': () => 'account',
|
|
20
|
+
});
|
|
21
|
+
const out = await runPure(boot(env, { sharedTabs: false }), {
|
|
22
|
+
handlers: { service: svc.handler, 'local.query': (e: any) => (e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : []) },
|
|
23
|
+
});
|
|
24
|
+
expect(svc.names()).toEqual([
|
|
25
|
+
'hint.read',
|
|
26
|
+
'local.connect',
|
|
27
|
+
'local.usesSurqlSchema',
|
|
28
|
+
'migrator.provision',
|
|
29
|
+
'ssp.init',
|
|
30
|
+
'ssp.setPermissions',
|
|
31
|
+
'auth.restoreSession',
|
|
32
|
+
'auth.sessionAuthId',
|
|
33
|
+
'crdt.setSessionId',
|
|
34
|
+
'auth.access',
|
|
35
|
+
'ssp.setSessionAuth',
|
|
36
|
+
'window.attach',
|
|
37
|
+
'features.init',
|
|
38
|
+
'releases.init',
|
|
39
|
+
]);
|
|
40
|
+
expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
|
|
41
|
+
expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
|
|
42
|
+
expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'GcTick', 'StartRemote']);
|
|
43
|
+
expect(svc.names()).not.toContain('remote.connect');
|
|
44
|
+
});
|
|
45
|
+
it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
|
|
46
|
+
const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
|
|
47
|
+
const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
|
|
48
|
+
expect(out.state.tabRole).toBe('leader');
|
|
49
|
+
expect(out.state.bucketId).toBe('anon');
|
|
50
|
+
expect(leader.names()).not.toContain('local.connect');
|
|
51
|
+
expect(leader.names()).not.toContain('migrator.provision');
|
|
52
|
+
expect(leader.names()).not.toContain('ssp.setSessionAuth');
|
|
53
|
+
const fallback = fakeServices({
|
|
54
|
+
'hint.read': () => 'x',
|
|
55
|
+
'tabs.start': async () => {
|
|
56
|
+
throw new Error('no broker');
|
|
57
|
+
},
|
|
58
|
+
'local.usesSurqlSchema': () => false,
|
|
59
|
+
'auth.restoreSession': async () => null,
|
|
60
|
+
'auth.sessionAuthId': () => null,
|
|
61
|
+
});
|
|
62
|
+
const fb = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: fallback.handler, 'local.query': () => [] } });
|
|
63
|
+
expect(fallback.calls).toContainEqual(['local.connect', ['x']]);
|
|
64
|
+
expect(fb.emitted).toContainEqual(expect.objectContaining({ level: 'warn' }));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('migrateWindowToView', () => {
|
|
69
|
+
it('copies legacy rows once, skips when _00_view has rows, tolerates errors and junk', async () => {
|
|
70
|
+
const upserts: any[] = [];
|
|
71
|
+
await runPure(migrateWindowToView(), {
|
|
72
|
+
handlers: {
|
|
73
|
+
'local.query': (e: any) =>
|
|
74
|
+
e.sql.startsWith('SELECT count()')
|
|
75
|
+
? [[]]
|
|
76
|
+
: [[{ id: '_00_window:k1', ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, { id: new RecordId('_00_window', 'k2'), ids: [] }, { id: 'x', ids: 'bad' }, { ids: [] }]],
|
|
77
|
+
'local.upsert': (e: any) => void upserts.push(e),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(upserts).toEqual([
|
|
81
|
+
{ kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k1'), data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, mode: 'replace' },
|
|
82
|
+
{ kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k2'), data: { ids: [], confirmed: false, updatedAt: 0 }, mode: 'replace' },
|
|
83
|
+
]);
|
|
84
|
+
const skipped = await runPure(migrateWindowToView(), { handlers: { 'local.query': () => [[{ count: 3 }]] } });
|
|
85
|
+
expect(skipped.log.filter((e) => e.kind === 'local.query')).toHaveLength(1);
|
|
86
|
+
const failing = await runPure(migrateWindowToView(), {
|
|
87
|
+
handlers: {
|
|
88
|
+
'local.query': () => {
|
|
89
|
+
throw new Error('no table');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
expect(failing.emitted).toEqual([expect.objectContaining({ level: 'debug' })]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', () => {
|
|
98
|
+
it('startRemote tolerates connect and auth failures and starts the loops', async () => {
|
|
99
|
+
const svc = fakeServices({
|
|
100
|
+
'remote.connect': async () => {
|
|
101
|
+
throw new Error('offline');
|
|
102
|
+
},
|
|
103
|
+
'auth.init': async () => {
|
|
104
|
+
throw new Error('401');
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const out = await runPure(startRemote(), { handlers: { service: svc.handler } });
|
|
108
|
+
expect(svc.names()).toEqual(['remote.connect', 'supervisor.start', 'auth.init']);
|
|
109
|
+
expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(2);
|
|
110
|
+
expect(out.dispatched.map((d) => d.type)).toEqual(['EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
|
|
111
|
+
const ok = await runPure(startRemote(), { handlers: { service: fakeServices().handler } });
|
|
112
|
+
expect(ok.emitted).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
it('primeCircuit passes outbox ids and always flips primed', async () => {
|
|
115
|
+
const svc = fakeServices({ 'ssp.prime': async () => undefined });
|
|
116
|
+
const s = buildState([], R.outboxReplace([buildOutboxItem({ recordId: 'thing:1' })]));
|
|
117
|
+
const out = await runPure(primeCircuit(), { state: s, handlers: { service: svc.handler } });
|
|
118
|
+
expect(svc.calls).toEqual([['ssp.prime', [['thing:1']]]]);
|
|
119
|
+
expect(out.state.primed).toBe(true);
|
|
120
|
+
const failing = fakeServices({
|
|
121
|
+
'ssp.prime': async () => {
|
|
122
|
+
throw new Error('snapshot');
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const f = await runPure(primeCircuit(), { state: buildState(), handlers: { service: failing.handler } });
|
|
126
|
+
expect(f.state.primed).toBe(true);
|
|
127
|
+
expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
128
|
+
});
|
|
129
|
+
it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
|
|
130
|
+
const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
|
|
131
|
+
expect(v.state.versions.get('thing:1')).toBe(3);
|
|
132
|
+
const svc = fakeServices();
|
|
133
|
+
const s = buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }), buildEntry({ def: { hash: 'b' } })]);
|
|
134
|
+
await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
|
|
135
|
+
expect(svc.calls).toEqual([['remote.releaseViews', [[new RecordId('_00_query', 'a')]]]]);
|
|
136
|
+
const none = fakeServices();
|
|
137
|
+
await runPure(pageHide(), { state: buildState(), handlers: { service: none.handler } });
|
|
138
|
+
expect(none.calls).toEqual([]);
|
|
139
|
+
const blobs = fakeServices({
|
|
140
|
+
'blobs.start': async () => {
|
|
141
|
+
throw new Error('opfs');
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const w = await runPure(warmBlobs(), { state: { ...buildState(), bucketId: 'u9' }, handlers: { service: blobs.handler } });
|
|
145
|
+
expect(blobs.calls).toEqual([['blobs.start', ['u9']]]);
|
|
146
|
+
expect(w.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
147
|
+
const anon = fakeServices({ 'blobs.start': async () => undefined });
|
|
148
|
+
await runPure(warmBlobs(), { state: buildState(), handlers: { service: anon.handler } });
|
|
149
|
+
expect(anon.calls).toEqual([['blobs.start', ['anon']]]);
|
|
150
|
+
});
|
|
151
|
+
});
|