@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +52 -0
- package/pithy.manifest.json +70 -0
- package/src/capability.ts +104 -0
- package/src/cloudflare-test.d.ts +14 -0
- package/src/config/config.ts +105 -0
- package/src/data/share.ts +36 -0
- package/src/data/storageObject.ts +81 -0
- package/src/data/tables.ts +37 -0
- package/src/error/errors.ts +140 -0
- package/src/http/guard.ts +32 -0
- package/src/http/handlers.ts +684 -0
- package/src/http/routes.ts +313 -0
- package/src/http/schemas.ts +214 -0
- package/src/http/serve.ts +288 -0
- package/src/index.ts +42 -0
- package/src/migrations/0001_objects.ts +86 -0
- package/src/object/cloudflare.ts +55 -0
- package/src/object/key.ts +40 -0
- package/src/object/multipart.ts +166 -0
- package/src/object/store.ts +371 -0
- package/src/provision/provisionStorage.ts +192 -0
- package/src/provision/resolveStorageConfig.ts +80 -0
- package/src/quota/quota.ts +241 -0
- package/src/secret/registry.ts +118 -0
- package/src/seeds/example.ts +120 -0
- package/src/test-utils/liveStorage.ts +261 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/retryPolicy.ts +43 -0
- package/src/workflows/specs.ts +85 -0
- package/src/workflows/sweep.ts +226 -0
- package/src/workflows/worker.ts +103 -0
- package/src/workflows/wrangler.jsonc +54 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { EXAMPLE_ADA, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
|
|
5
|
+
import { d1SeedGroup, defineSeed, type R2SeedItem, type SeedSet } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
import { StorageObject } from "../data/storageObject";
|
|
7
|
+
import { STORAGE_OBJECTS_TABLE } from "../data/tables";
|
|
8
|
+
import { OBJECT_KEY_PREFIX } from "../object/key";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Where the example set sorts among the whole project's seed registry. It runs after `auth` (100),
|
|
12
|
+
* whose example seeds the users these files belong to, so the owning identities exist first — the
|
|
13
|
+
* order encodes that dependency, exactly like the migration registry. It need not line up with
|
|
14
|
+
* {@link STORAGE_MIGRATION_ORDER}: a different registry, composed separately by `pithy seed`.
|
|
15
|
+
*/
|
|
16
|
+
const STORAGE_EXAMPLE_SEED_ORDER = 230;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A handful of demo files for the canonical example cast ({@link EXAMPLE_ADA} et al.).
|
|
20
|
+
*
|
|
21
|
+
* **Both halves are seeded — the rows and the bytes.** A storage row with no object in the bucket is
|
|
22
|
+
* precisely the divergence the orphan sweep exists to reconcile, so a fixture that seeded only D1
|
|
23
|
+
* would ship a demo where every download 404s and the sweep has work to do on a fresh install. The
|
|
24
|
+
* `r2` items below write the same keys the rows name, so `GET /storage/:id` returns real content on
|
|
25
|
+
* a freshly seeded environment.
|
|
26
|
+
*
|
|
27
|
+
* The ids and keys are **fixed**, not generated, which is what makes the set idempotent: re-seeding
|
|
28
|
+
* writes the same rows over the same keys rather than accumulating a new copy of Ada's notes on every
|
|
29
|
+
* run. One file is `public` on purpose, so the unauthenticated read path is exercised by the demo
|
|
30
|
+
* data rather than only by the tests.
|
|
31
|
+
*
|
|
32
|
+
* Composed in only when the project turns on `seed.includeExamples` (`pithy.config.ts`), and only for
|
|
33
|
+
* `dev` and `staging` — an example fixture never targets `prod`, regardless of that setting.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Fixed ids, so the fixture is idempotent and the D1 rows and R2 objects cannot drift apart. */
|
|
37
|
+
const NOTES_ID = "6f1c8f36-30a5-4d5f-9f4d-6a5a52b9f0a1";
|
|
38
|
+
const PLAN_ID = "8c2d9a47-41b6-4e60-a05e-7b6b63c0e1b2";
|
|
39
|
+
const README_ID = "9d3ea058-52c7-4f71-b16f-8c7c74d1f2c3";
|
|
40
|
+
|
|
41
|
+
const CREATED_AT = new Date("2026-01-01T00:00:00.000Z");
|
|
42
|
+
|
|
43
|
+
/** The demo bodies. Declared once so a row's `size` is the byte count actually written to R2. */
|
|
44
|
+
const BODIES = {
|
|
45
|
+
[NOTES_ID]: "Lovelace, notes on the Analytical Engine.\n",
|
|
46
|
+
[PLAN_ID]: "Hopper, plan for the next compiler.\n",
|
|
47
|
+
[README_ID]: "A public file. Anyone with the id may read this one.\n",
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/** Bytes of a demo body — `size` must be the encoded length, not the character count. */
|
|
51
|
+
function byteLength(id: keyof typeof BODIES): number {
|
|
52
|
+
return new TextEncoder().encode(BODIES[id]).length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The R2 object key a demo file lives under — the same opaque `obj/<uuid>` shape the handlers derive. */
|
|
56
|
+
function demoKey(id: string): string {
|
|
57
|
+
return `${OBJECT_KEY_PREFIX}${id}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The bucket objects the rows point at, so a seeded environment can actually serve the bytes. */
|
|
61
|
+
const objects: R2SeedItem[] = (Object.keys(BODIES) as Array<keyof typeof BODIES>).map((id) => ({
|
|
62
|
+
binding: "STORAGE_BUCKET",
|
|
63
|
+
key: demoKey(id),
|
|
64
|
+
body: BODIES[id],
|
|
65
|
+
contentType: "text/plain",
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
export const storageExampleSeed: SeedSet = defineSeed({
|
|
69
|
+
name: "example",
|
|
70
|
+
order: STORAGE_EXAMPLE_SEED_ORDER,
|
|
71
|
+
environments: ["dev", "staging"],
|
|
72
|
+
example: true,
|
|
73
|
+
d1: [
|
|
74
|
+
d1SeedGroup("app", STORAGE_OBJECTS_TABLE, StorageObject, [
|
|
75
|
+
{
|
|
76
|
+
id: NOTES_ID,
|
|
77
|
+
key: demoKey(NOTES_ID),
|
|
78
|
+
path: "notes/analytical-engine.txt",
|
|
79
|
+
ownerId: EXAMPLE_ADA.id,
|
|
80
|
+
contentType: "text/plain",
|
|
81
|
+
size: byteLength(NOTES_ID),
|
|
82
|
+
visibility: "private",
|
|
83
|
+
checksum: null,
|
|
84
|
+
status: "stored",
|
|
85
|
+
uploadId: null,
|
|
86
|
+
createdAt: CREATED_AT,
|
|
87
|
+
updatedAt: CREATED_AT,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: PLAN_ID,
|
|
91
|
+
key: demoKey(PLAN_ID),
|
|
92
|
+
path: "plans/compiler.txt",
|
|
93
|
+
ownerId: EXAMPLE_GRACE.id,
|
|
94
|
+
contentType: "text/plain",
|
|
95
|
+
size: byteLength(PLAN_ID),
|
|
96
|
+
visibility: "private",
|
|
97
|
+
checksum: null,
|
|
98
|
+
status: "stored",
|
|
99
|
+
uploadId: null,
|
|
100
|
+
createdAt: CREATED_AT,
|
|
101
|
+
updatedAt: CREATED_AT,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: README_ID,
|
|
105
|
+
key: demoKey(README_ID),
|
|
106
|
+
path: "public/readme.txt",
|
|
107
|
+
ownerId: EXAMPLE_ADA.id,
|
|
108
|
+
contentType: "text/plain",
|
|
109
|
+
size: byteLength(README_ID),
|
|
110
|
+
visibility: "public",
|
|
111
|
+
checksum: null,
|
|
112
|
+
status: "stored",
|
|
113
|
+
uploadId: null,
|
|
114
|
+
createdAt: CREATED_AT,
|
|
115
|
+
updatedAt: CREATED_AT,
|
|
116
|
+
},
|
|
117
|
+
]),
|
|
118
|
+
],
|
|
119
|
+
r2: objects,
|
|
120
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, R2Bucket } from "@cloudflare/workers-types";
|
|
5
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
6
|
+
import type { CloudflareR2Manager } from "@pithy-sh/cloudflare/src/r2/r2Manager";
|
|
7
|
+
import { type IntegrationCreds, uniqueName, withThrowawayResource } from "@pithy-sh/cloudflare/src/test-utils/harness";
|
|
8
|
+
import { createMigrationRegistry } from "@pithy-sh/core/src/migrations/registry";
|
|
9
|
+
import { runMigrations } from "@pithy-sh/core/src/migrations/runner";
|
|
10
|
+
import type { SecretsStoreEnv } from "@pithy-sh/secrets/src/env/bindings";
|
|
11
|
+
import { configureSharedSecrets } from "@pithy-sh/secrets/src/sharedSecretsStore";
|
|
12
|
+
import { STORAGE_MIGRATION_ORDER } from "../capability";
|
|
13
|
+
import { type StorageDatabase, storageDatabase } from "../data/tables";
|
|
14
|
+
import { storage_0001_objects } from "../migrations/0001_objects";
|
|
15
|
+
import { ObjectListing, ObjectMetadata, type ObjectStore, objectStore } from "../object/store";
|
|
16
|
+
import { type R2StorageCredentials, STORAGE_R2_SECRET, storageSecretsRegistry } from "../secret/registry";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scaffolding for this package's `*.integration.test.ts` suites — the live half of storage.
|
|
20
|
+
*
|
|
21
|
+
* `@pithy-sh/cloudflare`'s harness finds credentials and guarantees teardown. Storage needs two more
|
|
22
|
+
* things on top: the seam under test built the way a Worker builds it (an {@link objectStore} over a
|
|
23
|
+
* *resolved credential bundle*, not an injected fake), and a real database to reconcile against.
|
|
24
|
+
*
|
|
25
|
+
* **What is live here, and what cannot be.** The presigned half — presign, the whole multipart
|
|
26
|
+
* lifecycle, server-side copy — is real R2 over the S3 protocol, and it is the half Miniflare cannot
|
|
27
|
+
* emulate at all: it serves no S3 endpoint, so a presigned URL has nothing to address. The binding
|
|
28
|
+
* half is the mirror image. An `R2Bucket` exists only inside workerd, so outside a Worker there is no
|
|
29
|
+
* binding to call — {@link liveObjectStore} serves `head`, `list` and `delete` from the *same real
|
|
30
|
+
* bucket* over S3 instead, and leaves `get` throwing.
|
|
31
|
+
*
|
|
32
|
+
* That split is deliberate and it is the whole reason the two suites are complementary rather than
|
|
33
|
+
* redundant. `store.workers.test.ts` drives the binding against Miniflare with the presign half
|
|
34
|
+
* throwing; this drives the presign half against real R2 with the binding-only read throwing. Neither
|
|
35
|
+
* can quietly take the other's path and pass.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** One live object plane: the store under test, the bucket it addresses, and the S3 manager behind it. */
|
|
39
|
+
export interface LiveObjectPlane {
|
|
40
|
+
/** The throwaway bucket's name. Every key in a test lands here and nowhere else. */
|
|
41
|
+
bucketName: string;
|
|
42
|
+
/** The seam under test — production `objectStore`, with the binding half served over S3. */
|
|
43
|
+
store: ObjectStore;
|
|
44
|
+
/** The S3 manager, for a test that needs an oracle the seam does not expose. */
|
|
45
|
+
manager: CloudflareR2Manager;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One multipart upload this run opened and has not yet closed — teardown's list of what to abort. */
|
|
49
|
+
interface OpenUpload {
|
|
50
|
+
key: string;
|
|
51
|
+
uploadId: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The R2 binding, deliberately absent.
|
|
56
|
+
*
|
|
57
|
+
* `objectStore` takes a bucket binding, and no such thing exists outside workerd. Rather than hand it
|
|
58
|
+
* something that pretends, every method throws: a test that reaches the binding by accident fails
|
|
59
|
+
* loudly instead of passing against a stand-in. The methods that matter are re-implemented over S3 in
|
|
60
|
+
* {@link liveObjectStore}, so this is only ever reached by a path that has no live equivalent.
|
|
61
|
+
*/
|
|
62
|
+
function absentBinding(): R2Bucket {
|
|
63
|
+
const refuse = (method: string) => () => {
|
|
64
|
+
throw new Error(`R2 binding '${method}' has no equivalent outside a Worker — use the S3-backed seam.`);
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
get: refuse("get"),
|
|
68
|
+
head: refuse("head"),
|
|
69
|
+
put: refuse("put"),
|
|
70
|
+
delete: refuse("delete"),
|
|
71
|
+
list: refuse("list"),
|
|
72
|
+
createMultipartUpload: refuse("createMultipartUpload"),
|
|
73
|
+
resumeMultipartUpload: refuse("resumeMultipartUpload"),
|
|
74
|
+
} as unknown as R2Bucket;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build the object store a live test drives.
|
|
79
|
+
*
|
|
80
|
+
* The credential path is the production one end to end: the bundle is injected as a `.dev.vars`-shaped
|
|
81
|
+
* string under the registry name, `objectStore` resolves it through `sharedSecretsStore`, and the
|
|
82
|
+
* registry's Zod schema validates it before a URL is ever signed. So a drift in
|
|
83
|
+
* {@link R2StorageCredentials} fails here the same way it would fail in a deployed Worker.
|
|
84
|
+
*
|
|
85
|
+
* `head`, `list` and `delete` are then re-pointed at the same bucket over S3, because the binding they
|
|
86
|
+
* would otherwise use does not exist in Node. `get` is left throwing: a range or a conditional read
|
|
87
|
+
* through the binding has no S3 mapping this file could write without re-implementing workerd, and a
|
|
88
|
+
* re-implementation is exactly the mock these tests exist to avoid. The suites assert R2's range and
|
|
89
|
+
* conditional *semantics* over a presigned GET instead, where the answer comes from R2 itself.
|
|
90
|
+
*
|
|
91
|
+
* Multipart uploads are recorded as they open and forgotten as they close, so teardown can abort
|
|
92
|
+
* whatever a failure left in flight — stored parts hold a bucket against deletion and cost money.
|
|
93
|
+
*/
|
|
94
|
+
function liveObjectStore(
|
|
95
|
+
credentials: R2StorageCredentials,
|
|
96
|
+
manager: CloudflareR2Manager,
|
|
97
|
+
open: Map<string, OpenUpload>,
|
|
98
|
+
): ObjectStore {
|
|
99
|
+
// Local dev's own resolution path: no `ENVIRONMENT` var, so every secret comes from its injected
|
|
100
|
+
// string in the shape it is stored. Configuring the shared accessor also clears its TTL cache, which
|
|
101
|
+
// is what keeps one test's bucket credentials from leaking into the next test's store.
|
|
102
|
+
const env = { [STORAGE_R2_SECRET]: JSON.stringify(credentials) } as unknown as SecretsStoreEnv;
|
|
103
|
+
configureSharedSecrets({ registry: storageSecretsRegistry });
|
|
104
|
+
const store = objectStore({ bucket: absentBinding(), env });
|
|
105
|
+
|
|
106
|
+
async function head(key: string): Promise<ObjectMetadata | null> {
|
|
107
|
+
const object = await manager.headObject(key);
|
|
108
|
+
if (!object) return null;
|
|
109
|
+
if (!object.uploaded) throw new Error(`R2 reported no LastModified for '${key}'`);
|
|
110
|
+
return ObjectMetadata.parse({
|
|
111
|
+
key,
|
|
112
|
+
size: object.size,
|
|
113
|
+
// R2 quotes an ETag over the S3 protocol and leaves it unquoted through the binding. Strip it so
|
|
114
|
+
// the seam's shape is identical either way — no caller should be able to tell which half served it.
|
|
115
|
+
etag: object.etag.replace(/^"|"$/g, ""),
|
|
116
|
+
contentType: object.contentType,
|
|
117
|
+
uploaded: object.uploaded,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...store,
|
|
123
|
+
|
|
124
|
+
async initMultipart(key, contentType) {
|
|
125
|
+
const uploadId = await store.initMultipart(key, contentType);
|
|
126
|
+
open.set(`${key}\0${uploadId}`, { key, uploadId });
|
|
127
|
+
return uploadId;
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async completeMultipart(key, uploadId, parts) {
|
|
131
|
+
await store.completeMultipart(key, uploadId, parts);
|
|
132
|
+
open.delete(`${key}\0${uploadId}`);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async abortMultipart(key, uploadId) {
|
|
136
|
+
await store.abortMultipart(key, uploadId);
|
|
137
|
+
open.delete(`${key}\0${uploadId}`);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
head,
|
|
141
|
+
|
|
142
|
+
async list(options) {
|
|
143
|
+
const page = await manager.listObjects({
|
|
144
|
+
prefix: options?.prefix,
|
|
145
|
+
cursor: options?.cursor,
|
|
146
|
+
maxKeys: options?.limit,
|
|
147
|
+
});
|
|
148
|
+
const objects: ObjectMetadata[] = [];
|
|
149
|
+
for (const key of page.keys) {
|
|
150
|
+
// A key listed and then deleted before its HEAD is not an error — a bucket is not a snapshot.
|
|
151
|
+
const metadata = await head(key);
|
|
152
|
+
if (metadata) objects.push(metadata);
|
|
153
|
+
}
|
|
154
|
+
return ObjectListing.parse({ objects, cursor: page.cursor });
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async delete(key) {
|
|
158
|
+
await manager.deleteObject(key);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
get() {
|
|
162
|
+
throw new Error("`get` reads through the R2 binding, which no Node context has. Presign a GET instead.");
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Empty a bucket so R2 will delete it — the same drain `pithy storage deprovision --storage` runs, so a
|
|
169
|
+
* break in it fails a test here rather than an operator's teardown.
|
|
170
|
+
*
|
|
171
|
+
* Two things hold a bucket: stored objects, and the parts of a multipart upload that was never completed
|
|
172
|
+
* or aborted — precisely what a test that failed mid-upload leaves behind. `emptyBucket` handles both.
|
|
173
|
+
* The uploads this run opened are aborted first and best-effort, because a test may already have aborted
|
|
174
|
+
* one and this map is only local bookkeeping; the authoritative sweep is R2's own listing inside
|
|
175
|
+
* `emptyBucket`, which is also all a reaper cleaning up after a *previous* run has.
|
|
176
|
+
*/
|
|
177
|
+
async function purgeBucket(manager: CloudflareR2Manager, open: Map<string, OpenUpload>): Promise<void> {
|
|
178
|
+
for (const upload of open.values()) {
|
|
179
|
+
await manager.abortMultipartUpload(upload.key, upload.uploadId).catch(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
open.clear();
|
|
182
|
+
await manager.emptyBucket();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run `exercise` against a fresh throwaway bucket, then empty and delete it.
|
|
187
|
+
*
|
|
188
|
+
* Everything but the bucket is built *before* the create, so the one thing teardown has to undo is the
|
|
189
|
+
* one thing `create` did. One bucket per test keeps a failure's debris out of the next test — and out
|
|
190
|
+
* of the account.
|
|
191
|
+
*/
|
|
192
|
+
export async function withLiveBucket<T>(
|
|
193
|
+
creds: IntegrationCreds,
|
|
194
|
+
exercise: (plane: LiveObjectPlane) => Promise<T>,
|
|
195
|
+
): Promise<T> {
|
|
196
|
+
const r2 = creds.r2;
|
|
197
|
+
if (!r2) throw new Error("withLiveBucket needs R2_CREDENTIALS — gate the suite on `creds.r2`.");
|
|
198
|
+
|
|
199
|
+
const clients = new CloudflareClients({ accountId: creds.accountId, apiToken: creds.apiToken });
|
|
200
|
+
const provisioner = clients.r2Provisioner();
|
|
201
|
+
const bucketName = uniqueName("storage");
|
|
202
|
+
const manager = clients.r2({ ...r2, bucketName });
|
|
203
|
+
const open = new Map<string, OpenUpload>();
|
|
204
|
+
const store = liveObjectStore(
|
|
205
|
+
{ ...r2, accountId: creds.accountId, apiToken: creds.apiToken, bucket: bucketName },
|
|
206
|
+
manager,
|
|
207
|
+
open,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return withThrowawayResource(
|
|
211
|
+
() => provisioner.createBucket(bucketName),
|
|
212
|
+
() => exercise({ bucketName, store, manager }),
|
|
213
|
+
async () => {
|
|
214
|
+
// A purge failure would only resurface as a bucket-delete failure; let that be the loud one, so a
|
|
215
|
+
// genuine assertion error is never masked by teardown noise.
|
|
216
|
+
await purgeBucket(manager, open).catch(() => undefined);
|
|
217
|
+
await provisioner.deleteBucket(bucketName);
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The storage migrations, composed the way `pithy migrate` composes them for the app database. */
|
|
223
|
+
function storageMigrations() {
|
|
224
|
+
const registry = createMigrationRegistry([
|
|
225
|
+
{
|
|
226
|
+
database: "app",
|
|
227
|
+
namespace: "storage",
|
|
228
|
+
order: STORAGE_MIGRATION_ORDER,
|
|
229
|
+
migrations: { "0001_objects": storage_0001_objects },
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
const provider = registry.app;
|
|
233
|
+
if (!provider) throw new Error("no app migration provider");
|
|
234
|
+
return provider;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run `exercise` against a fresh throwaway D1, migrated to storage's schema, then delete it.
|
|
239
|
+
*
|
|
240
|
+
* A real database rather than Miniflare's, for one reason: this pool is Node, and Miniflare's D1 lives
|
|
241
|
+
* in workerd. Standing one up here would mean running the sweep against an emulator on one side and
|
|
242
|
+
* real R2 on the other, which is the mixed picture the live suite exists to replace. It also buys a
|
|
243
|
+
* second proof for free — the migration runs on real D1 over REST, exactly as `pithy migrate` runs it.
|
|
244
|
+
*/
|
|
245
|
+
export async function withLiveDatabase<T>(
|
|
246
|
+
creds: IntegrationCreds,
|
|
247
|
+
exercise: (db: StorageDatabase) => Promise<T>,
|
|
248
|
+
): Promise<T> {
|
|
249
|
+
const clients = new CloudflareClients({ accountId: creds.accountId, apiToken: creds.apiToken });
|
|
250
|
+
const provisioner = clients.d1Provisioner();
|
|
251
|
+
|
|
252
|
+
return withThrowawayResource(
|
|
253
|
+
() => provisioner.createDatabase(uniqueName("storage")),
|
|
254
|
+
async (database) => {
|
|
255
|
+
const d1 = clients.d1(database.uuid) as unknown as D1Database;
|
|
256
|
+
await runMigrations(d1, storageMigrations());
|
|
257
|
+
return exercise(storageDatabase(d1));
|
|
258
|
+
},
|
|
259
|
+
(database) => provisioner.deleteDatabase(database.uuid),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/storage knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/storage";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* **What the orphan sweep retries, and what it refuses to.**
|
|
8
|
+
*
|
|
9
|
+
* The sweep reconciles D1 against R2, and it has already decided what a failure means: every delete and
|
|
10
|
+
* every multipart abort is `.catch(() => {})` on purpose, because an object this run could not remove is
|
|
11
|
+
* an object the next run finds again. That is a reconciler's contract — not error swallowing — and it is
|
|
12
|
+
* what leaves this record empty (pithy-sh/pithy#348).
|
|
13
|
+
*
|
|
14
|
+
* **An empty record is a statement, not an omission.** Core answers for D1's transient vocabulary
|
|
15
|
+
* through `withD1Retry`, so a database under contention is still re-driven by the step, and nothing
|
|
16
|
+
* about that is restated here. What the empty record adds is that storage retries none of its *own*
|
|
17
|
+
* codes.
|
|
18
|
+
*
|
|
19
|
+
* ## Terminal, and why
|
|
20
|
+
*
|
|
21
|
+
* - **`storage/*`** — every one of them is a *request's* refusal: a file that is not there, a file that
|
|
22
|
+
* is not yours, a quota, an upload that never finished, an expired share. The sweep raises none of
|
|
23
|
+
* them, and a sweep that somehow did has found a bug rather than an outage.
|
|
24
|
+
* - **`validation/invalid_input`** — a `StorageSweepParams` an operator dispatched by hand. A payload
|
|
25
|
+
* parses the same way on the fifth attempt as on the first.
|
|
26
|
+
* - **`secrets/*`** — the R2 credentials the multipart abort needs. A key that is not in the bound key
|
|
27
|
+
* set will not be in it a minute later; this wants `pithy secrets provision`, not a backoff.
|
|
28
|
+
*
|
|
29
|
+
* **The cron is the outer retry, and that is why terminal is cheap here.** The sweep is idempotent by
|
|
30
|
+
* construction — a second pass over a reconciled bucket finds nothing left to do — so a run that fails
|
|
31
|
+
* costs one day, and the next day's run does the whole job. Five platform attempts against a refusal
|
|
32
|
+
* that cannot change cost the same day *and* bury the reason under four repeats of it.
|
|
33
|
+
*
|
|
34
|
+
* **What this cannot say, and it is worth knowing.** A bucket listing that fails mid-sweep throws
|
|
35
|
+
* whatever the R2 binding threw, which is not a `PithyError` and carries no code — so it is
|
|
36
|
+
* `unclassified`, which is terminal. That is the right default and the correct outcome here (the next
|
|
37
|
+
* fire re-lists from the top), but it is a default rather than a decision, and no policy record can
|
|
38
|
+
* turn it into one.
|
|
39
|
+
*/
|
|
40
|
+
export const storageWorkflowRetry: WorkflowRetryPolicy = {
|
|
41
|
+
capability: "storage",
|
|
42
|
+
retryable: {},
|
|
43
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { workflowKey } from "@pithy-sh/core/src/workflow/naming";
|
|
5
|
+
import type { WorkflowRegistry, WorkflowSpecMap } from "@pithy-sh/core/src/workflow/spec";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one durable job storage owns: the orphan sweep.
|
|
10
|
+
*
|
|
11
|
+
* Declared once, here. `capability.ts` derives its Workflow binding from this map, `provision/`
|
|
12
|
+
* derives the host worker's `workflows` array and its cron from it, and any caller triggers it by the
|
|
13
|
+
* `storage/sweep` key. There is no second place where the binding name, the class name, or the
|
|
14
|
+
* schedule is written down, so none of them can drift.
|
|
15
|
+
*
|
|
16
|
+
* `optional: true` because the Workflow lives in the prebuilt sweep worker, which exists only once
|
|
17
|
+
* `pithy storage provision` has run. Until then an app composing storage must still boot and serve
|
|
18
|
+
* every upload and download route; an absent binding degrades to a logged skip.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The capability name — the first segment of the dispatch key and of every deployed resource name. */
|
|
22
|
+
export const STORAGE_CAPABILITY = "storage";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The sweep's parameters. Every field is optional, which is a requirement rather than a convenience:
|
|
26
|
+
* a cron supplies no input, and `createEntrypoint` dispatches a scheduled job with `{}`. A schema
|
|
27
|
+
* that demanded a field could never run on its schedule.
|
|
28
|
+
*/
|
|
29
|
+
export const StorageSweepParams = z
|
|
30
|
+
.object({
|
|
31
|
+
olderThanSeconds: z
|
|
32
|
+
.number()
|
|
33
|
+
.int()
|
|
34
|
+
.positive()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe(
|
|
37
|
+
"Override how old a `pending` row must be before it is reclaimed. Omitted uses the config's `pendingTtlSeconds`. Lower it to reproduce a reclaim in staging without waiting a day.",
|
|
38
|
+
),
|
|
39
|
+
dryRun: z
|
|
40
|
+
.boolean()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe(
|
|
43
|
+
"Report what would be reclaimed and delete nothing. The safe way to answer 'what is this sweep about to do to prod' before letting it run.",
|
|
44
|
+
),
|
|
45
|
+
maxPages: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.positive()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe(
|
|
51
|
+
"Cap how many 1,000-key bucket pages one run scans. A bound on the work, so a first sweep over a very large bucket finishes rather than running until it is killed.",
|
|
52
|
+
),
|
|
53
|
+
})
|
|
54
|
+
.describe("What one orphan-sweep run should do. Every field optional, because a cron passes none of them.");
|
|
55
|
+
export type StorageSweepParams = z.infer<typeof StorageSweepParams>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Storage's durable jobs, keyed by job name.
|
|
59
|
+
*
|
|
60
|
+
* The schedule is daily at 03:00 UTC. Divergence between the bucket and the table accumulates slowly
|
|
61
|
+
* — an abandoned upload here, an interrupted delete there — so an hourly pass would list the whole
|
|
62
|
+
* bucket twenty-four times to find nothing. It is a cron *and* a dispatch target: a sweep nobody can
|
|
63
|
+
* run on demand cannot be tested in staging, which is precisely when you want to know what it does.
|
|
64
|
+
*/
|
|
65
|
+
export const storageWorkflows = {
|
|
66
|
+
sweep: {
|
|
67
|
+
binding: "STORAGE_SWEEP",
|
|
68
|
+
className: "StorageSweepWorkflow",
|
|
69
|
+
params: StorageSweepParams,
|
|
70
|
+
schedule: "0 3 * * *",
|
|
71
|
+
optional: true,
|
|
72
|
+
},
|
|
73
|
+
} as const satisfies WorkflowSpecMap;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Storage's jobs as a dispatch registry, keyed `storage/<job>`. Built here rather than through
|
|
77
|
+
* `composeWorkflows` because the sweep worker dispatches its own job before any project-wide registry
|
|
78
|
+
* exists — and the key format comes from core's {@link workflowKey} either way, so the two cannot drift.
|
|
79
|
+
*/
|
|
80
|
+
export const storageWorkflowRegistry: WorkflowRegistry = Object.fromEntries(
|
|
81
|
+
Object.entries(storageWorkflows).map(([job, spec]) => {
|
|
82
|
+
const key = workflowKey(STORAGE_CAPABILITY, job);
|
|
83
|
+
return [key, { key, capability: STORAGE_CAPABILITY, job, spec }];
|
|
84
|
+
}),
|
|
85
|
+
);
|