@maravilla-labs/platform 0.2.5 → 0.3.1

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,251 @@
1
+ /**
2
+ * @fileoverview Media transforms — async ffmpeg / image / OCR jobs.
3
+ *
4
+ * Mirrors the Rust `platform.media.transforms` surface. All mutating
5
+ * methods (transcode/thumbnail/resize/ocr) return a {@link JobHandle}
6
+ * whose `outputKey` is content-addressed via {@link keyFor} and known
7
+ * up front — clients can render placeholder UI for the derived asset
8
+ * before the worker even starts the job.
9
+ *
10
+ * The runtime injects the live JS object as `platform.media.transforms`
11
+ * (see `crates/runtime/src/ops/platform/js_bindings/platform_js.rs`).
12
+ * The static helper {@link keyFor} is exported separately and runs
13
+ * client-side — it must produce byte-for-byte identical output to the
14
+ * Rust `derive_key`. The shared golden-vector fixture
15
+ * `crates/platform/tests/derive_key_vectors.json` is consumed from both
16
+ * the Rust integration test (`crates/platform/tests/derive_key_golden.rs`)
17
+ * and the TS test (`packages/platform/tests/derive-key.test.ts`) to keep
18
+ * the two in lockstep.
19
+ */
20
+
21
+ // Isomorphic SHA-256 via @noble/hashes — runs in Node, Deno, and every
22
+ // browser without a bundler-specific shim. `node:crypto` would pull
23
+ // Node built-ins into browser bundles and break Vite builds that
24
+ // consume this package client-side.
25
+ import { sha256 } from '@noble/hashes/sha2';
26
+
27
+ // ── Types — mirror `crates/platform/src/media/transforms/types.rs` ──────
28
+
29
+ /** Video container targets supported by v1. */
30
+ export type VideoFormat = 'mp4' | 'webm';
31
+
32
+ /** Image output formats supported by v1. */
33
+ export type ImageFormat = 'jpg' | 'png' | 'webp';
34
+
35
+ /** Lifecycle state of a transform job. */
36
+ export type JobStatus = 'pending' | 'running' | 'complete' | 'failed';
37
+
38
+ /** Options for `transforms.transcode`. */
39
+ export interface TranscodeOpts {
40
+ format: VideoFormat;
41
+ codec?: string;
42
+ max_width?: number;
43
+ max_height?: number;
44
+ audio_codec?: string;
45
+ bitrate_kbps?: number;
46
+ }
47
+
48
+ /** Options for `transforms.thumbnail` — extract a single video frame. */
49
+ export interface ThumbnailOpts {
50
+ /** Offset into the source. Accepts `"00:00:01"`, `"1s"`, or a plain number-as-string. */
51
+ at: string;
52
+ width?: number;
53
+ height?: number;
54
+ /** Defaults to `"jpg"` server-side when omitted. */
55
+ format?: ImageFormat;
56
+ quality?: number;
57
+ }
58
+
59
+ /** Options for `transforms.resize`. */
60
+ export interface ResizeOpts {
61
+ width?: number;
62
+ height?: number;
63
+ format: ImageFormat;
64
+ quality?: number;
65
+ strip_metadata?: boolean;
66
+ }
67
+
68
+ /** Options for `transforms.ocr`. */
69
+ export interface OcrOpts {
70
+ /** Tesseract language code(s). Defaults to `"eng"` server-side when omitted. */
71
+ lang?: string;
72
+ }
73
+
74
+ /** Tagged union of all transform requests — matches the Rust `TransformSpec`. */
75
+ export type TransformSpec =
76
+ | ({ kind: 'transcode' } & TranscodeOpts)
77
+ | ({ kind: 'thumbnail' } & ThumbnailOpts)
78
+ | ({ kind: 'resize' } & ResizeOpts)
79
+ | ({ kind: 'ocr' } & OcrOpts);
80
+
81
+ /** Probe output. All fields optional — ffprobe doesn't fill every one for every input. */
82
+ export interface MediaInfo {
83
+ duration_secs?: number;
84
+ width?: number;
85
+ height?: number;
86
+ video_codec?: string;
87
+ audio_codec?: string;
88
+ bitrate_bps?: number;
89
+ container?: string;
90
+ }
91
+
92
+ /** Returned by every mutating transforms method. */
93
+ export interface JobHandle {
94
+ id: string;
95
+ src_key: string;
96
+ output_key: string;
97
+ status: JobStatus;
98
+ }
99
+
100
+ /** Returned by `transforms.job(id)`. */
101
+ export interface JobStatusResponse {
102
+ id: string;
103
+ status: JobStatus;
104
+ }
105
+
106
+ // ── Service interface (matches the JS object the runtime injects) ───────
107
+
108
+ export interface TransformsService {
109
+ transcode(srcKey: string, opts: TranscodeOpts): Promise<JobHandle>;
110
+ thumbnail(srcKey: string, opts: ThumbnailOpts): Promise<JobHandle>;
111
+ resize(srcKey: string, opts: ResizeOpts): Promise<JobHandle>;
112
+ probe(srcKey: string): Promise<MediaInfo>;
113
+ ocr(srcKey: string, opts?: OcrOpts | null): Promise<JobHandle>;
114
+ job(id: string): Promise<JobStatusResponse>;
115
+ }
116
+
117
+ // ── derive_key mirror ───────────────────────────────────────────────────
118
+
119
+ /** Sort object keys recursively so the canonical JSON is stable. */
120
+ function sortKeys(value: unknown): unknown {
121
+ if (Array.isArray(value)) return value.map(sortKeys);
122
+ if (value && typeof value === 'object') {
123
+ const out: Record<string, unknown> = {};
124
+ for (const k of Object.keys(value as Record<string, unknown>).sort()) {
125
+ out[k] = sortKeys((value as Record<string, unknown>)[k]);
126
+ }
127
+ return out;
128
+ }
129
+ return value;
130
+ }
131
+
132
+ /**
133
+ * Canonical JSON encoding — matches `serde_json::to_string` over a
134
+ * recursively key-sorted object. Numbers, booleans, strings, null, and
135
+ * nested objects/arrays only; no `undefined`, no functions.
136
+ */
137
+ function canonicalJson(value: unknown): string {
138
+ return JSON.stringify(sortKeys(value));
139
+ }
140
+
141
+ /** Hex-encoded SHA-256 of the input bytes, truncated to 16 chars (8 bytes). */
142
+ function shortHexSha256(input: string): string {
143
+ const bytes = new TextEncoder().encode(input);
144
+ const hash = sha256(bytes);
145
+ return Array.from(hash.slice(0, 8))
146
+ .map((b) => b.toString(16).padStart(2, '0'))
147
+ .join('');
148
+ }
149
+
150
+ /**
151
+ * Output extension for a spec — drives the `.<ext>` suffix on the
152
+ * derived key. Matches `TransformSpec::output_extension` in Rust.
153
+ */
154
+ function outputExtension(spec: TransformSpec): string {
155
+ switch (spec.kind) {
156
+ case 'transcode':
157
+ return spec.format; // 'mp4' | 'webm'
158
+ case 'thumbnail':
159
+ case 'resize': {
160
+ // thumbnail's `format` is optional in TS (defaults to jpg server-side);
161
+ // mirror that default here so keyFor matches the canonical Rust form.
162
+ const fmt = (spec as { format?: ImageFormat }).format ?? 'jpg';
163
+ return fmt;
164
+ }
165
+ case 'ocr':
166
+ return 'txt';
167
+ default: {
168
+ const _exhaust: never = spec;
169
+ throw new Error(`unknown transform kind: ${(_exhaust as { kind: string }).kind}`);
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Compute the canonical derived-asset key for `(srcKey, spec)`.
176
+ *
177
+ * Shape: `__derived/<srcHash>/<variantHash>.<ext>` where each hash is
178
+ * the first 16 hex chars (8 bytes / 64 bits) of `SHA-256(...)`.
179
+ *
180
+ * **MUST** match Rust `platform::media::transforms::derive_key` byte-for-byte.
181
+ * Cross-language golden vectors live at
182
+ * `crates/platform/tests/derive_key_vectors.json`.
183
+ */
184
+ export function keyFor(srcKey: string, spec: TransformSpec): string {
185
+ const srcHash = shortHexSha256(srcKey);
186
+ // Re-encode so the canonical JSON includes the `kind` discriminator at
187
+ // a sorted position alongside the inlined opts fields — matches what
188
+ // Rust's `serde_json::to_value(&spec)` produces for the tagged enum.
189
+ const variantHash = shortHexSha256(canonicalJson(spec));
190
+ const ext = outputExtension(spec);
191
+ return `__derived/${srcHash}/${variantHash}.${ext}`;
192
+ }
193
+
194
+ /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
195
+ export const transforms = {
196
+ keyFor,
197
+ };
198
+
199
+ // ── Declarative `transforms` config (consumed by the adapter compiler) ──
200
+ //
201
+ // Mirrors the build-time `TransformsConfig` in `@maravilla-labs/adapter-core`,
202
+ // duplicated here so user code that writes `defineConfig({ transforms: {...} })`
203
+ // gets full IDE completion without depending on adapter-core at runtime.
204
+ //
205
+ // The two type definitions are structurally identical — adapter-core's
206
+ // `compileTransforms` consumes either shape interchangeably (it only walks the
207
+ // JSON-shaped values, not the type-level identity).
208
+
209
+ /**
210
+ * Per-pattern transforms declaration used inside the `transforms` block of
211
+ * `maravilla.config.ts`. Each field accepts a single opts object or an array.
212
+ *
213
+ * `variants` is sugar for `resize` arrays — convenient for the common
214
+ * "image → multiple resized renditions" case.
215
+ */
216
+ export interface TransformsPatternSpec {
217
+ transcode?: TranscodeOpts | TranscodeOpts[];
218
+ thumbnail?: ThumbnailOpts | ThumbnailOpts[];
219
+ resize?: ResizeOpts | ResizeOpts[];
220
+ /** Sugar for `resize` arrays — same shape, same semantics. */
221
+ variants?: ResizeOpts[];
222
+ ocr?: OcrOpts | OcrOpts[];
223
+ }
224
+
225
+ /**
226
+ * Top-level `transforms` block. Keys are glob path patterns matched against
227
+ * the storage key of every uploaded object. The adapter compiles each entry
228
+ * into a synthetic `storage.put` event handler that fans out the declared
229
+ * transforms via `Promise.all`.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * import { defineConfig } from '@maravilla-labs/platform/config';
234
+ *
235
+ * export default defineConfig({
236
+ * transforms: {
237
+ * 'uploads/videos/**': {
238
+ * transcode: [{ format: 'mp4' }, { format: 'webm' }],
239
+ * thumbnail: { at: '1s', width: 640, format: 'jpg' },
240
+ * },
241
+ * 'uploads/photos/**': {
242
+ * variants: [
243
+ * { width: 1600, format: 'webp', quality: 85 },
244
+ * { width: 400, format: 'webp', quality: 80 },
245
+ * ],
246
+ * },
247
+ * },
248
+ * });
249
+ * ```
250
+ */
251
+ export type TransformsConfig = Record<string, TransformsPatternSpec>;
package/src/types.ts CHANGED
@@ -687,6 +687,26 @@ export interface Storage {
687
687
  lastModified: string;
688
688
  metadata?: any;
689
689
  } | null>;
690
+
691
+ /**
692
+ * Notify the platform that a presigned-PUT upload completed at the given
693
+ * key. The bytes never crossed the server, so no `storage.put` event would
694
+ * fire automatically — call this from your form action right after the
695
+ * client's direct PUT succeeds. The platform looks up the object's
696
+ * metadata and publishes the matching `storage.put` event so any registered
697
+ * event handlers (including the synthesized handlers from a `transforms`
698
+ * config block) fire as if the upload had streamed through the server.
699
+ *
700
+ * Idempotent — safe to call more than once with the same key (the server
701
+ * dedupes by emitting at most one event per (tenant, key, mtime) tuple).
702
+ *
703
+ * @example
704
+ * ```ts
705
+ * await fetch(presignedUrl, { method: 'PUT', body: blob });
706
+ * await platform.env.STORAGE.confirm(key);
707
+ * ```
708
+ */
709
+ confirm(key: string): Promise<void>;
690
710
  }
691
711
 
692
712
  /**
@@ -1346,6 +1366,83 @@ export interface PushService {
1346
1366
  rotateVapidKeys(): Promise<PublicPushConfig>;
1347
1367
  }
1348
1368
 
1369
+ /** State of a workflow run. */
1370
+ export type WorkflowRunStatus =
1371
+ | 'queued'
1372
+ | 'running'
1373
+ | 'sleeping'
1374
+ | 'waiting_event'
1375
+ | 'completed'
1376
+ | 'failed'
1377
+ | 'cancelled';
1378
+
1379
+ /** Kind of a recorded step. */
1380
+ export type WorkflowStepKind = 'run' | 'sleep' | 'wait_event' | 'ai' | 'mcp' | 'invoke';
1381
+
1382
+ /** A durable workflow run as stored in the ledger. */
1383
+ export interface WorkflowRun {
1384
+ runId: string;
1385
+ workflowId: string;
1386
+ projectId?: string;
1387
+ deploymentId: string;
1388
+ input: unknown;
1389
+ status: WorkflowRunStatus;
1390
+ attempt: number;
1391
+ createdAt: number;
1392
+ updatedAt: number;
1393
+ completedAt?: number;
1394
+ output?: unknown;
1395
+ error?: unknown;
1396
+ }
1397
+
1398
+ /** A single step in a run's history. */
1399
+ export interface WorkflowStepRecord {
1400
+ name: string;
1401
+ kind: WorkflowStepKind;
1402
+ status: 'pending' | 'completed' | 'failed';
1403
+ output?: unknown;
1404
+ error?: unknown;
1405
+ startedAt: number;
1406
+ completedAt?: number;
1407
+ wakeAt?: number;
1408
+ }
1409
+
1410
+ /**
1411
+ * Handle returned by `platform.workflows.start()` — poll status, await
1412
+ * the final result, cancel, or inspect step history.
1413
+ */
1414
+ export interface WorkflowHandle {
1415
+ /** The run's unique id. */
1416
+ readonly runId: string;
1417
+ /** Current run record, or `null` if the run does not exist. */
1418
+ status(): Promise<WorkflowRun | null>;
1419
+ /** Full step history — useful for debugging. */
1420
+ history(): Promise<WorkflowStepRecord[]>;
1421
+ /**
1422
+ * Wait for the run to reach a terminal state and return its output.
1423
+ * Throws if the run ended `failed` or `cancelled`; the thrown error
1424
+ * carries the run record on the `run` property.
1425
+ */
1426
+ result(options?: { timeoutMs?: number }): Promise<unknown>;
1427
+ /** Best-effort cancellation. Returns `true` if the run transitioned. */
1428
+ cancel(): Promise<boolean>;
1429
+ }
1430
+
1431
+ /** Durable multi-step workflow service. */
1432
+ export interface Workflows {
1433
+ /** Start a new run of the workflow with the given input. */
1434
+ start(workflowId: string, input?: unknown): Promise<WorkflowHandle>;
1435
+ /** Get a handle to an existing run without starting one. */
1436
+ handle(runId: string): WorkflowHandle;
1437
+ /**
1438
+ * Signal an event to any runs paused on `step.waitForEvent`. Returns
1439
+ * the number of runs resolved. Matching rules: `eventType` must equal
1440
+ * the waiter's filter `type`; each key in the filter's `match` must
1441
+ * be present in `payload` with an equal value.
1442
+ */
1443
+ sendEvent(eventType: string, payload?: unknown): Promise<number>;
1444
+ }
1445
+
1349
1446
  export interface Platform {
1350
1447
  /** Environment containing all available platform services */
1351
1448
  env: PlatformEnv;
@@ -1364,4 +1461,6 @@ export interface Platform {
1364
1461
  * fallback that doesn't proxy to delivery.
1365
1462
  */
1366
1463
  push?: PushService;
1464
+ /** Durable multi-step workflows — `start / handle / sendEvent`. */
1465
+ workflows: Workflows;
1367
1466
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Cross-language golden vectors for `transforms.keyFor`.
3
+ *
4
+ * Loads `crates/platform/tests/derive_key_vectors.json` — the same
5
+ * fixture the Rust `derive_key` integration test consumes — and asserts
6
+ * that the TypeScript mirror produces byte-identical output for every
7
+ * vector. If this fails, either:
8
+ * - the JS algorithm has drifted from Rust → fix `transforms.ts`;
9
+ * - the Rust algorithm changed deliberately → regenerate the fixture
10
+ * via `cargo run --example gen_derive_key_vectors -p platform > …`
11
+ * and update the JS impl in lockstep.
12
+ */
13
+
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { describe, expect, it } from 'vitest';
18
+
19
+ import { keyFor, type TransformSpec } from '../src/transforms.js';
20
+
21
+ interface Vector {
22
+ name: string;
23
+ src: string;
24
+ spec: Record<string, unknown>;
25
+ expected_key: string;
26
+ }
27
+
28
+ const fixturePath = join(
29
+ __dirname,
30
+ '..',
31
+ '..',
32
+ '..',
33
+ 'crates',
34
+ 'platform',
35
+ 'tests',
36
+ 'derive_key_vectors.json',
37
+ );
38
+
39
+ function loadVectors(): Vector[] {
40
+ const raw = readFileSync(fixturePath, 'utf8');
41
+ const parsed = JSON.parse(raw) as Vector[];
42
+ if (!Array.isArray(parsed) || parsed.length === 0) {
43
+ throw new Error(`expected non-empty vector array in ${fixturePath}`);
44
+ }
45
+ return parsed;
46
+ }
47
+
48
+ describe('transforms.keyFor (golden vectors shared with Rust)', () => {
49
+ const vectors = loadVectors();
50
+
51
+ it('loads at least 8 vectors from the shared fixture', () => {
52
+ expect(vectors.length).toBeGreaterThanOrEqual(8);
53
+ });
54
+
55
+ it('covers every TransformSpec variant', () => {
56
+ const kinds = new Set(vectors.map((v) => v.spec['kind'] as string));
57
+ for (const required of ['transcode', 'thumbnail', 'resize', 'ocr']) {
58
+ expect(kinds.has(required), `missing ${required}`).toBe(true);
59
+ }
60
+ });
61
+
62
+ for (const v of vectors) {
63
+ it(`matches Rust derive_key for ${v.name}`, () => {
64
+ const got = keyFor(v.src, v.spec as unknown as TransformSpec);
65
+ expect(got).toBe(v.expected_key);
66
+ });
67
+ }
68
+ });