@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/agent-tools.js +52 -0
- package/dist/client.js +234 -0
- package/dist/disk-mirror.js +97 -0
- package/dist/fs-core.worker.js +1001 -0
- package/dist/fs-query.worker.js +115 -0
- package/dist/sync/sync-engine.js +381 -0
- package/dist/sync/truth-port.js +1 -0
- package/dist/worker-lib/engine-shared.js +25 -0
- package/dist/worker-lib/paths.js +17 -0
- package/dist/worker-lib/rpc-types.js +1 -0
- package/dist/worker-lib/wal-codec.js +111 -0
- package/dist/zip.js +60 -0
- package/package.json +75 -0
- package/src/__checks__/types-smoke.ts +23 -0
- package/src/agent-tools.test.ts +151 -0
- package/src/agent-tools.ts +117 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +284 -0
- package/src/disk-mirror.test.ts +190 -0
- package/src/disk-mirror.ts +119 -0
- package/src/fs-core-recovery.ts +280 -0
- package/src/fs-core-write-ops.ts +402 -0
- package/src/fs-core.worker.ts +182 -0
- package/src/fs-query.worker.ts +152 -0
- package/src/import-smoke.test.ts +40 -0
- package/src/worker-lib/engine-shared.test.ts +30 -0
- package/src/worker-lib/engine-shared.ts +74 -0
- package/src/worker-lib/paths.test.ts +39 -0
- package/src/worker-lib/paths.ts +14 -0
- package/src/worker-lib/rpc-types.ts +67 -0
- package/src/worker-lib/wal-codec.test.ts +83 -0
- package/src/worker-lib/wal-codec.ts +130 -0
- package/src/zip.test.ts +124 -0
- package/src/zip.ts +60 -0
- package/sync/sync-engine.test.ts +695 -0
- package/sync/sync-engine.ts +459 -0
- 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
|
+
}
|