@dimina-kit/fs-core 0.2.0-dev.20260710085051

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 (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dist/agent-tools.js +52 -0
  4. package/dist/client.js +234 -0
  5. package/dist/disk-mirror.js +97 -0
  6. package/dist/fs-core.worker.js +1001 -0
  7. package/dist/fs-query.worker.js +115 -0
  8. package/dist/sync/sync-engine.js +381 -0
  9. package/dist/sync/truth-port.js +1 -0
  10. package/dist/worker-lib/engine-shared.js +25 -0
  11. package/dist/worker-lib/paths.js +17 -0
  12. package/dist/worker-lib/rpc-types.js +1 -0
  13. package/dist/worker-lib/wal-codec.js +111 -0
  14. package/dist/zip.js +60 -0
  15. package/package.json +75 -0
  16. package/src/__checks__/types-smoke.ts +23 -0
  17. package/src/agent-tools.test.ts +151 -0
  18. package/src/agent-tools.ts +117 -0
  19. package/src/client.test.ts +110 -0
  20. package/src/client.ts +284 -0
  21. package/src/disk-mirror.test.ts +190 -0
  22. package/src/disk-mirror.ts +119 -0
  23. package/src/fs-core-recovery.ts +280 -0
  24. package/src/fs-core-write-ops.ts +402 -0
  25. package/src/fs-core.worker.ts +182 -0
  26. package/src/fs-query.worker.ts +152 -0
  27. package/src/import-smoke.test.ts +40 -0
  28. package/src/worker-lib/engine-shared.test.ts +30 -0
  29. package/src/worker-lib/engine-shared.ts +74 -0
  30. package/src/worker-lib/paths.test.ts +39 -0
  31. package/src/worker-lib/paths.ts +14 -0
  32. package/src/worker-lib/rpc-types.ts +67 -0
  33. package/src/worker-lib/wal-codec.test.ts +83 -0
  34. package/src/worker-lib/wal-codec.ts +130 -0
  35. package/src/zip.test.ts +124 -0
  36. package/src/zip.ts +60 -0
  37. package/sync/sync-engine.test.ts +695 -0
  38. package/sync/sync-engine.ts +459 -0
  39. package/sync/truth-port.ts +72 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * fs-query worker — ProjectFS 只读镜像。
3
+ * fs-core 经专用 MessagePort 推 {gen, diff|full};本 worker 承接 snapshot/grep/glob/大读,
4
+ * 查询永不进入写路径(fs-core 的事件循环不被大 grep 阻塞)。镜像无持久状态,可随时重建。
5
+ */
6
+
7
+ interface MirrorEntry {
8
+ content: string
9
+ rev?: number
10
+ }
11
+
12
+ interface QueryOpError extends Error {
13
+ code?: string
14
+ }
15
+
16
+ interface SyncMsg {
17
+ gen: number
18
+ full?: boolean
19
+ files?: Record<string, MirrorEntry>
20
+ diff?: Record<string, MirrorEntry | null>
21
+ }
22
+
23
+ interface Waiter {
24
+ gen: number
25
+ resolve: () => void
26
+ timer: ReturnType<typeof setTimeout>
27
+ }
28
+
29
+ const state: {
30
+ mirror: Map<string, MirrorEntry>
31
+ gen: number
32
+ waiters: Waiter[]
33
+ } = {
34
+ mirror: new Map(), // path -> {content, rev}
35
+ gen: -1, // -1 = 尚未收到 core 的首次同步
36
+ waiters: [], // [{gen, resolve, timer}]
37
+ }
38
+
39
+ function applySync(msg: SyncMsg): void {
40
+ if (msg.full) {
41
+ state.mirror.clear()
42
+ for (const [p, ent] of Object.entries(msg.files || {})) state.mirror.set(p, ent)
43
+ } else {
44
+ for (const [p, ent] of Object.entries(msg.diff || {})) {
45
+ if (ent === null) state.mirror.delete(p)
46
+ else state.mirror.set(p, ent)
47
+ }
48
+ }
49
+ state.gen = msg.gen
50
+ state.waiters = state.waiters.filter((w) => {
51
+ if (state.gen >= w.gen) { clearTimeout(w.timer); w.resolve(); return false }
52
+ return true
53
+ })
54
+ }
55
+
56
+ /** 等镜像追平 gen;超时则按当前 gen 返回(结果带 stale 标记)。 */
57
+ function whenGen(gen: number | undefined): Promise<void> {
58
+ if (gen === undefined || state.gen >= gen) return Promise.resolve()
59
+ return new Promise((resolve) => {
60
+ const w: Waiter = { gen, resolve, timer: setTimeout(() => { state.waiters = state.waiters.filter((x) => x !== w); resolve() }, 5000) }
61
+ state.waiters.push(w)
62
+ })
63
+ }
64
+
65
+ const GLOB_SPECIAL_RE = /[.+^${}()|[\]\\]/
66
+
67
+ /** Translates the glob token starting at `pattern[i]`; returns the regex
68
+ * fragment plus how many EXTRA characters (beyond the one at `i`) it consumed,
69
+ * so the caller's loop index can skip over a consumed double-star (with or
70
+ * without a trailing slash). */
71
+ function translateGlobToken(pattern: string, i: number): { token: string; skip: number } {
72
+ const c = pattern[i]
73
+ if (c === '*') {
74
+ if (pattern[i + 1] === '*') {
75
+ const slash = pattern[i + 2] === '/'
76
+ return { token: slash ? '(?:.*/)?' : '.*', skip: slash ? 2 : 1 }
77
+ }
78
+ return { token: '[^/]*', skip: 0 }
79
+ }
80
+ if (c === '?') return { token: '[^/]', skip: 0 }
81
+ const escaped = GLOB_SPECIAL_RE.test(c!) ? '\\' + c : c!
82
+ return { token: escaped, skip: 0 }
83
+ }
84
+
85
+ function globToRegExp(pattern: string): RegExp {
86
+ let re = ''
87
+ for (let i = 0; i < pattern.length; i++) {
88
+ const { token, skip } = translateGlobToken(pattern, i)
89
+ re += token
90
+ i += skip
91
+ }
92
+ return new RegExp('^' + re + '$')
93
+ }
94
+
95
+ const ops = {
96
+ async snapshot({ gen }: { gen?: number }) {
97
+ await whenGen(gen)
98
+ const files: Record<string, string> = {}
99
+ for (const [p, ent] of state.mirror) files[p] = ent.content
100
+ return { files, gen: state.gen, stale: gen !== undefined && state.gen < gen }
101
+ },
102
+ async read({ path, gen }: { path: string; gen?: number }) {
103
+ await whenGen(gen)
104
+ const e = state.mirror.get(path)
105
+ if (!e) throw Object.assign(new Error(path), { code: 'not-found' })
106
+ return { content: e.content, rev: e.rev, gen: state.gen }
107
+ },
108
+ async glob({ pattern, gen }: { pattern: string; gen?: number }) {
109
+ await whenGen(gen)
110
+ const re = globToRegExp(pattern)
111
+ return { paths: [...state.mirror.keys()].filter((p) => re.test(p)).sort(), gen: state.gen }
112
+ },
113
+ async grep({ pattern, flags = '', glob, gen, limit = 200 }: { pattern: string; flags?: string; glob?: string; gen?: number; limit?: number }) {
114
+ await whenGen(gen)
115
+ const re = new RegExp(pattern, flags.replace('g', ''))
116
+ const scope = glob ? globToRegExp(glob) : null
117
+ const hits: Array<{ path: string; lineNo: number; line: string }> = []
118
+ for (const [path, ent] of state.mirror) {
119
+ if (scope && !scope.test(path)) continue
120
+ const lines = ent.content.split('\n')
121
+ for (let i = 0; i < lines.length; i++) {
122
+ if (re.test(lines[i]!)) {
123
+ hits.push({ path, lineNo: i + 1, line: lines[i]!.slice(0, 500) })
124
+ if (hits.length >= limit) return { hits, gen: state.gen, truncated: true }
125
+ }
126
+ }
127
+ }
128
+ return { hits, gen: state.gen, truncated: false }
129
+ },
130
+ }
131
+
132
+ self.onmessage = async (e: MessageEvent) => {
133
+ const msg = e.data
134
+ if (msg.type === 'init') {
135
+ msg.corePort.onmessage = (ev: MessageEvent) => applySync(ev.data)
136
+ self.postMessage({ type: 'ready' })
137
+ return
138
+ }
139
+ if (msg.id === undefined) return
140
+ try {
141
+ // ops's members are declared via method shorthand with their own distinct
142
+ // arg shapes; dispatching by dynamic string key needs one widening cast —
143
+ // through `unknown` since the concrete shapes don't structurally overlap
144
+ // with a single dynamic-args signature.
145
+ const dispatch = ops as unknown as Record<string, (args: Record<string, unknown>) => Promise<unknown>>
146
+ const result = await dispatch[msg.op]!(msg.args || {})
147
+ self.postMessage({ id: msg.id, ok: true, result })
148
+ } catch (err) {
149
+ const e = err as QueryOpError
150
+ self.postMessage({ id: msg.id, ok: false, code: e.code || 'internal', error: e.message || String(err) })
151
+ }
152
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ /** Guards that none of the four public entry points touch a browser-only
4
+ * global (navigator.storage, Worker, showDirectoryPicker, ...) merely by
5
+ * being imported — those APIs are only allowed to be touched when their
6
+ * exported functions/classes are actually invoked. This suite runs under
7
+ * plain Node with no jsdom globals, so any import-time access would throw. */
8
+ describe('public entry points import cleanly under plain Node', () => {
9
+ it('imports client.js and exposes the ProjectFsClient class with its static/instance API', async () => {
10
+ const mod = await import('./client.js')
11
+ expect(typeof mod.ProjectFsClient).toBe('function')
12
+ expect(typeof mod.ProjectFsClient.connect).toBe('function')
13
+ expect(typeof mod.ProjectFsClient.wipe).toBe('function')
14
+ expect(typeof mod.ProjectFsClient.prototype.write).toBe('function')
15
+ expect(typeof mod.ProjectFsClient.prototype.read).toBe('function')
16
+ })
17
+
18
+ it('imports agent-tools.js and exposes createAgentTools as a function', async () => {
19
+ const mod = await import('./agent-tools.js')
20
+ expect(typeof mod.createAgentTools).toBe('function')
21
+ })
22
+
23
+ it('imports disk-mirror.js and exposes createDiskMirror as a function', async () => {
24
+ const mod = await import('./disk-mirror.js')
25
+ expect(typeof mod.createDiskMirror).toBe('function')
26
+ })
27
+
28
+ it('imports zip.js and exposes makeZip as a function', async () => {
29
+ const mod = await import('./zip.js')
30
+ expect(typeof mod.makeZip).toBe('function')
31
+ })
32
+
33
+ it('does not touch navigator.storage, Worker, or showDirectoryPicker in this Node environment', () => {
34
+ // Node itself defines a minimal `navigator` global (userAgent info only);
35
+ // it has no `storage` property, unlike a real browser's OPFS-capable one.
36
+ expect((globalThis.navigator as { storage?: unknown } | undefined)?.storage).toBeUndefined()
37
+ expect(typeof (globalThis as { Worker?: unknown }).Worker).toBe('undefined')
38
+ expect(typeof (globalThis as { showDirectoryPicker?: unknown }).showDirectoryPicker).toBe('undefined')
39
+ })
40
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { epochFloor, rpcErr } from './engine-shared.js'
3
+ import type { WalRecord } from './wal-codec.js'
4
+
5
+ describe('rpcErr', () => {
6
+ it('builds an Error carrying a code', () => {
7
+ const e = rpcErr('not-found', 'missing thing')
8
+ expect(e).toBeInstanceOf(Error)
9
+ expect(e.message).toBe('missing thing')
10
+ expect(e.code).toBe('not-found')
11
+ expect(e.extra).toBeUndefined()
12
+ })
13
+
14
+ it('attaches optional extra fields', () => {
15
+ const e = rpcErr('restore-conflict', 'boom', { humanPaths: ['a.txt'] })
16
+ expect(e.extra).toEqual({ humanPaths: ['a.txt'] })
17
+ })
18
+ })
19
+
20
+ describe('epochFloor', () => {
21
+ const rec = (epoch: number): WalRecord => ({ gen: 1, epoch, opcode: 1, meta: {} })
22
+
23
+ it('returns 0 for an empty replay list (epoch floor before anything replayed)', () => {
24
+ expect(epochFloor([])).toBe(0)
25
+ })
26
+
27
+ it('returns the last replayed record\'s epoch', () => {
28
+ expect(epochFloor([rec(0), rec(2), rec(2)])).toBe(2)
29
+ })
30
+ })
@@ -0,0 +1,74 @@
1
+ /**
2
+ * fs-core 引擎共享定义(lib 中立)—— opcode 语义、调优常量、内存态记录形状、
3
+ * RPC 错误构造。被 fs-core.worker.ts 及其拆出的 fs-core-recovery.ts /
4
+ * fs-core-write-ops.ts 三方共用;纯类型/常量声明,不含任何 DOM/WebWorker
5
+ * 专属全局引用,可被主 tsconfig(DOM lib)与 tsconfig.worker.json
6
+ * (WebWorker lib)两个 program 同时编译。
7
+ */
8
+ import type { WalRecord } from './wal-codec.js'
9
+
10
+ export const OP = { WRITE: 1, RM: 2, MV: 3, MKDIR: 4, CHECKPOINT: 5, RESTORE: 6 } as const
11
+ 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
+ export const WRITE_OPCODES = new Set<number>([OP.WRITE, OP.RM, OP.MV, OP.RESTORE])
14
+ export const INLINE_MAX = 4096 // payload ≤4KB 内联进 WAL 记录
15
+ export const GROUP_WINDOW_MS = 50 // 人类写组提交窗口
16
+ export const SEGMENT_ROTATE_BYTES = 4 * 1024 * 1024
17
+ export const OPID_WINDOW = 1024
18
+ // P4 turn 能力:agent 写必须在有效 turn 内(fs-core 侧执法,不信任调用方透传)
19
+ export const TURN_DEFAULT_TTL_MS = 120000
20
+ export const TURN_MAX_OPS = 1000 // per-turn 限额(跑飞的 agent 刹车)
21
+ export const AUDIT_CAP = 4096 // 内存审计环(fs_diff 的数据源;重启由 WAL 回放重建)
22
+ // P5 checkpoint LRU:保留最近 N 个;被淘汰者的 blob 在下次 compaction GC 回收
23
+ export const CHECKPOINT_KEEP = 20
24
+
25
+ export interface MirrorEntry {
26
+ content: string
27
+ rev: number
28
+ }
29
+
30
+ export interface AuditEntry {
31
+ gen: number
32
+ opcode: number
33
+ actor?: string
34
+ turnId?: string
35
+ path?: string
36
+ from?: string
37
+ to?: string
38
+ cpId?: string
39
+ }
40
+
41
+ export interface TurnState {
42
+ turnId: string
43
+ cpId: string
44
+ expiresAt: number
45
+ ops: number
46
+ }
47
+
48
+ export interface WindowOp {
49
+ respond: (r: Record<string, unknown>) => void
50
+ gen: number
51
+ path?: string
52
+ actor?: string
53
+ opId?: string
54
+ extra?: Record<string, unknown>
55
+ }
56
+
57
+ export type Respond = (r: Record<string, unknown>) => void
58
+
59
+ export interface WorkerError extends Error {
60
+ code?: string
61
+ extra?: Record<string, unknown>
62
+ }
63
+
64
+ export function rpcErr(code: string, message: string, extra?: Record<string, unknown>): WorkerError {
65
+ const e = new Error(message) as WorkerError
66
+ e.code = code
67
+ if (extra) e.extra = extra
68
+ return e
69
+ }
70
+
71
+ /** 供 fs-core-recovery.ts 的回放循环使用;纯函数(无副作用)。 */
72
+ export function epochFloor(replayed: WalRecord[]): number {
73
+ return replayed.length ? replayed[replayed.length - 1]!.epoch : 0 // epoch 单调不减
74
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { DERIVED_PREFIXES, normalizePath } from './paths.js'
3
+
4
+ describe('normalizePath', () => {
5
+ it('strips a leading "./" and collapses internal "."/empty segments', () => {
6
+ expect(normalizePath('./a/./b')).toBe('a/b')
7
+ expect(normalizePath('a//b')).toBe('a/b')
8
+ })
9
+
10
+ it('rejects absolute paths', () => {
11
+ expect(normalizePath('/a/b')).toBeNull()
12
+ })
13
+
14
+ it('rejects paths that escape via ".."', () => {
15
+ expect(normalizePath('a/../../b')).toBeNull()
16
+ })
17
+
18
+ it('rejects non-string, empty, NUL, and backslash input', () => {
19
+ expect(normalizePath(undefined)).toBeNull()
20
+ expect(normalizePath(42)).toBeNull()
21
+ expect(normalizePath('')).toBeNull()
22
+ expect(normalizePath('a\0b')).toBeNull()
23
+ expect(normalizePath('a\\b')).toBeNull()
24
+ })
25
+
26
+ it('returns null for a path that normalizes to nothing (all "." segments)', () => {
27
+ expect(normalizePath('./.')).toBeNull()
28
+ })
29
+
30
+ it('leaves an already-clean relative path untouched', () => {
31
+ expect(normalizePath('a/b/c.txt')).toBe('a/b/c.txt')
32
+ })
33
+ })
34
+
35
+ describe('DERIVED_PREFIXES', () => {
36
+ it('lists the derived-area prefixes checkWrite rejects', () => {
37
+ expect(DERIVED_PREFIXES).toEqual(['node_modules/', '.checkpoints/'])
38
+ })
39
+ })
@@ -0,0 +1,14 @@
1
+ /** 路径监狱(纯函数,lib 中立)—— fs-core.worker.ts 的写路径统一走这里做归一化/校验。 */
2
+ export const DERIVED_PREFIXES = ['node_modules/', '.checkpoints/']
3
+
4
+ export function normalizePath(p: unknown): string | null {
5
+ if (typeof p !== 'string' || !p || p.includes('\0') || p.includes('\\')) return null
6
+ if (p.startsWith('/')) return null
7
+ const parts: string[] = []
8
+ for (const seg of p.split('/')) {
9
+ if (seg === '' || seg === '.') continue
10
+ if (seg === '..') return null
11
+ parts.push(seg)
12
+ }
13
+ return parts.length ? parts.join('/') : null
14
+ }
@@ -0,0 +1,67 @@
1
+ /** Per-op RPC argument shapes (lib-neutral type aliases only) —— handleRpc's
2
+ * dispatch table narrows the incoming `Record<string, unknown>` bag to one of
3
+ * these via a single `as` per op, matching each op method's own destructured
4
+ * parameter type below. */
5
+ export interface WriteArgs {
6
+ path: string
7
+ content: unknown
8
+ ifMatch?: number | null
9
+ actor?: string
10
+ turnId?: string
11
+ agentToken?: string
12
+ opId?: string
13
+ }
14
+ export interface EditArgs {
15
+ path: string
16
+ old: string
17
+ next: string
18
+ ifMatch?: number
19
+ actor?: string
20
+ turnId?: string
21
+ agentToken?: string
22
+ opId?: string
23
+ }
24
+ export interface RmArgs {
25
+ path: string
26
+ actor?: string
27
+ turnId?: string
28
+ agentToken?: string
29
+ opId?: string
30
+ }
31
+ export interface MvArgs {
32
+ from: string
33
+ to: string
34
+ actor?: string
35
+ turnId?: string
36
+ agentToken?: string
37
+ opId?: string
38
+ }
39
+ export interface CheckpointArgs {
40
+ actor?: string
41
+ turnId?: string
42
+ agentToken?: string
43
+ opId?: string
44
+ }
45
+ export interface RestoreArgs {
46
+ cpId: string
47
+ baseGen?: number
48
+ force?: boolean
49
+ actor?: string
50
+ turnId?: string
51
+ agentToken?: string
52
+ opId?: string
53
+ }
54
+ export interface TurnBeginArgs {
55
+ turnId: string
56
+ ttlMs?: number
57
+ opId?: string
58
+ }
59
+ export interface TurnEndArgs {
60
+ turnId: string
61
+ }
62
+ export interface ReadArgs {
63
+ path: string
64
+ }
65
+ export interface DiffArgs {
66
+ turnId?: string
67
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { crc32, decodeSlot, encodeSlot, frameRecord, parseRecord, sha256hex } from './wal-codec.js'
3
+
4
+ describe('crc32', () => {
5
+ it('matches the well-known CRC-32 of "123456789" (0xCBF43926)', () => {
6
+ const bytes = new TextEncoder().encode('123456789')
7
+ expect(crc32(bytes)).toBe(0xcbf43926)
8
+ })
9
+
10
+ it('returns 0 for an empty input', () => {
11
+ expect(crc32(new Uint8Array(0))).toBe(0)
12
+ })
13
+
14
+ it('honors the start/end window', () => {
15
+ const bytes = new TextEncoder().encode('xx123456789yy')
16
+ expect(crc32(bytes, 2, 11)).toBe(0xcbf43926)
17
+ })
18
+ })
19
+
20
+ describe('sha256hex', () => {
21
+ it('matches the well-known SHA-256 of the empty string', async () => {
22
+ const hex = await sha256hex(new Uint8Array(0))
23
+ expect(hex).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
24
+ })
25
+ })
26
+
27
+ describe('superblock slot encode/decode', () => {
28
+ it('round-trips a slot', () => {
29
+ const slot = { epoch: 7, compactGen: 42, walStartGen: 43, manifestCrc: 0xdeadbeef }
30
+ const decoded = decodeSlot(encodeSlot(slot))
31
+ expect(decoded).toEqual(slot)
32
+ })
33
+
34
+ it('rejects a corrupted slot (bad CRC)', () => {
35
+ const bytes = encodeSlot({ epoch: 1, compactGen: 2, walStartGen: 3, manifestCrc: 4 })
36
+ bytes[0] = bytes[0]! ^ 0xff // flip a byte inside the magic — CRC no longer matches
37
+ expect(decodeSlot(bytes)).toBeNull()
38
+ })
39
+
40
+ it('rejects a too-short buffer', () => {
41
+ expect(decodeSlot(new Uint8Array(10))).toBeNull()
42
+ })
43
+ })
44
+
45
+ describe('WAL record frame/parse', () => {
46
+ it('round-trips a record through frameRecord → parseRecord', () => {
47
+ const meta = { path: 'a.txt', actor: 'human', payload: { inline: 'hi' } }
48
+ const frame = frameRecord(5, 1, 1, meta)
49
+ const parsed = parseRecord(frame, 0)
50
+ expect(parsed).not.toBeNull()
51
+ expect(parsed!.rec).toEqual({ gen: 5, epoch: 1, opcode: 1, meta })
52
+ expect(parsed!.next).toBe(frame.length)
53
+ })
54
+
55
+ it('parses back-to-back records at sequential offsets', () => {
56
+ const a = frameRecord(1, 0, 1, { path: 'a' })
57
+ const b = frameRecord(2, 0, 2, { path: 'a' })
58
+ const buf = new Uint8Array(a.length + b.length)
59
+ buf.set(a, 0); buf.set(b, a.length)
60
+ const first = parseRecord(buf, 0)!
61
+ expect(first.rec.gen).toBe(1)
62
+ const second = parseRecord(buf, first.next)!
63
+ expect(second.rec.gen).toBe(2)
64
+ expect(second.next).toBe(buf.length)
65
+ })
66
+
67
+ it('returns null on a truncated buffer', () => {
68
+ const frame = frameRecord(1, 0, 1, { path: 'a' })
69
+ expect(parseRecord(frame.subarray(0, frame.length - 1), 0)).toBeNull()
70
+ })
71
+
72
+ it('returns null when the trailing CRC is corrupted', () => {
73
+ const frame = frameRecord(1, 0, 1, { path: 'a' })
74
+ frame[frame.length - 2] = frame[frame.length - 2]! ^ 0xff
75
+ expect(parseRecord(frame, 0)).toBeNull()
76
+ })
77
+
78
+ it('returns null when the commit byte is wrong', () => {
79
+ const frame = frameRecord(1, 0, 1, { path: 'a' })
80
+ frame[frame.length - 1] = 0x00
81
+ expect(parseRecord(frame, 0)).toBeNull()
82
+ })
83
+ })
@@ -0,0 +1,130 @@
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 实现(消除重复代码)。
7
+ *
8
+ * WAL 记录成帧:
9
+ * [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
10
+ * len = len 字段之后的字节数;crc 覆盖 gen..meta 末尾。
11
+ */
12
+ export const enc = new TextEncoder()
13
+ export const dec = new TextDecoder()
14
+
15
+ // ───────────────────────── crc32(查表法) ─────────────────────────
16
+ export const CRC_TABLE = (() => {
17
+ const t = new Uint32Array(256)
18
+ for (let n = 0; n < 256; n++) {
19
+ let c = n
20
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
21
+ t[n] = c >>> 0
22
+ }
23
+ return t
24
+ })()
25
+ export function crc32(bytes: Uint8Array, start = 0, end: number = bytes.length): number {
26
+ let c = 0xffffffff
27
+ for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
28
+ return (c ^ 0xffffffff) >>> 0
29
+ }
30
+
31
+ export async function sha256hex(bytes: Uint8Array): Promise<string> {
32
+ const d = await crypto.subtle.digest('SHA-256', bytes as BufferSource)
33
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('')
34
+ }
35
+
36
+ export interface SlotInfo {
37
+ epoch: number
38
+ compactGen: number
39
+ walStartGen: number
40
+ manifestCrc: number
41
+ }
42
+
43
+ const SB_MAGIC = 0x44574331 // 'DWC1'
44
+ export const SLOT_SIZE = 64
45
+
46
+ // ───────────────────────── superblock 槽编解码 ─────────────────────────
47
+ // 布局:magic u32 | epoch u32 | compactGen f64 | walStartGen f64 | manifestCrc u32 | pad→60 | crc32 u32
48
+ export function encodeSlot(s: SlotInfo): Uint8Array {
49
+ const buf = new ArrayBuffer(SLOT_SIZE)
50
+ const dv = new DataView(buf)
51
+ dv.setUint32(0, SB_MAGIC)
52
+ dv.setUint32(4, s.epoch)
53
+ dv.setFloat64(8, s.compactGen)
54
+ dv.setFloat64(16, s.walStartGen)
55
+ dv.setUint32(24, s.manifestCrc)
56
+ dv.setUint32(60, crc32(new Uint8Array(buf, 0, 60)))
57
+ return new Uint8Array(buf)
58
+ }
59
+ export function decodeSlot(bytes: Uint8Array): SlotInfo | null {
60
+ if (bytes.length < SLOT_SIZE) return null
61
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, SLOT_SIZE)
62
+ if (dv.getUint32(60) !== crc32(bytes, 0, 60)) return null
63
+ if (dv.getUint32(0) !== SB_MAGIC) return null
64
+ return {
65
+ epoch: dv.getUint32(4),
66
+ compactGen: dv.getFloat64(8),
67
+ walStartGen: dv.getFloat64(16),
68
+ manifestCrc: dv.getUint32(24),
69
+ }
70
+ }
71
+
72
+ /** WAL 记录的 meta 载荷形状 —— 各 opcode 只填自己用到的字段(落 WAL 的持久
73
+ * 线格式,字段名/结构不可随意变更,见 fs-core.worker.ts 里对应 op* 方法)。 */
74
+ export interface WalMeta {
75
+ opId?: string
76
+ path?: string
77
+ from?: string
78
+ to?: string
79
+ actor?: string
80
+ turnId?: string
81
+ ifMatch?: number | null
82
+ payload?: { inline?: string; h?: string }
83
+ cpId?: string
84
+ h?: string
85
+ }
86
+
87
+ export interface WalRecord {
88
+ gen: number
89
+ epoch: number
90
+ opcode: number
91
+ meta: WalMeta
92
+ }
93
+
94
+ // ───────────────────────── WAL 记录编解码 ─────────────────────────
95
+ export function frameRecord(gen: number, epoch: number, opcode: number, meta: unknown): Uint8Array {
96
+ const metaBytes = enc.encode(JSON.stringify(meta))
97
+ const len = 8 + 4 + 1 + 2 + metaBytes.length + 4 + 1
98
+ const buf = new ArrayBuffer(4 + len)
99
+ const dv = new DataView(buf)
100
+ const u8 = new Uint8Array(buf)
101
+ dv.setUint32(0, len)
102
+ dv.setBigUint64(4, BigInt(gen))
103
+ dv.setUint32(12, epoch)
104
+ dv.setUint8(16, opcode)
105
+ dv.setUint16(17, metaBytes.length)
106
+ u8.set(metaBytes, 19)
107
+ const crcEnd = 19 + metaBytes.length
108
+ dv.setUint32(crcEnd, crc32(u8, 4, crcEnd))
109
+ dv.setUint8(crcEnd + 4, 0xc1)
110
+ return u8
111
+ }
112
+ /** 解析一条记录;返回 {rec, next} 或 null(framing/CRC/commit 任一失败)。 */
113
+ export function parseRecord(u8: Uint8Array, off: number): { rec: WalRecord; next: number } | null {
114
+ if (off + 4 > u8.length) return null
115
+ const dv = new DataView(u8.buffer, u8.byteOffset)
116
+ const len = dv.getUint32(off)
117
+ if (len < 20 || off + 4 + len > u8.length) return null
118
+ const gen = Number(dv.getBigUint64(off + 4))
119
+ const epoch = dv.getUint32(off + 12)
120
+ const opcode = dv.getUint8(off + 16)
121
+ const metaLen = dv.getUint16(off + 17)
122
+ const metaStart = off + 19
123
+ const crcAt = metaStart + metaLen
124
+ if (crcAt + 5 !== off + 4 + len) return null
125
+ if (dv.getUint32(crcAt) !== crc32(u8, off + 4, crcAt)) return null
126
+ if (dv.getUint8(crcAt + 4) !== 0xc1) return null
127
+ let meta: WalMeta
128
+ try { meta = JSON.parse(dec.decode(u8.subarray(metaStart, crcAt))) as WalMeta } catch { return null }
129
+ return { rec: { gen, epoch, opcode, meta }, next: off + 4 + len }
130
+ }