wenay-common2 1.0.70 → 1.0.74
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/README.md +5 -3
- package/demo/client.ts +230 -3
- package/demo/index.html +37 -0
- package/demo/server.ts +91 -4
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +233 -212
- package/doc/changes/1.0.71.md +9 -0
- package/doc/changes/1.0.72.md +6 -0
- package/doc/changes/1.0.73.md +6 -0
- package/doc/changes/1.0.74.md +12 -0
- package/doc/wenay-common2-rare.md +684 -627
- package/doc/wenay-common2.md +50 -5
- package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
- package/lib/Common/events/route-signal-webrtc.js +15 -5
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +1 -0
- package/lib/Common/media/media-source.d.ts +3 -0
- package/lib/Common/media/media-source.js +136 -27
- package/lib/Common/media/media-view.d.ts +42 -0
- package/lib/Common/media/media-view.js +210 -0
- package/lib/Common/peer/peer-call.d.ts +65 -0
- package/lib/Common/peer/peer-call.js +173 -0
- package/lib/Common/peer/peer-client.d.ts +21 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +22 -5
- package/lib/Common/peer/peer-host.js +49 -5
- package/lib/Common/peer/peer-index.d.ts +2 -0
- package/lib/Common/peer/peer-index.js +2 -0
- package/lib/Common/peer/peer-media-relay.d.ts +22 -0
- package/lib/Common/peer/peer-media-relay.js +155 -0
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/listen-core.test.ts +1 -2
- package/package.json +1 -4
- package/replay/media-view.test.ts +164 -0
- package/replay/peer-call.test.ts +226 -0
- package/replay/peer-repair.test.ts +184 -0
- package/doc/changes/1.0.63.md +0 -8
- package/doc/changes/1.0.64.md +0 -10
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Acceptance oracle for the call system (ROADMAP "Call system" candidate):
|
|
2
|
+
// presence + ring/accept/decline/hangup over the EXISTING signal hub, media
|
|
3
|
+
// frames over the relay lines while the call is active — all on a real
|
|
4
|
+
// Socket.IO/RPC wire, next to the peer fragment, with zero new server routes
|
|
5
|
+
// beyond the fragment keys (presence) and the media relay object.
|
|
6
|
+
import express from 'express'
|
|
7
|
+
import {createServer} from 'http'
|
|
8
|
+
import type {AddressInfo} from 'net'
|
|
9
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
10
|
+
import {io} from 'socket.io-client'
|
|
11
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
12
|
+
import {createSignalHub, SignalEnvelope} from '../src/Common/events/route-signal-webrtc'
|
|
13
|
+
import {createPeerHost} from '../src/Common/peer/peer-host'
|
|
14
|
+
import {callPortOf, createCallManager} from '../src/Common/peer/peer-call'
|
|
15
|
+
import {createMediaRelay} from '../src/Common/peer/peer-media-relay'
|
|
16
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
17
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
18
|
+
|
|
19
|
+
let fails = 0
|
|
20
|
+
const ok = (condition: any, message: string) => {
|
|
21
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
22
|
+
else console.log(' OK ', message)
|
|
23
|
+
}
|
|
24
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
25
|
+
|
|
26
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
27
|
+
for (let i = 0; i < 200; i++) {
|
|
28
|
+
if (cond()) return
|
|
29
|
+
await delay(20)
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`timeout: ${label}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
console.log('\n[peer-call] presence + ring/accept/decline + media over the relay')
|
|
36
|
+
|
|
37
|
+
// ================= lifecycle regressions (in-process, deterministic) =================
|
|
38
|
+
const signalHub = createSignalHub()
|
|
39
|
+
const firstA = signalHub.register('same')
|
|
40
|
+
const newestA = signalHub.register('same')
|
|
41
|
+
const sender = signalHub.register('sender')
|
|
42
|
+
let firstHits = 0
|
|
43
|
+
let newestHits = 0
|
|
44
|
+
firstA.signals.on(() => firstHits++)
|
|
45
|
+
newestA.signals.on(() => newestHits++)
|
|
46
|
+
await sender.send({type: 'ring', pair: 'multi:1', from: 'sender', to: 'same'})
|
|
47
|
+
newestA.close()
|
|
48
|
+
const fallbackSent = await sender.send({type: 'ring', pair: 'multi:2', from: 'sender', to: 'same'})
|
|
49
|
+
ok(newestHits == 1 && firstHits == 1 && fallbackSent == true,
|
|
50
|
+
'closing the newest same-account signal port restores the previous live port')
|
|
51
|
+
signalHub.close()
|
|
52
|
+
|
|
53
|
+
const [emitLocalSignal, localSignals] = createListenPair<[SignalEnvelope]>()
|
|
54
|
+
const localSends: SignalEnvelope[] = []
|
|
55
|
+
const localCalls = createCallManager({
|
|
56
|
+
self: 'callee',
|
|
57
|
+
port: {
|
|
58
|
+
signals: localSignals,
|
|
59
|
+
send: function captureLocalSend(env) { localSends.push(env); return true },
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
let localRings = 0
|
|
63
|
+
localCalls.rings.on(() => localRings++)
|
|
64
|
+
emitLocalSignal({type: 'ring', pair: 'call:x:1', from: 'x', to: 'callee'})
|
|
65
|
+
emitLocalSignal({type: 'ring', pair: 'call:y:1', from: 'y', to: 'callee'})
|
|
66
|
+
await delay(0)
|
|
67
|
+
ok(localRings == 1 && localSends.some(env => env.type == 'decline' && env.reason == 'busy'),
|
|
68
|
+
'back-to-back incoming rings reserve admission: one rings, one is busy')
|
|
69
|
+
localCalls.close()
|
|
70
|
+
|
|
71
|
+
// ================= SERVER: peer fragment + media relay, no call-specific code =================
|
|
72
|
+
// Watch ACL as APP code: media access follows call state — the policy set is
|
|
73
|
+
// maintained by the clients below on 'active'/'ended' (via a legacy-style rpc key).
|
|
74
|
+
const allowedWatch = new Set<string>()
|
|
75
|
+
const host = createPeerHost()
|
|
76
|
+
const media = createMediaRelay({
|
|
77
|
+
lines: {cam: 'video', mic: 'audio'},
|
|
78
|
+
canWatch: (watcher, owner) => allowedWatch.has(watcher + '|' + owner),
|
|
79
|
+
})
|
|
80
|
+
const app = express()
|
|
81
|
+
const httpServer = createServer(app)
|
|
82
|
+
const ioServer = new SocketIOServer(httpServer)
|
|
83
|
+
ioServer.on('connection', socket => {
|
|
84
|
+
const account = String(socket.handshake.auth?.account)
|
|
85
|
+
const peer = host.connection(account)
|
|
86
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
87
|
+
socket.on('disconnect', () => { disconnect(); peer.close() })
|
|
88
|
+
createRpcServerAuto({
|
|
89
|
+
socket: {emit: (key, data) => socket.emit(key, data), on: (key, cb) => socket.on(key, cb)},
|
|
90
|
+
socketKey: 'app',
|
|
91
|
+
object: {
|
|
92
|
+
peer: peer.fragment,
|
|
93
|
+
media: {publish: media.publishOf(account), watch: media.watchOf(account)},
|
|
94
|
+
// app-level grant hook (a real app would derive this from its own call registry)
|
|
95
|
+
grantWatch: (a: string, b: string, on: boolean) => {
|
|
96
|
+
if (on) { allowedWatch.add(a + '|' + b); allowedWatch.add(b + '|' + a) }
|
|
97
|
+
else { allowedWatch.delete(a + '|' + b); allowedWatch.delete(b + '|' + a) }
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
disconnectListen,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
httpServer.once('error', reject)
|
|
105
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
106
|
+
})
|
|
107
|
+
const port = (httpServer.address() as AddressInfo).port
|
|
108
|
+
|
|
109
|
+
async function connect(account: string) {
|
|
110
|
+
const hub = createRpcClientHub(
|
|
111
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true, auth: {account}}),
|
|
112
|
+
r => ({api: r<any>('app')}) as const,
|
|
113
|
+
)
|
|
114
|
+
const clients = await hub.setToken(null)
|
|
115
|
+
await clients.api.readyStrict()
|
|
116
|
+
return {func: clients.api.func, closeSocket: () => hub.socket?.disconnect?.()}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const connA = await connect('a')
|
|
120
|
+
const connB = await connect('b')
|
|
121
|
+
|
|
122
|
+
// ================= presence =================
|
|
123
|
+
const presenceLog: any[] = []
|
|
124
|
+
connA.func.peer.presence.changes.on((ch: any) => presenceLog.push(ch))
|
|
125
|
+
const online = await connA.func.peer.presence.list()
|
|
126
|
+
ok(online.includes('a') && online.includes('b'), `presence list sees both accounts: ${JSON.stringify(online)}`)
|
|
127
|
+
|
|
128
|
+
// ================= happy path: ring -> accept -> active =================
|
|
129
|
+
const callsA = createCallManager({port: callPortOf(connA.func.peer), self: 'a'})
|
|
130
|
+
const callsB = createCallManager({port: callPortOf(connB.func.peer), self: 'b'})
|
|
131
|
+
await Promise.all([callsA.ready, callsB.ready])
|
|
132
|
+
let ringAtB: any = null
|
|
133
|
+
callsB.rings.on((h: any) => { ringAtB = h })
|
|
134
|
+
|
|
135
|
+
const out = callsA.call('b', {kinds: ['cam', 'mic']})
|
|
136
|
+
await waitFor('ring reaches b', () => ringAtB != null)
|
|
137
|
+
ok(ringAtB.peer == 'a' && ringAtB.direction == 'in', 'incoming handle points back at the caller')
|
|
138
|
+
ok(JSON.stringify(ringAtB.meta) == JSON.stringify({kinds: ['cam', 'mic']}), 'caller meta rides the ring envelope opaquely')
|
|
139
|
+
ok(out.state() == 'ringing' && ringAtB.state() == 'ringing', 'both sides are ringing')
|
|
140
|
+
|
|
141
|
+
// ================= watch ACL: denied OUTSIDE a call =================
|
|
142
|
+
let deniedBefore = false
|
|
143
|
+
try { await connB.func.media.watch.a.cam.keyframe() } catch { deniedBefore = true }
|
|
144
|
+
ok(deniedBefore, 'watch is DENIED outside a call (canWatch gates path resolution)')
|
|
145
|
+
|
|
146
|
+
ringAtB.accept()
|
|
147
|
+
await waitFor('caller sees accept', () => out.state() == 'active')
|
|
148
|
+
ok(callsB.active() == ringAtB && callsA.active() == out, 'both sides report the active call')
|
|
149
|
+
|
|
150
|
+
// ================= media while active: grant -> publish -> relay line -> watcher =================
|
|
151
|
+
await connA.func.grantWatch('a', 'b', true) // app code: access follows the call
|
|
152
|
+
const got: any[] = []
|
|
153
|
+
connB.func.media.watch.a.cam.on((frame: any, sentAt: number) => got.push([frame, sentAt]))
|
|
154
|
+
await connA.func.media.publish('cam', new Uint8Array([1, 2, 3]), 12345)
|
|
155
|
+
await waitFor('cam frame lands at b', () => got.length > 0)
|
|
156
|
+
ok(got[0][1] == 12345, 'sentAt stamp rides along the media frame')
|
|
157
|
+
const kf = await connB.func.media.watch.a.cam.keyframe()
|
|
158
|
+
ok(kf != null && kf.event[1] == 12345, 'video line is keep-latest: a late joiner pulls the last frame via keyframe()')
|
|
159
|
+
|
|
160
|
+
// ================= busy gate: third account calls into an active call =================
|
|
161
|
+
const connC = await connect('c')
|
|
162
|
+
const callsC = createCallManager({port: callPortOf(connC.func.peer), self: 'c'})
|
|
163
|
+
await callsC.ready
|
|
164
|
+
const cToB = callsC.call('b')
|
|
165
|
+
ok(await cToB.ended == 'busy', 'default gate auto-declines with busy while b is on a call')
|
|
166
|
+
let deniedThird = false
|
|
167
|
+
try { await connC.func.media.watch.a.cam.keyframe() } catch { deniedThird = true }
|
|
168
|
+
ok(deniedThird, 'a third account is still denied while a<->b are on a call')
|
|
169
|
+
|
|
170
|
+
// ================= hangup: media access is revoked with the call =================
|
|
171
|
+
out.hangup()
|
|
172
|
+
await waitFor('callee sees hangup', () => ringAtB.state() == 'ended')
|
|
173
|
+
ok(ringAtB.reason() == 'hangup' && await out.ended == 'hangup', 'hangup ends both sides with the reason')
|
|
174
|
+
await connA.func.grantWatch('a', 'b', false) // app code: revoke with the call
|
|
175
|
+
const framesBeforeRevoke = got.length
|
|
176
|
+
await connA.func.media.publish('cam', new Uint8Array([9]), 54321)
|
|
177
|
+
await delay(50)
|
|
178
|
+
ok(got.length == framesBeforeRevoke, 'ACL revocation blocks frames on an already-open media subscription')
|
|
179
|
+
let deniedAfter = false
|
|
180
|
+
try { await connB.func.media.watch.a.cam.keyframe() } catch { deniedAfter = true }
|
|
181
|
+
ok(deniedAfter, 'watch is denied again after the call ends (new resolutions blocked)')
|
|
182
|
+
media.dropAccount('c')
|
|
183
|
+
ok(!media.accounts().includes('c') && media.accounts().includes('a'), 'dropAccount retires the account keyspace (server-side cleanup hook)')
|
|
184
|
+
|
|
185
|
+
// ================= decline =================
|
|
186
|
+
let ringAtA: any = null
|
|
187
|
+
callsA.rings.on((h: any) => { ringAtA = h })
|
|
188
|
+
const bToA = callsB.call('a')
|
|
189
|
+
await waitFor('ring reaches a', () => ringAtA != null)
|
|
190
|
+
ringAtA.decline()
|
|
191
|
+
ok(await bToA.ended == 'declined', 'decline propagates to the caller')
|
|
192
|
+
|
|
193
|
+
// ================= offline callee: fails fast, no timeout wait =================
|
|
194
|
+
const t0 = Date.now()
|
|
195
|
+
const toGhost = callsA.call('ghost')
|
|
196
|
+
ok(await toGhost.ended == 'offline' && Date.now() - t0 < 2000, 'calling an offline account fails fast with offline')
|
|
197
|
+
|
|
198
|
+
// ================= ring timeout =================
|
|
199
|
+
const callsA2 = createCallManager({port: callPortOf(connA.func.peer), self: 'a', ringTimeoutMs: 300})
|
|
200
|
+
await callsA2.ready
|
|
201
|
+
let unanswered: any = null
|
|
202
|
+
const offRings = callsB.rings.on((h: any) => { unanswered = h })
|
|
203
|
+
const timedOut = callsA2.call('b')
|
|
204
|
+
ok(await timedOut.ended == 'timeout', 'unanswered ring times out on the caller')
|
|
205
|
+
await waitFor('callee ring cleaned up', () => unanswered != null && unanswered.state() == 'ended')
|
|
206
|
+
ok(unanswered.reason() == 'canceled', 'the callee ring is canceled by the caller timeout')
|
|
207
|
+
if (typeof offRings == 'function') offRings()
|
|
208
|
+
|
|
209
|
+
// ================= presence offline edge =================
|
|
210
|
+
connC.closeSocket()
|
|
211
|
+
await waitFor('presence offline edge', () => presenceLog.some(ch => ch.account == 'c' && ch.online == false))
|
|
212
|
+
ok(true, 'presence emits the offline edge on disconnect')
|
|
213
|
+
|
|
214
|
+
// ================= teardown =================
|
|
215
|
+
callsA.close(); callsA2.close(); callsB.close(); callsC.close()
|
|
216
|
+
connA.closeSocket(); connB.closeSocket()
|
|
217
|
+
media.close()
|
|
218
|
+
host.close()
|
|
219
|
+
ioServer.close()
|
|
220
|
+
await new Promise<void>(resolve => httpServer.close(() => resolve()))
|
|
221
|
+
|
|
222
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
223
|
+
if (fails) process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Publish-path reconnect correctness (peer SDK): the relay journal never lies
|
|
2
|
+
// after a publisher gap. Gap matrix on createPatchRelayJournal + rejection-driven
|
|
3
|
+
// repair in createPeerClient ('tail' | 'keyframe'), sacred eviction, resync().
|
|
4
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
5
|
+
import {applyStorePatch, createStore, StorePatch} from '../src/Common/Observe/store'
|
|
6
|
+
import {syncStoreReplay} from '../src/Common/Observe/store-replay'
|
|
7
|
+
import {createPatchRelayJournal, createPeerClient, PatchEnvelope, PeerRemote} from '../src/Common/peer/peer-index'
|
|
8
|
+
|
|
9
|
+
let fails = 0
|
|
10
|
+
const ok = (condition: any, message: string) => {
|
|
11
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
12
|
+
else console.log(' OK ', message)
|
|
13
|
+
}
|
|
14
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
15
|
+
const json = (v: any) => JSON.stringify(v)
|
|
16
|
+
|
|
17
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
18
|
+
for (let i = 0; i < 100; i++) {
|
|
19
|
+
if (cond()) return
|
|
20
|
+
await delay(10)
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`timeout: ${label}`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const env = (seq: number, path: PropertyKey[], value: any): PatchEnvelope =>
|
|
26
|
+
({seq, ts: seq, event: [{path, value, exists: value !== undefined}]})
|
|
27
|
+
const root = (seq: number, value: any) => env(seq, [], value)
|
|
28
|
+
|
|
29
|
+
type World = {n: number, tag?: string}
|
|
30
|
+
|
|
31
|
+
// lossy transport wrapper: drops pushes while `offline` is true — the client's
|
|
32
|
+
// next successful delivery gets the {seq} verdict and repairs the gap
|
|
33
|
+
function makeLossyRemote(journal: ReturnType<typeof createPatchRelayJournal>, account: string) {
|
|
34
|
+
const state = {offline: false, dropped: 0}
|
|
35
|
+
const remote: PeerRemote = {
|
|
36
|
+
signal: {send: async () => false, signals: {on: () => () => {}}},
|
|
37
|
+
publish: (e: PatchEnvelope) => {
|
|
38
|
+
if (state.offline) { state.dropped++; return true }
|
|
39
|
+
return journal.push(e)
|
|
40
|
+
},
|
|
41
|
+
peers: {[account]: journal.remote as any},
|
|
42
|
+
}
|
|
43
|
+
return {remote, state}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
console.log('\n[peer-repair] gap matrix on the relay journal')
|
|
48
|
+
{
|
|
49
|
+
const j = createPatchRelayJournal({history: 16})
|
|
50
|
+
ok(j.push(env(0, ['n'], 1)) != true && json(j.push(env(0, ['n'], 1))) == json({seq: -1}),
|
|
51
|
+
'folding journal refuses a non-root FIRST envelope (partial-state lie) with repair coord -1')
|
|
52
|
+
ok(j.push(root(0, {n: 1})) == true, 'root first envelope accepted')
|
|
53
|
+
ok(j.push(env(1, ['n'], 2)) == true && j.push(env(2, ['n'], 3)) == true, 'contiguous envelopes accepted')
|
|
54
|
+
ok(j.push(env(2, ['n'], 99)) == true && j.seq() == 2 && j.snapshot().n == 3,
|
|
55
|
+
'duplicate is an idempotent no-op (reconnect overlap)')
|
|
56
|
+
ok(json(j.push(env(5, ['n'], 6))) == json({seq: 2}), 'non-root gap rejected WITH the repair coordinate')
|
|
57
|
+
ok(j.seq() == 2 && j.snapshot().n == 3, 'rejected gap does not corrupt the fold')
|
|
58
|
+
ok(j.push(root(7, {n: 7})) == true && j.seq() == 7 && j.snapshot().n == 7,
|
|
59
|
+
'root patch with a gap = legitimate reset point (keyframe repair / owner restart)')
|
|
60
|
+
ok(j.push(root(3, {n: 3})) == true && j.seq() == 3,
|
|
61
|
+
'root patch with a LOWER seq = owner restart reset')
|
|
62
|
+
ok(json(j.remote.keyframe()?.event[0].value) == json({n: 3}), 'folded keyframe stays truthful throughout')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('\n[peer-repair] sacred journal: never invents, strict contiguity')
|
|
66
|
+
{
|
|
67
|
+
const j = createPatchRelayJournal({history: 2, gap: 'sacred'})
|
|
68
|
+
ok(j.push(root(0, {n: 0})) == true && j.push(env(1, ['n'], 1)) == true, 'sacred accepts a contiguous line')
|
|
69
|
+
ok(json(j.push(root(5, {n: 5}))) == json({seq: 1}), 'sacred rejects even a ROOT patch with a gap (no reset semantics)')
|
|
70
|
+
ok(j.remote.keyframe() == null, 'sacred never folds a keyframe')
|
|
71
|
+
j.push(env(2, ['n'], 2)); j.push(env(3, ['n'], 3)) // history 2 -> seq 0,1 evicted
|
|
72
|
+
let threw = false
|
|
73
|
+
try { j.remote.frame!(0) } catch { threw = true }
|
|
74
|
+
ok(threw, 'sacred frame() on an evicted tail THROWS (loud, never a silent seq jump)')
|
|
75
|
+
ok(json(j.remote.since(1)?.map(e => e.seq)) == json([2, 3]), 'covered tail is still served exactly')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log('\n[peer-repair] tail repair: lossless catch-up after an offline window')
|
|
79
|
+
{
|
|
80
|
+
const j = createPatchRelayJournal({history: 64})
|
|
81
|
+
const net = makeLossyRemote(j, 'a')
|
|
82
|
+
const errors: unknown[] = []
|
|
83
|
+
const a = createPeerClient<World>({
|
|
84
|
+
remote: net.remote, account: 'a', initial: {n: 0},
|
|
85
|
+
repair: 'tail', drain: 'micro', onPublishError: e => errors.push(e),
|
|
86
|
+
})
|
|
87
|
+
await waitFor('warmup', () => j.seq() >= 0)
|
|
88
|
+
|
|
89
|
+
a.store.state.n = 1
|
|
90
|
+
await flushReactive(a.store.state)
|
|
91
|
+
await waitFor('live publish', () => j.snapshot().n == 1)
|
|
92
|
+
|
|
93
|
+
net.state.offline = true
|
|
94
|
+
a.store.state.n = 2
|
|
95
|
+
a.store.state.tag = 'missed'
|
|
96
|
+
await flushReactive(a.store.state)
|
|
97
|
+
a.store.state.n = 3
|
|
98
|
+
await flushReactive(a.store.state)
|
|
99
|
+
await delay(20)
|
|
100
|
+
net.state.offline = false
|
|
101
|
+
ok(net.state.dropped >= 2 && j.snapshot().n == 1, 'offline window: relay is behind, fold untouched')
|
|
102
|
+
|
|
103
|
+
a.store.state.n = 4
|
|
104
|
+
await flushReactive(a.store.state)
|
|
105
|
+
await waitFor('tail repair', () => j.snapshot().n == 4)
|
|
106
|
+
ok(j.snapshot().tag == 'missed', 'tail repair delivered the MISSED envelopes verbatim (lossless)')
|
|
107
|
+
ok(json(j.snapshot()) == json(a.store.snapshot()), 'relay converged with the owner')
|
|
108
|
+
// ring integrity: a late joiner can fold the line from any covered coordinate
|
|
109
|
+
const late = createStore<World>({n: -1}, {drain: 'micro'})
|
|
110
|
+
const sub = syncStoreReplay(late, j.remote)
|
|
111
|
+
await sub.ready
|
|
112
|
+
ok(json(late.state) == json(a.store.snapshot()), 'late joiner folds a truthful state after repair')
|
|
113
|
+
ok(errors.length == 0, 'no publish errors on the happy repair path')
|
|
114
|
+
sub(); a.close()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log('\n[peer-repair] keyframe repair: cheap reset for ephemeral state')
|
|
118
|
+
{
|
|
119
|
+
const j = createPatchRelayJournal({history: 64})
|
|
120
|
+
const net = makeLossyRemote(j, 'a')
|
|
121
|
+
const a = createPeerClient<World>({
|
|
122
|
+
remote: net.remote, account: 'a', initial: {n: 0},
|
|
123
|
+
repair: 'keyframe', drain: 'micro',
|
|
124
|
+
})
|
|
125
|
+
await waitFor('warmup', () => j.seq() >= 0)
|
|
126
|
+
net.state.offline = true
|
|
127
|
+
a.store.state.n = 100
|
|
128
|
+
await flushReactive(a.store.state)
|
|
129
|
+
await delay(20)
|
|
130
|
+
net.state.offline = false
|
|
131
|
+
a.store.state.n = 101
|
|
132
|
+
await flushReactive(a.store.state)
|
|
133
|
+
await waitFor('keyframe repair', () => j.snapshot().n == 101)
|
|
134
|
+
ok(json(j.snapshot()) == json(a.store.snapshot()), 'keyframe repair resets the relay to current state')
|
|
135
|
+
a.close()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log('\n[peer-repair] sacred + local eviction: loud publisher fault, journal stays truthful')
|
|
139
|
+
{
|
|
140
|
+
const j = createPatchRelayJournal({history: 64, gap: 'sacred'})
|
|
141
|
+
const net = makeLossyRemote(j, 'a')
|
|
142
|
+
const errors: unknown[] = []
|
|
143
|
+
const a = createPeerClient<World, 'sacred'>({
|
|
144
|
+
remote: net.remote, account: 'a', initial: {n: 0},
|
|
145
|
+
journal: 'sacred', repair: 'tail', history: 2, // tiny local journal -> eviction
|
|
146
|
+
drain: 'micro', onPublishError: e => errors.push(e),
|
|
147
|
+
})
|
|
148
|
+
await waitFor('warmup', () => j.seq() >= 0)
|
|
149
|
+
const seqBefore = j.seq()
|
|
150
|
+
net.state.offline = true
|
|
151
|
+
for (let i = 1; i <= 6; i++) { a.store.state.n = i; await flushReactive(a.store.state) }
|
|
152
|
+
net.state.offline = false
|
|
153
|
+
a.store.state.n = 7
|
|
154
|
+
await flushReactive(a.store.state)
|
|
155
|
+
await waitFor('sacred repair failure surfaces', () => errors.length > 0)
|
|
156
|
+
ok(String(errors[0]).includes('unrepairable'), `publisher fault is loud: ${String(errors[0]).slice(0, 90)}`)
|
|
157
|
+
ok(j.seq() == seqBefore, 'sacred journal stayed truthful (nothing invented, nothing gapped)')
|
|
158
|
+
a.close()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log('\n[peer-repair] resync(): repair without waiting for the next write')
|
|
162
|
+
{
|
|
163
|
+
const j = createPatchRelayJournal({history: 64})
|
|
164
|
+
const net = makeLossyRemote(j, 'a')
|
|
165
|
+
const a = createPeerClient<World>({remote: net.remote, account: 'a', initial: {n: 0}, drain: 'micro'})
|
|
166
|
+
await waitFor('warmup', () => j.seq() >= 0)
|
|
167
|
+
net.state.offline = true
|
|
168
|
+
a.store.state.n = 5
|
|
169
|
+
a.store.state.tag = 'silent'
|
|
170
|
+
await flushReactive(a.store.state)
|
|
171
|
+
await delay(20)
|
|
172
|
+
net.state.offline = false
|
|
173
|
+
ok(j.snapshot().n == 0, 'relay is behind and no further writes are coming')
|
|
174
|
+
await a.resync()
|
|
175
|
+
ok(json(j.snapshot()) == json(a.store.snapshot()) && j.snapshot().tag == 'silent',
|
|
176
|
+
'resync() repaired the journal without a new write')
|
|
177
|
+
a.close()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
181
|
+
if (fails) process.exit(1)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
package/doc/changes/1.0.63.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# 1.0.63
|
|
2
|
-
|
|
3
|
-
- Add `Observe.createStoreManager` and `Observe.managedStore` resource builders for declarative mirror/replay/offline store startup.
|
|
4
|
-
- Add manager planning with priority, tags, usage scoring, `large`, and `explicitOnly` gates.
|
|
5
|
-
- Add manager lifecycle helpers: `start`, `startPlanned`, `stop`, `stopAll`, `get`, `touch`, `usage`, `statusListen`, and typed `handles`.
|
|
6
|
-
- Rename public-facing Observe oracle folder from `observable2` to `observe` and make `Listen.ts` / `reactive.ts` the canonical source files, leaving numbered files as compatibility shims.
|
|
7
|
-
- Update brief and extended API docs for the store manager and current naming.
|
|
8
|
-
- Publish package `wenay-common2@1.0.63`.
|
package/doc/changes/1.0.64.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# 1.0.64
|
|
2
|
-
|
|
3
|
-
- Make `README.md` navigation-only and point it to the brief docs, rare docs, naming migrations, recent changes, and project rules.
|
|
4
|
-
- Add the `doc/changes/` release-note catalog rule to `CLAUDE.md`: one file per published version, commit-style summary, latest 10 versions only.
|
|
5
|
-
- Add the recent changes catalog README and keep the current release notes under `doc/changes/`.
|
|
6
|
-
- Package documentation, `CLAUDE.md`, and `rpc.md` into `dist` so README links work in the npm package.
|
|
7
|
-
- Restore the `noStrict` dynamic-map contract in the extended docs after removing a duplicated store-manager block.
|
|
8
|
-
- Move regression oracles to the canonical `observe/reactive` names after the `observable2/reactive2` rename.
|
|
9
|
-
- Normalize npm repository metadata.
|
|
10
|
-
- Publish package `wenay-common2@1.0.64`.
|