@ai-matrx/media 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @ai-matrx/media
2
2
 
3
+
4
+ ## 0.12.0 — upload transport rides @ai-matrx/data
5
+
6
+ - The TUS wire (`tus-js-client`, the `mtx-tus-urls` resume store, `buildUploadMetadataEnvelope`) and
7
+ the upload dedup guard now live in `@ai-matrx/data` (`./files/tus`, `./files`); `tusUploadRaw`
8
+ binds the host's fresh headers and base URL and hydrates the row — same signature, same result.
9
+ `upload/tusUpload` and `upload/uploadDedupGuard` re-export the moved names (architecture step 14, D1).
10
+ - `FileUrls` is data's type; `fileUrls(fileId)` here is the one caller-facing signature, built on
11
+ data's `buildFileUrls(filesBaseUrl, fileId)`.
12
+ - `tus-js-client` is no longer a direct dependency (it arrives through `@ai-matrx/data`).
13
+
14
+ Consumer action: none — every import path and signature is unchanged. Needs `@ai-matrx/data` ≥ 0.21.0.
15
+
3
16
  ## 0.11.0 — file errors and the transport decision come from data
4
17
 
5
18
  - `FileHandlerError` and `FileAccessDeniedError`, `FileNotFoundError`, `FileDeletedError`,
@@ -22,6 +22,8 @@
22
22
  * `createShareLink` in `@/utils/permissions/shareLinks` — the landing page is
23
23
  * `/s/{token}` and the clean byte endpoint is Python's `/share/{token}`.
24
24
  */
25
+ import { type FileUrls } from "@ai-matrx/data/files";
26
+ export type { FileUrls };
25
27
  export declare function pythonBaseUrl(): string;
26
28
  export interface ShareUrls {
27
29
  /**
@@ -72,19 +74,6 @@ export declare function shareChildFileUrls(token: string, fileId: string, opts?:
72
74
  * single canonical embeddable URL.
73
75
  */
74
76
  export declare function pythonShareUrl(token: string): string;
75
- export interface FileUrls {
76
- /**
77
- * Authenticated direct-download endpoint. CORS-safe for `fetch()`.
78
- * Returns bytes with `Content-Disposition: attachment` so a `<a>`
79
- * click triggers a download.
80
- */
81
- download: string;
82
- /**
83
- * Authenticated inline endpoint — same bytes, `Content-Disposition:
84
- * inline` so the URL works as `<img src>`, `<video src>`, etc.
85
- */
86
- inline: string;
87
- }
88
77
  /**
89
78
  * Build every URL variant for a cld_files file id in one call. Cheap —
90
79
  * just string concatenation. These are DURABLE URLs: they never expire and
@@ -1,4 +1,4 @@
1
- import { fileUrls as packageFileUrls, shareUrl as packageShareUrl } from "@ai-matrx/data/files";
1
+ import { buildFileUrls, shareUrl as packageShareUrl } from "@ai-matrx/data/files";
2
2
  import { resolveFilesBaseUrl } from "../../host/python-client.js";
3
3
  import { shareLinkUrl } from "../../host/share-links.js";
4
4
  function pythonBaseUrl() {
@@ -22,7 +22,7 @@ function pythonShareUrl(token) {
22
22
  return shareUrls(token).public;
23
23
  }
24
24
  function fileUrls(fileId) {
25
- return packageFileUrls(pythonBaseUrl(), fileId);
25
+ return buildFileUrls(pythonBaseUrl(), fileId);
26
26
  }
27
27
  const AUTHENTICATED_FILE_BYTES_RE = /\/files\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/download(?:[?#]|$)/i;
28
28
  function isAuthenticatedFileBytesUrl(url) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../src/files/engine/handler/utils/python-base.ts"],"sourcesContent":["/**\n * features/files/handler/utils/python-base.ts\n *\n * Resolve Python backend URLs for direct browser → Python file traffic.\n * The handler emits URLs that point at Python, never at Next.js — there\n * is no server-side file handler in this app and no `/app/api/*` proxy\n * involvement.\n *\n * Design note — \"all variants in one object\":\n * Python exposes the same bytes via slightly-different URLs (inline vs\n * attachment) and the FE has a share landing page. Rather than add a new\n * function for every future tweak, the canonical builders here\n * (`shareUrls`, `fileUrls`) return ALL the variants up front and let the\n * caller pick. Single spelling for \"everything you can do with a\n * token / file id.\"\n *\n * Single-string helpers (`pythonShareUrl`, `pythonFileDownloadUrl`,\n * `pythonFileInlineUrl`) are kept as thin wrappers so existing callers\n * don't churn. Prefer the object form in new code.\n *\n * Tokens are CANONICAL share-link tokens (`platform.share_links`), minted via\n * `createShareLink` in `@/utils/permissions/shareLinks` — the landing page is\n * `/s/{token}` and the clean byte endpoint is Python's `/share/{token}`.\n */\n\nimport { fileUrls as packageFileUrls, shareUrl as packageShareUrl } from \"@ai-matrx/data/files\";\nimport { resolveFilesBaseUrl } from \"../../host/python-client\";\nimport { shareLinkUrl } from \"../../host/share-links\";\n\nexport function pythonBaseUrl(): string {\n return resolveFilesBaseUrl();\n}\n\n// ---------------------------------------------------------------------------\n// Share-link URLs (token-based, public, no auth)\n// ---------------------------------------------------------------------------\n\nexport interface ShareUrls {\n /**\n * Canonical clean public URL with `inline` Content-Disposition (the default).\n * Works as `<img src>`, `<video src>`, `<audio src>`, PDF preview, or a\n * normal browser link a recipient can paste anywhere.\n *\n * Image / video / audio / PDF render inline. Dangerous types\n * (HTML, SVG, JS) are forced to `attachment` server-side.\n */\n public: string;\n /**\n * Explicit download endpoint with `?inline=false` — forces\n * `Content-Disposition: attachment` so the browser triggers a\n * download dialog instead of rendering inline. Use for \"Save As…\"\n * style affordances on images / PDFs.\n */\n attachment: string;\n /**\n * The canonical share landing page on the FE — `/s/{token}` (metadata via\n * the `resolve_share_token` RPC + preview + download button). Useful as a\n * clickable link in chat / email / docs; NOT a media src.\n */\n page: string;\n}\n\n/**\n * Build every URL variant for a share token in one call. Cheap —\n * just string concatenation, no I/O.\n */\nexport function shareUrls(\n token: string,\n opts?: { appOrigin?: string },\n): ShareUrls {\n const t = encodeURIComponent(token);\n // The URL grammar is @ai-matrx/data/files'; the host only binds the base.\n const base = packageShareUrl(pythonBaseUrl(), token);\n return {\n public: base,\n attachment: `${base}/download?inline=false`,\n page: opts?.appOrigin\n ? `${opts.appOrigin.replace(/\\/$/, \"\")}/s/${t}`\n : shareLinkUrl(t),\n };\n}\n\n/**\n * Byte URLs for ONE child file of a shared record — a shared chat's\n * attachment — reached with the parent's share token (access ladder T-19b).\n * The files service answers `/share/{token}/files/{fileId}` only when the file\n * is a child of the record that token shares; nothing else is reachable.\n */\nexport function shareChildFileUrls(\n token: string,\n fileId: string,\n opts?: { baseUrl?: string },\n): { inline: string; attachment: string } {\n const backend = (opts?.baseUrl ?? pythonBaseUrl()).replace(/\\/$/, \"\");\n const base = `${backend}/share/${encodeURIComponent(token)}/files/${encodeURIComponent(fileId)}`;\n return { inline: base, attachment: `${base}?inline=false` };\n}\n\n/**\n * Python's clean public byte-streaming share endpoint. Convenience wrapper\n * around `shareUrls(token).public` for callers that just want the\n * single canonical embeddable URL.\n */\nexport function pythonShareUrl(token: string): string {\n return shareUrls(token).public;\n}\n\n// ---------------------------------------------------------------------------\n// Authenticated file URLs (file-id, owner / shared-permission required)\n// ---------------------------------------------------------------------------\n\nexport interface FileUrls {\n /**\n * Authenticated direct-download endpoint. CORS-safe for `fetch()`.\n * Returns bytes with `Content-Disposition: attachment` so a `<a>`\n * click triggers a download.\n */\n download: string;\n /**\n * Authenticated inline endpoint — same bytes, `Content-Disposition:\n * inline` so the URL works as `<img src>`, `<video src>`, etc.\n */\n inline: string;\n}\n\n/**\n * Build every URL variant for a cld_files file id in one call. Cheap —\n * just string concatenation. These are DURABLE URLs: they never expire and\n * carry only the file id. Auth is either an Authorization header on\n * `fetch()` (the python-client attaches it) or the `mx_files_session`\n * cookie for plain `<img>`/`<video>` bindings (see `../session.ts`).\n * `?inline=1` matches the exact spelling the backend emits on\n * `FileRecord.url`, keeping cache keys identical either way.\n */\nexport function fileUrls(fileId: string): FileUrls {\n // The URL grammar is @ai-matrx/data/files'; the host only binds the base.\n return packageFileUrls(pythonBaseUrl(), fileId);\n}\n\nconst AUTHENTICATED_FILE_BYTES_RE =\n /\\/files\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\/download(?:[?#]|$)/i;\n\n/**\n * True for a durable `/files/{id}/download` URL (either host, either\n * spelling) — bytes that answer 401 unless the request carries the person's\n * Authorization header or the `mx_files_session` cookie. A consumer that\n * loads bytes with its own fetch (PDF.js) must never send one bare.\n */\nexport function isAuthenticatedFileBytesUrl(url: string | null | undefined): boolean {\n return !!url && AUTHENTICATED_FILE_BYTES_RE.test(url);\n}\n\n/** Single-URL convenience wrapper around `fileUrls(fileId).download`. */\nexport function pythonFileDownloadUrl(fileId: string): string {\n return fileUrls(fileId).download;\n}\n\n/** Single-URL convenience wrapper around `fileUrls(fileId).inline`. */\nexport function pythonFileInlineUrl(fileId: string): string {\n return fileUrls(fileId).inline;\n}\n\n// ---------------------------------------------------------------------------\n// URL classification + viewer-page derivation\n// ---------------------------------------------------------------------------\n\n/**\n * Match anything that ends in `/share/{token}` or `/share/{token}/download`\n * (with optional query string / hash) where `{token}` is a hex-ish string of\n * at least 8 chars (covers both UUIDs and shorter custom tokens). Used to\n * recognize URLs that we ourselves emitted via `shareUrls()` so we can\n * round-trip them back into a viewer URL.\n */\nconst SHARE_TOKEN_RE = /\\/share\\/([0-9a-f-]{8,})(?:\\/download)?(?:[/?#]|$)/i;\n\n/**\n * Extract the share token from any URL we recognize. Returns `null` for\n * URLs that aren't ours (opaque external and third-party URLs).\n */\nexport function tokenFromShareUrl(url: string): string | null {\n if (!url) return null;\n const match = url.match(SHARE_TOKEN_RE);\n return match ? match[1] : null;\n}\n\n/**\n * \"Best-effort viewer URL\" for any image / file URL we might have stored\n * in the database. The intent is what the user clicks when they want to\n * SEE the file (not download it):\n *\n * - Our bare `/share/{token}` (or legacy `/share/{token}/download`) URLs → the\n * canonical FE landing page at `${origin}/s/{token}` (metadata + preview\n * + download button). This is what an admin wants when they click a\n * screenshot in the feedback dialog.\n * - Anything else (external URLs) →\n * returned unchanged. Browsers render image bytes inline regardless,\n * and we have no viewer page for them anyway.\n */\nexport function imageViewUrl(\n url: string,\n opts?: { appOrigin?: string },\n): string {\n const token = tokenFromShareUrl(url);\n if (!token) return url;\n return shareUrls(token, opts).page;\n}\n"],"mappings":"AAyBA,SAAS,YAAY,iBAAiB,YAAY,uBAAuB;AACzE,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAEtB,SAAS,gBAAwB;AACtC,SAAO,oBAAoB;AAC7B;AAmCO,SAAS,UACd,OACA,MACW;AACX,QAAM,IAAI,mBAAmB,KAAK;AAElC,QAAM,OAAO,gBAAgB,cAAc,GAAG,KAAK;AACnD,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,YAAY,GAAG,IAAI;AAAA,IACnB,MAAM,MAAM,YACR,GAAG,KAAK,UAAU,QAAQ,OAAO,EAAE,CAAC,MAAM,CAAC,KAC3C,aAAa,CAAC;AAAA,EACpB;AACF;AAQO,SAAS,mBACd,OACA,QACA,MACwC;AACxC,QAAM,WAAW,MAAM,WAAW,cAAc,GAAG,QAAQ,OAAO,EAAE;AACpE,QAAM,OAAO,GAAG,OAAO,UAAU,mBAAmB,KAAK,CAAC,UAAU,mBAAmB,MAAM,CAAC;AAC9F,SAAO,EAAE,QAAQ,MAAM,YAAY,GAAG,IAAI,gBAAgB;AAC5D;AAOO,SAAS,eAAe,OAAuB;AACpD,SAAO,UAAU,KAAK,EAAE;AAC1B;AA6BO,SAAS,SAAS,QAA0B;AAEjD,SAAO,gBAAgB,cAAc,GAAG,MAAM;AAChD;AAEA,MAAM,8BACJ;AAQK,SAAS,4BAA4B,KAAyC;AACnF,SAAO,CAAC,CAAC,OAAO,4BAA4B,KAAK,GAAG;AACtD;AAGO,SAAS,sBAAsB,QAAwB;AAC5D,SAAO,SAAS,MAAM,EAAE;AAC1B;AAGO,SAAS,oBAAoB,QAAwB;AAC1D,SAAO,SAAS,MAAM,EAAE;AAC1B;AAaA,MAAM,iBAAiB;AAMhB,SAAS,kBAAkB,KAA4B;AAC5D,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,QAAQ,IAAI,MAAM,cAAc;AACtC,SAAO,QAAQ,MAAM,CAAC,IAAI;AAC5B;AAeO,SAAS,aACd,KACA,MACQ;AACR,QAAM,QAAQ,kBAAkB,GAAG;AACnC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,UAAU,OAAO,IAAI,EAAE;AAChC;","names":[]}
