@doync/client 0.1.0

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -0
  3. package/dist/adapter.cjs +1 -0
  4. package/dist/adapter.d.cts +86 -0
  5. package/dist/adapter.d.cts.map +1 -0
  6. package/dist/adapter.d.ts +86 -0
  7. package/dist/adapter.d.ts.map +1 -0
  8. package/dist/adapter.js +2 -0
  9. package/dist/adapter.js.map +1 -0
  10. package/dist/client-C6jAdhbe.cjs +15 -0
  11. package/dist/client-CNyLMCw0.d.ts +812 -0
  12. package/dist/client-CNyLMCw0.d.ts.map +1 -0
  13. package/dist/client-ClV8ce6X.js +16 -0
  14. package/dist/client-ClV8ce6X.js.map +1 -0
  15. package/dist/client-DHXO0dbf.d.cts +812 -0
  16. package/dist/client-DHXO0dbf.d.cts.map +1 -0
  17. package/dist/index.cjs +0 -0
  18. package/dist/index.d.cts +2 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +0 -0
  21. package/dist/internal.cjs +1 -0
  22. package/dist/internal.d.cts +69 -0
  23. package/dist/internal.d.cts.map +1 -0
  24. package/dist/internal.d.ts +69 -0
  25. package/dist/internal.d.ts.map +1 -0
  26. package/dist/internal.js +2 -0
  27. package/dist/internal.js.map +1 -0
  28. package/package.json +79 -0
  29. package/src/adapter.ts +25 -0
  30. package/src/client-mutation-registry.ts +31 -0
  31. package/src/client.ts +100 -0
  32. package/src/engine.ts +3322 -0
  33. package/src/identity.ts +41 -0
  34. package/src/index.ts +36 -0
  35. package/src/internal.ts +37 -0
  36. package/src/migrate.ts +156 -0
  37. package/src/mutations.ts +96 -0
  38. package/src/port.ts +74 -0
  39. package/src/raw-read.ts +135 -0
  40. package/src/replica/db/0000_replica_engine_v0.sql +20 -0
  41. package/src/replica/db/0001_release_stamps.sql +6 -0
  42. package/src/replica/db/meta/0000_snapshot.json +129 -0
  43. package/src/replica/db/meta/0001_snapshot.json +167 -0
  44. package/src/replica/db/meta/_journal.json +20 -0
  45. package/src/replica/db/schema.ts +67 -0
  46. package/src/replica/index.ts +185 -0
  47. package/src/replica/meta.ts +26 -0
  48. package/src/replica/stamps.ts +102 -0
  49. package/src/replica/track.ts +190 -0
  50. package/src/socket-reconnect.ts +242 -0
  51. package/src/socket.ts +44 -0
  52. package/src/sql-raw.d.ts +4 -0
