@pithy-sh/media 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 +85 -0
- package/src/ai/enrich.ts +187 -0
- package/src/ai/videoBatching.ts +130 -0
- package/src/capability.ts +129 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +155 -0
- package/src/data/enums.ts +53 -0
- package/src/data/extend.ts +135 -0
- package/src/data/mediaAsset.ts +78 -0
- package/src/data/mediaHash.ts +24 -0
- package/src/data/tables.ts +45 -0
- package/src/deliver/url.ts +78 -0
- package/src/error/errors.ts +95 -0
- package/src/hash/duplicates.ts +129 -0
- package/src/hash/sha256.ts +34 -0
- package/src/http/dispatch.ts +86 -0
- package/src/http/guard.ts +25 -0
- package/src/http/handlers.ts +205 -0
- package/src/http/routes.ts +119 -0
- package/src/http/schemas.ts +90 -0
- package/src/index.ts +39 -0
- package/src/migrations/0001_init.ts +104 -0
- package/src/migrations/extend.ts +40 -0
- package/src/provision/provisionMedia.ts +226 -0
- package/src/provision/resolveMediaConfig.ts +78 -0
- package/src/record/d1Store.ts +95 -0
- package/src/record/hashStore.ts +104 -0
- package/src/record/kvStore.ts +147 -0
- package/src/record/resolve.ts +34 -0
- package/src/record/store.ts +50 -0
- package/src/secret/registry.ts +85 -0
- package/src/storage/backend.ts +29 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/minter.ts +57 -0
- package/src/storage/resolve.ts +78 -0
- package/src/storage/storage.ts +130 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/enrich.ts +116 -0
- package/src/workflows/hls.ts +99 -0
- package/src/workflows/retryPolicy.ts +56 -0
- package/src/workflows/specs.ts +75 -0
- package/src/workflows/worker.ts +192 -0
- package/src/workflows/wrangler.jsonc +73 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { z } from "zod";
|
|
5
|
+
import { MEDIA_ASSETS_TABLE, type MediaDatabase } from "../data/tables";
|
|
6
|
+
import { MediaNotFoundError } from "../error/errors";
|
|
7
|
+
import type { ListRecordsOptions, MediaRecord, RecordStore } from "./store";
|
|
8
|
+
|
|
9
|
+
/** Default page size for {@link RecordStore.list}. */
|
|
10
|
+
const DEFAULT_LIMIT = 50;
|
|
11
|
+
|
|
12
|
+
/** The row shape after `schema.encode` — a bag of SQLite-primitive columns. */
|
|
13
|
+
type EncodedRow = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The D1-backed record store — the default. Records live in `pithy_media_assets`, one row each, read
|
|
17
|
+
* and written through the effective schema's codecs (dates ↔ ms-epoch, booleans ↔ 0|1). Extension
|
|
18
|
+
* columns ride along automatically: `schema.encode` includes them and Kysely writes every key present,
|
|
19
|
+
* so an adopter's fields persist with no store changes. Derived text (`transcription`, `extractedText`)
|
|
20
|
+
* is a real column, so it is queryable — the reason D1 is the default store.
|
|
21
|
+
*/
|
|
22
|
+
export function d1RecordStore(db: MediaDatabase, schema: z.ZodObject): RecordStore {
|
|
23
|
+
/** Decode a raw D1 row into a validated record. */
|
|
24
|
+
const decode = (row: unknown): MediaRecord => schema.parse(row) as MediaRecord;
|
|
25
|
+
|
|
26
|
+
/** Encode an app-shape record into its SQLite row. */
|
|
27
|
+
const encode = (record: MediaRecord): EncodedRow => schema.encode(record) as EncodedRow;
|
|
28
|
+
|
|
29
|
+
const table = db.selectFrom(MEDIA_ASSETS_TABLE);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
async create(record) {
|
|
33
|
+
const row = encode(record);
|
|
34
|
+
// Kysely writes every runtime key on the object, so extension columns are inserted too; the cast
|
|
35
|
+
// narrows the compile-time type to the base table without dropping those keys at runtime.
|
|
36
|
+
await db
|
|
37
|
+
.insertInto(MEDIA_ASSETS_TABLE)
|
|
38
|
+
.values(row as never)
|
|
39
|
+
.execute();
|
|
40
|
+
return decode(row);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async get(id) {
|
|
44
|
+
const row = await table.selectAll().where("id", "=", id).executeTakeFirst();
|
|
45
|
+
return row ? decode(row) : null;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async patch(id, changes) {
|
|
49
|
+
const existing = await table.selectAll().where("id", "=", id).executeTakeFirst();
|
|
50
|
+
if (!existing) {
|
|
51
|
+
throw new MediaNotFoundError({ detail: `no media record to patch for id ${id}` });
|
|
52
|
+
}
|
|
53
|
+
const merged = { ...(decode(existing) as object), ...(changes as object) } as MediaRecord;
|
|
54
|
+
const row = encode(merged);
|
|
55
|
+
await db
|
|
56
|
+
.updateTable(MEDIA_ASSETS_TABLE)
|
|
57
|
+
.set(row as never)
|
|
58
|
+
.where("id", "=", id)
|
|
59
|
+
.execute();
|
|
60
|
+
return decode(row);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async delete(id) {
|
|
64
|
+
await db.deleteFrom(MEDIA_ASSETS_TABLE).where("id", "=", id).execute();
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async list(options: ListRecordsOptions = {}) {
|
|
68
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
69
|
+
const offset = decodeCursor(options.cursor);
|
|
70
|
+
let query = table.selectAll();
|
|
71
|
+
if (options.type) query = query.where("type", "=", options.type);
|
|
72
|
+
const rows = await query
|
|
73
|
+
.orderBy("createdAt", "desc")
|
|
74
|
+
.orderBy("id", "desc")
|
|
75
|
+
.limit(limit + 1)
|
|
76
|
+
.offset(offset)
|
|
77
|
+
.execute();
|
|
78
|
+
const items = rows.slice(0, limit).map(decode);
|
|
79
|
+
const cursor = rows.length > limit ? encodeCursor(offset + limit) : undefined;
|
|
80
|
+
return { items, cursor };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Encode an integer offset as an opaque cursor string. */
|
|
86
|
+
function encodeCursor(offset: number): string {
|
|
87
|
+
return String(offset);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decode an opaque cursor back to an integer offset; a missing or malformed cursor starts at 0. */
|
|
91
|
+
function decodeCursor(cursor: string | undefined): number {
|
|
92
|
+
if (!cursor) return 0;
|
|
93
|
+
const parsed = Number.parseInt(cursor, 10);
|
|
94
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
95
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import type { MediaType } from "../data/enums";
|
|
6
|
+
import { MediaHash } from "../data/mediaHash";
|
|
7
|
+
import { MEDIA_HASHES_TABLE, mediaHashDatabase } from "../data/tables";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The dedup hash store — always D1, independent of where records live. Written on finalize, read by
|
|
11
|
+
* duplicate search, deleted with the record. Keeping it in its own table (not on the record, not in KV)
|
|
12
|
+
* is what makes `sha256` exact-match and `phash` near-duplicate detection work in every configuration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The hash to persist for one media record. */
|
|
16
|
+
export interface HashEntry {
|
|
17
|
+
/** The media record id. */
|
|
18
|
+
mediaId: string;
|
|
19
|
+
/** The media type — scopes the near-duplicate scan to images. */
|
|
20
|
+
mediaType: MediaType;
|
|
21
|
+
/** Lowercase-hex SHA-256 of the file. */
|
|
22
|
+
sha256: string;
|
|
23
|
+
/** Perceptual hash for near-duplicate images; null/undefined for other types. */
|
|
24
|
+
phash?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A `{ mediaId, phash }` pair for near-duplicate scanning. */
|
|
28
|
+
export interface HashPhashEntry {
|
|
29
|
+
/** The media record id. */
|
|
30
|
+
mediaId: string;
|
|
31
|
+
/** The record's perceptual hash. */
|
|
32
|
+
phash: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A `{ mediaId, mediaType }` pair returned by an exact-match lookup. */
|
|
36
|
+
export interface HashMatch {
|
|
37
|
+
/** The media record id. */
|
|
38
|
+
mediaId: string;
|
|
39
|
+
/** The media type. */
|
|
40
|
+
mediaType: MediaType;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The dedup hash store seam. */
|
|
44
|
+
export interface HashStore {
|
|
45
|
+
/** Insert or replace the hash for a media record (idempotent per `mediaId`). */
|
|
46
|
+
upsert(entry: HashEntry): Promise<void>;
|
|
47
|
+
/** Delete the hash row for a media record. A miss is a no-op. */
|
|
48
|
+
deleteByMedia(mediaId: string): Promise<void>;
|
|
49
|
+
/** Every record whose `sha256` exactly matches — the exact-duplicate lookup. */
|
|
50
|
+
findBySha256(sha256: string): Promise<HashMatch[]>;
|
|
51
|
+
/** Every image record carrying a perceptual hash — the bounded set the near-duplicate scan compares. */
|
|
52
|
+
listImagePhashes(): Promise<HashPhashEntry[]>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build the D1-backed hash store from the `DB` binding. */
|
|
56
|
+
export function d1HashStore(d1: D1Database, now: () => Date = () => new Date()): HashStore {
|
|
57
|
+
const db = mediaHashDatabase(d1);
|
|
58
|
+
return {
|
|
59
|
+
async upsert(entry) {
|
|
60
|
+
const row = MediaHash.encode({
|
|
61
|
+
id: 0, // ignored — auto-increment PK
|
|
62
|
+
mediaId: entry.mediaId,
|
|
63
|
+
mediaType: entry.mediaType,
|
|
64
|
+
sha256: entry.sha256,
|
|
65
|
+
phash: entry.phash ?? null,
|
|
66
|
+
createdAt: now(),
|
|
67
|
+
});
|
|
68
|
+
// `mediaId` is unique — replace any existing row so a re-finalize doesn't duplicate.
|
|
69
|
+
await db
|
|
70
|
+
.insertInto(MEDIA_HASHES_TABLE)
|
|
71
|
+
.values(row as never)
|
|
72
|
+
.onConflict((oc) => oc.column("mediaId").doUpdateSet({ sha256: row.sha256, phash: row.phash }))
|
|
73
|
+
.execute();
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async deleteByMedia(mediaId) {
|
|
77
|
+
await db.deleteFrom(MEDIA_HASHES_TABLE).where("mediaId", "=", mediaId).execute();
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async findBySha256(sha256) {
|
|
81
|
+
const rows = await db
|
|
82
|
+
.selectFrom(MEDIA_HASHES_TABLE)
|
|
83
|
+
.select(["mediaId", "mediaType"])
|
|
84
|
+
.where("sha256", "=", sha256)
|
|
85
|
+
.execute();
|
|
86
|
+
return rows.map((row) => ({ mediaId: row.mediaId, mediaType: row.mediaType as MediaType }));
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async listImagePhashes() {
|
|
90
|
+
const rows = await db
|
|
91
|
+
.selectFrom(MEDIA_HASHES_TABLE)
|
|
92
|
+
.select(["mediaId", "phash"])
|
|
93
|
+
.where("mediaType", "=", "image")
|
|
94
|
+
.where("phash", "is not", null)
|
|
95
|
+
.execute();
|
|
96
|
+
const entries: HashPhashEntry[] = [];
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
if (typeof row.phash === "string" && row.phash.length > 0)
|
|
99
|
+
entries.push({ mediaId: row.mediaId, phash: row.phash });
|
|
100
|
+
}
|
|
101
|
+
return entries;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { KVNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { kvMetadata, TypedKv } from "@pithy-sh/core/src/kv/kv";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { MediaNotFoundError } from "../error/errors";
|
|
9
|
+
import type { ListRecordsOptions, MediaRecord, RecordStore } from "./store";
|
|
10
|
+
|
|
11
|
+
/** Default page size for {@link RecordStore.list}. */
|
|
12
|
+
const DEFAULT_LIMIT = 50;
|
|
13
|
+
|
|
14
|
+
/** The KV key: the fixed `media` prefix plus the record id — `media:<id>`. */
|
|
15
|
+
const MediaKey = z
|
|
16
|
+
.object({ id: z.string().describe("The media record id — the KV key segment.") })
|
|
17
|
+
.describe("The KV key for a media record: `media:<id>`.");
|
|
18
|
+
|
|
19
|
+
/** Fields the store always projects into metadata so `list` can filter by type and sort by recency. */
|
|
20
|
+
const REQUIRED_METADATA_FIELDS = ["type", "createdAt"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The KV-backed record store — the `recordStore: 'kv'` opt-in. Records are typed KV values (`media:<id>`),
|
|
24
|
+
* validated whole against the effective schema on every read and write, so extension fields round-trip
|
|
25
|
+
* exactly as in D1 with no columns and no migration.
|
|
26
|
+
*
|
|
27
|
+
* KV is a key-value store, not a query engine. `get`/`patch`/`delete` are direct key lookups. `list` is
|
|
28
|
+
* made scalable by **KV metadata**: `metadataFields` (from `kvMetadata` config) are stored as each entry's
|
|
29
|
+
* metadata, which rides free on a KV `list` — so `list` filters by type, sorts by recency, and paginates
|
|
30
|
+
* from metadata alone, then reads only the returned page's values (bounded by the page size, not the
|
|
31
|
+
* corpus). Put the fields your list views need (an owning `userId`, tags) in `kvMetadata`; KV caps
|
|
32
|
+
* metadata at 1024 bytes, so keep them small. Duplicate detection is not here — it always uses D1.
|
|
33
|
+
*/
|
|
34
|
+
export function kvRecordStore(namespace: KVNamespace, schema: z.ZodObject, metadataFields: string[] = []): RecordStore {
|
|
35
|
+
// Always include the fields `list` needs (type, createdAt). Field names are validated against the
|
|
36
|
+
// effective schema at capability construction (see `assertValidKvMetadata`), so no shape filter here —
|
|
37
|
+
// the metadata is derived from the value by name, which lets an adopter's extension field ride along
|
|
38
|
+
// even in the enrichment worker, whose passthrough schema does not carry the extension in its shape.
|
|
39
|
+
const fields = [...new Set([...REQUIRED_METADATA_FIELDS, ...metadataFields])];
|
|
40
|
+
// A size-bounded, permissive metadata object: the projected fields (including extension fields the
|
|
41
|
+
// schema's `shape` may not list) validate as a small denormalized bag for list controls.
|
|
42
|
+
const metadataSchema = kvMetadata(z.record(z.string(), z.unknown()));
|
|
43
|
+
|
|
44
|
+
const kv = new TypedKv(namespace, {
|
|
45
|
+
prefix: "media",
|
|
46
|
+
key: MediaKey,
|
|
47
|
+
value: schema,
|
|
48
|
+
metadata: metadataSchema,
|
|
49
|
+
deriveMetadata: (value) => {
|
|
50
|
+
const record = value as Record<string, unknown>;
|
|
51
|
+
const meta: Record<string, unknown> = {};
|
|
52
|
+
for (const field of fields) {
|
|
53
|
+
if (record[field] !== undefined) meta[field] = record[field];
|
|
54
|
+
}
|
|
55
|
+
return meta;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
async create(record) {
|
|
61
|
+
await kv.put({ id: String(record.id) }, record);
|
|
62
|
+
return (await kv.get({ id: String(record.id) })) as MediaRecord;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async get(id) {
|
|
66
|
+
return (await kv.get({ id })) as MediaRecord | null;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async patch(id, changes) {
|
|
70
|
+
const existing = await kv.get({ id });
|
|
71
|
+
if (!existing) {
|
|
72
|
+
throw new MediaNotFoundError({ detail: `no media record to patch for id ${id}` });
|
|
73
|
+
}
|
|
74
|
+
const merged = { ...(existing as object), ...(changes as object) } as MediaRecord;
|
|
75
|
+
// A re-derive keeps metadata in step with the merged value.
|
|
76
|
+
await kv.put({ id }, merged);
|
|
77
|
+
return merged;
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async delete(id) {
|
|
81
|
+
await kv.delete({ id });
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async list(options: ListRecordsOptions = {}) {
|
|
85
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
86
|
+
|
|
87
|
+
// List every key with its metadata — cheap, no per-value reads. Filter and sort from metadata.
|
|
88
|
+
// `createdAt` round-trips through KV as a JSON (ISO) value, so normalize it to an epoch for sorting.
|
|
89
|
+
const entries: Array<{ id: string; type: unknown; createdAt: number }> = [];
|
|
90
|
+
let listCursor: string | undefined;
|
|
91
|
+
do {
|
|
92
|
+
const page = await kv.list({ cursor: listCursor, limit: 1000 });
|
|
93
|
+
for (const entry of page.keys) {
|
|
94
|
+
const meta = entry.metadata as { type?: unknown; createdAt?: unknown } | null;
|
|
95
|
+
entries.push({ id: entry.key.id, type: meta?.type, createdAt: toEpoch(meta?.createdAt) });
|
|
96
|
+
}
|
|
97
|
+
listCursor = page.cursor;
|
|
98
|
+
} while (listCursor);
|
|
99
|
+
|
|
100
|
+
const filtered = options.type ? entries.filter((entry) => entry.type === options.type) : entries;
|
|
101
|
+
// Newest first — the same order D1 returns.
|
|
102
|
+
filtered.sort((a, b) => b.createdAt - a.createdAt);
|
|
103
|
+
|
|
104
|
+
const offset = decodeCursor(options.cursor);
|
|
105
|
+
const pageEntries = filtered.slice(offset, offset + limit);
|
|
106
|
+
// Read only the page's values (bounded by `limit`, not the corpus size).
|
|
107
|
+
const items: MediaRecord[] = [];
|
|
108
|
+
for (const entry of pageEntries) {
|
|
109
|
+
const value = await kv.get({ id: entry.id });
|
|
110
|
+
if (value) items.push(value as MediaRecord);
|
|
111
|
+
}
|
|
112
|
+
const cursor = offset + limit < filtered.length ? String(offset + limit) : undefined;
|
|
113
|
+
return { items, cursor };
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Decode an opaque cursor back to an integer offset; a missing or malformed cursor starts at 0. */
|
|
119
|
+
function decodeCursor(cursor: string | undefined): number {
|
|
120
|
+
if (!cursor) return 0;
|
|
121
|
+
const parsed = Number.parseInt(cursor, 10);
|
|
122
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Normalize a metadata `createdAt` (ISO string, epoch number, or Date) to an epoch; 0 when absent/invalid. */
|
|
126
|
+
function toEpoch(value: unknown): number {
|
|
127
|
+
if (value == null) return 0;
|
|
128
|
+
const time = new Date(value as string | number | Date).getTime();
|
|
129
|
+
return Number.isFinite(time) ? time : 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate the adopter's `kvMetadata` field names against the effective record schema, so a typo or an
|
|
134
|
+
* unknown field fails fast at capability construction with a clear message — never silently ignored.
|
|
135
|
+
* Called by `media()`; `type`/`createdAt` are always included by the store and need not be listed.
|
|
136
|
+
*/
|
|
137
|
+
export function assertValidKvMetadata(fields: readonly string[], schema: z.ZodObject): void {
|
|
138
|
+
const valid = new Set(Object.keys(schema.shape));
|
|
139
|
+
const unknownFields = fields.filter((field) => !valid.has(field));
|
|
140
|
+
if (unknownFields.length > 0) {
|
|
141
|
+
throw new InternalError({
|
|
142
|
+
message: `Unknown kvMetadata field(s): ${unknownFields.join(", ")}.`,
|
|
143
|
+
action: "Every kvMetadata entry must be a record field (a base field or one added via media({ extend })).",
|
|
144
|
+
detail: `Valid record fields: ${[...valid].join(", ")}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database, KVNamespace } from "@cloudflare/workers-types";
|
|
5
|
+
import type { z } from "zod";
|
|
6
|
+
import type { MediaConfig } from "../config/config";
|
|
7
|
+
import { mediaDatabase } from "../data/tables";
|
|
8
|
+
import { d1RecordStore } from "./d1Store";
|
|
9
|
+
import { d1HashStore, type HashStore } from "./hashStore";
|
|
10
|
+
import { kvRecordStore } from "./kvStore";
|
|
11
|
+
import type { RecordStore } from "./store";
|
|
12
|
+
|
|
13
|
+
/** The bindings the record store reads from the request env: the app `DB` and the `MEDIA` KV namespace. */
|
|
14
|
+
export interface RecordStoreEnv {
|
|
15
|
+
/** The app D1 database the `pithy_media_assets` table lives in (used when `recordStore: 'd1'`). */
|
|
16
|
+
DB: D1Database;
|
|
17
|
+
/** The KV namespace media records live in (used when `recordStore: 'kv'`). */
|
|
18
|
+
MEDIA: KVNamespace;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the configured record store from the request env. `recordStore: 'd1'` (default) builds the
|
|
23
|
+
* Kysely-backed D1 store; `recordStore: 'kv'` builds the KV-backed store. Both are bound to the effective
|
|
24
|
+
* schema so extension fields validate and round-trip.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveRecordStore(env: RecordStoreEnv, config: MediaConfig, schema: z.ZodObject): RecordStore {
|
|
27
|
+
if (config.recordStore === "kv") return kvRecordStore(env.MEDIA, schema, config.kvMetadata);
|
|
28
|
+
return d1RecordStore(mediaDatabase(env.DB, schema), schema);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Resolve the dedup hash store — always the D1 `DB` binding, whatever the record store. */
|
|
32
|
+
export function resolveHashStore(env: RecordStoreEnv, now: () => Date = () => new Date()): HashStore {
|
|
33
|
+
return d1HashStore(env.DB, now);
|
|
34
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MediaType } from "../data/enums";
|
|
5
|
+
import type { MediaAsset } from "../data/mediaAsset";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A stored media record: the base {@link MediaAsset} plus whatever fields the adopter added through
|
|
9
|
+
* `media({ extend })`. The extra fields are unknown at the package's type level but validated at runtime
|
|
10
|
+
* by the effective schema, so they round-trip through create/get/list unchanged.
|
|
11
|
+
*/
|
|
12
|
+
export type MediaRecord = MediaAsset & Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/** Options for listing records. Filtering by `type` is precise in D1 and a scan-filter in KV. */
|
|
15
|
+
export interface ListRecordsOptions {
|
|
16
|
+
/** Restrict to one media type. */
|
|
17
|
+
type?: MediaType;
|
|
18
|
+
/** Maximum records per page. Defaults to 50. */
|
|
19
|
+
limit?: number;
|
|
20
|
+
/** Opaque cursor from a previous page. */
|
|
21
|
+
cursor?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A page of listed records with an optional continuation cursor. */
|
|
25
|
+
export interface RecordPage {
|
|
26
|
+
/** The records on this page. */
|
|
27
|
+
items: MediaRecord[];
|
|
28
|
+
/** Cursor for the next page, or undefined when the listing is complete. */
|
|
29
|
+
cursor?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The record-store seam: one interface, two backends. `recordStore: 'd1'` (default) persists to the
|
|
34
|
+
* `pithy_media_assets` table with queryable derived text; `recordStore: 'kv'` persists to a KV namespace
|
|
35
|
+
* (key lookup; `list` scans, documented as such). Both validate every read and write against the effective
|
|
36
|
+
* schema, so an adopter's extension fields survive with no backend-specific work. Duplicate detection is
|
|
37
|
+
* NOT here — it always runs against the D1 {@link HashStore}, independent of the record store.
|
|
38
|
+
*/
|
|
39
|
+
export interface RecordStore {
|
|
40
|
+
/** Persist a new record. Returns the stored, re-validated record. */
|
|
41
|
+
create(record: MediaRecord): Promise<MediaRecord>;
|
|
42
|
+
/** Read a record by id, or null on a miss. */
|
|
43
|
+
get(id: string): Promise<MediaRecord | null>;
|
|
44
|
+
/** Merge `changes` over the current record, validate, and write it back. Throws if the id is unknown. */
|
|
45
|
+
patch(id: string, changes: Partial<MediaRecord>): Promise<MediaRecord>;
|
|
46
|
+
/** Delete a record by id. A miss is a no-op. */
|
|
47
|
+
delete(id: string): Promise<void>;
|
|
48
|
+
/** List records, newest first, optionally filtered by type. */
|
|
49
|
+
list(options?: ListRecordsOptions): Promise<RecordPage>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { defineSecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
5
|
+
import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The secrets the media capability reads (CLAUDE.md §secrets). There are two, because they answer to
|
|
10
|
+
* two different owners.
|
|
11
|
+
*
|
|
12
|
+
* `media-storage-credentials` is media's own: the scoped Cloudflare API token that mints Images and
|
|
13
|
+
* Stream direct-upload URLs, and the account id it is scoped to. Nothing else.
|
|
14
|
+
*
|
|
15
|
+
* `media-r2-credentials` is **declared here and read nowhere in this package**. It is
|
|
16
|
+
* `@pithy-sh/storage`'s R2 bundle, declared through that package's `r2CredentialsRegistry` factory and
|
|
17
|
+
* resolved by the `ObjectStore` seam media presigns through. One factory means every declaration of
|
|
18
|
+
* the name agrees on `backend`, `scope`, `rotatable`, and `valueType` — exactly what
|
|
19
|
+
* `aggregateSecretRegistries` requires before it will let two capabilities share a name. So media
|
|
20
|
+
* *names* the R2 secret and never handles an R2 key again.
|
|
21
|
+
*
|
|
22
|
+
* The values are **supplied, not minted**: Cloudflare exposes no API for creating an R2 S3 access-key
|
|
23
|
+
* pair, and the permission catalog carries no Images or Stream keys, so the operator creates both and
|
|
24
|
+
* hands them to `pithy media provision`. Pithy stores, scopes, and rotates them from there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The name the Images + Stream credentials are stored and resolved under. */
|
|
28
|
+
export const MEDIA_STORAGE_SECRET = "media-storage-credentials";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where a human makes the scoped Images + Stream token, and where Cloudflare rolls it.
|
|
32
|
+
*
|
|
33
|
+
* **Two pages, because the two axes genuinely differ here.** The kit cannot compose the creation command:
|
|
34
|
+
* `pithy token mint` works from a permission catalog, and that catalog carries no Images or Stream group,
|
|
35
|
+
* so an operator picks them in the dashboard. Replacement is not the same act — a Cloudflare API token
|
|
36
|
+
* rolls through Cloudflare's own API and comes back with its successor, which is what `provider` means
|
|
37
|
+
* and what the manager's own token already declares.
|
|
38
|
+
*
|
|
39
|
+
* This is the pair `media-r2-credentials` does *not* have, and the reason rotation is declared per secret
|
|
40
|
+
* rather than per issuer: both come from Cloudflare, and only one of them can replace itself.
|
|
41
|
+
*/
|
|
42
|
+
const MEDIA_TOKEN_PAGE = "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/";
|
|
43
|
+
const MEDIA_TOKEN_ROLL = "https://developers.cloudflare.com/api/resources/user/subresources/tokens/methods/update/";
|
|
44
|
+
|
|
45
|
+
/** The name media's R2 bucket credentials are stored under — the name `objectStore` is pointed at. */
|
|
46
|
+
export const MEDIA_R2_SECRET = "media-r2-credentials";
|
|
47
|
+
|
|
48
|
+
/** The credential bundle media reads to mint Cloudflare Images and Stream direct-upload URLs. */
|
|
49
|
+
export const MediaStorageCredentials = z
|
|
50
|
+
.object({
|
|
51
|
+
apiToken: z
|
|
52
|
+
.string()
|
|
53
|
+
.describe("A scoped Cloudflare API token with Images and Stream permissions, for minting direct-upload URLs."),
|
|
54
|
+
accountId: z.string().describe("The Cloudflare account id the media resources live in."),
|
|
55
|
+
})
|
|
56
|
+
.describe("The credentials the media capability reads to mint Cloudflare Images and Stream direct-upload URLs.");
|
|
57
|
+
export type MediaStorageCredentials = z.output<typeof MediaStorageCredentials>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The R2 half of media's registry — one entry, built by `@pithy-sh/storage`'s factory. Exported on its
|
|
61
|
+
* own so the provisioner can write the secret against the *declared* schema rather than a second copy
|
|
62
|
+
* of the shape, which is the only way the two cannot drift.
|
|
63
|
+
*/
|
|
64
|
+
export const mediaR2Registry = r2CredentialsRegistry(MEDIA_R2_SECRET);
|
|
65
|
+
|
|
66
|
+
/** The media capability's secret-registry slice — aggregated into the shared accessor at startup. */
|
|
67
|
+
export const mediaSecretsRegistry = defineSecretRegistry({
|
|
68
|
+
[MEDIA_STORAGE_SECRET]: {
|
|
69
|
+
// An encrypted row in the per-environment secrets D1 — where this value actually lives. No
|
|
70
|
+
// wrangler template binds it from the Cloudflare Secrets Store; `pithy media provision` writes it
|
|
71
|
+
// through `dispatchSecretWrite` → the manager Workflow → `SystemSecretsStore`, the D1 path. The
|
|
72
|
+
// read seam routes strictly on this field, so it has to say where the value really is.
|
|
73
|
+
backend: "d1",
|
|
74
|
+
scope: "environment",
|
|
75
|
+
rotatable: false,
|
|
76
|
+
valueType: "json",
|
|
77
|
+
schema: MediaStorageCredentials,
|
|
78
|
+
origin: { kind: "obtained", issuer: "cloudflare", documentation: MEDIA_TOKEN_PAGE },
|
|
79
|
+
rotation: { kind: "provider", issuer: "cloudflare", documentation: MEDIA_TOKEN_ROLL },
|
|
80
|
+
},
|
|
81
|
+
// Both axes arrive with the entry, from `@pithy-sh/storage`'s factory. Restating them here is how the
|
|
82
|
+
// two declarations of one name would drift, and `aggregateSecretRegistries` is what would then refuse
|
|
83
|
+
// to boot a Worker composing both capabilities.
|
|
84
|
+
...mediaR2Registry,
|
|
85
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { MediaConfig } from "../config/config";
|
|
5
|
+
import type { MediaType, StorageBackend } from "../data/enums";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve which storage backend a media type uses from config. Images and video are configurable
|
|
9
|
+
* (`r2` | `cf-images`, `r2` | `cf-stream`); audio and documents are always R2.
|
|
10
|
+
*/
|
|
11
|
+
export function backendForType(type: MediaType, config: MediaConfig): StorageBackend {
|
|
12
|
+
switch (type) {
|
|
13
|
+
case "image":
|
|
14
|
+
return config.images.store;
|
|
15
|
+
case "video":
|
|
16
|
+
return config.video.store;
|
|
17
|
+
case "audio":
|
|
18
|
+
case "document":
|
|
19
|
+
return "r2";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The R2 object key for a media record: `media/<type>/<id>`. Namespaced under `media/` so a shared
|
|
25
|
+
* bucket never collides with an adopter's own objects, and partitioned by type for clarity.
|
|
26
|
+
*/
|
|
27
|
+
export function mediaR2Key(type: MediaType, id: string): string {
|
|
28
|
+
return `media/${type}/${id}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CloudflareImageManager } from "@pithy-sh/cloudflare/src/media/imageManager";
|
|
5
|
+
import { type AssetOwner, withAssetOwnership } from "@pithy-sh/cloudflare/src/media/ownership";
|
|
6
|
+
import type { CloudflareStreamManager } from "@pithy-sh/cloudflare/src/media/streamManager";
|
|
7
|
+
import type { ObjectStore } from "@pithy-sh/storage/src/object/store";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import type { ImageMinter, R2Minter, VideoMinter } from "./minter";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The adapters that fill the {@link ImageMinter}/{@link VideoMinter}/{@link R2Minter} seams.
|
|
13
|
+
*
|
|
14
|
+
* Images and Stream are the SDK-friction boundary: this is the only media file that touches the
|
|
15
|
+
* `@pithy-sh/cloudflare` managers' SDK-typed responses, and it validates the handful of fields it needs
|
|
16
|
+
* with a local Zod object so an unexpected shape fails loudly rather than surfacing as `undefined`.
|
|
17
|
+
*
|
|
18
|
+
* They are also the one boundary where a **name** cannot isolate a project. An R2 key lives inside a
|
|
19
|
+
* bucket already called `<project>-<env>-media`; an Images id and a Stream uid are minted by
|
|
20
|
+
* Cloudflare into one account-flat store. So both minters take an {@link AssetOwner} at construction
|
|
21
|
+
* and stamp it into every asset they create — `withAssetOwnership` merges it **over** the caller's bag,
|
|
22
|
+
* so a caller can neither omit nor overwrite it.
|
|
23
|
+
*
|
|
24
|
+
* R2 is not adapted from an SDK at all. It comes from `@pithy-sh/storage`'s {@link ObjectStore} — the
|
|
25
|
+
* object-plane seam built to be pointed at a second bucket under a second credential name. Media holds
|
|
26
|
+
* no `CloudflareR2Manager`, no key pair, and no bucket name; it holds a store it asks for two URLs.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The default max video duration (seconds) CF Stream requires for a direct upload — 6 hours. */
|
|
30
|
+
const STREAM_MAX_DURATION_SECONDS = 21600;
|
|
31
|
+
|
|
32
|
+
const ImageUploadResponse = z.object({ id: z.string(), uploadURL: z.string() });
|
|
33
|
+
const StreamUploadResponse = z.object({ uid: z.string(), uploadURL: z.string() });
|
|
34
|
+
|
|
35
|
+
/** Adapt a {@link CloudflareImageManager} to the {@link ImageMinter} seam, stamped for one owner. */
|
|
36
|
+
export function imageMinter(manager: CloudflareImageManager, owner: AssetOwner): ImageMinter {
|
|
37
|
+
return {
|
|
38
|
+
async mintDirectUpload(metadata) {
|
|
39
|
+
const response = ImageUploadResponse.parse(
|
|
40
|
+
await manager.createDirectUploadUrl({ metadata: withAssetOwnership(owner, metadata) }),
|
|
41
|
+
);
|
|
42
|
+
return { id: response.id, uploadUrl: response.uploadURL };
|
|
43
|
+
},
|
|
44
|
+
delete: (id) => manager.deleteImage(id),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Adapt a {@link CloudflareStreamManager} to the {@link VideoMinter} seam, stamped for one owner. */
|
|
49
|
+
export function videoMinter(manager: CloudflareStreamManager, owner: AssetOwner): VideoMinter {
|
|
50
|
+
return {
|
|
51
|
+
async mintDirectUpload(metadata) {
|
|
52
|
+
const response = StreamUploadResponse.parse(
|
|
53
|
+
await manager.createDirectUpload({
|
|
54
|
+
maxDurationSeconds: STREAM_MAX_DURATION_SECONDS,
|
|
55
|
+
meta: withAssetOwnership(owner, metadata),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
return { uid: response.uid, uploadUrl: response.uploadURL };
|
|
59
|
+
},
|
|
60
|
+
delete: (uid) => manager.deleteVideo(uid),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Adapt an {@link ObjectStore} to the {@link R2Minter} seam — the whole of media's R2 wiring.
|
|
66
|
+
*
|
|
67
|
+
* Only the presign paths cross here. `deleteObject` and `readR2Object` stay on the `R2Bucket` binding
|
|
68
|
+
* in `storage.ts`: a binding read needs no credential, makes no round trip to resolve one, and streams
|
|
69
|
+
* its body, so routing it through the store would cost something and buy nothing. The store's default
|
|
70
|
+
* expiry (one hour) is the one media wants for both URLs, so neither call names one.
|
|
71
|
+
*/
|
|
72
|
+
export function objectStoreMinter(store: ObjectStore): R2Minter {
|
|
73
|
+
return {
|
|
74
|
+
mintUpload: (key, contentType, contentLength) => store.presignPut(key, contentType, contentLength),
|
|
75
|
+
mintDownload: (key) => store.presignGet(key),
|
|
76
|
+
};
|
|
77
|
+
}
|