1
+ {"version":3,"sources":["../../../../../src/files/engine/handler/utils/python-base.ts"],"sourcesContent":["/**\n * features/files/handler/utils/python-base.ts\n *\n * Resolve Python backend URLs for direct browser → Python file traffic.\n * The handler emits URLs that point at Python, never at Next.js — there\n * is no server-side file handler in this app and no `/app/api/*` proxy\n * involvement.\n *\n * Design note — \"all variants in one object\":\n * Python exposes the same bytes via slightly-different URLs (inline vs\n * attachment) and the FE has a share landing page. Rather than add a new\n * function for every future tweak, the canonical builders here\n * (`shareUrls`, `fileUrls`) return ALL the variants up front and let the\n * caller pick. Single spelling for \"everything you can do with a\n * token / file id.\"\n *\n * Single-string helpers (`pythonShareUrl`, `pythonFileDownloadUrl`,\n * `pythonFileInlineUrl`) are kept as thin wrappers so existing callers\n * don't churn. Prefer the object form in new code.\n *\n * Tokens are CANONICAL share-link tokens (`platform.share_links`), minted via\n * `createShareLink` in `@/utils/permissions/shareLinks` — the landing page is\n * `/s/{token}` and the clean byte endpoint is Python's `/share/{token}`.\n */\n\nimport { buildFileUrls, shareUrl as packageShareUrl, type FileUrls } from \"@ai-matrx/data/files\";\n\nexport type { FileUrls };\nimport { resolveFilesBaseUrl } from \"../../host/python-client\";\nimport { shareLinkUrl } from \"../../host/share-links\";\n\nexport function pythonBaseUrl(): string {\n return resolveFilesBaseUrl();\n}\n\n// ---------------------------------------------------------------------------\n// Share-link URLs (token-based, public, no auth)\n// ---------------------------------------------------------------------------\n\nexport interface ShareUrls {\n /**\n * Canonical clean public URL with `inline` Content-Disposition (the default).\n * Works as `<img src>`, `<video src>`, `<audio src>`, PDF preview, or a\n * normal browser link a recipient can paste anywhere.\n *\n * Image / video / audio / PDF render inline. Dangerous types\n * (HTML, SVG, JS) are forced to `attachment` server-side.\n */\n public: string;\n /**\n * Explicit download endpoint with `?inline=false` — forces\n * `Content-Disposition: attachment` so the browser triggers a\n * download dialog instead of rendering inline. Use for \"Save As…\"\n * style affordances on images / PDFs.\n */\n attachment: string;\n /**\n * The canonical share landing page on the FE — `/s/{token}` (metadata via\n * the `resolve_share_token` RPC + preview + download button). Useful as a\n * clickable link in chat / email / docs; NOT a media src.\n */\n page: string;\n}\n\n/**\n * Build every URL variant for a share token in one call. Cheap —\n * just string concatenation, no I/O.\n */\nexport function shareUrls(\n token: string,\n opts?: { appOrigin?: string },\n): ShareUrls {\n const t = encodeURIComponent(token);\n // The URL grammar is @ai-matrx/data/files'; the host only binds the base.\n const base = packageShareUrl(pythonBaseUrl(), token);\n return {\n public: base,\n attachment: `${base}/download?inline=false`,\n page: opts?.appOrigin\n ? `${opts.appOrigin.replace(/\\/$/, \"\")}/s/${t}`\n : shareLinkUrl(t),\n };\n}\n\n/**\n * Byte URLs for ONE child file of a shared record — a shared chat's\n * attachment — reached with the parent's share token (access ladder T-19b).\n * The files service answers `/share/{token}/files/{fileId}` only when the file\n * is a child of the record that token shares; nothing else is reachable.\n */\nexport function shareChildFileUrls(\n token: string,\n fileId: string,\n opts?: { baseUrl?: string },\n): { inline: string; attachment: string } {\n const backend = (opts?.baseUrl ?? pythonBaseUrl()).replace(/\\/$/, \"\");\n const base = `${backend}/share/${encodeURIComponent(token)}/files/${encodeURIComponent(fileId)}`;\n return { inline: base, attachment: `${base}?inline=false` };\n}\n\n/**\n * Python's clean public byte-streaming share endpoint. Convenience wrapper\n * around `shareUrls(token).public` for callers that just want the\n * single canonical embeddable URL.\n */\nexport function pythonShareUrl(token: string): string {\n return shareUrls(token).public;\n}\n\n// ---------------------------------------------------------------------------\n// Authenticated file URLs (file-id, owner / shared-permission required)\n// ---------------------------------------------------------------------------\n\n\n/**\n * Build every URL variant for a cld_files file id in one call. Cheap —\n * just string concatenation. These are DURABLE URLs: they never expire and\n * carry only the file id. Auth is either an Authorization header on\n * `fetch()` (the python-client attaches it) or the `mx_files_session`\n * cookie for plain `<img>`/`<video>` bindings (see `../session.ts`).\n * `?inline=1` matches the exact spelling the backend emits on\n * `FileRecord.url`, keeping cache keys identical either way.\n */\nexport function fileUrls(fileId: string): FileUrls {\n // The URL grammar is @ai-matrx/data/files'; the host only binds the base.\n return buildFileUrls(pythonBaseUrl(), fileId);\n}\n\nconst AUTHENTICATED_FILE_BYTES_RE =\n /\\/files\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\/download(?:[?#]|$)/i;\n\n/**\n * True for a durable `/files/{id}/download` URL (either host, either\n * spelling) — bytes that answer 401 unless the request carries the person's\n * Authorization header or the `mx_files_session` cookie. A consumer that\n * loads bytes with its own fetch (PDF.js) must never send one bare.\n */\nexport function isAuthenticatedFileBytesUrl(url: string | null | undefined): boolean {\n return !!url && AUTHENTICATED_FILE_BYTES_RE.test(url);\n}\n\n/** Single-URL convenience wrapper around `fileUrls(fileId).download`. */\nexport function pythonFileDownloadUrl(fileId: string): string {\n return fileUrls(fileId).download;\n}\n\n/** Single-URL convenience wrapper around `fileUrls(fileId).inline`. */\nexport function pythonFileInlineUrl(fileId: string): string {\n return fileUrls(fileId).inline;\n}\n\n// ---------------------------------------------------------------------------\n// URL classification + viewer-page derivation\n// ---------------------------------------------------------------------------\n\n/**\n * Match anything that ends in `/share/{token}` or `/share/{token}/download`\n * (with optional query string / hash) where `{token}` is a hex-ish string of\n * at least 8 chars (covers both UUIDs and shorter custom tokens). Used to\n * recognize URLs that we ourselves emitted via `shareUrls()` so we can\n * round-trip them back into a viewer URL.\n */\nconst SHARE_TOKEN_RE = /\\/share\\/([0-9a-f-]{8,})(?:\\/download)?(?:[/?#]|$)/i;\n\n/**\n * Extract the share token from any URL we recognize. Returns `null` for\n * URLs that aren't ours (opaque external and third-party URLs).\n */\nexport function tokenFromShareUrl(url: string): string | null {\n if (!url) return null;\n const match = url.match(SHARE_TOKEN_RE);\n return match ? match[1] : null;\n}\n\n/**\n * \"Best-effort viewer URL\" for any image / file URL we might have stored\n * in the database. The intent is what the user clicks when they want to\n * SEE the file (not download it):\n *\n * - Our bare `/share/{token}` (or legacy `/share/{token}/download`) URLs → the\n * canonical FE landing page at `${origin}/s/{token}` (metadata + preview\n * + download button). This is what an admin wants when they click a\n * screenshot in the feedback dialog.\n * - Anything else (external URLs) →\n * returned unchanged. Browsers render image bytes inline regardless,\n * and we have no viewer page for them anyway.\n */\nexport function imageViewUrl(\n url: string,\n opts?: { appOrigin?: string },\n): string {\n const token = tokenFromShareUrl(url);\n if (!token) return url;\n return shareUrls(token, opts).page;\n}\n"],"mappings":"AAyBA,SAAS,eAAe,YAAY,uBAAsC;AAG1E,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAEtB,SAAS,gBAAwB;AACtC,SAAO,oBAAoB;AAC7B;AAmCO,SAAS,UACd,OACA,MACW;AACX,QAAM,IAAI,mBAAmB,KAAK;AAElC,QAAM,OAAO,gBAAgB,cAAc,GAAG,KAAK;AACnD,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,YAAY,GAAG,IAAI;AAAA,IACnB,MAAM,MAAM,YACR,GAAG,KAAK,UAAU,QAAQ,OAAO,EAAE,CAAC,MAAM,CAAC,KAC3C,aAAa,CAAC;AAAA,EACpB;AACF;AAQO,SAAS,mBACd,OACA,QACA,MACwC;AACxC,QAAM,WAAW,MAAM,WAAW,cAAc,GAAG,QAAQ,OAAO,EAAE;AACpE,QAAM,OAAO,GAAG,OAAO,UAAU,mBAAmB,KAAK,CAAC,UAAU,mBAAmB,MAAM,CAAC;AAC9F,SAAO,EAAE,QAAQ,MAAM,YAAY,GAAG,IAAI,gBAAgB;AAC5D;AAOO,SAAS,eAAe,OAAuB;AACpD,SAAO,UAAU,KAAK,EAAE;AAC1B;AAgBO,SAAS,SAAS,QAA0B;AAEjD,SAAO,cAAc,cAAc,GAAG,MAAM;AAC9C;AAEA,MAAM,8BACJ;AAQK,SAAS,4BAA4B,KAAyC;AACnF,SAAO,CAAC,CAAC,OAAO,4BAA4B,KAAK,GAAG;AACtD;AAGO,SAAS,sBAAsB,QAAwB;AAC5D,SAAO,SAAS,MAAM,EAAE;AAC1B;AAGO,SAAS,oBAAoB,QAAwB;AAC1D,SAAO,SAAS,MAAM,EAAE;AAC1B;AAaA,MAAM,iBAAiB;AAMhB,SAAS,kBAAkB,KAA4B;AAC5D,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,QAAQ,IAAI,MAAM,cAAc;AACtC,SAAO,QAAQ,MAAM,CAAC,IAAI;AAC5B;AAeO,SAAS,aACd,KACA,MACQ;AACR,QAAM,QAAQ,kBAAkB,GAAG;AACnC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,UAAU,OAAO,IAAI,EAAE;AAChC;","names":[]}
@@ -23,7 +23,7 @@
23
23
  * - Final `X-Cld-File-Id` captured from response headers (final PATCH, or the
24
24
  * completed-session HEAD/POST recovery paths) — a lost final response never