@@ -0,0 +1,190 @@
1
+ import type { LocalDb, LocalRow } from '../port'
2
+
3
+ /**
4
+ * Replica engine track + LocalDb twin of server `runEngineTrack` (ADR-0024 /
5
+ * #159). Drizzle migrations under `./db/` via `*.sql?raw` (#156); journal by
6
+ * idx; split on statement-breakpoint. Ledger `__doync_engine` survives wipe
7
+ * with pending + clientId.
8
+ */
9
+ import migration0000 from './db/0000_replica_engine_v0.sql?raw'
10
+ import migration0001 from './db/0001_release_stamps.sql?raw'
11
+ import journal from './db/meta/_journal.json'
12
+ import { readMeta, writeMeta } from './meta'
13
+
14
+ /** One ordered engine-track step: the statements of one migration file. */
15
+ export type EngineTrackStep = readonly string[]
16
+
17
+ /**
18
+ * Drizzle-kit journal shape (min fields we consume). Kept local so the client
19
+ * does not depend on drizzle-kit at runtime.
20
+ */
21
+ type Journal = {
22
+ readonly entries: readonly {
23
+ readonly idx: number
24
+ readonly tag: string
25
+ }[]
26
+ }
27
+
28
+ /** Migration tag → raw SQL text, filled by the raw imports above. */
29
+ const MIGRATION_SQL: Readonly<Record<string, string>> = {
30
+ '0000_replica_engine_v0': migration0000,
31
+ '0001_release_stamps': migration0001,
32
+ }
33
+
34
+ /**
35
+ * Assembled track: journal order, each migration split on drizzle's breakpoint
36
+ * marker. Empty statements (trailing markers) drop out.
37
+ */
38
+ export const REPLICA_ENGINE_TRACK: readonly EngineTrackStep[] = (
39
+ journal as Journal
40
+ ).entries
41
+ .slice()
42
+ .sort((a, b) => a.idx - b.idx)
43
+ .map((entry) => {
44
+ const sql = MIGRATION_SQL[entry.tag]
45
+ if (sql === undefined) {
46
+ throw new Error(
47
+ `doync: replica engine track missing sql for journal tag ${JSON.stringify(entry.tag)}`,
48
+ )
49
+ }
50
+ return sql
51
+ .split(/-->\s*statement-breakpoint/)
52
+ .map((s) => s.trim())
53
+ .filter((s) => s.length > 0)
54
+ })
55
+
56
+ /** Outcome of applying (or rebuilding) the replica engine track. */
57
+ export type EngineTrackResult = {
58
+ /**
59
+ * True when ledger was above bundled length: engine tables wiped/rebuilt,
60
+ * survivors restored best-effort. Caller should finish consumer
61
+ * wipe-and-resync.
62
+ */
63
+ readonly rolledBack: boolean
64
+ }
65
+
66
+ /** Unversioned ledger bootstrap — created before any track step. */
67
+ function ensureEngineLedger(db: LocalDb): void {
68
+ db.exec(
69
+ `CREATE TABLE IF NOT EXISTS __doync_engine (
70
+ k TEXT PRIMARY KEY,
71
+ v
72
+ ) WITHOUT ROWID`,
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Apply every track step from `fromVersion` (0-based exclusive start of the
78
+ * next step) up to the track length, recording `track_version` after each.
79
+ */
80
+ function applyTrackFrom(db: LocalDb, fromVersion: number): void {
81
+ let version = fromVersion
82
+ while (version < REPLICA_ENGINE_TRACK.length) {
83
+ const step = REPLICA_ENGINE_TRACK[version]
84
+ if (step === undefined) break
85
+ const next = version + 1
86
+ for (const statement of step) {
87
+ db.exec(statement)
88
+ }
89
+ db.exec(
90
+ `INSERT INTO __doync_engine (k, v) VALUES ('track_version', ?)
91
+ ON CONFLICT (k) DO UPDATE SET v = excluded.v`,
92
+ next,
93
+ )
94
+ version = next
95
+ }
96
+ }
97
+
98
+ /**
99
+ * LocalDb twin of the server's `runEngineTrack` (packages/server/src/boot.ts):
100
+ *
101
+ * 1. Create `__doync_engine (k, v)` unversioned (`CREATE TABLE IF NOT EXISTS`).
102
+ * 2. Read `track_version` (absent → 0).
103
+ * 3. Rollback tripwire: version above bundled length → `console.warn`, rebuild
104
+ * engine tables from scratch (preserve pending + clientId best-effort),
105
+ * return `{ rolledBack: true }`. NOT a throw (Origin-only).
106
+ * 4. Else apply every step above the ledger in order, record the new version after
107
+ * each step.
108
+ */
109
+ export function runReplicaEngineTrack(db: LocalDb): EngineTrackResult {
110
+ ensureEngineLedger(db)
111
+ const recorded = db.exec<{ v: string | number | null }>(
112
+ `SELECT v FROM __doync_engine WHERE k = 'track_version'`,
113
+ )
114
+ let version = recorded.length === 0 ? 0 : Number(recorded[0]?.v)
115
+ if (!Number.isFinite(version) || version < 0) version = 0
116
+
117
+ if (version > REPLICA_ENGINE_TRACK.length) {
118
+ console.warn(
119
+ `doync: replica engine track_version ${version} is above bundled track ` +
120
+ `length ${REPLICA_ENGINE_TRACK.length} — wiping engine tables and ` +
121
+ `rebuilding (code rollback against a newer database). Consumer state ` +
122
+ `will resync through wipe-and-resync.`,
123
+ )
124
+ rebuildEngineTables(db)
125
+ return { rolledBack: true }
126
+ }
127
+ try {
128
+ applyTrackFrom(db, version)
129
+ } catch (error) {
130
+ console.warn(
131
+ `doync: replica engine track failed at version ${version} — wiping ` +
132
+ `engine tables and rebuilding (code rollback against a newer database). ` +
133
+ `Consumer state will resync through wipe-and-resync.`,
134
+ error,
135
+ )
136
+ rebuildEngineTables(db)
137
+ return { rolledBack: true }
138
+ }
139
+ return { rolledBack: false }
140
+ }
141
+
142
+ /**
143
+ * Wipe and re-apply engine track (tripwire). Best-effort capture of survivors
144
+ * before DROP (wipe-and-resync-shaped, not forget).
145
+ */
146
+ function rebuildEngineTables(db: LocalDb): void {
147
+ type PendingRow = LocalRow & {
148
+ mutation_id: number
149
+ name: string
150
+ args: string
151
+ idem_key: string | null
152
+ }
153
+ let clientId: string | null = null
154
+ let nextMutationId: string | null = null
155
+ let logoutBehavior: string | null = null
156
+ let pending: PendingRow[] = []
157
+ try {
158
+ clientId = readMeta(db, 'client_id')
159
+ nextMutationId = readMeta(db, 'next_mutation_id')
160
+ logoutBehavior = readMeta(db, 'logout_behavior')
161
+ pending = db.exec<PendingRow>(
162
+ `SELECT mutation_id, name, args, idem_key FROM __doync_pending`,
163
+ )
164
+ } catch {
165
+ // Unknown table shape under true rollback — survivors gone.
166
+ }
167
+
168
+ db.exec(`DROP TABLE IF EXISTS __doync_release_stamp`)
169
+ db.exec(`DROP TABLE IF EXISTS __doync_membership`)
170
+ db.exec(`DROP TABLE IF EXISTS __doync_meta`)
171
+ db.exec(`DROP TABLE IF EXISTS __doync_pending`)
172
+ db.exec(`DROP TABLE IF EXISTS __doync_engine`)
173
+
174
+ ensureEngineLedger(db)
175
+ applyTrackFrom(db, 0)
176
+
177
+ if (clientId !== null) writeMeta(db, 'client_id', clientId)
178
+ if (nextMutationId !== null) writeMeta(db, 'next_mutation_id', nextMutationId)
179
+ if (logoutBehavior !== null) writeMeta(db, 'logout_behavior', logoutBehavior)
180
+ for (const row of pending) {
181
+ db.exec(
182
+ `INSERT INTO __doync_pending (mutation_id, name, args, idem_key)
183
+ VALUES (?, ?, ?, ?)`,
184
+ row.mutation_id,
185
+ row.name,
186
+ row.args,
187
+ row.idem_key,
188
+ )
189
+ }
190
+ }
@@ -0,0 +1,242 @@
1
+ // ADR-0016; closeio/doync#90/#185 — shared reconnect policy for web and mobile.
2
+ // Buffers sends, exponential backoff on unexpected close, keepalive ping,
3
+ // drops stale outbox on reconnect (re-handshake resends pending).
4
+
5
+ import { PING_FRAME } from '@doync/core/internal'
6
+
7
+ /**
8
+ * Minimal socket surface the reconnect coordinator drives — a structural subset
9
+ * of browser `WebSocket` so fakes work under test.
10
+ */
11
+ export interface ReconnectSocket {
12
+ readonly readyState: 'connecting' | 'open' | 'closed'
13
+ send(frame: string): void
14
+ close(): void
15
+ onopen: (() => void) | null
16
+ onmessage: ((data: string) => void) | null
17
+ /**
18
+ * Socket closed. `code` is the WebSocket close code when available (1008 =
19
+ * auth-close).
20
+ */
21
+ onclose: ((code?: number) => void) | null
22
+ /** Transport-level error; browsers fire it before `onclose`. */
23
+ onerror: (() => void) | null
24
+ }
25
+
26
+ /**
27
+ * How an unexpected drop looked: optional WebSocket close `code`, and whether a
28
+ * transport error preceded the close. Feeds disconnected / error / needs-auth
29
+ * status decisions.
30
+ */
31
+ export interface SocketCloseInfo {
32
+ readonly code?: number
33
+ readonly wasError?: boolean
34
+ }
35
+
36
+ /** Environment the reconnect coordinator needs (all injectable for tests). */
37
+ export interface ReconnectingSocketConfig {
38
+ /** Open a fresh server socket. Called on `open()` and each reconnect. */
39
+ connect(): ReconnectSocket
40
+ /**
41
+ * Fired on every (re)open — host should re-handshake and re-send pending
42
+ * pushes.
43
+ */
44
+ onReconnect(): void
45
+ /** A (re)connect attempt started (initial open and every backoff retry). */
46
+ onConnecting?(): void
47
+ /** Unexpected drop (not an explicit `close()`). Drives connection status. */
48
+ onDisconnected?(info: SocketCloseInfo): void
49
+ /** Server→client frame (JSON text). */
50
+ onMessage?(data: string): void
51
+ /** Schedule a reconnect after `delayMs` (defaults to `setTimeout`). */
52
+ schedule?(callback: () => void, delayMs: number): void
53
+ /**
54
+ * Register a repeating keepalive tick; return a canceller (defaults to
55
+ * `setInterval` / `clearInterval`).
56
+ */
57
+ scheduleInterval?(callback: () => void, ms: number): () => void
58
+ /** Backoff base in ms (first retry). Default 250. */
59
+ backoffBaseMs?: number
60
+ /** Backoff cap in ms. Default 10_000. */
61
+ backoffMaxMs?: number
62
+ /** Keepalive ping period in ms while open. Default 30_000. */
63
+ pingIntervalMs?: number
64
+ }
65
+
66
+ /** Lifecycle handle for one reconnecting server socket. */
67
+ export interface ReconnectingSocket {
68
+ /** Whether the socket is currently open. */
69
+ readonly isConnected: boolean
70
+ /** Open (idempotent while already open/connecting). */
71
+ open(): void
72
+ /** Intentional close — does not reconnect. */
73
+ close(): void
74
+ /** Drop and re-establish under backoff (engine-requested). */
75
+ reconnect(): void
76
+ /**
77
+ * Skip pending backoff and dial immediately if not open. No-op while open or
78
+ * dial already in flight. Use for mobile foreground / web `online`.
79
+ */
80
+ reconnectNow(): void
81
+ /** Enqueue a client→server frame; flushed once open. */
82
+ send(frame: string): void
83
+ }
84
+
85
+ const DEFAULT_BACKOFF_BASE_MS = 250
86
+ const DEFAULT_BACKOFF_MAX_MS = 10_000
87
+ const DEFAULT_PING_INTERVAL_MS = 30_000
88
+
89
+ /**
90
+ * Create a reconnecting socket coordinator: buffers sends, exponential backoff
91
+ * on unexpected close, keepalive ping while open, drops the stale outbox on
92
+ * reconnect (the re-handshake resends pending pushes).
93
+ */
94
+ export function createReconnectingSocket(
95
+ config: ReconnectingSocketConfig,
96
+ ): ReconnectingSocket {
97
+ const schedule =
98
+ config.schedule ??
99
+ ((cb, delay) => {
100
+ setTimeout(cb, delay)
101
+ })
102
+ const scheduleInterval =
103
+ config.scheduleInterval ??
104
+ ((cb, ms) => {
105
+ const handle = setInterval(cb, ms)
106
+ return () => clearInterval(handle)
107
+ })
108
+ const backoffBaseMs = config.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS
109
+ const backoffMaxMs = config.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS
110
+ const pingIntervalMs = config.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS
111
+
112
+ let socket: ReconnectSocket | null = null
113
+ const outbox: string[] = []
114
+ /** Set by `close()` so intentional teardown does not reconnect. */
115
+ let closedByUs = false
116
+ /** Failed/closed attempts since last successful open. */
117
+ let attempt = 0
118
+ /**
119
+ * Next open is a reconnect (socket opened at least once). First open keeps
120
+ * outbox; reconnect drops it (re-handshake resends pending).
121
+ */
122
+ let reopening = false
123
+ /** Cancel keepalive interval; null when not open. */
124
+ let cancelPing: (() => void) | null = null
125
+ /**
126
+ * Bumped to invalidate pending backoff (#185). Injectable `schedule` has no
127
+ * canceller — generation token lets `reconnectNow()` skip a dial.
128
+ */
129
+ let reconnectGeneration = 0
130
+
131
+ function stopPing(): void {
132
+ cancelPing?.()
133
+ cancelPing = null
134
+ }
135
+
136
+ function startPing(): void {
137
+ stopPing()
138
+ cancelPing = scheduleInterval(() => {
139
+ // Exact DO keepalive bytes (ADR-0016); skip if socket closed between ticks.
140
+ if (socket?.readyState === 'open') socket.send(PING_FRAME)
141
+ }, pingIntervalMs)
142
+ }
143
+
144
+ function flushOutbox(): void {
145
+ if (!socket || socket.readyState !== 'open') return
146
+ while (outbox.length > 0) {
147
+ const frame = outbox.shift()
148
+ if (frame !== undefined) socket.send(frame)
149
+ }
150
+ }
151
+
152
+ function scheduleReconnect(): void {
153
+ const delay = Math.min(backoffBaseMs * 2 ** attempt, backoffMaxMs)
154
+ attempt++
155
+ const gen = reconnectGeneration
156
+ schedule(() => {
157
+ if (closedByUs) return
158
+ // reconnectNow/later schedule stole this slot — no double-dial.
159
+ if (gen !== reconnectGeneration) return
160
+ connect()
161
+ }, delay)
162
+ }
163
+
164
+ function connect(): void {
165
+ if (socket && socket.readyState !== 'closed') return
166
+ // Surface connecting through the whole backoff, not just first open (#102).
167
+ config.onConnecting?.()
168
+ const next = config.connect()
169
+ socket = next
170
+ // Transport error on THIS socket before close => error status vs disconnected.
171
+ let errored = false
172
+ next.onopen = () => {
173
+ // A superseded socket (close then open before the prior onclose) must not
174
+ // drive handshake / ping of the abandoned dial (closeio/doync#319).
175
+ if (socket !== next) return
176
+ attempt = 0
177
+ // Reconnect drops stale outbox (re-handshake resends); first open keeps it.
178
+ if (reopening) outbox.length = 0
179
+ reopening = true
180
+ config.onReconnect()
181
+ flushOutbox()
182
+ startPing()
183
+ }
184
+ next.onmessage = (data: string) => {
185
+ if (socket !== next) return
186
+ config.onMessage?.(data)
187
+ }
188
+ next.onerror = () => {
189
+ if (socket !== next) return
190
+ errored = true
191
+ }
192
+ next.onclose = (code?: number) => {
193
+ // Browser WebSocket.close() delivers onclose asynchronously. Last-out
194
+ // teardown (ADR-0034 / #280) closes, then a new tab may open() before that
195
+ // onclose lands. The late event must not null the NEW socket or schedule a
196
+ // reconnect that races the intentional redial — otherwise the tab wedges
197
+ // `disconnected` with no usable dial (closeio/doync#319).
198
+ if (socket !== next) return
199
+ socket = null
200
+ stopPing()
201
+ // Report drop unless we closed; 1008 included (#102). Auth stays sticky on hub.
202
+ if (!closedByUs) config.onDisconnected?.({ code, wasError: errored })
203
+ // Unexpected close reconnects; close() stays down.
204
+ if (!closedByUs) scheduleReconnect()
205
+ }
206
+ }
207
+
208
+ return {
209
+ get isConnected(): boolean {
210
+ return socket?.readyState === 'open'
211
+ },
212
+ open(): void {
213
+ closedByUs = false
214
+ connect()
215
+ },
216
+ close(): void {
217
+ closedByUs = true
218
+ stopPing()
219
+ // Invalidate pending backoff so close after drop stays silent.
220
+ reconnectGeneration++
221
+ socket?.close()
222
+ socket = null
223
+ },
224
+ reconnect(): void {
225
+ // Engine drop-and-reopen (client-ahead skew, ADR-0020): onclose schedules
226
+ // one backoff (closedByUs false). No-op if no socket.
227
+ socket?.close()
228
+ },
229
+ reconnectNow(): void {
230
+ // Foreground/online nudge (#185): if not open and no dial in flight, skip
231
+ // backoff and dial now. Open/connecting is no-op.
232
+ if (socket && socket.readyState !== 'closed') return
233
+ closedByUs = false
234
+ reconnectGeneration++
235
+ connect()
236
+ },
237
+ send(frame: string): void {
238
+ outbox.push(frame)
239
+ flushOutbox()
240
+ },
241
+ }
242
+ }
package/src/socket.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { ClientMessage, ServerMessage } from '@doync/core/internal'
2
+
3
+ // ADR-0016/0019 (transport-agnostic Mirror socket seam).
4
+ /**
5
+ * Minimal transport the client engine uses to talk to its Mirror. Platform
6
+ * adapters supply WebSocket / reconnect policy behind this shape.
7
+ */
8
+ export interface SyncSocket {
9
+ /** Send one client→server frame (`connect` / `subscribe` / `push`). */
10
+ send(message: ClientMessage): void
11
+ /**
12
+ * Register frame and lifecycle handlers (once at construction). `open` fires
13
+ * on every (re)connect so the engine can re-handshake; `close` pauses
14
+ * outbound traffic until the next `open`.
15
+ */
16
+ setHandlers(handlers: SyncSocketHandlers): void
17
+ /**
18
+ * Drop and re-establish under the adapter's backoff. Used after a
19
+ * client-ahead schema skew; the next successful open triggers re-handshake.
20
+ */
21
+ reconnect(): void
22
+ }
23
+
24
+ /**
25
+ * Transient transport states a reconnecting adapter may report: `connecting`
26
+ * (dial/backoff) and `error` (abnormal drop). Connected / disconnected ride
27
+ * `open`/`close`; auth failure is inferred from frames.
28
+ */
29
+ export type SeamStatus = 'connecting' | 'error'
30
+
31
+ /** Handlers the engine registers on a {@link SyncSocket}. */
32
+ export interface SyncSocketHandlers {
33
+ /** A server→client frame arrived. */
34
+ message(message: ServerMessage): void
35
+ /** Socket (re)connected — engine handshakes and re-sends pending. */
36
+ open(): void
37
+ /** Socket disconnected — engine queues writes until the next open. */
38
+ close(): void
39
+ /**
40
+ * Optional transient status (`connecting` / `error`). Adapters without
41
+ * reconnect simply never call this.
42
+ */
43
+ status?(status: SeamStatus): void
44
+ }
@@ -0,0 +1,4 @@
1
+ declare module '*.sql?raw' {
2
+ const content: string
3
+ export default content
4
+ }