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.
@@ -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 push(env) {
27
- if (env == null || typeof env.seq != 'number')
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
- (0, store_1.applyStorePatch)(fold, patch);
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
- return kf ? [kf] : null;
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 = { line, since, keyframe, frame };
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.snapshot(),
96
+ snapshot: () => fold?.snapshot(),
75
97
  close: () => { line.close(); },
76
98
  };
77
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wenay-common2",
3
- "version": "1.0.70",
3
+ "version": "1.0.73",
4
4
  "description": "Common library",
5
5
  "strict": true,
6
6
  "main": "lib/index.js",
@@ -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) })
@@ -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`.