@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectFsClient — 主线程侧 ProjectFS 封装(P0,同 origin)。
|
|
3
|
+
* 起 fs-core / fs-query 两个 worker,牵好 core→query 的 diff 端口,
|
|
4
|
+
* 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
|
|
5
|
+
*/
|
|
6
|
+
const WRITE_TIMEOUT_MS = 8000
|
|
7
|
+
|
|
8
|
+
type Mode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loosely-shaped dynamic message bag flowing over the fs-core worker's main
|
|
12
|
+
* port: both the WELCOME/PONG/event messages (`event()`/`welcome()` in
|
|
13
|
+
* fs-core.worker.ts) and the RPC reply messages (`respond()`/`fail()` there)
|
|
14
|
+
* land here, distinguished at runtime by which optional fields are present.
|
|
15
|
+
* One flat interface (rather than a narrowed union) matches how the code
|
|
16
|
+
* actually reads it — every field is optional and independently checked.
|
|
17
|
+
*/
|
|
18
|
+
export interface CoreMessage {
|
|
19
|
+
type?: string
|
|
20
|
+
evt?: string
|
|
21
|
+
error?: string
|
|
22
|
+
mode?: Mode
|
|
23
|
+
readonly?: boolean
|
|
24
|
+
gen?: number
|
|
25
|
+
id?: number
|
|
26
|
+
ok?: boolean
|
|
27
|
+
result?: unknown
|
|
28
|
+
code?: string
|
|
29
|
+
humanPaths?: string[]
|
|
30
|
+
auditGap?: boolean
|
|
31
|
+
actor?: string
|
|
32
|
+
paths?: string[]
|
|
33
|
+
count?: number
|
|
34
|
+
restore?: string
|
|
35
|
+
t?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PendingEntry {
|
|
39
|
+
resolve: (v: unknown) => void
|
|
40
|
+
reject: (e: unknown) => void
|
|
41
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface QPendingEntry {
|
|
45
|
+
resolve: (v: unknown) => void
|
|
46
|
+
reject: (e: unknown) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DiffResult {
|
|
50
|
+
turnId: string
|
|
51
|
+
changes: Array<{ gen: number; op: string; path?: string; from?: string; to?: string }>
|
|
52
|
+
cpId: string | undefined
|
|
53
|
+
auditWindow: { cap: number; sinceGen: number }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StatusResult {
|
|
57
|
+
mode: string
|
|
58
|
+
appendedGen: number
|
|
59
|
+
walGen: number
|
|
60
|
+
memGen: number
|
|
61
|
+
ackGen: number
|
|
62
|
+
compactGen: number
|
|
63
|
+
epoch: number
|
|
64
|
+
walStartGen: number
|
|
65
|
+
checkpoints: string[]
|
|
66
|
+
turn: { turnId: string; cpId: string; expiresAt: number; ops: number } | null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class ProjectFsClient {
|
|
70
|
+
projectId!: string
|
|
71
|
+
clientId!: string
|
|
72
|
+
core!: Worker
|
|
73
|
+
query!: Worker
|
|
74
|
+
pending!: Map<number, PendingEntry>
|
|
75
|
+
qPending!: Map<number, QPendingEntry>
|
|
76
|
+
changeCbs!: Set<(evt: CoreMessage) => void>
|
|
77
|
+
modeCbs!: Set<(mode: Mode) => void>
|
|
78
|
+
_mode!: Mode
|
|
79
|
+
seq!: number
|
|
80
|
+
welcome: CoreMessage | null = null
|
|
81
|
+
pingTimer!: ReturnType<typeof setInterval>
|
|
82
|
+
lastPong!: number
|
|
83
|
+
private _retried?: boolean
|
|
84
|
+
|
|
85
|
+
/** 测试用:抹掉一个项目的全部持久层(只能在无 core 运行时调用)。 */
|
|
86
|
+
static async wipe(projectId: string): Promise<void> {
|
|
87
|
+
const root = await navigator.storage.getDirectory()
|
|
88
|
+
try { await root.removeEntry(projectId, { recursive: true }) } catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static async connect({
|
|
92
|
+
projectId,
|
|
93
|
+
coreUrl = '/ide/fs/fs-core.worker.js',
|
|
94
|
+
queryUrl = '/ide/fs/fs-query.worker.js',
|
|
95
|
+
clientId = 'c-' + Math.random().toString(36).slice(2, 10),
|
|
96
|
+
}: {
|
|
97
|
+
projectId: string
|
|
98
|
+
coreUrl?: string
|
|
99
|
+
queryUrl?: string
|
|
100
|
+
clientId?: string
|
|
101
|
+
}): Promise<ProjectFsClient> {
|
|
102
|
+
const c = new ProjectFsClient()
|
|
103
|
+
c.projectId = projectId
|
|
104
|
+
c.clientId = clientId
|
|
105
|
+
c.core = new Worker(coreUrl, { type: 'module' })
|
|
106
|
+
c.query = new Worker(queryUrl, { type: 'module' })
|
|
107
|
+
c.pending = new Map() // id -> {resolve, reject, timer}
|
|
108
|
+
c.qPending = new Map()
|
|
109
|
+
c.changeCbs = new Set()
|
|
110
|
+
c.modeCbs = new Set()
|
|
111
|
+
c._mode = 'starting'
|
|
112
|
+
c.seq = 0
|
|
113
|
+
c.welcome = null
|
|
114
|
+
|
|
115
|
+
const chan = new MessageChannel()
|
|
116
|
+
const welcomed = new Promise<CoreMessage>((resolve, reject) => {
|
|
117
|
+
c.core.onmessage = (e) => c._onCoreMessage(e.data, resolve, reject)
|
|
118
|
+
c.core.onerror = (e) => reject(new Error('fs-core worker error: ' + e.message))
|
|
119
|
+
})
|
|
120
|
+
c.query.onmessage = (e) => c._onQueryMessage(e.data)
|
|
121
|
+
c.query.postMessage({ type: 'init', corePort: chan.port2 }, [chan.port2])
|
|
122
|
+
c.core.postMessage({ type: 'HELLO', projectId, clientId, capabilityRequest: { mode: 'rw' }, queryPort: chan.port1 }, [chan.port1])
|
|
123
|
+
c.welcome = await welcomed
|
|
124
|
+
c._setMode(c.welcome.mode || (c.welcome.readonly ? 'readonly' : 'writer'))
|
|
125
|
+
|
|
126
|
+
c.pingTimer = setInterval(() => { try { c.core.postMessage({ type: 'PING', t: Date.now() }) } catch {} }, 10000)
|
|
127
|
+
c.lastPong = Date.now()
|
|
128
|
+
return c
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_onCoreMessage(msg: CoreMessage, resolveWelcome: ((v: CoreMessage) => void) | null, rejectWelcome: ((e: Error) => void) | null): void {
|
|
132
|
+
if (msg.type === 'WELCOME') { this._handleWelcome(msg, resolveWelcome); return }
|
|
133
|
+
if (msg.type === 'FATAL') { this._handleFatal(msg, rejectWelcome); return }
|
|
134
|
+
if (msg.type === 'PONG') { this.lastPong = Date.now(); return }
|
|
135
|
+
if (msg.evt) { this._handleEvt(msg); return }
|
|
136
|
+
if (msg.id !== undefined) this._handleRpcReply(msg)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_handleWelcome(msg: CoreMessage, resolveWelcome: ((v: CoreMessage) => void) | null): void {
|
|
140
|
+
if (resolveWelcome) resolveWelcome(msg)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_handleFatal(msg: CoreMessage, rejectWelcome: ((e: Error) => void) | null): void {
|
|
144
|
+
this._setMode('dead')
|
|
145
|
+
const err = new Error('fs-core fatal: ' + msg.error)
|
|
146
|
+
if (rejectWelcome) rejectWelcome(err)
|
|
147
|
+
for (const [, p] of this.pending) p.reject(err)
|
|
148
|
+
this.pending.clear()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_handleEvt(msg: CoreMessage): void {
|
|
152
|
+
if (msg.evt === 'writer-granted') this._setMode('writer')
|
|
153
|
+
else if (msg.evt === 'writer-lost') this._setMode('readonly')
|
|
154
|
+
for (const cb of this.changeCbs) { try { cb(msg) } catch {} }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_handleRpcReply(msg: CoreMessage): void {
|
|
158
|
+
const p = this.pending.get(msg.id!)
|
|
159
|
+
if (!p) return
|
|
160
|
+
this.pending.delete(msg.id!)
|
|
161
|
+
if (p.timer) clearTimeout(p.timer)
|
|
162
|
+
if (msg.ok) p.resolve(msg.result)
|
|
163
|
+
else p.reject(Object.assign(new Error(msg.error), { code: msg.code, ...(msg.humanPaths !== undefined ? { humanPaths: msg.humanPaths } : {}), ...(msg.auditGap !== undefined ? { auditGap: msg.auditGap } : {}) }))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_onQueryMessage(msg: CoreMessage): void {
|
|
167
|
+
if (msg.id === undefined) return
|
|
168
|
+
const p = this.qPending.get(msg.id)
|
|
169
|
+
if (!p) return
|
|
170
|
+
this.qPending.delete(msg.id)
|
|
171
|
+
if (msg.ok) p.resolve(msg.result)
|
|
172
|
+
else p.reject(Object.assign(new Error(msg.error), { code: msg.code }))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
_rpc<T = unknown>(op: string, args: Record<string, unknown>, { opId, timeout }: { opId?: string; timeout?: number } = {}): Promise<T> {
|
|
176
|
+
const id = ++this.seq
|
|
177
|
+
return new Promise<T>((resolve, reject) => {
|
|
178
|
+
const entryResolve = resolve as (v: unknown) => void
|
|
179
|
+
const timer = timeout
|
|
180
|
+
? setTimeout(() => {
|
|
181
|
+
// 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果)
|
|
182
|
+
if (opId && !this._retried) {
|
|
183
|
+
const retryId = ++this.seq
|
|
184
|
+
this.pending.set(retryId, { resolve: entryResolve, reject, timer: setTimeout(() => { this.pending.delete(retryId); reject(new Error(op + ' timeout (after retry)')) }, timeout) })
|
|
185
|
+
this.pending.delete(id)
|
|
186
|
+
this.core.postMessage({ id: retryId, op, args, opId })
|
|
187
|
+
} else {
|
|
188
|
+
this.pending.delete(id)
|
|
189
|
+
reject(new Error(op + ' timeout'))
|
|
190
|
+
}
|
|
191
|
+
}, timeout)
|
|
192
|
+
: null
|
|
193
|
+
this.pending.set(id, { resolve: entryResolve, reject, timer })
|
|
194
|
+
this.core.postMessage({ id, op, args, opId })
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
_qrpc<T = unknown>(op: string, args: Record<string, unknown>): Promise<T> {
|
|
199
|
+
const id = ++this.seq
|
|
200
|
+
return new Promise<T>((resolve, reject) => {
|
|
201
|
+
this.qPending.set(id, { resolve: resolve as (v: unknown) => void, reject })
|
|
202
|
+
this.query.postMessage({ id, op, args })
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_writeOp<T = unknown>(op: string, args: Record<string, unknown>): Promise<T> {
|
|
207
|
+
return this._rpc<T>(op, args, { opId: crypto.randomUUID(), timeout: WRITE_TIMEOUT_MS })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── 写 API({actor:'human'|'agent', turnId, ifMatch} 透传)──
|
|
211
|
+
write(path: string, content: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('write', { path, content, ...opts }) }
|
|
212
|
+
edit(path: string, old: string, next: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('edit', { path, old, next, ...opts }) }
|
|
213
|
+
rm(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('rm', { path, ...opts }) }
|
|
214
|
+
mv(from: string, to: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('mv', { from, to, ...opts }) }
|
|
215
|
+
mkdir(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('mkdir', { path, ...opts }) }
|
|
216
|
+
checkpoint(opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('checkpoint', { ...opts }) }
|
|
217
|
+
/** opts: {baseGen?, force?} 透传给 fs-core(§4.7 restore 冲突策略);baseGen 缺省时
|
|
218
|
+
* fs-core 从 checkpoint 自身记录的 gen 推导。 */
|
|
219
|
+
restore(cpId: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('restore', { cpId, ...opts }) }
|
|
220
|
+
compact(): Promise<unknown> { return this._rpc('compact', {}, { timeout: 30000 }) }
|
|
221
|
+
|
|
222
|
+
// ── P4 turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
|
|
223
|
+
turnBegin(turnId: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('turnBegin', { turnId, ...opts }) }
|
|
224
|
+
turnEnd(turnId: string): Promise<unknown> { return this._writeOp('turnEnd', { turnId }) }
|
|
225
|
+
/** 该 turn 的改动清单(WAL actor/turnId 审计标注免费提供)。 */
|
|
226
|
+
diff(turnId?: string): Promise<DiffResult> { return this._rpc<DiffResult>('diff', { turnId }) }
|
|
227
|
+
|
|
228
|
+
/** W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):特权方法,只应
|
|
229
|
+
* 由内核(kernel.js createKernel)在 boot 早期调用一次,把只有内核持有的随机令牌交给 fs-core;
|
|
230
|
+
* 此后 actor:'agent' 的写类 op 必须在 opts 里携带匹配的 agentToken(fs-core 侧强制,见
|
|
231
|
+
* fs-core.worker.js checkTurn/armAgentToken)。无 opId/超时重试——一次性 admin 调用,非写路径
|
|
232
|
+
* 幂等本就由 fs-core 的 armAgentToken 自身保证(同令牌重放 ok,不同令牌拒绝)。 */
|
|
233
|
+
armAgentTokenGate(token: string): Promise<{ armed: boolean; idempotent?: boolean }> { return this._rpc('armAgentTokenGate', { token }) }
|
|
234
|
+
|
|
235
|
+
// ── 读 API:小读走 core(权威),查询/快照走 query(不占写路径)──
|
|
236
|
+
read(path: string): Promise<{ content: string; rev?: number; gen: number }> { return this._rpc('read', { path }) }
|
|
237
|
+
ls(): Promise<{ paths: string[]; gen: number }> { return this._rpc('ls', {}) }
|
|
238
|
+
status(): Promise<StatusResult> { return this._rpc<StatusResult>('status', {}) }
|
|
239
|
+
snapshot(opts: { gen?: number } = {}): Promise<{ files: Record<string, string>; gen: number; stale: boolean }> { return this._qrpc('snapshot', opts) }
|
|
240
|
+
grep(pattern: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('grep', { pattern, ...opts }) }
|
|
241
|
+
glob(pattern: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('glob', { pattern, ...opts }) }
|
|
242
|
+
queryRead(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('read', { path, ...opts }) }
|
|
243
|
+
|
|
244
|
+
/** 空项目时批量写入种子文件;非空则跳过。返回 {seeded, count}。 */
|
|
245
|
+
async seed(files: Record<string, string>): Promise<{ seeded: boolean; count: number }> {
|
|
246
|
+
const { paths } = await this.ls()
|
|
247
|
+
if (paths.length) return { seeded: false, count: paths.length }
|
|
248
|
+
const entries = Object.entries(files)
|
|
249
|
+
for (let i = 0; i < entries.length; i += 50) {
|
|
250
|
+
await Promise.all(entries.slice(i, i + 50).map(([p, c]) => this.write(p, c, { actor: 'human' })))
|
|
251
|
+
}
|
|
252
|
+
return { seeded: true, count: entries.length }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
onChange(cb: (evt: CoreMessage) => void): () => void {
|
|
256
|
+
this.changeCbs.add(cb)
|
|
257
|
+
return () => this.changeCbs.delete(cb)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 当前单写者状态:starting(HELLO 未回)|writer(持写者租约)|readonly(排队中或已交出)|
|
|
261
|
+
* draining(worker 侧过渡态,client 不会长时间观察到——见 fs-core.worker.js onBroadcast,
|
|
262
|
+
* 收敛为 readonly 再广播事件)|dead(FATAL,worker 已不可用)。由 WELCOME 与
|
|
263
|
+
* writer-granted/writer-lost 事件驱动,供宿主向用户呈现"另一个标签页持有写权"之类提示。 */
|
|
264
|
+
get mode(): Mode { return this._mode }
|
|
265
|
+
|
|
266
|
+
_setMode(mode: Mode): void {
|
|
267
|
+
if (this._mode === mode) return
|
|
268
|
+
this._mode = mode
|
|
269
|
+
for (const cb of this.modeCbs) { try { cb(mode) } catch {} }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 订阅 mode 变化(writer ⇄ readonly ⇄ dead);返回退订函数。 */
|
|
273
|
+
onModeChange(cb: (mode: Mode) => void): () => void {
|
|
274
|
+
this.modeCbs.add(cb)
|
|
275
|
+
return () => this.modeCbs.delete(cb)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
destroy(): void {
|
|
279
|
+
clearInterval(this.pingTimer)
|
|
280
|
+
this._setMode('dead')
|
|
281
|
+
this.core.terminate()
|
|
282
|
+
this.query.terminate()
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for `createDiskMirror`'s lifecycle bookkeeping — the actual
|
|
3
|
+
* disk I/O only runs in a real Chromium window, so these exercise the pure
|
|
4
|
+
* scheduling/guard logic (pick(handle), dispose(), overlapping-sync pending
|
|
5
|
+
* re-arm) against fake FileSystemDirectoryHandle/FileHandle doubles and a
|
|
6
|
+
* fake `fs.snapshot()` source, using vitest's fake timers to control the 2s
|
|
7
|
+
* debounce deterministically.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { createDiskMirror } from './disk-mirror.js'
|
|
11
|
+
|
|
12
|
+
/** Minimal in-memory stand-in for a FileSystemFileHandle: buffers writes
|
|
13
|
+
* until `close()`, matching the real API's write-then-close contract. */
|
|
14
|
+
class FakeFileHandle {
|
|
15
|
+
content = ''
|
|
16
|
+
private buf = ''
|
|
17
|
+
async createWritable() {
|
|
18
|
+
return {
|
|
19
|
+
write: async (data: string) => {
|
|
20
|
+
this.buf = data
|
|
21
|
+
},
|
|
22
|
+
close: async () => {
|
|
23
|
+
this.content = this.buf
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal in-memory stand-in for a FileSystemDirectoryHandle: supports the
|
|
30
|
+
* three calls disk-mirror.js makes (getDirectoryHandle/getFileHandle/removeEntry)
|
|
31
|
+
* over a nested tree, plus a `read(path)` test helper to inspect mirrored content. */
|
|
32
|
+
class FakeDirHandle {
|
|
33
|
+
name: string
|
|
34
|
+
private dirs = new Map<string, FakeDirHandle>()
|
|
35
|
+
private fileHandles = new Map<string, FakeFileHandle>()
|
|
36
|
+
constructor(name = '') {
|
|
37
|
+
this.name = name
|
|
38
|
+
}
|
|
39
|
+
async getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<FakeDirHandle> {
|
|
40
|
+
let d = this.dirs.get(name)
|
|
41
|
+
if (!d) {
|
|
42
|
+
if (!opts?.create) throw new Error(`not found: ${name}`)
|
|
43
|
+
d = new FakeDirHandle(name)
|
|
44
|
+
this.dirs.set(name, d)
|
|
45
|
+
}
|
|
46
|
+
return d
|
|
47
|
+
}
|
|
48
|
+
async getFileHandle(name: string, opts?: { create?: boolean }): Promise<FakeFileHandle> {
|
|
49
|
+
let f = this.fileHandles.get(name)
|
|
50
|
+
if (!f) {
|
|
51
|
+
if (!opts?.create) throw new Error(`not found: ${name}`)
|
|
52
|
+
f = new FakeFileHandle()
|
|
53
|
+
this.fileHandles.set(name, f)
|
|
54
|
+
}
|
|
55
|
+
return f
|
|
56
|
+
}
|
|
57
|
+
async removeEntry(name: string): Promise<void> {
|
|
58
|
+
if (this.fileHandles.delete(name)) return
|
|
59
|
+
if (this.dirs.delete(name)) return
|
|
60
|
+
throw new Error(`not found: ${name}`)
|
|
61
|
+
}
|
|
62
|
+
/** Test helper: reads mirrored content at a '/'-joined path, or undefined if absent. */
|
|
63
|
+
read(path: string): string | undefined {
|
|
64
|
+
const parts = path.split('/')
|
|
65
|
+
const dir = parts.slice(0, -1).reduce<FakeDirHandle | undefined>(
|
|
66
|
+
(d, seg) => d?.dirs.get(seg),
|
|
67
|
+
this,
|
|
68
|
+
)
|
|
69
|
+
return dir?.fileHandles.get(parts[parts.length - 1] as string)?.content
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fake `fs` source whose `snapshot()` call count and blocking can be
|
|
74
|
+
* controlled from the test, to force a real overlap between an in-flight
|
|
75
|
+
* `syncAll()` and a subsequent `schedule()`. */
|
|
76
|
+
function makeFs(initialFiles: Record<string, string>) {
|
|
77
|
+
let files = { ...initialFiles }
|
|
78
|
+
let gen = 0
|
|
79
|
+
let calls = 0
|
|
80
|
+
let blockOnCall = -1
|
|
81
|
+
let releaseBlock: (() => void) | null = null
|
|
82
|
+
const snapshot = vi.fn(async () => {
|
|
83
|
+
calls++
|
|
84
|
+
// Capture synchronously (before any `await`) so a mutation the test makes
|
|
85
|
+
// *after* this call returns control is never smeared into this round's
|
|
86
|
+
// snapshot — exactly mirroring how a real snapshot is a point-in-time read.
|
|
87
|
+
const captured = { files: { ...files }, gen: ++gen }
|
|
88
|
+
if (calls === blockOnCall) {
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
releaseBlock = resolve
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
return captured
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
fs: { snapshot },
|
|
97
|
+
setFiles(next: Record<string, string>) {
|
|
98
|
+
files = { ...next }
|
|
99
|
+
},
|
|
100
|
+
callCount: () => calls,
|
|
101
|
+
/** Makes the Nth `snapshot()` call block (after capturing) until `release()` is called. */
|
|
102
|
+
blockCall(n: number) {
|
|
103
|
+
blockOnCall = n
|
|
104
|
+
},
|
|
105
|
+
release() {
|
|
106
|
+
const r = releaseBlock
|
|
107
|
+
releaseBlock = null
|
|
108
|
+
r?.()
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('createDiskMirror', () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
vi.useFakeTimers()
|
|
116
|
+
})
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.useRealTimers()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('pick(handle) uses the injected handle directly, skipping showDirectoryPicker', async () => {
|
|
122
|
+
const { fs } = makeFs({ 'a.txt': '1' })
|
|
123
|
+
const mirror = createDiskMirror(fs)
|
|
124
|
+
const dir = new FakeDirHandle('root')
|
|
125
|
+
|
|
126
|
+
const r = await mirror.pick(dir as unknown as FileSystemDirectoryHandle)
|
|
127
|
+
|
|
128
|
+
expect(r.name).toBe('root')
|
|
129
|
+
expect(dir.read('a.txt')).toBe('1')
|
|
130
|
+
expect(mirror.active).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('dispose() cancels the pending debounce timer and short-circuits later syncAll calls', async () => {
|
|
134
|
+
const { fs, callCount } = makeFs({ 'a.txt': '1' })
|
|
135
|
+
const mirror = createDiskMirror(fs)
|
|
136
|
+
const dir = new FakeDirHandle('root')
|
|
137
|
+
await mirror.pick(dir as unknown as FileSystemDirectoryHandle)
|
|
138
|
+
const callsAfterPick = callCount()
|
|
139
|
+
|
|
140
|
+
mirror.schedule() // arms the 2s debounce timer
|
|
141
|
+
mirror.dispose()
|
|
142
|
+
expect(mirror.active).toBe(false)
|
|
143
|
+
|
|
144
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
145
|
+
// The cleared timer never fires syncAll again.
|
|
146
|
+
expect(callCount()).toBe(callsAfterPick)
|
|
147
|
+
|
|
148
|
+
// Defense in depth: even a direct syncAll() call after dispose is a no-op.
|
|
149
|
+
const r = await mirror.syncAll()
|
|
150
|
+
expect(r).toBeNull()
|
|
151
|
+
expect(callCount()).toBe(callsAfterPick)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('re-arms and mirrors a change that happened while a sync was already in flight', async () => {
|
|
155
|
+
const { fs, setFiles, callCount, blockCall, release } = makeFs({ 'a.txt': '1' })
|
|
156
|
+
const mirror = createDiskMirror(fs)
|
|
157
|
+
const dir = new FakeDirHandle('root')
|
|
158
|
+
|
|
159
|
+
await mirror.pick(dir as unknown as FileSystemDirectoryHandle) // snapshot call #1
|
|
160
|
+
expect(dir.read('a.txt')).toBe('1')
|
|
161
|
+
|
|
162
|
+
// Round 2: block after the snapshot for call #2 is captured, simulating a
|
|
163
|
+
// sync still "in flight" (syncing === true) while further changes land.
|
|
164
|
+
blockCall(2)
|
|
165
|
+
mirror.schedule()
|
|
166
|
+
await vi.advanceTimersByTimeAsync(2000) // fires the timer -> syncAll() call #2 starts and blocks
|
|
167
|
+
|
|
168
|
+
// While round 2 is in flight, a new change arrives and schedule() is called
|
|
169
|
+
// again. Assert schedule() does NOT arm a fresh timer while syncing is
|
|
170
|
+
// true (it must set `pending` instead): advancing time here, while call #2
|
|
171
|
+
// is still blocked/in-flight, must NOT produce any further snapshot() call.
|
|
172
|
+
// The old implementation unconditionally re-armed a timer here, which
|
|
173
|
+
// would fire into `syncAll()`'s own `syncing` guard and silently drop the
|
|
174
|
+
// update forever (no retry is ever scheduled after a guard-drop).
|
|
175
|
+
setFiles({ 'a.txt': '2' })
|
|
176
|
+
mirror.schedule()
|
|
177
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
178
|
+
expect(callCount()).toBe(2) // still just call #2, blocked — no drop, no premature retry
|
|
179
|
+
|
|
180
|
+
release() // let call #2 finish; it writes the *old* ('1') snapshot it had captured
|
|
181
|
+
await vi.waitFor(() => expect(dir.read('a.txt')).toBe('1'))
|
|
182
|
+
|
|
183
|
+
// The finally-block pending re-arm should have scheduled a fresh 2s timer
|
|
184
|
+
// only once call #2 actually finished.
|
|
185
|
+
await vi.advanceTimersByTimeAsync(2000) // fires -> syncAll() call #3, captures the new value
|
|
186
|
+
|
|
187
|
+
expect(dir.read('a.txt')).toBe('2')
|
|
188
|
+
expect(callCount()).toBe(3)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地磁盘镜像(P5,Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
|
|
3
|
+
* 把项目树 write-through 镜像过去:用户在访达/资源管理器里能看到自己的项目。
|
|
4
|
+
* 这是"持久化到磁盘"的字面回答(架构文档 §4 出口 1)。
|
|
5
|
+
*
|
|
6
|
+
* 策略:fs-change 防抖 2s 全量比对同步(内容不变的文件跳过 —— 内容比对在内存,
|
|
7
|
+
* 磁盘只写变更);删除按上次镜像记录清理。授权需要用户手势,无授权时静默不动。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Structural shape disk-mirror.js actually calls on a directory handle —
|
|
11
|
+
* deliberately looser than the DOM lib's `FileSystemDirectoryHandle` (which
|
|
12
|
+
* also requires `resolve`/`isSameEntry`) so any handle-like object a consumer
|
|
13
|
+
* already holds — a real FSA handle, or a project's own narrower wrapper type
|
|
14
|
+
* — can be injected via `pick(handle)` without a structural mismatch. */
|
|
15
|
+
export interface DiskMirrorDirectoryHandle {
|
|
16
|
+
name: string
|
|
17
|
+
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<DiskMirrorDirectoryHandle>
|
|
18
|
+
getFileHandle(name: string, options?: { create?: boolean }): Promise<{
|
|
19
|
+
createWritable(): Promise<{ write(data: unknown): Promise<void>; close(): Promise<void> }>
|
|
20
|
+
}>
|
|
21
|
+
removeEntry?(name: string, options?: { recursive?: boolean }): Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// showDirectoryPicker isn't in TS's shipped DOM lib yet (real Chromium-only API).
|
|
25
|
+
declare global {
|
|
26
|
+
interface Window {
|
|
27
|
+
showDirectoryPicker?(options?: { mode?: 'read' | 'readwrite' }): Promise<DiskMirrorDirectoryHandle>
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Only the one ProjectFsClient method disk-mirror actually calls. */
|
|
32
|
+
export interface DiskMirrorFs {
|
|
33
|
+
snapshot(): Promise<{ files: Record<string, string>; gen: number }>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDiskMirror(fs: DiskMirrorFs) {
|
|
37
|
+
let dir: DiskMirrorDirectoryHandle | null = null
|
|
38
|
+
let last = new Map<string, string>() // path -> content(上次已镜像的内容)
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
40
|
+
let syncing = false
|
|
41
|
+
let pending = false // syncAll 运行期间又发生了变更,结束后需要再镜像一轮
|
|
42
|
+
|
|
43
|
+
/** @param handle 已获取授权的目录句柄;传了就直接用(跳过
|
|
44
|
+
* showDirectoryPicker 弹窗),调用方自行负责获取授权(例如复用宿主已有的句柄)。 */
|
|
45
|
+
async function pick(handle?: DiskMirrorDirectoryHandle) {
|
|
46
|
+
if (handle) {
|
|
47
|
+
dir = handle
|
|
48
|
+
} else {
|
|
49
|
+
if (!window.showDirectoryPicker) throw new Error('showDirectoryPicker 不可用(需要 Chromium)')
|
|
50
|
+
dir = await window.showDirectoryPicker({ mode: 'readwrite' })
|
|
51
|
+
}
|
|
52
|
+
last = new Map()
|
|
53
|
+
const r = await syncAll()
|
|
54
|
+
return { name: dir.name, ...r }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 停用镜像:取消挂起的防抖定时器,清空目录句柄与镜像记账,使后续任何 syncAll
|
|
58
|
+
* 调用(包括仍在飞行中的定时器回调)在入口处天然短路。 */
|
|
59
|
+
function dispose() {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
dir = null
|
|
62
|
+
last = new Map()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function syncAll(): Promise<{ written: number; removed: number; gen: number } | null> {
|
|
66
|
+
if (!dir || syncing) return null
|
|
67
|
+
syncing = true
|
|
68
|
+
try {
|
|
69
|
+
const snap: { files: Record<string, string>; gen: number } = await fs.snapshot()
|
|
70
|
+
let written = 0
|
|
71
|
+
for (const [p, content] of Object.entries(snap.files)) {
|
|
72
|
+
if (last.get(p) === content) continue
|
|
73
|
+
const parts = p.split('/')
|
|
74
|
+
let d = dir
|
|
75
|
+
for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg, { create: true })
|
|
76
|
+
const fh = await d.getFileHandle(parts[parts.length - 1] as string, { create: true })
|
|
77
|
+
const w = await fh.createWritable()
|
|
78
|
+
await w.write(content)
|
|
79
|
+
await w.close()
|
|
80
|
+
last.set(p, content)
|
|
81
|
+
written++
|
|
82
|
+
}
|
|
83
|
+
let removed = 0
|
|
84
|
+
for (const p of [...last.keys()]) {
|
|
85
|
+
if (p in snap.files) continue
|
|
86
|
+
try {
|
|
87
|
+
const parts = p.split('/')
|
|
88
|
+
let d = dir
|
|
89
|
+
for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg)
|
|
90
|
+
await d.removeEntry?.(parts[parts.length - 1] as string)
|
|
91
|
+
} catch { /* 目录已不在等:忽略 */ }
|
|
92
|
+
last.delete(p)
|
|
93
|
+
removed++
|
|
94
|
+
}
|
|
95
|
+
return { written, removed, gen: snap.gen }
|
|
96
|
+
} finally {
|
|
97
|
+
syncing = false
|
|
98
|
+
if (pending && dir) {
|
|
99
|
+
pending = false
|
|
100
|
+
schedule()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function schedule() {
|
|
106
|
+
if (!dir) return
|
|
107
|
+
if (syncing) {
|
|
108
|
+
// 一轮 syncAll 正在进行中:这次变更不能被这轮同步捕获(快照已在读取途中),
|
|
109
|
+
// 也不能立刻并发再跑一轮(syncAll 自身会在入口处丢弃)。记一个 pending,
|
|
110
|
+
// 交给上面 syncAll 的 finally 在这轮结束后重新排期,保证不丢更新。
|
|
111
|
+
pending = true
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
clearTimeout(timer)
|
|
115
|
+
timer = setTimeout(() => { syncAll().catch(() => {}) }, 2000)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { pick, syncAll, schedule, dispose, get active() { return !!dir } }
|
|
119
|
+
}
|