@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The authoritative statement of fs-core's worker-artifact distribution
3
+ * contract, for Node-side consumers that copy/serve the worker files
4
+ * (dimina-kit workbench's vite-preset, qdmp-web-workbench's server.cjs):
5
+ *
6
+ * dist/fs-core.worker.js and dist/fs-query.worker.js are single-file,
7
+ * self-contained ESM (no import statements) living in the SAME directory
8
+ * as dist/client.js, under exactly these file names.
9
+ *
10
+ * build-workers.js asserts this at build time; consumers should derive the
11
+ * names/locations from here instead of re-deriving them with hardcoded
12
+ * string joins. Pure string manipulation (no node:path) so the module loads
13
+ * in any runtime and ships as both ESM (dist/worker-files.js) and CJS
14
+ * (dist/worker-files.cjs — see the exports map's `require` condition, for
15
+ * CommonJS hosts like server.cjs).
16
+ */
17
+
18
+ /** The worker artifacts' literal file names, siblings of `client.js`. */
19
+ export const FS_CORE_WORKER_FILES = ['fs-core.worker.js', 'fs-query.worker.js'] as const
20
+
21
+ export interface ResolvedWorkerFiles {
22
+ /** Directory holding client.js and both worker files. */
23
+ dir: string
24
+ /** Absolute path of each worker file, in {@link FS_CORE_WORKER_FILES} order. */
25
+ files: string[]
26
+ }
27
+
28
+ /**
29
+ * Resolve the on-disk worker file paths from the resolved path of the
30
+ * `./client` entry — the one path every consumer can obtain:
31
+ *
32
+ * resolveWorkerFiles(require.resolve('@dimina-kit/fs-core/client'))
33
+ *
34
+ * Preserves whichever path separator the input uses (POSIX or Windows), so
35
+ * the result is joinable/copyable as-is on the host platform.
36
+ */
37
+ export function resolveWorkerFiles(clientEntryPath: string): ResolvedWorkerFiles {
38
+ const cut = Math.max(clientEntryPath.lastIndexOf('/'), clientEntryPath.lastIndexOf('\\'))
39
+ if (cut < 0) throw new Error(`resolveWorkerFiles: not a path to client.js: ${clientEntryPath}`)
40
+ const sep = clientEntryPath[cut]!
41
+ const dir = clientEntryPath.slice(0, cut)
42
+ return { dir, files: FS_CORE_WORKER_FILES.map((name) => dir + sep + name) }
43
+ }
@@ -6,20 +6,21 @@
6
6
  * (WebWorker lib)两个 program 同时编译。
7
7
  */
8
8
  import type { WalRecord } from './wal-codec.js'
9
+ import type { FsCoreErrorCode } from './protocol.js'
9
10
 
10
11
  export const OP = { WRITE: 1, RM: 2, MV: 3, MKDIR: 4, CHECKPOINT: 5, RESTORE: 6 } as const
11
12
  export const OP_NAME: Record<number, string> = { 1: 'write', 2: 'rm', 3: 'mv', 4: 'mkdir', 5: 'checkpoint', 6: 'restore' }
12
- // §4.7 restore 冲突检查只关心"写类"操作(改变文件内容/存在性),checkpoint 本身不算
13
+ // restore 冲突检查只关心"写类"操作(改变文件内容/存在性),checkpoint 本身不算
13
14
  export const WRITE_OPCODES = new Set<number>([OP.WRITE, OP.RM, OP.MV, OP.RESTORE])
14
15
  export const INLINE_MAX = 4096 // payload ≤4KB 内联进 WAL 记录
15
16
  export const GROUP_WINDOW_MS = 50 // 人类写组提交窗口
16
17
  export const SEGMENT_ROTATE_BYTES = 4 * 1024 * 1024
17
18
  export const OPID_WINDOW = 1024
18
- // P4 turn 能力:agent 写必须在有效 turn 内(fs-core 侧执法,不信任调用方透传)
19
+ // turn 能力:agent 写必须在有效 turn 内(fs-core 侧执法,不信任调用方透传)
19
20
  export const TURN_DEFAULT_TTL_MS = 120000
20
21
  export const TURN_MAX_OPS = 1000 // per-turn 限额(跑飞的 agent 刹车)
