@gentbajko/slopify 2.2.0 → 2.3.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.
@@ -0,0 +1,321 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, lstatSync, readdirSync } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { transact } from "../../kernel/db/tx.js";
6
+ import { backupFormat, backupSchemaVersion, checksumsMember, fontMember, libraryMember, manifestMember, projectFileMember, projectMember, projectTables, safeRelativePath, stagedMember, usageMember, } from "./backup-format.js";
7
+ import { projectDir, stagingPath } from "./layout.js";
8
+ import { exportableSettings, installedUploadedFontFiles } from "./portable.js";
9
+ import { tarEnd, tarHeader, tarMemberBytes, tarPadding } from "./tar.js";
10
+ // A project that is being made has half-written files and rows that say "running"; copied
11
+ // as they are, the other install would show a run nothing is doing. So a backup waits for
12
+ // every project to be finished, paused or canceled, and says which ones it is waiting for.
13
+ export class BackupBusyError extends Error {
14
+ projects;
15
+ constructor(projects) {
16
+ super(busySentence(projects));
17
+ this.projects = projects;
18
+ }
19
+ }
20
+ export function busySentence(projects) {
21
+ const named = projects
22
+ .slice(0, 3)
23
+ .map((project) => `"${project.title}"`)
24
+ .join(", ");
25
+ const more = projects.length > 3 ? ` and ${projects.length - 3} more` : "";
26
+ return `Slopify can't export while projects are being made (${named}${more}): their files are still being written. Wait for them to finish, or pause them on their project page, then press Export everything again.`;
27
+ }
28
+ export function busyProjects(deps) {
29
+ return deps.db
30
+ .prepare(`SELECT p.id AS id, p.title AS title,
31
+ EXISTS(SELECT 1 FROM stages s WHERE s.project_id=p.id AND s.state='running')
32
+ OR EXISTS(SELECT 1 FROM revision_work w WHERE w.project_id=p.id AND w.state='running')
33
+ OR EXISTS(SELECT 1 FROM project_queue q WHERE q.project_id=p.id AND q.state='queued')
34
+ AS busy
35
+ FROM projects p ORDER BY p.created_at, p.id`)
36
+ .all()
37
+ .flatMap((row) => {
38
+ const id = String(row.id);
39
+ return row.busy === 1 || deps.hasInflight?.(id) === true
40
+ ? [{ id, title: String(row.title) }]
41
+ : [];
42
+ });
43
+ }
44
+ // Reads everything the backup carries in one read transaction, so the rows agree with each
45
+ // other, and sizes every file so the archive's length is known before its first byte.
46
+ export function planBackup(deps) {
47
+ const busy = busyProjects(deps);
48
+ if (busy.length > 0)
49
+ throw new BackupBusyError(busy);
50
+ const snapshot = transact(deps.db, () => ({
51
+ databaseVersion: databaseVersion(deps.db),
52
+ library: librarySnapshot(deps),
53
+ usage: {
54
+ tables: {
55
+ telemetry_events: rowsOf(deps.db, "SELECT * FROM telemetry_events WHERE type<>'install' ORDER BY rowid"),
56
+ },
57
+ },
58
+ projects: deps.db
59
+ .prepare("SELECT id,title FROM projects ORDER BY created_at,id")
60
+ .all()
61
+ .map((row) => projectSnapshot(deps, String(row.id), String(row.title))),
62
+ }));
63
+ const createdAt = deps.clock.now().toISOString();
64
+ const files = [];
65
+ for (const project of snapshot.projects)
66
+ for (const file of project.part.files)
67
+ files.push({
68
+ name: projectFileMember(project.part.id, file.path),
69
+ kind: "file",
70
+ path: join(projectDir(deps.paths, project.part.id), file.path),
71
+ size: file.bytes,
72
+ });
73
+ for (const font of snapshot.library.part.fonts)
74
+ files.push({
75
+ name: fontMember(font.name),
76
+ kind: "file",
77
+ path: join(deps.paths.dataDir, "fonts", font.name),
78
+ size: font.bytes,
79
+ });
80
+ for (const staged of snapshot.library.part.staged)
81
+ files.push({
82
+ name: stagedMember(staged.id),
83
+ kind: "file",
84
+ path: stagingPath(deps.paths, staged.id),
85
+ size: staged.bytes,
86
+ });
87
+ const manifest = {
88
+ format: backupFormat,
89
+ schemaVersion: backupSchemaVersion,
90
+ appVersion: deps.appVersion.slice(0, 40),
91
+ databaseVersion: snapshot.databaseVersion,
92
+ backupId: deps.ids.next(),
93
+ createdAt,
94
+ projects: snapshot.projects.map((project) => ({
95
+ id: project.part.id,
96
+ title: project.title,
97
+ bytes: project.part.files.reduce((sum, file) => sum + file.bytes, 0),
98
+ })),
99
+ files: files.length,
100
+ bytes: files.reduce((sum, file) => sum + (file.kind === "file" ? file.size : 0), 0),
101
+ };
102
+ const members = [
103
+ json(manifestMember, manifest),
104
+ json(libraryMember, snapshot.library.part),
105
+ json(usageMember, snapshot.usage),
106
+ ...snapshot.projects.map((project) => json(projectMember(project.part.id), project.part)),
107
+ ...files,
108
+ ];
109
+ const archiveBytes = members.reduce((sum, member) => sum +
110
+ tarMemberBytes({
111
+ name: member.name,
112
+ size: member.kind === "bytes" ? member.content.byteLength : member.size,
113
+ }), 0) +
114
+ tarMemberBytes({
115
+ name: checksumsMember,
116
+ size: checksumsBytes(files, () => "0".repeat(64)).byteLength,
117
+ }) +
118
+ tarEnd.byteLength;
119
+ return {
120
+ manifest,
121
+ members,
122
+ archiveBytes,
123
+ fileName: `slopify-backup-${createdAt.slice(0, 10)}.tar`,
124
+ };
125
+ }
126
+ const readChunk = 1024 * 1024;
127
+ // The archive, a chunk at a time. Files are read as they are sent, so a two-hour video
128
+ // costs one megabyte of memory, not its size. A file that changed size since the plan was
129
+ // made ends the stream with an error, and the browser marks the download failed rather
130
+ // than saving a backup that would not import.
131
+ export async function* streamBackup(plan) {
132
+ const digests = new Map();
133
+ const files = [];
134
+ for (const member of plan.members) {
135
+ const size = member.kind === "bytes" ? member.content.byteLength : member.size;
136
+ yield tarHeader({ name: member.name, size });
137
+ if (member.kind === "bytes") {
138
+ yield member.content;
139
+ }
140
+ else {
141
+ files.push(member);
142
+ const hash = createHash("sha256");
143
+ const handle = await open(member.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
144
+ try {
145
+ const stat = await handle.stat();
146
+ if (!stat.isFile() || stat.size !== member.size)
147
+ throw changed(member.name);
148
+ let sent = 0;
149
+ while (sent < member.size) {
150
+ const buffer = new Uint8Array(Math.min(readChunk, member.size - sent));
151
+ const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, sent);
152
+ if (bytesRead === 0)
153
+ throw changed(member.name);
154
+ const chunk = buffer.subarray(0, bytesRead);
155
+ hash.update(chunk);
156
+ sent += bytesRead;
157
+ yield chunk;
158
+ }
159
+ }
160
+ finally {
161
+ await handle.close();
162
+ }
163
+ digests.set(member.name, hash.digest("hex"));
164
+ }
165
+ const padding = tarPadding(size);
166
+ if (padding > 0)
167
+ yield new Uint8Array(padding);
168
+ }
169
+ const checksums = checksumsBytes(files, (name) => digests.get(name) ?? "");
170
+ yield tarHeader({ name: checksumsMember, size: checksums.byteLength });
171
+ yield checksums;
172
+ const padding = tarPadding(checksums.byteLength);
173
+ if (padding > 0)
174
+ yield new Uint8Array(padding);
175
+ yield tarEnd;
176
+ }
177
+ function changed(name) {
178
+ return new Error(`${name} changed while the backup was being written.`);
179
+ }
180
+ function checksumsBytes(files, digest) {
181
+ return new TextEncoder().encode(JSON.stringify({
182
+ files: files.map((file) => ({ member: file.name, sha256: digest(file.name) })),
183
+ }));
184
+ }
185
+ function json(name, value) {
186
+ return { name, kind: "bytes", content: new TextEncoder().encode(JSON.stringify(value)) };
187
+ }
188
+ function databaseVersion(db) {
189
+ const row = db.prepare("SELECT max(version) AS version FROM schema_migrations").get();
190
+ return Number(row?.version ?? 0);
191
+ }
192
+ // Every row of one project in one table. The same selection reads the scratch database on
193
+ // import, so what is exported and what is copied in are the same rows by construction.
194
+ export function projectRows(db, table, projectId) {
195
+ switch (table) {
196
+ case "projects":
197
+ return rowsOf(db, "SELECT * FROM projects WHERE id=?", projectId);
198
+ case "attempts":
199
+ case "stage_pieces":
200
+ return rowsOf(db, `SELECT * FROM ${table} WHERE stage_id IN (SELECT id FROM stages WHERE project_id=?) ORDER BY rowid`, projectId);
201
+ case "revision_work_pieces":
202
+ return rowsOf(db, "SELECT * FROM revision_work_pieces WHERE work_id IN (SELECT id FROM revision_work WHERE project_id=?) ORDER BY rowid", projectId);
203
+ default:
204
+ return rowsOf(db, `SELECT * FROM ${table} WHERE project_id=? ORDER BY rowid`, projectId);
205
+ }
206
+ }
207
+ function projectSnapshot(deps, id, title) {
208
+ const tables = {};
209
+ for (const table of projectTables) {
210
+ const rows = projectRows(deps.db, table, id);
211
+ if (rows.length > 0)
212
+ tables[table] = rows;
213
+ }
214
+ return { title, part: { id, tables, files: projectFiles(projectDir(deps.paths, id)) } };
215
+ }
216
+ // Every plain file under the project's folder, symlinks and all else left out. Not only the
217
+ // rows' files: audio chunks a retry would reuse and the files a revision history keeps are
218
+ // named inside JSON the backup has no business parsing, and reconcile already removes what
219
+ // nothing names.
220
+ function projectFiles(root) {
221
+ const out = [];
222
+ const walk = (relative) => {
223
+ let entries;
224
+ try {
225
+ entries = readdirSync(join(root, relative), { withFileTypes: true });
226
+ }
227
+ catch (error) {
228
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
229
+ return;
230
+ throw error;
231
+ }
232
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
233
+ const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
234
+ if (entry.isDirectory())
235
+ walk(path);
236
+ else if (entry.isFile() && safeRelativePath(path))
237
+ out.push({ path, bytes: lstatSync(join(root, path)).size });
238
+ }
239
+ };
240
+ walk("");
241
+ return out;
242
+ }
243
+ function librarySnapshot(deps) {
244
+ const { db, paths } = deps;
245
+ const tables = {};
246
+ const put = (table, rows) => {
247
+ if (rows.length > 0)
248
+ tables[table] = rows;
249
+ };
250
+ const stored = {};
251
+ for (const row of db.prepare("SELECT key,value FROM settings ORDER BY key").all())
252
+ stored[String(row.key)] = String(row.value);
253
+ put("settings", Object.entries(exportableSettings(stored)).map(([key, value]) => ({ key, value })));
254
+ put("prompts", rowsOf(db, "SELECT * FROM prompts ORDER BY rowid"));
255
+ put("entries", rowsOf(db, "SELECT * FROM entries ORDER BY rowid"));
256
+ put("voices", rowsOf(db, "SELECT * FROM voices ORDER BY rowid"));
257
+ put("document_themes", rowsOf(db, "SELECT * FROM document_themes ORDER BY rowid"));
258
+ put("project_templates", rowsOf(db, "SELECT * FROM project_templates ORDER BY rowid"));
259
+ put("project_template_revisions", rowsOf(db, "SELECT * FROM project_template_revisions ORDER BY template_id,version"));
260
+ put("schedules", rowsOf(db, "SELECT * FROM schedules ORDER BY rowid"));
261
+ put("schedule_runs", rowsOf(db, "SELECT * FROM schedule_runs ORDER BY rowid"));
262
+ // Play drafts are work the user typed and has not started yet, so they travel. A draft
263
+ // that already became projects is not: its projects carry it. Its uploads travel with it
264
+ // when they are complete on disk; one that is not comes back asking to be reattached,
265
+ // the same thing a restart does with it.
266
+ const drafts = rowsOf(db, "SELECT * FROM play_drafts WHERE state='active' ORDER BY rowid");
267
+ put("play_drafts", drafts);
268
+ const draftIds = new Set(drafts.map((row) => String(row.id)));
269
+ const attachments = rowsOf(db, "SELECT * FROM play_draft_attachments ORDER BY rowid").filter((row) => draftIds.has(String(row.draft_id)));
270
+ const staged = [];
271
+ const stagedRows = [];
272
+ const reattach = "Upload is missing or incomplete. Reattach the file.";
273
+ put("play_draft_attachments", attachments.map((row) => {
274
+ const stagedId = row.staged_file_id;
275
+ if (typeof stagedId !== "string")
276
+ return row;
277
+ const file = db.prepare("SELECT * FROM staged_files WHERE id=?").get(stagedId);
278
+ const complete = file !== undefined &&
279
+ file.state === "staged" &&
280
+ file.path === stagedId &&
281
+ fileBytes(stagingPath(paths, stagedId)) === file.bytes;
282
+ if (!complete)
283
+ return { ...row, staged_file_id: null, status: "reattach", error: reattach };
284
+ if (!staged.some((one) => one.id === stagedId)) {
285
+ staged.push({ id: stagedId, bytes: Number(file.bytes) });
286
+ stagedRows.push(toRow(file));
287
+ }
288
+ return row;
289
+ }));
290
+ put("staged_files", stagedRows);
291
+ put("project_template_instantiations", rowsOf(db, "SELECT * FROM project_template_instantiations ORDER BY rowid").filter((row) => draftIds.has(String(row.draft_id))));
292
+ const fonts = installedUploadedFontFiles(paths).map((font) => ({
293
+ name: font.name,
294
+ bytes: font.bytes,
295
+ }));
296
+ return { part: { tables, fonts, staged } };
297
+ }
298
+ function fileBytes(path) {
299
+ try {
300
+ const stat = lstatSync(path);
301
+ return stat.isFile() ? stat.size : undefined;
302
+ }
303
+ catch {
304
+ return undefined;
305
+ }
306
+ }
307
+ export function rowsOf(db, sql, ...params) {
308
+ return db
309
+ .prepare(sql)
310
+ .all(...params)
311
+ .map(toRow);
312
+ }
313
+ function toRow(row) {
314
+ const out = {};
315
+ for (const [key, value] of Object.entries(row)) {
316
+ if (value !== null && typeof value !== "string" && typeof value !== "number")
317
+ throw new Error(`Column ${key} holds a value a backup cannot carry.`);
318
+ out[key] = value;
319
+ }
320
+ return out;
321
+ }
@@ -0,0 +1,155 @@
1
+ import { z } from "zod";
2
+ import { fontMaxBytes } from "../fonts/model.js";
3
+ // The full backup (Settings → Backup & storage → Export everything): a tar whose members
4
+ // come in this order, so an import can check every JSON part before it writes a single file:
5
+ //
6
+ // manifest.json what made it, when, and which projects it carries
7
+ // data/library.json settings, library, templates, schedules, drafts
8
+ // data/usage.json the Usage screen's event log
9
+ // data/projects/<id>.json one per project: every row it needs to open and keep going
10
+ // files/... the bytes: project folders, uploaded fonts, draft uploads
11
+ // checksums.json a SHA-256 per file, written as the files streamed out
12
+ //
13
+ // Rows travel as the database holds them, table by table, with the schema version they fit;
14
+ // an import loads them into a scratch database at that version and lets the same migrations
15
+ // an upgrade runs carry them forward. Provider keys, the telemetry machine id, logs, the
16
+ // model cache, updates and staging leftovers are never read.
17
+ export const backupFormat = "slopify-backup";
18
+ export const backupSchemaVersion = 2;
19
+ export const manifestMember = "manifest.json";
20
+ export const libraryMember = "data/library.json";
21
+ export const usageMember = "data/usage.json";
22
+ export const checksumsMember = "checksums.json";
23
+ export const projectMember = (id) => `data/projects/${id}.json`;
24
+ export const projectFileMember = (id, path) => `files/projects/${id}/${path}`;
25
+ export const fontMember = (name) => `files/fonts/${name}`;
26
+ export const stagedMember = (id) => `files/staging/${id}`;
27
+ // Every table a project's rows live in, parents before children. The queue is left out on
28
+ // purpose: a backup never carries a project that is waiting to run.
29
+ export const projectTables = [
30
+ "projects",
31
+ "project_controls",
32
+ "stages",
33
+ "attempts",
34
+ "stage_pieces",
35
+ "outputs",
36
+ "project_revisions",
37
+ "project_heads",
38
+ "project_assets",
39
+ "revision_outputs",
40
+ "revision_pieces",
41
+ "revision_mutations",
42
+ "rebuild_previews",
43
+ "rebuild_admissions",
44
+ "revision_work",
45
+ "revision_work_pieces",
46
+ "revision_work_reservations",
47
+ "project_control_receipts",
48
+ "revision_provided_reviews",
49
+ "review_checkpoints",
50
+ "review_checkpoint_approvals",
51
+ "project_recovery_requests",
52
+ ];
53
+ export const libraryTables = [
54
+ "settings",
55
+ "prompts",
56
+ "entries",
57
+ "voices",
58
+ "document_themes",
59
+ "project_templates",
60
+ "project_template_revisions",
61
+ "schedules",
62
+ "schedule_runs",
63
+ "staged_files",
64
+ "play_drafts",
65
+ "play_draft_attachments",
66
+ "project_template_instantiations",
67
+ ];
68
+ export const usageTables = ["telemetry_events"];
69
+ // Ceilings a well-formed backup of a real install stays far below. They bound what a hostile
70
+ // or damaged file can make an import hold in memory before anything is written.
71
+ export const backupMaxJsonBytes = 256 * 1024 * 1024;
72
+ export const backupMaxManifestBytes = 4 * 1024 * 1024;
73
+ const maxRows = 2_000_000;
74
+ const maxProjects = 50_000;
75
+ const maxFilesPerProject = 200_000;
76
+ const idPattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
77
+ export const backupId = z.string().regex(idPattern);
78
+ // A relative path inside a project folder: plain segments, no way up or out.
79
+ export function safeRelativePath(path) {
80
+ if (path.length === 0 || path.length > 512 || path.includes("\\") || path.includes("\0"))
81
+ return false;
82
+ return path
83
+ .split("/")
84
+ .every((part) => part.length > 0 && part !== "." && part !== ".." && part.length <= 255);
85
+ }
86
+ const relativePath = z.string().refine(safeRelativePath, "A file path leaves its folder.");
87
+ const cell = z.union([z.string(), z.number().finite(), z.null()]);
88
+ const row = z.record(z.string().regex(/^[a-z][a-z0-9_]{0,63}$/), cell);
89
+ const rows = z.array(row).max(maxRows);
90
+ function tablesOf(names) {
91
+ return z
92
+ .object(Object.fromEntries(names.map((name) => [name, rows.optional()])))
93
+ .strict();
94
+ }
95
+ // Only the first three fields are read before the version check, so a backup from a newer
96
+ // Slopify is refused with "update first" rather than a list of fields this build can't read.
97
+ export const manifestHead = z
98
+ .object({
99
+ format: z.literal(backupFormat),
100
+ schemaVersion: z.number().int().positive(),
101
+ appVersion: z.string().max(40),
102
+ })
103
+ .loose();
104
+ export const manifestSchema = z
105
+ .object({
106
+ format: z.literal(backupFormat),
107
+ schemaVersion: z.literal(backupSchemaVersion),
108
+ appVersion: z.string().max(40),
109
+ databaseVersion: z.number().int().positive(),
110
+ backupId,
111
+ createdAt: z.string().max(40),
112
+ projects: z
113
+ .array(z
114
+ .object({ id: backupId, title: z.string().max(200), bytes: z.number().int().min(0) })
115
+ .strict())
116
+ .max(maxProjects),
117
+ files: z.number().int().min(0),
118
+ bytes: z.number().int().min(0),
119
+ })
120
+ .strict();
121
+ const fileEntry = z.object({ path: relativePath, bytes: z.number().int().min(0) }).strict();
122
+ export const libraryPartSchema = z
123
+ .object({
124
+ tables: tablesOf(libraryTables),
125
+ fonts: z
126
+ .array(z
127
+ .object({
128
+ name: z.string().regex(/^uploaded-[a-f0-9]{64}\.(ttf|otf)$/),
129
+ bytes: z.number().int().min(12).max(fontMaxBytes),
130
+ })
131
+ .strict())
132
+ .max(64),
133
+ staged: z
134
+ .array(z.object({ id: backupId, bytes: z.number().int().positive() }).strict())
135
+ .max(10_000),
136
+ })
137
+ .strict();
138
+ export const usagePartSchema = z.object({ tables: tablesOf(usageTables) }).strict();
139
+ export const projectPartSchema = z
140
+ .object({
141
+ id: backupId,
142
+ tables: tablesOf(projectTables),
143
+ files: z.array(fileEntry).max(maxFilesPerProject),
144
+ })
145
+ .strict()
146
+ .refine((part) => part.tables.projects?.length === 1 && part.tables.projects[0]?.id === part.id, "A project part must hold exactly its own project row.");
147
+ export const checksumsSchema = z
148
+ .object({
149
+ files: z
150
+ .array(z
151
+ .object({ member: z.string().max(1024), sha256: z.string().regex(/^[a-f0-9]{64}$/) })
152
+ .strict())
153
+ .max(maxProjects * 10 + 1_000_000),
154
+ })
155
+ .strict();