@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.
- package/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/adapter.cjs +1 -0
- package/dist/adapter.d.cts +86 -0
- package/dist/adapter.d.cts.map +1 -0
- package/dist/adapter.d.ts +86 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/client-C6jAdhbe.cjs +15 -0
- package/dist/client-CNyLMCw0.d.ts +812 -0
- package/dist/client-CNyLMCw0.d.ts.map +1 -0
- package/dist/client-ClV8ce6X.js +16 -0
- package/dist/client-ClV8ce6X.js.map +1 -0
- package/dist/client-DHXO0dbf.d.cts +812 -0
- package/dist/client-DHXO0dbf.d.cts.map +1 -0
- package/dist/index.cjs +0 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +0 -0
- package/dist/internal.cjs +1 -0
- package/dist/internal.d.cts +69 -0
- package/dist/internal.d.cts.map +1 -0
- package/dist/internal.d.ts +69 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -0
- package/package.json +79 -0
- package/src/adapter.ts +25 -0
- package/src/client-mutation-registry.ts +31 -0
- package/src/client.ts +100 -0
- package/src/engine.ts +3322 -0
- package/src/identity.ts +41 -0
- package/src/index.ts +36 -0
- package/src/internal.ts +37 -0
- package/src/migrate.ts +156 -0
- package/src/mutations.ts +96 -0
- package/src/port.ts +74 -0
- package/src/raw-read.ts +135 -0
- package/src/replica/db/0000_replica_engine_v0.sql +20 -0
- package/src/replica/db/0001_release_stamps.sql +6 -0
- package/src/replica/db/meta/0000_snapshot.json +129 -0
- package/src/replica/db/meta/0001_snapshot.json +167 -0
- package/src/replica/db/meta/_journal.json +20 -0
- package/src/replica/db/schema.ts +67 -0
- package/src/replica/index.ts +185 -0
- package/src/replica/meta.ts +26 -0
- package/src/replica/stamps.ts +102 -0
- package/src/replica/track.ts +190 -0
- package/src/socket-reconnect.ts +242 -0
- package/src/socket.ts +44 -0
- 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
|
+
}
|
package/src/sql-raw.d.ts
ADDED