21
22
  export const AUDIT_CAP = 4096 // 内存审计环(fs_diff 的数据源;重启由 WAL 回放重建)
22
- // P5 checkpoint LRU:保留最近 N 个;被淘汰者的 blob 在下次 compaction GC 回收
23
+ // checkpoint LRU:保留最近 N 个;被淘汰者的 blob 在下次 compaction GC 回收
23
24
  export const CHECKPOINT_KEEP = 20
24
25
 
25
26
  export interface MirrorEntry {
@@ -54,14 +55,27 @@ export interface WindowOp {
54
55
  extra?: Record<string, unknown>
55
56
  }
56
57
 
58
+ /** An opId's cached RPC result, replayed verbatim (+`idempotent: true`) when
59
+ * the same opId is re-sent after a response timeout. The live write path
60
+ * caches the FULL respond payload (`{gen, rev, ...extra}` / restore's
61
+ * `{gen, restored}`) so a replay carries the same fields the first response
62
+ * did — `cpId`/`turnId`/`expiresAt`/`restored` included. Entries rebuilt by
63
+ * WAL replay after a worker restart only carry `{gen}` (the WAL does not
64
+ * record memory-only extras); those entries serve cross-session dedup, which
65
+ * a same-session retry never hits. */
66
+ export type OpIdResult = { gen: number; rev?: number } & Record<string, unknown>
67
+
57
68
  export type Respond = (r: Record<string, unknown>) => void
58
69
 
59
70
  export interface WorkerError extends Error {
60
- code?: string
71
+ code?: FsCoreErrorCode
61
72
  extra?: Record<string, unknown>
62
73
  }
63
74
 
64
- export function rpcErr(code: string, message: string, extra?: Record<string, unknown>): WorkerError {
75
+ /** `code` is typed against the exported wire contract (worker-lib/protocol.ts),
76
+ * so the set of codes this worker can throw and the set consumers can match on
77
+ * are the same list by construction. */
78
+ export function rpcErr(code: FsCoreErrorCode, message: string, extra?: Record<string, unknown>): WorkerError {
65
79
  const e = new Error(message) as WorkerError
66
80
  e.code = code
67
81
  if (extra) e.extra = extra
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Guards the exported wire contract: consumers match rejections via
3
+ * `isFsCoreErrorCode`/`getFsCoreErrorCode` against the SAME code list the
4
+ * worker's `rpcErr` factory is typed against — so a code the worker can
5
+ * throw and a code a consumer can match are one set by construction.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { FS_CORE_ERROR_CODES, getFsCoreErrorCode, isFsCoreErrorCode } from './protocol.js'
9
+ import { rpcErr } from './engine-shared.js'
10
+
11
+ describe('fs-core error-code contract', () => {
12
+ it('recognizes an error produced by the worker-side rpcErr factory', () => {
13
+ const e = rpcErr('turn-closed', 'turn expired: t-1')
14
+ expect(getFsCoreErrorCode(e)).toBe('turn-closed')
15
+ expect(isFsCoreErrorCode(e, 'turn-closed')).toBe(true)
16
+ expect(isFsCoreErrorCode(e, 'readonly')).toBe(false)
17
+ })
18
+
19
+ it('recognizes the plain-object shape a structured-clone/client rejection carries', () => {
20
+ // client.ts materializes rejections as Object.assign(new Error(msg), {code})
21
+ const clientShaped = Object.assign(new Error('fs-core is readonly'), { code: 'readonly' })
22
+ expect(getFsCoreErrorCode(clientShaped)).toBe('readonly')
23
+ expect(isFsCoreErrorCode(clientShaped, 'readonly')).toBe(true)
24
+ })
25
+
26
+ it('returns undefined for non-fs-core errors, unknown codes, and non-objects', () => {
27
+ expect(getFsCoreErrorCode(new Error('plain'))).toBeUndefined()
28
+ expect(getFsCoreErrorCode(Object.assign(new Error('x'), { code: 'ENOENT' }))).toBeUndefined()
29
+ expect(getFsCoreErrorCode(null)).toBeUndefined()
30
+ expect(getFsCoreErrorCode('readonly')).toBeUndefined()
31
+ expect(isFsCoreErrorCode(undefined, 'readonly')).toBe(false)
32
+ })
33
+
34
+ it('lists every code exactly once', () => {
35
+ expect(new Set(FS_CORE_ERROR_CODES).size).toBe(FS_CORE_ERROR_CODES.length)
36
+ })
37
+ })
@@ -0,0 +1,184 @@
1
+ /**
2
+ * fs-core wire contract — the single authoritative enumeration of the error
3
+ * codes, event names, mode values, and message shapes that cross the worker
4
+ * boundary. Both ends compile against this module (worker side:
5
+ * engine-shared.ts's `rpcErr` signature; main-thread side: client.ts), and
6
+ * consumers should match on these exported symbols instead of quoting string
7
+ * literals from worker source or citing dist line numbers — those references
8
+ * rot on every version bump while the wire strings silently stay load-bearing.
9
+ *
10
+ * Lib-neutral (types + pure predicates only) so it stays compilable by both
11
+ * the DOM and the WebWorker tsc programs — see this directory's convention in
12
+ * ../../tsconfig.json.
13
+ */
14
+
15
+ /**
16
+ * Single-writer lifecycle of one fs-core worker, as observed by a client:
17
+ * starting (HELLO sent, WELCOME not yet received) | writer (holds the Web
18
+ * Locks writer lease) | readonly (queued for the lease, or handed it over) |
19
+ * draining (worker-side handover transient — a client never observes it for
20
+ * long; see fs-core-recovery.ts, which converges to readonly before
21
+ * broadcasting) | dead (FATAL received, the worker is unusable).
22
+ */
23
+ export type FsCoreMode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
24
+
25
+ /**
26
+ * Every `code` a rejected fs-core RPC can carry (client-side: `error.code` on
27
+ * the rejection; wire-side: the `code` field of a `{ok: false}` reply). The
28
+ * worker's `rpcErr` factory is typed against this list, so a code appearing
29
+ * here and a code the worker can actually throw are the same set by
30
+ * construction.
31
+ */
32
+ export const FS_CORE_ERROR_CODES = [
33
+ /** Write attempted while this worker does not hold the writer lease (another tab owns it). */
34
+ 'readonly',
35
+ /** Write attempted while the writer lease handover is in progress. */
36
+ 'draining',
37
+ /** Agent write without an active matching turn, or the turn has expired. */
38
+ 'turn-closed',
39
+ /** `turnBegin` while another turn is already active. */
40
+ 'turn-active',
41
+ /** Per-turn op quota exceeded. */
42
+ 'turn-quota',
43
+ /** Agent write without the armed agent token (see `armAgentTokenGate`). */
44
+ 'agent-token-required',
45
+ /** `armAgentTokenGate` re-armed with a DIFFERENT token (same token replays are ok+idempotent). */
46
+ 'agent-token-gate-armed',
47
+ /** `restore` refused: the audit window no longer covers `baseGen`, or human edits landed since (carries `humanPaths`/`auditGap` extras). */
48
+ 'restore-conflict',
49
+ /** Optimistic-concurrency failure: `ifMatch` mismatch, or the target path already exists. */
50
+ 'cas-mismatch',
51
+ /** `edit`'s old string not found in the file. */
52
+ 'edit-no-match',
53
+ /** `edit`'s old string matches more than once. */
54
+ 'edit-ambiguous',
55
+ /** Path (or checkpoint id) does not exist. */
56
+ 'not-found',
57
+ /** Path failed normalization (absolute, `..`, empty, ...). */
58
+ 'bad-path',
59
+ /** Malformed argument (non-string content, missing turnId, ...). */
60
+ 'bad-args',
61
+ /** Unknown RPC op name. */
62
+ 'bad-op',
63
+ /** Write into a derived (read-only) area. */
64
+ 'derived-readonly',
65
+ /** Internal write-path signal (WAL segment rotation needed); not expected to surface to clients. */
66
+ 'rotate-needed',
67
+ /** Fallback for a worker-side error that carried no code of its own. */
68
+ 'internal',
69
+ ] as const
70
+
71
+ export type FsCoreErrorCode = (typeof FS_CORE_ERROR_CODES)[number]
72
+
73
+ /** Extra fields a `restore-conflict` rejection carries (see `rpcErr`'s `extra`). */
74
+ export interface FsCoreErrorExtras {
75
+ humanPaths?: string[]
76
+ auditGap?: boolean
77
+ }
78
+
79
+ /**
80
+ * `error.code` of `error` when it looks like an fs-core RPC rejection,
81
+ * else `undefined`. Purely structural — works on the plain `Error` the client
82
+ * materializes as well as on a structured-clone of it.
83
+ */
84
+ export function getFsCoreErrorCode(error: unknown): FsCoreErrorCode | undefined {
85
+ if (!error || typeof error !== 'object' || !('code' in error)) return undefined
86
+ const code = (error as { code?: unknown }).code
87
+ return (FS_CORE_ERROR_CODES as readonly unknown[]).includes(code) ? (code as FsCoreErrorCode) : undefined
88
+ }
89
+
90
+ /** True when `error` is an fs-core RPC rejection carrying exactly `code`. */
91
+ export function isFsCoreErrorCode(error: unknown, code: FsCoreErrorCode): boolean {
92
+ return getFsCoreErrorCode(error) === code
93
+ }
94
+
95
+ /** Names of the unsolicited events the core worker pushes to its client. */
96
+ export type FsCoreEventName = 'writer-granted' | 'writer-lost' | 'fs-change'
97
+
98
+ // ── Wire message shapes (worker → client main port) ──
99
+
100
+ /** First message after HELLO; resolves `ProjectFsClient.connect`. */
101
+ export interface CoreWelcomeMessage {
102
+ type: 'WELCOME'
103
+ epoch: number
104
+ memGen: number
105
+ readonly: boolean
106
+ mode: FsCoreMode
107
+ }
108
+
109
+ /** Unrecoverable worker failure — the worker is unusable afterwards. */
110
+ export interface CoreFatalMessage {
111
+ type: 'FATAL'
112
+ error: string
113
+ }
114
+
115
+ /** Liveness reply to the client's PING. */
116
+ export interface CorePongMessage {
117
+ type: 'PONG'
118
+ t?: number
119
+ }
120
+
121
+ /**
122
+ * Unsolicited event push. `writer-granted`/`writer-lost` drive the client's
123
+ * `mode`; `fs-change` reports a committed write batch (`paths` when ≤ 32,
124
+ * else just `count`; `restore` carries the checkpoint id a restore replayed).
125
+ */
126
+ export interface CoreEventMessage {
127
+ evt: FsCoreEventName
128
+ gen?: number
129
+ actor?: string
130
+ paths?: string[]
131
+ count?: number
132
+ restore?: string
133
+ }
134
+
135
+ /** RPC reply, success. */
136
+ export interface CoreReplyOkMessage {
137
+ id: number
138
+ ok: true
139
+ result: unknown
140
+ }
141
+
142
+ /** RPC reply, failure — `code` is one of {@link FS_CORE_ERROR_CODES}. */
143
+ export interface CoreReplyErrMessage extends FsCoreErrorExtras {
144
+ id: number
145
+ ok: false
146
+ code: FsCoreErrorCode
147
+ error: string
148
+ }
149
+
150
+ /** Everything the core worker's main port can deliver, as a discriminated union. */
151
+ export type CoreWireMessage =
152
+ | CoreWelcomeMessage
153
+ | CoreFatalMessage
154
+ | CorePongMessage
155
+ | CoreEventMessage
156
+ | CoreReplyOkMessage
157
+ | CoreReplyErrMessage
158
+
159
+ /**
160
+ * Loosely-shaped dynamic view of {@link CoreWireMessage}: every field
161
+ * optional, distinguished at runtime by which ones are present. This matches
162
+ * how `ProjectFsClient` actually reads the port (each field independently
163
+ * checked), and is the type its `onChange` callbacks receive; the union above
164
+ * is the authoritative shape each individual message satisfies.
165
+ */
166
+ export interface CoreMessage {
167
+ type?: string
168
+ evt?: string
169
+ error?: string
170
+ mode?: FsCoreMode
171
+ readonly?: boolean
172
+ gen?: number
173
+ id?: number
174
+ ok?: boolean
175
+ result?: unknown
176
+ code?: string
177
+ humanPaths?: string[]
178
+ auditGap?: boolean
179
+ actor?: string
180
+ paths?: string[]
181
+ count?: number
182
+ restore?: string
183
+ t?: number
184
+ }
@@ -1,9 +1,12 @@
1
1
  /**
2
- * WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析。
3
- * fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被主 tsconfig
4
- * (DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program 同时编译,
5
- * 因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program 侧)
6
- * 都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。
2
+ * WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析、
3
+ * sha256hex。从 fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被
4
+ * 主 tsconfig(DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program
5
+ * 同时编译,因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program
6
+ * 侧)都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。sync/ 侧
7
+ * (独立 rootDir 的第三个 tsc program)经 package.json 的 `imports` 自引用
8
+ * (`#worker-lib/wal-codec.js`,见 tsconfig.worker-lib.build.json)复用同一份
9
+ * sha256hex,同样消除重复代码。
7
10
  *
8
11
  * WAL 记录成帧:
9
12
  * [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
package/src/zip.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * store-only ZIP 打包(P5 导出出口)—— 无压缩、无依赖,全浏览器可用。
2
+ * store-only ZIP 打包(导出出口)—— 无压缩、无依赖,全浏览器可用。
3
3
  * 项目导出走"另一介质"才真正提升持久等级;zip 是最低门槛的那条出口。
4
4
  */
5
5
  import { crc32 } from './worker-lib/wal-codec.js'
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Contract tests for the binary side-car: classification, the size+sha256
3
+ * index, echo judgement, bytes retention, overlay precedence, and change
4
+ * events — the one authority every host's "binary never enters the string
5
+ * ledger" handling builds on.
6
+ */
7
+ import { describe, expect, it, vi } from 'vitest'
8
+ import { createBinarySidecar, looksBinary } from './binary-sidecar.js'
9
+
10
+ const png = (...bytes: number[]) => new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, ...bytes])
11
+
12
+ describe('looksBinary', () => {
13
+ it('classifies by a NUL byte in the first 8192 bytes', () => {
14
+ expect(looksBinary(png())).toBe(true)
15
+ expect(looksBinary(new TextEncoder().encode('plain text'))).toBe(false)
16
+ })
17
+
18
+ it('does not scan past the sniff window', () => {
19
+ const big = new Uint8Array(10000).fill(0x61)
20
+ big[9500] = 0 // NUL beyond the 8192-byte window
21
+ expect(looksBinary(big)).toBe(false)
22
+ })
23
+ })
24
+
25
+ describe('createBinarySidecar — index + echo judgement', () => {
26
+ it('put() records a new entry and reports changed', async () => {
27
+ const sc = createBinarySidecar()
28
+ expect(await sc.put('a.png', png(1))).toBe(true)
29
+ expect(sc.has('a.png')).toBe(true)
30
+ const entry = sc.entry('a.png')
31
+ expect(entry?.size).toBe(6)
32
+ expect(entry?.sha256).toMatch(/^[0-9a-f]{64}$/)
33
+ expect(sc.size).toBe(1)
34
+ })
35
+
36
+ it('put() of identical bytes is an echo: unchanged, no event', async () => {
37
+ const sc = createBinarySidecar()
38
+ const onChange = vi.fn()
39
+ await sc.put('a.png', png(1))
40
+ sc.onChange(onChange)
41
+ expect(await sc.put('a.png', png(1))).toBe(false)
42
+ expect(onChange).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('put() of different same-length bytes IS a change (sha decides, not size)', async () => {
46
+ const sc = createBinarySidecar()
47
+ await sc.put('a.png', png(1))
48
+ expect(await sc.put('a.png', png(2))).toBe(true)
49
+ })
50
+
51
+ it('remove() reports whether an entry existed and emits null once', async () => {
52
+ const sc = createBinarySidecar()
53
+ const onChange = vi.fn()
54
+ sc.onChange(onChange)
55
+ await expect(sc.remove('ghost.png')).resolves.toBe(false)
56
+ expect(onChange).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it('index-only sidecar retains no bytes and refuses overlay()', async () => {
60
+ const sc = createBinarySidecar()
61
+ await sc.put('a.png', png(1))
62
+ expect(sc.bytes('a.png')).toBeUndefined()
63
+ expect(sc.toRecord()).toEqual({})
64
+ expect(() => sc.overlay({})).toThrow(/retainBytes/)
65
+ })
66
+ })
67
+
68
+ describe('createBinarySidecar — retainBytes host shape', () => {
69
+ it('retains bytes and overlays them UNDER the ledger files (ledger wins)', async () => {
70
+ const sc = createBinarySidecar({ retainBytes: true })
71
+ await sc.put('img/a.png', png(1))
72
+ await sc.put('both.txt', png(9))
73
+ const merged = sc.overlay({ 'app.json': '{}', 'both.txt': 'ledger text' })
74
+ expect(merged['img/a.png']).toEqual(png(1))
75
+ expect(merged['app.json']).toBe('{}')
76
+ expect(merged['both.txt']).toBe('ledger text')
77
+ })
78
+
79
+ it('reset() reseeds silently-cleared state with the new entries', async () => {
80
+ const sc = createBinarySidecar({ retainBytes: true })
81
+ await sc.put('old.png', png(1))
82
+ await sc.reset({ 'new.png': png(2) })
83
+ expect(sc.has('old.png')).toBe(false)
84
+ expect(sc.keys()).toEqual(['new.png'])
85
+ expect(sc.bytes('new.png')).toEqual(png(2))
86
+ })
87
+
88
+ it('onChange fires for effective put/remove, unsubscribes cleanly, and a throwing subscriber cannot break the mutation', async () => {
89
+ const sc = createBinarySidecar({ retainBytes: true })
90
+ const seen: Array<[string, Uint8Array | null]> = []
91
+ sc.onChange(() => {
92
+ throw new Error('subscriber bug')
93
+ })
94
+ const off = sc.onChange((rel, bytes) => seen.push([rel, bytes]))
95
+ await sc.put('a.png', png(1))
96
+ await sc.remove('a.png')
97
+ expect(seen).toEqual([
98
+ ['a.png', png(1)],
99
+ ['a.png', null],
100
+ ])
101
+ expect(sc.has('a.png')).toBe(false)
102
+ off()
103
+ await sc.put('b.png', png(2))
104
+ expect(seen).toHaveLength(2)
105
+ })
106
+
107
+ it('reset() is atomic to readers and silent: a mid-reset read sees the OLD set, and no events fire', async () => {
108
+ const sc = createBinarySidecar({ retainBytes: true })
109
+ await sc.put('old.png', png(1))
110
+ const onChange = vi.fn()
111
+ sc.onChange(onChange)
112
+ const resetting = sc.reset({ 'new.png': png(2) })
113
+ // Hashing has suspended reset(); a concurrent reader (compile/export
114
+ // overlaying the sidecar) must still see the complete old set — never an
115
+ // empty or half-built one.
116
+ expect(sc.keys()).toEqual(['old.png'])
117
+ expect(sc.toRecord()).toEqual({ 'old.png': png(1) })
118
+ await resetting
119
+ expect(sc.keys()).toEqual(['new.png'])
120
+ expect(onChange).not.toHaveBeenCalled()
121
+ })
122
+
123
+ it('a put issued AFTER reset() queues behind it and lands in the NEW session (never silently swallowed by the swap)', async () => {
124
+ const sc = createBinarySidecar({ retainBytes: true })
125
+ const resetting = sc.reset({ 'seed.png': png(1) })
126
+ const putting = sc.put('live.png', png(2))
127
+ await Promise.all([resetting, putting])
128
+ expect(sc.keys().sort()).toEqual(['live.png', 'seed.png'])
129
+ })
130
+
131
+ it('overlapping resets resolve in CALLER order: the later caller wins even when the earlier one hashes longer', async () => {
132
+ const sc = createBinarySidecar({ retainBytes: true })
133
+ const slow = Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`slow${i}.png`, png(i)]))
134
+ const r1 = sc.reset(slow)
135
+ const r2 = sc.reset({ 'winner.png': png(9) })
136
+ await Promise.all([r1, r2])
137
+ expect(sc.keys()).toEqual(['winner.png'])
138
+ })
139
+
140
+ it('clear() is a silent session reseed: no per-entry removal events', async () => {
141
+ const sc = createBinarySidecar({ retainBytes: true })
142
+ await sc.put('a.png', png(1))
143
+ const onChange = vi.fn()
144
+ sc.onChange(onChange)
145
+ await sc.clear()
146
+ expect(sc.size).toBe(0)
147
+ expect(sc.toRecord()).toEqual({})
148
+ expect(onChange).not.toHaveBeenCalled()
149
+ })
150
+ })