@springbrand/space 0.1.0-alpha.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Cloudflare
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @springbrand/space
2
+
3
+ SpaceDO — a git-backed Durable Object workspace with deploy, preview and App
4
+ Facet database inspection.
5
+
6
+ ## Provenance
7
+
8
+ This package is a tracked fork of the VibeSDK `space/` package.
9
+
10
+ - Upstream: `cloudflare/vibesdk`, `space/src/space/**` + `space/test/**`
11
+ - Frozen at: `main@a318f08625db`
12
+ - Licence: MIT (see `LICENSE`)
13
+
14
+ Files under `src/space/` are the upstream implementation. Local additions are
15
+ confined to the `WorkspacePort` compatibility surface on `SpaceDO`
16
+ (`durable-object.ts`), which Agent Runtime hosts require: binary
17
+ writes, append, conditional write, quota, full file operations, R2 spill,
18
+ path-level restore and `destroySpace`.
19
+
20
+ ## Boundary
21
+
22
+ SpaceDO is infrastructure. It knows nothing about users, Chats or
23
+ authorisation — the host Worker decides which Space a request belongs to and
24
+ whether the caller may reach it.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@springbrand/space",
3
+ "version": "0.1.0-alpha.1",
4
+ "type": "module",
5
+ "files": [
6
+ "src",
7
+ "!src/**/*.test.ts"
8
+ ],
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "dependencies": {
16
+ "@cloudflare/shell": "0.4.3",
17
+ "@cloudflare/worker-bundler": "0.0.4",
18
+ "isomorphic-git": "1.38.6"
19
+ },
20
+ "devDependencies": {
21
+ "@cloudflare/vitest-pool-workers": "^0.20.1",
22
+ "@cloudflare/workers-types": "^4.20251008.0",
23
+ "typescript": "^7.0.2",
24
+ "vitest": "^4.1.10"
25
+ },
26
+ "scripts": {
27
+ "check": "tsc --noEmit",
28
+ "test": "vitest run"
29
+ }
30
+ }
package/src/env.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Bindings a SpaceDO needs from its host Worker.
3
+ *
4
+ * The host Worker owns identity, application ownership and routing; this
5
+ * package owns files, Git, deploy and preview. Everything below
6
+ * is therefore infrastructure, never business context.
7
+ */
8
+ export interface SpaceEnv {
9
+ /** The namespace the host addresses Workspace and UserSpace instances through. */
10
+ SPACE_DO: DurableObjectNamespace;
11
+
12
+ /** Worker Loader used to run a deployed app's Dynamic Worker. */
13
+ LOADER: WorkerLoader;
14
+
15
+ /** Large-file spill and Space blob storage. */
16
+ WORKSPACE_R2?: R2Bucket;
17
+
18
+ /**
19
+ * Cloudflare Artifacts binding. Optional: Artifacts is still a private beta,
20
+ * so production pins new Spaces to the SQL backend until an account is
21
+ * eligible and `ENABLE_ARTIFACTS` is turned on.
22
+ */
23
+ ARTIFACTS?: Artifacts;
24
+ ENABLE_ARTIFACTS?: string;
25
+
26
+ ENVIRONMENT?: string;
27
+ }
28
+
29
+ /** Historic alias kept so upstream Space files read unchanged. */
30
+ export type Env = SpaceEnv;
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // ── Durable Object classes ────────────────────────────────────────
2
+ export { SpaceDO } from "./space/durable-object";
3
+
4
+ // ── RPC surface ───────────────────────────────────────────────────
5
+ export type {
6
+ AppDatabaseColumn,
7
+ AppDatabaseReadResult,
8
+ AppDatabaseTable,
9
+ AppTableQueryOpts,
10
+ SpaceCommit,
11
+ SpaceConditionalWriteResult,
12
+ SpaceAppPort,
13
+ SpaceControlPort,
14
+ SpaceFileInfo,
15
+ SpaceFileVersion,
16
+ SpaceStub,
17
+ SpaceUsage,
18
+ SpaceWorkspacePort,
19
+ } from "./space/durable-object";
20
+ export { SPACE_RESERVED_PREFIXES } from "./space/durable-object";
21
+ export type { BranchDeploymentBundle } from "./space/deploy-engine";
22
+
23
+ // ── Preview hardening ─────────────────────────────────────────────
24
+ export { stripPreviewSecurityHeaders, STRIPPED_PREVIEW_HEADERS } from "./space/preview-headers";
25
+
26
+ // ── Environment bindings type ─────────────────────────────────────
27
+ export type { Env, SpaceEnv } from "./env";
@@ -0,0 +1,516 @@
1
+ /**
2
+ * ArtifactsFileSystem — an overlay `FileSystem` backed by a Cloudflare Artifacts
3
+ * branch.
4
+ *
5
+ * It composes two layers:
6
+ * - overlay (writable): an in-memory `InMemoryFs`. Holds all writes/edits,
7
+ * the `.git` dir, hydrated file contents, and its own bookkeeping under
8
+ * `/.afs`. Not durable across DO eviction — Artifacts is the source of truth.
9
+ * - base (read-only): an immutable snapshot of the imported Artifacts branch
10
+ * (`path -> { oid, mode }`) whose blobs live in the overlay's `.git` object
11
+ * store after one packfile fetch.
12
+ *
13
+ * Hydration is hybrid: the file index is available as soon as the snapshot is
14
+ * walked (`ready()`), point reads hydrate their own blob on demand, and any
15
+ * directory listing (or `whenFullyMaterialized()`) copies the whole base into
16
+ * the overlay. Once fully materialized, the FS behaves as a plain overlay.
17
+ *
18
+ * When the base snapshot is empty (an Artifacts repo with no commits yet),
19
+ * every operation passes straight through to the overlay — an exact in-memory
20
+ * overlay equivalent — until the first commit establishes a base.
21
+ */
22
+ import type { FileSystem, FsStat, EntryType } from "@cloudflare/shell"
23
+ import type { BaseEntry, BaseSnapshot, BaseSnapshotSource } from "./git-objects"
24
+ import { walkTreeFiles } from "./git-objects"
25
+ import type { CheckpointSource } from "./checkpoint"
26
+
27
+ type MkdirOptions = { recursive?: boolean }
28
+ type RmOptions = { recursive?: boolean; force?: boolean }
29
+ type CpOptions = { recursive?: boolean }
30
+ type Dirent = { name: string; type: EntryType }
31
+
32
+ /** Path prefixes that are always overlay-only and never resolved from the base. */
33
+ const RESERVED_PREFIXES = ["/.git", "/.afs"]
34
+ const STATE_PATH = "/.afs/state.json"
35
+ const EPOCH = new Date(0)
36
+
37
+ interface PersistedState {
38
+ version: 1
39
+ headOid: string
40
+ whiteouts: string[]
41
+ }
42
+
43
+ function isReserved(path: string): boolean {
44
+ return RESERVED_PREFIXES.some((p) => path === p || path.startsWith(p + "/"))
45
+ }
46
+
47
+ function enoent(path: string): Error {
48
+ const err = new Error(`ENOENT: no such file or directory, '${path}'`) as Error & { code: string }
49
+ err.code = "ENOENT"
50
+ return err
51
+ }
52
+
53
+ export interface ArtifactsFileSystemOptions {
54
+ /**
55
+ * Base layer. Always required: the SpaceDO is Artifacts-backed, so the FS is
56
+ * never a plain overlay. `loadSnapshot()` may still yield `null` for a repo
57
+ * with no commits yet (a legitimately empty base), in which case the FS acts
58
+ * as an overlay over nothing until the first commit.
59
+ */
60
+ source: BaseSnapshotSource
61
+ /** Branch this FS mirrors (recorded for diagnostics). */
62
+ branch?: string
63
+ /**
64
+ * Durable checkpoint of overlay writes (see checkpoint.ts), replayed over
65
+ * the base during `init()` so uncommitted work survives a DO reset.
66
+ */
67
+ checkpoint?: CheckpointSource
68
+ /**
69
+ * Called with the absolute path of every mutating operation (writes and
70
+ * deletions), so the owner can schedule a checkpoint flush. Must never
71
+ * throw — it is invoked fire-and-forget.
72
+ */
73
+ onChange?: (path: string) => void
74
+ }
75
+
76
+ export class ArtifactsFileSystem implements FileSystem {
77
+ private readonly overlay: FileSystem
78
+ private readonly source: BaseSnapshotSource
79
+ private readonly checkpoint?: CheckpointSource
80
+ private readonly onChange?: (path: string) => void
81
+
82
+ private base: Map<string, BaseEntry> | null = null
83
+ private headOid: string | null = null
84
+ private readonly whiteouts = new Set<string>()
85
+
86
+ private readyPromise: Promise<void> | null = null
87
+ private materializePromise: Promise<void> | null = null
88
+
89
+ constructor(overlay: FileSystem, options: ArtifactsFileSystemOptions) {
90
+ this.overlay = overlay
91
+ this.source = options.source
92
+ this.checkpoint = options.checkpoint
93
+ this.onChange = options.onChange
94
+ }
95
+
96
+ /** Notify the checkpoint owner of a mutation; bookkeeping must never break FS ops. */
97
+ private noteChange(path: string): void {
98
+ if (!this.onChange || isReserved(path)) return
99
+ try {
100
+ this.onChange(path)
101
+ } catch {
102
+ // best-effort
103
+ }
104
+ }
105
+
106
+ /** Replay the durable checkpoint over the freshly loaded base (idempotent). */
107
+ private async applyCheckpoint(): Promise<void> {
108
+ if (!this.checkpoint) return
109
+ try {
110
+ const { files, tombstones } = this.checkpoint.load()
111
+ // Tombstones first (whiteouts hide deleted base files), then files — a
112
+ // file row under a tombstoned dir was written after the deletion and
113
+ // must end up visible in the overlay.
114
+ for (const path of tombstones) this.whiteouts.add(path)
115
+ for (const [path, bytes] of files) {
116
+ await this.overlay.writeFileBytes(path, bytes)
117
+ }
118
+ } catch {
119
+ // Checkpoint restore is best-effort; the base alone is still consistent.
120
+ }
121
+ }
122
+
123
+ // ── Readiness / hydration ───────────────────────────────────────
124
+
125
+ /** Ensure the base snapshot (file index) is loaded. Fast; no blob copies. */
126
+ ready(): Promise<void> {
127
+ if (!this.readyPromise) this.readyPromise = this.init()
128
+ return this.readyPromise
129
+ }
130
+
131
+ /** Ensure every base file is materialized into the overlay. */
132
+ async whenFullyMaterialized(): Promise<void> {
133
+ // Await readiness first so a previously failed base load is retried here
134
+ // (a successful retry nulls materializePromise — see init — so the block
135
+ // below re-materializes with the freshly loaded base).
136
+ await this.ready()
137
+ if (!this.materializePromise) this.materializePromise = this.materializeAll()
138
+ return this.materializePromise
139
+ }
140
+
141
+ /**
142
+ * Ensure a single path's content is present in the overlay (hydrating it from
143
+ * the base on demand). Lets callers that read through the underlying Workspace
144
+ * still observe base files. No-op for reserved/overlay-only paths.
145
+ */
146
+ async hydrate(path: string): Promise<void> {
147
+ if (isReserved(path)) return
148
+ await this.ready()
149
+ await this.materialize(path)
150
+ }
151
+
152
+ private async init(): Promise<void> {
153
+ // Prefer a cheap local rebuild from persisted state (no network): the
154
+ // fetched objects live durably in the overlay `.git`, so we can re-walk.
155
+ const state = await this.readState()
156
+ if (state) {
157
+ try {
158
+ this.base = await walkTreeFiles(this.overlay, state.headOid)
159
+ this.headOid = state.headOid
160
+ for (const w of state.whiteouts) this.whiteouts.add(w)
161
+ // Replay uncommitted work over the rebuilt base, exactly as the
162
+ // network-load branches below do — the fast-path must not be the one
163
+ // place that drops the checkpoint.
164
+ await this.applyCheckpoint()
165
+ return
166
+ } catch {
167
+ // Objects missing/corrupt — fall through to a fresh load.
168
+ }
169
+ }
170
+
171
+ let snapshot: BaseSnapshot | null
172
+ try {
173
+ snapshot = await this.source.loadSnapshot()
174
+ } catch {
175
+ // Transient base-load failure (e.g. cold-start fetch timeout). Do NOT
176
+ // cache it: reset readyPromise so the next ready() retries. Until a
177
+ // retry succeeds the FS degrades to overlay-only — previously pushed
178
+ // files are hidden, but never permanently (and never overwritten by a
179
+ // partial force-push: see the push ancestry guard in artifacts-sync).
180
+ // The checkpoint still applies: uncommitted work is the most valuable
181
+ // data and must stay visible even while the base is unreachable.
182
+ this.readyPromise = null
183
+ await this.applyCheckpoint()
184
+ return
185
+ }
186
+ if (!snapshot) {
187
+ // Repo has no commits yet — a legitimately empty base.
188
+ this.base = null
189
+ await this.applyCheckpoint()
190
+ return
191
+ }
192
+ this.base = snapshot.files
193
+ this.headOid = snapshot.head
194
+ // A base arriving after a degraded (base-less) period must invalidate any
195
+ // materialization that already ran with no base, so the next listing
196
+ // re-materializes the full tree.
197
+ this.materializePromise = null
198
+ await this.writeState()
199
+ await this.applyCheckpoint()
200
+ }
201
+
202
+ private async materializeAll(): Promise<void> {
203
+ await this.ready()
204
+ if (!this.base) return
205
+ for (const [path, entry] of this.base) {
206
+ if (this.isCovered(path)) continue
207
+ if (await this.overlay.exists(path)) continue
208
+ const bytes = await this.source.readBlob(entry.oid)
209
+ await this.overlay.writeFileBytes(path, bytes)
210
+ }
211
+ }
212
+
213
+ private async materializeUnder(prefix: string): Promise<void> {
214
+ await this.ready()
215
+ if (!this.base) return
216
+ const dirPrefix = prefix.endsWith("/") ? prefix : prefix + "/"
217
+ for (const [path, entry] of this.base) {
218
+ if (path !== prefix && !path.startsWith(dirPrefix)) continue
219
+ if (this.isCovered(path)) continue
220
+ if (await this.overlay.exists(path)) continue
221
+ const bytes = await this.source.readBlob(entry.oid)
222
+ await this.overlay.writeFileBytes(path, bytes)
223
+ }
224
+ }
225
+
226
+ /** Copy a single base file into the overlay if needed. Returns true if the path is now a file. */
227
+ private async materialize(path: string): Promise<boolean> {
228
+ if (await this.overlay.exists(path)) return true
229
+ if (isReserved(path) || !this.base) return false
230
+ if (this.isCovered(path)) return false
231
+ const entry = this.base.get(path)
232
+ if (!entry) return false
233
+ const bytes = await this.source.readBlob(entry.oid)
234
+ await this.overlay.writeFileBytes(path, bytes)
235
+ entry.size = bytes.length
236
+ return true
237
+ }
238
+
239
+ // ── Base helpers ────────────────────────────────────────────────
240
+
241
+ /**
242
+ * True when `path` is whiteouted — directly or via a whiteouted ancestor.
243
+ * Ancestor coverage matters for checkpoint tombstones: a directory deletion
244
+ * is recorded as a single tombstone row for the dir, and after a cold start
245
+ * it must hide every base file beneath it (rm's per-key whiteouts only
246
+ * exist within the lifetime that performed the delete).
247
+ */
248
+ private isCovered(path: string): boolean {
249
+ if (this.whiteouts.has(path)) return true
250
+ let i = path.indexOf("/", 1)
251
+ while (i > 0) {
252
+ if (this.whiteouts.has(path.slice(0, i))) return true
253
+ i = path.indexOf("/", i + 1)
254
+ }
255
+ return false
256
+ }
257
+
258
+ private baseHasFile(path: string): boolean {
259
+ return !!this.base && this.base.has(path) && !this.isCovered(path)
260
+ }
261
+
262
+ private baseHasDir(path: string): boolean {
263
+ if (!this.base) return false
264
+ const prefix = path.endsWith("/") ? path : path + "/"
265
+ for (const key of this.base.keys()) {
266
+ if (key.startsWith(prefix) && !this.isCovered(key)) return true
267
+ }
268
+ return false
269
+ }
270
+
271
+ private async computeSize(entry: BaseEntry): Promise<number> {
272
+ if (entry.size !== undefined) return entry.size
273
+ const bytes = await this.source.readBlob(entry.oid)
274
+ entry.size = bytes.length
275
+ return entry.size
276
+ }
277
+
278
+ private async clearWhiteout(path: string): Promise<void> {
279
+ if (this.whiteouts.delete(path)) await this.writeState()
280
+ }
281
+
282
+ private async addWhiteouts(paths: string[]): Promise<void> {
283
+ let changed = false
284
+ for (const p of paths) if (!this.whiteouts.has(p)) (this.whiteouts.add(p), (changed = true))
285
+ if (changed) await this.writeState()
286
+ }
287
+
288
+ private async readState(): Promise<PersistedState | null> {
289
+ try {
290
+ const raw = await this.overlay.readFile(STATE_PATH)
291
+ const parsed = JSON.parse(raw) as PersistedState
292
+ if (parsed.version !== 1 || typeof parsed.headOid !== "string") return null
293
+ return parsed
294
+ } catch {
295
+ return null
296
+ }
297
+ }
298
+
299
+ private async writeState(): Promise<void> {
300
+ if (!this.headOid) return
301
+ const state: PersistedState = {
302
+ version: 1,
303
+ headOid: this.headOid,
304
+ whiteouts: [...this.whiteouts],
305
+ }
306
+ try {
307
+ await this.overlay.mkdir("/.afs", { recursive: true })
308
+ } catch {
309
+ // already exists
310
+ }
311
+ await this.overlay.writeFile(STATE_PATH, JSON.stringify(state))
312
+ }
313
+
314
+ // ── FileSystem: reads ───────────────────────────────────────────
315
+
316
+ async readFile(path: string): Promise<string> {
317
+ if (isReserved(path)) return this.overlay.readFile(path)
318
+ await this.ready()
319
+ if (this.base && !(await this.overlay.exists(path)) && this.baseHasFile(path)) {
320
+ await this.materialize(path)
321
+ }
322
+ return this.overlay.readFile(path)
323
+ }
324
+
325
+ async readFileBytes(path: string): Promise<Uint8Array> {
326
+ if (isReserved(path)) return this.overlay.readFileBytes(path)
327
+ await this.ready()
328
+ if (this.base && !(await this.overlay.exists(path)) && this.baseHasFile(path)) {
329
+ await this.materialize(path)
330
+ }
331
+ return this.overlay.readFileBytes(path)
332
+ }
333
+
334
+ async exists(path: string): Promise<boolean> {
335
+ if (isReserved(path)) return this.overlay.exists(path)
336
+ await this.ready()
337
+ if (await this.overlay.exists(path)) return true
338
+ return this.baseHasFile(path) || this.baseHasDir(path)
339
+ }
340
+
341
+ async stat(path: string): Promise<FsStat> {
342
+ return this.statImpl(path, false)
343
+ }
344
+
345
+ async lstat(path: string): Promise<FsStat> {
346
+ return this.statImpl(path, true)
347
+ }
348
+
349
+ private async statImpl(path: string, l: boolean): Promise<FsStat> {
350
+ if (isReserved(path)) return l ? this.overlay.lstat(path) : this.overlay.stat(path)
351
+ await this.ready()
352
+ if (await this.overlay.exists(path)) {
353
+ return l ? this.overlay.lstat(path) : this.overlay.stat(path)
354
+ }
355
+ if (this.base) {
356
+ const entry = this.base.get(path)
357
+ if (entry && !this.isCovered(path)) {
358
+ return { type: "file", size: await this.computeSize(entry), mtime: EPOCH, mode: entry.mode }
359
+ }
360
+ if (this.baseHasDir(path)) {
361
+ return { type: "directory", size: 0, mtime: EPOCH }
362
+ }
363
+ }
364
+ throw enoent(path)
365
+ }
366
+
367
+ // ── FileSystem: listing (delegates after full materialization) ──
368
+
369
+ async glob(pattern: string): Promise<string[]> {
370
+ await this.whenFullyMaterialized()
371
+ const paths = await this.overlay.glob(pattern)
372
+ return paths.filter((p) => !isReserved(p) || p.startsWith("/.git"))
373
+ }
374
+
375
+ async readdir(path: string): Promise<string[]> {
376
+ if (isReserved(path)) return this.overlay.readdir(path)
377
+ await this.whenFullyMaterialized()
378
+ const names = await this.overlay.readdir(path)
379
+ return path === "/" ? names.filter((n) => n !== ".afs") : names
380
+ }
381
+
382
+ async readdirWithFileTypes(path: string): Promise<Dirent[]> {
383
+ if (isReserved(path)) return this.overlay.readdirWithFileTypes(path)
384
+ await this.whenFullyMaterialized()
385
+ const entries = await this.overlay.readdirWithFileTypes(path)
386
+ return path === "/" ? entries.filter((e) => e.name !== ".afs") : entries
387
+ }
388
+
389
+ // ── FileSystem: writes ──────────────────────────────────────────
390
+
391
+ async writeFile(path: string, content: string): Promise<void> {
392
+ await this.overlay.writeFile(path, content)
393
+ if (!isReserved(path)) await this.clearWhiteout(path)
394
+ this.noteChange(path)
395
+ }
396
+
397
+ async writeFileBytes(path: string, content: Uint8Array): Promise<void> {
398
+ await this.overlay.writeFileBytes(path, content)
399
+ if (!isReserved(path)) await this.clearWhiteout(path)
400
+ this.noteChange(path)
401
+ }
402
+
403
+ async appendFile(path: string, content: string | Uint8Array): Promise<void> {
404
+ if (!isReserved(path)) {
405
+ await this.ready()
406
+ await this.materialize(path)
407
+ }
408
+ await this.overlay.appendFile(path, content)
409
+ if (!isReserved(path)) await this.clearWhiteout(path)
410
+ this.noteChange(path)
411
+ }
412
+
413
+ async mkdir(path: string, options?: MkdirOptions): Promise<void> {
414
+ await this.overlay.mkdir(path, options)
415
+ }
416
+
417
+ async rm(path: string, options?: RmOptions): Promise<void> {
418
+ if (isReserved(path)) return this.overlay.rm(path, options)
419
+ await this.ready()
420
+ const inOverlay = await this.overlay.exists(path)
421
+ const covered = !!this.base && (this.baseHasFile(path) || this.baseHasDir(path))
422
+
423
+ if (inOverlay) {
424
+ await this.overlay.rm(path, options)
425
+ } else if (!covered && !options?.force) {
426
+ // Neither overlay nor base has it — let the overlay raise ENOENT.
427
+ await this.overlay.rm(path, options)
428
+ }
429
+
430
+ if (covered && this.base) {
431
+ const whiteout: string[] = []
432
+ if (this.baseHasFile(path)) whiteout.push(path)
433
+ const dirPrefix = path.endsWith("/") ? path : path + "/"
434
+ for (const key of this.base.keys()) {
435
+ if (key.startsWith(dirPrefix) && !this.isCovered(key)) whiteout.push(key)
436
+ }
437
+ await this.addWhiteouts(whiteout)
438
+ }
439
+ // One mark covers recursive deletes: the checkpoint tombstone drops rows
440
+ // beneath the path, and whiteout ancestor coverage hides base children.
441
+ this.noteChange(path)
442
+ }
443
+
444
+ async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
445
+ if (!isReserved(src)) {
446
+ await this.ready()
447
+ if (this.baseHasDir(src)) await this.materializeUnder(src)
448
+ else await this.materialize(src)
449
+ }
450
+ await this.overlay.cp(src, dest, options)
451
+ if (!isReserved(dest)) await this.clearWhiteout(dest)
452
+ this.noteChange(dest)
453
+ await this.noteSubtree(dest)
454
+ }
455
+
456
+ async mv(src: string, dest: string): Promise<void> {
457
+ if (!isReserved(src)) {
458
+ await this.ready()
459
+ if (this.baseHasDir(src)) await this.materializeUnder(src)
460
+ else await this.materialize(src)
461
+ }
462
+ await this.overlay.mv(src, dest)
463
+ if (!isReserved(src) && this.base) {
464
+ const whiteout: string[] = []
465
+ if (this.baseHasFile(src)) whiteout.push(src)
466
+ const dirPrefix = src.endsWith("/") ? src : src + "/"
467
+ for (const key of this.base.keys()) {
468
+ if (key.startsWith(dirPrefix) && !this.isCovered(key)) whiteout.push(key)
469
+ }
470
+ await this.addWhiteouts(whiteout)
471
+ }
472
+ if (!isReserved(dest)) await this.clearWhiteout(dest)
473
+ this.noteChange(src)
474
+ this.noteChange(dest)
475
+ await this.noteSubtree(dest)
476
+ }
477
+
478
+ async symlink(target: string, linkPath: string): Promise<void> {
479
+ await this.overlay.symlink(target, linkPath)
480
+ if (!isReserved(linkPath)) await this.clearWhiteout(linkPath)
481
+ this.noteChange(linkPath)
482
+ }
483
+
484
+ /** Mark every file under a copied/moved directory (flush reads their bytes). */
485
+ private async noteSubtree(path: string): Promise<void> {
486
+ if (!this.onChange || isReserved(path)) return
487
+ try {
488
+ const st = await this.overlay.stat(path)
489
+ if (st.type !== "directory") return
490
+ const children = await this.overlay.glob(path === "/" ? "**/*" : `${path}/**/*`)
491
+ for (const child of children) this.noteChange(child)
492
+ } catch {
493
+ // best-effort
494
+ }
495
+ }
496
+
497
+ async readlink(path: string): Promise<string> {
498
+ if (!isReserved(path)) {
499
+ await this.ready()
500
+ await this.materialize(path)
501
+ }
502
+ return this.overlay.readlink(path)
503
+ }
504
+
505
+ async realpath(path: string): Promise<string> {
506
+ if (!isReserved(path)) {
507
+ await this.ready()
508
+ await this.materialize(path)
509
+ }
510
+ return this.overlay.realpath(path)
511
+ }
512
+
513
+ resolvePath(base: string, path: string): string {
514
+ return this.overlay.resolvePath(base, path)
515
+ }
516
+ }