25
25
  * forces a re-upload.
26
- * - Resume URLs live in a dedicated tiny IndexedDB (`mtx-tus-urls`) — NEVER
26
+ * - Resume URLs live in a dedicated tiny browser store (`mtx-tus-urls`, owned by @ai-matrx/data/files/tus) — NEVER
27
27
  * the recorder chunk journal DB.
28
28
  *
29
29
  * NOT LIVE-TESTED against production: the server side (CORS, metadata parity,
@@ -31,70 +31,25 @@
31
31
  * see features/files/handler/FEATURE.md § Transport policy for the pending
32
32
  * E2E note. The client is unit-tested with an injected HttpStack.
33
33
  */
34
- import * as tus from "tus-js-client";
34
+ import { type TusHttpStack, type TusFileReader, type TusUrlStorage } from "@ai-matrx/data/files/tus";
35
35
  import type { CloudUploadOptions, CloudUploadResult } from "./cloudUpload.js";
36
- /** Server-bounded chunk size (8–32 MiB allowed): 16 MiB. */
37
- export declare const TUS_CHUNK_SIZE_BYTES: number;
38
- export declare const TUS_UPLOAD_PATH = "/files/upload/tus";
39
- /**
40
- * The ONE builder for the upload metadata JSON object. The buffered path
41
- * serializes this into the `metadata_json` form field
42
- * (`features/files/api/files.ts`); the TUS path serializes the SAME object
43
- * into the `metadata_json` Upload-Metadata key. One builder ⇒ the two
44
- * transports can never drift (parity is unit-tested).
45
- *
46
- * 🚨 `scope.organization_id` is the OWNING WORKSPACE of the uploaded file, and
47
- * it is not decoration. The server reads it (`SyncEngine._metadata_organization_id`,
48
- * membership-checked against `iam.has_org_access_for`) and stamps
49
- * `files.files.organization_id` from it. Omit it and the server falls back to
50
- * the uploader's OWN organization — which is how, on 2026-08-30, a screenshot
51
- * attached from the composer landed in a own organization nobody had chosen
52
- * and then disagreed with the team organization the person actually picked.
53
- *
54
- * Callers never set it by hand: `bindUploadOrganization` in `cloudUpload.ts`
55
- * resolves it for every upload before a byte moves, so it is already present in
56
- * `metadata` by the time this builder runs. This function keeps the shape
57
- * canonical and is where the contract is written down.
58
- */
59
- export declare function buildUploadMetadataEnvelope(metadata: Record<string, unknown> | undefined): Record<string, unknown>;
60
- /** tus-js-client UrlStorage backed by the dedicated `mtx-tus-urls` DB —
61
- * deliberately SEPARATE from the recorder chunk journal. */
62
- export declare function createTusUrlStorage(): NonNullable<ConstructorParameters<typeof tus.Upload>[1]["urlStorage"]>;
63
- /** Read-only summary of a stored TUS resume session (diagnostics surfaces —
64
- * /camera's resume-pending indicator, /camera/admin). */
65
- export interface StoredTusUploadSummary {
66
- urlStorageKey: string;
67
- fingerprint: string;
68
- size: number | null;
69
- metadata: Record<string, string>;
70
- creationTime: string;
71
- uploadUrl: string | null;
72
- }
73
- /**
74
- * List every stored TUS resume-URL entry (the `mtx-tus-urls` DB). Read-only —
75
- * resuming still happens inside `tusUploadRaw` via the UrlStorage; this only
76
- * lets management surfaces SHOW that a resumable session is pending. Returns
77
- * [] where IndexedDB is unavailable (never throws — diagnostics must not
78
- * break a page).
79
- */
80
- export declare function listStoredTusUploads(): Promise<StoredTusUploadSummary[]>;
36
+ export { buildUploadMetadataEnvelope, createTusUrlStorage, listStoredTusUploads, TUS_CHUNK_SIZE_BYTES, TUS_UPLOAD_PATH, type StoredTusUploadSummary, } from "@ai-matrx/data/files/tus";
81
37
  export interface TusUploadDeps {
82
38
  /** Injected HttpStack for unit tests (mock wire, no XHR). */
83
- httpStack?: tus.HttpStack;
39
+ httpStack?: TusHttpStack;
84
40
  /** Injected file reader for unit tests (Node builds of tus-js-client
85
41
  * cannot slice browser Files). */
86
- fileReader?: ConstructorParameters<typeof tus.Upload>[1]["fileReader"];
42
+ fileReader?: TusFileReader;
87
43
  /** Override the endpoint (tests). */
88
44
  endpointOverride?: string;
89
45
  /** Override URL storage (tests). */
90
- urlStorage?: ReturnType<typeof createTusUrlStorage>;
46
+ urlStorage?: TusUrlStorage;
91
47
  }
92
48
  /**
93
49
  * Resumable upload of one file via TUS. Same result contract as
94
50
  * `cloudUploadRaw` — `{ ok: true, fileId, ... } | { ok: false, error }`,
95
- * never throws. Automatically resumes a previous session for the same file
96
- * (custom UrlStorage + server HEAD), including the completed-session
97
- * recovery: when the server reports the session already finished and hands
98
- * back `X-Cld-File-Id`, the upload is skipped and the file resolves directly.
51
+ * never throws. The transfer is data's `tusTransfer`; this binds fresh
52
+ * host headers (mandatory-org, fail-closed `buildHeaders`) to every request
53
+ * and hydrates the canonical row so the result matches the buffered contract.
99
54
  */
100
55
  export declare function tusUploadRaw(file: File, filePath: string, options: CloudUploadOptions, idempotencyKey: string, deps?: TusUploadDeps): Promise<CloudUploadResult>;
@@ -1,233 +1,41 @@
1
- import * as tus from "tus-js-client";
2
1
  import { buildHeaders, resolveBaseUrlForPath } from "../host/python-client.js";
3
2
  import * as Files from "../api/files.js";
4
3
  import { extractErrorMessage } from "@ai-matrx/data/net";
5
- const TUS_CHUNK_SIZE_BYTES = 16 * 1024 * 1024;
6
- const TUS_UPLOAD_PATH = "/files/upload/tus";
7
- function buildUploadMetadataEnvelope(metadata) {
8
- const envelope = {
9
- // Origin tag aids backend log triage when something goes wrong.
10
- origin: "cloudUpload",
11
- ...metadata ?? {}
12
- };
13
- if (process.env.NODE_ENV !== "production" && !hasUploadOrganization(envelope)) {
14
- console.warn(
15
- "[buildUploadMetadataEnvelope] upload carries no scope.organization_id \u2014 it will be filed in an organization nobody chose. Route this upload through cloudUpload/cloudUploadRaw so the organization gate runs."
16
- );
17
- }
18
- return envelope;
19
- }
20
- function hasUploadOrganization(envelope) {
21
- if (typeof envelope.organization_id === "string" && envelope.organization_id)
22
- return true;
23
- const scope = envelope.scope;
24
- return Boolean(
25
- scope && typeof scope === "object" && typeof scope.organization_id === "string" && scope.organization_id
26
- );
27
- }
28
- const URL_DB_NAME = "mtx-tus-urls";
29
- const URL_STORE = "urls";
30
- let urlDbPromise = null;
31
- function openUrlDb() {
32
- if (urlDbPromise) return urlDbPromise;
33
- urlDbPromise = new Promise((resolve, reject) => {
34
- if (typeof indexedDB === "undefined") {
35
- reject(new Error("[tusUpload] IndexedDB unavailable"));
36
- return;
37
- }
38
- const req = indexedDB.open(URL_DB_NAME, 1);
39
- req.onupgradeneeded = () => {
40
- const db = req.result;
41
- if (!db.objectStoreNames.contains(URL_STORE)) {
42
- const store = db.createObjectStore(URL_STORE, {
43
- keyPath: "urlStorageKey"
44
- });
45
- store.createIndex("fingerprint", "fingerprint", { unique: false });
46
- }
47
- };
48
- req.onsuccess = () => resolve(req.result);
49
- req.onerror = () => reject(req.error ?? new Error("[tusUpload] failed to open mtx-tus-urls"));
50
- });
51
- urlDbPromise.catch(() => {
52
- urlDbPromise = null;
53
- });
54
- return urlDbPromise;
55
- }
56
- function idbRequest(req) {
57
- return new Promise((resolve, reject) => {
58
- req.onsuccess = () => resolve(req.result);
59
- req.onerror = () => reject(req.error ?? new Error("IndexedDB failed"));
60
- });
61
- }
62
- function createTusUrlStorage() {
63
- return {
64
- async findAllUploads() {
65
- const db = await openUrlDb();
66
- const tx = db.transaction(URL_STORE, "readonly");
67
- const all = await idbRequest(
68
- tx.objectStore(URL_STORE).getAll()
69
- );
70
- return all;
71
- },
72
- async findUploadsByFingerprint(fingerprint) {
73
- const db = await openUrlDb();
74
- const tx = db.transaction(URL_STORE, "readonly");
75
- const matches = await idbRequest(
76
- tx.objectStore(URL_STORE).index("fingerprint").getAll(fingerprint)
77
- );
78
- return matches;
79
- },
80
- async removeUpload(urlStorageKey) {
81
- const db = await openUrlDb();
82
- const tx = db.transaction(URL_STORE, "readwrite");
83
- tx.objectStore(URL_STORE).delete(urlStorageKey);
84
- await new Promise((resolve, reject) => {
85
- tx.oncomplete = () => resolve();
86
- tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx failed"));
87
- });
88
- },
89
- async addUpload(fingerprint, upload) {
90
- const urlStorageKey = `tus::${fingerprint}::${Date.now().toString(36)}`;
91
- const record = {
92
- urlStorageKey,
93
- fingerprint,
94
- size: upload.size,
95
- metadata: upload.metadata,
96
- creationTime: upload.creationTime,
97
- uploadUrl: upload.uploadUrl,
98
- parallelUploadUrls: upload.parallelUploadUrls
99
- };
100
- const db = await openUrlDb();
101
- const tx = db.transaction(URL_STORE, "readwrite");
102
- tx.objectStore(URL_STORE).put(record);
103
- await new Promise((resolve, reject) => {
104
- tx.oncomplete = () => resolve();
105
- tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx failed"));
106
- });
107
- return urlStorageKey;
108
- }
109
- };
110
- }
111
- async function listStoredTusUploads() {
112
- try {
113
- const db = await openUrlDb();
114
- const tx = db.transaction(URL_STORE, "readonly");
115
- const all = await idbRequest(
116
- tx.objectStore(URL_STORE).getAll()
117
- );
118
- return all.map((r) => ({
119
- urlStorageKey: r.urlStorageKey,
120
- fingerprint: r.fingerprint,
121
- size: r.size,
122
- metadata: r.metadata,
123
- creationTime: r.creationTime,
124
- uploadUrl: r.uploadUrl
125
- }));
126
- } catch (err) {
127
- console.error("[tusUpload] listStoredTusUploads failed:", err);
128
- return [];
129
- }
130
- }
4
+ import {
5
+ tusTransfer,
6
+ TUS_UPLOAD_PATH
7
+ } from "@ai-matrx/data/files/tus";
8
+ import {
9
+ buildUploadMetadataEnvelope,
10
+ createTusUrlStorage,
11
+ listStoredTusUploads,
12
+ TUS_CHUNK_SIZE_BYTES,
13
+ TUS_UPLOAD_PATH as TUS_UPLOAD_PATH2
14
+ } from "@ai-matrx/data/files/tus";
131
15
  async function tusUploadRaw(file, filePath, options, idempotencyKey, deps = {}) {
132
16
  const endpoint = deps.endpointOverride ?? `${resolveBaseUrlForPath(TUS_UPLOAD_PATH, void 0, "POST")}${TUS_UPLOAD_PATH}`;
133
- const metadataEnvelope = buildUploadMetadataEnvelope(options.metadata);
134
- let cldFileId = null;
135
- let aborted = false;
136
- try {
137
- await new Promise((resolve, reject) => {
138
- const upload = new tus.Upload(file, {
139
- endpoint,
140
- chunkSize: TUS_CHUNK_SIZE_BYTES,
141
- retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
142
- removeFingerprintOnSuccess: true,
143
- urlStorage: deps.urlStorage ?? createTusUrlStorage(),
144
- ...deps.httpStack ? { httpStack: deps.httpStack } : {},
145
- ...deps.fileReader ? { fileReader: deps.fileReader } : {},
146
- metadata: {
147
- filename: file.name,
148
- filepath: filePath,
149
- // ONE validated JSON envelope — tus-js-client base64-encodes the
150
- // value per the Upload-Metadata spec; the server parses and merges
151
- // it exactly like the buffered `metadata_json` form field.
152
- metadata_json: JSON.stringify(metadataEnvelope),
153
- ...options.visibility ? { visibility: options.visibility } : {}
154
- },
155
- onBeforeRequest: async (req) => {
156
- const { headers } = await buildHeaders({}, false);
157
- if (headers.Authorization) {
158
- req.setHeader("Authorization", headers.Authorization);
159
- }
160
- if (headers["X-Guest-Fingerprint"]) {
161
- req.setHeader("X-Guest-Fingerprint", headers["X-Guest-Fingerprint"]);
162
- }
163
- if (headers["X-Organization-Id"]) {
164
- req.setHeader("X-Organization-Id", headers["X-Organization-Id"]);
165
- }
166
- if (req.getMethod() === "POST") {
167
- req.setHeader("X-Idempotency-Key", idempotencyKey);
168
- }
169
- },
170
- onAfterResponse: (_req, res) => {
171
- const id = res.getHeader("X-Cld-File-Id");
172
- if (id) cldFileId = id;
173
- },
174
- onProgress: (bytesSent, bytesTotal) => {
175
- options.onProgress?.({ loaded: bytesSent, total: bytesTotal });
176
- },
177
- onSuccess: () => resolve(),
178
- onError: (error) => reject(error)
179
- });
180
- if (options.signal) {
181
- if (options.signal.aborted) {
182
- aborted = true;
183
- reject(new Error("Upload cancelled"));
184
- return;
185
- }
186
- options.signal.addEventListener(
187
- "abort",
188
- () => {
189
- aborted = true;
190
- void upload.abort();
191
- reject(new Error("Upload cancelled"));
192
- },
193
- { once: true }
194
- );
195
- }
196
- void upload.findPreviousUploads().then((previous) => {
197
- if (previous.length > 0) {
198
- upload.resumeFromPreviousUpload(previous[0]);
199
- }
200
- upload.start();
201
- }).catch((err) => {
202
- console.warn(
203
- "[tusUpload] previous-upload lookup failed \u2014 starting fresh:",
204
- err
205
- );
206
- upload.start();
207
- });
208
- });
209
- } catch (err) {
210
- if (cldFileId && !aborted) {
211
- console.warn(
212
- `[tusUpload] transfer reported an error but the server exposed X-Cld-File-Id=${cldFileId} \u2014 recovering the completed session.`
213
- );
214
- } else {
215
- return {
216
- ok: false,
217
- error: extractErrorMessage(err),
218
- errorCode: aborted ? "upload_cancelled" : "tus_upload_failed",
219
- fileName: file.name
220
- };
221
- }
222
- }
223
- if (!cldFileId) {
17
+ const transfer = await tusTransfer(file, {
18
+ endpoint,
19
+ filePath,
20
+ metadata: options.metadata,
21
+ visibility: options.visibility,
22
+ idempotencyKey,
23
+ headers: async () => (await buildHeaders({}, false)).headers,
24
+ onProgress: options.onProgress ? (loaded, total) => options.onProgress?.({ loaded, total }) : void 0,
25
+ signal: options.signal,
26
+ httpStack: deps.httpStack,
27
+ fileReader: deps.fileReader,
28
+ urlStorage: deps.urlStorage
29
+ });
30
+ if (!transfer.ok) {
224
31
  return {
225
32
  ok: false,
226
- error: "TUS upload finished but the server never exposed X-Cld-File-Id \u2014 the file cannot be resolved. Check server CORS expose_headers.",
227
- errorCode: "tus_missing_file_id",
33
+ error: transfer.error,
34
+ errorCode: transfer.errorCode,
228
35
  fileName: file.name
229
36
  };
230
37
  }
38
+ const cldFileId = transfer.fileId;
231
39
  try {
232
40
  const { data: record } = await Files.getFile(cldFileId);
233
41
  return {
@@ -249,7 +57,7 @@ async function tusUploadRaw(file, filePath, options, idempotencyKey, deps = {})
249
57
  }
250
58
  export {
251
59
  TUS_CHUNK_SIZE_BYTES,
252
- TUS_UPLOAD_PATH,
60
+ TUS_UPLOAD_PATH2 as TUS_UPLOAD_PATH,
253
61
  buildUploadMetadataEnvelope,
254
62
  createTusUrlStorage,
255
63
  listStoredTusUploads,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/files/engine/upload/tusUpload.ts"],"sourcesContent":["/**\n * features/files/upload/tusUpload.ts\n *\n * Resumable (TUS) upload transport for the file handler — the large-file\n * sibling of the buffered multipart path in `cloudUpload.ts`. Callers never\n * import this directly: `cloudUpload` routes here per the transport policy\n * (size ≥ `TUS_TRANSPORT_THRESHOLD_BYTES`, or an explicit\n * `transport: \"tus\"` override).\n *\n * Wire contract (common-docs/systems/media/media-capture/FEATURE.md § TUS):\n * - Endpoint: `${PYTHON_BACKEND}/files/upload/tus` (resolved through the same\n * base-url helper the python-client uses — server toggle respected).\n * - Explicit chunk size 16 MiB (server bounds 8–32 MiB; never the client\n * default).\n * - `Upload-Metadata` carries `filename`, `filepath`, and ONE `metadata_json`\n * key — the base64 of the SAME JSON object the buffered path sends as its\n * `metadata_json` form field (tus-js-client base64-encodes values; the\n * object is built by the shared `buildUploadMetadataEnvelope`). Parity with\n * buffered uploads is a tested invariant.\n * - FRESH Authorization per request (`onBeforeRequest` re-reads the Supabase\n * session — long uploads outlive a single JWT).\n * - `X-Idempotency-Key` on the creation POST only.\n * - Final `X-Cld-File-Id` captured from response headers (final PATCH, or the\n * completed-session HEAD/POST recovery paths) — a lost final response never\n * forces a re-upload.\n * - Resume URLs live in a dedicated tiny IndexedDB (`mtx-tus-urls`) — NEVER\n * the recorder chunk journal DB.\n *\n * NOT LIVE-TESTED against production: the server side (CORS, metadata parity,\n * completed-HEAD recovery) exists in aidream locally but is not deployed —\n * see features/files/handler/FEATURE.md § Transport policy for the pending\n * E2E note. The client is unit-tested with an injected HttpStack.\n */\n\nimport * as tus from \"tus-js-client\";\nimport { buildHeaders, resolveBaseUrlForPath } from \"../host/python-client\";\n// eslint-disable-next-line no-restricted-imports -- transport sibling INSIDE the ring-fenced upload internals (same as cloudUpload.ts's own internal imports)\nimport * as Files from \"../api/files\";\nimport { extractErrorMessage } from \"@ai-matrx/data/net\";\n// eslint-disable-next-line no-restricted-imports -- type-only import between the two upload transports (both internal to features/files/upload)\nimport type {\n CloudUploadOptions,\n CloudUploadResult,\n} from \"./cloudUpload\";\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Server-bounded chunk size (8–32 MiB allowed): 16 MiB. */\nexport const TUS_CHUNK_SIZE_BYTES = 16 * 1024 * 1024;\n\nexport const TUS_UPLOAD_PATH = \"/files/upload/tus\";\n\n// ─── Shared metadata envelope (buffered ↔ TUS parity) ────────────────────────\n\n/**\n * The ONE builder for the upload metadata JSON object. The buffered path\n * serializes this into the `metadata_json` form field\n * (`features/files/api/files.ts`); the TUS path serializes the SAME object\n * into the `metadata_json` Upload-Metadata key. One builder ⇒ the two\n * transports can never drift (parity is unit-tested).\n *\n * 🚨 `scope.organization_id` is the OWNING WORKSPACE of the uploaded file, and\n * it is not decoration. The server reads it (`SyncEngine._metadata_organization_id`,\n * membership-checked against `iam.has_org_access_for`) and stamps\n * `files.files.organization_id` from it. Omit it and the server falls back to\n * the uploader's OWN organization — which is how, on 2026-08-30, a screenshot\n * attached from the composer landed in a own organization nobody had chosen\n * and then disagreed with the team organization the person actually picked.\n *\n * Callers never set it by hand: `bindUploadOrganization` in `cloudUpload.ts`\n * resolves it for every upload before a byte moves, so it is already present in\n * `metadata` by the time this builder runs. This function keeps the shape\n * canonical and is where the contract is written down.\n */\nexport function buildUploadMetadataEnvelope(\n metadata: Record<string, unknown> | undefined,\n): Record<string, unknown> {\n const envelope: Record<string, unknown> = {\n // Origin tag aids backend log triage when something goes wrong.\n origin: \"cloudUpload\",\n ...(metadata ?? {}),\n };\n if (\n process.env.NODE_ENV !== \"production\" &&\n !hasUploadOrganization(envelope)\n ) {\n // Loud in development, never a hard failure in production: a missing\n // organization is a silent mis-filing, and silence is exactly what made the\n // original bug survive. The server still fails closed on its own terms.\n console.warn(\n \"[buildUploadMetadataEnvelope] upload carries no scope.organization_id — \" +\n \"it will be filed in an organization nobody chose. Route this \" +\n \"upload through cloudUpload/cloudUploadRaw so the organization gate runs.\",\n );\n }\n return envelope;\n}\n\nfunction hasUploadOrganization(envelope: Record<string, unknown>): boolean {\n if (typeof envelope.organization_id === \"string\" && envelope.organization_id)\n return true;\n const scope = envelope.scope;\n return Boolean(\n scope &&\n typeof scope === \"object\" &&\n typeof (scope as Record<string, unknown>).organization_id === \"string\" &&\n (scope as Record<string, unknown>).organization_id,\n );\n}\n\n// ─── Dedicated resume-URL storage (mtx-tus-urls) ─────────────────────────────\n\nconst URL_DB_NAME = \"mtx-tus-urls\";\nconst URL_STORE = \"urls\";\n\ninterface StoredTusUpload {\n urlStorageKey: string;\n fingerprint: string;\n size: number | null;\n metadata: Record<string, string>;\n creationTime: string;\n uploadUrl: string | null;\n parallelUploadUrls: string[] | null;\n}\n\nlet urlDbPromise: Promise<IDBDatabase> | null = null;\n\nfunction openUrlDb(): Promise<IDBDatabase> {\n if (urlDbPromise) return urlDbPromise;\n urlDbPromise = new Promise<IDBDatabase>((resolve, reject) => {\n if (typeof indexedDB === \"undefined\") {\n reject(new Error(\"[tusUpload] IndexedDB unavailable\"));\n return;\n }\n const req = indexedDB.open(URL_DB_NAME, 1);\n req.onupgradeneeded = () => {\n const db = req.result;\n if (!db.objectStoreNames.contains(URL_STORE)) {\n const store = db.createObjectStore(URL_STORE, {\n keyPath: \"urlStorageKey\",\n });\n store.createIndex(\"fingerprint\", \"fingerprint\", { unique: false });\n }\n };\n req.onsuccess = () => resolve(req.result);\n req.onerror = () =>\n reject(req.error ?? new Error(\"[tusUpload] failed to open mtx-tus-urls\"));\n });\n urlDbPromise.catch(() => {\n urlDbPromise = null;\n });\n return urlDbPromise;\n}\n\nfunction idbRequest<T>(req: IDBRequest<T>): Promise<T> {\n return new Promise<T>((resolve, reject) => {\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error ?? new Error(\"IndexedDB failed\"));\n });\n}\n\ntype PreviousUpload = Awaited<\n ReturnType<tus.Upload[\"findPreviousUploads\"]>\n>[number];\n\n/** tus-js-client UrlStorage backed by the dedicated `mtx-tus-urls` DB —\n * deliberately SEPARATE from the recorder chunk journal. */\nexport function createTusUrlStorage(): NonNullable<\n ConstructorParameters<typeof tus.Upload>[1][\"urlStorage\"]\n> {\n return {\n async findAllUploads(): Promise<PreviousUpload[]> {\n const db = await openUrlDb();\n const tx = db.transaction(URL_STORE, \"readonly\");\n const all = await idbRequest(\n tx.objectStore(URL_STORE).getAll() as IDBRequest<StoredTusUpload[]>,\n );\n return all;\n },\n async findUploadsByFingerprint(\n fingerprint: string,\n ): Promise<PreviousUpload[]> {\n const db = await openUrlDb();\n const tx = db.transaction(URL_STORE, \"readonly\");\n const matches = await idbRequest(\n tx\n .objectStore(URL_STORE)\n .index(\"fingerprint\")\n .getAll(fingerprint) as IDBRequest<StoredTusUpload[]>,\n );\n return matches;\n },\n async removeUpload(urlStorageKey: string): Promise<void> {\n const db = await openUrlDb();\n const tx = db.transaction(URL_STORE, \"readwrite\");\n tx.objectStore(URL_STORE).delete(urlStorageKey);\n await new Promise<void>((resolve, reject) => {\n tx.oncomplete = () => resolve();\n tx.onerror = () => reject(tx.error ?? new Error(\"IndexedDB tx failed\"));\n });\n },\n async addUpload(\n fingerprint: string,\n upload: PreviousUpload,\n ): Promise<string> {\n const urlStorageKey = `tus::${fingerprint}::${Date.now().toString(36)}`;\n const record: StoredTusUpload = {\n urlStorageKey,\n fingerprint,\n size: upload.size,\n metadata: upload.metadata,\n creationTime: upload.creationTime,\n uploadUrl: upload.uploadUrl,\n parallelUploadUrls: upload.parallelUploadUrls,\n };\n const db = await openUrlDb();\n const tx = db.transaction(URL_STORE, \"readwrite\");\n tx.objectStore(URL_STORE).put(record);\n await new Promise<void>((resolve, reject) => {\n tx.oncomplete = () => resolve();\n tx.onerror = () => reject(tx.error ?? new Error(\"IndexedDB tx failed\"));\n });\n return urlStorageKey;\n },\n };\n}\n\n/** Read-only summary of a stored TUS resume session (diagnostics surfaces —\n * /camera's resume-pending indicator, /camera/admin). */\nexport interface StoredTusUploadSummary {\n urlStorageKey: string;\n fingerprint: string;\n size: number | null;\n metadata: Record<string, string>;\n creationTime: string;\n uploadUrl: string | null;\n}\n\n/**\n * List every stored TUS resume-URL entry (the `mtx-tus-urls` DB). Read-only —\n * resuming still happens inside `tusUploadRaw` via the UrlStorage; this only\n * lets management surfaces SHOW that a resumable session is pending. Returns\n * [] where IndexedDB is unavailable (never throws — diagnostics must not\n * break a page).\n */\nexport async function listStoredTusUploads(): Promise<\n StoredTusUploadSummary[]\n> {\n try {\n const db = await openUrlDb();\n const tx = db.transaction(URL_STORE, \"readonly\");\n const all = await idbRequest(\n tx.objectStore(URL_STORE).getAll() as IDBRequest<StoredTusUpload[]>,\n );\n return all.map((r) => ({\n urlStorageKey: r.urlStorageKey,\n fingerprint: r.fingerprint,\n size: r.size,\n metadata: r.metadata,\n creationTime: r.creationTime,\n uploadUrl: r.uploadUrl,\n }));\n } catch (err) {\n console.error(\"[tusUpload] listStoredTusUploads failed:\", err);\n return [];\n }\n}\n\n// ─── The transport ───────────────────────────────────────────────────────────\n\nexport interface TusUploadDeps {\n /** Injected HttpStack for unit tests (mock wire, no XHR). */\n httpStack?: tus.HttpStack;\n /** Injected file reader for unit tests (Node builds of tus-js-client\n * cannot slice browser Files). */\n fileReader?: ConstructorParameters<typeof tus.Upload>[1][\"fileReader\"];\n /** Override the endpoint (tests). */\n endpointOverride?: string;\n /** Override URL storage (tests). */\n urlStorage?: ReturnType<typeof createTusUrlStorage>;\n}\n\n/**\n * Resumable upload of one file via TUS. Same result contract as\n * `cloudUploadRaw` — `{ ok: true, fileId, ... } | { ok: false, error }`,\n * never throws. Automatically resumes a previous session for the same file\n * (custom UrlStorage + server HEAD), including the completed-session\n * recovery: when the server reports the session already finished and hands\n * back `X-Cld-File-Id`, the upload is skipped and the file resolves directly.\n */\nexport async function tusUploadRaw(\n file: File,\n filePath: string,\n options: CloudUploadOptions,\n idempotencyKey: string,\n deps: TusUploadDeps = {},\n): Promise<CloudUploadResult> {\n const endpoint =\n deps.endpointOverride ??\n `${resolveBaseUrlForPath(TUS_UPLOAD_PATH, undefined, \"POST\")}${TUS_UPLOAD_PATH}`;\n\n const metadataEnvelope = buildUploadMetadataEnvelope(options.metadata);\n\n let cldFileId: string | null = null;\n let aborted = false;\n\n try {\n await new Promise<void>((resolve, reject) => {\n const upload: tus.Upload = new tus.Upload(file, {\n endpoint,\n chunkSize: TUS_CHUNK_SIZE_BYTES,\n retryDelays: [0, 1000, 3000, 5000, 10000],\n removeFingerprintOnSuccess: true,\n urlStorage: deps.urlStorage ?? createTusUrlStorage(),\n ...(deps.httpStack ? { httpStack: deps.httpStack } : {}),\n ...(deps.fileReader ? { fileReader: deps.fileReader } : {}),\n metadata: {\n filename: file.name,\n filepath: filePath,\n // ONE validated JSON envelope — tus-js-client base64-encodes the\n // value per the Upload-Metadata spec; the server parses and merges\n // it exactly like the buffered `metadata_json` form field.\n metadata_json: JSON.stringify(metadataEnvelope),\n ...(options.visibility ? { visibility: options.visibility } : {}),\n },\n onBeforeRequest: async (req) => {\n // FRESH auth on EVERY request — long uploads outlive a JWT.\n //\n // `buildHeaders` (lib/python-client.ts) is mandatory-org, fail-closed:\n // it resolves the organization the SAME way `bindUploadOrganization`\n // in cloudUpload.ts already committed it (that gate dispatches into\n // Redux BEFORE this ever runs — see OrganizationGateDialog's\n // `resolveOrganizationForBlockedAction` — so the selection is already\n // there by the time the first chunk goes out). Until this fix,\n // X-Organization-Id was computed here and then silently dropped —\n // only Authorization and X-Guest-Fingerprint were copied onto the\n // TUS request, so every resumable upload reached the server\n // completely unscoped (aidream commit 8e5ee0b93 now 400s that).\n const { headers } = await buildHeaders({}, false);\n if (headers.Authorization) {\n req.setHeader(\"Authorization\", headers.Authorization);\n }\n if (headers[\"X-Guest-Fingerprint\"]) {\n req.setHeader(\"X-Guest-Fingerprint\", headers[\"X-Guest-Fingerprint\"]);\n }\n if (headers[\"X-Organization-Id\"]) {\n req.setHeader(\"X-Organization-Id\", headers[\"X-Organization-Id\"]);\n }\n if (req.getMethod() === \"POST\") {\n // Creation only — one intended upload, one key.\n req.setHeader(\"X-Idempotency-Key\", idempotencyKey);\n }\n },\n onAfterResponse: (_req, res) => {\n // The final PATCH (and the completed-session HEAD recovery) carry\n // the created file id. Capture it whenever it appears.\n const id = res.getHeader(\"X-Cld-File-Id\");\n if (id) cldFileId = id;\n },\n onProgress: (bytesSent, bytesTotal) => {\n options.onProgress?.({ loaded: bytesSent, total: bytesTotal });\n },\n onSuccess: () => resolve(),\n onError: (error) => reject(error),\n });\n\n if (options.signal) {\n if (options.signal.aborted) {\n aborted = true;\n reject(new Error(\"Upload cancelled\"));\n return;\n }\n options.signal.addEventListener(\n \"abort\",\n () => {\n aborted = true;\n void upload.abort();\n reject(new Error(\"Upload cancelled\"));\n },\n { once: true },\n );\n }\n\n // Resume automatically when a previous session for this file exists —\n // tus-js-client HEADs the stored URL; a completed session resolves via\n // the X-Cld-File-Id captured in onAfterResponse.\n void upload\n .findPreviousUploads()\n .then((previous) => {\n if (previous.length > 0) {\n upload.resumeFromPreviousUpload(previous[0]);\n }\n upload.start();\n })\n .catch((err) => {\n console.warn(\n \"[tusUpload] previous-upload lookup failed — starting fresh:\",\n err,\n );\n upload.start();\n });\n });\n } catch (err) {\n if (cldFileId && !aborted) {\n // Completed-session recovery: the transfer errored (e.g. a lost final\n // response surfaced as an error) but the server told us the file\n // exists. Resolve it instead of failing/re-uploading. LOUD by design.\n console.warn(\n `[tusUpload] transfer reported an error but the server exposed ` +\n `X-Cld-File-Id=${cldFileId} — recovering the completed session.`,\n );\n } else {\n return {\n ok: false,\n error: extractErrorMessage(err),\n errorCode: aborted ? \"upload_cancelled\" : \"tus_upload_failed\",\n fileName: file.name,\n };\n }\n }\n\n if (!cldFileId) {\n return {\n ok: false,\n error:\n \"TUS upload finished but the server never exposed X-Cld-File-Id — \" +\n \"the file cannot be resolved. Check server CORS expose_headers.\",\n errorCode: \"tus_missing_file_id\",\n fileName: file.name,\n };\n }\n\n // Hydrate the canonical row so the result matches the buffered contract.\n try {\n const { data: record } = await Files.getFile(cldFileId);\n return {\n ok: true,\n fileId: record.id,\n filePath: record.file_path,\n fileSize: record.size_bytes ?? file.size,\n versionNumber: record.current_version ?? 1,\n url: null,\n };\n } catch (err) {\n return {\n ok: false,\n error: `TUS upload completed (file ${cldFileId}) but the record fetch failed: ${extractErrorMessage(err)}`,\n errorCode: \"tus_record_fetch_failed\",\n fileName: file.name,\n };\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../../src/files/engine/upload/tusUpload.ts"],"sourcesContent":["/**\n * features/files/upload/tusUpload.ts\n *\n * Resumable (TUS) upload transport for the file handler — the large-file\n * sibling of the buffered multipart path in `cloudUpload.ts`. Callers never\n * import this directly: `cloudUpload` routes here per the transport policy\n * (size ≥ `TUS_TRANSPORT_THRESHOLD_BYTES`, or an explicit\n * `transport: \"tus\"` override).\n *\n * Wire contract (common-docs/systems/media/media-capture/FEATURE.md § TUS):\n * - Endpoint: `${PYTHON_BACKEND}/files/upload/tus` (resolved through the same\n * base-url helper the python-client uses — server toggle respected).\n * - Explicit chunk size 16 MiB (server bounds 8–32 MiB; never the client\n * default).\n * - `Upload-Metadata` carries `filename`, `filepath`, and ONE `metadata_json`\n * key — the base64 of the SAME JSON object the buffered path sends as its\n * `metadata_json` form field (tus-js-client base64-encodes values; the\n * object is built by the shared `buildUploadMetadataEnvelope`). Parity with\n * buffered uploads is a tested invariant.\n * - FRESH Authorization per request (`onBeforeRequest` re-reads the Supabase\n * session — long uploads outlive a single JWT).\n * - `X-Idempotency-Key` on the creation POST only.\n * - Final `X-Cld-File-Id` captured from response headers (final PATCH, or the\n * completed-session HEAD/POST recovery paths) — a lost final response never\n * forces a re-upload.\n * - Resume URLs live in a dedicated tiny browser store (`mtx-tus-urls`, owned by @ai-matrx/data/files/tus) — NEVER\n * the recorder chunk journal DB.\n *\n * NOT LIVE-TESTED against production: the server side (CORS, metadata parity,\n * completed-HEAD recovery) exists in aidream locally but is not deployed —\n * see features/files/handler/FEATURE.md § Transport policy for the pending\n * E2E note. The client is unit-tested with an injected HttpStack.\n */\n\nimport { buildHeaders, resolveBaseUrlForPath } from \"../host/python-client\";\n// eslint-disable-next-line no-restricted-imports -- transport sibling INSIDE the ring-fenced upload internals (same as cloudUpload.ts's own internal imports)\nimport * as Files from \"../api/files\";\nimport { extractErrorMessage } from \"@ai-matrx/data/net\";\nimport {\n tusTransfer,\n TUS_UPLOAD_PATH,\n type TusHttpStack,\n type TusFileReader,\n type TusUrlStorage,\n} from \"@ai-matrx/data/files/tus\";\n// eslint-disable-next-line no-restricted-imports -- type-only import between the two upload transports (both internal to features/files/upload)\nimport type {\n CloudUploadOptions,\n CloudUploadResult,\n} from \"./cloudUpload\";\n\n// The wire (TUS client, resume store, metadata envelope) is @ai-matrx/data/files/tus\n// — architecture step 14 (D1). This module binds the host's headers and base URL and\n// hydrates the canonical row; the names below stay importable from here.\nexport {\n buildUploadMetadataEnvelope,\n createTusUrlStorage,\n listStoredTusUploads,\n TUS_CHUNK_SIZE_BYTES,\n TUS_UPLOAD_PATH,\n type StoredTusUploadSummary,\n} from \"@ai-matrx/data/files/tus\";\n\nexport interface TusUploadDeps {\n /** Injected HttpStack for unit tests (mock wire, no XHR). */\n httpStack?: TusHttpStack;\n /** Injected file reader for unit tests (Node builds of tus-js-client\n * cannot slice browser Files). */\n fileReader?: TusFileReader;\n /** Override the endpoint (tests). */\n endpointOverride?: string;\n /** Override URL storage (tests). */\n urlStorage?: TusUrlStorage;\n}\n\n/**\n * Resumable upload of one file via TUS. Same result contract as\n * `cloudUploadRaw` — `{ ok: true, fileId, ... } | { ok: false, error }`,\n * never throws. The transfer is data's `tusTransfer`; this binds fresh\n * host headers (mandatory-org, fail-closed `buildHeaders`) to every request\n * and hydrates the canonical row so the result matches the buffered contract.\n */\nexport async function tusUploadRaw(\n file: File,\n filePath: string,\n options: CloudUploadOptions,\n idempotencyKey: string,\n deps: TusUploadDeps = {},\n): Promise<CloudUploadResult> {\n const endpoint =\n deps.endpointOverride ??\n `${resolveBaseUrlForPath(TUS_UPLOAD_PATH, undefined, \"POST\")}${TUS_UPLOAD_PATH}`;\n\n const transfer = await tusTransfer(file, {\n endpoint,\n filePath,\n metadata: options.metadata,\n visibility: options.visibility,\n idempotencyKey,\n headers: async () => (await buildHeaders({}, false)).headers,\n onProgress: options.onProgress\n ? (loaded, total) => options.onProgress?.({ loaded, total })\n : undefined,\n signal: options.signal,\n httpStack: deps.httpStack,\n fileReader: deps.fileReader,\n urlStorage: deps.urlStorage,\n });\n if (!transfer.ok) {\n return {\n ok: false,\n error: transfer.error,\n errorCode: transfer.errorCode,\n fileName: file.name,\n };\n }\n const cldFileId = transfer.fileId;\n\n // Hydrate the canonical row so the result matches the buffered contract.\n try {\n const { data: record } = await Files.getFile(cldFileId);\n return {\n ok: true,\n fileId: record.id,\n filePath: record.file_path,\n fileSize: record.size_bytes ?? file.size,\n versionNumber: record.current_version ?? 1,\n url: null,\n };\n } catch (err) {\n return {\n ok: false,\n error: `TUS upload completed (file ${cldFileId}) but the record fetch failed: ${extractErrorMessage(err)}`,\n errorCode: \"tus_record_fetch_failed\",\n fileName: file.name,\n };\n }\n}\n"],"mappings":"AAkCA,SAAS,cAAc,6BAA6B;AAEpD,YAAY,WAAW;AACvB,SAAS,2BAA2B;AACpC;AAAA,EACE;AAAA,EACA;AAAA,OAIK;AAUP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,mBAAAA;AAAA,OAEK;AAqBP,eAAsB,aACpB,MACA,UACA,SACA,gBACA,OAAsB,CAAC,GACK;AAC5B,QAAM,WACJ,KAAK,oBACL,GAAG,sBAAsB,iBAAiB,QAAW,MAAM,CAAC,GAAG,eAAe;AAEhF,QAAM,WAAW,MAAM,YAAY,MAAM;AAAA,IACvC;AAAA,IACA;AAAA,IACA,UAAU,QAAQ;AAAA,IAClB,YAAY,QAAQ;AAAA,IACpB;AAAA,IACA,SAAS,aAAa,MAAM,aAAa,CAAC,GAAG,KAAK,GAAG;AAAA,IACrD,YAAY,QAAQ,aAChB,CAAC,QAAQ,UAAU,QAAQ,aAAa,EAAE,QAAQ,MAAM,CAAC,IACzD;AAAA,IACJ,QAAQ,QAAQ;AAAA,IAChB,WAAW,KAAK;AAAA,IAChB,YAAY,KAAK;AAAA,IACjB,YAAY,KAAK;AAAA,EACnB,CAAC;AACD,MAAI,CAAC,SAAS,IAAI;AAChB,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,SAAS;AAAA,MAChB,WAAW,SAAS;AAAA,MACpB,UAAU,KAAK;AAAA,IACjB;AAAA,EACF;AACA,QAAM,YAAY,SAAS;AAG3B,MAAI;AACF,UAAM,EAAE,MAAM,OAAO,IAAI,MAAM,MAAM,QAAQ,SAAS;AACtD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ,OAAO;AAAA,MACf,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO,cAAc,KAAK;AAAA,MACpC,eAAe,OAAO,mBAAmB;AAAA,MACzC,KAAK;AAAA,IACP;AAAA,EACF,SAAS,KAAK;AACZ,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,8BAA8B,SAAS,kCAAkC,oBAAoB,GAAG,CAAC;AAAA,MACxG,WAAW;AAAA,MACX,UAAU,KAAK;AAAA,IACjB;AAAA,EACF;AACF;","names":["TUS_UPLOAD_PATH"]}
@@ -1,58 +1 @@
1
- /**
2
- * features/files/upload/uploadDedupGuard.ts
3
- *
4
- * Root cause (`common-docs/projects/acquisition-frontier/own-files/VERIFICATION.md`
5
- * §12, 2026-09-18): in a 7-file pile, both zero-byte files were uploaded
6
- * TWICE, 18s apart, the second auto-renamed "(1)" by `uniqueName()` in
7
- * `features/files/redux/thunks.ts` — right after the dev server had refused
8
- * connections. A client-side retry of the whole `uploadFiles()` dispatch
9
- * after a transient failure re-sends a file whose first attempt may already
10
- * have landed server-side (the client only knows its OWN request failed —
11
- * a connection reset gives no proof the server never received the bytes).
12
- *
13
- * The obvious fix is the backend's OWN `X-Idempotency-Key` mechanism
14
- * (`lib/python-client.ts` already sends it, `aidream` already stores it in
15
- * `metadata._idempotency_key`) — but it is not the full answer here: the
16
- * key is only reused *within* a single `uploadFiles()` dispatch
17
- * (`newRequestId()` mints a fresh one per worker() call in thunks.ts), and
18
- * server-side `_replayable_412` in `aidream/aidream/api/routers/files/__init__.py`
19
- * only replays a 412 (precondition-failed) response under the key — a
20
- * SUCCESSFUL upload is never deduped by the server on replay. So a second,
21
- * independent `uploadFiles()` dispatch for the same intended file — the
22
- * shape a caller-level retry takes — is indistinguishable from a genuinely
23
- * new upload to both client and server.
24
- *
25
- * This guard closes the gap client-side, per (folder, name, size): the
26
- * FIRST claim for a signature performs the real network request; any
27
- * SECOND claim for the same signature while that request is still in
28
- * flight, or shortly after it landed, reuses the first attempt's outcome
29
- * instead of sending the bytes again. A claim that ends in failure is
30
- * cleared immediately, so a genuine "it never landed, please retry" case is
31
- * never blocked — only a request whose outcome is still pending, or already
32
- * known-good, is deduped.
33
- */
34
- export interface DedupedUploadOutcome {
35
- fileId: string;
36
- filePath: string;
37
- }
38
- /** The dedup key: same folder, same original name, same byte count. */
39
- export declare function uploadDedupKey(folderPath: string, fileName: string, fileSize: number): string;
40
- export type UploadClaim = {
41
- shared: true;
42
- promise: Promise<DedupedUploadOutcome>;
43
- } | {
44
- shared: false;
45
- /**
46
- * The caller that owns this claim MUST call `settle` exactly once
47
- * with the real upload's outcome (or a rejected promise on failure).
48
- */
49
- settle: (outcome: Promise<DedupedUploadOutcome>) => void;
50
- };
51
- /**
52
- * Claim a signature before starting an upload. See module doc for the
53
- * contract: a `shared: true` result means someone else already owns this
54
- * signature — await its `promise` instead of sending the file again.
55
- */
56
- export declare function claimUpload(key: string): UploadClaim;
57
- /** Test-only: clears every claim so suites don't leak state between tests. */
58
- export declare function __resetUploadDedupGuardForTests(): void;
1
+ export { __resetUploadDedupGuardForTests, claimUpload, uploadDedupKey, type DedupedUploadOutcome, type UploadClaim, } from "@ai-matrx/data/files";
@@ -1,38 +1,8 @@
1
- const CLAIM_WINDOW_MS = 3 * 60 * 1e3;
2
- const claims = /* @__PURE__ */ new Map();
3
- function uploadDedupKey(folderPath, fileName, fileSize) {
4
- return [folderPath, fileName, String(fileSize)].join("\\0");
5
- }
6
- function claimUpload(key) {
7
- const now = Date.now();
8
- const existing = claims.get(key);
9
- if (existing && existing.expiresAt > now) {
10
- return { shared: true, promise: existing.promise };
11
- }
12
- let settleFn;
13
- const publicPromise = new Promise(
14
- (resolve, reject) => {
15
- settleFn = (outcome) => {
16
- outcome.then(
17
- (value) => {
18
- resolve(value);
19
- },
20
- (err) => {
21
- claims.delete(key);
22
- reject(err instanceof Error ? err : new Error(String(err)));
23
- }
24
- );
25
- };
26
- }
27
- );
28
- publicPromise.catch(() => {
29
- });
30
- claims.set(key, { promise: publicPromise, expiresAt: now + CLAIM_WINDOW_MS });
31
- return { shared: false, settle: settleFn };
32
- }
33
- function __resetUploadDedupGuardForTests() {
34
- claims.clear();
35
- }
1
+ import {
2
+ __resetUploadDedupGuardForTests,
3
+ claimUpload,
4
+ uploadDedupKey
5
+ } from "@ai-matrx/data/files";
36
6
  export {
37
7
  __resetUploadDedupGuardForTests,
38
8
  claimUpload,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../src/files/engine/upload/uploadDedupGuard.ts"],"sourcesContent":["/**\n * features/files/upload/uploadDedupGuard.ts\n *\n * Root cause (`common-docs/projects/acquisition-frontier/own-files/VERIFICATION.md`\n * §12, 2026-09-18): in a 7-file pile, both zero-byte files were uploaded\n * TWICE, 18s apart, the second auto-renamed \"(1)\" by `uniqueName()` in\n * `features/files/redux/thunks.ts` — right after the dev server had refused\n * connections. A client-side retry of the whole `uploadFiles()` dispatch\n * after a transient failure re-sends a file whose first attempt may already\n * have landed server-side (the client only knows its OWN request failed —\n * a connection reset gives no proof the server never received the bytes).\n *\n * The obvious fix is the backend's OWN `X-Idempotency-Key` mechanism\n * (`lib/python-client.ts` already sends it, `aidream` already stores it in\n * `metadata._idempotency_key`) — but it is not the full answer here: the\n * key is only reused *within* a single `uploadFiles()` dispatch\n * (`newRequestId()` mints a fresh one per worker() call in thunks.ts), and\n * server-side `_replayable_412` in `aidream/aidream/api/routers/files/__init__.py`\n * only replays a 412 (precondition-failed) response under the key — a\n * SUCCESSFUL upload is never deduped by the server on replay. So a second,\n * independent `uploadFiles()` dispatch for the same intended file — the\n * shape a caller-level retry takes — is indistinguishable from a genuinely\n * new upload to both client and server.\n *\n * This guard closes the gap client-side, per (folder, name, size): the\n * FIRST claim for a signature performs the real network request; any\n * SECOND claim for the same signature while that request is still in\n * flight, or shortly after it landed, reuses the first attempt's outcome\n * instead of sending the bytes again. A claim that ends in failure is\n * cleared immediately, so a genuine \"it never landed, please retry\" case is\n * never blocked — only a request whose outcome is still pending, or already\n * known-good, is deduped.\n */\n\nexport interface DedupedUploadOutcome {\n fileId: string;\n filePath: string;\n}\n\ninterface Claim {\n promise: Promise<DedupedUploadOutcome>;\n expiresAt: number;\n}\n\n/**\n * How long a landed (or in-flight) upload's signature stays claimed. Long\n * enough to cover the 18s gap actually observed between the two identical\n * uploads in the verifier's run, with wide margin for a slower connection.\n */\nconst CLAIM_WINDOW_MS = 3 * 60 * 1000;\n\nconst claims = new Map<string, Claim>();\n\n/** The dedup key: same folder, same original name, same byte count. */\nexport function uploadDedupKey(\n folderPath: string,\n fileName: string,\n fileSize: number,\n): string {\n return [folderPath, fileName, String(fileSize)].join(\"\\\\0\");\n}\n\nexport type UploadClaim =\n | { shared: true; promise: Promise<DedupedUploadOutcome> }\n | {\n shared: false;\n /**\n * The caller that owns this claim MUST call `settle` exactly once\n * with the real upload's outcome (or a rejected promise on failure).\n */\n settle: (outcome: Promise<DedupedUploadOutcome>) => void;\n };\n\n/**\n * Claim a signature before starting an upload. See module doc for the\n * contract: a `shared: true` result means someone else already owns this\n * signature — await its `promise` instead of sending the file again.\n */\nexport function claimUpload(key: string): UploadClaim {\n const now = Date.now();\n const existing = claims.get(key);\n if (existing && existing.expiresAt > now) {\n return { shared: true, promise: existing.promise };\n }\n\n let settleFn!: (outcome: Promise<DedupedUploadOutcome>) => void;\n const publicPromise = new Promise<DedupedUploadOutcome>(\n (resolve, reject) => {\n settleFn = (outcome) => {\n outcome.then(\n (value) => {\n // Success stays claimed for the full window — a landed upload\n // must keep deduping late-arriving retries too.\n resolve(value);\n },\n (err) => {\n // A failed attempt must never haunt a real retry: clear the\n // slot immediately so the next claim for this signature runs\n // for real instead of replaying a failure forever.\n claims.delete(key);\n reject(err instanceof Error ? err : new Error(String(err)));\n },\n );\n };\n },\n );\n // Every claim gets a silent subscriber of its own: a claim nobody ever\n // shares (the common case — most uploads never race a retry) must not\n // trip Node's unhandled-rejection detector when it fails. This does not\n // swallow the failure for a real shared consumer — each `.then()`/`.catch()`\n // on the same promise still sees the rejection independently.\n publicPromise.catch(() => {});\n claims.set(key, { promise: publicPromise, expiresAt: now + CLAIM_WINDOW_MS });\n return { shared: false, settle: settleFn };\n}\n\n/** Test-only: clears every claim so suites don't leak state between tests. */\nexport function __resetUploadDedupGuardForTests(): void {\n claims.clear();\n}\n"],"mappings":"AAiDA,MAAM,kBAAkB,IAAI,KAAK;AAEjC,MAAM,SAAS,oBAAI,IAAmB;AAG/B,SAAS,eACd,YACA,UACA,UACQ;AACR,SAAO,CAAC,YAAY,UAAU,OAAO,QAAQ,CAAC,EAAE,KAAK,KAAK;AAC5D;AAkBO,SAAS,YAAY,KAA0B;AACpD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,WAAW,OAAO,IAAI,GAAG;AAC/B,MAAI,YAAY,SAAS,YAAY,KAAK;AACxC,WAAO,EAAE,QAAQ,MAAM,SAAS,SAAS,QAAQ;AAAA,EACnD;AAEA,MAAI;AACJ,QAAM,gBAAgB,IAAI;AAAA,IACxB,CAAC,SAAS,WAAW;AACnB,iBAAW,CAAC,YAAY;AACtB,gBAAQ;AAAA,UACN,CAAC,UAAU;AAGT,oBAAQ,KAAK;AAAA,UACf;AAAA,UACA,CAAC,QAAQ;AAIP,mBAAO,OAAO,GAAG;AACjB,mBAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC,CAAC;AAAA,UAC5D;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAMA,gBAAc,MAAM,MAAM;AAAA,EAAC,CAAC;AAC5B,SAAO,IAAI,KAAK,EAAE,SAAS,eAAe,WAAW,MAAM,gBAAgB,CAAC;AAC5E,SAAO,EAAE,QAAQ,OAAO,QAAQ,SAAS;AAC3C;AAGO,SAAS,kCAAwC;AACtD,SAAO,MAAM;AACf;","names":[]}
1
+ {"version":3,"sources":["../../../../src/files/engine/upload/uploadDedupGuard.ts"],"sourcesContent":["// The upload dedup guard lives in @ai-matrx/data/files (architecture step 14, D1:\n// byte transport and its dedup are data's). Re-exported so engine importers keep working.\nexport {\n __resetUploadDedupGuardForTests,\n claimUpload,\n uploadDedupKey,\n type DedupedUploadOutcome,\n type UploadClaim,\n} from \"@ai-matrx/data/files\";\n"],"mappings":"AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAGK;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-matrx/media",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The AI Matrx media components kit: durable-ref-only renderers for images, video, audio and file thumbnails plus live TTS playback that just work with the Matrx file system — headless core hooks plus DOM bindings, wired to an injected MediaClient. A signed URL is a handoff, never an identity.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -757,10 +757,9 @@
757
757
  "lucide-react": "^1.48.0",
758
758
  "reselect": "^5.1.0",
759
759
  "tailwind-merge": "^3.7.0",
760
- "tus-js-client": "^4.3.1",
761
760
  "@ai-matrx/agents": "latest",
762
- "@ai-matrx/data": "latest",
763
761
  "@ai-matrx/associations": "latest",
762
+ "@ai-matrx/data": "latest",
764
763
  "@ai-matrx/design-system": "latest",
765
764
  "@ai-matrx/kit": "latest",
766
765
  "@ai-matrx/realtime": "latest"