@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,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename hygiene for bare-file resources: map a user-supplied name
|
|
3
|
+
* (upload filename or zip entry name) onto a safe relative path inside
|
|
4
|
+
* a resource folder.
|
|
5
|
+
*
|
|
6
|
+
* Resource folders hold user entries next to metadata dotfiles
|
|
7
|
+
* (`.cover.*`, `.deleted`), so every rule here also guarantees the result
|
|
8
|
+
* can never collide with those: leading dots are stripped, and a
|
|
9
|
+
* trailing-dot/space rule mirrors Windows normalization.
|
|
10
|
+
*
|
|
11
|
+
* Cross-platform guarantees: names are NFC-normalized (macOS stores
|
|
12
|
+
* NFD on disk — one canonical form keeps names stable when a library
|
|
13
|
+
* moves between systems), segments are capped at 240 UTF-8 bytes
|
|
14
|
+
* (ext4's 255-byte component limit, minus suffix headroom), and the
|
|
15
|
+
* Windows reserved-name set includes the device aliases (`CONIN$`,
|
|
16
|
+
* superscript COM/LPT variants).
|
|
17
|
+
*
|
|
18
|
+
* The migration script (`scripts/migrate-hoard-to-files.mjs`) mirrors
|
|
19
|
+
* these rules verbatim — keep both in sync when changing them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FORBIDDEN_VISIBLE_CHARS = '<>:"|?*'
|
|
23
|
+
|
|
24
|
+
/** Max UTF-8 bytes per path segment (ext4 limit 255, suffix headroom). */
|
|
25
|
+
const MAX_SEGMENT_BYTES = 240
|
|
26
|
+
|
|
27
|
+
/** Max characters for the whole relative path. */
|
|
28
|
+
const MAX_REL_PATH_CHARS = 700
|
|
29
|
+
|
|
30
|
+
/** Safety net for the collision-suffix loop in {@link uniqueEntryName}. */
|
|
31
|
+
const MAX_UNIQUE_RETRIES = 10_000
|
|
32
|
+
|
|
33
|
+
/** Windows reserved base names (case-insensitive), incl. device aliases. */
|
|
34
|
+
const WINDOWS_RESERVED = new Set([
|
|
35
|
+
"CON",
|
|
36
|
+
"PRN",
|
|
37
|
+
"AUX",
|
|
38
|
+
"NUL",
|
|
39
|
+
"COM0",
|
|
40
|
+
"COM1",
|
|
41
|
+
"COM2",
|
|
42
|
+
"COM3",
|
|
43
|
+
"COM4",
|
|
44
|
+
"COM5",
|
|
45
|
+
"COM6",
|
|
46
|
+
"COM7",
|
|
47
|
+
"COM8",
|
|
48
|
+
"COM9",
|
|
49
|
+
"LPT0",
|
|
50
|
+
"LPT1",
|
|
51
|
+
"LPT2",
|
|
52
|
+
"LPT3",
|
|
53
|
+
"LPT4",
|
|
54
|
+
"LPT5",
|
|
55
|
+
"LPT6",
|
|
56
|
+
"LPT7",
|
|
57
|
+
"LPT8",
|
|
58
|
+
"LPT9",
|
|
59
|
+
"CONIN$",
|
|
60
|
+
"CONOUT$",
|
|
61
|
+
"COM\u00b9",
|
|
62
|
+
"COM\u00b2",
|
|
63
|
+
"COM\u00b3",
|
|
64
|
+
"LPT\u00b9",
|
|
65
|
+
"LPT\u00b2",
|
|
66
|
+
"LPT\u00b3",
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Sanitize a single entry name into a safe relative path. Separators
|
|
71
|
+
* (`/` and `\`) become subdirectory boundaries; `..` and absolute paths
|
|
72
|
+
* are rejected. Returns `undefined` when the name is unusable (empty,
|
|
73
|
+
* escapes the folder, or cleans to nothing) — callers drop or reject
|
|
74
|
+
* such entries.
|
|
75
|
+
*/
|
|
76
|
+
export function sanitizeEntryName(name: string): string | undefined {
|
|
77
|
+
if (name.length === 0 || name.includes("\0")) return undefined
|
|
78
|
+
// One canonical Unicode form so names survive filesystem
|
|
79
|
+
// normalization (macOS NFD) when a library moves between hosts.
|
|
80
|
+
const normalized = name.normalize("NFC")
|
|
81
|
+
const rawSegments = normalized.replace(/\\/g, "/").split("/")
|
|
82
|
+
const segments: string[] = []
|
|
83
|
+
for (const raw of rawSegments) {
|
|
84
|
+
const cleaned = cleanSegment(raw)
|
|
85
|
+
if (cleaned === undefined) continue
|
|
86
|
+
segments.push(cleaned)
|
|
87
|
+
}
|
|
88
|
+
if (segments.length === 0) return undefined
|
|
89
|
+
const relPath = segments.join("/")
|
|
90
|
+
if (relPath.length > MAX_REL_PATH_CHARS) return undefined
|
|
91
|
+
return relPath
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Clean one path segment; `undefined` when it must be dropped entirely. */
|
|
95
|
+
function cleanSegment(raw: string): string | undefined {
|
|
96
|
+
if (raw.length === 0 || raw === "." || raw === "..") return undefined
|
|
97
|
+
let out = ""
|
|
98
|
+
for (const ch of raw) {
|
|
99
|
+
const code = ch.codePointAt(0)
|
|
100
|
+
if (code !== undefined && code < 32) continue
|
|
101
|
+
if (FORBIDDEN_VISIBLE_CHARS.includes(ch)) {
|
|
102
|
+
out += "_"
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
out += ch
|
|
106
|
+
}
|
|
107
|
+
// Leading dots would make the entry a dotfile (colliding with the
|
|
108
|
+
// metadata namespace `.cover.*` / `.deleted`); trailing dots and
|
|
109
|
+
// spaces vanish on Windows and would break path equality.
|
|
110
|
+
let trimmed = out.replace(/^\.+/, "").replace(/[. ]+$/, "")
|
|
111
|
+
if (trimmed.length === 0) return undefined
|
|
112
|
+
trimmed = truncateUtf8(trimmed, MAX_SEGMENT_BYTES)
|
|
113
|
+
if (trimmed.length === 0) return undefined
|
|
114
|
+
const base = trimmed.split(".")[0]?.toUpperCase()
|
|
115
|
+
if (base !== undefined && WINDOWS_RESERVED.has(base)) {
|
|
116
|
+
trimmed = `_${trimmed}`
|
|
117
|
+
}
|
|
118
|
+
return trimmed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Truncate `value` so its UTF-8 length stays within `maxBytes`. */
|
|
122
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
123
|
+
let bytes = 0
|
|
124
|
+
let out = ""
|
|
125
|
+
for (const ch of value) {
|
|
126
|
+
const len = Buffer.byteLength(ch, "utf8")
|
|
127
|
+
if (bytes + len > maxBytes) break
|
|
128
|
+
bytes += len
|
|
129
|
+
out += ch
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Occupancy bookkeeping for name resolution: which paths are already
|
|
136
|
+
* taken by files (blocking everything) and which are directories
|
|
137
|
+
* (blocking only exact-path installs — a file cannot replace a folder).
|
|
138
|
+
*/
|
|
139
|
+
export type OccupiedNames = {
|
|
140
|
+
readonly files: Set<string>
|
|
141
|
+
readonly dirs: Set<string>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build occupancy from optional existing on-disk names (with types). */
|
|
145
|
+
export function createOccupiedNames(existing?: {
|
|
146
|
+
readonly files?: Iterable<string>
|
|
147
|
+
readonly dirs?: Iterable<string>
|
|
148
|
+
}): OccupiedNames {
|
|
149
|
+
return {
|
|
150
|
+
files: new Set(existing?.files ?? []),
|
|
151
|
+
dirs: new Set(existing?.dirs ?? []),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Record `relPath` as an installed file; every ancestor prefix becomes
|
|
157
|
+
* a directory. A later entry colliding with one of the ancestors (a
|
|
158
|
+
* file that sits on a directory path) is resolved by
|
|
159
|
+
* {@link uniqueEntryName}.
|
|
160
|
+
*/
|
|
161
|
+
export function occupyEntryName(
|
|
162
|
+
occupied: OccupiedNames,
|
|
163
|
+
relPath: string,
|
|
164
|
+
): void {
|
|
165
|
+
occupied.files.add(relPath)
|
|
166
|
+
let prefix = ""
|
|
167
|
+
for (const segment of relPath.split("/").slice(0, -1)) {
|
|
168
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment
|
|
169
|
+
occupied.dirs.add(prefix)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a sanitized relative path against the names already present
|
|
175
|
+
* in the target folder by suffixing the colliding segment (`-1`, `-2`,
|
|
176
|
+
* …). The comparison is case-insensitive (Windows and macOS filesystems
|
|
177
|
+
* are). Files block their path and every ancestor prefix; directories
|
|
178
|
+
* block only their exact path. The suffix lands on the segment whose
|
|
179
|
+
* prefix collides, so `x/y.txt` against an occupied file `x` becomes
|
|
180
|
+
* `x-1/y.txt`, not `x/y-1.txt` (which would collide forever).
|
|
181
|
+
*/
|
|
182
|
+
export function uniqueEntryName(
|
|
183
|
+
occupied: OccupiedNames,
|
|
184
|
+
relPath: string,
|
|
185
|
+
): string {
|
|
186
|
+
const lowerFiles = new Set([...occupied.files].map((p) => p.toLowerCase()))
|
|
187
|
+
const lowerDirs = new Set([...occupied.dirs].map((p) => p.toLowerCase()))
|
|
188
|
+
let candidate = relPath
|
|
189
|
+
for (let guard = 0; guard < MAX_UNIQUE_RETRIES; guard += 1) {
|
|
190
|
+
const segIdx = collidingSegment(lowerFiles, lowerDirs, candidate)
|
|
191
|
+
if (segIdx < 0) return candidate
|
|
192
|
+
const segments = candidate.split("/")
|
|
193
|
+
const target = segments[segIdx]!
|
|
194
|
+
const dot = target.lastIndexOf(".")
|
|
195
|
+
const stem = dot > 0 ? target.slice(0, dot) : target
|
|
196
|
+
const ext = dot > 0 ? target.slice(dot) : ""
|
|
197
|
+
for (let i = 1; ; i += 1) {
|
|
198
|
+
const next = `${stem}-${i}${ext}`
|
|
199
|
+
const rebuilt = segments
|
|
200
|
+
.map((seg, idx) => (idx === segIdx ? next : seg))
|
|
201
|
+
.join("/")
|
|
202
|
+
if (collidingSegment(lowerFiles, lowerDirs, rebuilt) < 0) {
|
|
203
|
+
candidate = rebuilt
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`unable to resolve a unique name for ${relPath}`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Index of the segment whose path prefix is taken by a FILE, or whose
|
|
213
|
+
* exact path is taken by a file or directory, or `-1`. Directory
|
|
214
|
+
* ancestors are not collisions (nested entries need them).
|
|
215
|
+
*/
|
|
216
|
+
function collidingSegment(
|
|
217
|
+
lowerFiles: ReadonlySet<string>,
|
|
218
|
+
lowerDirs: ReadonlySet<string>,
|
|
219
|
+
candidate: string,
|
|
220
|
+
): number {
|
|
221
|
+
const segments = candidate.split("/")
|
|
222
|
+
const lower = candidate.toLowerCase()
|
|
223
|
+
if (lowerFiles.has(lower) || lowerDirs.has(lower)) return segments.length - 1
|
|
224
|
+
let prefix = ""
|
|
225
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
226
|
+
prefix = prefix.length > 0 ? `${prefix}/${segments[i]}` : segments[i]!
|
|
227
|
+
if (lowerFiles.has(prefix.toLowerCase())) return i
|
|
228
|
+
}
|
|
229
|
+
return -1
|
|
230
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs"
|
|
2
|
+
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"
|
|
3
|
+
import { basename, extname, join } from "node:path"
|
|
4
|
+
import { pipeline } from "node:stream/promises"
|
|
5
|
+
import type { StoragePaths } from "./paths.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extension used for archive (zip) uploads staged in the global pool.
|
|
9
|
+
* The original filename is ignored — archive uploads are always stored
|
|
10
|
+
* as `<fileId>.zip` so commit / extraction can locate them by `fileId`
|
|
11
|
+
* alone.
|
|
12
|
+
*/
|
|
13
|
+
const STAGED_ARCHIVE_EXT = ".zip"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the on-disk path of a file staged in the global pool by
|
|
17
|
+
* `fileId`. The pool is flat: each file is stored as `<fileId><ext>`
|
|
18
|
+
* where `ext` is the lower-cased extension of the original filename
|
|
19
|
+
* (or `.zip` for archive uploads). Because the extension is part of the
|
|
20
|
+
* filename we scan the directory for a sibling whose stem matches
|
|
21
|
+
* `fileId`.
|
|
22
|
+
*
|
|
23
|
+
* Returns `undefined` when no staged file matches `fileId`.
|
|
24
|
+
*/
|
|
25
|
+
export async function findStagedPoolFile(
|
|
26
|
+
paths: StoragePaths,
|
|
27
|
+
fileId: string,
|
|
28
|
+
): Promise<string | undefined> {
|
|
29
|
+
const poolDir = paths.local.stagingPoolRoot()
|
|
30
|
+
const names = await readdir(poolDir).catch(() => [])
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const ext = extname(name)
|
|
33
|
+
const stem = basename(name, ext)
|
|
34
|
+
if (stem === fileId) return join(poolDir, name)
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve multiple pool files by `fileId` with a SINGLE directory scan.
|
|
41
|
+
* Committing an ordered upload resolves and then consumes every staged
|
|
42
|
+
* file; the per-file {@link findStagedPoolFile} scan would cost one full
|
|
43
|
+
* `readdir` per file, and the pool can hold hundreds of entries under
|
|
44
|
+
* concurrent uploads. Returns a map keyed by `fileId`; ids without a
|
|
45
|
+
* staged match are absent.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveStagedPoolFiles(
|
|
48
|
+
paths: StoragePaths,
|
|
49
|
+
fileIds: readonly string[],
|
|
50
|
+
): Promise<Map<string, string>> {
|
|
51
|
+
const poolDir = paths.local.stagingPoolRoot()
|
|
52
|
+
const names = await readdir(poolDir).catch(() => [])
|
|
53
|
+
const byStem = new Map<string, string>()
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
const ext = extname(name)
|
|
56
|
+
const stem = basename(name, ext)
|
|
57
|
+
if (!byStem.has(stem)) byStem.set(stem, join(poolDir, name))
|
|
58
|
+
}
|
|
59
|
+
const out = new Map<string, string>()
|
|
60
|
+
for (const fileId of fileIds) {
|
|
61
|
+
const hit = byStem.get(fileId)
|
|
62
|
+
if (hit !== undefined) out.set(fileId, hit)
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stream a single file into the global staging pool as
|
|
69
|
+
* `<fileId><ext>`. The file is first written to a `.tmp-*` sibling and
|
|
70
|
+
* atomically renamed so concurrent readers (preview) never observe a
|
|
71
|
+
* partial file. Returns the final staged path.
|
|
72
|
+
*/
|
|
73
|
+
export async function writeStagedPoolFile(
|
|
74
|
+
paths: StoragePaths,
|
|
75
|
+
fileId: string,
|
|
76
|
+
filename: string,
|
|
77
|
+
stream: NodeJS.ReadableStream,
|
|
78
|
+
): Promise<string> {
|
|
79
|
+
await mkdir(paths.local.stagingPoolRoot(), { recursive: true })
|
|
80
|
+
const ext = extname(filename).toLowerCase()
|
|
81
|
+
const dest = paths.local.stagingPoolFile(fileId, ext)
|
|
82
|
+
const tmp = `${dest}.tmp-${Date.now()}`
|
|
83
|
+
try {
|
|
84
|
+
await pipeline(stream, createWriteStream(tmp))
|
|
85
|
+
await rename(tmp, dest)
|
|
86
|
+
return dest
|
|
87
|
+
} catch (err) {
|
|
88
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
89
|
+
throw err
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stream an archive (zip) upload into the global staging pool as
|
|
95
|
+
* `<fileId>.zip`. Same atomic write semantics as
|
|
96
|
+
* {@link writeStagedPoolFile}. Returns the final staged path.
|
|
97
|
+
*/
|
|
98
|
+
export async function writeStagedArchiveFile(
|
|
99
|
+
paths: StoragePaths,
|
|
100
|
+
fileId: string,
|
|
101
|
+
stream: NodeJS.ReadableStream,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
return writeStagedPoolFile(paths, fileId, "archive.zip", stream)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve the staged path of an archive upload by `fileId`. */
|
|
107
|
+
export async function findStagedArchiveFile(
|
|
108
|
+
paths: StoragePaths,
|
|
109
|
+
fileId: string,
|
|
110
|
+
): Promise<string | undefined> {
|
|
111
|
+
const existing = await findStagedPoolFile(paths, fileId)
|
|
112
|
+
if (existing === undefined) return undefined
|
|
113
|
+
// Archives are always stored with the `.zip` extension.
|
|
114
|
+
return extname(existing).toLowerCase() === STAGED_ARCHIVE_EXT
|
|
115
|
+
? existing
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove a single file from the global staging pool. Returns `true` when
|
|
121
|
+
* a file was removed, `false` when `fileId` was not present.
|
|
122
|
+
*/
|
|
123
|
+
export async function removeStagedPoolFile(
|
|
124
|
+
paths: StoragePaths,
|
|
125
|
+
fileId: string,
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
const existing = await findStagedPoolFile(paths, fileId)
|
|
128
|
+
if (existing === undefined) return false
|
|
129
|
+
await rm(existing, { force: true }).catch(() => {})
|
|
130
|
+
// Best-effort: the file may have been removed concurrently.
|
|
131
|
+
const stillThere = await stat(existing).catch(() => undefined)
|
|
132
|
+
return stillThere === undefined
|
|
133
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs"
|
|
9
|
+
import { tmpdir } from "node:os"
|
|
10
|
+
import { join } from "node:path"
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest"
|
|
12
|
+
import { DomainError } from "../errors.ts"
|
|
13
|
+
import {
|
|
14
|
+
createNextVersion,
|
|
15
|
+
currentVersion,
|
|
16
|
+
ensureBootstrapVersion,
|
|
17
|
+
listVersions,
|
|
18
|
+
readActiveVersion,
|
|
19
|
+
writeActiveVersion,
|
|
20
|
+
} from "./version.ts"
|
|
21
|
+
|
|
22
|
+
describe("listVersions", () => {
|
|
23
|
+
let root: string
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
root = mkdtempSync(join(tmpdir(), "ver-list-"))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
rmSync(root, { recursive: true, force: true })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("empty directory returns empty array", () => {
|
|
34
|
+
expect(listVersions(root)).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("ignores non-numeric directory names", () => {
|
|
38
|
+
mkdirSync(join(root, "versions", "1"), { recursive: true })
|
|
39
|
+
mkdirSync(join(root, "versions", "foo"), { recursive: true })
|
|
40
|
+
mkdirSync(join(root, "versions", ".hidden"), { recursive: true })
|
|
41
|
+
mkdirSync(join(root, "versions", "01"), { recursive: true })
|
|
42
|
+
expect(listVersions(root)).toEqual([1])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("returns sorted ascending", () => {
|
|
46
|
+
for (const v of [3, 1, 10, 2]) {
|
|
47
|
+
mkdirSync(join(root, "versions", String(v)), { recursive: true })
|
|
48
|
+
}
|
|
49
|
+
expect(listVersions(root)).toEqual([1, 2, 3, 10])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("currentVersion", () => {
|
|
54
|
+
let root: string
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
root = mkdtempSync(join(tmpdir(), "ver-cur-"))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rmSync(root, { recursive: true, force: true })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("returns 0 when no versions exist", () => {
|
|
65
|
+
expect(currentVersion(root)).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("returns max version number", () => {
|
|
69
|
+
mkdirSync(join(root, "versions", "1"), { recursive: true })
|
|
70
|
+
mkdirSync(join(root, "versions", "5"), { recursive: true })
|
|
71
|
+
expect(currentVersion(root)).toBe(5)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe("ensureBootstrapVersion", () => {
|
|
76
|
+
let root: string
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
root = mkdtempSync(join(tmpdir(), "ver-boot-"))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
rmSync(root, { recursive: true, force: true })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("creates versions/1 on empty storage", () => {
|
|
87
|
+
const v = ensureBootstrapVersion(root)
|
|
88
|
+
expect(v).toBe(1)
|
|
89
|
+
expect(existsSync(join(root, "versions", "1"))).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test("is a no-op when any version already exists", () => {
|
|
93
|
+
mkdirSync(join(root, "versions", "3"), { recursive: true })
|
|
94
|
+
const v = ensureBootstrapVersion(root)
|
|
95
|
+
expect(v).toBe(3)
|
|
96
|
+
expect(existsSync(join(root, "versions", "1"))).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("readActiveVersion", () => {
|
|
101
|
+
let root: string
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
root = mkdtempSync(join(tmpdir(), "ver-read-"))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
rmSync(root, { recursive: true, force: true })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("falls back to current when state file is missing", () => {
|
|
112
|
+
mkdirSync(join(root, "versions", "2"), { recursive: true })
|
|
113
|
+
expect(readActiveVersion(root)).toBe(2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("returns persisted active when it exists", () => {
|
|
117
|
+
mkdirSync(join(root, "versions", "1"), { recursive: true })
|
|
118
|
+
mkdirSync(join(root, "versions", "2"), { recursive: true })
|
|
119
|
+
mkdirSync(join(root, "local"), { recursive: true })
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(root, "local", "version-state.json"),
|
|
122
|
+
JSON.stringify({ active: 1 }),
|
|
123
|
+
)
|
|
124
|
+
expect(readActiveVersion(root)).toBe(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("falls back to current when state points at missing version", () => {
|
|
128
|
+
mkdirSync(join(root, "versions", "2"), { recursive: true })
|
|
129
|
+
mkdirSync(join(root, "local"), { recursive: true })
|
|
130
|
+
writeFileSync(
|
|
131
|
+
join(root, "local", "version-state.json"),
|
|
132
|
+
JSON.stringify({ active: 99 }),
|
|
133
|
+
)
|
|
134
|
+
expect(readActiveVersion(root)).toBe(2)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("falls back to current when state file is malformed", () => {
|
|
138
|
+
mkdirSync(join(root, "versions", "3"), { recursive: true })
|
|
139
|
+
mkdirSync(join(root, "local"), { recursive: true })
|
|
140
|
+
writeFileSync(join(root, "local", "version-state.json"), "not json")
|
|
141
|
+
expect(readActiveVersion(root)).toBe(3)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("writeActiveVersion", () => {
|
|
146
|
+
let root: string
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
root = mkdtempSync(join(tmpdir(), "ver-write-"))
|
|
150
|
+
mkdirSync(join(root, "versions", "1"), { recursive: true })
|
|
151
|
+
mkdirSync(join(root, "versions", "2"), { recursive: true })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
rmSync(root, { recursive: true, force: true })
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("persists valid active version", () => {
|
|
159
|
+
writeActiveVersion(root, 1)
|
|
160
|
+
const raw = readFileSync(join(root, "local", "version-state.json"), "utf8")
|
|
161
|
+
expect(JSON.parse(raw)).toEqual({ active: 1 })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("rejects unknown version", () => {
|
|
165
|
+
try {
|
|
166
|
+
writeActiveVersion(root, 99)
|
|
167
|
+
expect.unreachable("should have thrown")
|
|
168
|
+
} catch (err) {
|
|
169
|
+
expect(err).toBeInstanceOf(DomainError)
|
|
170
|
+
expect((err as DomainError).kind).toBe("version.not_found")
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("createNextVersion", () => {
|
|
176
|
+
let root: string
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
root = mkdtempSync(join(tmpdir(), "ver-next-"))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
rmSync(root, { recursive: true, force: true })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("throws when no version exists yet", () => {
|
|
187
|
+
try {
|
|
188
|
+
createNextVersion(root, () => {})
|
|
189
|
+
expect.unreachable("should have thrown")
|
|
190
|
+
} catch (err) {
|
|
191
|
+
expect(err).toBeInstanceOf(DomainError)
|
|
192
|
+
expect((err as DomainError).kind).toBe("version.bootstrap_required")
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("snapshots the live DB into versions/<prev>/app.sqlite", () => {
|
|
197
|
+
// Bootstrap version 1, then have the injected vacuum write a marker
|
|
198
|
+
// file — the primitive's job is the destination, not the sqlite
|
|
199
|
+
// snapshot (the server's DbHandles.vacuumInto owns that).
|
|
200
|
+
ensureBootstrapVersion(root)
|
|
201
|
+
const result = createNextVersion(root, (dest) => {
|
|
202
|
+
writeFileSync(dest, "snapshot")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(result.previous).toBe(1)
|
|
206
|
+
expect(result.created).toBe(2)
|
|
207
|
+
|
|
208
|
+
const snapshotPath = join(root, "versions", "1", "app.sqlite")
|
|
209
|
+
expect(existsSync(snapshotPath)).toBe(true)
|
|
210
|
+
expect(readFileSync(snapshotPath, "utf8")).toBe("snapshot")
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("throws when snapshot already exists", () => {
|
|
214
|
+
ensureBootstrapVersion(root)
|
|
215
|
+
|
|
216
|
+
// First call succeeds
|
|
217
|
+
createNextVersion(root, (dest) => {
|
|
218
|
+
writeFileSync(dest, "snapshot")
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// After first publish, current version becomes 2; the next snapshot
|
|
222
|
+
// target would be versions/2/app.sqlite. Pre-create it to trigger
|
|
223
|
+
// the already_exists guard.
|
|
224
|
+
mkdirSync(join(root, "versions", "2"), { recursive: true })
|
|
225
|
+
writeFileSync(join(root, "versions", "2", "app.sqlite"), "")
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
createNextVersion(root, () => {
|
|
229
|
+
expect.unreachable("vacuumInto should not be called")
|
|
230
|
+
})
|
|
231
|
+
expect.unreachable("should have thrown")
|
|
232
|
+
} catch (err) {
|
|
233
|
+
expect(err).toBeInstanceOf(DomainError)
|
|
234
|
+
expect((err as DomainError).kind).toBe("version.already_exists")
|
|
235
|
+
}
|
|
236
|
+
expect(existsSync(join(root, "versions", "3"))).toBe(false)
|
|
237
|
+
expect(currentVersion(root)).toBe(2)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("copies installed plugins into the next version", () => {
|
|
241
|
+
ensureBootstrapVersion(root)
|
|
242
|
+
const pluginId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
243
|
+
const src = join(root, "versions", "1", "plugins", pluginId)
|
|
244
|
+
mkdirSync(src, { recursive: true })
|
|
245
|
+
writeFileSync(join(src, "manifest.json"), JSON.stringify({ id: pluginId }))
|
|
246
|
+
writeFileSync(join(src, "main.js"), "export default {}\n")
|
|
247
|
+
mkdirSync(join(root, "versions", "1", "plugins", ".staging-x"), {
|
|
248
|
+
recursive: true,
|
|
249
|
+
})
|
|
250
|
+
mkdirSync(join(root, "versions", "1", "plugins", "not-a-plugin"), {
|
|
251
|
+
recursive: true,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
createNextVersion(root, (dest) => {
|
|
255
|
+
writeFileSync(dest, "snapshot")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
expect(
|
|
259
|
+
existsSync(join(root, "versions", "2", "plugins", pluginId, "main.js")),
|
|
260
|
+
).toBe(true)
|
|
261
|
+
expect(
|
|
262
|
+
existsSync(join(root, "versions", "1", "plugins", pluginId, "main.js")),
|
|
263
|
+
).toBe(true)
|
|
264
|
+
expect(
|
|
265
|
+
existsSync(join(root, "versions", "2", "plugins", ".staging-x")),
|
|
266
|
+
).toBe(false)
|
|
267
|
+
expect(
|
|
268
|
+
existsSync(join(root, "versions", "2", "plugins", "not-a-plugin")),
|
|
269
|
+
).toBe(false)
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test("removes the next version directory when vacuumInto throws", () => {
|
|
273
|
+
ensureBootstrapVersion(root)
|
|
274
|
+
try {
|
|
275
|
+
createNextVersion(root, () => {
|
|
276
|
+
throw new Error("vacuum failed")
|
|
277
|
+
})
|
|
278
|
+
expect.unreachable("should have thrown")
|
|
279
|
+
} catch (err) {
|
|
280
|
+
expect((err as Error).message).toBe("vacuum failed")
|
|
281
|
+
}
|
|
282
|
+
expect(existsSync(join(root, "versions", "2"))).toBe(false)
|
|
283
|
+
expect(currentVersion(root)).toBe(1)
|
|
284
|
+
})
|
|
285
|
+
})
|