@pithy-sh/storage 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.
@@ -0,0 +1,226 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { chunkByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
7
+ import { StorageObject } from "../data/storageObject";
8
+ import { STORAGE_OBJECTS_TABLE, type StorageDatabase } from "../data/tables";
9
+ import { isDerivedObjectKey, OBJECT_KEY_PREFIX } from "../object/key";
10
+ import type { ObjectStore } from "../object/store";
11
+ import { QUOTA_COUNTED_STATUSES } from "../quota/quota";
12
+
13
+ /**
14
+ * The orphan sweep: reconciling two stores that can only ever be *eventually* consistent.
15
+ *
16
+ * A file is two writes — a row in D1 and an object in R2 — and no transaction spans them. Every
17
+ * interruption between the two leaves one behind, and the divergence goes **both** ways:
18
+ *
19
+ * - **A row with no object.** An upload started (the row reserves its quota) and never finished.
20
+ * The reservation is real: it counts against the owner's limit forever unless something returns
21
+ * it. This is the direction that silently locks a user out of their own quota.
22
+ * - **An object no row bills for.** A delete removed the row and then failed on R2, a client uploaded
23
+ * to a presigned URL after its row was deleted, or an upload was aborted and the URL used anyway —
24
+ * the row survives as `failed` and reserves nothing. The bytes are unreachable and unbilled to
25
+ * anyone — a pure leak, paid for monthly.
26
+ *
27
+ * A sweep that only walked one direction would leave the other to accumulate indefinitely, so this
28
+ * one does both, in that order: reclaiming rows first means an object whose row was just dropped is
29
+ * caught by the same run rather than waiting a day for the next.
30
+ *
31
+ * **Two things keep it from deleting live data.** Only keys carrying storage's own `obj/` prefix are
32
+ * ever considered, so a bucket shared with another tool is left alone. And an object is only orphaned
33
+ * if it is *older than the cutoff*: a row is written before its upload URL is minted, but R2's view
34
+ * and D1's view of "now" are independent, and an object uploaded seconds ago whose row this run has
35
+ * not yet read is not an orphan — it is a race. The age check turns that race into a wait.
36
+ *
37
+ * Pure over injected dependencies: no bindings, no `cloudflare:workers`, no config import. The
38
+ * Workflow in `worker.ts` is a durable shell around this function, which is what makes the reconciling
39
+ * logic testable against a deliberately divergent bucket and table.
40
+ */
41
+
42
+ /** What one sweep run needs. */
43
+ export interface SweepDeps {
44
+ /** The storage database. */
45
+ db: StorageDatabase;
46
+ /** The object plane — used for `abortMultipart`, `delete`, and the bucket listing. */
47
+ store: ObjectStore;
48
+ /** The current time. */
49
+ now: () => Date;
50
+ /** How old a `pending` row (and an unmatched object) must be before it is reclaimed. */
51
+ ttlSeconds: number;
52
+ /** Report and delete nothing. */
53
+ dryRun?: boolean;
54
+ /** Cap on bucket pages scanned in one run. */
55
+ maxPages?: number;
56
+ /**
57
+ * Keys per bucket list page. Defaults to R2's own cap of 1,000, which is what a real run should ask
58
+ * for. Exposed so a test can force pagination without writing a thousand objects — the paging and
59
+ * truncation logic is otherwise only reachable at a scale no test suite should pay for.
60
+ */
61
+ pageSize?: number;
62
+ }
63
+
64
+ /** What one sweep run found and did. */
65
+ export interface SweepResult {
66
+ /** `pending` rows past the TTL: their uploads aborted, their bytes dropped, their rows removed. */
67
+ reclaimedRows: number;
68
+ /** Bucket objects with no row, older than the cutoff: deleted. */
69
+ deletedObjects: number;
70
+ /** Bucket objects examined — the run's cost, and how a `maxPages` cap is noticed. */
71
+ scannedObjects: number;
72
+ /** Whether the run stopped at its page cap with more of the bucket unexamined. */
73
+ truncated: boolean;
74
+ /** Whether this run only reported. */
75
+ dryRun: boolean;
76
+ }
77
+
78
+ /** R2's own cap on a list page. Asking for more is silently clamped, so ask for exactly this. */
79
+ const LIST_PAGE_SIZE = 1000;
80
+
81
+ /** Default cap on pages per run: a million keys, which is a long sweep and a sane bound. */
82
+ const DEFAULT_MAX_PAGES = 1000;
83
+
84
+ /**
85
+ * The membership lookup binds its status filter besides the key list, so the chunk gets D1's budget
86
+ * less those two. Add another `where` to that query and this number moves with it.
87
+ */
88
+ const CLAIMED_KEYS_FIXED_PARAMETERS = QUOTA_COUNTED_STATUSES.length;
89
+
90
+ /**
91
+ * Reclaim `pending` rows older than the cutoff.
92
+ *
93
+ * Each row's in-flight multipart is aborted (R2 bills stored parts, so leaving them is a real cost),
94
+ * then any bytes that did land are deleted, then the row goes — object first, row second, so a
95
+ * failure part-way leaves an object with no row, which the second phase collects. The other order
96
+ * would leave a row pointing at nothing, which nothing collects.
97
+ *
98
+ * Both R2 calls are best-effort. An upload id R2 has already forgotten, or a key that was never
99
+ * written, must not stop the row from being reclaimed — the whole point of the run is to unwedge
100
+ * state, not to insist it was tidy.
101
+ */
102
+ async function reclaimPendingRows(deps: SweepDeps, cutoff: Date): Promise<number> {
103
+ const rows = await deps.db
104
+ .selectFrom(STORAGE_OBJECTS_TABLE)
105
+ .selectAll()
106
+ .where("status", "=", "pending")
107
+ // Through the codec, never a hand-written epoch: the column's encoding is the schema's to decide.
108
+ .where("createdAt", "<", SQLiteDate.encode(cutoff))
109
+ .execute();
110
+
111
+ if (deps.dryRun) return rows.length;
112
+
113
+ let reclaimed = 0;
114
+ for (const row of rows) {
115
+ const object = StorageObject.parse(row);
116
+ if (object.uploadId) await deps.store.abortMultipart(object.key, object.uploadId).catch(() => {});
117
+ await deps.store.delete(object.key).catch(() => {});
118
+ await deps.db.deleteFrom(STORAGE_OBJECTS_TABLE).where("id", "=", object.id).execute();
119
+ reclaimed += 1;
120
+ }
121
+ return reclaimed;
122
+ }
123
+
124
+ /**
125
+ * Delete bucket objects that no row claims.
126
+ *
127
+ * **A row claims a key only while it bills for it.** The membership test filters on the same statuses
128
+ * the quota sums ({@link QUOTA_COUNTED_STATUSES}), so a `failed` row — the record an abort or a refused
129
+ * completion leaves behind — reserves nothing *and* shields nothing. Without that filter the two
130
+ * predicates disagree, and the disagreement is a leak with no floor: abort a single-PUT upload, PUT to
131
+ * the presigned URL you still hold, and the surviving `failed` row marks the key as claimed forever
132
+ * while `usedBytes` counts none of it. Phase one skips it (it only reclaims `pending` rows), so nothing
133
+ * automated would ever collect those bytes again.
134
+ *
135
+ * The row itself is left alone either way. It is the owner's record that an upload was attempted and
136
+ * abandoned; only its claim on the bytes is withdrawn.
137
+ *
138
+ * The membership test is a batched `WHERE key IN (…)` rather than one query per key: a page is a
139
+ * thousand keys, and a thousand round trips to D1 is the difference between a sweep that finishes and
140
+ * one that times out. It is batched rather than one query per page because D1 caps a statement at 100
141
+ * bound parameters and a page carries up to a thousand keys — so the page is chunked under that cap
142
+ * and the claimed sets unioned before anything is deleted. The two limits are unrelated knobs: the
143
+ * page size is R2's listing cost, the chunk size is D1's statement cost, and tying one to the other
144
+ * would be a coincidence rather than a reason.
145
+ *
146
+ * Only keys this capability derived are considered, and only ones older than the cutoff — see the
147
+ * module comment on why the age check is load-bearing rather than cautious.
148
+ */
149
+ async function deleteOrphanedObjects(
150
+ deps: SweepDeps,
151
+ cutoff: Date,
152
+ ): Promise<{ deleted: number; scanned: number; truncated: boolean }> {
153
+ const maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
154
+ let cursor: string | undefined;
155
+ let deleted = 0;
156
+ let scanned = 0;
157
+ let pages = 0;
158
+
159
+ for (;;) {
160
+ const page = await deps.store.list({ prefix: OBJECT_KEY_PREFIX, cursor, limit: deps.pageSize ?? LIST_PAGE_SIZE });
161
+ pages += 1;
162
+ scanned += page.objects.length;
163
+
164
+ const candidates = page.objects.filter(
165
+ (object) => isDerivedObjectKey(object.key) && object.uploaded.getTime() < cutoff.getTime(),
166
+ );
167
+ if (candidates.length > 0) {
168
+ const keys = candidates.map((object) => object.key);
169
+ const claimed = new Set<string>();
170
+ for (const group of chunkByBoundParameters(keys, CLAIMED_KEYS_FIXED_PARAMETERS)) {
171
+ const known = await deps.db
172
+ .selectFrom(STORAGE_OBJECTS_TABLE)
173
+ .select("key")
174
+ .where("key", "in", group)
175
+ .where("status", "in", [...QUOTA_COUNTED_STATUSES])
176
+ .execute();
177
+ for (const row of known) claimed.add(row.key);
178
+ }
179
+ for (const object of candidates) {
180
+ if (claimed.has(object.key)) continue;
181
+ if (!deps.dryRun) await deps.store.delete(object.key).catch(() => {});
182
+ deleted += 1;
183
+ }
184
+ }
185
+
186
+ cursor = page.cursor;
187
+ if (!cursor) return { deleted, scanned, truncated: false };
188
+ if (pages >= maxPages) return { deleted, scanned, truncated: true };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Run one sweep. Idempotent by construction — a second run over a reconciled bucket finds nothing and
194
+ * deletes nothing — so a retried Workflow step, a manual trigger after a cron, and a nervous operator
195
+ * running it twice all cost the same.
196
+ */
197
+ export async function sweepStorage(deps: SweepDeps): Promise<SweepResult> {
198
+ const cutoff = new Date(deps.now().getTime() - deps.ttlSeconds * 1000);
199
+ const reclaimedRows = await reclaimPendingRows(deps, cutoff);
200
+ const objects = await deleteOrphanedObjects(deps, cutoff);
201
+ return {
202
+ reclaimedRows,
203
+ deletedObjects: objects.deleted,
204
+ scannedObjects: objects.scanned,
205
+ truncated: objects.truncated,
206
+ dryRun: deps.dryRun === true,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Report a finished run. The tally is the sweep's only visible output — a sweep whose findings are
212
+ * invisible is a sweep nobody can tell has stopped working — so it goes out as fields, which is what
213
+ * makes "how much quota did we reclaim this month" a query rather than a grep.
214
+ *
215
+ * A truncated run is `warn`, not `info`. It succeeded and left part of the bucket unexamined, so the
216
+ * orphans it did not reach are paid for until a later pass gets to them. That is the one outcome an
217
+ * operator should be able to find without reading every run that went fine.
218
+ */
219
+ export function reportSweep(log: Logger, result: SweepResult): void {
220
+ const fields = { ...result };
221
+ if (result.truncated) {
222
+ log.warn("storage sweep stopped at its page cap", fields);
223
+ return;
224
+ }
225
+ log.info("storage sweep complete", fields);
226
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
5
+ import { NonRetryableError } from "cloudflare:workflows";
6
+ import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
7
+ import { bindWorkflowContext, createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
8
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
9
+ import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
10
+ import { configureSharedSecrets } from "@pithy-sh/secrets/src/sharedSecretsStore";
11
+ import { StorageConfig } from "../config/config";
12
+ import { storageDatabase } from "../data/tables";
13
+ import { objectStore } from "../object/store";
14
+ import { STORAGE_R2_SECRET, storageSecretsRegistry } from "../secret/registry";
15
+ import { storageWorkflowRetry } from "./retryPolicy";
16
+ import { STORAGE_CAPABILITY, StorageSweepParams } from "./specs";
17
+ import { reportSweep, type SweepResult, sweepStorage } from "./sweep";
18
+
19
+ /**
20
+ * The prebuilt storage sweep worker. `pithy storage provision` deploys one per environment; the
21
+ * adopter authors none of it. It hosts a single Workflow — the orphan sweep — and fires it on a daily
22
+ * cron.
23
+ *
24
+ * **Why its own worker rather than the app worker's `scheduled()`.** The sweep lists an entire bucket
25
+ * and can delete from it. Keeping it out of the request-serving deployment means it cannot compete
26
+ * with a user's download for CPU, and — more to the point — the app worker never needs a binding that
27
+ * can delete arbitrary objects. It is also a Workflow rather than a plain scheduled pass because a
28
+ * Worker invocation is wall-clock bounded while a Workflow step is not: a bucket with a million keys
29
+ * is a thousand list pages, and that is a job you want checkpointed rather than restarted.
30
+ *
31
+ * This module imports `cloudflare:workers`, so it runs only in the Workers runtime and is excluded
32
+ * from the node `.describe()` meta-test.
33
+ */
34
+
35
+ /** The sweep worker's env: the app database, the bucket, the master key, and the resolved config. */
36
+ export interface StorageWorkerEnv extends SecretsStoreEnv {
37
+ /** The app database the `pithy_storage_*` tables live in. */
38
+ DB: D1Database;
39
+ /** The bucket the sweep lists and deletes from. */
40
+ STORAGE_BUCKET: R2Bucket;
41
+ /** The resolved storage config as a JSON string, filled at provision. Absent falls back to defaults. */
42
+ STORAGE_CONFIG?: string;
43
+ /** This worker's own Workflow binding — how `scheduled()` starts an instance. */
44
+ STORAGE_SWEEP: { create(options?: { id?: string; params?: unknown }): Promise<unknown> };
45
+ }
46
+
47
+ // A standalone worker, not assembled by `createBackend`, so wire the shared secrets accessor directly.
48
+ // The sweep aborts multipart uploads, which is an S3-protocol call, which needs the R2 credentials.
49
+ configureSharedSecrets({ registry: storageSecretsRegistry });
50
+
51
+ /** The orphan sweep, as a cron-triggered Workflow. One step, because one run is one reconciliation. */
52
+ export class StorageSweepWorkflow extends WorkflowEntrypoint<StorageWorkerEnv, StorageSweepParams> {
53
+ override async run(event: WorkflowEvent<StorageSweepParams>, step: WorkflowStep): Promise<void> {
54
+ // Parse rather than trust: an instance can be started by a cron, by `c.var.workflows.trigger`, or
55
+ // by an operator through the dashboard, and only the first two have already been validated.
56
+ const params = StorageSweepParams.parse(event.payload ?? {});
57
+ const config = StorageConfig.parse(this.env.STORAGE_CONFIG ? JSON.parse(this.env.STORAGE_CONFIG) : {});
58
+
59
+ // A run has no request to inherit a logger from, so it builds its own and binds the run context:
60
+ // every record carries the instance id, which is the id the dashboard and `wrangler workflows`
61
+ // search by — the difference between "the sweep is noisy" and "this run is."
62
+ const log = bindWorkflowContext(createWorkerLogger({ name: STORAGE_CAPABILITY }), {
63
+ workflow: event.workflowName,
64
+ instance: event.instanceId,
65
+ env: this.env.ENVIRONMENT ?? "unknown",
66
+ });
67
+
68
+ // One durable step. A retry re-runs the whole reconciliation, which is safe precisely because the
69
+ // sweep is idempotent — a second pass over a reconciled bucket finds nothing left to do.
70
+ // Under `storageWorkflowRetry`, whose record is empty and says so: every delete and abort the sweep
71
+ // makes is already best-effort, core answers for D1, and storage retries none of its own codes. A
72
+ // run that fails costs one day, and the next day's run does the whole job. See `retryPolicy.ts`.
73
+ const result: SweepResult = await classifiedSteps(step, storageWorkflowRetry, NonRetryableError).do(
74
+ "sweep",
75
+ async () =>
76
+ sweepStorage({
77
+ db: storageDatabase(this.env.DB),
78
+ store: objectStore({
79
+ bucket: this.env.STORAGE_BUCKET,
80
+ env: this.env,
81
+ secretName: STORAGE_R2_SECRET,
82
+ }),
83
+ now: () => new Date(),
84
+ ttlSeconds: params.olderThanSeconds ?? config.pendingTtlSeconds,
85
+ dryRun: params.dryRun,
86
+ maxPages: params.maxPages,
87
+ }),
88
+ );
89
+
90
+ reportSweep(log, result);
91
+ }
92
+ }
93
+
94
+ export default {
95
+ /**
96
+ * Cron entry: start one sweep instance per fire, with empty parameters — the defaults are the
97
+ * scheduled behavior. The same Workflow stays dispatchable with a payload (`dryRun`, a shorter
98
+ * TTL, a page cap), which is how the sweep is exercised in staging without waiting for 03:00.
99
+ */
100
+ async scheduled(_controller: unknown, env: StorageWorkerEnv): Promise<void> {
101
+ await env.STORAGE_SWEEP.create({ params: {} });
102
+ },
103
+ };
@@ -0,0 +1,54 @@
1
+ {
2
+ // The prebuilt storage sweep worker. Like the email and media workers, this is a TEMPLATE, not a
3
+ // wrangler env-stanza file: staging and prod are genuinely separate workers. `pithy storage
4
+ // provision` resolves it into one complete config per environment — filling the `<...>` placeholders,
5
+ // deriving the `workflows` array and the cron from the capability's specs — and deploys each with
6
+ // `wrangler deploy --config <resolved>`. The adopter authors none of it.
7
+ // Resolved per project and env → <project>-staging-storage / <project>-prod-storage. Worker
8
+ // script names are account-scoped, so the project segment is what stops a second Pithy project's
9
+ // deploy overwriting this one's running worker instead of colliding with it.
10
+ "name": "pithy-storage",
11
+ "main": "./worker.ts",
12
+ // The compatibility date every Worker in this repository runs on. Stated once in the repository
13
+ // root's `compatibility.ts` and copied here because JSONC cannot import it —
14
+ // `cli/src/ci/compatibilityDates.test.ts` fails on any Worker older than it.
15
+ "compatibility_date": "2026-06-01",
16
+ "compatibility_flags": ["nodejs_compat"],
17
+
18
+ // No public URL. Every storage route lives in the app worker; this worker is reached only by its
19
+ // cron and by Workflow dispatch. It can delete from the bucket, so it stays off workers.dev.
20
+ "workers_dev": false,
21
+
22
+ // The app database (the pithy_storage_* tables) and the secrets database, read for the R2 credentials.
23
+ "d1_databases": [
24
+ { "binding": "DB", "database_name": "pithy-app", "database_id": "<filled-at-provision>" },
25
+ { "binding": "SECRETS", "database_name": "pithy-secrets", "database_id": "<filled-at-provision>" }
26
+ ],
27
+
28
+ // The bucket the sweep lists and deletes orphans from.
29
+ "r2_buckets": [{ "binding": "STORAGE_BUCKET", "bucket_name": "<filled-at-provision>" }],
30
+
31
+ // The master key for decrypting the R2 credentials, read through the secretsStore accessor.
32
+ "secrets_store_secrets": [
33
+ {
34
+ "binding": "SECRETS_ENCRYPTION_KEYS",
35
+ "store_id": "<filled-at-provision>",
36
+ "secret_name": "<filled-at-provision>"
37
+ }
38
+ ],
39
+
40
+ // The one Workflow this worker hosts. Rewritten at provision from `workflows/specs.ts`, so the
41
+ // binding, the class name, and the project- and environment-scoped deployed name come from the spec
42
+ // rather than from this block — it is here so the template reads as a complete config.
43
+ "workflows": [{ "binding": "STORAGE_SWEEP", "name": "pithy-storage-sweep", "class_name": "StorageSweepWorkflow" }],
44
+
45
+ // The daily sweep. Also rewritten at provision from the spec's `schedule`, for the same reason.
46
+ "triggers": { "crons": ["0 3 * * *"] },
47
+
48
+ "vars": {
49
+ // The resolved StorageConfig as one JSON blob, filled at provision from the app's storage() config.
50
+ // The sweep reads `pendingTtlSeconds` from it; absent falls back to the defaults.
51
+ "STORAGE_CONFIG": "<filled-at-provision>",
52
+ "ENVIRONMENT": "<filled-at-provision>"
53
+ }
54
+ }