@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,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
+ }