@gentbajko/slopify 0.1.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 +21 -0
- package/README.md +63 -0
- package/dist/adapter-registry.js +68 -0
- package/dist/adapters/image/bytes.js +64 -0
- package/dist/adapters/image/fal.js +164 -0
- package/dist/adapters/image/openai.js +155 -0
- package/dist/adapters/image/replicate.js +183 -0
- package/dist/adapters/llm/claude-code.js +158 -0
- package/dist/adapters/llm/codex.js +126 -0
- package/dist/adapters/llm/openrouter.js +193 -0
- package/dist/adapters/llm/run-cli.js +134 -0
- package/dist/adapters/llm/sse-lines.js +46 -0
- package/dist/adapters/retry-after.js +21 -0
- package/dist/adapters/tts/cartesia.js +105 -0
- package/dist/adapters/tts/elevenlabs.js +107 -0
- package/dist/adapters/tts/openai.js +95 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/events/hub.js +76 -0
- package/dist/edge/http/actions.js +125 -0
- package/dist/edge/http/app.js +72 -0
- package/dist/edge/http/entries.js +41 -0
- package/dist/edge/http/files.js +67 -0
- package/dist/edge/http/problem.js +65 -0
- package/dist/edge/http/projects.js +128 -0
- package/dist/edge/http/prompts.js +73 -0
- package/dist/edge/http/providers.js +62 -0
- package/dist/edge/http/settings.js +90 -0
- package/dist/edge/http/staging.js +104 -0
- package/dist/edge/http/telemetry.js +29 -0
- package/dist/edge/http/usage.js +19 -0
- package/dist/edge/open-browser.js +20 -0
- package/dist/kernel/clock.js +18 -0
- package/dist/kernel/config/index.js +32 -0
- package/dist/kernel/db/index.js +24 -0
- package/dist/kernel/db/migrate.js +59 -0
- package/dist/kernel/db/migrations/0001-init.sql +17 -0
- package/dist/kernel/db/tx.js +53 -0
- package/dist/kernel/events.js +1 -0
- package/dist/kernel/ids.js +4 -0
- package/dist/kernel/lock.js +73 -0
- package/dist/kernel/log.js +34 -0
- package/dist/kernel/paths.js +21 -0
- package/dist/kernel/pipeline.js +22 -0
- package/dist/kernel/ports/image.js +1 -0
- package/dist/kernel/ports/llm.js +1 -0
- package/dist/kernel/ports/model.js +34 -0
- package/dist/kernel/ports/registry.js +1 -0
- package/dist/kernel/ports/tts.js +1 -0
- package/dist/kernel/runner/attempt-repo.js +50 -0
- package/dist/kernel/runner/attempt.js +146 -0
- package/dist/kernel/runner/graph.js +82 -0
- package/dist/kernel/runner/index.js +205 -0
- package/dist/kernel/runner/piece-repo.js +58 -0
- package/dist/kernel/runner/providers.js +92 -0
- package/dist/kernel/version.js +14 -0
- package/dist/main.js +212 -0
- package/dist/slices/admission/model.js +6 -0
- package/dist/slices/admission/repo.js +178 -0
- package/dist/slices/admission/rules.js +201 -0
- package/dist/slices/admission/start.js +103 -0
- package/dist/slices/admission/substitute.js +80 -0
- package/dist/slices/article/continuation.js +81 -0
- package/dist/slices/article/plain.js +14 -0
- package/dist/slices/article/run.js +166 -0
- package/dist/slices/article/split.js +51 -0
- package/dist/slices/article/store.js +21 -0
- package/dist/slices/cancel/index.js +61 -0
- package/dist/slices/images/run.js +221 -0
- package/dist/slices/library/lint.js +53 -0
- package/dist/slices/library/model.js +13 -0
- package/dist/slices/library/repo.js +111 -0
- package/dist/slices/library/save.js +87 -0
- package/dist/slices/library/slots.js +86 -0
- package/dist/slices/narration/chunk.js +71 -0
- package/dist/slices/narration/concat.js +79 -0
- package/dist/slices/narration/run.js +280 -0
- package/dist/slices/reruns/cascade.js +66 -0
- package/dist/slices/reruns/index.js +269 -0
- package/dist/slices/research/planner.js +81 -0
- package/dist/slices/research/run.js +192 -0
- package/dist/slices/research/synthesis.js +48 -0
- package/dist/slices/settings/cli-status.js +37 -0
- package/dist/slices/settings/keys.js +55 -0
- package/dist/slices/settings/model.js +54 -0
- package/dist/slices/settings/playback.js +60 -0
- package/dist/slices/settings/readiness.js +21 -0
- package/dist/slices/settings/repo.js +59 -0
- package/dist/slices/settings/voices.js +48 -0
- package/dist/slices/storage/asset-name.js +16 -0
- package/dist/slices/storage/delete-project.js +35 -0
- package/dist/slices/storage/downloads.js +100 -0
- package/dist/slices/storage/layout.js +59 -0
- package/dist/slices/storage/model.js +20 -0
- package/dist/slices/storage/reconcile.js +102 -0
- package/dist/slices/storage/repo.js +104 -0
- package/dist/slices/storage/staging.js +224 -0
- package/dist/slices/telemetry/collector-client.js +51 -0
- package/dist/slices/telemetry/flush.js +111 -0
- package/dist/slices/telemetry/machine.js +33 -0
- package/dist/slices/telemetry/model.js +39 -0
- package/dist/slices/telemetry/record.js +41 -0
- package/dist/slices/telemetry/repo.js +73 -0
- package/dist/slices/telemetry/usage.js +82 -0
- package/dist/slices/thumbnail/by-llm.js +30 -0
- package/dist/slices/thumbnail/run.js +200 -0
- package/dist/slices/video/ffmpeg.js +230 -0
- package/dist/slices/video/plan.js +71 -0
- package/dist/slices/video/run.js +179 -0
- package/dist/web/app-icon.svg +1 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
- package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
- package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
- package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
- package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
- package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
- package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
- package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
- package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
- package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
- package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
- package/dist/web/assets/index-BPqQnrpy.css +1 -0
- package/dist/web/assets/index-D_sWbKQi.js +81 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +15 -0
- package/package.json +41 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { outputRoles, stagedFileStates, stageKinds } from "./model.js";
|
|
3
|
+
const metaSchema = z.object({
|
|
4
|
+
promptName: z.string().optional(),
|
|
5
|
+
prompt: z.string().optional(),
|
|
6
|
+
index: z.number().optional(),
|
|
7
|
+
provider: z.string().optional(),
|
|
8
|
+
model: z.string().optional(),
|
|
9
|
+
voice: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
const outputRow = z.object({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
project_id: z.string(),
|
|
14
|
+
stage_kind: z.enum(stageKinds),
|
|
15
|
+
role: z.enum(outputRoles),
|
|
16
|
+
path: z.string(),
|
|
17
|
+
original_filename: z.string().nullable(),
|
|
18
|
+
bytes: z.number(),
|
|
19
|
+
duration_ms: z.number().nullable(),
|
|
20
|
+
meta: z.string().nullable(),
|
|
21
|
+
created_at: z.string(),
|
|
22
|
+
});
|
|
23
|
+
const stagedFileRow = z.object({
|
|
24
|
+
id: z.string(),
|
|
25
|
+
stage_kind: z.enum(stageKinds),
|
|
26
|
+
path: z.string(),
|
|
27
|
+
original_filename: z.string(),
|
|
28
|
+
bytes: z.number(),
|
|
29
|
+
state: z.enum(stagedFileStates),
|
|
30
|
+
created_at: z.string(),
|
|
31
|
+
});
|
|
32
|
+
export function insertOutput(db, output) {
|
|
33
|
+
db.prepare("INSERT INTO outputs (id, project_id, stage_kind, role, path, original_filename, bytes, duration_ms, meta, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(output.id, output.projectId, output.stageKind, output.role, output.path, output.originalFilename, output.bytes, output.durationMs, JSON.stringify(output.meta), output.createdAt);
|
|
34
|
+
}
|
|
35
|
+
export function outputsOf(db, projectId) {
|
|
36
|
+
return db
|
|
37
|
+
.prepare("SELECT * FROM outputs WHERE project_id = ? ORDER BY rowid")
|
|
38
|
+
.all(projectId)
|
|
39
|
+
.map((row) => toOutput(outputRow.parse(row)));
|
|
40
|
+
}
|
|
41
|
+
export function outputById(db, id) {
|
|
42
|
+
const row = db.prepare("SELECT * FROM outputs WHERE id = ?").get(id);
|
|
43
|
+
return row === undefined ? undefined : toOutput(outputRow.parse(row));
|
|
44
|
+
}
|
|
45
|
+
// `logic/12` §Q106: an action replaces an output rather than versioning it. The row goes
|
|
46
|
+
// here; the file it names is the caller's to unlink once this has committed.
|
|
47
|
+
export function deleteOutput(db, id) {
|
|
48
|
+
db.prepare("DELETE FROM outputs WHERE id = ?").run(id);
|
|
49
|
+
}
|
|
50
|
+
export function insertStagedFile(db, file) {
|
|
51
|
+
db.prepare("INSERT INTO staged_files (id, stage_kind, path, original_filename, bytes, state, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(file.id, file.stageKind, file.path, file.originalFilename, file.bytes, file.state, file.createdAt);
|
|
52
|
+
}
|
|
53
|
+
export function markStagedFileCopied(db, id, bytes) {
|
|
54
|
+
db.prepare("UPDATE staged_files SET bytes = ?, state = 'staged' WHERE id = ?").run(bytes, id);
|
|
55
|
+
}
|
|
56
|
+
export function stagedFileById(db, id) {
|
|
57
|
+
const row = db.prepare("SELECT * FROM staged_files WHERE id = ?").get(id);
|
|
58
|
+
return row === undefined ? undefined : toStagedFile(stagedFileRow.parse(row));
|
|
59
|
+
}
|
|
60
|
+
export function stagedFiles(db) {
|
|
61
|
+
return db
|
|
62
|
+
.prepare("SELECT * FROM staged_files ORDER BY created_at, id")
|
|
63
|
+
.all()
|
|
64
|
+
.map((row) => toStagedFile(stagedFileRow.parse(row)));
|
|
65
|
+
}
|
|
66
|
+
export function deleteStagedFile(db, id) {
|
|
67
|
+
db.prepare("DELETE FROM staged_files WHERE id = ?").run(id);
|
|
68
|
+
}
|
|
69
|
+
export function projectTitle(db, projectId) {
|
|
70
|
+
const row = db.prepare("SELECT title FROM projects WHERE id = ?").get(projectId);
|
|
71
|
+
if (row === undefined) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return z.object({ title: z.string() }).parse(row).title;
|
|
75
|
+
}
|
|
76
|
+
function toOutput(row) {
|
|
77
|
+
return {
|
|
78
|
+
id: row.id,
|
|
79
|
+
projectId: row.project_id,
|
|
80
|
+
stageKind: row.stage_kind,
|
|
81
|
+
role: row.role,
|
|
82
|
+
path: row.path,
|
|
83
|
+
originalFilename: row.original_filename,
|
|
84
|
+
bytes: row.bytes,
|
|
85
|
+
durationMs: row.duration_ms,
|
|
86
|
+
meta: toMeta(row.meta),
|
|
87
|
+
createdAt: row.created_at,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// A JSON column never reaches a caller as a string.
|
|
91
|
+
function toMeta(meta) {
|
|
92
|
+
return meta === null ? {} : metaSchema.parse(JSON.parse(meta));
|
|
93
|
+
}
|
|
94
|
+
function toStagedFile(row) {
|
|
95
|
+
return {
|
|
96
|
+
id: row.id,
|
|
97
|
+
stageKind: row.stage_kind,
|
|
98
|
+
path: row.path,
|
|
99
|
+
originalFilename: row.original_filename,
|
|
100
|
+
bytes: row.bytes,
|
|
101
|
+
state: row.state,
|
|
102
|
+
createdAt: row.created_at,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { copyFileSync, createWriteStream, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { pipeline } from "node:stream/promises";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
6
|
+
import { extensionOf, outputFileName, outputPath, stagingPath } from "./layout.js";
|
|
7
|
+
import { deleteStagedFile, insertOutput, insertStagedFile, markStagedFileCopied, stagedFileById, } from "./repo.js";
|
|
8
|
+
// The upload's own name is data, never a path component: the file is stored under a
|
|
9
|
+
// generated id, and anything with a separator, a traversal, or a control character is
|
|
10
|
+
// refused rather than sanitised, so nothing silently lands under a name nobody chose.
|
|
11
|
+
const filename = z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(255)
|
|
16
|
+
.refine((name) => !name.includes("/") && !name.includes("\\"))
|
|
17
|
+
.refine((name) => name !== "." && name !== "..")
|
|
18
|
+
.refine((name) => ![...name].some((character) => (character.codePointAt(0) ?? 0) < 0x20));
|
|
19
|
+
// ceiling: progress is coalesced to one event per 200 ms per upload. A page that wants
|
|
20
|
+
// a smoother bar reads bytes from the staging list instead of asking for more events.
|
|
21
|
+
const progressIntervalMs = 200;
|
|
22
|
+
export async function stageUpload(deps, input) {
|
|
23
|
+
const named = filename.safeParse(input.originalFilename);
|
|
24
|
+
if (!named.success) {
|
|
25
|
+
return { ok: false, reason: "unsafe-filename" };
|
|
26
|
+
}
|
|
27
|
+
const originalFilename = named.data;
|
|
28
|
+
const id = deps.ids.next();
|
|
29
|
+
const target = stagingPath(deps.paths, id);
|
|
30
|
+
const file = {
|
|
31
|
+
id,
|
|
32
|
+
stageKind: input.stageKind,
|
|
33
|
+
path: id,
|
|
34
|
+
originalFilename,
|
|
35
|
+
bytes: 0,
|
|
36
|
+
state: "copying",
|
|
37
|
+
createdAt: deps.clock.now().toISOString(),
|
|
38
|
+
};
|
|
39
|
+
// The row exists before the first byte, so a process that dies mid-copy leaves a
|
|
40
|
+
// record reconcile collects rather than an untracked file (logic/05 §Q44).
|
|
41
|
+
insertStagedFile(deps.db, file);
|
|
42
|
+
let bytes = 0;
|
|
43
|
+
let lastEmit = 0;
|
|
44
|
+
const notify = (event) => {
|
|
45
|
+
// Nothing about progress delivery may abort the write: a page that navigated away
|
|
46
|
+
// is not a reason to lose the upload it started (logic/05 §Q43).
|
|
47
|
+
try {
|
|
48
|
+
deps.emit(event);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
deps.log.write("warn", "staging.emit", { detail: messageOf(error) });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const progress = (state) => {
|
|
55
|
+
notify({
|
|
56
|
+
type: "staging.progress",
|
|
57
|
+
stagedFileId: id,
|
|
58
|
+
stageKind: input.stageKind,
|
|
59
|
+
originalFilename,
|
|
60
|
+
bytes,
|
|
61
|
+
state,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
async function* counted() {
|
|
65
|
+
for await (const chunk of input.content) {
|
|
66
|
+
yield chunk;
|
|
67
|
+
bytes += chunk.byteLength;
|
|
68
|
+
const at = deps.clock.now().getTime();
|
|
69
|
+
if (at - lastEmit >= progressIntervalMs) {
|
|
70
|
+
lastEmit = at;
|
|
71
|
+
progress("copying");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const sink = createWriteStream(target, { mode: 0o600, flags: "wx" });
|
|
76
|
+
try {
|
|
77
|
+
await pipeline(counted(), sink);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// pipeline rejects the moment the source throws, which can be before the file the
|
|
81
|
+
// write stream is opening exists. Removing it first would leave the stray file the
|
|
82
|
+
// reconcile has to collect at the next boot, so the close is waited for.
|
|
83
|
+
await closed(sink);
|
|
84
|
+
discard(deps, id, target, "staging.aborted");
|
|
85
|
+
notify({
|
|
86
|
+
type: "staging.failed",
|
|
87
|
+
stagedFileId: id,
|
|
88
|
+
stageKind: input.stageKind,
|
|
89
|
+
originalFilename,
|
|
90
|
+
detail: messageOf(error),
|
|
91
|
+
});
|
|
92
|
+
deps.log.write("error", "staging.failed", {
|
|
93
|
+
detail: `${id} ${originalFilename}: ${messageOf(error)}`,
|
|
94
|
+
});
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (bytes === 0) {
|
|
98
|
+
discard(deps, id, target, "staging.empty");
|
|
99
|
+
return { ok: false, reason: "empty-file" };
|
|
100
|
+
}
|
|
101
|
+
markStagedFileCopied(deps.db, id, bytes);
|
|
102
|
+
progress("staged");
|
|
103
|
+
return { ok: true, file: { ...file, bytes, state: "staged" } };
|
|
104
|
+
}
|
|
105
|
+
export function attachStagedFile(deps, input) {
|
|
106
|
+
const staged = stagedFileById(deps.db, input.stagedFileId);
|
|
107
|
+
if (staged === undefined) {
|
|
108
|
+
return { ok: false, reason: "unknown-staged-file" };
|
|
109
|
+
}
|
|
110
|
+
// A run never starts with provided content that is still copying (logic/05 §Q44).
|
|
111
|
+
if (staged.state !== "staged") {
|
|
112
|
+
return { ok: false, reason: "still-copying" };
|
|
113
|
+
}
|
|
114
|
+
const index = input.index ?? 1;
|
|
115
|
+
const name = outputFileName(input.role, index, extensionOf(staged.originalFilename), staged.stageKind);
|
|
116
|
+
const target = outputPath(deps.paths, input.projectId, name);
|
|
117
|
+
const source = stagingPath(deps.paths, staged.path);
|
|
118
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
119
|
+
// Copied, not renamed. A rename cannot be undone when the caller's transaction rolls
|
|
120
|
+
// back, so a run that failed on its fourth file would have left the first three with
|
|
121
|
+
// restored staged_files rows pointing at files that had already moved, and every retry
|
|
122
|
+
// of the same form would fail the same way. Copying also survives a staging directory
|
|
123
|
+
// mounted separately from projects/, which a rename cannot (EXDEV).
|
|
124
|
+
copyFileSync(source, target);
|
|
125
|
+
const output = {
|
|
126
|
+
id: deps.ids.next(),
|
|
127
|
+
projectId: input.projectId,
|
|
128
|
+
stageKind: staged.stageKind,
|
|
129
|
+
role: input.role,
|
|
130
|
+
path: name,
|
|
131
|
+
originalFilename: staged.originalFilename,
|
|
132
|
+
bytes: staged.bytes,
|
|
133
|
+
durationMs: null,
|
|
134
|
+
meta: input.role === "image" ? { index } : {},
|
|
135
|
+
createdAt: deps.clock.now().toISOString(),
|
|
136
|
+
};
|
|
137
|
+
// The file already moved. If this fails the file is under projects/ with no row,
|
|
138
|
+
// which is exactly what the boot reconcile collects.
|
|
139
|
+
// A savepoint rather than BEGIN, so startRun can attach several files inside the one
|
|
140
|
+
// transaction that creates the project.
|
|
141
|
+
try {
|
|
142
|
+
transact(deps.db, () => {
|
|
143
|
+
insertOutput(deps.db, output);
|
|
144
|
+
deleteStagedFile(deps.db, staged.id);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
deps.log.write("error", "storage.attach", {
|
|
149
|
+
projectId: input.projectId,
|
|
150
|
+
detail: `${staged.id} → ${name}: ${messageOf(error)}`,
|
|
151
|
+
});
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, output, stagedSource: source };
|
|
155
|
+
}
|
|
156
|
+
// The other half of attachStagedFile: called once the transaction that attached the file
|
|
157
|
+
// has committed. A failure here is not worth failing the run over, because the file is a
|
|
158
|
+
// duplicate of one the project already holds and the next boot's reconcile clears it.
|
|
159
|
+
export function dropStagedSource(deps, source) {
|
|
160
|
+
try {
|
|
161
|
+
rmSync(source, { force: true });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
deps.log.write("warn", "staging.source", { detail: `${source}: ${messageOf(error)}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// logic/05 step 1: a stage set to Provide can carry pasted text instead of a file, and
|
|
168
|
+
// the project holds it as an output like any other. Unlike an attached upload this needs
|
|
169
|
+
// no two-phase dance: the text came in on the request, so a rollback loses nothing the
|
|
170
|
+
// caller cannot write again, and the file left under a project id that was rolled back
|
|
171
|
+
// is an orphan the boot reconcile removes.
|
|
172
|
+
// ceiling: the paste is stored as typed. logic/05 §Q37 also asks for markdown syntax to
|
|
173
|
+
// be stripped for the narration source; that reduction belongs to the narration slice
|
|
174
|
+
// that reads this file, and lands with it.
|
|
175
|
+
export function storeText(deps, input) {
|
|
176
|
+
const name = outputFileName(input.role, 1, ".txt", input.stageKind);
|
|
177
|
+
const target = outputPath(deps.paths, input.projectId, name);
|
|
178
|
+
const bytes = Buffer.from(input.text, "utf8");
|
|
179
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
180
|
+
writeFileSync(target, bytes, { mode: 0o600 });
|
|
181
|
+
const output = {
|
|
182
|
+
id: deps.ids.next(),
|
|
183
|
+
projectId: input.projectId,
|
|
184
|
+
stageKind: input.stageKind,
|
|
185
|
+
role: input.role,
|
|
186
|
+
path: name,
|
|
187
|
+
originalFilename: null,
|
|
188
|
+
bytes: bytes.byteLength,
|
|
189
|
+
durationMs: null,
|
|
190
|
+
meta: {},
|
|
191
|
+
createdAt: deps.clock.now().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
insertOutput(deps.db, output);
|
|
194
|
+
return output;
|
|
195
|
+
}
|
|
196
|
+
export function discardStagedFile(deps, id) {
|
|
197
|
+
const staged = stagedFileById(deps.db, id);
|
|
198
|
+
if (staged === undefined) {
|
|
199
|
+
return { ok: false, reason: "unknown-staged-file" };
|
|
200
|
+
}
|
|
201
|
+
discard(deps, id, stagingPath(deps.paths, staged.path), "staging.discarded");
|
|
202
|
+
return { ok: true };
|
|
203
|
+
}
|
|
204
|
+
function discard(deps, id, target, event) {
|
|
205
|
+
try {
|
|
206
|
+
rmSync(target, { force: true });
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
// The row stays `copying`, so the next boot's reconcile removes the file instead.
|
|
210
|
+
deps.log.write("warn", event, { detail: `${id}: ${messageOf(error)}` });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
deleteStagedFile(deps.db, id);
|
|
214
|
+
}
|
|
215
|
+
function closed(sink) {
|
|
216
|
+
return sink.closed
|
|
217
|
+
? Promise.resolve()
|
|
218
|
+
: new Promise((resolve) => {
|
|
219
|
+
sink.once("close", resolve);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function messageOf(error) {
|
|
223
|
+
return error instanceof Error ? error.message : String(error);
|
|
224
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 07-operations: the collector URL is built into the release and is not user
|
|
2
|
+
// configurable. The environment variable below is a test seam, not a documented knob:
|
|
3
|
+
// packages/app/test/telemetry-flush.test.ts points it at a fake collector.
|
|
4
|
+
export const defaultCollectorUrl = "https://collector.slopify.stream";
|
|
5
|
+
export function collectorEndpoint(env) {
|
|
6
|
+
const override = env.SLOPIFY_COLLECTOR_URL?.trim();
|
|
7
|
+
if (override === undefined || override === "") {
|
|
8
|
+
return defaultCollectorUrl;
|
|
9
|
+
}
|
|
10
|
+
const parsed = URL.parse(override);
|
|
11
|
+
if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
|
|
12
|
+
return defaultCollectorUrl;
|
|
13
|
+
}
|
|
14
|
+
return override.replace(/\/+$/, "");
|
|
15
|
+
}
|
|
16
|
+
export function httpPostEvents(endpoint, timeoutMs) {
|
|
17
|
+
return async (events) => {
|
|
18
|
+
if (events.length === 0) {
|
|
19
|
+
return { ok: true };
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetch(`${endpoint}/events`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ events }),
|
|
26
|
+
// Without this an unanswering collector would hold a socket open for as long as
|
|
27
|
+
// the app runs, and the shutdown would wait on it.
|
|
28
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
29
|
+
});
|
|
30
|
+
// Nothing here reads the answer, and undici holds the connection open until a body
|
|
31
|
+
// is consumed or cancelled.
|
|
32
|
+
await response.body?.cancel();
|
|
33
|
+
if (response.ok) {
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
retriable: response.status >= 500 || response.status === 429,
|
|
39
|
+
reason: `the collector answered ${response.status}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// Offline is the ordinary case, not a fault: the events stay queued and the user is
|
|
44
|
+
// told nothing (logic/16 step 5).
|
|
45
|
+
return { ok: false, retriable: true, reason: messageOf(error) };
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function messageOf(error) {
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { machineOf, markDelivered, undeliveredEvents } from "./repo.js";
|
|
2
|
+
// ceiling: 200 events per request against the collector's cap of 500, and at most 25
|
|
3
|
+
// requests per flush. A queue longer than 5000 undelivered events drains over several
|
|
4
|
+
// flushes rather than in one burst, which is the back-pressure an unbounded queue
|
|
5
|
+
// (logic/16 §Q134) needs against a collector that has been away for months.
|
|
6
|
+
export const batchSize = 200;
|
|
7
|
+
const maxBatchesPerFlush = 25;
|
|
8
|
+
// logic/16 step 5: the queue is flushed at app start and after each new event. Nothing
|
|
9
|
+
// here touches a pipeline, and an unreachable collector costs one refused socket.
|
|
10
|
+
export async function flush(deps) {
|
|
11
|
+
const machine = machineOf(deps.db);
|
|
12
|
+
if (machine === undefined) {
|
|
13
|
+
// Before the notice is dismissed there is no machine id to send events under, and
|
|
14
|
+
// logic/16 step 1 says nothing leaves until then.
|
|
15
|
+
return { delivered: 0, dropped: 0 };
|
|
16
|
+
}
|
|
17
|
+
let delivered = 0;
|
|
18
|
+
let dropped = 0;
|
|
19
|
+
for (let batch = 0; batch < maxBatchesPerFlush; batch += 1) {
|
|
20
|
+
const events = undeliveredEvents(deps.db, batchSize);
|
|
21
|
+
if (events.length === 0) {
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
const outcome = await deps.post(events.map((event) => envelope(event, machine.machineId)));
|
|
25
|
+
const ids = events.map((event) => event.id);
|
|
26
|
+
if (outcome.ok) {
|
|
27
|
+
markDelivered(deps.db, ids, deps.clock.now().toISOString());
|
|
28
|
+
delivered += ids.length;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (outcome.retriable) {
|
|
32
|
+
// Offline, or the collector is having a bad day. The events stay queued and the
|
|
33
|
+
// user is told nothing (logic/16 step 5).
|
|
34
|
+
deps.log.write("info", "telemetry.deferred", {
|
|
35
|
+
detail: `${ids.length} events stay queued: ${outcome.reason}`,
|
|
36
|
+
});
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
// The collector refused the batch itself. Retrying it would refuse it again and park
|
|
40
|
+
// every later event behind it, so it is marked delivered and lost on purpose.
|
|
41
|
+
markDelivered(deps.db, ids, deps.clock.now().toISOString());
|
|
42
|
+
dropped += ids.length;
|
|
43
|
+
deps.log.write("warn", "telemetry.dropped", {
|
|
44
|
+
detail: `${ids.length} events were refused and discarded: ${outcome.reason}`,
|
|
45
|
+
});
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return { delivered, dropped };
|
|
49
|
+
}
|
|
50
|
+
// The schedule from logic/16 step 5, debounced: a fan-out that finishes four stages at
|
|
51
|
+
// once sends one request, not four. Every timer is unref'd, so a queued flush never
|
|
52
|
+
// keeps the process alive.
|
|
53
|
+
export function createFlusher(deps, delayMs) {
|
|
54
|
+
let timer;
|
|
55
|
+
let running = false;
|
|
56
|
+
let again = false;
|
|
57
|
+
let stopped = false;
|
|
58
|
+
const soon = () => {
|
|
59
|
+
if (stopped || timer !== undefined) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (running) {
|
|
63
|
+
// Events recorded while a flush is in flight would otherwise wait for the next
|
|
64
|
+
// one, and on a short run there is no next one.
|
|
65
|
+
again = true;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
timer = setTimeout(run, delayMs);
|
|
69
|
+
timer.unref();
|
|
70
|
+
};
|
|
71
|
+
const run = () => {
|
|
72
|
+
timer = undefined;
|
|
73
|
+
running = true;
|
|
74
|
+
flush(deps)
|
|
75
|
+
.catch((error) => {
|
|
76
|
+
// The timer has no caller to fail: a broken flush is logged and the next one
|
|
77
|
+
// tries again (logic/16 step 5).
|
|
78
|
+
deps.log.write("warn", "telemetry.flush", { detail: messageOf(error) });
|
|
79
|
+
})
|
|
80
|
+
.finally(() => {
|
|
81
|
+
running = false;
|
|
82
|
+
if (again) {
|
|
83
|
+
again = false;
|
|
84
|
+
soon();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
soon,
|
|
90
|
+
stop: () => {
|
|
91
|
+
stopped = true;
|
|
92
|
+
again = false;
|
|
93
|
+
if (timer !== undefined) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
timer = undefined;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function envelope(event, machineId) {
|
|
101
|
+
return {
|
|
102
|
+
id: event.id,
|
|
103
|
+
machineId,
|
|
104
|
+
type: event.type,
|
|
105
|
+
payload: event.payload,
|
|
106
|
+
createdAt: event.createdAt,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function messageOf(error) {
|
|
110
|
+
return error instanceof Error ? error.message : String(error);
|
|
111
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { writeEvent } from "./record.js";
|
|
4
|
+
import { insertMachine, machineOf } from "./repo.js";
|
|
5
|
+
// logic/16 step 1: the notice is shown when no machine id exists.
|
|
6
|
+
export function noticeSeen(db) {
|
|
7
|
+
return machineOf(db) !== undefined;
|
|
8
|
+
}
|
|
9
|
+
// logic/16 step 1 and mockup/02-first-run-notice: the id is created when the user
|
|
10
|
+
// dismisses the notice, never at boot, so nothing is collected before the promise about
|
|
11
|
+
// what is collected has been made. Deleting the data directory makes a new install
|
|
12
|
+
// (§Q127); there is no reset control and no opt-out.
|
|
13
|
+
export function dismissNotice(deps) {
|
|
14
|
+
const existing = machineOf(deps.db);
|
|
15
|
+
if (existing !== undefined) {
|
|
16
|
+
return existing;
|
|
17
|
+
}
|
|
18
|
+
// Not the ULID generator the rest of the app uses: a ULID is sortable and carries the
|
|
19
|
+
// millisecond it was made, so a machine id built from one would order every install by
|
|
20
|
+
// install time and leak it to the collector. This one is random and nothing else.
|
|
21
|
+
const machine = {
|
|
22
|
+
machineId: randomUUID(),
|
|
23
|
+
noticeSeenAt: deps.clock.now().toISOString(),
|
|
24
|
+
appVersion: deps.appVersion,
|
|
25
|
+
};
|
|
26
|
+
transact(deps.db, () => {
|
|
27
|
+
insertMachine(deps.db, machine);
|
|
28
|
+
// logic/16 step 2: exactly one install event per machine. Written in the same
|
|
29
|
+
// transaction as the row that makes it unique, so a machine cannot exist without it.
|
|
30
|
+
writeEvent(deps, "install", {});
|
|
31
|
+
});
|
|
32
|
+
return machine;
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
|
+
// logic/16 step 2: one install event, one per project created, one per stage completing.
|
|
4
|
+
export const telemetryEventTypes = ["install", "project.created", "stage.completed"];
|
|
5
|
+
// logic/16 step 2 counts finer than a stage: "one event per stage completing: research,
|
|
6
|
+
// article, each intro/outro text, audio per segment (body, intro, outro), images,
|
|
7
|
+
// thumbnail, video". The three that are not whole stages are named by `segment` beside
|
|
8
|
+
// the stage that produced them - the article stage writes the intro and outro texts
|
|
9
|
+
// (logic/07 step 5) and the audio stage narrates all three (logic/08 step 5) - so the
|
|
10
|
+
// event type stays `stage.completed` and the pair (stage, segment) says which unit it is.
|
|
11
|
+
export const audioSegments = ["body", "intro", "outro"];
|
|
12
|
+
export const noTokens = { tokensIn: 0, tokensOut: 0 };
|
|
13
|
+
export function plusUsage(tokens, usage) {
|
|
14
|
+
return {
|
|
15
|
+
tokensIn: tokens.tokensIn + (usage?.inputTokens ?? 0),
|
|
16
|
+
tokensOut: tokens.tokensOut + (usage?.outputTokens ?? 0),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// The whole privacy surface. logic/16 step 4 bars API keys, prompt bodies, keyword
|
|
20
|
+
// values, titles, article and research text, files, filenames, OS, locale and hardware
|
|
21
|
+
// from a payload, and mockup/02-first-run-notice promises the user exactly that. The
|
|
22
|
+
// schema below is strict, so a caller that hands `record` anything not named here is
|
|
23
|
+
// rejected before the row is written; model.test.ts pins the set and record.test.ts
|
|
24
|
+
// sweeps every event type against it.
|
|
25
|
+
export const payloadSchema = z.strictObject({
|
|
26
|
+
appVersion: z.string().max(40),
|
|
27
|
+
stage: z.enum(stageKinds).optional(),
|
|
28
|
+
segment: z.enum(audioSegments).optional(),
|
|
29
|
+
// ceiling: provider and model are length-capped, not matched against an allow-list,
|
|
30
|
+
// because model ids are the providers' own and change weekly. The cap is what stops a
|
|
31
|
+
// caller from smuggling prose through the one free-text pair the payload has.
|
|
32
|
+
provider: z.string().max(40).optional(),
|
|
33
|
+
model: z.string().max(120).optional(),
|
|
34
|
+
tokensIn: z.number().int().nonnegative().optional(),
|
|
35
|
+
tokensOut: z.number().int().nonnegative().optional(),
|
|
36
|
+
audioSeconds: z.number().nonnegative().finite().optional(),
|
|
37
|
+
images: z.number().int().nonnegative().optional(),
|
|
38
|
+
thumbnails: z.number().int().nonnegative().optional(),
|
|
39
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { payloadSchema } from "./model.js";
|
|
2
|
+
import { insertTelemetryEvent, machineOf } from "./repo.js";
|
|
3
|
+
// Appends one row to the local queue and hands it back. Throws: the notice dismissal
|
|
4
|
+
// needs the machine row and the install event to land together or not at all. Pipeline
|
|
5
|
+
// callers use `record` below instead.
|
|
6
|
+
export function writeEvent(deps, type, counters) {
|
|
7
|
+
const event = {
|
|
8
|
+
id: deps.ids.next(),
|
|
9
|
+
type,
|
|
10
|
+
// Strict: the payload is checked against the allow-list before it is written, so a
|
|
11
|
+
// caller cannot widen what leaves this machine by passing an extra field.
|
|
12
|
+
payload: payloadSchema.parse({ appVersion: deps.appVersion, ...counters }),
|
|
13
|
+
createdAt: deps.clock.now().toISOString(),
|
|
14
|
+
deliveredAt: null,
|
|
15
|
+
};
|
|
16
|
+
insertTelemetryEvent(deps.db, event);
|
|
17
|
+
return event;
|
|
18
|
+
}
|
|
19
|
+
// logic/16 step 5: telemetry never blocks and never fails a pipeline stage. This is the
|
|
20
|
+
// one place in the codebase where an error is swallowed rather than propagated - a
|
|
21
|
+
// failure to count a finished render must not undo the render. It is logged, so the
|
|
22
|
+
// failure is still visible in <data-dir>/logs, and nothing else in this module catches.
|
|
23
|
+
export function record(deps, type, counters) {
|
|
24
|
+
try {
|
|
25
|
+
// logic/16 step 2 precondition: a machine id exists for every event except the
|
|
26
|
+
// install event that creates it. Before the notice is dismissed nothing is written,
|
|
27
|
+
// so nothing about a run the user has not been told about can ever be sent.
|
|
28
|
+
if (type !== "install" && machineOf(deps.db) === undefined) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
writeEvent(deps, type, counters);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
deps.log.write("warn", "telemetry.record", {
|
|
35
|
+
detail: `${type}: ${messageOf(error)}`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function messageOf(error) {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { payloadSchema, telemetryEventTypes } from "./model.js";
|
|
3
|
+
const eventRow = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
type: z.enum(telemetryEventTypes),
|
|
6
|
+
payload: z.string(),
|
|
7
|
+
created_at: z.string(),
|
|
8
|
+
delivered_at: z.string().nullable(),
|
|
9
|
+
});
|
|
10
|
+
const machineRow = z.object({
|
|
11
|
+
machine_id: z.string(),
|
|
12
|
+
notice_seen_at: z.string().nullable(),
|
|
13
|
+
app_version: z.string(),
|
|
14
|
+
});
|
|
15
|
+
export function insertTelemetryEvent(db, event) {
|
|
16
|
+
db.prepare("INSERT INTO telemetry_events (id, type, payload, created_at, delivered_at) VALUES (?, ?, ?, ?, ?)").run(event.id, event.type, JSON.stringify(event.payload), event.createdAt, event.deliveredAt);
|
|
17
|
+
}
|
|
18
|
+
// The queue in the order it was written. By rowid rather than by created_at: two events
|
|
19
|
+
// recorded in the same millisecond carry the same timestamp and their ULIDs are random
|
|
20
|
+
// within it, so a timestamp sort would hand back the install after the project it
|
|
21
|
+
// preceded. The collector deduplicates by id, so re-sending the head of the queue after
|
|
22
|
+
// an ambiguous failure is safe (logic/16 §Q134).
|
|
23
|
+
export function undeliveredEvents(db, limit) {
|
|
24
|
+
return db
|
|
25
|
+
.prepare("SELECT * FROM telemetry_events WHERE delivered_at IS NULL ORDER BY rowid LIMIT ?")
|
|
26
|
+
.all(limit)
|
|
27
|
+
.map((row) => toEvent(eventRow.parse(row)));
|
|
28
|
+
}
|
|
29
|
+
// logic/16 step 6 and §Q132: the Usage page's totals are the sum of the whole local log,
|
|
30
|
+
// delivered or not, and §Q134 keeps that log forever.
|
|
31
|
+
//
|
|
32
|
+
// ceiling: every row is read and folded in memory. One event per stage of one run is a
|
|
33
|
+
// few dozen bytes and a machine would need a hundred thousand runs to make this cost a
|
|
34
|
+
// page load; the upgrade is to sum the counters in SQL with json_extract.
|
|
35
|
+
export function allTelemetryEvents(db) {
|
|
36
|
+
return db
|
|
37
|
+
.prepare("SELECT * FROM telemetry_events ORDER BY rowid")
|
|
38
|
+
.all()
|
|
39
|
+
.map((row) => toEvent(eventRow.parse(row)));
|
|
40
|
+
}
|
|
41
|
+
export function markDelivered(db, ids, at) {
|
|
42
|
+
if (ids.length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const holes = ids.map(() => "?").join(", ");
|
|
46
|
+
db.prepare(`UPDATE telemetry_events SET delivered_at = ? WHERE id IN (${holes})`).run(at, ...ids);
|
|
47
|
+
}
|
|
48
|
+
// 02-models: a single row, so the first one is the machine.
|
|
49
|
+
export function machineOf(db) {
|
|
50
|
+
const row = db.prepare("SELECT * FROM machine LIMIT 1").get();
|
|
51
|
+
return row === undefined ? undefined : toMachine(machineRow.parse(row));
|
|
52
|
+
}
|
|
53
|
+
export function insertMachine(db, machine) {
|
|
54
|
+
db.prepare("INSERT INTO machine (machine_id, notice_seen_at, app_version) VALUES (?, ?, ?)").run(machine.machineId, machine.noticeSeenAt, machine.appVersion);
|
|
55
|
+
}
|
|
56
|
+
function toEvent(row) {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
type: row.type,
|
|
60
|
+
// A JSON column never reaches a caller as a string, and the payload is re-checked on
|
|
61
|
+
// the way out: a row hand-edited into the database still cannot widen what is sent.
|
|
62
|
+
payload: payloadSchema.parse(JSON.parse(row.payload)),
|
|
63
|
+
createdAt: row.created_at,
|
|
64
|
+
deliveredAt: row.delivered_at,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function toMachine(row) {
|
|
68
|
+
return {
|
|
69
|
+
machineId: row.machine_id,
|
|
70
|
+
noticeSeenAt: row.notice_seen_at,
|
|
71
|
+
appVersion: row.app_version,
|
|
72
|
+
};
|
|
73
|
+
}
|