@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 +21 -0
- package/README.md +24 -0
- package/package.json +30 -0
- package/src/env.ts +30 -0
- package/src/index.ts +27 -0
- package/src/space/artifacts-fs.ts +516 -0
- package/src/space/artifacts-sync.ts +331 -0
- package/src/space/checkpoint.ts +101 -0
- package/src/space/deploy-engine.ts +363 -0
- package/src/space/durable-object.ts +1557 -0
- package/src/space/fileinfo.ts +117 -0
- package/src/space/fs-backend.ts +235 -0
- package/src/space/git-objects.ts +315 -0
- package/src/space/inspector-wrapper.ts +141 -0
- package/src/space/preview-headers.ts +38 -0
- package/src/space/workspace-port.ts +245 -0
- package/src/space/wrangler-config.ts +188 -0
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
|
+
}
|