wenay-common2 1.0.70 → 1.0.73
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 +2 -1
- package/demo/client.ts +123 -1
- package/demo/index.html +30 -0
- package/demo/server.ts +52 -2
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +19 -1
- 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/wenay-common2-rare.md +29 -8
- package/doc/wenay-common2.md +13 -2
- 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 +191 -0
- package/lib/Common/peer/peer-client.d.ts +12 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +8 -4
- package/lib/Common/peer/peer-host.js +2 -2
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/package.json +1 -1
- package/replay/media-view.test.ts +164 -0
- package/replay/peer-repair.test.ts +184 -0
- package/doc/changes/1.0.63.md +0 -8
|
@@ -16,37 +16,51 @@ function condensePatchTail(tail) {
|
|
|
16
16
|
return Array.from(held.values());
|
|
17
17
|
}
|
|
18
18
|
function createPatchRelayJournal(opts = {}) {
|
|
19
|
-
const { history = 1024 } = opts;
|
|
19
|
+
const { history = 1024, gap = 'resume' } = opts;
|
|
20
|
+
const folding = gap != 'sacred';
|
|
20
21
|
const [emitLine, line] = (0, Listen_1.listen)();
|
|
21
|
-
const fold = (0, store_1.createStore)({}, { drain: 'micro' });
|
|
22
|
+
const fold = folding ? (0, store_1.createStore)({}, { drain: 'micro' }) : null;
|
|
22
23
|
let ring = [];
|
|
23
24
|
let last = -1;
|
|
24
25
|
let lastTs = 0;
|
|
25
26
|
let hasState = false;
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
return false;
|
|
29
|
-
const patch = env.event?.[0];
|
|
30
|
-
if (patch == null || !Array.isArray(patch.path))
|
|
31
|
-
return false;
|
|
32
|
-
const isRoot = patch.path.length == 0;
|
|
33
|
-
if (env.seq <= last) {
|
|
34
|
-
if (!isRoot || env.seq == last)
|
|
35
|
-
return false;
|
|
27
|
+
function accept(env, reset) {
|
|
28
|
+
if (reset)
|
|
36
29
|
ring = [];
|
|
37
|
-
}
|
|
38
30
|
last = env.seq;
|
|
39
31
|
lastTs = env.ts ?? lastTs;
|
|
40
32
|
hasState = true;
|
|
41
|
-
|
|
33
|
+
if (fold)
|
|
34
|
+
(0, store_1.applyStorePatch)(fold, env.event[0]);
|
|
42
35
|
ring.push(env);
|
|
43
36
|
if (ring.length > history)
|
|
44
37
|
ring.splice(0, ring.length - history);
|
|
45
38
|
emitLine(env);
|
|
46
39
|
return true;
|
|
47
40
|
}
|
|
41
|
+
function push(env) {
|
|
42
|
+
if (env == null || typeof env.seq != 'number')
|
|
43
|
+
return false;
|
|
44
|
+
const patch = env.event?.[0];
|
|
45
|
+
if (patch == null || !Array.isArray(patch.path))
|
|
46
|
+
return false;
|
|
47
|
+
const isRoot = patch.path.length == 0;
|
|
48
|
+
if (env.seq <= last) {
|
|
49
|
+
if (folding && isRoot && env.seq < last)
|
|
50
|
+
return accept(env, true);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (hasState && env.seq > last + 1) {
|
|
54
|
+
if (folding && isRoot)
|
|
55
|
+
return accept(env, true);
|
|
56
|
+
return { seq: last };
|
|
57
|
+
}
|
|
58
|
+
if (folding && !hasState && !isRoot)
|
|
59
|
+
return { seq: -1 };
|
|
60
|
+
return accept(env, false);
|
|
61
|
+
}
|
|
48
62
|
function keyframe() {
|
|
49
|
-
if (!hasState)
|
|
63
|
+
if (!fold || !hasState)
|
|
50
64
|
return null;
|
|
51
65
|
return { seq: last, ts: lastTs, event: [{ path: [], exists: true, value: fold.snapshot() }] };
|
|
52
66
|
}
|
|
@@ -64,14 +78,22 @@ function createPatchRelayJournal(opts = {}) {
|
|
|
64
78
|
if (tail)
|
|
65
79
|
return condensePatchTail(tail);
|
|
66
80
|
const kf = keyframe();
|
|
67
|
-
|
|
81
|
+
if (kf)
|
|
82
|
+
return [kf];
|
|
83
|
+
if (!folding && hasState)
|
|
84
|
+
throw new Error('sacred relay journal: tail evicted, no keyframe to invent');
|
|
85
|
+
return null;
|
|
68
86
|
}
|
|
69
|
-
const remote = {
|
|
87
|
+
const remote = {
|
|
88
|
+
line, since, keyframe, frame,
|
|
89
|
+
seq: () => last,
|
|
90
|
+
};
|
|
70
91
|
return {
|
|
71
92
|
push,
|
|
72
93
|
remote,
|
|
94
|
+
gap,
|
|
73
95
|
seq: () => last,
|
|
74
|
-
snapshot: () => fold
|
|
96
|
+
snapshot: () => fold?.snapshot(),
|
|
75
97
|
close: () => { line.close(); },
|
|
76
98
|
};
|
|
77
99
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/media-view.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Media viewer helpers oracle (Node, no browser):
|
|
5
|
+
// - attachAudioPlayer: PCM decode, sequential playhead, live
|
|
6
|
+
// backlog drop, enable/disable, non-audio frames ignored
|
|
7
|
+
// - attachVideoCanvas: injected bitmap decoder, sizing follows
|
|
8
|
+
// frames, busy-skip keeps latest, stats
|
|
9
|
+
// - pipeMediaPublish: sentAt stamp, onError on rejection
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import {listen} from '../src/Common/events/Listen'
|
|
13
|
+
import {
|
|
14
|
+
attachAudioPlayer,
|
|
15
|
+
attachVideoCanvas,
|
|
16
|
+
encodeMediaFrame,
|
|
17
|
+
pipeMediaPublish,
|
|
18
|
+
} from '../src/Common/media/media-index'
|
|
19
|
+
|
|
20
|
+
let fails = 0
|
|
21
|
+
const ok = (condition: any, message: string) => {
|
|
22
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
23
|
+
else console.log(' OK ', message)
|
|
24
|
+
}
|
|
25
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
26
|
+
|
|
27
|
+
function pcmFrame(seq: number, nSamples = 480, sampleRate = 48000) {
|
|
28
|
+
const pcm = new Int16Array(nSamples)
|
|
29
|
+
for (let i = 0; i < nSamples; i++) pcm[i] = (i % 2 ? 1 : -1) * 1000
|
|
30
|
+
return encodeMediaFrame({kind: 'audio-pcm', codec: 'pcm16', seq, tMono: seq, sampleRate, channels: 1, nSamples}, new Uint8Array(pcm.buffer))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function videoFrame(seq: number, width: number, height: number) {
|
|
34
|
+
return encodeMediaFrame({kind: 'video-frame', codec: 'jpeg', seq, tMono: seq, width, height}, new Uint8Array([1, 2, 3]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createFakeAudioContext() {
|
|
38
|
+
const scheduled: {at: number, frames: number}[] = []
|
|
39
|
+
const ctx = {
|
|
40
|
+
currentTime: 0,
|
|
41
|
+
destination: {},
|
|
42
|
+
resumed: 0,
|
|
43
|
+
closed: 0,
|
|
44
|
+
resume() { ctx.resumed++ },
|
|
45
|
+
close() { ctx.closed++ },
|
|
46
|
+
createBuffer: (_ch: number, frames: number, _rate: number) => ({
|
|
47
|
+
frames,
|
|
48
|
+
getChannelData: () => new Float32Array(frames),
|
|
49
|
+
}),
|
|
50
|
+
createBufferSource: () => {
|
|
51
|
+
const node: any = {connect: () => {}, start: (at: number) => scheduled.push({at, frames: node.buffer.frames})}
|
|
52
|
+
return node
|
|
53
|
+
},
|
|
54
|
+
scheduled,
|
|
55
|
+
}
|
|
56
|
+
return ctx
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
console.log('\n[media-view] audio player: sequential playhead + live backlog drop')
|
|
61
|
+
{
|
|
62
|
+
const [emit, line] = listen<[Uint8Array, number]>()
|
|
63
|
+
const ctx = createFakeAudioContext()
|
|
64
|
+
const player = attachAudioPlayer(line as any, {audioContext: () => ctx as any})
|
|
65
|
+
|
|
66
|
+
emit(pcmFrame(1), Date.now())
|
|
67
|
+
ok(player.stats().frames == 1 && player.stats().played == 0, 'disabled player counts frames but schedules nothing')
|
|
68
|
+
|
|
69
|
+
player.enable()
|
|
70
|
+
ok(player.enabled && ctx.resumed == 1, 'enable() builds the injected context and resumes it')
|
|
71
|
+
|
|
72
|
+
emit(pcmFrame(2), Date.now() - 40)
|
|
73
|
+
ok(player.stats().played == 1 && ctx.scheduled.length == 1, 'enabled player schedules a decoded pcm16 buffer')
|
|
74
|
+
ok(ctx.scheduled[0].frames == 480, 'sample count survives decode (480 frames)')
|
|
75
|
+
ok(player.stats().ageMs >= 40, 'sentAt stamp turns into a real age measurement')
|
|
76
|
+
|
|
77
|
+
const before = ctx.scheduled.length
|
|
78
|
+
emit(videoFrame(3, 320, 240), Date.now())
|
|
79
|
+
ok(ctx.scheduled.length == before && player.stats().frames == 3, 'video frames on an audio player are counted, not scheduled')
|
|
80
|
+
|
|
81
|
+
// 40 x 10ms chunks pushed instantly = 0.4s of queued audio while currentTime stays 0
|
|
82
|
+
for (let i = 0; i < 40; i++) emit(pcmFrame(10 + i), Date.now())
|
|
83
|
+
ok(player.stats().dropped > 0, 'backlog past maxBacklogSec is dropped (live policy), not queued forever')
|
|
84
|
+
const last = ctx.scheduled[ctx.scheduled.length - 1]
|
|
85
|
+
ok(last.at <= 0.45, `playhead rebased near "now" after the drop (last at=${last.at.toFixed(2)}s)`)
|
|
86
|
+
|
|
87
|
+
player.disable()
|
|
88
|
+
ok(!player.enabled && ctx.closed == 1, 'disable() closes the context')
|
|
89
|
+
player.off()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n[media-view] video canvas: sizing follows frames, busy-skip keeps latest')
|
|
93
|
+
{
|
|
94
|
+
const [emit, line] = listen<[Uint8Array, number]>()
|
|
95
|
+
const drawnImages: any[] = []
|
|
96
|
+
const canvas: any = {
|
|
97
|
+
width: 0,
|
|
98
|
+
height: 0,
|
|
99
|
+
getContext: () => ({drawImage: (img: any) => drawnImages.push(img)}),
|
|
100
|
+
}
|
|
101
|
+
let decodeDelay = 0
|
|
102
|
+
const view = attachVideoCanvas(line as any, canvas, {
|
|
103
|
+
createBitmap: async blob => {
|
|
104
|
+
if (decodeDelay) await delay(decodeDelay)
|
|
105
|
+
return {size: blob.size, close: () => {}}
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
emit(videoFrame(1, 640, 480), Date.now() - 25)
|
|
110
|
+
await delay(10)
|
|
111
|
+
ok(view.stats().drawn == 1 && drawnImages.length == 1, 'video frame decoded via injected bitmap factory and drawn')
|
|
112
|
+
ok(canvas.width == 640 && canvas.height == 480, 'canvas takes the frame size')
|
|
113
|
+
ok(view.stats().width == 640 && view.stats().ageMs >= 25, 'stats expose frame size and age')
|
|
114
|
+
|
|
115
|
+
emit(pcmFrame(2), Date.now())
|
|
116
|
+
await delay(10)
|
|
117
|
+
ok(view.stats().drawn == 1 && view.stats().frames == 2, 'audio frame on a video canvas is counted, not drawn')
|
|
118
|
+
|
|
119
|
+
decodeDelay = 30
|
|
120
|
+
emit(videoFrame(3, 1280, 720), Date.now())
|
|
121
|
+
emit(videoFrame(4, 1280, 720), Date.now()) // arrives while frame 3 decodes -> skipped
|
|
122
|
+
await delay(60)
|
|
123
|
+
ok(view.stats().frames == 4 && view.stats().drawn == 2, 'busy-skip: overlapping frame dropped, not queued')
|
|
124
|
+
ok(canvas.width == 1280 && canvas.height == 720, 'canvas resized to the new resolution')
|
|
125
|
+
|
|
126
|
+
view.off()
|
|
127
|
+
emit(videoFrame(5, 320, 240), Date.now())
|
|
128
|
+
await delay(10)
|
|
129
|
+
ok(view.stats().frames == 4, 'off() unsubscribes the line')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log('\n[media-view] publish pipe: stamp + onError')
|
|
133
|
+
{
|
|
134
|
+
const [emit, line] = listen<[Uint8Array]>()
|
|
135
|
+
const sent: {frame: Uint8Array, sentAt?: number}[] = []
|
|
136
|
+
const errors: unknown[] = []
|
|
137
|
+
const off = pipeMediaPublish(line as any, (frame, sentAt) => {
|
|
138
|
+
sent.push({frame, sentAt})
|
|
139
|
+
return frame.byteLength == 0 ? Promise.reject(new Error('boom')) : Promise.resolve(true)
|
|
140
|
+
}, {onError: e => errors.push(e)})
|
|
141
|
+
|
|
142
|
+
emit(pcmFrame(1))
|
|
143
|
+
ok(sent.length == 1 && typeof sent[0].sentAt == 'number', 'frames are published with a wall-clock stamp by default')
|
|
144
|
+
|
|
145
|
+
emit(new Uint8Array(0))
|
|
146
|
+
await delay(10)
|
|
147
|
+
ok(errors.length == 1, 'publish rejection lands in onError, capture loop survives')
|
|
148
|
+
|
|
149
|
+
off()
|
|
150
|
+
emit(pcmFrame(2))
|
|
151
|
+
ok(sent.length == 2, 'off() stops publishing')
|
|
152
|
+
|
|
153
|
+
const noStamp: (number | undefined)[] = []
|
|
154
|
+
const [emit2, line2] = listen<[Uint8Array]>()
|
|
155
|
+
pipeMediaPublish(line2 as any, (_f, sentAt) => { noStamp.push(sentAt) }, {stamp: false})
|
|
156
|
+
emit2(pcmFrame(3))
|
|
157
|
+
ok(noStamp.length == 1 && noStamp[0] == undefined, 'stamp:false publishes without a timestamp')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
161
|
+
if (fails) process.exit(1)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
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`.
|