@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
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Artifacts sync for the SpaceDO.
|
|
3
|
+
*
|
|
4
|
+
* Artifacts is a git-compatible, versioned remote (see
|
|
5
|
+
* https://developers.cloudflare.com/artifacts/). Each app gets its own repo,
|
|
6
|
+
* and the SpaceDO mirrors every commit/deploy there via `git push` so Artifacts
|
|
7
|
+
* is the durable source of truth — the SpaceDO's local isomorphic-git (backed by
|
|
8
|
+
* an in-memory overlay FS) is the live working tree used to build/serve previews
|
|
9
|
+
* and is rehydrated from Artifacts on cold start.
|
|
10
|
+
*
|
|
11
|
+
* All operations here are best-effort: Artifacts is a beta product and may be
|
|
12
|
+
* absent in local dev (no binding). Failures are logged and reported via return
|
|
13
|
+
* values; they must never break a commit or deploy.
|
|
14
|
+
*/
|
|
15
|
+
import type { Git } from "@cloudflare/shell/git"
|
|
16
|
+
|
|
17
|
+
const REMOTE_NAME = "artifacts"
|
|
18
|
+
/** Refresh the write token this many ms before it actually expires. */
|
|
19
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000
|
|
20
|
+
/** Requested token lifetime (seconds). Artifacts allows 60s..1y. */
|
|
21
|
+
const TOKEN_TTL_SECONDS = 3600
|
|
22
|
+
/** Max attempts for a single push before giving up (best-effort). */
|
|
23
|
+
const PUSH_MAX_ATTEMPTS = 4
|
|
24
|
+
/** Base backoff between push retries; grows linearly per attempt. */
|
|
25
|
+
const PUSH_RETRY_BASE_MS = 100
|
|
26
|
+
|
|
27
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Detect a rejected push that a retry can plausibly resolve.
|
|
31
|
+
*
|
|
32
|
+
* The push protocol sends the ref's *current* server value (`oldoid`, read
|
|
33
|
+
* from a fresh ref advertisement) as the expected old value. If another writer
|
|
34
|
+
* updates the ref between that advertisement and the receive-pack, the server
|
|
35
|
+
* rejects the update as "stale info"/"stale ref" — even under `force`, because
|
|
36
|
+
* the mismatch is detected server-side. Re-running the push re-reads a fresh
|
|
37
|
+
* `oldoid`, so a bounded retry converges. isomorphic-git surfaces this as a
|
|
38
|
+
* `GitPushError`; treat those (and explicit stale wording) as retryable.
|
|
39
|
+
*/
|
|
40
|
+
function isRetryablePushError(err: unknown): boolean {
|
|
41
|
+
const e = err as { code?: string; message?: string; data?: unknown } | null
|
|
42
|
+
if (!e) return false
|
|
43
|
+
if (e.code === "GitPushError") return true
|
|
44
|
+
const text = String(e.message ?? err)
|
|
45
|
+
return /stale|not-fast-forward|rejected|fetch first|failed to update ref/i.test(text)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Artifacts repo names allow alphanumerics, dots, hyphens and underscores.
|
|
50
|
+
* SpaceDO instance names are already conservative, but sanitize defensively so
|
|
51
|
+
* an unexpected name never fails `create()` with INVALID_REPO_NAME.
|
|
52
|
+
*/
|
|
53
|
+
function sanitizeRepoName(name: string): string {
|
|
54
|
+
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+/, "")
|
|
55
|
+
return cleaned.length > 0 ? cleaned : "space"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ArtifactsSyncLogger {
|
|
59
|
+
warn(message: string, ...args: unknown[]): void
|
|
60
|
+
info?(message: string, ...args: unknown[]): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Durable persistence for the repo's git remote URL. The URL is only ever
|
|
65
|
+
* surfaced as a by-value result of `create()`/`import()`; a `get()` handle
|
|
66
|
+
* exposes methods only (its data properties are unreadable across the local-dev
|
|
67
|
+
* remote-binding proxy). Cloudflare's guidance is to save the value — so the
|
|
68
|
+
* SpaceDO backs this with durable DO storage, which survives eviction even
|
|
69
|
+
* though the in-memory working tree does not.
|
|
70
|
+
*/
|
|
71
|
+
export interface ArtifactsRemoteStore {
|
|
72
|
+
read(): Promise<string | null>
|
|
73
|
+
write(remoteUrl: string): Promise<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Dispose an RPC stub/result if it is disposable; never throws. */
|
|
77
|
+
function disposeQuietly(value: unknown): void {
|
|
78
|
+
// `Symbol.dispose` is not in the ES2022 lib we target, so look it up at
|
|
79
|
+
// runtime instead of referencing it statically.
|
|
80
|
+
const disposeSym = (Symbol as unknown as { dispose?: symbol }).dispose
|
|
81
|
+
if (!disposeSym || value == null) return
|
|
82
|
+
try {
|
|
83
|
+
const fn = (value as Record<symbol, unknown>)[disposeSym]
|
|
84
|
+
if (typeof fn === "function") (fn as () => void).call(value)
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort cleanup
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ArtifactsSync {
|
|
91
|
+
private readonly repoName: string
|
|
92
|
+
private remoteUrl: string | null = null
|
|
93
|
+
private remoteRegistered = false
|
|
94
|
+
private token: string | null = null
|
|
95
|
+
private tokenExpiresAt = 0
|
|
96
|
+
/**
|
|
97
|
+
* Serializes pushes within this DO instance. Concurrent pushes to the same
|
|
98
|
+
* branch (e.g. a commit's fire-and-forget mirror overlapping a deploy's push,
|
|
99
|
+
* or rapid successive commits) race on the ref advertisement and get rejected
|
|
100
|
+
* as "stale ref". Chaining pushes eliminates that self-inflicted race.
|
|
101
|
+
*/
|
|
102
|
+
private pushQueue: Promise<boolean> = Promise.resolve(true)
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
private readonly artifacts: Artifacts,
|
|
106
|
+
private readonly git: Git,
|
|
107
|
+
repoName: string,
|
|
108
|
+
private readonly store?: ArtifactsRemoteStore,
|
|
109
|
+
private readonly logger: ArtifactsSyncLogger = console,
|
|
110
|
+
) {
|
|
111
|
+
this.repoName = sanitizeRepoName(repoName)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ensure the app's Artifacts repo exists and the local git repo has an
|
|
116
|
+
* `artifacts` remote pointing at it. Idempotent. Returns false (and logs) if
|
|
117
|
+
* the repo could not be ensured — callers should treat sync as unavailable.
|
|
118
|
+
*
|
|
119
|
+
* Resolution order for the remote URL: in-memory cache -> durable store ->
|
|
120
|
+
* provision (create, persist). This means a cold-started DO (empty in-memory
|
|
121
|
+
* FS) recovers the remote from durable storage without any `get()` handle.
|
|
122
|
+
*/
|
|
123
|
+
private async ensureRepo(): Promise<boolean> {
|
|
124
|
+
if (this.remoteRegistered && this.remoteUrl) return true
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
let remote = this.remoteUrl ?? (await this.store?.read()) ?? null
|
|
128
|
+
if (!remote) remote = await this.provisionRemote()
|
|
129
|
+
if (!remote) return false
|
|
130
|
+
|
|
131
|
+
this.remoteUrl = remote
|
|
132
|
+
await this.store?.write(remote)
|
|
133
|
+
await this.registerRemote(remote)
|
|
134
|
+
this.remoteRegistered = true
|
|
135
|
+
return true
|
|
136
|
+
} catch (e) {
|
|
137
|
+
this.logger.warn("ArtifactsSync.ensureRepo failed", e)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Obtain the repo's git remote URL, creating the repo if needed.
|
|
144
|
+
*
|
|
145
|
+
* Only `create()`/`import()` return the `remote` as a by-value RPC result; a
|
|
146
|
+
* `get()` handle exposes methods only, and its data properties cannot be read
|
|
147
|
+
* across the remote-binding proxy used in local dev (they resolve as method
|
|
148
|
+
* calls and throw "does not implement the method"). So we create first and,
|
|
149
|
+
* on ALREADY_EXISTS, fall back to reading the handle's `remote` — which works
|
|
150
|
+
* with the native binding in production; in local dev it may fail, in which
|
|
151
|
+
* case sync degrades to unavailable (best-effort) until a value is persisted.
|
|
152
|
+
*/
|
|
153
|
+
private async provisionRemote(): Promise<string | null> {
|
|
154
|
+
let created: ArtifactsCreateRepoResult | null = null
|
|
155
|
+
try {
|
|
156
|
+
created = await this.artifacts.create(this.repoName, { setDefaultBranch: "main" })
|
|
157
|
+
const remote = await created.remote
|
|
158
|
+
try {
|
|
159
|
+
// Reuse the initial token to avoid an extra round-trip on first push.
|
|
160
|
+
this.token = await created.token
|
|
161
|
+
this.tokenExpiresAt = Date.parse(await created.tokenExpiresAt) || 0
|
|
162
|
+
} catch {
|
|
163
|
+
// Token unreadable here — getWriteToken() will mint one on demand.
|
|
164
|
+
}
|
|
165
|
+
return remote
|
|
166
|
+
} catch {
|
|
167
|
+
const repo = await this.artifacts.get(this.repoName).catch(() => null)
|
|
168
|
+
if (!repo) return null
|
|
169
|
+
try {
|
|
170
|
+
return await repo.remote
|
|
171
|
+
} catch (e) {
|
|
172
|
+
this.logger.warn(
|
|
173
|
+
"ArtifactsSync: repo exists but its remote URL is not readable and none is persisted; sync unavailable",
|
|
174
|
+
e,
|
|
175
|
+
)
|
|
176
|
+
return null
|
|
177
|
+
} finally {
|
|
178
|
+
disposeQuietly(repo)
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
if (created) disposeQuietly(created)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async registerRemote(url: string): Promise<void> {
|
|
186
|
+
try {
|
|
187
|
+
await this.git.remote({ add: { name: REMOTE_NAME, url } })
|
|
188
|
+
} catch {
|
|
189
|
+
// Remote already exists — ensure the URL is current by removing and
|
|
190
|
+
// re-adding (Artifacts remotes are stable, but be defensive).
|
|
191
|
+
try {
|
|
192
|
+
await this.git.remote({ remove: REMOTE_NAME })
|
|
193
|
+
await this.git.remote({ add: { name: REMOTE_NAME, url } })
|
|
194
|
+
} catch (e) {
|
|
195
|
+
this.logger.warn("ArtifactsSync.registerRemote failed", e)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Return a valid write token, minting/refreshing as needed. */
|
|
201
|
+
private async getWriteToken(): Promise<string | null> {
|
|
202
|
+
if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_SKEW_MS) {
|
|
203
|
+
return this.token
|
|
204
|
+
}
|
|
205
|
+
const repo = await this.artifacts.get(this.repoName).catch(() => null)
|
|
206
|
+
if (!repo) {
|
|
207
|
+
this.logger.warn("ArtifactsSync.getWriteToken failed to get repo handle")
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
// createToken() is a method call whose result is a by-value plain object,
|
|
212
|
+
// so its `plaintext`/`expiresAt` fields are directly readable.
|
|
213
|
+
const result = await repo.createToken("write", TOKEN_TTL_SECONDS)
|
|
214
|
+
this.token = result.plaintext
|
|
215
|
+
this.tokenExpiresAt = Date.parse(result.expiresAt) || 0
|
|
216
|
+
return this.token
|
|
217
|
+
} catch (e) {
|
|
218
|
+
this.logger.warn("ArtifactsSync.getWriteToken failed", e)
|
|
219
|
+
return null
|
|
220
|
+
} finally {
|
|
221
|
+
disposeQuietly(repo)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Mirror `branch` to Artifacts. Best-effort: returns true on success, false
|
|
227
|
+
* (with a logged warning) otherwise. Never throws.
|
|
228
|
+
*
|
|
229
|
+
* Pushes are serialized per DO instance (see `pushQueue`) so overlapping
|
|
230
|
+
* callers can't race each other into a "stale ref" rejection, and each push
|
|
231
|
+
* is retried with backoff to absorb races from any other writer.
|
|
232
|
+
*/
|
|
233
|
+
async push(branch: string): Promise<boolean> {
|
|
234
|
+
const run = this.pushQueue.then(
|
|
235
|
+
() => this.pushWithRetry(branch),
|
|
236
|
+
() => this.pushWithRetry(branch),
|
|
237
|
+
)
|
|
238
|
+
// Keep the chain alive regardless of this push's outcome.
|
|
239
|
+
this.pushQueue = run.catch(() => false)
|
|
240
|
+
return run
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Guard against destroying remote history. Pushes use `force` (to converge
|
|
245
|
+
* under stale-ref races), which makes them capable of silently REPLACING the
|
|
246
|
+
* remote branch. That is only safe when the local branch contains the remote
|
|
247
|
+
* head. After a cold start the local branch is reconciled onto the fetched
|
|
248
|
+
* head (see `reconcileLocalBranch` in git-objects.ts), so a remote head that
|
|
249
|
+
* is NOT an ancestor of the local head means the histories genuinely
|
|
250
|
+
* diverged (e.g. commits made while the base could not be loaded) — refuse
|
|
251
|
+
* the push instead of deleting earlier commits. When the remote head is
|
|
252
|
+
* unknown (first push, or the base was never fetched) there is nothing
|
|
253
|
+
* verifiable to destroy, so the push proceeds (best-effort sync).
|
|
254
|
+
*/
|
|
255
|
+
private async remoteHeadContainedIn(branch: string): Promise<boolean> {
|
|
256
|
+
let remoteHead: string | null = null
|
|
257
|
+
try {
|
|
258
|
+
const tracking = await this.git.log({ ref: `refs/remotes/${REMOTE_NAME}/${branch}`, depth: 1 })
|
|
259
|
+
remoteHead = tracking[0]?.oid ?? null
|
|
260
|
+
} catch {
|
|
261
|
+
return true // No tracking ref — nothing known to protect.
|
|
262
|
+
}
|
|
263
|
+
if (!remoteHead) return true
|
|
264
|
+
try {
|
|
265
|
+
const history = await this.git.log({ ref: branch, depth: 10_000 })
|
|
266
|
+
return history.some((entry) => entry.oid === remoteHead)
|
|
267
|
+
} catch {
|
|
268
|
+
// Local branch unreadable — let the push attempt surface the real error.
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async pushWithRetry(branch: string): Promise<boolean> {
|
|
274
|
+
if (!(await this.ensureRepo())) return false
|
|
275
|
+
if (!(await this.remoteHeadContainedIn(branch))) {
|
|
276
|
+
this.logger.warn(
|
|
277
|
+
`ArtifactsSync.push refused for branch "${branch}": local history does not contain the remote head; not force-overwriting remote history`,
|
|
278
|
+
)
|
|
279
|
+
return false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (let attempt = 1; attempt <= PUSH_MAX_ATTEMPTS; attempt++) {
|
|
283
|
+
const token = await this.getWriteToken()
|
|
284
|
+
if (!token) return false
|
|
285
|
+
try {
|
|
286
|
+
await this.git.push({
|
|
287
|
+
remote: REMOTE_NAME,
|
|
288
|
+
ref: branch,
|
|
289
|
+
force: true,
|
|
290
|
+
username: "x",
|
|
291
|
+
password: token,
|
|
292
|
+
})
|
|
293
|
+
return true
|
|
294
|
+
} catch (e) {
|
|
295
|
+
const retryable = isRetryablePushError(e)
|
|
296
|
+
if (retryable && attempt < PUSH_MAX_ATTEMPTS) {
|
|
297
|
+
this.logger.warn(
|
|
298
|
+
`ArtifactsSync.push retry ${attempt}/${PUSH_MAX_ATTEMPTS} for branch "${branch}" (stale ref race)`,
|
|
299
|
+
)
|
|
300
|
+
await sleep(PUSH_RETRY_BASE_MS * attempt)
|
|
301
|
+
continue
|
|
302
|
+
}
|
|
303
|
+
this.logger.warn(`ArtifactsSync.push failed for branch "${branch}"`, e)
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Fetch `branch` from Artifacts to reconcile the local mirror before a
|
|
312
|
+
* restore. Best-effort: returns true on success, false otherwise.
|
|
313
|
+
*/
|
|
314
|
+
async fetch(branch: string): Promise<boolean> {
|
|
315
|
+
if (!(await this.ensureRepo())) return false
|
|
316
|
+
const token = await this.getWriteToken()
|
|
317
|
+
if (!token) return false
|
|
318
|
+
try {
|
|
319
|
+
await this.git.fetch({
|
|
320
|
+
remote: REMOTE_NAME,
|
|
321
|
+
ref: branch,
|
|
322
|
+
username: "x",
|
|
323
|
+
password: token,
|
|
324
|
+
})
|
|
325
|
+
return true
|
|
326
|
+
} catch (e) {
|
|
327
|
+
this.logger.warn(`ArtifactsSync.fetch failed for branch "${branch}"`, e)
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable checkpoint of the workspace overlay in the SpaceDO's own SQLite
|
|
3
|
+
* storage.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the workspace (overlay workdir + `.git` objects) is
|
|
6
|
+
* in-memory, and Cloudflare resets DO instances on every code update, eviction,
|
|
7
|
+
* or runtime update. Artifacts only holds PUSHED commits — so until the first
|
|
8
|
+
* `deploy_space` (and between deploys), a reset wipes everything the agent has
|
|
9
|
+
* written. The checkpoint closes that gap: every write is mirrored into DO
|
|
10
|
+
* storage (debounced), and a cold-started DO replays it on top of the
|
|
11
|
+
* Artifacts base — the overlay shadows the base, so the newer checkpointed
|
|
12
|
+
* content wins automatically.
|
|
13
|
+
*
|
|
14
|
+
* Rows are a delta against the last successful push: after a push succeeds the
|
|
15
|
+
* table is cleared (the base now covers those files) and only subsequent
|
|
16
|
+
* writes are checkpointed. Tombstones record deletions relative to the base.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is best-effort: checkpoint failures must never break a file
|
|
19
|
+
* operation.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The checkpoint contents, applied over the Artifacts base after a cold start. */
|
|
23
|
+
export interface CheckpointSnapshot {
|
|
24
|
+
/** Absolute path -> current bytes (shadows the base). */
|
|
25
|
+
files: Array<[path: string, bytes: Uint8Array]>
|
|
26
|
+
/** Absolute paths deleted relative to the base (applied as whiteouts). */
|
|
27
|
+
tombstones: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CheckpointSource {
|
|
31
|
+
load(): CheckpointSnapshot
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TABLE = "space_checkpoint"
|
|
35
|
+
|
|
36
|
+
/** Minimal shape of `ctx.storage.sql` this store relies on. */
|
|
37
|
+
export interface SqlStorageLike {
|
|
38
|
+
exec(
|
|
39
|
+
query: string,
|
|
40
|
+
...bindings: unknown[]
|
|
41
|
+
): { toArray(): Array<Record<string, unknown>> }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class CheckpointStore implements CheckpointSource {
|
|
45
|
+
private readonly sql: SqlStorageLike
|
|
46
|
+
|
|
47
|
+
constructor(storage: { sql: SqlStorageLike }) {
|
|
48
|
+
this.sql = storage.sql
|
|
49
|
+
this.sql.exec(
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS ${TABLE} (` +
|
|
51
|
+
`path TEXT PRIMARY KEY, data BLOB, tombstone INTEGER NOT NULL DEFAULT 0)`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record a path's current bytes — or, with `null`, its deletion. Deleting a
|
|
57
|
+
* path also drops any checkpointed rows BENEATH it (a recursive `rm`), so a
|
|
58
|
+
* later restore cannot resurrect them; base files under the path are hidden
|
|
59
|
+
* by the whiteout's ancestor coverage.
|
|
60
|
+
*/
|
|
61
|
+
save(path: string, bytes: Uint8Array | null): void {
|
|
62
|
+
if (bytes === null) {
|
|
63
|
+
this.sql.exec(
|
|
64
|
+
`DELETE FROM ${TABLE} WHERE path = ? OR substr(path, 1, ?) = ?`,
|
|
65
|
+
path,
|
|
66
|
+
path.length + 1,
|
|
67
|
+
`${path}/`,
|
|
68
|
+
)
|
|
69
|
+
this.sql.exec(
|
|
70
|
+
`INSERT OR REPLACE INTO ${TABLE} (path, data, tombstone) VALUES (?, NULL, 1)`,
|
|
71
|
+
path,
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
// `.slice().buffer`: a fresh, exactly-sized ArrayBuffer for the blob binding.
|
|
76
|
+
this.sql.exec(
|
|
77
|
+
`INSERT OR REPLACE INTO ${TABLE} (path, data, tombstone) VALUES (?, ?, 0)`,
|
|
78
|
+
path,
|
|
79
|
+
bytes.slice().buffer,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drop every row. Call after a successful Artifacts push — the pushed base
|
|
85
|
+
* now covers all checkpointed content, so the checkpoint restarts empty.
|
|
86
|
+
*/
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.sql.exec(`DELETE FROM ${TABLE}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
load(): CheckpointSnapshot {
|
|
92
|
+
const files: Array<[string, Uint8Array]> = []
|
|
93
|
+
const tombstones: string[] = []
|
|
94
|
+
for (const row of this.sql.exec(`SELECT path, data, tombstone FROM ${TABLE}`).toArray()) {
|
|
95
|
+
const path = row.path as string
|
|
96
|
+
if (row.tombstone) tombstones.push(path)
|
|
97
|
+
else files.push([path, new Uint8Array(row.data as ArrayBuffer)])
|
|
98
|
+
}
|
|
99
|
+
return { files, tombstones }
|
|
100
|
+
}
|
|
101
|
+
}
|