@ttt-productions/media-processing-core 0.8.6 → 0.8.8

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.
@@ -3,7 +3,7 @@ import type { MediaObjectStore } from "./storage-ops.js";
3
3
  export interface CreateObjectStoreMediaIOArgs {
4
4
  /** Firebase Storage staging path of the uploaded source file. */
5
5
  inputStoragePath: string;
6
- /** Store receiving the processed outputs (R2 or emulator). */
6
+ /** Store receiving the processed outputs. */
7
7
  outputStore: MediaObjectStore;
8
8
  /** Key prefix for outputs, e.g. `mediaAssets/{mediaAssetId}`. */
9
9
  outputKeyPrefix: string;
@@ -1 +1 @@
1
- {"version":3,"file":"firebase-media-io.d.ts","sourceRoot":"","sources":["../../src/server/firebase-media-io.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,MAAM,WAAW,4BAA4B;IAC3C,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,CAAC;IACzB,8DAA8D;IAC9D,WAAW,EAAE,gBAAgB,CAAC;IAC9B,iEAAiE;IACjE,eAAe,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACnC;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,4BAA4B,GAAG,OAAO,CAsBpF"}
1
+ {"version":3,"file":"firebase-media-io.d.ts","sourceRoot":"","sources":["../../src/server/firebase-media-io.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,MAAM,WAAW,4BAA4B;IAC3C,iEAAiE;IACjE,gBAAgB,EAAE,MAAM,CAAC;IACzB,6CAA6C;IAC7C,WAAW,EAAE,gBAAgB,CAAC;IAC9B,iEAAiE;IACjE,eAAe,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACnC;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,4BAA4B,GAAG,OAAO,CAsBpF"}
@@ -1,6 +1,6 @@
1
1
  // MediaIO adapter: reads the staged upload from Firebase Storage, writes
2
- // processed outputs through a MediaObjectStore (R2 in deployed envs, the
3
- // Storage emulator locally). Output keys are extension-less:
2
+ // processed outputs through whichever MediaObjectStore the app selected for the
3
+ // environment (see storage-ops.ts). Output keys are extension-less:
4
4
  // `${outputKeyPrefix}/${outputKey}` — meaning lives in the asset registry,
5
5
  // not the key. Writes return the object key; no URLs anywhere.
6
6
  import { getStorage } from "firebase-admin/storage";
@@ -1 +1 @@
1
- {"version":3,"file":"firebase-media-io.js","sourceRoot":"","sources":["../../src/server/firebase-media-io.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,yEAAyE;AACzE,6DAA6D;AAC7D,2EAA2E;AAC3E,+DAA+D;AAE/D,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAuBpD,MAAM,UAAU,wBAAwB,CAAC,IAAkC;IACzE,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC,MAAM,EAAE,CAAC;IAErC,OAAO;QACL,KAAK,EAAE;YACL,IAAI,EAAE,IAAI,CAAC,SAAS;YACpB,KAAK,CAAC,UAAU,CAAC,SAAiB;gBAChC,MAAM,IAAI,GACR,IAAI,CAAC,eAAe,IAAI,IAAI;oBAC1B,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,eAAe,EAAE,CAAC;oBAC1E,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBACzC,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAC;YAClD,CAAC;SACF;QACD,MAAM,EAAE;YACN,KAAK,CAAC,aAAa,CAAC,SAAiB,EAAE,SAAiB;gBACtD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,eAAe,IAAI,SAAS,EAAE,CAAC;gBACnD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC;gBAClE,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC;YAC9B,CAAC;SACF;KACF,CAAC;AACJ,CAAC","sourcesContent":["// MediaIO adapter: reads the staged upload from Firebase Storage, writes\n// processed outputs through a MediaObjectStore (R2 in deployed envs, the\n// Storage emulator locally). Output keys are extension-less:\n// `${outputKeyPrefix}/${outputKey}` — meaning lives in the asset registry,\n// not the key. Writes return the object key; no URLs anywhere.\n\nimport { getStorage } from \"firebase-admin/storage\";\nimport type { MediaIO } from \"../io/types.js\";\nimport type { MediaObjectStore } from \"./storage-ops.js\";\n\nexport interface CreateObjectStoreMediaIOArgs {\n /** Firebase Storage staging path of the uploaded source file. */\n inputStoragePath: string;\n /** Store receiving the processed outputs (R2 or emulator). */\n outputStore: MediaObjectStore;\n /** Key prefix for outputs, e.g. `mediaAssets/{mediaAssetId}`. */\n outputKeyPrefix: string;\n /** Optional mime hint for the input. */\n inputMime?: string;\n /**\n * Optional object generation to pin the input download to. When set, the\n * download resolves the exact immutable generation of the staged object\n * (per GCS generation-precondition semantics), so a concurrent overwrite of\n * the source path cannot swap the bytes that get read. Accepts the numeric\n * generation or its string form (GCS generations exceed 2^53).\n */\n inputGeneration?: string | number;\n}\n\nexport function createObjectStoreMediaIO(args: CreateObjectStoreMediaIOArgs): MediaIO {\n const bucket = getStorage().bucket();\n\n return {\n input: {\n mime: args.inputMime,\n async readToFile(localPath: string) {\n const file =\n args.inputGeneration != null\n ? bucket.file(args.inputStoragePath, { generation: args.inputGeneration })\n : bucket.file(args.inputStoragePath);\n await file.download({ destination: localPath });\n },\n },\n output: {\n async writeFromFile(localPath: string, outputKey: string) {\n const key = `${args.outputKeyPrefix}/${outputKey}`;\n const result = await args.outputStore.putFile({ localPath, key });\n return { path: result.key };\n },\n },\n };\n}\n"]}
1
+ {"version":3,"file":"firebase-media-io.js","sourceRoot":"","sources":["../../src/server/firebase-media-io.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,gFAAgF;AAChF,oEAAoE;AACpE,2EAA2E;AAC3E,+DAA+D;AAE/D,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAuBpD,MAAM,UAAU,wBAAwB,CAAC,IAAkC;IACzE,MAAM,MAAM,GAAG,UAAU,EAAE,CAAC,MAAM,EAAE,CAAC;IAErC,OAAO;QACL,KAAK,EAAE;YACL,IAAI,EAAE,IAAI,CAAC,SAAS;YACpB,KAAK,CAAC,UAAU,CAAC,SAAiB;gBAChC,MAAM,IAAI,GACR,IAAI,CAAC,eAAe,IAAI,IAAI;oBAC1B,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,EAAE,EAAE,UAAU,EAAE,IAAI,CAAC,eAAe,EAAE,CAAC;oBAC1E,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;gBACzC,MAAM,IAAI,CAAC,QAAQ,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC,CAAC;YAClD,CAAC;SACF;QACD,MAAM,EAAE;YACN,KAAK,CAAC,aAAa,CAAC,SAAiB,EAAE,SAAiB;gBACtD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,eAAe,IAAI,SAAS,EAAE,CAAC;gBACnD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAC;gBAClE,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC;YAC9B,CAAC;SACF;KACF,CAAC;AACJ,CAAC","sourcesContent":["// MediaIO adapter: reads the staged upload from Firebase Storage, writes\n// processed outputs through whichever MediaObjectStore the app selected for the\n// environment (see storage-ops.ts). Output keys are extension-less:\n// `${outputKeyPrefix}/${outputKey}` — meaning lives in the asset registry,\n// not the key. Writes return the object key; no URLs anywhere.\n\nimport { getStorage } from \"firebase-admin/storage\";\nimport type { MediaIO } from \"../io/types.js\";\nimport type { MediaObjectStore } from \"./storage-ops.js\";\n\nexport interface CreateObjectStoreMediaIOArgs {\n /** Firebase Storage staging path of the uploaded source file. */\n inputStoragePath: string;\n /** Store receiving the processed outputs. */\n outputStore: MediaObjectStore;\n /** Key prefix for outputs, e.g. `mediaAssets/{mediaAssetId}`. */\n outputKeyPrefix: string;\n /** Optional mime hint for the input. */\n inputMime?: string;\n /**\n * Optional object generation to pin the input download to. When set, the\n * download resolves the exact immutable generation of the staged object\n * (per GCS generation-precondition semantics), so a concurrent overwrite of\n * the source path cannot swap the bytes that get read. Accepts the numeric\n * generation or its string form (GCS generations exceed 2^53).\n */\n inputGeneration?: string | number;\n}\n\nexport function createObjectStoreMediaIO(args: CreateObjectStoreMediaIOArgs): MediaIO {\n const bucket = getStorage().bucket();\n\n return {\n input: {\n mime: args.inputMime,\n async readToFile(localPath: string) {\n const file =\n args.inputGeneration != null\n ? bucket.file(args.inputStoragePath, { generation: args.inputGeneration })\n : bucket.file(args.inputStoragePath);\n await file.download({ destination: localPath });\n },\n },\n output: {\n async writeFromFile(localPath: string, outputKey: string) {\n const key = `${args.outputKeyPrefix}/${outputKey}`;\n const result = await args.outputStore.putFile({ localPath, key });\n return { path: result.key };\n },\n },\n };\n}\n"]}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * True when a Cloud Storage call failed because the object does not exist. The Admin
3
+ * SDK reports it as a numeric or string `404` code, or only in the message.
4
+ */
5
+ export declare function isObjectNotFoundError(e: unknown): boolean;
6
+ //# sourceMappingURL=gcs-errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcs-errors.d.ts","sourceRoot":"","sources":["../../src/server/gcs-errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAGzD"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * True when a Cloud Storage call failed because the object does not exist. The Admin
3
+ * SDK reports it as a numeric or string `404` code, or only in the message.
4
+ */
5
+ export function isObjectNotFoundError(e) {
6
+ const err = e;
7
+ return err?.code === 404 || err?.code === "404" || /No such object/i.test(String(err?.message));
8
+ }
9
+ //# sourceMappingURL=gcs-errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gcs-errors.js","sourceRoot":"","sources":["../../src/server/gcs-errors.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,CAAU;IAC9C,MAAM,GAAG,GAAG,CAA6D,CAAC;IAC1E,OAAO,GAAG,EAAE,IAAI,KAAK,GAAG,IAAI,GAAG,EAAE,IAAI,KAAK,KAAK,IAAI,iBAAiB,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC;AAClG,CAAC","sourcesContent":["/**\n * True when a Cloud Storage call failed because the object does not exist. The Admin\n * SDK reports it as a numeric or string `404` code, or only in the message.\n */\nexport function isObjectNotFoundError(e: unknown): boolean {\n const err = e as { code?: unknown; message?: unknown } | null | undefined;\n return err?.code === 404 || err?.code === \"404\" || /No such object/i.test(String(err?.message));\n}\n"]}
@@ -1,3 +1,5 @@
1
1
  export * from "./storage-ops.js";
2
2
  export * from "./firebase-media-io.js";
3
+ export * from "./pending-media.js";
4
+ export * from "./staged-object.js";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC"}
@@ -1,3 +1,5 @@
1
1
  export * from "./storage-ops.js";
2
2
  export * from "./firebase-media-io.js";
3
+ export * from "./pending-media.js";
4
+ export * from "./staged-object.js";
3
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC","sourcesContent":["export * from \"./storage-ops.js\";\nexport * from \"./firebase-media-io.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,wBAAwB,CAAC;AACvC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC","sourcesContent":["export * from \"./storage-ops.js\";\nexport * from \"./firebase-media-io.js\";\nexport * from \"./pending-media.js\";\nexport * from \"./staged-object.js\";\n"]}
@@ -0,0 +1,65 @@
1
+ import type { DocumentReference, DocumentSnapshot, Firestore, Transaction } from "firebase-admin/firestore";
2
+ import { type ClaimablePendingMedia, type PendingMediaClaimOutcome, type PendingMediaClaimPolicy, type PendingMediaFinalizeResult, type PendingMediaTerminalStatus } from "@ttt-productions/media-schemas";
3
+ /** The app's pending-media row schema — anything with zod's `safeParse` shape. */
4
+ export interface PendingMediaRowParser<TRow> {
5
+ safeParse(data: unknown): {
6
+ success: true;
7
+ data: TRow;
8
+ } | {
9
+ success: false;
10
+ };
11
+ }
12
+ export interface ClaimPendingMediaOptions<TRow> {
13
+ schema: PendingMediaRowParser<TRow>;
14
+ policy: PendingMediaClaimPolicy;
15
+ /** Epoch ms; defaults to `Date.now()`. */
16
+ now?: number;
17
+ }
18
+ /**
19
+ * Claim a row for processing inside `transaction`, driven by the CURRENT row (never a
20
+ * frozen event snapshot). Reads before any write. A row that no longer parses is
21
+ * `missing` and never dispatched: a corrupt row must not drive the processor.
22
+ */
23
+ export declare function claimPendingMediaForProcessing<TRow extends ClaimablePendingMedia>(transaction: Transaction, pendingMediaRef: DocumentReference, options: ClaimPendingMediaOptions<TRow>): Promise<PendingMediaClaimOutcome<TRow>>;
24
+ export interface PendingMediaFinalizeContext {
25
+ targetStatus: PendingMediaTerminalStatus;
26
+ /** True when the row failed schema validation and is being failed as unparsable. */
27
+ unparsable: boolean;
28
+ }
29
+ /**
30
+ * Caller-owned writes that must commit atomically with a terminal transition, and only
31
+ * when it applies (releasing a quota reservation, for example — exactly once, because
32
+ * the compare-and-set gates it). It runs in the transaction's READ phase and may
33
+ * `transaction.get`; it returns the WRITE-phase step, or undefined when there is nothing
34
+ * to write. The step runs after the terminal write, and its return value is surfaced as
35
+ * the outcome's `extra`.
36
+ */
37
+ export type PendingMediaExtraWrites<TExtra> = (transaction: Transaction, snapshot: DocumentSnapshot, context: PendingMediaFinalizeContext) => Promise<(() => TExtra) | undefined>;
38
+ export interface FinalizePendingMediaOptions<TExtra> {
39
+ /** Merged into the terminal fields (`result`, `errorCategory`, `errorMessage`, …). */
40
+ extraFields?: Record<string, unknown>;
41
+ extraWrites?: PendingMediaExtraWrites<TExtra>;
42
+ /** Epoch ms; defaults to `Date.now()`. */
43
+ now?: number;
44
+ }
45
+ export interface PendingMediaFinalizeOutcome<TExtra> extends PendingMediaFinalizeResult {
46
+ /** What the extra writes returned — present only on `applied`, when they wrote. */
47
+ extra?: TExtra;
48
+ }
49
+ /**
50
+ * Compare-and-set finalize inside the caller's transaction: re-read the row, decide,
51
+ * and write the terminal fields (plus any extra writes) only on `applied`. An
52
+ * already-terminal row is never overwritten. Reads-before-writes holds as long as the
53
+ * caller has issued no write on `transaction` yet.
54
+ */
55
+ export declare function finalizePendingMediaInTransaction<TExtra = never>(transaction: Transaction, ref: DocumentReference, targetStatus: PendingMediaTerminalStatus, options?: FinalizePendingMediaOptions<TExtra>): Promise<PendingMediaFinalizeOutcome<TExtra>>;
56
+ /** Standalone compare-and-set finalize, in its own transaction. */
57
+ export declare function finalizePendingMedia<TExtra = never>(db: Firestore, ref: DocumentReference, targetStatus: PendingMediaTerminalStatus, options?: FinalizePendingMediaOptions<TExtra>): Promise<PendingMediaFinalizeOutcome<TExtra>>;
58
+ /**
59
+ * Fail a row that no longer passes schema validation, so the transition table cannot
60
+ * be applied (it may carry no readable `status`). One transaction: the row is failed —
61
+ * with any extra writes — only while it has no status or a `pending` / `processing`
62
+ * one, so an already-terminal row is never touched and extra writes run exactly once.
63
+ */
64
+ export declare function finalizeUnparsablePendingMedia<TExtra = never>(db: Firestore, ref: DocumentReference, options?: FinalizePendingMediaOptions<TExtra>): Promise<void>;
65
+ //# sourceMappingURL=pending-media.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-media.d.ts","sourceRoot":"","sources":["../../src/server/pending-media.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5G,OAAO,EAML,KAAK,qBAAqB,EAC1B,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAC/B,KAAK,0BAA0B,EAChC,MAAM,gCAAgC,CAAC;AAMxC,kFAAkF;AAClF,MAAM,WAAW,qBAAqB,CAAC,IAAI;IACzC,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,IAAI,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,GAAG;QAAE,OAAO,EAAE,KAAK,CAAA;KAAE,CAAC;CAC9E;AAED,MAAM,WAAW,wBAAwB,CAAC,IAAI;IAC5C,MAAM,EAAE,qBAAqB,CAAC,IAAI,CAAC,CAAC;IACpC,MAAM,EAAE,uBAAuB,CAAC;IAChC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAsB,8BAA8B,CAAC,IAAI,SAAS,qBAAqB,EACrF,WAAW,EAAE,WAAW,EACxB,eAAe,EAAE,iBAAiB,EAClC,OAAO,EAAE,wBAAwB,CAAC,IAAI,CAAC,GACtC,OAAO,CAAC,wBAAwB,CAAC,IAAI,CAAC,CAAC,CAWzC;AAMD,MAAM,WAAW,2BAA2B;IAC1C,YAAY,EAAE,0BAA0B,CAAC;IACzC,oFAAoF;IACpF,UAAU,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,uBAAuB,CAAC,MAAM,IAAI,CAC5C,WAAW,EAAE,WAAW,EACxB,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,2BAA2B,KACjC,OAAO,CAAC,CAAC,MAAM,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;AAEzC,MAAM,WAAW,2BAA2B,CAAC,MAAM;IACjD,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,WAAW,CAAC,EAAE,uBAAuB,CAAC,MAAM,CAAC,CAAC;IAC9C,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,2BAA2B,CAAC,MAAM,CAAE,SAAQ,0BAA0B;IACrF,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAsB,iCAAiC,CAAC,MAAM,GAAG,KAAK,EACpE,WAAW,EAAE,WAAW,EACxB,GAAG,EAAE,iBAAiB,EACtB,YAAY,EAAE,0BAA0B,EACxC,OAAO,GAAE,2BAA2B,CAAC,MAAM,CAAM,GAChD,OAAO,CAAC,2BAA2B,CAAC,MAAM,CAAC,CAAC,CAW9C;AAED,mEAAmE;AACnE,wBAAgB,oBAAoB,CAAC,MAAM,GAAG,KAAK,EACjD,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,iBAAiB,EACtB,YAAY,EAAE,0BAA0B,EACxC,OAAO,GAAE,2BAA2B,CAAC,MAAM,CAAM,GAChD,OAAO,CAAC,2BAA2B,CAAC,MAAM,CAAC,CAAC,CAE9C;AAED;;;;;GAKG;AACH,wBAAsB,8BAA8B,CAAC,MAAM,GAAG,KAAK,EACjE,EAAE,EAAE,SAAS,EACb,GAAG,EAAE,iBAAiB,EACtB,OAAO,GAAE,2BAA2B,CAAC,MAAM,CAAM,GAChD,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -0,0 +1,65 @@
1
+ // Admin-SDK transaction wrappers for the pending-media lifecycle. The decisions are the
2
+ // pure ones in `@ttt-productions/media-schemas` (`decidePendingMediaClaim`,
3
+ // `decidePendingMediaFinalize`); these read the CURRENT row inside a transaction,
4
+ // decide, and write. Everything app-specific is injected: the row's `DocumentReference`
5
+ // (so no collection name lives here), the row schema, the claim policy, the extra
6
+ // writes a terminal transition carries, and the clock.
7
+ import { buildPendingMediaTerminalFields, decidePendingMediaClaim, decidePendingMediaFinalize, isPendingMediaNonTerminalStatus, PendingMediaStatusSchema, } from "@ttt-productions/media-schemas";
8
+ /**
9
+ * Claim a row for processing inside `transaction`, driven by the CURRENT row (never a
10
+ * frozen event snapshot). Reads before any write. A row that no longer parses is
11
+ * `missing` and never dispatched: a corrupt row must not drive the processor.
12
+ */
13
+ export async function claimPendingMediaForProcessing(transaction, pendingMediaRef, options) {
14
+ const snap = await transaction.get(pendingMediaRef);
15
+ if (!snap.exists)
16
+ return { kind: "missing" };
17
+ const parsed = options.schema.safeParse(snap.data());
18
+ if (!parsed.success)
19
+ return { kind: "missing" };
20
+ const { outcome, write } = decidePendingMediaClaim(parsed.data, options.now ?? Date.now(), options.policy);
21
+ // Spread into a literal: the Admin SDK's update() wants an UpdateData-compatible object.
22
+ if (write)
23
+ transaction.update(pendingMediaRef, { ...write });
24
+ return outcome;
25
+ }
26
+ /**
27
+ * Compare-and-set finalize inside the caller's transaction: re-read the row, decide,
28
+ * and write the terminal fields (plus any extra writes) only on `applied`. An
29
+ * already-terminal row is never overwritten. Reads-before-writes holds as long as the
30
+ * caller has issued no write on `transaction` yet.
31
+ */
32
+ export async function finalizePendingMediaInTransaction(transaction, ref, targetStatus, options = {}) {
33
+ const snap = await transaction.get(ref);
34
+ const result = decidePendingMediaFinalize(snap.exists ? (snap.data() ?? {}) : undefined, targetStatus);
35
+ if (result.decision !== "applied")
36
+ return result;
37
+ const applyExtra = await options.extraWrites?.(transaction, snap, { targetStatus, unparsable: false });
38
+ transaction.update(ref, buildPendingMediaTerminalFields(targetStatus, options.extraFields ?? {}, options.now ?? Date.now()));
39
+ return applyExtra ? { ...result, extra: applyExtra() } : result;
40
+ }
41
+ /** Standalone compare-and-set finalize, in its own transaction. */
42
+ export function finalizePendingMedia(db, ref, targetStatus, options = {}) {
43
+ return db.runTransaction((transaction) => finalizePendingMediaInTransaction(transaction, ref, targetStatus, options));
44
+ }
45
+ /**
46
+ * Fail a row that no longer passes schema validation, so the transition table cannot
47
+ * be applied (it may carry no readable `status`). One transaction: the row is failed —
48
+ * with any extra writes — only while it has no status or a `pending` / `processing`
49
+ * one, so an already-terminal row is never touched and extra writes run exactly once.
50
+ */
51
+ export async function finalizeUnparsablePendingMedia(db, ref, options = {}) {
52
+ await db.runTransaction(async (transaction) => {
53
+ const snap = await transaction.get(ref);
54
+ if (!snap.exists)
55
+ return;
56
+ const status = snap.data()?.status;
57
+ if (status !== undefined && !isPendingMediaNonTerminalStatus(status))
58
+ return;
59
+ const failed = PendingMediaStatusSchema.enum.failed;
60
+ const applyExtra = await options.extraWrites?.(transaction, snap, { targetStatus: failed, unparsable: true });
61
+ transaction.update(ref, buildPendingMediaTerminalFields(failed, options.extraFields ?? {}, options.now ?? Date.now()));
62
+ applyExtra?.();
63
+ });
64
+ }
65
+ //# sourceMappingURL=pending-media.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending-media.js","sourceRoot":"","sources":["../../src/server/pending-media.ts"],"names":[],"mappings":"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","sourcesContent":["// Admin-SDK transaction wrappers for the pending-media lifecycle. The decisions are the\n// pure ones in `@ttt-productions/media-schemas` (`decidePendingMediaClaim`,\n// `decidePendingMediaFinalize`); these read the CURRENT row inside a transaction,\n// decide, and write. Everything app-specific is injected: the row's `DocumentReference`\n// (so no collection name lives here), the row schema, the claim policy, the extra\n// writes a terminal transition carries, and the clock.\n\nimport type { DocumentReference, DocumentSnapshot, Firestore, Transaction } from \"firebase-admin/firestore\";\nimport {\n buildPendingMediaTerminalFields,\n decidePendingMediaClaim,\n decidePendingMediaFinalize,\n isPendingMediaNonTerminalStatus,\n PendingMediaStatusSchema,\n type ClaimablePendingMedia,\n type PendingMediaClaimOutcome,\n type PendingMediaClaimPolicy,\n type PendingMediaFinalizeResult,\n type PendingMediaTerminalStatus,\n} from \"@ttt-productions/media-schemas\";\n\n// ---------------------------------------------------------------------------\n// Claim\n// ---------------------------------------------------------------------------\n\n/** The app's pending-media row schema — anything with zod's `safeParse` shape. */\nexport interface PendingMediaRowParser<TRow> {\n safeParse(data: unknown): { success: true; data: TRow } | { success: false };\n}\n\nexport interface ClaimPendingMediaOptions<TRow> {\n schema: PendingMediaRowParser<TRow>;\n policy: PendingMediaClaimPolicy;\n /** Epoch ms; defaults to `Date.now()`. */\n now?: number;\n}\n\n/**\n * Claim a row for processing inside `transaction`, driven by the CURRENT row (never a\n * frozen event snapshot). Reads before any write. A row that no longer parses is\n * `missing` and never dispatched: a corrupt row must not drive the processor.\n */\nexport async function claimPendingMediaForProcessing<TRow extends ClaimablePendingMedia>(\n transaction: Transaction,\n pendingMediaRef: DocumentReference,\n options: ClaimPendingMediaOptions<TRow>,\n): Promise<PendingMediaClaimOutcome<TRow>> {\n const snap = await transaction.get(pendingMediaRef);\n if (!snap.exists) return { kind: \"missing\" };\n\n const parsed = options.schema.safeParse(snap.data());\n if (!parsed.success) return { kind: \"missing\" };\n\n const { outcome, write } = decidePendingMediaClaim(parsed.data, options.now ?? Date.now(), options.policy);\n // Spread into a literal: the Admin SDK's update() wants an UpdateData-compatible object.\n if (write) transaction.update(pendingMediaRef, { ...write });\n return outcome;\n}\n\n// ---------------------------------------------------------------------------\n// Finalize\n// ---------------------------------------------------------------------------\n\nexport interface PendingMediaFinalizeContext {\n targetStatus: PendingMediaTerminalStatus;\n /** True when the row failed schema validation and is being failed as unparsable. */\n unparsable: boolean;\n}\n\n/**\n * Caller-owned writes that must commit atomically with a terminal transition, and only\n * when it applies (releasing a quota reservation, for example — exactly once, because\n * the compare-and-set gates it). It runs in the transaction's READ phase and may\n * `transaction.get`; it returns the WRITE-phase step, or undefined when there is nothing\n * to write. The step runs after the terminal write, and its return value is surfaced as\n * the outcome's `extra`.\n */\nexport type PendingMediaExtraWrites<TExtra> = (\n transaction: Transaction,\n snapshot: DocumentSnapshot,\n context: PendingMediaFinalizeContext,\n) => Promise<(() => TExtra) | undefined>;\n\nexport interface FinalizePendingMediaOptions<TExtra> {\n /** Merged into the terminal fields (`result`, `errorCategory`, `errorMessage`, …). */\n extraFields?: Record<string, unknown>;\n extraWrites?: PendingMediaExtraWrites<TExtra>;\n /** Epoch ms; defaults to `Date.now()`. */\n now?: number;\n}\n\nexport interface PendingMediaFinalizeOutcome<TExtra> extends PendingMediaFinalizeResult {\n /** What the extra writes returned — present only on `applied`, when they wrote. */\n extra?: TExtra;\n}\n\n/**\n * Compare-and-set finalize inside the caller's transaction: re-read the row, decide,\n * and write the terminal fields (plus any extra writes) only on `applied`. An\n * already-terminal row is never overwritten. Reads-before-writes holds as long as the\n * caller has issued no write on `transaction` yet.\n */\nexport async function finalizePendingMediaInTransaction<TExtra = never>(\n transaction: Transaction,\n ref: DocumentReference,\n targetStatus: PendingMediaTerminalStatus,\n options: FinalizePendingMediaOptions<TExtra> = {},\n): Promise<PendingMediaFinalizeOutcome<TExtra>> {\n const snap = await transaction.get(ref);\n const result = decidePendingMediaFinalize(snap.exists ? (snap.data() ?? {}) : undefined, targetStatus);\n if (result.decision !== \"applied\") return result;\n\n const applyExtra = await options.extraWrites?.(transaction, snap, { targetStatus, unparsable: false });\n transaction.update(\n ref,\n buildPendingMediaTerminalFields(targetStatus, options.extraFields ?? {}, options.now ?? Date.now()),\n );\n return applyExtra ? { ...result, extra: applyExtra() } : result;\n}\n\n/** Standalone compare-and-set finalize, in its own transaction. */\nexport function finalizePendingMedia<TExtra = never>(\n db: Firestore,\n ref: DocumentReference,\n targetStatus: PendingMediaTerminalStatus,\n options: FinalizePendingMediaOptions<TExtra> = {},\n): Promise<PendingMediaFinalizeOutcome<TExtra>> {\n return db.runTransaction((transaction) => finalizePendingMediaInTransaction(transaction, ref, targetStatus, options));\n}\n\n/**\n * Fail a row that no longer passes schema validation, so the transition table cannot\n * be applied (it may carry no readable `status`). One transaction: the row is failed —\n * with any extra writes — only while it has no status or a `pending` / `processing`\n * one, so an already-terminal row is never touched and extra writes run exactly once.\n */\nexport async function finalizeUnparsablePendingMedia<TExtra = never>(\n db: Firestore,\n ref: DocumentReference,\n options: FinalizePendingMediaOptions<TExtra> = {},\n): Promise<void> {\n await db.runTransaction(async (transaction) => {\n const snap = await transaction.get(ref);\n if (!snap.exists) return;\n const status = (snap.data() as { status?: unknown } | undefined)?.status;\n if (status !== undefined && !isPendingMediaNonTerminalStatus(status)) return;\n\n const failed = PendingMediaStatusSchema.enum.failed;\n const applyExtra = await options.extraWrites?.(transaction, snap, { targetStatus: failed, unparsable: true });\n transaction.update(ref, buildPendingMediaTerminalFields(failed, options.extraFields ?? {}, options.now ?? Date.now()));\n applyExtra?.();\n });\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import type { Bucket } from "./storage-ops.js";
2
+ /** The stored-object metadata fields these reads use (the Admin SDK returns more). */
3
+ export interface StagedObjectMetadata {
4
+ contentType?: string;
5
+ size?: string | number;
6
+ generation?: string | number;
7
+ [field: string]: unknown;
8
+ }
9
+ export type StagedUploadMetadataRead = {
10
+ found: true;
11
+ metadata: StagedObjectMetadata;
12
+ } | {
13
+ found: false;
14
+ };
15
+ /**
16
+ * Read a staged object's stored metadata (`contentType`, `size`, `generation`, …) for
17
+ * `verifyStagedUploadMetadata`. A missing object is `{ found: false }`; any other read
18
+ * failure throws.
19
+ */
20
+ export declare function readStagedUploadMetadata(bucket: Bucket, storagePath: string): Promise<StagedUploadMetadataRead>;
21
+ export interface ReadStagedObjectGenerationOptions {
22
+ /** Called when the generation cannot be read (the caller logs it); reads then stay unpinned. */
23
+ onReadFailure?: (error: unknown) => void;
24
+ }
25
+ /**
26
+ * The staged object's current generation, as a string (GCS generations exceed 2^53).
27
+ *
28
+ * This is the one value that pins every later read of the staged bytes — the processing
29
+ * download, a moderation scan, a hash — to the SAME immutable object, so "processed
30
+ * bytes == scanned bytes == hashed bytes" holds even if the staging path were
31
+ * overwritten (a pinned read resolves exactly the captured generation). Returns
32
+ * `undefined` when no path is given, the object reports no generation (the emulator may
33
+ * not), or the read fails — best effort: callers fall back to unpinned reads, and a
34
+ * create-only staging rule stays the primary defense.
35
+ */
36
+ export declare function readStagedObjectGeneration(bucket: Bucket, storagePath: string | undefined, options?: ReadStagedObjectGenerationOptions): Promise<string | undefined>;
37
+ /**
38
+ * A `gs://` URI pinned to a captured generation (`gs://bucket/path#<generation>`), so a
39
+ * service reading by URI (a moderation scan) reads the same bytes the processor did.
40
+ * Without a generation it is the bare URI.
41
+ */
42
+ export declare function pinnedGcsUri(gcsUri: string, generation: string | undefined): string;
43
+ //# sourceMappingURL=staged-object.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged-object.d.ts","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C,sFAAsF;AACtF,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC7B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;CAC1B;AAED,MAAM,MAAM,wBAAwB,GAChC;IAAE,KAAK,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,oBAAoB,CAAA;CAAE,GAC/C;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAErB;;;;GAIG;AACH,wBAAsB,wBAAwB,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAQrH;AAED,MAAM,WAAW,iCAAiC;IAChD,gGAAgG;IAChG,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC1C;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,0BAA0B,CAC9C,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,GAAE,iCAAsC,GAC9C,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAU7B;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAEnF"}
@@ -0,0 +1,53 @@
1
+ // Admin-SDK reads of a STAGED upload object (the client-written staging copy a
2
+ // processor reads from). The accept-time verdict over the metadata is the pure
3
+ // `verifyStagedUploadMetadata` in `@ttt-productions/media-schemas`.
4
+ import { isObjectNotFoundError } from "./gcs-errors.js";
5
+ /**
6
+ * Read a staged object's stored metadata (`contentType`, `size`, `generation`, …) for
7
+ * `verifyStagedUploadMetadata`. A missing object is `{ found: false }`; any other read
8
+ * failure throws.
9
+ */
10
+ export async function readStagedUploadMetadata(bucket, storagePath) {
11
+ try {
12
+ const [metadata] = await bucket.file(storagePath).getMetadata();
13
+ return { found: true, metadata };
14
+ }
15
+ catch (e) {
16
+ if (isObjectNotFoundError(e))
17
+ return { found: false };
18
+ throw e;
19
+ }
20
+ }
21
+ /**
22
+ * The staged object's current generation, as a string (GCS generations exceed 2^53).
23
+ *
24
+ * This is the one value that pins every later read of the staged bytes — the processing
25
+ * download, a moderation scan, a hash — to the SAME immutable object, so "processed
26
+ * bytes == scanned bytes == hashed bytes" holds even if the staging path were
27
+ * overwritten (a pinned read resolves exactly the captured generation). Returns
28
+ * `undefined` when no path is given, the object reports no generation (the emulator may
29
+ * not), or the read fails — best effort: callers fall back to unpinned reads, and a
30
+ * create-only staging rule stays the primary defense.
31
+ */
32
+ export async function readStagedObjectGeneration(bucket, storagePath, options = {}) {
33
+ if (!storagePath)
34
+ return undefined;
35
+ try {
36
+ const [metadata] = await bucket.file(storagePath).getMetadata();
37
+ const generation = metadata.generation;
38
+ return generation === undefined || generation === null ? undefined : String(generation);
39
+ }
40
+ catch (e) {
41
+ options.onReadFailure?.(e);
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * A `gs://` URI pinned to a captured generation (`gs://bucket/path#<generation>`), so a
47
+ * service reading by URI (a moderation scan) reads the same bytes the processor did.
48
+ * Without a generation it is the bare URI.
49
+ */
50
+ export function pinnedGcsUri(gcsUri, generation) {
51
+ return generation ? `${gcsUri}#${generation}` : gcsUri;
52
+ }
53
+ //# sourceMappingURL=staged-object.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staged-object.js","sourceRoot":"","sources":["../../src/server/staged-object.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,+EAA+E;AAC/E,oEAAoE;AAEpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAexD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAAC,MAAc,EAAE,WAAmB;IAChF,IAAI,CAAC;QACH,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,WAAW,EAAE,CAAC;QAChE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;IACnC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAI,qBAAqB,CAAC,CAAC,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;QACtD,MAAM,CAAC,CAAC;IACV,CAAC;AACH,CAAC;AAOD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,MAAc,EACd,WAA+B,EAC/B,UAA6C,EAAE;IAE/C,IAAI,CAAC,WAAW;QAAE,OAAO,SAAS,CAAC;IACnC,IAAI,CAAC;QACH,MAAM,CAAC,QAAQ,CAAC,GAAG,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,WAAW,EAAE,CAAC;QAChE,MAAM,UAAU,GAAG,QAAQ,CAAC,UAAU,CAAC;QACvC,OAAO,UAAU,KAAK,SAAS,IAAI,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC1F,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC;QAC3B,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,UAA8B;IACzE,OAAO,UAAU,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;AACzD,CAAC","sourcesContent":["// Admin-SDK reads of a STAGED upload object (the client-written staging copy a\n// processor reads from). The accept-time verdict over the metadata is the pure\n// `verifyStagedUploadMetadata` in `@ttt-productions/media-schemas`.\n\nimport { isObjectNotFoundError } from \"./gcs-errors.js\";\nimport type { Bucket } from \"./storage-ops.js\";\n\n/** The stored-object metadata fields these reads use (the Admin SDK returns more). */\nexport interface StagedObjectMetadata {\n contentType?: string;\n size?: string | number;\n generation?: string | number;\n [field: string]: unknown;\n}\n\nexport type StagedUploadMetadataRead =\n | { found: true; metadata: StagedObjectMetadata }\n | { found: false };\n\n/**\n * Read a staged object's stored metadata (`contentType`, `size`, `generation`, …) for\n * `verifyStagedUploadMetadata`. A missing object is `{ found: false }`; any other read\n * failure throws.\n */\nexport async function readStagedUploadMetadata(bucket: Bucket, storagePath: string): Promise<StagedUploadMetadataRead> {\n try {\n const [metadata] = await bucket.file(storagePath).getMetadata();\n return { found: true, metadata };\n } catch (e) {\n if (isObjectNotFoundError(e)) return { found: false };\n throw e;\n }\n}\n\nexport interface ReadStagedObjectGenerationOptions {\n /** Called when the generation cannot be read (the caller logs it); reads then stay unpinned. */\n onReadFailure?: (error: unknown) => void;\n}\n\n/**\n * The staged object's current generation, as a string (GCS generations exceed 2^53).\n *\n * This is the one value that pins every later read of the staged bytes — the processing\n * download, a moderation scan, a hash — to the SAME immutable object, so \"processed\n * bytes == scanned bytes == hashed bytes\" holds even if the staging path were\n * overwritten (a pinned read resolves exactly the captured generation). Returns\n * `undefined` when no path is given, the object reports no generation (the emulator may\n * not), or the read fails — best effort: callers fall back to unpinned reads, and a\n * create-only staging rule stays the primary defense.\n */\nexport async function readStagedObjectGeneration(\n bucket: Bucket,\n storagePath: string | undefined,\n options: ReadStagedObjectGenerationOptions = {},\n): Promise<string | undefined> {\n if (!storagePath) return undefined;\n try {\n const [metadata] = await bucket.file(storagePath).getMetadata();\n const generation = metadata.generation;\n return generation === undefined || generation === null ? undefined : String(generation);\n } catch (e) {\n options.onReadFailure?.(e);\n return undefined;\n }\n}\n\n/**\n * A `gs://` URI pinned to a captured generation (`gs://bucket/path#<generation>`), so a\n * service reading by URI (a moderation scan) reads the same bytes the processor did.\n * Without a generation it is the bare URI.\n */\nexport function pinnedGcsUri(gcsUri: string, generation: string | undefined): string {\n return generation ? `${gcsUri}#${generation}` : gcsUri;\n}\n"]}
@@ -12,7 +12,12 @@ export interface ObjectReadResult {
12
12
  contentType: string;
13
13
  }
14
14
  export interface MediaObjectStore {
15
- /** Upload a local file to the store at `key`. */
15
+ /**
16
+ * Upload a local file to the store at `key`. Objects are written with a
17
+ * one-year `immutable` cache-control, so keys are write-once: a replacement is
18
+ * written under a NEW key and the app repoints its reference to it. Overwriting
19
+ * an existing key would keep serving the old bytes from caches for up to a year.
20
+ */
16
21
  putFile(args: {
17
22
  localPath: string;
18
23
  key: string;
@@ -28,9 +33,10 @@ export interface MediaObjectStore {
28
33
  /**
29
34
  * Download an object's bytes from the store to a local file (streamed, so a
30
35
  * large video never sits fully in memory). Used by safety evidence capture to
31
- * pull prod media — which in deployed envs lives in R2 — before writing it into
32
- * the separate (GCS) evidence vault: capture is inherently read-here-then-write,
33
- * NOT a server-side copy, because source and vault are different backends.
36
+ * pull final media — which may live in a different backend (R2) — before
37
+ * writing it into the separate (GCS) evidence vault: capture is inherently
38
+ * read-here-then-write, NOT a server-side copy, because source and vault can
39
+ * be different backends.
34
40
  */
35
41
  readToFile(args: {
36
42
  key: string;
@@ -56,12 +62,17 @@ export interface CreateR2ObjectStoreArgs {
56
62
  nowImpl?: () => number;
57
63
  }
58
64
  export declare function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore;
59
- export interface CreateEmulatorObjectStoreArgs {
65
+ export interface CreateFirebaseStorageObjectStoreArgs {
60
66
  bucket: Bucket;
67
+ }
68
+ /** Firebase Storage store for deployed environments. Token-free (see header). */
69
+ export declare function createFirebaseStorageObjectStore(args: CreateFirebaseStorageObjectStoreArgs): MediaObjectStore;
70
+ export interface CreateEmulatorObjectStoreArgs extends CreateFirebaseStorageObjectStoreArgs {
61
71
  /** Fixed token attached to every object so deterministic emulator URLs work
62
72
  * (token URLs bypass rules in the emulator). Pass the app's constant. */
63
73
  downloadToken: string;
64
74
  }
75
+ /** Local dev / tests only: the Firebase Storage store plus a fixed download token. */
65
76
  export declare function createFirebaseEmulatorObjectStore(args: CreateEmulatorObjectStoreArgs): MediaObjectStore;
66
77
  export declare function getMimeFromExt(ext: string): string;
67
78
  //# sourceMappingURL=storage-ops.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storage-ops.d.ts","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AASzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,mFAAmF;AACnF,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEzE,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,iDAAiD;IACjD,OAAO,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACpG,yCAAyC;IACzC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE;;;;;;OAMG;IACH,UAAU,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAChF,mEAAmE;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAQD,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC;IAC1B,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,gBAAgB,CAqHnF;AAcD,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,MAAM,CAAC;IACf;6EACyE;IACzE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,iCAAiC,CAAC,IAAI,EAAE,6BAA6B,GAAG,gBAAgB,CA2CvG;AAQD,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAsBlD"}
1
+ {"version":3,"file":"storage-ops.d.ts","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAWzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,mFAAmF;AACnF,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;AAEzE,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;;OAKG;IACH,OAAO,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACpG,yCAAyC;IACzC,IAAI,CAAC,IAAI,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzE;;;;;;;OAOG;IACH,UAAU,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAChF,mEAAmE;IACnE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAQD,MAAM,WAAW,uBAAuB;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gCAAgC;IAChC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1C,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,MAAM,CAAC;IAC1B,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,uBAAuB,GAAG,gBAAgB,CAqHnF;AAiBD,MAAM,WAAW,oCAAoC;IACnD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,iFAAiF;AACjF,wBAAgB,gCAAgC,CAAC,IAAI,EAAE,oCAAoC,GAAG,gBAAgB,CAE7G;AAED,MAAM,WAAW,6BAA8B,SAAQ,oCAAoC;IACzF;6EACyE;IACzE,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,sFAAsF;AACtF,wBAAgB,iCAAiC,CAAC,IAAI,EAAE,6BAA6B,GAAG,gBAAgB,CAEvG;AAgFD,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAsBlD"}
@@ -3,12 +3,17 @@
3
3
  // FINAL media are forbidden outside this module. (Upload STAGING stays plain
4
4
  // Firebase Storage and is not this module's concern.)
5
5
  //
6
- // There are two implementations:
7
- // - R2 (S3-compatible) for deployed environments — the only place final user
8
- // media lives in prod/dev; bytes are served exclusively by the gateway Worker.
9
- // - Firebase Storage emulator for local dev/tests — objects get a FIXED
10
- // caller-supplied download token so deterministic emulator URLs work with
11
- // zero Cloudflare involvement. Production NEVER mints download tokens.
6
+ // Implementations (the consuming app picks one per environment):
7
+ // - R2 (S3-compatible) for deployed environments whose final media lives in
8
+ // R2 and is served by an edge gateway.
9
+ // - Firebase Storage for deployed environments whose final media lives in
10
+ // Firebase Storage. Token-free: it writes and copies no download token, so
11
+ // its objects are readable only through the app's own access path (Storage
12
+ // rules). Firebase can still mint a token later (a client getDownloadURL, the
13
+ // console); the app's read path must never do so.
14
+ // - Firebase Storage emulator for local dev/tests — the Firebase Storage store
15
+ // plus a FIXED caller-supplied download token on every object, so
16
+ // deterministic emulator URLs work. Production NEVER mints download tokens.
12
17
  //
13
18
  // Writes return `{ key }` — never URLs. Display URLs are built at render time
14
19
  // from asset refs (see the consuming app's media-asset-url helper).
@@ -17,6 +22,8 @@ import { stat } from "node:fs/promises";
17
22
  import { Readable } from "node:stream";
18
23
  import { pipeline } from "node:stream/promises";
19
24
  import { AwsClient } from "aws4fetch";
25
+ import { NEUTRAL_CONTENT_TYPE } from "@ttt-productions/media-schemas";
26
+ import { isObjectNotFoundError } from "./gcs-errors.js";
20
27
  import { defaultR2RetrySeams, fetchWithR2Retry, } from "./r2-retry.js";
21
28
  // Re-export the exhaustion error so callers can recognize an exhausted R2
22
29
  // operation. The rest of the retry helper stays private to this module.
@@ -106,7 +113,7 @@ export function createR2ObjectStore(args) {
106
113
  if (!res.ok || !res.body) {
107
114
  return { kind: "response", response: res };
108
115
  }
109
- const contentType = res.headers.get("content-type") ?? "application/octet-stream";
116
+ const contentType = res.headers.get("content-type") ?? NEUTRAL_CONTENT_TYPE;
110
117
  // Pipe ONLY after a successful response, and open the destination in
111
118
  // truncating mode ('w') on every attempt so a partial download from a
112
119
  // prior attempt can never prefix/corrupt this one. res.body is a web
@@ -137,27 +144,70 @@ async function safeText(res) {
137
144
  return "<no body>";
138
145
  }
139
146
  }
147
+ // ---------------------------------------------------------------------------
148
+ // Firebase Storage stores — deployed (token-free) and emulator (fixed token)
149
+ // ---------------------------------------------------------------------------
150
+ /** Custom-metadata key Firebase Storage reads download tokens from. */
151
+ const DOWNLOAD_TOKENS_METADATA_KEY = "firebaseStorageDownloadTokens";
152
+ /** Firebase Storage store for deployed environments. Token-free (see header). */
153
+ export function createFirebaseStorageObjectStore(args) {
154
+ return createFirebaseStore(args.bucket, null);
155
+ }
156
+ /** Local dev / tests only: the Firebase Storage store plus a fixed download token. */
140
157
  export function createFirebaseEmulatorObjectStore(args) {
141
- const { bucket, downloadToken } = args;
158
+ return createFirebaseStore(args.bucket, args.downloadToken);
159
+ }
160
+ /** `downloadToken` is the store's token policy: `null` = token-free, a string = that token on every object. */
161
+ function createFirebaseStore(bucket, downloadToken) {
162
+ async function deleteObject(key) {
163
+ try {
164
+ await bucket.file(key).delete();
165
+ }
166
+ catch (e) {
167
+ if (isObjectNotFoundError(e))
168
+ return;
169
+ throw e;
170
+ }
171
+ }
142
172
  return {
143
173
  async putFile({ localPath, key, contentType }) {
144
174
  const { size } = await stat(localPath);
145
175
  const ct = contentType ?? getMimeFromExt(extractExt(localPath));
176
+ // An upload replaces the object and all of its metadata, so a token-free
177
+ // put carries no custom metadata at all.
146
178
  await bucket.upload(localPath, {
147
179
  destination: key,
148
180
  metadata: {
149
181
  contentType: ct,
150
182
  cacheControl: DEFAULT_CACHE_CONTROL,
151
- metadata: { firebaseStorageDownloadTokens: downloadToken },
183
+ ...(downloadToken === null ? {} : { metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } }),
152
184
  },
153
185
  });
154
186
  return { key, sizeBytes: size, contentType: ct };
155
187
  },
156
188
  async copy({ fromKey, toKey }) {
157
- await bucket.file(fromKey).copy(bucket.file(toKey));
158
- await bucket.file(toKey).setMetadata({
159
- metadata: { firebaseStorageDownloadTokens: downloadToken },
160
- });
189
+ const [, rewrite] = await bucket.file(fromKey).copy(bucket.file(toKey));
190
+ // A copy carries the source's custom metadata, token included. Stamp the
191
+ // destination with this store's policy: the fixed token, or `null`, which
192
+ // deletes any token the source carried. Pinning the stamp to the rewritten
193
+ // object's metageneration makes it conditionally idempotent, so the
194
+ // library retries transient failures instead of giving up after one try.
195
+ const metageneration = rewrite
196
+ ?.resource?.metageneration;
197
+ try {
198
+ await bucket.file(toKey).setMetadata({ metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } }, metageneration === undefined ? {} : { ifMetagenerationMatch: metageneration });
199
+ }
200
+ catch (stampError) {
201
+ // Never leave the destination live under the wrong token policy: remove
202
+ // it and fail the copy, so the caller retries the whole copy.
203
+ try {
204
+ await deleteObject(toKey);
205
+ }
206
+ catch (cleanupError) {
207
+ throw new AggregateError([stampError, cleanupError], `Copy to ${toKey}: stamping download-token metadata failed and the destination could not be removed`, { cause: cleanupError });
208
+ }
209
+ throw stampError;
210
+ }
161
211
  return { key: toKey };
162
212
  },
163
213
  async readToFile({ key, localPath }) {
@@ -165,19 +215,10 @@ export function createFirebaseEmulatorObjectStore(args) {
165
215
  await file.download({ destination: localPath });
166
216
  const [meta] = await file.getMetadata();
167
217
  const { size } = await stat(localPath);
168
- const contentType = meta.contentType ?? "application/octet-stream";
218
+ const contentType = meta.contentType ?? NEUTRAL_CONTENT_TYPE;
169
219
  return { sizeBytes: size, contentType };
170
220
  },
171
- async delete(key) {
172
- try {
173
- await bucket.file(key).delete();
174
- }
175
- catch (e) {
176
- if (e?.code === 404 || /No such object/i.test(String(e?.message)))
177
- return;
178
- throw e;
179
- }
180
- },
221
+ delete: deleteObject,
181
222
  };
182
223
  }
183
224
  // ---------------------------------------------------------------------------
@@ -204,7 +245,7 @@ export function getMimeFromExt(ext) {
204
245
  case "mp3":
205
246
  return "audio/mpeg";
206
247
  default:
207
- return "application/octet-stream";
248
+ return NEUTRAL_CONTENT_TYPE;
208
249
  }
209
250
  }
210
251
  //# sourceMappingURL=storage-ops.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"storage-ops.js","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"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","sourcesContent":["// Final-media object-store chokepoint — every final-media write/copy/delete in\n// the system goes through a MediaObjectStore. Inline storage-provider calls for\n// FINAL media are forbidden outside this module. (Upload STAGING stays plain\n// Firebase Storage and is not this module's concern.)\n//\n// There are two implementations:\n// - R2 (S3-compatible) for deployed environments — the only place final user\n// media lives in prod/dev; bytes are served exclusively by the gateway Worker.\n// - Firebase Storage emulator for local dev/tests — objects get a FIXED\n// caller-supplied download token so deterministic emulator URLs work with\n// zero Cloudflare involvement. Production NEVER mints download tokens.\n//\n// Writes return `{ key }` — never URLs. Display URLs are built at render time\n// from asset refs (see the consuming app's media-asset-url helper).\n\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\nimport { pipeline } from \"node:stream/promises\";\nimport { AwsClient } from \"aws4fetch\";\nimport type { getStorage } from \"firebase-admin/storage\";\nimport {\n defaultR2RetrySeams,\n fetchWithR2Retry,\n type R2RetrySeams,\n} from \"./r2-retry.js\";\n\n// Re-export the exhaustion error so callers can recognize an exhausted R2\n// operation. The rest of the retry helper stays private to this module.\nexport { R2StorageError } from \"./r2-retry.js\";\n\n/** Firebase Admin Storage Bucket — inferred to avoid @google-cloud/storage dep. */\nexport type Bucket = ReturnType<ReturnType<typeof getStorage>[\"bucket\"]>;\n\nexport interface ObjectWriteResult {\n key: string;\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface ObjectReadResult {\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface MediaObjectStore {\n /** Upload a local file to the store at `key`. */\n putFile(args: { localPath: string; key: string; contentType?: string }): Promise<ObjectWriteResult>;\n /** Server-side copy within the store. */\n copy(args: { fromKey: string; toKey: string }): Promise<{ key: string }>;\n /**\n * Download an object's bytes from the store to a local file (streamed, so a\n * large video never sits fully in memory). Used by safety evidence capture to\n * pull prod media — which in deployed envs lives in R2 — before writing it into\n * the separate (GCS) evidence vault: capture is inherently read-here-then-write,\n * NOT a server-side copy, because source and vault are different backends.\n */\n readToFile(args: { key: string; localPath: string }): Promise<ObjectReadResult>;\n /** Delete an object. Missing objects are a no-op, not an error. */\n delete(key: string): Promise<void>;\n}\n\nconst DEFAULT_CACHE_CONTROL = \"public, max-age=31536000, immutable\";\n\n// ---------------------------------------------------------------------------\n// R2 (S3-compatible) store — deployed environments\n// ---------------------------------------------------------------------------\n\nexport interface CreateR2ObjectStoreArgs {\n accountId: string;\n accessKeyId: string;\n secretAccessKey: string;\n bucket: string;\n /** Override for tests; defaults to the real R2 S3 endpoint. */\n endpoint?: string;\n /** Override fetch for tests. */\n fetchImpl?: typeof fetch;\n /** Test seam: override the retry sleep (defaults to real `setTimeout`). */\n sleepImpl?: (ms: number) => Promise<void>;\n /** Test seam: override the backoff jitter source (defaults to `Math.random`). */\n randomImpl?: () => number;\n /** Test seam: override the clock used for HTTP-date `Retry-After` (defaults to `Date.now`). */\n nowImpl?: () => number;\n}\n\nexport function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore {\n const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\\/$/, \"\");\n const client = new AwsClient({\n accessKeyId: args.accessKeyId,\n secretAccessKey: args.secretAccessKey,\n service: \"s3\",\n region: \"auto\",\n });\n const doFetch = args.fetchImpl ?? fetch;\n\n // Retry seams: production defaults are internal; tests inject deterministic\n // sleep/random/now. putFile/copy/readToFile ride the bounded retry helper;\n // delete stays one-shot (never routes through it — see below).\n const defaults = defaultR2RetrySeams();\n const seams: R2RetrySeams = {\n sleep: args.sleepImpl ?? defaults.sleep,\n random: args.randomImpl ?? defaults.random,\n now: args.nowImpl ?? defaults.now,\n };\n\n const objectUrl = (key: string) =>\n `${endpoint}/${args.bucket}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n\n async function signedFetch(url: string, init: RequestInit & { headers?: Record<string, string> }) {\n const signed = await client.sign(url, { ...init, aws: { allHeaders: true } } as any);\n return doFetch(signed, { ...(init.body ? { duplex: \"half\" } : {}) } as any);\n }\n\n return {\n async putFile({ localPath, key, contentType }) {\n // stat() once, outside the retry loop: a missing input file is a\n // deterministic local error and must fail immediately, never retry.\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n return fetchWithR2Retry({\n operation: \"putFile\",\n bucket: args.bucket,\n key,\n seams,\n // Fresh signed request AND a fresh read stream on every attempt — a\n // consumed stream is never reused.\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), {\n method: \"PUT\",\n headers: {\n \"content-type\": ct,\n \"content-length\": String(size),\n \"cache-control\": DEFAULT_CACHE_CONTROL,\n },\n body: createReadStream(localPath) as unknown as BodyInit,\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key, sizeBytes: size, contentType: ct } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async copy({ fromKey, toKey }) {\n // Preserve the exact x-amz-copy-source across every re-signed attempt.\n const copySource = `/${args.bucket}/${fromKey.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n return fetchWithR2Retry({\n operation: \"copy\",\n bucket: args.bucket,\n key: toKey,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(toKey), {\n method: \"PUT\",\n headers: { \"x-amz-copy-source\": copySource },\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key: toKey } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async readToFile({ key, localPath }) {\n return fetchWithR2Retry({\n operation: \"readToFile\",\n bucket: args.bucket,\n key,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), { method: \"GET\" });\n if (!res.ok || !res.body) {\n return { kind: \"response\" as const, response: res };\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n // Pipe ONLY after a successful response, and open the destination in\n // truncating mode ('w') on every attempt so a partial download from a\n // prior attempt can never prefix/corrupt this one. res.body is a web\n // ReadableStream; stream it to disk so large media never sits fully in\n // memory. A transient body/network stream failure here throws and is\n // retried by the helper; a deterministic local fs error is not.\n await pipeline(\n Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]),\n createWriteStream(localPath, { flags: \"w\" }),\n );\n const { size } = await stat(localPath);\n return { kind: \"success\" as const, value: { sizeBytes: size, contentType } };\n },\n });\n },\n\n async delete(key) {\n // Intentionally one-shot: delete is never retried (see r2-retry.ts header).\n const res = await signedFetch(objectUrl(key), { method: \"DELETE\" });\n // S3 DELETE returns 204 even for missing keys; anything else non-ok is real.\n if (!res.ok && res.status !== 404) {\n throw new Error(`R2 delete failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n },\n };\n}\n\nasync function safeText(res: Response): Promise<string> {\n try {\n return (await res.text()).slice(0, 500);\n } catch {\n return \"<no body>\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// Firebase Storage emulator store — local dev / tests only\n// ---------------------------------------------------------------------------\n\nexport interface CreateEmulatorObjectStoreArgs {\n bucket: Bucket;\n /** Fixed token attached to every object so deterministic emulator URLs work\n * (token URLs bypass rules in the emulator). Pass the app's constant. */\n downloadToken: string;\n}\n\nexport function createFirebaseEmulatorObjectStore(args: CreateEmulatorObjectStoreArgs): MediaObjectStore {\n const { bucket, downloadToken } = args;\n return {\n async putFile({ localPath, key, contentType }) {\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n await bucket.upload(localPath, {\n destination: key,\n metadata: {\n contentType: ct,\n cacheControl: DEFAULT_CACHE_CONTROL,\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n },\n });\n return { key, sizeBytes: size, contentType: ct };\n },\n\n async copy({ fromKey, toKey }) {\n await bucket.file(fromKey).copy(bucket.file(toKey));\n await bucket.file(toKey).setMetadata({\n metadata: { firebaseStorageDownloadTokens: downloadToken },\n });\n return { key: toKey };\n },\n\n async readToFile({ key, localPath }) {\n const file = bucket.file(key);\n await file.download({ destination: localPath });\n const [meta] = await file.getMetadata();\n const { size } = await stat(localPath);\n const contentType = (meta.contentType as string | undefined) ?? \"application/octet-stream\";\n return { sizeBytes: size, contentType };\n },\n\n async delete(key) {\n try {\n await bucket.file(key).delete();\n } catch (e: any) {\n if (e?.code === 404 || /No such object/i.test(String(e?.message))) return;\n throw e;\n }\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n\nfunction extractExt(localPath: string): string {\n return localPath.split(\".\").pop() || \"\";\n}\n\nexport function getMimeFromExt(ext: string): string {\n switch (ext.toLowerCase()) {\n case \"jpg\":\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n return \"image/webp\";\n case \"mp4\":\n return \"video/mp4\";\n case \"webm\":\n return \"video/webm\";\n case \"m4a\":\n return \"audio/mp4\";\n case \"aac\":\n return \"audio/aac\";\n case \"mp3\":\n return \"audio/mpeg\";\n default:\n return \"application/octet-stream\";\n }\n}\n"]}
1
+ {"version":3,"file":"storage-ops.js","sourceRoot":"","sources":["../../src/server/storage-ops.ts"],"names":[],"mappings":"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","sourcesContent":["// Final-media object-store chokepoint — every final-media write/copy/delete in\n// the system goes through a MediaObjectStore. Inline storage-provider calls for\n// FINAL media are forbidden outside this module. (Upload STAGING stays plain\n// Firebase Storage and is not this module's concern.)\n//\n// Implementations (the consuming app picks one per environment):\n// - R2 (S3-compatible) for deployed environments whose final media lives in\n// R2 and is served by an edge gateway.\n// - Firebase Storage for deployed environments whose final media lives in\n// Firebase Storage. Token-free: it writes and copies no download token, so\n// its objects are readable only through the app's own access path (Storage\n// rules). Firebase can still mint a token later (a client getDownloadURL, the\n// console); the app's read path must never do so.\n// - Firebase Storage emulator for local dev/tests — the Firebase Storage store\n// plus a FIXED caller-supplied download token on every object, so\n// deterministic emulator URLs work. Production NEVER mints download tokens.\n//\n// Writes return `{ key }` — never URLs. Display URLs are built at render time\n// from asset refs (see the consuming app's media-asset-url helper).\n\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { stat } from \"node:fs/promises\";\nimport { Readable } from \"node:stream\";\nimport { pipeline } from \"node:stream/promises\";\nimport { AwsClient } from \"aws4fetch\";\nimport type { getStorage } from \"firebase-admin/storage\";\nimport { NEUTRAL_CONTENT_TYPE } from \"@ttt-productions/media-schemas\";\nimport { isObjectNotFoundError } from \"./gcs-errors.js\";\nimport {\n defaultR2RetrySeams,\n fetchWithR2Retry,\n type R2RetrySeams,\n} from \"./r2-retry.js\";\n\n// Re-export the exhaustion error so callers can recognize an exhausted R2\n// operation. The rest of the retry helper stays private to this module.\nexport { R2StorageError } from \"./r2-retry.js\";\n\n/** Firebase Admin Storage Bucket — inferred to avoid @google-cloud/storage dep. */\nexport type Bucket = ReturnType<ReturnType<typeof getStorage>[\"bucket\"]>;\n\nexport interface ObjectWriteResult {\n key: string;\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface ObjectReadResult {\n sizeBytes: number;\n contentType: string;\n}\n\nexport interface MediaObjectStore {\n /**\n * Upload a local file to the store at `key`. Objects are written with a\n * one-year `immutable` cache-control, so keys are write-once: a replacement is\n * written under a NEW key and the app repoints its reference to it. Overwriting\n * an existing key would keep serving the old bytes from caches for up to a year.\n */\n putFile(args: { localPath: string; key: string; contentType?: string }): Promise<ObjectWriteResult>;\n /** Server-side copy within the store. */\n copy(args: { fromKey: string; toKey: string }): Promise<{ key: string }>;\n /**\n * Download an object's bytes from the store to a local file (streamed, so a\n * large video never sits fully in memory). Used by safety evidence capture to\n * pull final media — which may live in a different backend (R2) — before\n * writing it into the separate (GCS) evidence vault: capture is inherently\n * read-here-then-write, NOT a server-side copy, because source and vault can\n * be different backends.\n */\n readToFile(args: { key: string; localPath: string }): Promise<ObjectReadResult>;\n /** Delete an object. Missing objects are a no-op, not an error. */\n delete(key: string): Promise<void>;\n}\n\nconst DEFAULT_CACHE_CONTROL = \"public, max-age=31536000, immutable\";\n\n// ---------------------------------------------------------------------------\n// R2 (S3-compatible) store — deployed environments\n// ---------------------------------------------------------------------------\n\nexport interface CreateR2ObjectStoreArgs {\n accountId: string;\n accessKeyId: string;\n secretAccessKey: string;\n bucket: string;\n /** Override for tests; defaults to the real R2 S3 endpoint. */\n endpoint?: string;\n /** Override fetch for tests. */\n fetchImpl?: typeof fetch;\n /** Test seam: override the retry sleep (defaults to real `setTimeout`). */\n sleepImpl?: (ms: number) => Promise<void>;\n /** Test seam: override the backoff jitter source (defaults to `Math.random`). */\n randomImpl?: () => number;\n /** Test seam: override the clock used for HTTP-date `Retry-After` (defaults to `Date.now`). */\n nowImpl?: () => number;\n}\n\nexport function createR2ObjectStore(args: CreateR2ObjectStoreArgs): MediaObjectStore {\n const endpoint = (args.endpoint ?? `https://${args.accountId}.r2.cloudflarestorage.com`).replace(/\\/$/, \"\");\n const client = new AwsClient({\n accessKeyId: args.accessKeyId,\n secretAccessKey: args.secretAccessKey,\n service: \"s3\",\n region: \"auto\",\n });\n const doFetch = args.fetchImpl ?? fetch;\n\n // Retry seams: production defaults are internal; tests inject deterministic\n // sleep/random/now. putFile/copy/readToFile ride the bounded retry helper;\n // delete stays one-shot (never routes through it — see below).\n const defaults = defaultR2RetrySeams();\n const seams: R2RetrySeams = {\n sleep: args.sleepImpl ?? defaults.sleep,\n random: args.randomImpl ?? defaults.random,\n now: args.nowImpl ?? defaults.now,\n };\n\n const objectUrl = (key: string) =>\n `${endpoint}/${args.bucket}/${key.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n\n async function signedFetch(url: string, init: RequestInit & { headers?: Record<string, string> }) {\n const signed = await client.sign(url, { ...init, aws: { allHeaders: true } } as any);\n return doFetch(signed, { ...(init.body ? { duplex: \"half\" } : {}) } as any);\n }\n\n return {\n async putFile({ localPath, key, contentType }) {\n // stat() once, outside the retry loop: a missing input file is a\n // deterministic local error and must fail immediately, never retry.\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n return fetchWithR2Retry({\n operation: \"putFile\",\n bucket: args.bucket,\n key,\n seams,\n // Fresh signed request AND a fresh read stream on every attempt — a\n // consumed stream is never reused.\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), {\n method: \"PUT\",\n headers: {\n \"content-type\": ct,\n \"content-length\": String(size),\n \"cache-control\": DEFAULT_CACHE_CONTROL,\n },\n body: createReadStream(localPath) as unknown as BodyInit,\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key, sizeBytes: size, contentType: ct } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async copy({ fromKey, toKey }) {\n // Preserve the exact x-amz-copy-source across every re-signed attempt.\n const copySource = `/${args.bucket}/${fromKey.split(\"/\").map(encodeURIComponent).join(\"/\")}`;\n return fetchWithR2Retry({\n operation: \"copy\",\n bucket: args.bucket,\n key: toKey,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(toKey), {\n method: \"PUT\",\n headers: { \"x-amz-copy-source\": copySource },\n });\n if (res.ok) {\n return { kind: \"success\" as const, value: { key: toKey } };\n }\n return { kind: \"response\" as const, response: res };\n },\n });\n },\n\n async readToFile({ key, localPath }) {\n return fetchWithR2Retry({\n operation: \"readToFile\",\n bucket: args.bucket,\n key,\n seams,\n runAttempt: async () => {\n const res = await signedFetch(objectUrl(key), { method: \"GET\" });\n if (!res.ok || !res.body) {\n return { kind: \"response\" as const, response: res };\n }\n const contentType = res.headers.get(\"content-type\") ?? NEUTRAL_CONTENT_TYPE;\n // Pipe ONLY after a successful response, and open the destination in\n // truncating mode ('w') on every attempt so a partial download from a\n // prior attempt can never prefix/corrupt this one. res.body is a web\n // ReadableStream; stream it to disk so large media never sits fully in\n // memory. A transient body/network stream failure here throws and is\n // retried by the helper; a deterministic local fs error is not.\n await pipeline(\n Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]),\n createWriteStream(localPath, { flags: \"w\" }),\n );\n const { size } = await stat(localPath);\n return { kind: \"success\" as const, value: { sizeBytes: size, contentType } };\n },\n });\n },\n\n async delete(key) {\n // Intentionally one-shot: delete is never retried (see r2-retry.ts header).\n const res = await signedFetch(objectUrl(key), { method: \"DELETE\" });\n // S3 DELETE returns 204 even for missing keys; anything else non-ok is real.\n if (!res.ok && res.status !== 404) {\n throw new Error(`R2 delete failed for ${key}: ${res.status} ${await safeText(res)}`);\n }\n },\n };\n}\n\nasync function safeText(res: Response): Promise<string> {\n try {\n return (await res.text()).slice(0, 500);\n } catch {\n return \"<no body>\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// Firebase Storage stores — deployed (token-free) and emulator (fixed token)\n// ---------------------------------------------------------------------------\n\n/** Custom-metadata key Firebase Storage reads download tokens from. */\nconst DOWNLOAD_TOKENS_METADATA_KEY = \"firebaseStorageDownloadTokens\";\n\nexport interface CreateFirebaseStorageObjectStoreArgs {\n bucket: Bucket;\n}\n\n/** Firebase Storage store for deployed environments. Token-free (see header). */\nexport function createFirebaseStorageObjectStore(args: CreateFirebaseStorageObjectStoreArgs): MediaObjectStore {\n return createFirebaseStore(args.bucket, null);\n}\n\nexport interface CreateEmulatorObjectStoreArgs extends CreateFirebaseStorageObjectStoreArgs {\n /** Fixed token attached to every object so deterministic emulator URLs work\n * (token URLs bypass rules in the emulator). Pass the app's constant. */\n downloadToken: string;\n}\n\n/** Local dev / tests only: the Firebase Storage store plus a fixed download token. */\nexport function createFirebaseEmulatorObjectStore(args: CreateEmulatorObjectStoreArgs): MediaObjectStore {\n return createFirebaseStore(args.bucket, args.downloadToken);\n}\n\n/** `downloadToken` is the store's token policy: `null` = token-free, a string = that token on every object. */\nfunction createFirebaseStore(bucket: Bucket, downloadToken: string | null): MediaObjectStore {\n async function deleteObject(key: string): Promise<void> {\n try {\n await bucket.file(key).delete();\n } catch (e) {\n if (isObjectNotFoundError(e)) return;\n throw e;\n }\n }\n\n return {\n async putFile({ localPath, key, contentType }) {\n const { size } = await stat(localPath);\n const ct = contentType ?? getMimeFromExt(extractExt(localPath));\n // An upload replaces the object and all of its metadata, so a token-free\n // put carries no custom metadata at all.\n await bucket.upload(localPath, {\n destination: key,\n metadata: {\n contentType: ct,\n cacheControl: DEFAULT_CACHE_CONTROL,\n ...(downloadToken === null ? {} : { metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } }),\n },\n });\n return { key, sizeBytes: size, contentType: ct };\n },\n\n async copy({ fromKey, toKey }) {\n const [, rewrite] = await bucket.file(fromKey).copy(bucket.file(toKey));\n // A copy carries the source's custom metadata, token included. Stamp the\n // destination with this store's policy: the fixed token, or `null`, which\n // deletes any token the source carried. Pinning the stamp to the rewritten\n // object's metageneration makes it conditionally idempotent, so the\n // library retries transient failures instead of giving up after one try.\n const metageneration = (rewrite as { resource?: { metageneration?: string | number } } | undefined)\n ?.resource?.metageneration;\n try {\n await bucket.file(toKey).setMetadata(\n { metadata: { [DOWNLOAD_TOKENS_METADATA_KEY]: downloadToken } },\n metageneration === undefined ? {} : { ifMetagenerationMatch: metageneration },\n );\n } catch (stampError) {\n // Never leave the destination live under the wrong token policy: remove\n // it and fail the copy, so the caller retries the whole copy.\n try {\n await deleteObject(toKey);\n } catch (cleanupError) {\n throw new AggregateError(\n [stampError, cleanupError],\n `Copy to ${toKey}: stamping download-token metadata failed and the destination could not be removed`,\n { cause: cleanupError },\n );\n }\n throw stampError;\n }\n return { key: toKey };\n },\n\n async readToFile({ key, localPath }) {\n const file = bucket.file(key);\n await file.download({ destination: localPath });\n const [meta] = await file.getMetadata();\n const { size } = await stat(localPath);\n const contentType = (meta.contentType as string | undefined) ?? NEUTRAL_CONTENT_TYPE;\n return { sizeBytes: size, contentType };\n },\n\n delete: deleteObject,\n };\n}\n\n// ---------------------------------------------------------------------------\n\nfunction extractExt(localPath: string): string {\n return localPath.split(\".\").pop() || \"\";\n}\n\nexport function getMimeFromExt(ext: string): string {\n switch (ext.toLowerCase()) {\n case \"jpg\":\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n return \"image/webp\";\n case \"mp4\":\n return \"video/mp4\";\n case \"webm\":\n return \"video/webm\";\n case \"m4a\":\n return \"audio/mp4\";\n case \"aac\":\n return \"audio/aac\";\n case \"mp3\":\n return \"audio/mpeg\";\n default:\n return NEUTRAL_CONTENT_TYPE;\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/media-processing-core",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/ttt-productions/ttt-packages.git",
@@ -31,9 +31,10 @@
31
31
  "prepublishOnly": "npm run clean && npm run build"
32
32
  },
33
33
  "dependencies": {
34
- "@ttt-productions/media-schemas": "^0.14.5",
34
+ "@ttt-productions/media-schemas": "^0.14.6",
35
35
  "aws4fetch": "^1.0.20",
36
- "sharp": "^0.35.3"
36
+ "sharp": "^0.35.3",
37
+ "zod": "^4.4.3"
37
38
  },
38
39
  "peerDependencies": {
39
40
  "firebase-admin": ">=10.0.0"
@@ -45,7 +46,7 @@
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/node": "^22.10.0",
48
- "firebase-admin": "^12.0.0",
49
+ "firebase-admin": "^14.0.0",
49
50
  "typescript": "^5.8.3"
50
51
  },
51
52
  "author": "DJ (TTT Productions)",