@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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. 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
+ })