@hoardodile/host 0.0.0
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 +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs"
|
|
10
|
+
import { join, resolve } from "node:path"
|
|
11
|
+
import { conflict, notFound } from "../errors.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Persisted version-state file (under `<root>/local/`, not `versions/`,
|
|
15
|
+
* because `versions/` is reserved for version folders only).
|
|
16
|
+
*/
|
|
17
|
+
const STATE_FILENAME = "version-state.json"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* On-disk shape of the version state.
|
|
21
|
+
*
|
|
22
|
+
* - `active` — the version the user is currently viewing. When `active`
|
|
23
|
+
* equals the current (max) version the server runs in normal R/W mode;
|
|
24
|
+
* when `active < current` the server runs READ-ONLY against a cloned
|
|
25
|
+
* snapshot of the active version's DB.
|
|
26
|
+
*/
|
|
27
|
+
type VersionState = {
|
|
28
|
+
readonly active: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Root layout helper: the versions root directory `<root>/versions`. */
|
|
32
|
+
function versionsRoot(root: string): string {
|
|
33
|
+
return resolve(root, "versions")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** State file lives under `<root>/local/version-state.json`. */
|
|
37
|
+
function stateFile(root: string): string {
|
|
38
|
+
return resolve(root, "local", STATE_FILENAME)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Enumerate version directories under `<root>/versions/`. Names that are
|
|
43
|
+
* not pure positive integers are ignored. Result is sorted ascending.
|
|
44
|
+
*/
|
|
45
|
+
export function listVersions(root: string): readonly number[] {
|
|
46
|
+
const dir = versionsRoot(root)
|
|
47
|
+
if (!existsSync(dir)) return []
|
|
48
|
+
const names = readdirSync(dir, { withFileTypes: true })
|
|
49
|
+
.filter((e) => e.isDirectory())
|
|
50
|
+
.map((e) => e.name)
|
|
51
|
+
const nums: number[] = []
|
|
52
|
+
for (const n of names) {
|
|
53
|
+
if (!/^[1-9][0-9]*$/.test(n)) continue
|
|
54
|
+
nums.push(Number.parseInt(n, 10))
|
|
55
|
+
}
|
|
56
|
+
nums.sort((a, b) => a - b)
|
|
57
|
+
return nums
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Current (maximum) version on disk. Returns `0` when no version exists
|
|
62
|
+
* yet (caller is expected to bootstrap version 1 in that case).
|
|
63
|
+
*/
|
|
64
|
+
export function currentVersion(root: string): number {
|
|
65
|
+
const all = listVersions(root)
|
|
66
|
+
return all.length === 0 ? 0 : (all[all.length - 1] ?? 0)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the persisted active version. Falls back to current when the
|
|
71
|
+
* state file is missing or malformed, or when the recorded value points
|
|
72
|
+
* at a version that no longer exists.
|
|
73
|
+
*/
|
|
74
|
+
export function readActiveVersion(root: string): number {
|
|
75
|
+
const cur = currentVersion(root)
|
|
76
|
+
const file = stateFile(root)
|
|
77
|
+
if (!existsSync(file)) return cur
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(
|
|
80
|
+
readFileSync(file, "utf8"),
|
|
81
|
+
) as Partial<VersionState>
|
|
82
|
+
const active = typeof parsed.active === "number" ? parsed.active : cur
|
|
83
|
+
const all = listVersions(root)
|
|
84
|
+
if (all.includes(active)) return active
|
|
85
|
+
return cur
|
|
86
|
+
} catch {
|
|
87
|
+
return cur
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Persist the active version. Caller must ensure `version` exists on
|
|
93
|
+
* disk (use {@link listVersions}).
|
|
94
|
+
*
|
|
95
|
+
* @throws DomainError `version.not_found` when `version` is not a known
|
|
96
|
+
* version directory.
|
|
97
|
+
*/
|
|
98
|
+
export function writeActiveVersion(root: string, version: number): void {
|
|
99
|
+
const all = listVersions(root)
|
|
100
|
+
if (!all.includes(version)) {
|
|
101
|
+
throw notFound("version.not_found", `version ${version} does not exist`, {
|
|
102
|
+
version,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
const file = stateFile(root)
|
|
106
|
+
mkdirSync(resolve(root, "local"), { recursive: true })
|
|
107
|
+
const payload: VersionState = { active: version }
|
|
108
|
+
writeFileSync(file, JSON.stringify(payload), "utf8")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Bootstrap version 1 if the versions root has no version directories yet.
|
|
113
|
+
* Idempotent; a no-op when any version already exists.
|
|
114
|
+
*/
|
|
115
|
+
export function ensureBootstrapVersion(root: string): number {
|
|
116
|
+
const cur = currentVersion(root)
|
|
117
|
+
if (cur > 0) return cur
|
|
118
|
+
const v1 = resolve(versionsRoot(root), "1")
|
|
119
|
+
mkdirSync(v1, { recursive: true })
|
|
120
|
+
return 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type CreateNextVersionResult = {
|
|
124
|
+
readonly previous: number
|
|
125
|
+
readonly created: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Snapshot the current version into a freshly-numbered next version.
|
|
130
|
+
*
|
|
131
|
+
* Process:
|
|
132
|
+
* 1. Compute `next = currentVersion + 1`.
|
|
133
|
+
* 2. Refuse to proceed when `versions/<prev>/app.sqlite` already exists.
|
|
134
|
+
* 3. Create `<root>/versions/<next>/` and copy installed plugins from
|
|
135
|
+
* `versions/<prev>/plugins` (resource binaries stay at their
|
|
136
|
+
* `fileVersion`; plugins have no equivalent pointer).
|
|
137
|
+
* 4. Vacuum-snapshot the live DB into `<root>/versions/<prev>/app.sqlite`.
|
|
138
|
+
* (Caller passes a `vacuumInto` function; we don't take a DB handle
|
|
139
|
+
* here to keep this module database-agnostic.)
|
|
140
|
+
* 5. On any failure after step 3, remove `versions/<next>` so
|
|
141
|
+
* {@link currentVersion} does not jump to an empty directory.
|
|
142
|
+
*
|
|
143
|
+
* @throws DomainError `version.bootstrap_required` when no version exists
|
|
144
|
+
* yet (caller must bootstrap first).
|
|
145
|
+
* @throws DomainError `version.already_exists` when the previous
|
|
146
|
+
* version's DB snapshot already exists.
|
|
147
|
+
*/
|
|
148
|
+
export function createNextVersion(
|
|
149
|
+
root: string,
|
|
150
|
+
vacuumInto: (destination: string) => void,
|
|
151
|
+
): CreateNextVersionResult {
|
|
152
|
+
const prev = currentVersion(root)
|
|
153
|
+
if (prev === 0) {
|
|
154
|
+
throw conflict(
|
|
155
|
+
"version.bootstrap_required",
|
|
156
|
+
"no current version to snapshot from",
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
const next = prev + 1
|
|
160
|
+
const prevDir = resolve(versionsRoot(root), String(prev))
|
|
161
|
+
const nextDir = resolve(versionsRoot(root), String(next))
|
|
162
|
+
// Archive the current live DB under the PREVIOUS version directory so
|
|
163
|
+
// that `versions/<prev>/app.sqlite` becomes the immutable snapshot.
|
|
164
|
+
// The live database stays at `<root>/app.sqlite`; only this archived
|
|
165
|
+
// copy lands in `versions/`.
|
|
166
|
+
const dest = resolve(prevDir, "app.sqlite")
|
|
167
|
+
if (existsSync(dest)) {
|
|
168
|
+
throw conflict(
|
|
169
|
+
"version.already_exists",
|
|
170
|
+
`app.sqlite already exists for version ${next}`,
|
|
171
|
+
{ version: next },
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
mkdirSync(nextDir, { recursive: true })
|
|
175
|
+
try {
|
|
176
|
+
copyVersionPlugins(prevDir, nextDir)
|
|
177
|
+
vacuumInto(dest)
|
|
178
|
+
} catch (err) {
|
|
179
|
+
rmSync(nextDir, { recursive: true, force: true })
|
|
180
|
+
throw err
|
|
181
|
+
}
|
|
182
|
+
return { previous: prev, created: next }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Copy installed plugin directories from one version folder into the
|
|
187
|
+
* next. Only subdirectories that look like plugins (contain
|
|
188
|
+
* `manifest.json`) are copied; dot-directories such as leftover
|
|
189
|
+
* staging folders are skipped.
|
|
190
|
+
*/
|
|
191
|
+
function copyVersionPlugins(prevDir: string, nextDir: string): void {
|
|
192
|
+
const src = join(prevDir, "plugins")
|
|
193
|
+
if (!existsSync(src)) return
|
|
194
|
+
const entries = readdirSync(src, { withFileTypes: true })
|
|
195
|
+
for (const entry of entries) {
|
|
196
|
+
if (!entry.isDirectory()) continue
|
|
197
|
+
if (entry.name.startsWith(".")) continue
|
|
198
|
+
const from = join(src, entry.name)
|
|
199
|
+
if (!existsSync(join(from, "manifest.json"))) continue
|
|
200
|
+
cpSync(from, join(nextDir, "plugins", entry.name), { recursive: true })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Path to the DB file for version `v`: `<root>/versions/<v>/app.sqlite`.
|
|
206
|
+
*/
|
|
207
|
+
export function versionedDbFile(root: string, v: number): string {
|
|
208
|
+
return resolve(versionsRoot(root), String(v), "app.sqlite")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Path to the per-version archive directory `<root>/versions/<v>`.
|
|
213
|
+
*/
|
|
214
|
+
export function versionedPath(root: string, v: number): string {
|
|
215
|
+
return resolve(versionsRoot(root), String(v))
|
|
216
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir, readdir, rename, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { extname, join } from "node:path"
|
|
3
|
+
import type { StoragePaths } from "./paths.ts"
|
|
4
|
+
import { writeVersioned } from "./write-versioned.ts"
|
|
5
|
+
|
|
6
|
+
export type VersionedFolderOps = {
|
|
7
|
+
/** Ensure the current-version entity folder exists. */
|
|
8
|
+
ensureFolder(id: string): Promise<void>
|
|
9
|
+
/** Remove the current-version entity folder. Swallows missing-path errors. */
|
|
10
|
+
removeFolder(id: string): Promise<void>
|
|
11
|
+
/**
|
|
12
|
+
* When an entity's files live only under frozen past archives, hard
|
|
13
|
+
* delete cannot remove those folders; drop a `.deleted` placeholder in
|
|
14
|
+
* the current-version folder instead.
|
|
15
|
+
*/
|
|
16
|
+
markDeleted(id: string): Promise<string>
|
|
17
|
+
/**
|
|
18
|
+
* Move the current-version entity folder into `local/trash/` with a
|
|
19
|
+
* unique directory name. No-op when the source path is missing (same as
|
|
20
|
+
* a removed folder). Returns the destination path (whether or not a move
|
|
21
|
+
* occurred).
|
|
22
|
+
*/
|
|
23
|
+
moveFolderToTrash(id: string): Promise<string>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The four lifecycle operations shared by the resource and character
|
|
28
|
+
* file-system layers. They differ only in which versioned folder they
|
|
29
|
+
* target and how the trash / placeholder names are derived.
|
|
30
|
+
*
|
|
31
|
+
* `moveFolderToTrash` treats `EPERM`/`EBUSY`/`UNKNOWN` as transient Windows
|
|
32
|
+
* locks (a file inside the folder is still open) and leaves the source in
|
|
33
|
+
* place; a boot-time orphan sweep reclaims it later.
|
|
34
|
+
*
|
|
35
|
+
* `readOnly` is a live `{ current: boolean }` ref (the server's runtime
|
|
36
|
+
* read-only flag), so a version switch mid-request re-reads it.
|
|
37
|
+
*/
|
|
38
|
+
export function buildVersionedFolderOps(
|
|
39
|
+
paths: StoragePaths,
|
|
40
|
+
readOnly: { readonly current: boolean },
|
|
41
|
+
kind: "resource" | "character",
|
|
42
|
+
): VersionedFolderOps {
|
|
43
|
+
const folderOf =
|
|
44
|
+
(id: string) =>
|
|
45
|
+
(current: StoragePaths["latest"]): string =>
|
|
46
|
+
kind === "resource" ? current.resource(id) : current.character(id)
|
|
47
|
+
const trashPrefix = kind === "resource" ? "resources-" : "characters-"
|
|
48
|
+
const deletedKind = kind === "resource" ? "resources" : "characters"
|
|
49
|
+
|
|
50
|
+
async function ensureFolder(id: string): Promise<void> {
|
|
51
|
+
await writeVersioned(paths, readOnly.current, (current) =>
|
|
52
|
+
mkdir(folderOf(id)(current), { recursive: true }),
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function removeFolder(id: string): Promise<void> {
|
|
57
|
+
await writeVersioned(paths, readOnly.current, (current) =>
|
|
58
|
+
rm(folderOf(id)(current), {
|
|
59
|
+
recursive: true,
|
|
60
|
+
force: true,
|
|
61
|
+
}).catch(() => {}),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function markDeleted(id: string): Promise<string> {
|
|
66
|
+
return writeVersioned(paths, readOnly.current, async (current) => {
|
|
67
|
+
const folder = folderOf(id)(current)
|
|
68
|
+
await mkdir(folder, { recursive: true })
|
|
69
|
+
const marker = current.deletedMarker(deletedKind, id)
|
|
70
|
+
// Empty file: the placeholder's existence is the whole signal.
|
|
71
|
+
await writeFile(marker, "")
|
|
72
|
+
return marker
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function moveFolderToTrash(id: string): Promise<string> {
|
|
77
|
+
// write-local-only: trash directory is under local/, not versions/.
|
|
78
|
+
await mkdir(paths.local.trash(), { recursive: true })
|
|
79
|
+
return writeVersioned(paths, readOnly.current, async (current) => {
|
|
80
|
+
const src = folderOf(id)(current)
|
|
81
|
+
const dest = join(
|
|
82
|
+
paths.local.trash(),
|
|
83
|
+
`${trashPrefix}${id}-${Date.now()}`,
|
|
84
|
+
)
|
|
85
|
+
try {
|
|
86
|
+
await rename(src, dest)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
89
|
+
if (code === "ENOENT") {
|
|
90
|
+
// Already gone; return dest as a conventional indicator.
|
|
91
|
+
return dest
|
|
92
|
+
}
|
|
93
|
+
// Windows may throw EPERM/EBUSY/UNKNOWN when a file inside src is
|
|
94
|
+
// still open (thumb pipeline, HTTP stream, zip handle, ...). Don't
|
|
95
|
+
// let a transient lock block the hard-delete; an orphan sweep at
|
|
96
|
+
// boot will reclaim the leftover folder later.
|
|
97
|
+
if (code === "EPERM" || code === "EBUSY" || code === "UNKNOWN") {
|
|
98
|
+
return src
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
return dest
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ensureFolder,
|
|
108
|
+
removeFolder,
|
|
109
|
+
markDeleted,
|
|
110
|
+
moveFolderToTrash,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Move every file in `sourceFolder` whose name matches `match` into
|
|
116
|
+
* `destFolder` under a timestamped archive name (`<prefix><stamp><ext>`, or
|
|
117
|
+
* `<prefix><stamp>_<i><ext>` when several files match). Moves to the local
|
|
118
|
+
* (non-synced) directory so replaced files are preserved without polluting
|
|
119
|
+
* the versions sync scope. Creates `destFolder` on demand; silently ignores
|
|
120
|
+
* ENOENT on source files and a missing source folder.
|
|
121
|
+
*/
|
|
122
|
+
export async function archiveStaleFiles(args: {
|
|
123
|
+
readonly sourceFolder: string
|
|
124
|
+
readonly destFolder: string
|
|
125
|
+
readonly match: (name: string) => boolean
|
|
126
|
+
readonly archivePrefix: string
|
|
127
|
+
}): Promise<void> {
|
|
128
|
+
const { sourceFolder, destFolder, match, archivePrefix } = args
|
|
129
|
+
const entries = await readdir(sourceFolder).catch(() => [])
|
|
130
|
+
const stale = entries.filter(match)
|
|
131
|
+
if (stale.length === 0) return
|
|
132
|
+
await mkdir(destFolder, { recursive: true })
|
|
133
|
+
const stamp = Date.now()
|
|
134
|
+
await Promise.all(
|
|
135
|
+
stale.map(async (name, i) => {
|
|
136
|
+
const ext = extname(name)
|
|
137
|
+
const archiveName =
|
|
138
|
+
stale.length === 1
|
|
139
|
+
? `${archivePrefix}${stamp}${ext}`
|
|
140
|
+
: `${archivePrefix}${stamp}_${i}${ext}`
|
|
141
|
+
try {
|
|
142
|
+
await rename(join(sourceFolder, name), join(destFolder, archiveName))
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (!isEnoentError(err)) throw err
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isEnoentError(err: unknown): boolean {
|
|
151
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT"
|
|
152
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { describe, expect, test } from "vitest"
|
|
5
|
+
import { createStoragePaths } from "./paths.ts"
|
|
6
|
+
import { writeVersioned } from "./write-versioned.ts"
|
|
7
|
+
|
|
8
|
+
describe("writeVersioned", () => {
|
|
9
|
+
test("throws DomainError when readOnly is true", async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), "write-versioned-ro-"))
|
|
11
|
+
try {
|
|
12
|
+
const paths = createStoragePaths({ root })
|
|
13
|
+
await expect(
|
|
14
|
+
writeVersioned(paths, true, async () => "should not run"),
|
|
15
|
+
).rejects.toMatchObject({
|
|
16
|
+
code: "CONFLICT",
|
|
17
|
+
kind: "server.read_only_archive",
|
|
18
|
+
message:
|
|
19
|
+
"server is viewing a read-only archive; versioned writes are blocked",
|
|
20
|
+
})
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(root, { recursive: true, force: true })
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("executes command on paths.latest when readOnly is false", async () => {
|
|
27
|
+
const root = await mkdtemp(join(tmpdir(), "write-versioned-ok-"))
|
|
28
|
+
try {
|
|
29
|
+
const paths = createStoragePaths({ root })
|
|
30
|
+
const result = await writeVersioned(paths, false, async (current) => {
|
|
31
|
+
const dir = current.resource("res-1")
|
|
32
|
+
await mkdir(dir, { recursive: true })
|
|
33
|
+
const file = join(dir, "blob")
|
|
34
|
+
await writeFile(file, "data")
|
|
35
|
+
return file
|
|
36
|
+
})
|
|
37
|
+
expect(result.startsWith(paths.latest.resource("res-1"))).toBe(true)
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(root, { recursive: true, force: true })
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("callback always receives the current version even when viewing an older version", async () => {
|
|
44
|
+
const root = await mkdtemp(join(tmpdir(), "write-versioned-version-"))
|
|
45
|
+
try {
|
|
46
|
+
// Prepare two version directories and pin active to v1 while current is v2.
|
|
47
|
+
await mkdir(join(root, "versions", "1"), { recursive: true })
|
|
48
|
+
await mkdir(join(root, "versions", "2"), { recursive: true })
|
|
49
|
+
const paths = createStoragePaths({
|
|
50
|
+
root,
|
|
51
|
+
activeVersion: 1,
|
|
52
|
+
latestVersion: 2,
|
|
53
|
+
})
|
|
54
|
+
expect(paths.active.version).toBe(1)
|
|
55
|
+
expect(paths.latest.version).toBe(2)
|
|
56
|
+
|
|
57
|
+
const receivedVersion = await writeVersioned(
|
|
58
|
+
paths,
|
|
59
|
+
false,
|
|
60
|
+
async (current) => current.version,
|
|
61
|
+
)
|
|
62
|
+
expect(receivedVersion).toBe(2)
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(root, { recursive: true, force: true })
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { conflict } from "../errors.ts"
|
|
2
|
+
import type { StoragePaths } from "./paths.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Command that performs a write under the **current** (latest, writable)
|
|
6
|
+
* archive version. Receiving `paths.latest` instead of the whole
|
|
7
|
+
* {@link StoragePaths} object makes it impossible for callers to accidentally
|
|
8
|
+
* target `paths.active`, which may point at a frozen past version when the
|
|
9
|
+
* server is in read-only viewing mode.
|
|
10
|
+
*/
|
|
11
|
+
export type VersionedWriteCommand<T> = (
|
|
12
|
+
paths: StoragePaths["latest"],
|
|
13
|
+
) => T | Promise<T>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Central gate for every file-system write that lands under `versions/<v>`.
|
|
17
|
+
*
|
|
18
|
+
* - Blocks the operation when `readOnly` is true, surfacing a domain conflict
|
|
19
|
+
* with code `server.read_only_archive`.
|
|
20
|
+
* - Forces the command to operate on `paths.latest` (the latest archive
|
|
21
|
+
* version) so past frozen versions can never be mutated.
|
|
22
|
+
*
|
|
23
|
+
* Local-only writes (thumbs, caches, staging pool, trash) should NOT use this
|
|
24
|
+
* helper; they live under `local/` and are governed by the route-level
|
|
25
|
+
* write gate instead.
|
|
26
|
+
*/
|
|
27
|
+
export async function writeVersioned<T>(
|
|
28
|
+
paths: StoragePaths,
|
|
29
|
+
readOnly: boolean,
|
|
30
|
+
cmd: VersionedWriteCommand<T>,
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
if (readOnly) {
|
|
33
|
+
throw conflict(
|
|
34
|
+
"server.read_only_archive",
|
|
35
|
+
"server is viewing a read-only archive; versioned writes are blocked",
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return await cmd(paths.latest)
|
|
39
|
+
}
|