@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301
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 +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2026 -137
- package/dist/index.js +14939 -2432
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1765 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -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 +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +370 -0
- package/src/client/sp00ky-client.test.ts +303 -0
- package/src/client/sp00ky-client.ts +586 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -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 +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +146 -61
- 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/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -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 +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- 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 +58 -2
- 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 +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +48 -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 +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
package/src/pure.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@spooky-sync/core/pure`: the side-effect-free builders of the sync protocol.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is plain functions and constants over `surrealdb` value
|
|
5
|
+
* types: no wasm, no logger, no `window`/`document`/`localStorage`. Safe to
|
|
6
|
+
* import from a service worker, a dedicated worker or Node. The full client
|
|
7
|
+
* imports the same modules, so what this entry builds is byte for byte what
|
|
8
|
+
* `Sp00kyClient` sends.
|
|
9
|
+
*/
|
|
10
|
+
import { queryHashInput, type QueryKeyInput } from './query/hash';
|
|
11
|
+
import { sha256Hex } from './utils/sha256';
|
|
12
|
+
|
|
13
|
+
// Statement builders: register + snapshot, heartbeat, bodies, views.
|
|
14
|
+
export * from './query/sql';
|
|
15
|
+
// Query keys.
|
|
16
|
+
export { queryHashInput, viewKeyInput, type QueryKeyInput } from './query/hash';
|
|
17
|
+
// `_00_list_ref` table naming and user-id sanitizing (mirrors ssp-protocol).
|
|
18
|
+
export {
|
|
19
|
+
ANON_USER_ID,
|
|
20
|
+
DEFAULT_REF_MODE,
|
|
21
|
+
bucketIdForUser,
|
|
22
|
+
listRefTableFor,
|
|
23
|
+
sanitizeUserId,
|
|
24
|
+
type RefMode,
|
|
25
|
+
} from './modules/ref-tables';
|
|
26
|
+
// Membership snapshot parsing (the answer to the register read-back).
|
|
27
|
+
export {
|
|
28
|
+
FAILED,
|
|
29
|
+
dedupeRecordVersions,
|
|
30
|
+
metaFromRow,
|
|
31
|
+
snapshotFromSingle,
|
|
32
|
+
type Answer,
|
|
33
|
+
type ListRefSnapshot,
|
|
34
|
+
type QueryMetaRow,
|
|
35
|
+
} from './query/membership';
|
|
36
|
+
// LIVE edge decoding.
|
|
37
|
+
export { hashOfEdge, rowOfEdge } from './utils/edges';
|
|
38
|
+
// SurrealQL string helpers.
|
|
39
|
+
export {
|
|
40
|
+
surql,
|
|
41
|
+
type SealOptions,
|
|
42
|
+
type SealedQuery,
|
|
43
|
+
type SurqlHelper,
|
|
44
|
+
type TxQuery,
|
|
45
|
+
} from './utils/surql';
|
|
46
|
+
// Param typing the full client applies before registering.
|
|
47
|
+
export { parseQueryParams } from './utils/parser';
|
|
48
|
+
export { encodeRecordId, parseRecordIdString, parseDuration } from './utils/index';
|
|
49
|
+
// Unverified JWT claim reading (user id, access method, impersonation).
|
|
50
|
+
export {
|
|
51
|
+
decodeTokenClaims,
|
|
52
|
+
IMPERSONATION_ACCESS,
|
|
53
|
+
type TokenClaims,
|
|
54
|
+
} from './modules/auth/impersonation';
|
|
55
|
+
export { sha256Hex } from './utils/sha256';
|
|
56
|
+
// Web Push wire types.
|
|
57
|
+
export * from './push/types';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The remote `_00_query` key of a query for one session salt: what the full
|
|
61
|
+
* client names its view (`_00_query:<key>`).
|
|
62
|
+
*/
|
|
63
|
+
export function queryKey(input: QueryKeyInput, sessionId: string | null): Promise<string> {
|
|
64
|
+
return sha256Hex(queryHashInput(input, sessionId));
|
|
65
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Push wire types shared by the page (`db.webPush`), the service worker
|
|
3
|
+
* bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
|
|
4
|
+
* dependency-free helpers, safe in any context.
|
|
5
|
+
*
|
|
6
|
+
* `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
|
|
7
|
+
* `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Payload format version this build understands. */
|
|
11
|
+
export const PUSH_PAYLOAD_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export type PushPayloadKind = 'rule' | 'message';
|
|
14
|
+
export type PushOp = 'create' | 'update' | 'delete';
|
|
15
|
+
export type PushUrgency = 'very-low' | 'low' | 'normal' | 'high';
|
|
16
|
+
|
|
17
|
+
/** One button on a notification (`NotificationAction` plus a per-action `url`). */
|
|
18
|
+
export interface PushNotificationAction {
|
|
19
|
+
action: string;
|
|
20
|
+
title: string;
|
|
21
|
+
icon?: string;
|
|
22
|
+
/** Opened instead of the notification's `url` when this action is clicked. */
|
|
23
|
+
url?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a content push shows. Every key other than `title` and `url` is passed
|
|
29
|
+
* to `showNotification` as is, unknown ones included (`vibrate`, `timestamp`,
|
|
30
|
+
* whatever a browser adds later).
|
|
31
|
+
*/
|
|
32
|
+
export interface PushNotification {
|
|
33
|
+
title?: string;
|
|
34
|
+
body?: string;
|
|
35
|
+
icon?: string;
|
|
36
|
+
badge?: string;
|
|
37
|
+
image?: string;
|
|
38
|
+
lang?: string;
|
|
39
|
+
dir?: string;
|
|
40
|
+
/** Defaults to the push's `topic`. */
|
|
41
|
+
tag?: string;
|
|
42
|
+
/** Opened (or focused) on click. */
|
|
43
|
+
url?: string;
|
|
44
|
+
requireInteraction?: boolean;
|
|
45
|
+
renotify?: boolean;
|
|
46
|
+
silent?: boolean;
|
|
47
|
+
actions?: PushNotificationAction[];
|
|
48
|
+
/** Merged into the notification's `data` next to the bridge's own keys. */
|
|
49
|
+
data?: Record<string, unknown>;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The JSON every push carries (then RFC 8291 encrypted). `notification`
|
|
55
|
+
* present: a content push the service worker shows as is. Absent: a
|
|
56
|
+
* content-free nudge, the app renders from its own data.
|
|
57
|
+
*/
|
|
58
|
+
export interface PushPayload {
|
|
59
|
+
v: number;
|
|
60
|
+
kind: PushPayloadKind;
|
|
61
|
+
/** Rule name (`kind: 'rule'`). */
|
|
62
|
+
rule?: string;
|
|
63
|
+
/** `_00_push_message` id (`kind: 'message'`). */
|
|
64
|
+
message?: string;
|
|
65
|
+
/** The row that fired the rule. */
|
|
66
|
+
table?: string;
|
|
67
|
+
id?: string;
|
|
68
|
+
op?: PushOp;
|
|
69
|
+
topic?: string;
|
|
70
|
+
notification?: PushNotification;
|
|
71
|
+
data?: Record<string, unknown>;
|
|
72
|
+
/** Epoch ms when the engine built it. */
|
|
73
|
+
ts: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Page -> service worker: the session to use for live rendering. */
|
|
77
|
+
export interface BridgeTokenMessage {
|
|
78
|
+
type: 'sp00ky:token';
|
|
79
|
+
token: string;
|
|
80
|
+
userId: string;
|
|
81
|
+
/** The SurrealDB endpoint (`wss://host/rpc`), not the push endpoint. */
|
|
82
|
+
endpoint: string;
|
|
83
|
+
namespace: string;
|
|
84
|
+
database: string;
|
|
85
|
+
/** VAPID public key (base64url) the page subscribed with, for `pushsubscriptionchange`. */
|
|
86
|
+
publicKey?: string;
|
|
87
|
+
/** This browser's push endpoint, when subscribed. */
|
|
88
|
+
pushEndpoint?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Page -> service worker: forget the session and close every notification. */
|
|
92
|
+
export interface BridgeSignOutMessage {
|
|
93
|
+
type: 'sp00ky:signout';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Service worker -> page messages. */
|
|
97
|
+
export type WebPushClientMessage =
|
|
98
|
+
/** A push arrived while this page was visible, so nothing was shown. */
|
|
99
|
+
| { type: 'sp00ky:push'; payload: PushPayload }
|
|
100
|
+
/** A notification was clicked and the bridge was told to route in-app. */
|
|
101
|
+
| { type: 'sp00ky:navigate'; url: string; payload?: PushPayload; action?: string }
|
|
102
|
+
/** The browser rotated the push subscription; the page should `sync()`. */
|
|
103
|
+
| { type: 'sp00ky:subscriptionchange'; endpoint?: string };
|
|
104
|
+
|
|
105
|
+
/** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
|
|
106
|
+
export function base64UrlToBytes(value: string): Uint8Array {
|
|
107
|
+
const padded =
|
|
108
|
+
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
|
109
|
+
const raw = typeof atob === 'function' ? atob(padded) : decodeBase64Fallback(padded);
|
|
110
|
+
const out = new Uint8Array(raw.length);
|
|
111
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
|
|
116
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
117
|
+
let raw = '';
|
|
118
|
+
for (let i = 0; i < view.length; i++) raw += String.fromCharCode(view[i]);
|
|
119
|
+
const b64 = typeof btoa === 'function' ? btoa(raw) : encodeBase64Fallback(raw);
|
|
120
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sameBytes(
|
|
124
|
+
a: ArrayBuffer | Uint8Array | null | undefined,
|
|
125
|
+
b: ArrayBuffer | Uint8Array | null | undefined
|
|
126
|
+
): boolean {
|
|
127
|
+
if (!a || !b) return false;
|
|
128
|
+
const x = a instanceof Uint8Array ? a : new Uint8Array(a);
|
|
129
|
+
const y = b instanceof Uint8Array ? b : new Uint8Array(b);
|
|
130
|
+
if (x.length !== y.length) return false;
|
|
131
|
+
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A payload this build can act on (`v` known, `kind` set). */
|
|
136
|
+
export function isPushPayload(value: unknown): value is PushPayload {
|
|
137
|
+
if (!value || typeof value !== 'object') return false;
|
|
138
|
+
const v = value as { v?: unknown; kind?: unknown };
|
|
139
|
+
return v.v === PUSH_PAYLOAD_VERSION && (v.kind === 'rule' || v.kind === 'message');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
143
|
+
|
|
144
|
+
function decodeBase64Fallback(input: string): string {
|
|
145
|
+
const clean = input.replace(/=+$/, '');
|
|
146
|
+
let out = '';
|
|
147
|
+
let buffer = 0;
|
|
148
|
+
let bits = 0;
|
|
149
|
+
for (const ch of clean) {
|
|
150
|
+
const idx = B64.indexOf(ch);
|
|
151
|
+
if (idx < 0) continue;
|
|
152
|
+
buffer = (buffer << 6) | idx;
|
|
153
|
+
bits += 6;
|
|
154
|
+
if (bits >= 8) {
|
|
155
|
+
bits -= 8;
|
|
156
|
+
out += String.fromCharCode((buffer >> bits) & 0xff);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function encodeBase64Fallback(raw: string): string {
|
|
163
|
+
let out = '';
|
|
164
|
+
for (let i = 0; i < raw.length; i += 3) {
|
|
165
|
+
const a = raw.charCodeAt(i);
|
|
166
|
+
const b = i + 1 < raw.length ? raw.charCodeAt(i + 1) : 0;
|
|
167
|
+
const c = i + 2 < raw.length ? raw.charCodeAt(i + 2) : 0;
|
|
168
|
+
const n = (a << 16) | (b << 8) | c;
|
|
169
|
+
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
|
|
170
|
+
out += i + 1 < raw.length ? B64[(n >> 6) & 63] : '=';
|
|
171
|
+
out += i + 2 < raw.length ? B64[n & 63] : '=';
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { columnsFor, defaultEnv, listRefTable } from './env';
|
|
3
|
+
import { emptyState } from '../state/client-state';
|
|
4
|
+
|
|
5
|
+
const schema = { tables: [{ name: 'thing', columns: { a: { type: 'string' } } }] } as any;
|
|
6
|
+
|
|
7
|
+
describe('SagaEnv', () => {
|
|
8
|
+
it('defaultEnv applies overrides', () => {
|
|
9
|
+
expect(defaultEnv(schema).outboxBatchSize).toBe(50);
|
|
10
|
+
expect(defaultEnv(schema, { outboxBatchSize: 5 }).outboxBatchSize).toBe(5);
|
|
11
|
+
});
|
|
12
|
+
it('listRefTable: per-user, anonymous when enabled, global otherwise', () => {
|
|
13
|
+
const s = emptyState({ tabId: 't' });
|
|
14
|
+
expect(listRefTable(defaultEnv(schema), { ...s, userId: 'user:abc' })).toBe('_00_list_ref_user_abc');
|
|
15
|
+
expect(listRefTable(defaultEnv(schema, { anonLive: true }), s)).toBe('_00_list_ref_anon');
|
|
16
|
+
expect(listRefTable(defaultEnv(schema), s)).toBe('_00_list_ref');
|
|
17
|
+
});
|
|
18
|
+
it('columnsFor: schema table, framework table, unknown', () => {
|
|
19
|
+
expect(columnsFor(defaultEnv(schema), 'thing')).toEqual({ a: { type: 'string' } });
|
|
20
|
+
expect(columnsFor(defaultEnv(schema), '_00_feature_flag')).toEqual({});
|
|
21
|
+
expect(columnsFor(defaultEnv(schema), 'nope')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/query/env.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { RefMode } from '../modules/ref-tables';
|
|
3
|
+
import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor } from '../modules/ref-tables';
|
|
4
|
+
import type { ClientState } from '../state/client-state';
|
|
5
|
+
import {
|
|
6
|
+
DEGRADE_AFTER_FAILURES,
|
|
7
|
+
LIST_REF_POLL_BASE_MS,
|
|
8
|
+
MATERIALIZE_DEBOUNCE_MS,
|
|
9
|
+
OUTBOX_BATCH_SIZE,
|
|
10
|
+
PUSH_TIMEOUT_MS,
|
|
11
|
+
} from '../kernel/constants';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Static configuration the sagas read. Plain data, built once by the runtime
|
|
15
|
+
* from `Sp00kyConfig`; sagas never see the config object itself.
|
|
16
|
+
*/
|
|
17
|
+
export interface SagaEnv {
|
|
18
|
+
readonly schema: SchemaStructure;
|
|
19
|
+
readonly refMode: RefMode;
|
|
20
|
+
readonly anonLive: boolean;
|
|
21
|
+
readonly remoteTimeoutMs: number;
|
|
22
|
+
readonly pushTimeoutMs: number;
|
|
23
|
+
readonly outboxBatchSize: number;
|
|
24
|
+
readonly degradeAfter: number;
|
|
25
|
+
readonly materializeDebounceMs: number;
|
|
26
|
+
readonly pollBaseMs: number;
|
|
27
|
+
readonly defaultTtlMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultEnv(schema: SchemaStructure, over: Partial<SagaEnv> = {}): SagaEnv {
|
|
31
|
+
return {
|
|
32
|
+
schema,
|
|
33
|
+
refMode: DEFAULT_REF_MODE,
|
|
34
|
+
anonLive: false,
|
|
35
|
+
remoteTimeoutMs: 60_000,
|
|
36
|
+
pushTimeoutMs: PUSH_TIMEOUT_MS,
|
|
37
|
+
outboxBatchSize: OUTBOX_BATCH_SIZE,
|
|
38
|
+
degradeAfter: DEGRADE_AFTER_FAILURES,
|
|
39
|
+
materializeDebounceMs: MATERIALIZE_DEBOUNCE_MS,
|
|
40
|
+
pollBaseMs: LIST_REF_POLL_BASE_MS,
|
|
41
|
+
defaultTtlMs: 600_000,
|
|
42
|
+
...over,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The `_00_list_ref` table this session reads: per-user, anonymous, or global. */
|
|
47
|
+
export function listRefTable(env: SagaEnv, s: ClientState): string {
|
|
48
|
+
const userId = s.userId ?? (env.anonLive ? ANON_USER_ID : null);
|
|
49
|
+
return listRefTableFor(env.refMode, userId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function columnsFor(env: SagaEnv, table: string): Record<string, unknown> | null {
|
|
53
|
+
const found = env.schema.tables.find((t) => t.name === table);
|
|
54
|
+
if (found) return found.columns as Record<string, unknown>;
|
|
55
|
+
return table.startsWith('_00_') ? {} : null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
5
|
+
import * as R from '../state/reducers';
|
|
6
|
+
import { defaultEnv } from './env';
|
|
7
|
+
import { fetchRows, landChunk } from './fetch.saga';
|
|
8
|
+
import type { StatementResult } from '../kernel/effects';
|
|
9
|
+
|
|
10
|
+
const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {} } }] } as any);
|
|
11
|
+
const ok = (result: unknown): StatementResult => ({ status: 'OK', result });
|
|
12
|
+
const live = { phase: 'live' } as const;
|
|
13
|
+
const primed = (s: ReturnType<typeof buildState>) => ({ ...s, primed: true });
|
|
14
|
+
|
|
15
|
+
describe('fetchRows', () => {
|
|
16
|
+
it('waits for the prime and returns on an empty plan (resetting the attempt counter)', async () => {
|
|
17
|
+
const s = R.patchSync({ fetchAttempts: 3 })(buildState());
|
|
18
|
+
const out = await runPure(fetchRows(env), { state: s, handlers: { 'state.wait': (_e, ctx) => void (ctx.state = { ...ctx.state, primed: true }) } });
|
|
19
|
+
expect(out.state.sync.fetchAttempts).toBe(0);
|
|
20
|
+
expect(out.log.filter((e) => e.kind === 'remote.query')).toHaveLength(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('fetches the deduped set once, writes + ingests bodies, records versions, balances fetch depth', async () => {
|
|
24
|
+
const s = primed(
|
|
25
|
+
buildState(
|
|
26
|
+
[
|
|
27
|
+
buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 2], ['thing:2', 1]] }),
|
|
28
|
+
buildEntry({ def: { hash: 'b' }, lifecycle: live, remoteArray: [['thing:2', 1], ['thing:3', 1]] }),
|
|
29
|
+
],
|
|
30
|
+
R.setVersions([['thing:3', 1], ['thing:1', 1]]),
|
|
31
|
+
R.outboxReplace([buildOutboxItem({ type: 'delete', recordId: 'thing:2' })])
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
const depths: number[] = [];
|
|
35
|
+
const out = await runPure(fetchRows(env), {
|
|
36
|
+
state: s,
|
|
37
|
+
epoch: 7,
|
|
38
|
+
handlers: {
|
|
39
|
+
'remote.query': (e: any, ctx) => {
|
|
40
|
+
depths.push(ctx.state.queries.get('a')!.lifecycle.fetchDepth);
|
|
41
|
+
expect(e.sql).toBe('SELECT * FROM $ids');
|
|
42
|
+
expect(e.vars.ids).toEqual([new RecordId('thing', '1')]);
|
|
43
|
+
return [ok([{ id: new RecordId('thing', '1'), a: 'x', junk: true }])];
|
|
44
|
+
},
|
|
45
|
+
'local.execute': () => undefined,
|
|
46
|
+
'ssp.ingest': () => undefined,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(depths).toEqual([1]);
|
|
50
|
+
expect(out.state.queries.get('a')!.lifecycle.fetchDepth).toBe(0);
|
|
51
|
+
expect(out.state.versions.get('thing:1')).toBe(2);
|
|
52
|
+
const exec = out.log.find((e) => e.kind === 'local.execute') as any;
|
|
53
|
+
expect(exec.vars.content0).toEqual({ a: 'x', _00_rv: 2 });
|
|
54
|
+
expect(exec.epoch).toBe(7);
|
|
55
|
+
expect(out.log.findIndex((e) => e.kind === 'local.epoch')).toBeLessThan(out.log.findIndex((e) => e.kind === 'all'));
|
|
56
|
+
const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
|
|
57
|
+
expect(ingest.records).toEqual([{ table: 'thing', op: 'UPDATE', id: 'thing:1', record: { id: new RecordId('thing', '1'), a: 'x', _00_rv: 2 } }]);
|
|
58
|
+
expect(out.dispatched).toEqual([{ type: 'SyncOutcome', ok: true, error: undefined }]);
|
|
59
|
+
expect(out.state.dirty.has('a')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('a body the server does not return is remembered at the requested version so the plan converges', async () => {
|
|
63
|
+
const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1], ['other:2', 1]] })]));
|
|
64
|
+
let calls = 0;
|
|
65
|
+
const out = await runPure(fetchRows(env), {
|
|
66
|
+
state: s,
|
|
67
|
+
handlers: {
|
|
68
|
+
'remote.query': () => {
|
|
69
|
+
calls++;
|
|
70
|
+
return [ok([{ id: new RecordId('other', '2'), z: 1 }, { nope: true }])];
|
|
71
|
+
},
|
|
72
|
+
'local.execute': () => undefined,
|
|
73
|
+
'ssp.ingest': () => undefined,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
expect(calls).toBe(1);
|
|
77
|
+
expect(out.state.versions.get('thing:1')).toBe(1);
|
|
78
|
+
expect(out.state.versions.get('other:2')).toBe(1);
|
|
79
|
+
const extra = await runPure(fetchRows(env), {
|
|
80
|
+
state: s,
|
|
81
|
+
handlers: {
|
|
82
|
+
'remote.query': () => [ok([{ id: new RecordId('thing', '1') }, { id: new RecordId('thing', 'unrequested') }])],
|
|
83
|
+
'local.execute': () => undefined,
|
|
84
|
+
'ssp.ingest': (e: any) => {
|
|
85
|
+
expect(e.records[1]).toMatchObject({ id: 'thing:unrequested', record: { _00_rv: 0 } });
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
expect(extra.state.versions.has('thing:unrequested')).toBe(false);
|
|
90
|
+
const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
|
|
91
|
+
expect(ingest.records[0]).toMatchObject({ table: 'other', op: 'CREATE', record: { z: 1, _00_rv: 1 } });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('failures back off: transport error, ERR statement, non-array result, local write error; ingest errors are logged only', async () => {
|
|
95
|
+
const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })]));
|
|
96
|
+
const expectBackoff = (out: Awaited<ReturnType<typeof runPure>>, attempts: number) => {
|
|
97
|
+
expect(out.state.sync.fetchAttempts).toBe(attempts);
|
|
98
|
+
expect(out.timers.get('fetch')).toEqual({ ms: 500 * 2 ** (attempts - 1), event: { type: 'FetchRows' } });
|
|
99
|
+
expect(out.state.queries.get('a')!.lifecycle.fetchDepth).toBe(0);
|
|
100
|
+
expect(out.dispatched.at(-1)).toMatchObject({ type: 'SyncOutcome', ok: false });
|
|
101
|
+
};
|
|
102
|
+
expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => { throw new Error('offline'); } } }), 1);
|
|
103
|
+
expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => [{ status: 'ERR', error: 'x' }] } }), 1);
|
|
104
|
+
expectBackoff(await runPure(fetchRows(env), { state: s, handlers: { 'remote.query': () => [ok('nope')] } }), 1);
|
|
105
|
+
expectBackoff(
|
|
106
|
+
await runPure(fetchRows(env), {
|
|
107
|
+
state: R.patchSync({ fetchAttempts: 1 })(s),
|
|
108
|
+
handlers: {
|
|
109
|
+
'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])],
|
|
110
|
+
'local.execute': () => {
|
|
111
|
+
throw new Error('stale epoch');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
2
|
|
116
|
+
);
|
|
117
|
+
const ingestFail = await runPure(fetchRows(env), {
|
|
118
|
+
state: s,
|
|
119
|
+
handlers: {
|
|
120
|
+
'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])],
|
|
121
|
+
'local.execute': () => undefined,
|
|
122
|
+
'ssp.ingest': () => {
|
|
123
|
+
throw new Error('wasm');
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
expect(ingestFail.state.sync.fetchAttempts).toBe(0);
|
|
128
|
+
expect(ingestFail.emitted).toContainEqual(expect.objectContaining({ message: 'circuit ingest failed' }));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('a leader relays landed bodies to followers', async () => {
|
|
132
|
+
const s = { ...primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })])), tabRole: 'leader' as const };
|
|
133
|
+
const out = await runPure(fetchRows(env), {
|
|
134
|
+
state: s,
|
|
135
|
+
handlers: { 'remote.query': () => [ok([{ id: new RecordId('thing', '1') }])], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
136
|
+
});
|
|
137
|
+
expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ id: 'thing:1' })] } });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('loops until the plan is empty', async () => {
|
|
141
|
+
const s = primed(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: live, remoteArray: [['thing:1', 1]] })]));
|
|
142
|
+
let calls = 0;
|
|
143
|
+
const out = await runPure(fetchRows(env), {
|
|
144
|
+
state: s,
|
|
145
|
+
handlers: {
|
|
146
|
+
'remote.query': (_e, ctx) => {
|
|
147
|
+
calls++;
|
|
148
|
+
if (calls === 1) ctx.state = R.commitMembership('a', [['thing:1', 1], ['thing:2', 1]], true)(ctx.state);
|
|
149
|
+
return [ok([])];
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
expect(calls).toBe(2);
|
|
154
|
+
expect(out.state.versions.get('thing:2')).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('landChunk', () => {
|
|
159
|
+
const typed = defaultEnv({
|
|
160
|
+
tables: [
|
|
161
|
+
{
|
|
162
|
+
name: 'broadcast',
|
|
163
|
+
columns: {
|
|
164
|
+
title: { type: 'string', optional: false },
|
|
165
|
+
owner: { type: 'string', recordId: true, optional: false },
|
|
166
|
+
club: { type: 'string', recordId: true, optional: true },
|
|
167
|
+
note: { type: 'string', optional: true },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
} as any);
|
|
172
|
+
|
|
173
|
+
// `UPDATE broadcast:x UNSET club` leaves no `club` key in the body the server
|
|
174
|
+
// returns, and a MERGE that does not name it kept the stale local club.
|
|
175
|
+
it('names each optional column the body lacks as undefined so the MERGE clears it', async () => {
|
|
176
|
+
const rid = new RecordId('broadcast', 'x');
|
|
177
|
+
const out = await runPure(landChunk(typed, ['broadcast:x'], [{ id: rid, title: 't', note: 'n', _00_extra: 1 }], new Map([['broadcast:x', 4]]), buildState(), 3), {
|
|
178
|
+
handlers: { 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
179
|
+
});
|
|
180
|
+
expect(out.result).toBe(true);
|
|
181
|
+
const exec = out.log.find((e) => e.kind === 'local.execute') as any;
|
|
182
|
+
expect(exec.query.sql).toContain('UPSERT ONLY $id0 MERGE $content0');
|
|
183
|
+
expect(exec.vars.content0).toStrictEqual({ club: undefined, title: 't', note: 'n', _00_extra: 1, _00_rv: 4 });
|
|
184
|
+
expect(Object.prototype.hasOwnProperty.call(exec.vars.content0, 'club')).toBe(true);
|
|
185
|
+
// A required column (`owner`) is never cleared: its absence means hidden, not unset.
|
|
186
|
+
expect(Object.prototype.hasOwnProperty.call(exec.vars.content0, 'owner')).toBe(false);
|
|
187
|
+
// The circuit record carries no clear: CREATE/UPDATE replace the circuit row.
|
|
188
|
+
const ingest = out.log.find((e) => e.kind === 'ssp.ingest') as any;
|
|
189
|
+
expect(ingest.records[0].record).toStrictEqual({ id: rid, title: 't', note: 'n', _00_extra: 1, _00_rv: 4 });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('names nothing for a table without a schema', async () => {
|
|
193
|
+
const out = await runPure(landChunk(typed, ['other:1'], [{ id: new RecordId('other', '1'), z: 1 }], new Map([['other:1', 1]]), buildState(), 0), {
|
|
194
|
+
handlers: { 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
|
|
195
|
+
});
|
|
196
|
+
const exec = out.log.find((e) => e.kind === 'local.execute') as any;
|
|
197
|
+
expect(exec.vars.content0).toStrictEqual({ z: 1, _00_rv: 1 });
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { RecordId } from 'surrealdb';
|
|
2
|
+
import type { Saga } from '../kernel/saga';
|
|
3
|
+
import type { Settled, StatementResult } from '../kernel/effects';
|
|
4
|
+
import { fx } from '../kernel/effects';
|
|
5
|
+
import { backoffMs } from '../kernel/constants';
|
|
6
|
+
import type { IngestRecord } from '../services/stream-processor/index';
|
|
7
|
+
import type { ClientState } from '../state/client-state';
|
|
8
|
+
import * as R from '../state/reducers';
|
|
9
|
+
import { planFetch } from '../state/selectors';
|
|
10
|
+
import { encodeRecordId, parseRecordIdString } from '../utils/index';
|
|
11
|
+
import { absentOptionalColumns, cleanRecord } from '../utils/parser';
|
|
12
|
+
import type { SagaEnv } from './env';
|
|
13
|
+
import * as sql from './sql';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The unified row fetcher (the poke analogue). Runs on the serial `fetch`
|
|
17
|
+
* lane: computes the cross-query set of bodies missing or stale locally,
|
|
18
|
+
* pulls them in parallel chunks, writes + ingests them, and loops until the
|
|
19
|
+
* plan is empty. A row shared by N queries is fetched once. Bodies are never
|
|
20
|
+
* deleted here; ids that left membership simply stop rendering.
|
|
21
|
+
*/
|
|
22
|
+
export function* fetchRows(env: SagaEnv): Saga<void> {
|
|
23
|
+
yield fx.state.wait((s) => s.primed);
|
|
24
|
+
for (;;) {
|
|
25
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
26
|
+
const plan = planFetch(state);
|
|
27
|
+
if (plan.chunks.length === 0) {
|
|
28
|
+
if (state.sync.fetchAttempts !== 0) yield fx.state.update(R.patchSync({ fetchAttempts: 0 }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// The engine's epoch, not a mirrored counter: it also moves on transport
|
|
32
|
+
// failover, and a stale value makes the store drop every body write.
|
|
33
|
+
const epoch = (yield fx.local.epoch()) as number;
|
|
34
|
+
yield fx.state.update(R.compose(...plan.hashes.map((h) => R.applyLifecycle(h, { type: 'fetch-begin' }))));
|
|
35
|
+
let failed = 0;
|
|
36
|
+
try {
|
|
37
|
+
const results = (yield fx.all(
|
|
38
|
+
plan.chunks.map((ids) => fx.remote.query(sql.bodySelect(), { ids: ids.map(parseRecordIdString) }, env.remoteTimeoutMs))
|
|
39
|
+
)) as Settled[];
|
|
40
|
+
for (let i = 0; i < results.length; i++) {
|
|
41
|
+
const r = results[i];
|
|
42
|
+
const first = r.ok ? (r.value as StatementResult[])[0] : undefined;
|
|
43
|
+
if (!r.ok || !first || first.status === 'ERR' || !Array.isArray(first.result)) {
|
|
44
|
+
failed++;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const landed = yield* landChunk(env, plan.chunks[i], first.result as Array<Record<string, unknown>>, plan.versions, state, epoch);
|
|
48
|
+
if (!landed) failed++;
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
yield fx.state.update(R.compose(...plan.hashes.map((h) => R.applyLifecycle(h, { type: 'fetch-end' }))));
|
|
52
|
+
}
|
|
53
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: failed === 0, error: failed > 0 ? 'body fetch failed' : undefined });
|
|
54
|
+
if (failed > 0) {
|
|
55
|
+
const attempt = (yield fx.state.read((s) => s.sync.fetchAttempts)) as number;
|
|
56
|
+
yield fx.state.update(R.patchSync({ fetchAttempts: attempt + 1 }));
|
|
57
|
+
yield fx.timer.set('fetch', backoffMs(attempt), { type: 'FetchRows' });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write one chunk's bodies to the store and circuit; record their versions.
|
|
65
|
+
*
|
|
66
|
+
* Shared with the LIVE path: a body that arrives on a notification is landed
|
|
67
|
+
* through here so it is indistinguishable from a fetched one, which is what
|
|
68
|
+
* makes `planFetch` stop asking for it.
|
|
69
|
+
*/
|
|
70
|
+
export function* landChunk(
|
|
71
|
+
env: SagaEnv,
|
|
72
|
+
requested: string[],
|
|
73
|
+
rows: Array<Record<string, unknown>>,
|
|
74
|
+
versions: ReadonlyMap<string, number>,
|
|
75
|
+
state: ClientState,
|
|
76
|
+
epoch: number
|
|
77
|
+
): Saga<boolean> {
|
|
78
|
+
const bodies: Array<{ id: unknown; content: Record<string, unknown> }> = [];
|
|
79
|
+
const ingest: IngestRecord[] = [];
|
|
80
|
+
const seen = new Set<string>();
|
|
81
|
+
for (const row of rows) {
|
|
82
|
+
const rid = row?.id as RecordId<string> | undefined;
|
|
83
|
+
if (!rid || typeof rid !== 'object') continue;
|
|
84
|
+
const id = encodeRecordId(rid);
|
|
85
|
+
const table = String(rid.table);
|
|
86
|
+
const version = versions.get(id) ?? 0;
|
|
87
|
+
const columns = env.schema.tables.find((t) => t.name === table)?.columns;
|
|
88
|
+
const cleaned = columns ? cleanRecord(columns, row) : row;
|
|
89
|
+
const { id: _id, ...content } = cleaned;
|
|
90
|
+
// The row is the server's whole body, so an optional column it lacks was
|
|
91
|
+
// cleared upstream: name it (as `undefined`) so the MERGE removes the local
|
|
92
|
+
// copy instead of keeping it. `_00_*` local bookkeeping is never named, so
|
|
93
|
+
// MERGE (not REPLACE) still preserves it. The circuit record below needs no
|
|
94
|
+
// such key: CREATE/UPDATE replace the circuit's row outright.
|
|
95
|
+
bodies.push({ id: rid, content: { ...absentOptionalColumns(columns, row), ...content, _00_rv: version } });
|
|
96
|
+
ingest.push({ table, op: state.versions.has(id) ? 'UPDATE' : 'CREATE', id, record: { ...cleaned, _00_rv: version } });
|
|
97
|
+
seen.add(id);
|
|
98
|
+
}
|
|
99
|
+
if (bodies.length > 0) {
|
|
100
|
+
try {
|
|
101
|
+
const tx = sql.upsertBodiesTx(bodies);
|
|
102
|
+
yield fx.local.execute(tx.query, tx.vars, epoch);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'body write failed', data: { error } });
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
yield fx.ssp.ingest(ingest);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'circuit ingest failed', data: { error } });
|
|
111
|
+
}
|
|
112
|
+
if (state.tabRole === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'ingest', records: ingest } });
|
|
113
|
+
}
|
|
114
|
+
// Ids the server did not return (deleted or unreadable upstream) count as
|
|
115
|
+
// known at the requested version, so the plan stops asking for them; their
|
|
116
|
+
// absence from the store is what the render shows.
|
|
117
|
+
const entries: Array<readonly [string, number]> = requested.map((id) => [id, versions.get(id) as number]);
|
|
118
|
+
yield fx.state.update(R.setVersions(entries));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { queryHashInput, viewKeyInput } from './hash';
|
|
3
|
+
|
|
4
|
+
describe('hash inputs (golden against the previous DataModule formulas)', () => {
|
|
5
|
+
const input = { surql: 'SELECT * FROM t WHERE a = $a', params: { a: 1, b: 'x' } };
|
|
6
|
+
it('salted key is JSON.stringify({surql, params, sessionId}) in that key order', () => {
|
|
7
|
+
expect(queryHashInput(input, 'sess')).toBe(JSON.stringify({ ...input, sessionId: 'sess' }));
|
|
8
|
+
expect(queryHashInput(input, null)).toBe(JSON.stringify({ ...input, sessionId: null }));
|
|
9
|
+
});
|
|
10
|
+
it('view key is the same input minus the salt', () => {
|
|
11
|
+
expect(viewKeyInput(input)).toBe(JSON.stringify(input));
|
|
12
|
+
expect(viewKeyInput(input)).not.toBe(queryHashInput(input, 'sess'));
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash inputs for the two query keys. Kept byte-identical to the previous
|
|
3
|
+
* implementation (`DataModule.calculateHash` / `calculateMembershipKey`):
|
|
4
|
+
* the salted key names the remote `_00_query` row, the unsalted one names the
|
|
5
|
+
* durable `_00_view` row, and both are `sha256(JSON.stringify(input))`.
|
|
6
|
+
*/
|
|
7
|
+
export interface QueryKeyInput {
|
|
8
|
+
surql: string;
|
|
9
|
+
params: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Session-salted: two tabs of one user must not share a remote view. */
|
|
13
|
+
export const queryHashInput = (input: QueryKeyInput, sessionId: string | null): string =>
|
|
14
|
+
JSON.stringify({ surql: input.surql, params: input.params, sessionId });
|
|
15
|
+
|
|
16
|
+
/** Session-independent: survives reload and bucket switch. */
|
|
17
|
+
export const viewKeyInput = (input: QueryKeyInput): string =>
|
|
18
|
+
JSON.stringify({ surql: input.surql, params: input.params });
|