@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,754 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whole-archive extraction. Every supported format (zip, tar, 7z, rar,
|
|
5
|
+
* xz, gzip) extracts through 7-Zip with listing-validated budgets; when
|
|
6
|
+
* the optional 7-Zip binary is absent, zip falls back to a yauzl
|
|
7
|
+
* streaming path with a per-byte budget counter. Used by plugin
|
|
8
|
+
* installation, folder-import extraction and the plugin extract API —
|
|
9
|
+
* the workflows that need a directory layout.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Extraction progress reporter for {@link extractArchiveInto}. The
|
|
13
|
+
* 7-Zip path reports the two phases only (listing pre-check, done);
|
|
14
|
+
* the yauzl fallback reports per-entry events.
|
|
15
|
+
*/
|
|
16
|
+
type ZipExtractReporter = (event: ZipExtractReport) => void;
|
|
17
|
+
type ZipExtractReport = {
|
|
18
|
+
readonly phase: "started";
|
|
19
|
+
readonly totalEntries: number;
|
|
20
|
+
readonly totalBytes: number;
|
|
21
|
+
} | {
|
|
22
|
+
readonly phase: "entry";
|
|
23
|
+
readonly entriesDone: number;
|
|
24
|
+
readonly totalEntries: number;
|
|
25
|
+
readonly bytesDone: number;
|
|
26
|
+
readonly totalBytes: number;
|
|
27
|
+
} | {
|
|
28
|
+
readonly phase: "done";
|
|
29
|
+
};
|
|
30
|
+
type ExtractArchiveOptions = {
|
|
31
|
+
/** Cumulative uncompressed byte budget for the whole archive. */
|
|
32
|
+
readonly maxBytes: number;
|
|
33
|
+
/** Optional entry-count budget (enforced on the listing). */
|
|
34
|
+
readonly maxEntries?: number;
|
|
35
|
+
readonly onProgress?: ZipExtractReporter;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Stream an archive into `destDir`, format-agnostic: every supported
|
|
39
|
+
* format (zip/tar/7z/rar/xz/gzip) extracts through 7-Zip after
|
|
40
|
+
* validating budgets, encryption and paths from its listing, then
|
|
41
|
+
* re-verifies the extracted tree (size + symlink scan). Without a
|
|
42
|
+
* 7-Zip binary, zip extracts through yauzl with a per-byte budget
|
|
43
|
+
* counter; the other formats error with "7-Zip is not installed".
|
|
44
|
+
* Used by plugin installation and folder-import extraction (the
|
|
45
|
+
* workflows that need a directory layout).
|
|
46
|
+
*
|
|
47
|
+
* Refuses any entry whose normalised path escapes `destDir` (zip-slip)
|
|
48
|
+
* or contains an absolute path / drive letter. Defends against zip
|
|
49
|
+
* bombs via `maxBytes` (and `maxEntries` when provided).
|
|
50
|
+
*
|
|
51
|
+
* @throws DomainError VALIDATION when the archive is malformed,
|
|
52
|
+
* contains an unsafe entry, exceeds the budgets, is encrypted, or is
|
|
53
|
+
* not a supported archive at all.
|
|
54
|
+
*/
|
|
55
|
+
declare function extractArchiveInto(source: NodeJS.ReadableStream, destDir: string, opts: ExtractArchiveOptions): Promise<void>;
|
|
56
|
+
|
|
57
|
+
/** A unified, format-agnostic inner entry. */
|
|
58
|
+
type ArchiveEntry = {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly sizeBytes: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Cross-engine archive listing and budget validation. Zip lists through
|
|
65
|
+
* yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary; the engine choice
|
|
66
|
+
* (and the zip-only legacy-cp437 name decoding, handled inside the 7-Zip
|
|
67
|
+
* wrapper) lives here so callers never branch on format.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* List the file entries of an on-disk archive across engines: zip lists
|
|
71
|
+
* through yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary (gzip
|
|
72
|
+
* reports its single decompressed stream).
|
|
73
|
+
*
|
|
74
|
+
* @throws DomainError `resource.archive_open_failed` when the file is
|
|
75
|
+
* not a readable archive, or 7-Zip is missing for a non-zip format.
|
|
76
|
+
*/
|
|
77
|
+
declare function listArchiveEntries(archivePath: string): Promise<readonly ArchiveEntry[]>;
|
|
78
|
+
/**
|
|
79
|
+
* Validate an on-disk archive's cumulative uncompressed size against
|
|
80
|
+
* `maxBytes` without inflating any entry bytes (metadata-only — the
|
|
81
|
+
* listing never decompresses). Format-agnostic via
|
|
82
|
+
* {@link listArchiveEntries}; unknown content and non-zip formats
|
|
83
|
+
* without the 7-Zip binary are rejected outright.
|
|
84
|
+
*
|
|
85
|
+
* Used at commit time for uploaded archives so every supported format
|
|
86
|
+
* passes the same bomb gate.
|
|
87
|
+
*
|
|
88
|
+
* @throws DomainError `resource.archive_open_failed` when the file is
|
|
89
|
+
* not a readable archive (or 7-Zip is missing for non-zip formats).
|
|
90
|
+
* @throws DomainError `resource.archive_too_large` when the cumulative
|
|
91
|
+
* uncompressed size exceeds `maxBytes`.
|
|
92
|
+
*/
|
|
93
|
+
declare function validateArchiveBudget(archivePath: string, maxBytes: number): Promise<void>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* On-the-fly zip exports. Resource sources are stored as bare files on
|
|
97
|
+
* disk — nothing is packed at commit time; this module streams STORED
|
|
98
|
+
* zip bytes straight to the consumer without a staging file.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* A logical zip entry whose bytes are produced on demand. Streams are
|
|
102
|
+
* read once, in order, when the output is consumed.
|
|
103
|
+
*/
|
|
104
|
+
type ZipStreamEntry = {
|
|
105
|
+
readonly name: string;
|
|
106
|
+
readonly size: number;
|
|
107
|
+
readonly openStream: () => Readable;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Stream a STORED zip from an ordered list of logical entries. Used by
|
|
111
|
+
* the HTTP layer for exports (resource source packs, bulk downloads)
|
|
112
|
+
* without a staging file on disk. Zero-length entries are packed as
|
|
113
|
+
* empty buffers.
|
|
114
|
+
*/
|
|
115
|
+
declare function streamStoredZip(entries: readonly ZipStreamEntry[]): NodeJS.ReadableStream;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Shared directory-size walk: the one implementation of "recursive byte
|
|
119
|
+
* size of a directory tree" for host storage. Consumers opt into
|
|
120
|
+
* different file policies via options:
|
|
121
|
+
*
|
|
122
|
+
* - vault totals skip dot-prefixed files (host temp staging), while
|
|
123
|
+
* storage totals count everything — see {@link vaultTotalSize} and
|
|
124
|
+
* `apps/server/src/domain/storage/service.ts`.
|
|
125
|
+
*
|
|
126
|
+
* Symbolic links are skipped so cycles cannot hang the walk and
|
|
127
|
+
* linked-out files are not double counted; missing/transient entries
|
|
128
|
+
* are ignored.
|
|
129
|
+
*/
|
|
130
|
+
type DirSizeOptions = {
|
|
131
|
+
/** Skip file names starting with a dot (host temp staging). */
|
|
132
|
+
readonly excludeDotPrefix?: boolean;
|
|
133
|
+
};
|
|
134
|
+
declare function sumDirSizes(root: string, opts?: DirSizeOptions): Promise<number>;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Explicit file order for a resource's content root (`data/`). Uploads
|
|
138
|
+
* install entries in the user's chosen order and persist that sequence
|
|
139
|
+
* in this manifest so the display order matches the upload order even
|
|
140
|
+
* when filenames sort differently. Written at commit time; because it
|
|
141
|
+
* lives inside the replaced `data/` subtree it is always consistent
|
|
142
|
+
* with the entries around it.
|
|
143
|
+
*
|
|
144
|
+
* The manifest is a JSON array of relative entry names (`/`-separated),
|
|
145
|
+
* listing the final installed names (after collision suffixing). Reads
|
|
146
|
+
* are strict: a malformed manifest, or one whose entries do not all
|
|
147
|
+
* resolve against the actual listing, is ignored entirely and callers
|
|
148
|
+
* fall back to the natural name sort. Strictness matters because
|
|
149
|
+
* `createDirectoryContainer` also serves arbitrary user directories
|
|
150
|
+
* (folder import, plugin dev) that may coincidentally contain a file
|
|
151
|
+
* named `.order`.
|
|
152
|
+
*/
|
|
153
|
+
declare const ORDER_MANIFEST_NAME = ".order";
|
|
154
|
+
/** Path of the order manifest inside a resource content root. */
|
|
155
|
+
declare function orderManifestPath(dataDir: string): string;
|
|
156
|
+
/**
|
|
157
|
+
* Atomically persist `names` as the manifest of `dataDir` (tmp + rename,
|
|
158
|
+
* so a concurrent reader never observes a half-written file).
|
|
159
|
+
*/
|
|
160
|
+
declare function writeOrderManifest(dataDir: string, names: readonly string[]): Promise<void>;
|
|
161
|
+
/**
|
|
162
|
+
* Read the manifest of `dataDir`. Resolves to `undefined` when the
|
|
163
|
+
* manifest is absent or fails structural validation
|
|
164
|
+
* ({@link parseOrderManifest}) — callers fall back to natural order.
|
|
165
|
+
*/
|
|
166
|
+
declare function readOrderManifest(dataDir: string): Promise<readonly string[] | undefined>;
|
|
167
|
+
/**
|
|
168
|
+
* Structural validation of a raw manifest body: a JSON array of
|
|
169
|
+
* non-empty relative paths with `/` separators that cannot escape the
|
|
170
|
+
* content root. Returns `undefined` when the body is not a usable
|
|
171
|
+
* manifest. Semantic validation (entries exist in the listing) happens
|
|
172
|
+
* in {@link orderEntries}.
|
|
173
|
+
*/
|
|
174
|
+
declare function parseOrderManifest(raw: string): readonly string[] | undefined;
|
|
175
|
+
/**
|
|
176
|
+
* Reorder `listed` by the manifest: entries named by the manifest come
|
|
177
|
+
* first, in manifest order; entries the manifest does not mention are
|
|
178
|
+
* appended in natural order. Returns `undefined` when the manifest
|
|
179
|
+
* cannot be applied against this listing (some manifest entry is
|
|
180
|
+
* missing — the strict trust rule), so the caller falls back to the
|
|
181
|
+
* natural sort.
|
|
182
|
+
*/
|
|
183
|
+
declare function orderEntries(manifest: readonly string[], listed: readonly string[]): readonly string[] | undefined;
|
|
184
|
+
/** Case-insensitive natural name sort (`1 < 2 < 10`, `a < b`). */
|
|
185
|
+
declare function naturalSort(names: readonly string[]): readonly string[];
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Name of the content subfolder inside a resource folder. User entries
|
|
189
|
+
* live under `resources/<id>/data/`; metadata dotfiles (`.cover.*`,
|
|
190
|
+
* `.deleted`, `.order`) stay at the resource root. Content and metadata
|
|
191
|
+
* are replaced/kept independently: commits swap the whole `data/`
|
|
192
|
+
* subtree, while root dotfiles survive re-uploads.
|
|
193
|
+
*/
|
|
194
|
+
declare const RESOURCE_DATA_DIR_NAME = "data";
|
|
195
|
+
/**
|
|
196
|
+
* Root-level subdirectory semantics:
|
|
197
|
+
* - `versions/<version>/` is the user's manual-sync scope, partitioned by
|
|
198
|
+
* archive version. Each version folder holds: `app.sqlite` (the
|
|
199
|
+
* per-version database snapshot), `db-backups/` (manual backups,
|
|
200
|
+
* only kept for the current version), `snapshots/` (automatic daily
|
|
201
|
+
* snapshots, only kept for the current version), `resources/<id>/`,
|
|
202
|
+
* `characters/<id>/`, `plugins/<id>/` (installed content plugins
|
|
203
|
+
* frozen with that version; the builtin `file` plugin is not stored
|
|
204
|
+
* here). Old versions are FROZEN: no writes ever land in
|
|
205
|
+
* `versions/<v>` once a `versions/<v+1>` exists.
|
|
206
|
+
* - `local` holds host-only state. Derived caches (thumbs, previews,
|
|
207
|
+
* extraction caches, tmp, read-only DB clones for past-version
|
|
208
|
+
* viewing) live under `local/cache/` and are wiped wholesale by
|
|
209
|
+
* clear cache; the rest (`logs`, `trash`, session key, upload
|
|
210
|
+
* staging) is persistent. It never leaves the host.
|
|
211
|
+
*
|
|
212
|
+
* `paths.active` resolves to the **active** version (which may be the
|
|
213
|
+
* latest version, or a past version when the user is viewing
|
|
214
|
+
* read-only). `paths.latest` always points at the latest version --
|
|
215
|
+
* the only version writers may target. `paths.atVersion(v)` exposes
|
|
216
|
+
* arbitrary cross-version reads (used for character avatar/fullbody
|
|
217
|
+
* fallback once `avatarVersion` / `fullbodyVersion` columns point at a
|
|
218
|
+
* historical archive).
|
|
219
|
+
*
|
|
220
|
+
* Every resolved path MUST come out of this module so the frozen-version
|
|
221
|
+
* boundary stays enforceable.
|
|
222
|
+
*/
|
|
223
|
+
type StoragePaths = {
|
|
224
|
+
readonly root: string;
|
|
225
|
+
readonly activeVersion: number;
|
|
226
|
+
readonly latestVersion: number;
|
|
227
|
+
readonly active: VersionPaths;
|
|
228
|
+
readonly latest: VersionPaths;
|
|
229
|
+
readonly local: LocalPaths;
|
|
230
|
+
/** Per-version archive paths. Use for cross-version fallback reads. */
|
|
231
|
+
atVersion(v: number): VersionPaths;
|
|
232
|
+
/**
|
|
233
|
+
* Path to the live runtime SQLite DB: `<root>/app.sqlite`.
|
|
234
|
+
* This file is the only writable database during normal operation.
|
|
235
|
+
* It lives outside `versions/` so that syncing `versions/` to other
|
|
236
|
+
* devices cannot corrupt the in-use database. Only archived snapshots
|
|
237
|
+
* (written by {@link createNextVersion}) and backup files belong in
|
|
238
|
+
* `versions/`.
|
|
239
|
+
*/
|
|
240
|
+
runtimeDb(): string;
|
|
241
|
+
};
|
|
242
|
+
type VersionPaths = {
|
|
243
|
+
readonly root: string;
|
|
244
|
+
readonly version: number;
|
|
245
|
+
/** Path to the per-version SQLite DB: `<root>/versions/<v>/app.sqlite`. */
|
|
246
|
+
versionSnapshotDb(): string;
|
|
247
|
+
/** Root folder of a resource: `<root>/versions/<v>/resources/<id>`. */
|
|
248
|
+
resource(id: string): string;
|
|
249
|
+
/**
|
|
250
|
+
* Content root of a resource — where user entries live:
|
|
251
|
+
* `<root>/versions/<v>/resources/<id>/data`. The container reads
|
|
252
|
+
* entries from here; the resource root only holds metadata dotfiles.
|
|
253
|
+
*/
|
|
254
|
+
resourceData(id: string): string;
|
|
255
|
+
/** Root folder of all resources in this version: `<root>/versions/<v>/resources`. */
|
|
256
|
+
resources(): string;
|
|
257
|
+
/** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
|
|
258
|
+
characters(): string;
|
|
259
|
+
/** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
|
|
260
|
+
documents(): string;
|
|
261
|
+
/** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
|
|
262
|
+
character(id: string): string;
|
|
263
|
+
/** Root of manual backups: `<root>/versions/<v>/db-backups`. */
|
|
264
|
+
dbBackups(): string;
|
|
265
|
+
/** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
|
|
266
|
+
dbBackup(name: string): string;
|
|
267
|
+
/** Root of automatic snapshots: `<root>/versions/<v>/snapshots`. */
|
|
268
|
+
snapshots(): string;
|
|
269
|
+
/** Path to one automatic snapshot: `<root>/versions/<v>/snapshots/<name>`. */
|
|
270
|
+
snapshot(name: string): string;
|
|
271
|
+
/**
|
|
272
|
+
* Path to a deleted-entity placeholder
|
|
273
|
+
* (`<root>/versions/<v>/<kind>/<id>/.deleted`) written when hard
|
|
274
|
+
* delete cannot remove a folder whose files live under frozen past
|
|
275
|
+
* archives.
|
|
276
|
+
*/
|
|
277
|
+
deletedMarker(kind: "resources" | "characters", id: string): string;
|
|
278
|
+
/** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
|
|
279
|
+
document(id: string): string;
|
|
280
|
+
/**
|
|
281
|
+
* Installed content plugins for this version:
|
|
282
|
+
* `<root>/versions/<v>/plugins`. Each subdirectory is named by
|
|
283
|
+
* `manifest.id`. The builtin `file` plugin is not stored here.
|
|
284
|
+
*/
|
|
285
|
+
plugins(): string;
|
|
286
|
+
/**
|
|
287
|
+
* Host-managed plugin asset vault of an installed plugin:
|
|
288
|
+
* `<root>/versions/<v>/plugins/<id>/vault`. Downloads land here
|
|
289
|
+
* (user-consented, vault-confined); the directory is host-owned,
|
|
290
|
+
* reserved against plugin zips (upload rejects a shipped `vault/`),
|
|
291
|
+
* and its lifecycle follows the plugin's own directory.
|
|
292
|
+
*/
|
|
293
|
+
pluginVaultDir(id: string): string;
|
|
294
|
+
};
|
|
295
|
+
type LocalPaths = {
|
|
296
|
+
/** The local (non-synced) root: `<root>/local`. */
|
|
297
|
+
readonly root: string;
|
|
298
|
+
/** Derived cache root: `<localRoot>/cache`. */
|
|
299
|
+
cache(): string;
|
|
300
|
+
/** Server logs: `<localRoot>/logs`. */
|
|
301
|
+
logs(): string;
|
|
302
|
+
/**
|
|
303
|
+
* Path to a local derived cover/thumb variant:
|
|
304
|
+
* `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
|
|
305
|
+
* Holds synthesized covers (resource covers, character avatars and
|
|
306
|
+
* fullbody images); re-rendered when cleared.
|
|
307
|
+
*/
|
|
308
|
+
localCover(subjectKind: "resource" | "character", id: string, variant: string, format?: string): string;
|
|
309
|
+
/**
|
|
310
|
+
* Per-file derived image variant:
|
|
311
|
+
* `<localRoot>/cache/resources/<id>/file-preview/<sourceCacheId>__<variantKey>.<format>`.
|
|
312
|
+
* The source identity (see {@link sourceCacheId}) is collision-free
|
|
313
|
+
* across nested paths; the variant key (see {@link imageVariantKey})
|
|
314
|
+
* encodes the render spec, so distinct specs never collide on one
|
|
315
|
+
* cache file.
|
|
316
|
+
*/
|
|
317
|
+
resFileVariant(id: string, filename: string, variantKey: string, format?: string): string;
|
|
318
|
+
/** Directory holding a resource's per-file variants. */
|
|
319
|
+
resFilePreviewDir(id: string): string;
|
|
320
|
+
/** Resource file-list sidecar: `<localRoot>/cache/resources/<id>/files-cache.json`. */
|
|
321
|
+
resFilesCache(id: string): string;
|
|
322
|
+
/**
|
|
323
|
+
* Root of the local per-resource directory:
|
|
324
|
+
* `<localRoot>/cache/resources/<id>`.
|
|
325
|
+
*/
|
|
326
|
+
resource(id: string): string;
|
|
327
|
+
/**
|
|
328
|
+
* Root of the local per-character directory:
|
|
329
|
+
* `<localRoot>/cache/characters/<id>`.
|
|
330
|
+
* Holds (a) versioned copies of replaced avatar / fullbody images and
|
|
331
|
+
* (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
|
|
332
|
+
*/
|
|
333
|
+
character(id: string): string;
|
|
334
|
+
/** Root of the trash: `<localRoot>/trash`. */
|
|
335
|
+
trash(): string;
|
|
336
|
+
/** Path to a single trashed item: `<localRoot>/trash/<id>`. */
|
|
337
|
+
trashItem(id: string): string;
|
|
338
|
+
/**
|
|
339
|
+
* Root of the local temp directory: `<localRoot>/cache/tmp`. Holds
|
|
340
|
+
* short-lived working files (upload buffers, backup markers, read-only
|
|
341
|
+
* `view-<v>.sqlite` clones). Lives under the cache root so clear cache
|
|
342
|
+
* wipes it together with the other derived data; it is also cleaned on
|
|
343
|
+
* server startup.
|
|
344
|
+
*/
|
|
345
|
+
tmp(): string;
|
|
346
|
+
/** Path to a single temp file: `<localRoot>/cache/tmp/<name>`. */
|
|
347
|
+
tmpFile(name: string): string;
|
|
348
|
+
/**
|
|
349
|
+
* Path to the iron-session seal key file: `<root>/local/.session-key`.
|
|
350
|
+
* 32-byte base64-encoded secret, auto-generated on first boot. Lives in
|
|
351
|
+
* `local/` (never synced) so each host has its own seal key.
|
|
352
|
+
*/
|
|
353
|
+
sessionKey(): string;
|
|
354
|
+
/**
|
|
355
|
+
* Root of the host-only temporary directory tree:
|
|
356
|
+
* `<localRoot>/.tmp`. Holds the global staging pool
|
|
357
|
+
* ({@link stagingPoolRoot}) plus short-lived extraction directories
|
|
358
|
+
* (`extract-*`). The leading dot keeps host-only state out of any
|
|
359
|
+
* user-facing listing. Cleared on server startup together with
|
|
360
|
+
* {@link LocalPaths.tmp}.
|
|
361
|
+
*/
|
|
362
|
+
uploadStagingRoot(): string;
|
|
363
|
+
/**
|
|
364
|
+
* Root of the global staging pool: `<localRoot>/.tmp/staging`.
|
|
365
|
+
* Every file uploaded through the per-file upload endpoint lands
|
|
366
|
+
* here as `<fileId><ext>` and is addressed by its `fileId` alone —
|
|
367
|
+
* there is no per-batch grouping. Files are removed individually
|
|
368
|
+
* on client delete or consumed (and deleted) by commit at resource
|
|
369
|
+
* creation. Cleared on startup together with
|
|
370
|
+
* {@link LocalPaths.uploadStagingRoot}.
|
|
371
|
+
*/
|
|
372
|
+
stagingPoolRoot(): string;
|
|
373
|
+
/**
|
|
374
|
+
* Path of a single staged file in the global pool:
|
|
375
|
+
* `<stagingPoolRoot>/<fileId><ext>`. `ext` is the lower-cased
|
|
376
|
+
* extension of the original filename (empty for extensionless
|
|
377
|
+
* uploads).
|
|
378
|
+
*/
|
|
379
|
+
stagingPoolFile(fileId: string, ext: string): string;
|
|
380
|
+
/**
|
|
381
|
+
* Per-video-frame thumbnail cache:
|
|
382
|
+
* `<localRoot>/cache/resources/<id>/frames/<sourceCacheId>/<timeMs>.avif`.
|
|
383
|
+
* Synthesised on-demand by the video hover preview endpoint.
|
|
384
|
+
*/
|
|
385
|
+
resVideoFrame(id: string, filename: string, timeMs: number): string;
|
|
386
|
+
/**
|
|
387
|
+
* Root of persisted zip-entry extractions for a resource version:
|
|
388
|
+
* `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/`.
|
|
389
|
+
*/
|
|
390
|
+
resExtractedDir(id: string, fileVersion: number): string;
|
|
391
|
+
/**
|
|
392
|
+
* On-disk path for a materialized zip entry used by probe/ffmpeg
|
|
393
|
+
* paths: `<localRoot>/cache/resources/<id>/extracted/v<v>/<sourceCacheId>`.
|
|
394
|
+
*/
|
|
395
|
+
resExtractedEntry(id: string, fileVersion: number, entryName: string): string;
|
|
396
|
+
/**
|
|
397
|
+
* Plugin container extraction root for a resource version:
|
|
398
|
+
* `<localRoot>/cache/resources/<id>/extracted/v<fileVersion>/archives`.
|
|
399
|
+
* `extractArchive` materializes each archive into its own
|
|
400
|
+
* subdirectory here (`archives/<archiveName>/<innerPath>`), served to
|
|
401
|
+
* the browser via the `/extracted/` route.
|
|
402
|
+
*/
|
|
403
|
+
resExtractedArchivesDir(id: string, fileVersion: number): string;
|
|
404
|
+
};
|
|
405
|
+
type CreateStoragePathsOptions = {
|
|
406
|
+
readonly root: string;
|
|
407
|
+
/**
|
|
408
|
+
* Active (viewing) version. When omitted together with latestVersion,
|
|
409
|
+
* resolves from `local/version-state.json` and the version dirs under
|
|
410
|
+
* `versions/`. When only latestVersion is pinned explicitly, defaults to
|
|
411
|
+
* that same value (call sites that override max version only).
|
|
412
|
+
*/
|
|
413
|
+
readonly activeVersion?: number;
|
|
414
|
+
/**
|
|
415
|
+
* Latest (current, writable) version. When omitted, the maximum
|
|
416
|
+
* version directory under `versions/` is used, or `1` when none exist.
|
|
417
|
+
*/
|
|
418
|
+
readonly latestVersion?: number;
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* Build a {@link StoragePaths} rooted at `opts.root`. The root must be an
|
|
422
|
+
* absolute path (the sync boundary would not be well-defined otherwise).
|
|
423
|
+
*
|
|
424
|
+
* @throws `Error` when `root` is not absolute.
|
|
425
|
+
*/
|
|
426
|
+
declare function createStoragePaths(opts: CreateStoragePathsOptions): StoragePaths;
|
|
427
|
+
/**
|
|
428
|
+
* Short, stable cache identity for one derived image variant: the
|
|
429
|
+
* sha256 of the spec's canonical string (see
|
|
430
|
+
* `imageVariantCanonical` in `@hoardodile/sdk-types/image-variant`),
|
|
431
|
+
* truncated to 8 hex chars.
|
|
432
|
+
*/
|
|
433
|
+
declare function imageVariantKey(canonical: string): string;
|
|
434
|
+
/**
|
|
435
|
+
* Validate a single path segment. We reject anything that embeds a path
|
|
436
|
+
* separator, a drive letter, a NUL or other control code, a Windows
|
|
437
|
+
* reserved basename, or a trailing dot/space (Windows normalises those away
|
|
438
|
+
* and you get the wrong file).
|
|
439
|
+
*
|
|
440
|
+
* @throws `Error` when `segment` is empty or rejected by any of the rules.
|
|
441
|
+
*/
|
|
442
|
+
declare function assertSafeSegment(segment: string): string;
|
|
443
|
+
/**
|
|
444
|
+
* Ensure `candidate` is contained within `ancestor` (after `resolve`). Used
|
|
445
|
+
* as a belt-and-braces check before any disk operation that mixes user
|
|
446
|
+
* input with a base directory.
|
|
447
|
+
*/
|
|
448
|
+
declare function assertInside(ancestor: string, candidate: string): string;
|
|
449
|
+
|
|
450
|
+
/** Thrown when a vault path violates the isolation rules (POLICY). */
|
|
451
|
+
declare class PluginVaultPathError extends Error {
|
|
452
|
+
constructor(message: string);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Parse a vault-relative destination and resolve it against the vault
|
|
456
|
+
* root. Every segment goes through {@link assertSafeSegment} (no empty
|
|
457
|
+
* segments, `.`/`..`, separators inside a segment, drive letters,
|
|
458
|
+
* control chars, Windows-reserved names, trailing dot/space) and the
|
|
459
|
+
* final path is re-checked with {@link assertInside} — doubly so, the
|
|
460
|
+
* whole point is that a downloaded file can only ever land inside the
|
|
461
|
+
* plugin's own vault.
|
|
462
|
+
*/
|
|
463
|
+
declare function parsePluginVaultDest(vaultDir: string, dest: string): {
|
|
464
|
+
readonly rel: string;
|
|
465
|
+
readonly abs: string;
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* Byte size of a vault file (or `undefined` when absent or not a
|
|
469
|
+
* regular file). The cheap presence check `download` builds on.
|
|
470
|
+
*/
|
|
471
|
+
declare function vaultStatFile(vaultDir: string, rel: string): Promise<{
|
|
472
|
+
readonly sizeBytes: number;
|
|
473
|
+
} | undefined>;
|
|
474
|
+
/** Stream a vault file's bytes (bounded by `maxBytes`). */
|
|
475
|
+
declare function vaultReadFile(vaultDir: string, rel: string, maxBytes: number): Promise<Uint8Array>;
|
|
476
|
+
/**
|
|
477
|
+
* Remove a vault file. Idempotent (absent → false); directories and
|
|
478
|
+
* anything outside the vault are rejected.
|
|
479
|
+
*/
|
|
480
|
+
declare function vaultRemoveFile(vaultDir: string, rel: string): Promise<boolean>;
|
|
481
|
+
/**
|
|
482
|
+
* Path of the caller's download temp file inside the vault (host-side
|
|
483
|
+
* staging — never a user-visible name). The downloader streams into it;
|
|
484
|
+
* {@link commitVaultFile} renames it into place.
|
|
485
|
+
*/
|
|
486
|
+
declare function vaultTempFile(vaultDir: string): string;
|
|
487
|
+
/** Delete a temp file (download failure path). */
|
|
488
|
+
declare function discardVaultTempFile(tempPath: string): Promise<void>;
|
|
489
|
+
/**
|
|
490
|
+
* Commit a downloaded temp file into the vault: mkdir the nested
|
|
491
|
+
* destination, verify the per-plugin total-size budget (the destination's
|
|
492
|
+
* own previous bytes count against the quota only once — re-downloading
|
|
493
|
+
* replaces, it does not add), then rename the temp file into place
|
|
494
|
+
* (atomic — the final path never holds a partial download).
|
|
495
|
+
*/
|
|
496
|
+
declare function commitVaultFile(opts: {
|
|
497
|
+
readonly vaultDir: string;
|
|
498
|
+
readonly rel: string;
|
|
499
|
+
readonly tempPath: string;
|
|
500
|
+
readonly maxFileBytes: number;
|
|
501
|
+
readonly maxTotalBytes: number;
|
|
502
|
+
}): Promise<{
|
|
503
|
+
readonly sizeBytes: number;
|
|
504
|
+
readonly sha256: string;
|
|
505
|
+
}>;
|
|
506
|
+
/**
|
|
507
|
+
* Total bytes of the vault's regular files (shared directory-size walk;
|
|
508
|
+
* host temp staging files — leading `.` — are excluded as transient).
|
|
509
|
+
*/
|
|
510
|
+
declare function vaultTotalSize(vaultDir: string): Promise<number>;
|
|
511
|
+
/** sha256 of a vault file's bytes (streamed; callers verify pins against it). */
|
|
512
|
+
declare function vaultFileSha256(vaultDir: string, rel: string): Promise<string>;
|
|
513
|
+
/** Result of {@link commitVaultFile}: the stored file's identity. */
|
|
514
|
+
type VaultCommitResult = {
|
|
515
|
+
readonly sizeBytes: number;
|
|
516
|
+
readonly sha256: string;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Filename hygiene for bare-file resources: map a user-supplied name
|
|
521
|
+
* (upload filename or zip entry name) onto a safe relative path inside
|
|
522
|
+
* a resource folder.
|
|
523
|
+
*
|
|
524
|
+
* Resource folders hold user entries next to metadata dotfiles
|
|
525
|
+
* (`.cover.*`, `.deleted`), so every rule here also guarantees the result
|
|
526
|
+
* can never collide with those: leading dots are stripped, and a
|
|
527
|
+
* trailing-dot/space rule mirrors Windows normalization.
|
|
528
|
+
*
|
|
529
|
+
* Cross-platform guarantees: names are NFC-normalized (macOS stores
|
|
530
|
+
* NFD on disk — one canonical form keeps names stable when a library
|
|
531
|
+
* moves between systems), segments are capped at 240 UTF-8 bytes
|
|
532
|
+
* (ext4's 255-byte component limit, minus suffix headroom), and the
|
|
533
|
+
* Windows reserved-name set includes the device aliases (`CONIN$`,
|
|
534
|
+
* superscript COM/LPT variants).
|
|
535
|
+
*
|
|
536
|
+
* The migration script (`scripts/migrate-hoard-to-files.mjs`) mirrors
|
|
537
|
+
* these rules verbatim — keep both in sync when changing them.
|
|
538
|
+
*/
|
|
539
|
+
/**
|
|
540
|
+
* Sanitize a single entry name into a safe relative path. Separators
|
|
541
|
+
* (`/` and `\`) become subdirectory boundaries; `..` and absolute paths
|
|
542
|
+
* are rejected. Returns `undefined` when the name is unusable (empty,
|
|
543
|
+
* escapes the folder, or cleans to nothing) — callers drop or reject
|
|
544
|
+
* such entries.
|
|
545
|
+
*/
|
|
546
|
+
declare function sanitizeEntryName(name: string): string | undefined;
|
|
547
|
+
/**
|
|
548
|
+
* Occupancy bookkeeping for name resolution: which paths are already
|
|
549
|
+
* taken by files (blocking everything) and which are directories
|
|
550
|
+
* (blocking only exact-path installs — a file cannot replace a folder).
|
|
551
|
+
*/
|
|
552
|
+
type OccupiedNames = {
|
|
553
|
+
readonly files: Set<string>;
|
|
554
|
+
readonly dirs: Set<string>;
|
|
555
|
+
};
|
|
556
|
+
/** Build occupancy from optional existing on-disk names (with types). */
|
|
557
|
+
declare function createOccupiedNames(existing?: {
|
|
558
|
+
readonly files?: Iterable<string>;
|
|
559
|
+
readonly dirs?: Iterable<string>;
|
|
560
|
+
}): OccupiedNames;
|
|
561
|
+
/**
|
|
562
|
+
* Record `relPath` as an installed file; every ancestor prefix becomes
|
|
563
|
+
* a directory. A later entry colliding with one of the ancestors (a
|
|
564
|
+
* file that sits on a directory path) is resolved by
|
|
565
|
+
* {@link uniqueEntryName}.
|
|
566
|
+
*/
|
|
567
|
+
declare function occupyEntryName(occupied: OccupiedNames, relPath: string): void;
|
|
568
|
+
/**
|
|
569
|
+
* Resolve a sanitized relative path against the names already present
|
|
570
|
+
* in the target folder by suffixing the colliding segment (`-1`, `-2`,
|
|
571
|
+
* …). The comparison is case-insensitive (Windows and macOS filesystems
|
|
572
|
+
* are). Files block their path and every ancestor prefix; directories
|
|
573
|
+
* block only their exact path. The suffix lands on the segment whose
|
|
574
|
+
* prefix collides, so `x/y.txt` against an occupied file `x` becomes
|
|
575
|
+
* `x-1/y.txt`, not `x/y-1.txt` (which would collide forever).
|
|
576
|
+
*/
|
|
577
|
+
declare function uniqueEntryName(occupied: OccupiedNames, relPath: string): string;
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Resolve the on-disk path of a file staged in the global pool by
|
|
581
|
+
* `fileId`. The pool is flat: each file is stored as `<fileId><ext>`
|
|
582
|
+
* where `ext` is the lower-cased extension of the original filename
|
|
583
|
+
* (or `.zip` for archive uploads). Because the extension is part of the
|
|
584
|
+
* filename we scan the directory for a sibling whose stem matches
|
|
585
|
+
* `fileId`.
|
|
586
|
+
*
|
|
587
|
+
* Returns `undefined` when no staged file matches `fileId`.
|
|
588
|
+
*/
|
|
589
|
+
declare function findStagedPoolFile(paths: StoragePaths, fileId: string): Promise<string | undefined>;
|
|
590
|
+
/**
|
|
591
|
+
* Resolve multiple pool files by `fileId` with a SINGLE directory scan.
|
|
592
|
+
* Committing an ordered upload resolves and then consumes every staged
|
|
593
|
+
* file; the per-file {@link findStagedPoolFile} scan would cost one full
|
|
594
|
+
* `readdir` per file, and the pool can hold hundreds of entries under
|
|
595
|
+
* concurrent uploads. Returns a map keyed by `fileId`; ids without a
|
|
596
|
+
* staged match are absent.
|
|
597
|
+
*/
|
|
598
|
+
declare function resolveStagedPoolFiles(paths: StoragePaths, fileIds: readonly string[]): Promise<Map<string, string>>;
|
|
599
|
+
/**
|
|
600
|
+
* Stream a single file into the global staging pool as
|
|
601
|
+
* `<fileId><ext>`. The file is first written to a `.tmp-*` sibling and
|
|
602
|
+
* atomically renamed so concurrent readers (preview) never observe a
|
|
603
|
+
* partial file. Returns the final staged path.
|
|
604
|
+
*/
|
|
605
|
+
declare function writeStagedPoolFile(paths: StoragePaths, fileId: string, filename: string, stream: NodeJS.ReadableStream): Promise<string>;
|
|
606
|
+
/**
|
|
607
|
+
* Stream an archive (zip) upload into the global staging pool as
|
|
608
|
+
* `<fileId>.zip`. Same atomic write semantics as
|
|
609
|
+
* {@link writeStagedPoolFile}. Returns the final staged path.
|
|
610
|
+
*/
|
|
611
|
+
declare function writeStagedArchiveFile(paths: StoragePaths, fileId: string, stream: NodeJS.ReadableStream): Promise<string>;
|
|
612
|
+
/** Resolve the staged path of an archive upload by `fileId`. */
|
|
613
|
+
declare function findStagedArchiveFile(paths: StoragePaths, fileId: string): Promise<string | undefined>;
|
|
614
|
+
/**
|
|
615
|
+
* Remove a single file from the global staging pool. Returns `true` when
|
|
616
|
+
* a file was removed, `false` when `fileId` was not present.
|
|
617
|
+
*/
|
|
618
|
+
declare function removeStagedPoolFile(paths: StoragePaths, fileId: string): Promise<boolean>;
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Enumerate version directories under `<root>/versions/`. Names that are
|
|
622
|
+
* not pure positive integers are ignored. Result is sorted ascending.
|
|
623
|
+
*/
|
|
624
|
+
declare function listVersions(root: string): readonly number[];
|
|
625
|
+
/**
|
|
626
|
+
* Current (maximum) version on disk. Returns `0` when no version exists
|
|
627
|
+
* yet (caller is expected to bootstrap version 1 in that case).
|
|
628
|
+
*/
|
|
629
|
+
declare function currentVersion(root: string): number;
|
|
630
|
+
/**
|
|
631
|
+
* Read the persisted active version. Falls back to current when the
|
|
632
|
+
* state file is missing or malformed, or when the recorded value points
|
|
633
|
+
* at a version that no longer exists.
|
|
634
|
+
*/
|
|
635
|
+
declare function readActiveVersion(root: string): number;
|
|
636
|
+
/**
|
|
637
|
+
* Persist the active version. Caller must ensure `version` exists on
|
|
638
|
+
* disk (use {@link listVersions}).
|
|
639
|
+
*
|
|
640
|
+
* @throws DomainError `version.not_found` when `version` is not a known
|
|
641
|
+
* version directory.
|
|
642
|
+
*/
|
|
643
|
+
declare function writeActiveVersion(root: string, version: number): void;
|
|
644
|
+
/**
|
|
645
|
+
* Bootstrap version 1 if the versions root has no version directories yet.
|
|
646
|
+
* Idempotent; a no-op when any version already exists.
|
|
647
|
+
*/
|
|
648
|
+
declare function ensureBootstrapVersion(root: string): number;
|
|
649
|
+
type CreateNextVersionResult = {
|
|
650
|
+
readonly previous: number;
|
|
651
|
+
readonly created: number;
|
|
652
|
+
};
|
|
653
|
+
/**
|
|
654
|
+
* Snapshot the current version into a freshly-numbered next version.
|
|
655
|
+
*
|
|
656
|
+
* Process:
|
|
657
|
+
* 1. Compute `next = currentVersion + 1`.
|
|
658
|
+
* 2. Refuse to proceed when `versions/<prev>/app.sqlite` already exists.
|
|
659
|
+
* 3. Create `<root>/versions/<next>/` and copy installed plugins from
|
|
660
|
+
* `versions/<prev>/plugins` (resource binaries stay at their
|
|
661
|
+
* `fileVersion`; plugins have no equivalent pointer).
|
|
662
|
+
* 4. Vacuum-snapshot the live DB into `<root>/versions/<prev>/app.sqlite`.
|
|
663
|
+
* (Caller passes a `vacuumInto` function; we don't take a DB handle
|
|
664
|
+
* here to keep this module database-agnostic.)
|
|
665
|
+
* 5. On any failure after step 3, remove `versions/<next>` so
|
|
666
|
+
* {@link currentVersion} does not jump to an empty directory.
|
|
667
|
+
*
|
|
668
|
+
* @throws DomainError `version.bootstrap_required` when no version exists
|
|
669
|
+
* yet (caller must bootstrap first).
|
|
670
|
+
* @throws DomainError `version.already_exists` when the previous
|
|
671
|
+
* version's DB snapshot already exists.
|
|
672
|
+
*/
|
|
673
|
+
declare function createNextVersion(root: string, vacuumInto: (destination: string) => void): CreateNextVersionResult;
|
|
674
|
+
/**
|
|
675
|
+
* Path to the DB file for version `v`: `<root>/versions/<v>/app.sqlite`.
|
|
676
|
+
*/
|
|
677
|
+
declare function versionedDbFile(root: string, v: number): string;
|
|
678
|
+
/**
|
|
679
|
+
* Path to the per-version archive directory `<root>/versions/<v>`.
|
|
680
|
+
*/
|
|
681
|
+
declare function versionedPath(root: string, v: number): string;
|
|
682
|
+
|
|
683
|
+
type VersionedFolderOps = {
|
|
684
|
+
/** Ensure the current-version entity folder exists. */
|
|
685
|
+
ensureFolder(id: string): Promise<void>;
|
|
686
|
+
/** Remove the current-version entity folder. Swallows missing-path errors. */
|
|
687
|
+
removeFolder(id: string): Promise<void>;
|
|
688
|
+
/**
|
|
689
|
+
* When an entity's files live only under frozen past archives, hard
|
|
690
|
+
* delete cannot remove those folders; drop a `.deleted` placeholder in
|
|
691
|
+
* the current-version folder instead.
|
|
692
|
+
*/
|
|
693
|
+
markDeleted(id: string): Promise<string>;
|
|
694
|
+
/**
|
|
695
|
+
* Move the current-version entity folder into `local/trash/` with a
|
|
696
|
+
* unique directory name. No-op when the source path is missing (same as
|
|
697
|
+
* a removed folder). Returns the destination path (whether or not a move
|
|
698
|
+
* occurred).
|
|
699
|
+
*/
|
|
700
|
+
moveFolderToTrash(id: string): Promise<string>;
|
|
701
|
+
};
|
|
702
|
+
/**
|
|
703
|
+
* The four lifecycle operations shared by the resource and character
|
|
704
|
+
* file-system layers. They differ only in which versioned folder they
|
|
705
|
+
* target and how the trash / placeholder names are derived.
|
|
706
|
+
*
|
|
707
|
+
* `moveFolderToTrash` treats `EPERM`/`EBUSY`/`UNKNOWN` as transient Windows
|
|
708
|
+
* locks (a file inside the folder is still open) and leaves the source in
|
|
709
|
+
* place; a boot-time orphan sweep reclaims it later.
|
|
710
|
+
*
|
|
711
|
+
* `readOnly` is a live `{ current: boolean }` ref (the server's runtime
|
|
712
|
+
* read-only flag), so a version switch mid-request re-reads it.
|
|
713
|
+
*/
|
|
714
|
+
declare function buildVersionedFolderOps(paths: StoragePaths, readOnly: {
|
|
715
|
+
readonly current: boolean;
|
|
716
|
+
}, kind: "resource" | "character"): VersionedFolderOps;
|
|
717
|
+
/**
|
|
718
|
+
* Move every file in `sourceFolder` whose name matches `match` into
|
|
719
|
+
* `destFolder` under a timestamped archive name (`<prefix><stamp><ext>`, or
|
|
720
|
+
* `<prefix><stamp>_<i><ext>` when several files match). Moves to the local
|
|
721
|
+
* (non-synced) directory so replaced files are preserved without polluting
|
|
722
|
+
* the versions sync scope. Creates `destFolder` on demand; silently ignores
|
|
723
|
+
* ENOENT on source files and a missing source folder.
|
|
724
|
+
*/
|
|
725
|
+
declare function archiveStaleFiles(args: {
|
|
726
|
+
readonly sourceFolder: string;
|
|
727
|
+
readonly destFolder: string;
|
|
728
|
+
readonly match: (name: string) => boolean;
|
|
729
|
+
readonly archivePrefix: string;
|
|
730
|
+
}): Promise<void>;
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Command that performs a write under the **current** (latest, writable)
|
|
734
|
+
* archive version. Receiving `paths.latest` instead of the whole
|
|
735
|
+
* {@link StoragePaths} object makes it impossible for callers to accidentally
|
|
736
|
+
* target `paths.active`, which may point at a frozen past version when the
|
|
737
|
+
* server is in read-only viewing mode.
|
|
738
|
+
*/
|
|
739
|
+
type VersionedWriteCommand<T> = (paths: StoragePaths["latest"]) => T | Promise<T>;
|
|
740
|
+
/**
|
|
741
|
+
* Central gate for every file-system write that lands under `versions/<v>`.
|
|
742
|
+
*
|
|
743
|
+
* - Blocks the operation when `readOnly` is true, surfacing a domain conflict
|
|
744
|
+
* with code `server.read_only_archive`.
|
|
745
|
+
* - Forces the command to operate on `paths.latest` (the latest archive
|
|
746
|
+
* version) so past frozen versions can never be mutated.
|
|
747
|
+
*
|
|
748
|
+
* Local-only writes (thumbs, caches, staging pool, trash) should NOT use this
|
|
749
|
+
* helper; they live under `local/` and are governed by the route-level
|
|
750
|
+
* write gate instead.
|
|
751
|
+
*/
|
|
752
|
+
declare function writeVersioned<T>(paths: StoragePaths, readOnly: boolean, cmd: VersionedWriteCommand<T>): Promise<T>;
|
|
753
|
+
|
|
754
|
+
export { type CreateNextVersionResult, type CreateStoragePathsOptions, type DirSizeOptions, type LocalPaths, ORDER_MANIFEST_NAME, type OccupiedNames, PluginVaultPathError, RESOURCE_DATA_DIR_NAME, type StoragePaths, type VaultCommitResult, type VersionPaths, type VersionedFolderOps, type VersionedWriteCommand, type ZipStreamEntry, archiveStaleFiles, assertInside, assertSafeSegment, buildVersionedFolderOps, commitVaultFile, createNextVersion, createOccupiedNames, createStoragePaths, currentVersion, discardVaultTempFile, ensureBootstrapVersion, extractArchiveInto, findStagedArchiveFile, findStagedPoolFile, imageVariantKey, listArchiveEntries, listVersions, naturalSort, occupyEntryName, orderEntries, orderManifestPath, parseOrderManifest, parsePluginVaultDest, readActiveVersion, readOrderManifest, removeStagedPoolFile, resolveStagedPoolFiles, sanitizeEntryName, streamStoredZip, sumDirSizes, uniqueEntryName, validateArchiveBudget, vaultFileSha256, vaultReadFile, vaultRemoveFile, vaultStatFile, vaultTempFile, vaultTotalSize, versionedDbFile, versionedPath, writeActiveVersion, writeOrderManifest, writeStagedArchiveFile, writeStagedPoolFile, writeVersioned };
|