@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
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 +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -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 +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -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 +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -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.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -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 +167 -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 +512 -0
- package/src/live/index.ts +1059 -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 +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -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 +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -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 +301 -0
- package/src/mutation/push.saga.ts +225 -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 +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -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 +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -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 +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -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 +209 -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 +156 -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 +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -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 +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -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 +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -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 +261 -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 +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -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 +806 -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 +234 -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 +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -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 +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -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 +97 -0
- package/src/sync/connection.saga.ts +89 -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 +663 -0
- package/src/sync/policy.ts +515 -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 +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -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 +633 -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 +89 -12
- 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 +29 -18
- package/src/utils/utils.test.ts +3 -2
- 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
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import type { SchemaStructure } from '@spooky-sync/query-builder';
|
|
2
|
+
import type { LoroDoc } from 'loro-crdt';
|
|
3
|
+
import { RecordId } from 'surrealdb';
|
|
4
|
+
import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
|
|
5
|
+
import type { Logger } from '../../services/logger/index';
|
|
6
|
+
import { parseRecordIdString } from '../../utils/index';
|
|
7
|
+
import { check } from './acceptance';
|
|
8
|
+
import { asBytes, randomPeer } from './bytes';
|
|
9
|
+
import { CrdtField } from './crdt-field';
|
|
10
|
+
import { stateHash } from './hash';
|
|
11
|
+
import { loadLoro } from './loro-loader';
|
|
12
|
+
import type { CrdtKind } from './mirror';
|
|
13
|
+
|
|
14
|
+
export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Collaborative (`@crdt`) fields.
|
|
18
|
+
*
|
|
19
|
+
* Each open field is a Loro document. The client never sends the document,
|
|
20
|
+
* only its own edits: every batch of local edits becomes one row of the
|
|
21
|
+
* generated `<table>__crdt_log` table, written through the ordinary outbox
|
|
22
|
+
* (so it survives offline, reloads and retries), and every client receives
|
|
23
|
+
* the other rows through an ordinary query and merges them. Nobody writes the
|
|
24
|
+
* canonical state but the SSP, which folds the log into `_00_crdt_doc` and the
|
|
25
|
+
* parent field's plain text (see `packages/ssp-node/src/crdt`).
|
|
26
|
+
*
|
|
27
|
+
* A row is imported only if it passes the acceptance rule the fold applies
|
|
28
|
+
* (`./acceptance`), so this tab and the server always hold the same ops.
|
|
29
|
+
*
|
|
30
|
+
* Edits carry this tab's Loro peer id, claimed for the signed-in user in
|
|
31
|
+
* `_00_crdt_peer` before the first row is sent; the database refuses a row
|
|
32
|
+
* whose peer its author does not own. A tab picks a fresh peer per session.
|
|
33
|
+
* Offline, edits stay local (and in the local snapshot) until the claim and
|
|
34
|
+
* the outbox can go through.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** A followed query: stop following it, and know when the server answered. */
|
|
38
|
+
export interface Watch {
|
|
39
|
+
stop: () => void;
|
|
40
|
+
/** Resolves once the server's membership for the query is known and its
|
|
41
|
+
* rows have landed locally (never rejects; may never resolve offline). */
|
|
42
|
+
settled: Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The runtime pieces the manager needs from the client facade. */
|
|
46
|
+
export interface CrdtPorts {
|
|
47
|
+
/** Register a query on `table` filtered by `where` and follow its rows. */
|
|
48
|
+
watch(table: string, where: Record<string, unknown>, onRows: (rows: Record<string, unknown>[]) => void): Promise<Watch>;
|
|
49
|
+
/** Optimistic create through the outbox. */
|
|
50
|
+
create(id: string, data: Record<string, unknown>): Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Local-only state of one field: the merged document and own unsent edits. */
|
|
54
|
+
interface LocalState {
|
|
55
|
+
snapshot?: Uint8Array;
|
|
56
|
+
/** Server fold this snapshot includes (for the canonical refetch). */
|
|
57
|
+
rev: number;
|
|
58
|
+
/** Every peer this device edited the field under, and how far its ops
|
|
59
|
+
* are already in the outbox. */
|
|
60
|
+
peers: Record<string, { enqueued: number }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Session {
|
|
64
|
+
key: string;
|
|
65
|
+
table: string;
|
|
66
|
+
recordId: string;
|
|
67
|
+
field: string;
|
|
68
|
+
kind: CrdtKind;
|
|
69
|
+
cursor: boolean;
|
|
70
|
+
doc: LoroDoc;
|
|
71
|
+
handle: CrdtField;
|
|
72
|
+
refs: number;
|
|
73
|
+
local: LocalState;
|
|
74
|
+
/** Log rows already looked at (imported, refused or own). */
|
|
75
|
+
seen: Set<string>;
|
|
76
|
+
/** Log rows in the latest delivery: when some vanish, the server folded. */
|
|
77
|
+
present: Set<string>;
|
|
78
|
+
/** Highest published rev this device's document was checked against. */
|
|
79
|
+
verifiedRev: number;
|
|
80
|
+
verifyTimer: ReturnType<typeof setTimeout> | null;
|
|
81
|
+
disposers: Array<() => void>;
|
|
82
|
+
flushTimer: ReturnType<typeof setTimeout> | null;
|
|
83
|
+
firstDirtyAt: number;
|
|
84
|
+
persistTimer: ReturnType<typeof setTimeout> | null;
|
|
85
|
+
flushing: Promise<void> | null;
|
|
86
|
+
refetching: boolean;
|
|
87
|
+
presenceTimer: ReturnType<typeof setTimeout> | null;
|
|
88
|
+
presencePending: Uint8Array | null;
|
|
89
|
+
/** This session wrote a presence row (removed again on close). */
|
|
90
|
+
presenceSent: boolean;
|
|
91
|
+
/** The latest import of delivered rows (chained, so awaiting it awaits all). */
|
|
92
|
+
ingesting: Promise<void>;
|
|
93
|
+
/** The server answered the published-document read (with a row or none). */
|
|
94
|
+
canonicalAnswered: boolean;
|
|
95
|
+
/** The log watch delivered the server's first answer. */
|
|
96
|
+
logSettled: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const LOCAL_TABLE = '_00_crdt_local';
|
|
100
|
+
const LOG_SUFFIX = '__crdt_log';
|
|
101
|
+
const PRESENCE_SUFFIX = '__crdt_presence';
|
|
102
|
+
/** Longest a burst of typing waits before its edits go out. */
|
|
103
|
+
const MAX_WAIT_MS = 2000;
|
|
104
|
+
const PERSIST_DEBOUNCE_MS = 400;
|
|
105
|
+
const PRESENCE_THROTTLE_MS = 250;
|
|
106
|
+
const CLAIM_RETRY_MS = 5000;
|
|
107
|
+
/** Longest an open or close waits on the server (the first answer for a
|
|
108
|
+
* field with nothing local, the last edits going out) before going on
|
|
109
|
+
* without it. Offline it does not wait at all. */
|
|
110
|
+
const SERVER_WAIT_MS = 4000;
|
|
111
|
+
/** Pause after a fold is noticed before checking the published document. */
|
|
112
|
+
const VERIFY_DELAY_MS = 1000;
|
|
113
|
+
|
|
114
|
+
export class CrdtManager {
|
|
115
|
+
private sessions = new Map<string, Session>();
|
|
116
|
+
private opening = new Map<string, Promise<Session>>();
|
|
117
|
+
/** Closes still saving their field's local state, by session key. */
|
|
118
|
+
private saving = new Map<string, Promise<void>>();
|
|
119
|
+
private ports: CrdtPorts | null = null;
|
|
120
|
+
private logger: Logger;
|
|
121
|
+
private peer: `${number}` = randomPeer();
|
|
122
|
+
/** The current peer's claim: settled once the database holds it for us. */
|
|
123
|
+
private claim: Promise<boolean> | null = null;
|
|
124
|
+
private claimRetry: ReturnType<typeof setTimeout> | null = null;
|
|
125
|
+
private connectionUnsubscribes: Array<() => void> = [];
|
|
126
|
+
|
|
127
|
+
constructor(
|
|
128
|
+
private schema: SchemaStructure,
|
|
129
|
+
private local: LocalStore,
|
|
130
|
+
private remote: RemoteDatabaseService,
|
|
131
|
+
logger: Logger,
|
|
132
|
+
private debounceMs: number = 300
|
|
133
|
+
) {
|
|
134
|
+
this.logger = logger.child({ service: 'CrdtManager' });
|
|
135
|
+
this.connectionUnsubscribes.push(
|
|
136
|
+
this.remote.subscribeConnection('connected', () => {
|
|
137
|
+
for (const s of this.sessions.values()) {
|
|
138
|
+
this.scheduleFlush(s, 0);
|
|
139
|
+
// Opened while the server could not answer: read it now.
|
|
140
|
+
if (!s.handle.isLoaded() && !s.canonicalAnswered) void this.refetchCanonical(s);
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hand over the runtime ports. Called once by the client facade. */
|
|
147
|
+
attach(ports: CrdtPorts): void {
|
|
148
|
+
this.ports = ports;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A new signed-in identity (boot, sign-in, sign-out): edits from here on
|
|
153
|
+
* belong to it, under a peer it claims. Edits already made stay with the
|
|
154
|
+
* peer they were made under.
|
|
155
|
+
*/
|
|
156
|
+
setSessionId(_salt: string): void {
|
|
157
|
+
this.peer = randomPeer();
|
|
158
|
+
this.claim = null;
|
|
159
|
+
for (const s of this.sessions.values()) s.doc.setPeerId(this.peer);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
dispose(): void {
|
|
163
|
+
for (const off of this.connectionUnsubscribes.splice(0)) {
|
|
164
|
+
try {
|
|
165
|
+
off();
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (this.claimRetry) clearTimeout(this.claimRetry);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Open a collaborative field. Every open of the same field shares one
|
|
175
|
+
* document; close each open with {@link close}.
|
|
176
|
+
*
|
|
177
|
+
* `draft` (from {@link createDraft}) hands over edits made before the record
|
|
178
|
+
* existed: they are merged in and sent like this tab's own.
|
|
179
|
+
*/
|
|
180
|
+
async open(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
|
|
181
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
182
|
+
const key = `${table}|${recordId}|${field}`;
|
|
183
|
+
let session = this.sessions.get(key);
|
|
184
|
+
if (session) {
|
|
185
|
+
session.refs++;
|
|
186
|
+
} else {
|
|
187
|
+
const inFlight = this.opening.get(key);
|
|
188
|
+
if (inFlight) {
|
|
189
|
+
session = await inFlight;
|
|
190
|
+
session.refs++;
|
|
191
|
+
} else {
|
|
192
|
+
const pending = this.start(key, table, recordId, field, kind, cursor, !!draft).finally(() => this.opening.delete(key));
|
|
193
|
+
this.opening.set(key, pending);
|
|
194
|
+
session = await pending;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (draft) this.adopt(session, draft.getDoc());
|
|
198
|
+
return session.handle;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A detached document for a field of a record that does not exist yet (a
|
|
203
|
+
* "new thread" form). Nothing is sent; pass it to {@link open} once the
|
|
204
|
+
* record is created and its edits become the record's first ones.
|
|
205
|
+
*/
|
|
206
|
+
async createDraft(table: string, field: string): Promise<CrdtField> {
|
|
207
|
+
const { kind, cursor } = this.fieldSpec(table, field);
|
|
208
|
+
const loro = await loadLoro();
|
|
209
|
+
return new CrdtField(table, '', field, kind, cursor, new loro.LoroDoc(), { pushPresence: () => {} });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private adopt(s: Session, draft: LoroDoc): void {
|
|
213
|
+
const peers = [...draft.oplogVersion().toJSON().keys()];
|
|
214
|
+
if (peers.length === 0) return;
|
|
215
|
+
this.tryImport(s.doc, draft.export({ mode: 'update' }), s.key);
|
|
216
|
+
// The draft's ops carry its own peer: claim and send them under it.
|
|
217
|
+
for (const peer of peers) s.local.peers[String(peer)] ??= { enqueued: 0 };
|
|
218
|
+
this.schedulePersist(s);
|
|
219
|
+
this.scheduleFlush(s, 0);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Close one open. The last close of a field resolves once its unsent
|
|
223
|
+
* edits are in the outbox, or once they are saved on the device when the
|
|
224
|
+
* server does not answer in time (offline: at once). */
|
|
225
|
+
close(table: string, recordId: string, field: string): Promise<void> {
|
|
226
|
+
const s = this.sessions.get(`${table}|${recordId}|${field}`);
|
|
227
|
+
if (!s || --s.refs > 0) return Promise.resolve();
|
|
228
|
+
return this.stop(s, true);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Close every field. `flush: false` (a bucket switch) drops unsent edits
|
|
232
|
+
* from this session's memory; they stay in the local snapshot of the
|
|
233
|
+
* bucket they belong to. */
|
|
234
|
+
closeAll(options: { flush?: boolean } = {}): void {
|
|
235
|
+
for (const s of [...this.sessions.values()]) void this.stop(s, options.flush ?? true);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
private fieldSpec(table: string, field: string): { kind: CrdtKind; cursor: boolean } {
|
|
241
|
+
const t = this.schema.tables.find((x) => x.name === table);
|
|
242
|
+
if (!t) throw new Error(`CrdtManager: unknown table '${table}'`);
|
|
243
|
+
const col = (t.columns as Record<string, any>)[field];
|
|
244
|
+
if (!col) throw new Error(`CrdtManager: unknown field '${table}.${field}'`);
|
|
245
|
+
if (!col.crdt) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`CrdtManager: '${table}.${field}' is not a CRDT field. Add \`-- @crdt text\` or \`-- @crdt prosemirror\` above its DEFINE FIELD and re-run \`spky generate\`.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const kind: CrdtKind = col.crdt === 'prosemirror' ? 'prosemirror' : 'text';
|
|
251
|
+
return { kind, cursor: !!col.cursor };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async start(
|
|
255
|
+
key: string,
|
|
256
|
+
table: string,
|
|
257
|
+
recordId: string,
|
|
258
|
+
field: string,
|
|
259
|
+
kind: CrdtKind,
|
|
260
|
+
cursor: boolean,
|
|
261
|
+
/** Opened with a draft: the record was just created here, so the server
|
|
262
|
+
* has nothing for it yet and the open does not wait on it. */
|
|
263
|
+
fresh = false
|
|
264
|
+
): Promise<Session> {
|
|
265
|
+
if (!this.ports) throw new Error('CrdtManager: not attached to a client');
|
|
266
|
+
const loro = await loadLoro();
|
|
267
|
+
const doc = new loro.LoroDoc();
|
|
268
|
+
doc.setPeerId(this.peer);
|
|
269
|
+
|
|
270
|
+
// A close of this field may still be saving: read what it saves.
|
|
271
|
+
await this.saving.get(key);
|
|
272
|
+
const local = await this.readLocal(recordId, field);
|
|
273
|
+
if (local.snapshot) this.tryImport(doc, local.snapshot, key);
|
|
274
|
+
|
|
275
|
+
const session: Session = {
|
|
276
|
+
key,
|
|
277
|
+
table,
|
|
278
|
+
recordId,
|
|
279
|
+
field,
|
|
280
|
+
kind,
|
|
281
|
+
cursor,
|
|
282
|
+
doc,
|
|
283
|
+
handle: undefined as unknown as CrdtField,
|
|
284
|
+
refs: 1,
|
|
285
|
+
local,
|
|
286
|
+
seen: new Set(),
|
|
287
|
+
present: new Set(),
|
|
288
|
+
verifiedRev: 0,
|
|
289
|
+
verifyTimer: null,
|
|
290
|
+
disposers: [],
|
|
291
|
+
flushTimer: null,
|
|
292
|
+
firstDirtyAt: 0,
|
|
293
|
+
persistTimer: null,
|
|
294
|
+
flushing: null,
|
|
295
|
+
refetching: false,
|
|
296
|
+
presenceTimer: null,
|
|
297
|
+
presencePending: null,
|
|
298
|
+
presenceSent: false,
|
|
299
|
+
ingesting: Promise.resolve(),
|
|
300
|
+
canonicalAnswered: false,
|
|
301
|
+
logSettled: false,
|
|
302
|
+
};
|
|
303
|
+
session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, {
|
|
304
|
+
pushPresence: (state) => this.pushPresence(session, state),
|
|
305
|
+
});
|
|
306
|
+
// Known from the start: this device's copy, or a record just created here.
|
|
307
|
+
if (local.snapshot || fresh) session.handle.markLoaded();
|
|
308
|
+
this.sessions.set(key, session);
|
|
309
|
+
|
|
310
|
+
// Claimed now, so the first edits do not wait on the round trip.
|
|
311
|
+
void this.ensureClaimed();
|
|
312
|
+
// First open on this device: start from the published document. Offline
|
|
313
|
+
// it is read once the connection is back instead of holding the open.
|
|
314
|
+
if (!local.snapshot && !fresh) await this.serverWait(this.refetchCanonical(session));
|
|
315
|
+
|
|
316
|
+
session.disposers.push(
|
|
317
|
+
doc.subscribeLocalUpdates(() => {
|
|
318
|
+
// Remember every peer that made edits here: a later identity change
|
|
319
|
+
// swaps the peer, and these ops must still go out under this one.
|
|
320
|
+
session.local.peers[this.peer] ??= { enqueued: 0 };
|
|
321
|
+
this.scheduleFlush(session);
|
|
322
|
+
this.schedulePersist(session);
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
const ports = this.ports;
|
|
326
|
+
const log = await ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
|
|
327
|
+
session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
|
|
328
|
+
});
|
|
329
|
+
session.disposers.push(log.stop);
|
|
330
|
+
void log.settled.then(() => {
|
|
331
|
+
session.logSettled = true;
|
|
332
|
+
void session.ingesting.then(() => this.checkLoaded(session));
|
|
333
|
+
});
|
|
334
|
+
if (cursor) {
|
|
335
|
+
const presence = await ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) =>
|
|
336
|
+
this.ingestPresence(session, rows)
|
|
337
|
+
);
|
|
338
|
+
session.disposers.push(presence.stop);
|
|
339
|
+
}
|
|
340
|
+
// An editor binding an empty document creates its root structure, and
|
|
341
|
+
// two clients doing that at once keep only one of them (with whatever was
|
|
342
|
+
// typed into the other). With nothing local, hand the document out only
|
|
343
|
+
// once the server's rows for it are in.
|
|
344
|
+
if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
|
|
345
|
+
await session.ingesting;
|
|
346
|
+
// Edits a previous session made but never sent (closed tab, offline).
|
|
347
|
+
this.scheduleFlush(session, 0);
|
|
348
|
+
this.logger.debug({ key, peer: this.peer, Category: 'sp00ky-client::CrdtManager::open' }, 'CRDT field opened');
|
|
349
|
+
return session;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async stop(s: Session, flush: boolean): Promise<void> {
|
|
353
|
+
this.sessions.delete(s.key);
|
|
354
|
+
for (const off of s.disposers.splice(0)) {
|
|
355
|
+
try {
|
|
356
|
+
off();
|
|
357
|
+
} catch {
|
|
358
|
+
/* ignore */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (s.flushTimer) clearTimeout(s.flushTimer);
|
|
362
|
+
if (s.persistTimer) clearTimeout(s.persistTimer);
|
|
363
|
+
if (s.presenceTimer) clearTimeout(s.presenceTimer);
|
|
364
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
365
|
+
if (s.presenceSent) void this.dropPresence(s);
|
|
366
|
+
if (flush) {
|
|
367
|
+
const saved = this.persist(s);
|
|
368
|
+
this.saving.set(s.key, saved);
|
|
369
|
+
await saved;
|
|
370
|
+
if (this.saving.get(s.key) === saved) this.saving.delete(s.key);
|
|
371
|
+
await this.serverWait(this.flush(s));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Wait for server-bound `work`, at most {@link SERVER_WAIT_MS}, and not at
|
|
377
|
+
* all while disconnected: a remote call made then queues behind the
|
|
378
|
+
* reconnect for up to the query timeout, and an offline "new thread" form
|
|
379
|
+
* must not hang on it. The work itself goes on either way.
|
|
380
|
+
*/
|
|
381
|
+
private serverWait(work: Promise<unknown>): Promise<unknown> {
|
|
382
|
+
if (this.remote.getStatus() !== 'connected') return Promise.resolve();
|
|
383
|
+
return Promise.race([work, delay(SERVER_WAIT_MS)]);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- receiving ---------------------------------------------------
|
|
387
|
+
|
|
388
|
+
private async ingest(s: Session, rows: Record<string, unknown>[]): Promise<void> {
|
|
389
|
+
const loro = await loadLoro();
|
|
390
|
+
const ids = new Set(rows.map((row) => String(row.id ?? '')));
|
|
391
|
+
for (const id of s.present) {
|
|
392
|
+
if (!ids.has(id)) {
|
|
393
|
+
this.scheduleVerify(s);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
s.present = ids;
|
|
398
|
+
const blobs: Uint8Array[] = [];
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const id = String(row.id ?? '');
|
|
401
|
+
if (!id || s.seen.has(id)) continue;
|
|
402
|
+
s.seen.add(id);
|
|
403
|
+
const blob = asBytes(row.data);
|
|
404
|
+
const verdict = check(loro, blob, row.peer == null ? undefined : String(row.peer));
|
|
405
|
+
if (!verdict.ok) {
|
|
406
|
+
this.logger.debug({ row: id, reason: verdict.reason, Category: 'sp00ky-client::CrdtManager::ingest' }, 'CRDT log row refused');
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
blobs.push(blob!);
|
|
410
|
+
}
|
|
411
|
+
if (blobs.length === 0) return;
|
|
412
|
+
let pending = false;
|
|
413
|
+
for (const blob of blobs) {
|
|
414
|
+
const status = this.tryImport(s.doc, blob, s.key);
|
|
415
|
+
if (status?.pending) pending = true;
|
|
416
|
+
}
|
|
417
|
+
this.checkLoaded(s);
|
|
418
|
+
this.schedulePersist(s);
|
|
419
|
+
// Ops depending on rows that were folded away before this device saw
|
|
420
|
+
// them: the published snapshot holds those predecessors.
|
|
421
|
+
if (pending) void this.refetchCanonical(s);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Mark the field loaded once its content arrived, or the server answered
|
|
425
|
+
* both reads (the published document and the log) with nothing. */
|
|
426
|
+
private checkLoaded(s: Session): void {
|
|
427
|
+
if (s.handle.hasContent() || (s.canonicalAnswered && s.logSettled)) s.handle.markLoaded();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private tryImport(doc: LoroDoc, blob: Uint8Array, key: string): { pending: boolean } | null {
|
|
431
|
+
try {
|
|
432
|
+
const status = doc.import(blob);
|
|
433
|
+
return { pending: !!status.pending && status.pending.size > 0 };
|
|
434
|
+
} catch (e) {
|
|
435
|
+
this.logger.warn({ key, error: String(e), Category: 'sp00ky-client::CrdtManager::import' }, 'CRDT import failed');
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private async refetchCanonical(s: Session): Promise<void> {
|
|
441
|
+
if (s.refetching) return;
|
|
442
|
+
s.refetching = true;
|
|
443
|
+
try {
|
|
444
|
+
const [row] = await this.remote.query<[{ rev?: number; snapshot?: unknown } | null]>(
|
|
445
|
+
"SELECT rev, snapshot FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
|
|
446
|
+
{ doc: s.recordId, field: s.field }
|
|
447
|
+
);
|
|
448
|
+
const snapshot = asBytes(row?.snapshot);
|
|
449
|
+
if (snapshot) {
|
|
450
|
+
this.tryImport(s.doc, snapshot, s.key);
|
|
451
|
+
s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
|
|
452
|
+
this.schedulePersist(s);
|
|
453
|
+
}
|
|
454
|
+
s.canonicalAnswered = true;
|
|
455
|
+
this.checkLoaded(s);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
// Offline or no published document yet: the log rows still arrive.
|
|
458
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::refetch' }, 'No canonical CRDT document fetched');
|
|
459
|
+
} finally {
|
|
460
|
+
s.refetching = false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// --- checking against the published document ---------------------
|
|
465
|
+
|
|
466
|
+
private scheduleVerify(s: Session): void {
|
|
467
|
+
if (s.verifyTimer) clearTimeout(s.verifyTimer);
|
|
468
|
+
s.verifyTimer = setTimeout(() => {
|
|
469
|
+
s.verifyTimer = null;
|
|
470
|
+
void this.verify(s);
|
|
471
|
+
}, VERIFY_DELAY_MS);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* After a fold: compare this device's document with the one the server
|
|
476
|
+
* published, at the version the server published. Missing ops (rows this
|
|
477
|
+
* device never got before they were folded away) are repaired by merging
|
|
478
|
+
* the published snapshot in. The same ops giving a different state cannot
|
|
479
|
+
* be merged away; that is logged.
|
|
480
|
+
*/
|
|
481
|
+
private async verify(s: Session): Promise<void> {
|
|
482
|
+
type Published = {
|
|
483
|
+
rev?: number;
|
|
484
|
+
state_hash?: string;
|
|
485
|
+
upto?: { vv?: Record<string, number>; frontiers?: { peer: `${number}`; counter: number }[] };
|
|
486
|
+
};
|
|
487
|
+
let row: Published | null;
|
|
488
|
+
try {
|
|
489
|
+
[row] = await this.remote.query<[Published | null]>(
|
|
490
|
+
"SELECT rev, upto, state_hash FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
|
|
491
|
+
{ doc: s.recordId, field: s.field }
|
|
492
|
+
);
|
|
493
|
+
} catch {
|
|
494
|
+
return; // offline: the next fold checks again
|
|
495
|
+
}
|
|
496
|
+
const rev = row?.rev ?? 0;
|
|
497
|
+
const frontiers = row?.upto?.frontiers;
|
|
498
|
+
if (!row?.state_hash || !frontiers || !row.upto?.vv || rev <= s.verifiedRev || !this.sessions.has(s.key)) return;
|
|
499
|
+
|
|
500
|
+
const have = s.doc.oplogVersion().toJSON();
|
|
501
|
+
const behind = Object.entries(row.upto.vv).some(([peer, end]) => (have.get(peer as `${number}`) ?? 0) < end);
|
|
502
|
+
if (behind) {
|
|
503
|
+
this.logger.info({ key: s.key, rev, Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document behind the published one; merging it in');
|
|
504
|
+
await this.refetchCanonical(s);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
s.verifiedRev = rev;
|
|
508
|
+
// WebCrypto is missing on insecure origins: nothing to check with there.
|
|
509
|
+
if (!globalThis.crypto?.subtle) return;
|
|
510
|
+
const loro = await loadLoro();
|
|
511
|
+
let fork: LoroDoc | undefined;
|
|
512
|
+
try {
|
|
513
|
+
fork = s.doc.forkAt(frontiers);
|
|
514
|
+
const hash = await stateHash(loro, fork);
|
|
515
|
+
if (hash !== row.state_hash) {
|
|
516
|
+
this.logger.warn(
|
|
517
|
+
{ key: s.key, rev, hash, published: row.state_hash, Category: 'sp00ky-client::CrdtManager::verify' },
|
|
518
|
+
'CRDT document differs from the published one at the same version'
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
} catch (e) {
|
|
522
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document check failed');
|
|
523
|
+
} finally {
|
|
524
|
+
fork?.free();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// --- sending -----------------------------------------------------
|
|
529
|
+
|
|
530
|
+
private scheduleFlush(s: Session, delay = this.debounceMs): void {
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
if (!s.flushTimer) s.firstDirtyAt = now;
|
|
533
|
+
else clearTimeout(s.flushTimer);
|
|
534
|
+
const wait = Math.max(0, Math.min(delay, s.firstDirtyAt + MAX_WAIT_MS - now));
|
|
535
|
+
s.flushTimer = setTimeout(() => {
|
|
536
|
+
s.flushTimer = null;
|
|
537
|
+
void this.flush(s);
|
|
538
|
+
}, wait);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private async flush(s: Session): Promise<void> {
|
|
542
|
+
if (s.flushing) return s.flushing;
|
|
543
|
+
s.flushing = this.sendOwnEdits(s).finally(() => {
|
|
544
|
+
s.flushing = null;
|
|
545
|
+
});
|
|
546
|
+
return s.flushing;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Send every own peer's ops that are not in the outbox yet, one row per
|
|
550
|
+
* peer. Only the peer's own ops travel (`updates-in-range`): a plain
|
|
551
|
+
* `from: version` export would carry ops imported from others too, and
|
|
552
|
+
* the acceptance rule refuses such a row. */
|
|
553
|
+
private async sendOwnEdits(s: Session): Promise<void> {
|
|
554
|
+
const vv = s.doc.oplogVersion();
|
|
555
|
+
const own = new Set([...Object.keys(s.local.peers), this.peer]);
|
|
556
|
+
const due = [...own]
|
|
557
|
+
.map((peer) => ({ peer, end: vv.get(peer as `${number}`) ?? 0, from: s.local.peers[peer]?.enqueued ?? 0 }))
|
|
558
|
+
.filter((p) => p.end > p.from);
|
|
559
|
+
if (due.length === 0 || !this.ports) return;
|
|
560
|
+
if (!(await this.ensureClaimed())) {
|
|
561
|
+
this.retryLater(s);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
for (const { peer, end, from } of due) {
|
|
565
|
+
if (peer !== this.peer && !(await this.claimPeer(peer as `${number}`))) continue;
|
|
566
|
+
const data = s.doc.export({
|
|
567
|
+
mode: 'updates-in-range',
|
|
568
|
+
spans: [{ id: { peer: peer as `${number}`, counter: from }, len: end - from }],
|
|
569
|
+
});
|
|
570
|
+
try {
|
|
571
|
+
await this.ports.create(`${s.table}${LOG_SUFFIX}:${rowKey()}`, {
|
|
572
|
+
doc: s.recordId,
|
|
573
|
+
field: s.field,
|
|
574
|
+
peer,
|
|
575
|
+
data,
|
|
576
|
+
});
|
|
577
|
+
} catch (e) {
|
|
578
|
+
this.logger.warn({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::flush' }, 'Queueing CRDT edits failed');
|
|
579
|
+
this.retryLater(s);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
s.local.peers[peer] = { enqueued: end };
|
|
583
|
+
}
|
|
584
|
+
await this.persist(s);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private retryLater(s: Session): void {
|
|
588
|
+
if (this.sessions.get(s.key) === s) this.scheduleFlush(s, CLAIM_RETRY_MS);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Claim this session's peer once; a failure (offline, signed out) is retried. */
|
|
592
|
+
private ensureClaimed(): Promise<boolean> {
|
|
593
|
+
if (!this.claim) {
|
|
594
|
+
const peer = this.peer;
|
|
595
|
+
this.claim = this.claimPeer(peer).then((ok) => {
|
|
596
|
+
if (!ok && this.peer === peer) this.claim = null;
|
|
597
|
+
return ok;
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return this.claim;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private async claimPeer(peer: `${number}`): Promise<boolean> {
|
|
604
|
+
try {
|
|
605
|
+
// A refused CREATE (signed out, impersonating) answers with no row.
|
|
606
|
+
const [created] = await this.remote.query<[unknown[]]>("CREATE type::record('_00_crdt_peer', $peer) RETURN id", { peer });
|
|
607
|
+
return Array.isArray(created) && created.length > 0;
|
|
608
|
+
} catch (e) {
|
|
609
|
+
if (!/already exists/i.test(String(e))) return false;
|
|
610
|
+
// Ours from an earlier claim (only its owner can read it), or taken.
|
|
611
|
+
try {
|
|
612
|
+
const [owner] = await this.remote.query<[unknown]>(
|
|
613
|
+
"SELECT VALUE author FROM ONLY type::record('_00_crdt_peer', $peer)",
|
|
614
|
+
{ peer }
|
|
615
|
+
);
|
|
616
|
+
if (owner != null) return true;
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
this.logger.error({ peer, Category: 'sp00ky-client::CrdtManager::claim' }, 'CRDT peer id taken by another user');
|
|
621
|
+
if (peer === this.peer) this.setSessionId('');
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- local state -------------------------------------------------
|
|
627
|
+
|
|
628
|
+
private localId(recordId: string, field: string): RecordId {
|
|
629
|
+
return new RecordId(LOCAL_TABLE, `${recordId}|${field}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async readLocal(recordId: string, field: string): Promise<LocalState> {
|
|
633
|
+
try {
|
|
634
|
+
const row = await this.local.getById(LOCAL_TABLE, this.localId(recordId, field));
|
|
635
|
+
if (row) {
|
|
636
|
+
return {
|
|
637
|
+
snapshot: asBytes(row.snapshot),
|
|
638
|
+
rev: Number(row.rev ?? 0),
|
|
639
|
+
peers: (row.peers as LocalState['peers']) ?? {},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
} catch (e) {
|
|
643
|
+
this.logger.debug({ error: String(e), Category: 'sp00ky-client::CrdtManager::readLocal' }, 'No local CRDT state');
|
|
644
|
+
}
|
|
645
|
+
return { rev: 0, peers: {} };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private schedulePersist(s: Session): void {
|
|
649
|
+
if (s.persistTimer) return;
|
|
650
|
+
s.persistTimer = setTimeout(() => {
|
|
651
|
+
s.persistTimer = null;
|
|
652
|
+
void this.persist(s);
|
|
653
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Save the merged document. Another tab may have saved too: merge with
|
|
657
|
+
* its snapshot rather than overwrite it, and keep the further of the two
|
|
658
|
+
* outbox marks per peer. */
|
|
659
|
+
private async persist(s: Session): Promise<void> {
|
|
660
|
+
try {
|
|
661
|
+
const stored = await this.readLocal(s.recordId, s.field);
|
|
662
|
+
if (stored.snapshot) this.tryImport(s.doc, stored.snapshot, s.key);
|
|
663
|
+
for (const [peer, mark] of Object.entries(stored.peers)) {
|
|
664
|
+
const mine = s.local.peers[peer]?.enqueued ?? 0;
|
|
665
|
+
if (mark.enqueued > mine) s.local.peers[peer] = { enqueued: mark.enqueued };
|
|
666
|
+
}
|
|
667
|
+
s.local.rev = Math.max(s.local.rev, stored.rev);
|
|
668
|
+
s.local.snapshot = s.doc.export({ mode: 'snapshot' });
|
|
669
|
+
await this.local.upsert(
|
|
670
|
+
LOCAL_TABLE,
|
|
671
|
+
this.localId(s.recordId, s.field),
|
|
672
|
+
{ doc: s.recordId, field: s.field, snapshot: s.local.snapshot, rev: s.local.rev, peers: s.local.peers },
|
|
673
|
+
'replace'
|
|
674
|
+
);
|
|
675
|
+
} catch (e) {
|
|
676
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::persist' }, 'Saving local CRDT state failed');
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// --- presence ----------------------------------------------------
|
|
681
|
+
|
|
682
|
+
private pushPresence(s: Session, state: Uint8Array): void {
|
|
683
|
+
s.presencePending = state;
|
|
684
|
+
if (s.presenceTimer) return;
|
|
685
|
+
s.presenceTimer = setTimeout(() => {
|
|
686
|
+
s.presenceTimer = null;
|
|
687
|
+
const data = s.presencePending;
|
|
688
|
+
s.presencePending = null;
|
|
689
|
+
if (data) void this.sendPresence(s, data);
|
|
690
|
+
}, PRESENCE_THROTTLE_MS);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Presence is the latest state only: no outbox, no offline queue. */
|
|
694
|
+
private async sendPresence(s: Session, state: Uint8Array): Promise<void> {
|
|
695
|
+
if (!(await this.ensureClaimed())) return;
|
|
696
|
+
try {
|
|
697
|
+
await this.remote.query(
|
|
698
|
+
// One row per session and field, keyed by a plain identifier (an array
|
|
699
|
+
// key does not survive the client's record-id handling). `$session`
|
|
700
|
+
// is a protected SurrealDB variable: the session id binds as `$sid`.
|
|
701
|
+
`UPSERT type::record($table, $key) SET doc = type::record($doc), field = $field, session = $sid, state = $state RETURN NONE`,
|
|
702
|
+
{
|
|
703
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
704
|
+
key: presenceKey(this.peer, s.field, s.recordId),
|
|
705
|
+
doc: s.recordId,
|
|
706
|
+
field: s.field,
|
|
707
|
+
sid: this.peer,
|
|
708
|
+
state,
|
|
709
|
+
}
|
|
710
|
+
);
|
|
711
|
+
s.presenceSent = true;
|
|
712
|
+
} catch (e) {
|
|
713
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence update failed');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Remove this session's cursor when the field closes; the server's TTL
|
|
718
|
+
* sweep covers a tab that never gets here. */
|
|
719
|
+
private async dropPresence(s: Session): Promise<void> {
|
|
720
|
+
try {
|
|
721
|
+
await this.remote.query(`DELETE type::record($table, $key) RETURN NONE`, {
|
|
722
|
+
table: `${s.table}${PRESENCE_SUFFIX}`,
|
|
723
|
+
key: presenceKey(this.peer, s.field, s.recordId),
|
|
724
|
+
});
|
|
725
|
+
} catch (e) {
|
|
726
|
+
this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence removal failed');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
private ingestPresence(s: Session, rows: Record<string, unknown>[]): void {
|
|
732
|
+
for (const row of rows) {
|
|
733
|
+
if (String(row.session ?? '') === this.peer) continue;
|
|
734
|
+
const state = asBytes(row.state);
|
|
735
|
+
if (state) s.handle.deliverPresence(state);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** `p<session>_<field>_<record key>`, letters, digits and `_` only. */
|
|
741
|
+
function presenceKey(session: string, field: string, recordId: string): string {
|
|
742
|
+
const key = recordId.slice(recordId.indexOf(':') + 1).replace(/[^A-Za-z0-9_]/g, '_');
|
|
743
|
+
return `p${session}_${field}_${key}`;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function delay(ms: number): Promise<void> {
|
|
747
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function rowKey(): string {
|
|
751
|
+
const bytes = new Uint8Array(12);
|
|
752
|
+
crypto.getRandomValues(bytes);
|
|
753
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Kept for callers that parse record ids next to CRDT fields.
|
|
757
|
+
export { parseRecordIdString };
|