@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,280 @@
1
+ /**
2
+ * fs-core 恢复/启动/只读查询路径 —— 从 fs-core.worker.ts 抽出(file-length +
3
+ * cognitive-complexity 双重考量)。每个导出函数的第一个参数 `core` 是宿主
4
+ * FsCore 实例;这里只是把原本内联在类里的方法体搬出来,`this.` 机械替换为
5
+ * `core.`,行为逐字保留。依赖 OPFS/Worker 专属 API(FileSystemSyncAccessHandle
6
+ * 等),不追求 lib 中立,因此不进 worker-lib/、且从主 tsconfig.json 里排除
7
+ * (见 tsconfig.json 的 exclude)。
8
+ */
9
+ import type { FsCore } from './fs-core.worker.js'
10
+ import {
11
+ crc32, dec, decodeSlot, enc, encodeSlot, parseRecord, sha256hex, SLOT_SIZE,
12
+ type SlotInfo, type WalRecord,
13
+ } from './worker-lib/wal-codec.js'
14
+ import { normalizePath } from './worker-lib/paths.js'
15
+ import { epochFloor, OP, OPID_WINDOW, rpcErr, SEGMENT_ROTATE_BYTES, type MirrorEntry } from './worker-lib/engine-shared.js'
16
+
17
+ export { epochFloor }
18
+
19
+ // ── 启动:锁 → 恢复 → 打开写句柄 → 全量同步 query ──
20
+ export async function start(core: FsCore, projectId: string): Promise<void> {
21
+ core.projectId = projectId
22
+ core.root = await (await navigator.storage.getDirectory()).getDirectoryHandle(projectId, { create: true })
23
+ core.bc = new BroadcastChannel('dwc:' + projectId)
24
+ core.bc.onmessage = (e: MessageEvent) => core.onBroadcast(e.data)
25
+
26
+ // 排队等锁;3s 拿不到先以只读服务,granted 后升级。禁止 steal。
27
+ const granted = new Promise<Lock | null>((resolve) => {
28
+ navigator.locks.request('dwc:writer:' + projectId, { mode: 'exclusive' }, (lock) => {
29
+ resolve(lock)
30
+ return new Promise<void>((release) => { core.releaseLock = release })
31
+ }).catch(() => resolve(null))
32
+ })
33
+ const winner = await Promise.race([granted, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 3000))])
34
+ if (winner === 'timeout') {
35
+ await core.recover()
36
+ core.mode = 'readonly'
37
+ core.pushFullToQuery()
38
+ core.welcome()
39
+ granted.then(async (lock) => {
40
+ if (!lock || core.mode === 'dead') return
41
+ await core.enqueue(async () => { await core.becomeWriter() })
42
+ })
43
+ return
44
+ }
45
+ await core.becomeWriter()
46
+ core.welcome()
47
+ }
48
+
49
+ export async function becomeWriter(core: FsCore): Promise<void> {
50
+ await core.recover() // 旧写者可能刚交出——以盘上状态为准重建
51
+ // epoch 递增写入候选槽(防御性护栏:记录归属可判别)
52
+ core.sbHandle = await (await core.root.getFileHandle('superblock', { create: true })).createSyncAccessHandle()
53
+ core.epoch += 1
54
+ core.writeSuperblock()
55
+ // 打开(或创建)最后一个 WAL 段,定位到有效前缀末尾
56
+ const segName = 'wal.' + core.lastSegStart
57
+ core.walHandle = await (await core.root.getFileHandle(segName, { create: true })).createSyncAccessHandle()
58
+ core.walOffset = core.lastSegValidEnd
59
+ core.mode = 'writer'
60
+ core.pushFullToQuery()
61
+ core.event({ evt: 'writer-granted', gen: core.memGen })
62
+ // 启动补偿:上一世代积累的 WAL 已超阈值(如崩溃打断了 compaction)→ 立即整理
63
+ if (core.walOffset > SEGMENT_ROTATE_BYTES) core.enqueue(() => core.compactNow())
64
+ }
65
+
66
+ export function writeSuperblock(core: FsCore): void {
67
+ const cand = 1 - core.currentSlot
68
+ const bytes = encodeSlot({ epoch: core.epoch, compactGen: core.compactGen, walStartGen: core.walStartGen, manifestCrc: core.manifestCrc || 0 })
69
+ const n = core.sbHandle!.write(bytes, { at: cand * SLOT_SIZE })
70
+ if (n !== SLOT_SIZE) throw new Error('superblock partial write: ' + n)
71
+ core.sbHandle!.flush()
72
+ core.currentSlot = cand
73
+ }
74
+
75
+ // ── 恢复:superblock 选槽 → manifest → 顺序回放 WAL 段(完整前缀截断) ──
76
+ async function loadSuperblockSlot(core: FsCore): Promise<SlotInfo> {
77
+ let sb: SlotInfo | null = null
78
+ try {
79
+ const f = await (await core.root.getFileHandle('superblock')).getFile()
80
+ const u8 = new Uint8Array(await f.arrayBuffer())
81
+ const s0 = decodeSlot(u8.subarray(0, SLOT_SIZE))
82
+ const s1 = u8.length >= 128 ? decodeSlot(u8.subarray(SLOT_SIZE, 2 * SLOT_SIZE)) : null
83
+ if (s0 && s1) { sb = s1.compactGen > s0.compactGen || (s1.compactGen === s0.compactGen && s1.epoch >= s0.epoch) ? s1 : s0; core.currentSlot = sb === s1 ? 1 : 0 }
84
+ else if (s0) { sb = s0; core.currentSlot = 0 }
85
+ else if (s1) { sb = s1; core.currentSlot = 1 }
86
+ } catch { /* 首次初始化 */ }
87
+ if (!sb) {
88
+ sb = { epoch: 0, compactGen: 0, walStartGen: 1, manifestCrc: 0 }
89
+ core.currentSlot = 1 // 首个 writeSuperblock 落 slot0
90
+ }
91
+ return sb
92
+ }
93
+
94
+ async function loadManifestState(core: FsCore, compactGen: number): Promise<number> {
95
+ if (compactGen <= 0) return 0
96
+ const f = await (await (await core.root.getDirectoryHandle('manifests')).getFileHandle(compactGen + '.json')).getFile()
97
+ const bytes = new Uint8Array(await f.arrayBuffer())
98
+ if (crc32(bytes) !== core.manifestCrc) throw new Error('manifest CRC mismatch (gen ' + compactGen + ')')
99
+ const m = JSON.parse(dec.decode(bytes))
100
+ for (const [path, ent] of Object.entries(m.files) as Array<[string, { h: string; rev: number }]>) {
101
+ core.mirror.set(path, { content: await core.readBlob(ent.h), rev: ent.rev })
102
+ }
103
+ for (const [cpId, entry] of Object.entries(m.checkpoints || {}) as Array<[string, { h: string; gen: number }]>) core.checkpoints.set(cpId, entry)
104
+ return m.gen
105
+ }
106
+
107
+ async function listWalSegmentNumbers(core: FsCore): Promise<number[]> {
108
+ const segs: number[] = []
109
+ for await (const [name] of core.root.entries()) {
110
+ const m = /^wal\.(\d+)$/.exec(name)
111
+ if (m && +m[1]! >= core.walStartGen) segs.push(+m[1]!)
112
+ }
113
+ segs.sort((a, b) => a - b)
114
+ return segs
115
+ }
116
+
117
+ function shouldStopReplay(p: { rec: WalRecord; next: number } | null, gen: number, stopBefore: number, replayed: WalRecord[]): boolean {
118
+ if (!p) return true
119
+ if (p.rec.gen !== gen + 1) return true
120
+ if (p.rec.gen >= stopBefore) return true
121
+ if (p.rec.epoch < epochFloor(replayed)) return true
122
+ return false
123
+ }
124
+
125
+ /** 单个 WAL 段的回放;返回段内推进后的 gen。isLastSeg 时把有效前缀末尾写回
126
+ * core.lastSegValidEnd(原 recover() 内联逻辑,行为不变)。 */
127
+ async function replaySegment(core: FsCore, segNum: number, stopBefore: number, gen: number, replayed: WalRecord[], isLastSeg: boolean): Promise<number> {
128
+ const f = await (await core.root.getFileHandle('wal.' + segNum)).getFile()
129
+ const u8 = new Uint8Array(await f.arrayBuffer())
130
+ let off = 0
131
+ while (off < u8.length) {
132
+ const p = parseRecord(u8, off)
133
+ if (shouldStopReplay(p, gen, stopBefore, replayed)) break
134
+ await core.applyRecord(p!.rec)
135
+ gen = p!.rec.gen
136
+ replayed.push(p!.rec)
137
+ const m = p!.rec.meta
138
+ core.audit({ gen, opcode: p!.rec.opcode, actor: m.actor, turnId: m.turnId, path: m.path, from: m.from, to: m.to, cpId: m.cpId })
139
+ off = p!.next
140
+ if (isLastSeg) core.lastSegValidEnd = off
141
+ }
142
+ // 中间空段(!isLastSeg && off === 0):允许,继续——无需额外处理。
143
+ return gen
144
+ }
145
+
146
+ async function replayWalSegments(core: FsCore, segs: number[], gen0: number): Promise<{ finalGen: number; replayed: WalRecord[] }> {
147
+ let gen = gen0
148
+ const replayed: WalRecord[] = []
149
+ for (let i = 0; i < segs.length; i++) {
150
+ const stopBefore = i + 1 < segs.length ? segs[i + 1]! : Infinity
151
+ gen = await replaySegment(core, segs[i]!, stopBefore, gen, replayed, segs[i] === core.lastSegStart)
152
+ }
153
+ return { finalGen: gen, replayed }
154
+ }
155
+
156
+ export async function recover(core: FsCore): Promise<void> {
157
+ core.mirror.clear(); core.checkpoints.clear(); core.opIds.clear()
158
+ core.auditLog = []; core.turn = null
159
+
160
+ const sb = await loadSuperblockSlot(core)
161
+ core.epoch = sb.epoch
162
+ core.compactGen = sb.compactGen
163
+ core.walStartGen = sb.walStartGen
164
+ core.manifestCrc = sb.manifestCrc
165
+
166
+ let gen = await loadManifestState(core, core.compactGen)
167
+
168
+ // 列出 ≥ walStartGen 的段,按 startGen 升序回放;段边界优先于段内垃圾尾
169
+ const segs = await listWalSegmentNumbers(core)
170
+ core.lastSegStart = segs.length ? segs[segs.length - 1]! : core.walStartGen
171
+ core.lastSegValidEnd = 0
172
+ const { finalGen, replayed } = await replayWalSegments(core, segs, gen)
173
+ gen = finalGen
174
+
175
+ core.appendedGen = core.walGen = core.memGen = core.ackGen = gen
176
+ core.trimCheckpoints() // 回放会重新加回历史 checkpoint 记录 → 恢复后同样裁剪
177
+ for (const r of replayed.slice(-OPID_WINDOW)) {
178
+ if (r.meta.opId) core.rememberOpId(r.meta.opId, { gen: r.gen })
179
+ }
180
+ if (!segs.length) {
181
+ // 确保首段存在(空段无害;真正写入偏移由 walOffset 管理)
182
+ await core.root.getFileHandle('wal.' + core.walStartGen, { create: true })
183
+ }
184
+ }
185
+
186
+ export async function applyRecord(core: FsCore, r: WalRecord): Promise<void> {
187
+ const m = r.meta
188
+ switch (r.opcode) {
189
+ case OP.WRITE: {
190
+ const content = m.payload?.inline !== undefined ? m.payload.inline : await core.readBlob(m.payload!.h!)
191
+ core.mirror.set(m.path!, { content, rev: r.gen })
192
+ break
193
+ }
194
+ case OP.RM: core.mirror.delete(m.path!); break
195
+ case OP.MV: {
196
+ const e = core.mirror.get(m.from!)
197
+ if (e) { core.mirror.delete(m.from!); core.mirror.set(m.to!, { content: e.content, rev: r.gen }) }
198
+ break
199
+ }
200
+ case OP.MKDIR: break // 目录隐式;记录仅为审计
201
+ case OP.CHECKPOINT: core.checkpoints.set(m.cpId!, { h: m.h!, gen: r.gen }); break
202
+ case OP.RESTORE: {
203
+ const map = JSON.parse(await core.readBlob(m.h!)) as Record<string, string>
204
+ const next = new Map<string, MirrorEntry>()
205
+ for (const [path, h] of Object.entries(map)) next.set(path, { content: await core.readBlob(h), rev: r.gen })
206
+ core.mirror = next
207
+ break
208
+ }
209
+ }
210
+ }
211
+
212
+ // ── blob 存取 ──
213
+ export async function ensureBlob(core: FsCore, content: string): Promise<string> {
214
+ const bytes = enc.encode(content)
215
+ const h = await sha256hex(bytes)
216
+ const d2 = await (await core.root.getDirectoryHandle('blobs', { create: true })).getDirectoryHandle(h.slice(0, 2), { create: true })
217
+ try { await d2.getFileHandle(h); return h } catch { /* 不存在则写入 */ }
218
+ const fh = await d2.getFileHandle(h, { create: true })
219
+ const sh = await fh.createSyncAccessHandle()
220
+ sh.write(bytes); sh.flush(); sh.close()
221
+ return h
222
+ }
223
+ export async function readBlob(core: FsCore, h: string): Promise<string> {
224
+ const f = await (await (await (await core.root.getDirectoryHandle('blobs')).getDirectoryHandle(h.slice(0, 2))).getFileHandle(h)).getFile()
225
+ return f.text()
226
+ }
227
+
228
+ // ── 读类 ──
229
+ export function opRead(core: FsCore, { path }: { path: string }): { content: string; rev: number; gen: number } {
230
+ const norm = normalizePath(path)
231
+ const e = norm && core.mirror.get(norm)
232
+ if (!e) throw rpcErr('not-found', String(path))
233
+ return { content: e.content, rev: e.rev, gen: core.memGen } // P0:镜像在内存,64KB 路由约束成为空转(见 §2.1)
234
+ }
235
+ export function opLs(core: FsCore): { paths: string[]; gen: number } { return { paths: [...core.mirror.keys()].sort(), gen: core.memGen } }
236
+ export function opStatus(core: FsCore): {
237
+ mode: string; appendedGen: number; walGen: number; memGen: number; ackGen: number; compactGen: number; epoch: number; walStartGen: number
238
+ checkpoints: string[]; turn: { turnId: string; cpId: string; expiresAt: number; ops: number } | null
239
+ } {
240
+ const { mode, appendedGen, walGen, memGen, ackGen, compactGen, epoch, walStartGen } = core
241
+ return {
242
+ mode, appendedGen, walGen, memGen, ackGen, compactGen, epoch, walStartGen,
243
+ checkpoints: [...core.checkpoints.keys()],
244
+ turn: core.turn ? { turnId: core.turn.turnId, cpId: core.turn.cpId, expiresAt: core.turn.expiresAt, ops: core.turn.ops } : null,
245
+ }
246
+ }
247
+
248
+ // ── query 同步 / 事件 ──
249
+ export function pushDiff(core: FsCore, diff: Record<string, MirrorEntry | null>, gen: number): void {
250
+ if (!core.queryPort) return
251
+ const wire: Record<string, { content: string; rev: number } | null> = {}
252
+ for (const [p, ent] of Object.entries(diff)) wire[p] = ent ? { content: ent.content, rev: ent.rev } : null
253
+ core.queryPort.postMessage({ gen, diff: wire })
254
+ }
255
+ export function pushFullToQuery(core: FsCore): void {
256
+ if (!core.queryPort) return
257
+ const files: Record<string, { content: string; rev: number }> = {}
258
+ for (const [p, ent] of core.mirror) files[p] = { content: ent.content, rev: ent.rev }
259
+ core.queryPort.postMessage({ gen: core.memGen, full: true, files })
260
+ }
261
+ export function event(core: FsCore, e: Record<string, unknown>): void { if (core.clientPort) core.clientPort.postMessage(e) }
262
+ export function welcome(core: FsCore): void {
263
+ core.event({ type: 'WELCOME', epoch: core.epoch, memGen: core.memGen, readonly: core.mode !== 'writer', mode: core.mode })
264
+ }
265
+
266
+ // ── 协作交接(禁 steal)──
267
+ export async function onBroadcast(core: FsCore, msg: { type?: string }): Promise<void> {
268
+ if (msg.type === 'handover-request' && core.mode === 'writer') {
269
+ core.mode = 'draining'
270
+ await core.enqueue(async () => {
271
+ core.flushWindow()
272
+ core.walHandle!.close(); core.walHandle = null
273
+ core.sbHandle!.close(); core.sbHandle = null
274
+ core.mode = 'readonly'
275
+ if (core.releaseLock) { core.releaseLock(); core.releaseLock = null }
276
+ core.bc.postMessage({ type: 'handover-done', gen: core.memGen })
277
+ core.event({ evt: 'writer-lost', gen: core.memGen })
278
+ })
279
+ }
280
+ }