@ai-matrx/media 0.10.1 → 0.10.3
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 +4 -0
- package/dist/files/engine/cache/register-service-worker.js +1 -1
- package/dist/files/engine/cache/register-service-worker.js.map +1 -1
- package/dist/files/engine/redux/thunks.js +1 -1
- package/dist/files/engine/redux/thunks.js.map +1 -1
- package/dist/files/engine/support/logger.js +1 -1
- package/dist/files/engine/support/logger.js.map +1 -1
- package/dist/files/engine/upload/tusUpload.js +1 -1
- package/dist/files/engine/upload/tusUpload.js.map +1 -1
- package/dist/files/engine/upload/uploadGuardOpeners.js +1 -1
- package/dist/files/engine/upload/uploadGuardOpeners.js.map +1 -1
- package/package.json +248 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const DEBUG_FLAG_KEY = "matrx:sync:debug";
|
|
2
2
|
const DEBUG_URL_PARAM = "sync-debug";
|
|
3
3
|
function isDebugEnabled() {
|
|
4
|
-
if (
|
|
4
|
+
if (process.env.NODE_ENV !== "production") return true;
|
|
5
5
|
if (typeof window === "undefined") return false;
|
|
6
6
|
try {
|
|
7
7
|
if (window.localStorage?.getItem(DEBUG_FLAG_KEY) === "1") return true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/files/engine/support/logger.ts"],"sourcesContent":["/**\n * lib/sync/logger.ts\n *\n * Single prefixed logger for the sync engine. Silent in production unless\n * `?sync-debug=1` is in the URL or `localStorage.getItem('matrx:sync:debug') === '1'`.\n *\n * Phase 12 expands this into the debug-panel feed + observability hooks (Q8).\n */\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\ninterface LogEvent {\n event: string;\n sliceName?: string;\n ms?: number;\n meta?: Record<string, unknown>;\n}\n\nconst DEBUG_FLAG_KEY = \"matrx:sync:debug\";\nconst DEBUG_URL_PARAM = \"sync-debug\";\n\nfunction isDebugEnabled(): boolean {\n if (process.env.NODE_ENV !== \"production\") return true;\n if (typeof window === \"undefined\") return false;\n try {\n if (window.localStorage?.getItem(DEBUG_FLAG_KEY) === \"1\") return true;\n const params = new URLSearchParams(window.location.search);\n return params.get(DEBUG_URL_PARAM) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction emit(level: LogLevel, evt: LogEvent): void {\n if (!isDebugEnabled() && level !== \"error\") return;\n const line = `[sync] ${evt.event}`;\n const payload: Record<string, unknown> = {};\n if (evt.sliceName) payload.sliceName = evt.sliceName;\n if (evt.ms != null) payload.ms = evt.ms;\n if (evt.meta) Object.assign(payload, evt.meta);\n const hasPayload = Object.keys(payload).length > 0;\n // eslint-disable-next-line no-console\n const fn = console[level] ?? console.log;\n hasPayload ? fn(line, payload) : fn(line);\n}\n\nexport const logger = {\n debug: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"debug\", { event, ...meta }),\n info: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"info\", { event, ...meta }),\n warn: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"warn\", { event, ...meta }),\n error: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"error\", { event, ...meta }),\n};\n\n/** Visible for tests only. */\nexport const __internal = { DEBUG_FLAG_KEY, DEBUG_URL_PARAM, isDebugEnabled };\n"],"mappings":"AAkBA,MAAM,iBAAiB;AACvB,MAAM,kBAAkB;AAExB,SAAS,iBAA0B;AAC/B,MAAI,
|
|
1
|
+
{"version":3,"sources":["../../../../src/files/engine/support/logger.ts"],"sourcesContent":["/**\n * lib/sync/logger.ts\n *\n * Single prefixed logger for the sync engine. Silent in production unless\n * `?sync-debug=1` is in the URL or `localStorage.getItem('matrx:sync:debug') === '1'`.\n *\n * Phase 12 expands this into the debug-panel feed + observability hooks (Q8).\n */\n\ntype LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\ninterface LogEvent {\n event: string;\n sliceName?: string;\n ms?: number;\n meta?: Record<string, unknown>;\n}\n\nconst DEBUG_FLAG_KEY = \"matrx:sync:debug\";\nconst DEBUG_URL_PARAM = \"sync-debug\";\n\nfunction isDebugEnabled(): boolean {\n if (process.env.NODE_ENV !== \"production\") return true;\n if (typeof window === \"undefined\") return false;\n try {\n if (window.localStorage?.getItem(DEBUG_FLAG_KEY) === \"1\") return true;\n const params = new URLSearchParams(window.location.search);\n return params.get(DEBUG_URL_PARAM) === \"1\";\n } catch {\n return false;\n }\n}\n\nfunction emit(level: LogLevel, evt: LogEvent): void {\n if (!isDebugEnabled() && level !== \"error\") return;\n const line = `[sync] ${evt.event}`;\n const payload: Record<string, unknown> = {};\n if (evt.sliceName) payload.sliceName = evt.sliceName;\n if (evt.ms != null) payload.ms = evt.ms;\n if (evt.meta) Object.assign(payload, evt.meta);\n const hasPayload = Object.keys(payload).length > 0;\n // eslint-disable-next-line no-console\n const fn = console[level] ?? console.log;\n hasPayload ? fn(line, payload) : fn(line);\n}\n\nexport const logger = {\n debug: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"debug\", { event, ...meta }),\n info: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"info\", { event, ...meta }),\n warn: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"warn\", { event, ...meta }),\n error: (event: string, meta?: Omit<LogEvent, \"event\">) => emit(\"error\", { event, ...meta }),\n};\n\n/** Visible for tests only. */\nexport const __internal = { DEBUG_FLAG_KEY, DEBUG_URL_PARAM, isDebugEnabled };\n"],"mappings":"AAkBA,MAAM,iBAAiB;AACvB,MAAM,kBAAkB;AAExB,SAAS,iBAA0B;AAC/B,MAAI,yBAAyB,aAAc,QAAO;AAClD,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,MAAI;AACA,QAAI,OAAO,cAAc,QAAQ,cAAc,MAAM,IAAK,QAAO;AACjE,UAAM,SAAS,IAAI,gBAAgB,OAAO,SAAS,MAAM;AACzD,WAAO,OAAO,IAAI,eAAe,MAAM;AAAA,EAC3C,QAAQ;AACJ,WAAO;AAAA,EACX;AACJ;AAEA,SAAS,KAAK,OAAiB,KAAqB;AAChD,MAAI,CAAC,eAAe,KAAK,UAAU,QAAS;AAC5C,QAAM,OAAO,UAAU,IAAI,KAAK;AAChC,QAAM,UAAmC,CAAC;AAC1C,MAAI,IAAI,UAAW,SAAQ,YAAY,IAAI;AAC3C,MAAI,IAAI,MAAM,KAAM,SAAQ,KAAK,IAAI;AACrC,MAAI,IAAI,KAAM,QAAO,OAAO,SAAS,IAAI,IAAI;AAC7C,QAAM,aAAa,OAAO,KAAK,OAAO,EAAE,SAAS;AAEjD,QAAM,KAAK,QAAQ,KAAK,KAAK,QAAQ;AACrC,eAAa,GAAG,MAAM,OAAO,IAAI,GAAG,IAAI;AAC5C;AAEO,MAAM,SAAS;AAAA,EAClB,OAAO,CAAC,OAAe,SAAmC,KAAK,SAAS,EAAE,OAAO,GAAG,KAAK,CAAC;AAAA,EAC1F,MAAM,CAAC,OAAe,SAAmC,KAAK,QAAQ,EAAE,OAAO,GAAG,KAAK,CAAC;AAAA,EACxF,MAAM,CAAC,OAAe,SAAmC,KAAK,QAAQ,EAAE,OAAO,GAAG,KAAK,CAAC;AAAA,EACxF,OAAO,CAAC,OAAe,SAAmC,KAAK,SAAS,EAAE,OAAO,GAAG,KAAK,CAAC;AAC9F;AAGO,MAAM,aAAa,EAAE,gBAAgB,iBAAiB,eAAe;","names":[]}
|
|
@@ -10,7 +10,7 @@ function buildUploadMetadataEnvelope(metadata) {
|
|
|
10
10
|
origin: "cloudUpload",
|
|
11
11
|
...metadata ?? {}
|
|
12
12
|
};
|
|
13
|
-
if (!hasUploadOrganization(envelope)) {
|
|
13
|
+
if (process.env.NODE_ENV !== "production" && !hasUploadOrganization(envelope)) {
|
|
14
14
|
console.warn(
|
|
15
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
16
|
);
|
|
@@ -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 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":[]}
|
|
@@ -9,7 +9,7 @@ async function requestUpload(arg) {
|
|
|
9
9
|
if (registered) {
|
|
10
10
|
return registered(arg);
|
|
11
11
|
}
|
|
12
|
-
if (
|
|
12
|
+
if (process.env.NODE_ENV !== "production") {
|
|
13
13
|
console.warn(
|
|
14
14
|
"[upload-guard] requestUpload called before <UploadGuardHost/> mounted \u2014 falling back to direct dispatch with no duplicate check."
|
|
15
15
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/files/engine/upload/uploadGuardOpeners.ts"],"sourcesContent":["/**\n * features/files/upload/uploadGuardOpeners.ts\n *\n * Module-level imperative API for the upload-guard system. Same shape\n * as `cloudFilesPickerOpeners` — a single host component registers a\n * handler at mount, and any code anywhere can call `requestUpload`\n * without threading state.\n *\n * The host pre-flights uploaded files for duplicates (identical\n * checksum or filename collisions in the target folder), shows a\n * resolution dialog if needed, and only then dispatches the upload.\n * If the host hasn't mounted yet, `requestUpload` warns in dev and\n * falls back to a direct thunk dispatch so we don't break the upload\n * for the (rare) caller that fires during boot.\n *\n * Pure TypeScript — no React, no dialog markup. Safe to import from\n * anywhere, including the static graph of route entries.\n */\n\nimport type { UploadFilesArg } from \"../types\";\nimport type { AppDispatch } from \"../host/store\";\n\n/**\n * The handler the host registers at mount. Returns a promise that\n * resolves with the same shape as the underlying `uploadFiles` thunk\n * payload — `{ uploaded: string[], failed: [...] }`.\n */\nexport type UploadGuardHandler = (\n arg: UploadFilesArg,\n) => Promise<UploadGuardResult>;\n\nexport interface UploadGuardResult {\n uploaded: string[];\n failed: Array<{ name: string; error: string }>;\n /** True when the user dismissed the duplicate dialog. */\n cancelled: boolean;\n /**\n * Files the user chose to NOT re-upload because a duplicate already\n * exists, and they explicitly picked \"use the existing one.\" Each\n * entry maps the index of the original input file (in `arg.files`)\n * to the existing `cld_files.id` the user picked.\n *\n * The chat-resource-attach flow uses this to wire the existing\n * fileId into its parent context (e.g. attach the existing PDF to\n * the conversation) so the user gets a confirmed-and-attached file\n * with zero wasted bytes. Other callers can iterate `aliased` to\n * surface \"found N existing matches\" UI.\n *\n * Empty for the normal \"no duplicates\" upload path.\n */\n aliased: Array<{ inputIndex: number; existingFileId: string }>;\n}\n\nlet registered: UploadGuardHandler | null = null;\n\n/** Called once by the host's `useEffect` mount. */\nexport function registerUploadGuardHandler(handler: UploadGuardHandler): void {\n registered = handler;\n}\n\n/** Called by the host's cleanup. */\nexport function unregisterUploadGuardHandler(\n handler: UploadGuardHandler,\n): void {\n if (registered === handler) registered = null;\n}\n\n/**\n * Public entry point. Call from any UI surface that initiates an\n * upload (drag-drop, paste, FAB, picker). Returns the same shape as\n * `uploadFiles.unwrap()` plus a `cancelled` flag.\n *\n * Behaviour:\n * - If the host is mounted: pre-flight scan → optional dialog →\n * dispatch with the user's choices. Resolves when uploads finish\n * or when the user cancels (`cancelled: true`, no dispatch).\n * - If the host is NOT mounted: dispatches directly with no\n * duplicate check, so old call paths keep working. Logs a dev\n * warning so the missing host gets noticed.\n */\nexport async function requestUpload(\n arg: UploadFilesArg,\n): Promise<UploadGuardResult> {\n if (registered) {\n return registered(arg);\n }\n // Fallback path — should rarely fire in practice. Imported lazily\n // to keep this opener module dispatch-free at module load.\n if (process.env.NODE_ENV !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n \"[upload-guard] requestUpload called before <UploadGuardHost/> mounted — falling back to direct dispatch with no duplicate check.\",\n );\n }\n const [{ getStore }, { uploadFiles }] = await Promise.all([\n import(\"../host/store\"),\n import(\"../redux/thunks\"),\n ]);\n const store = getStore();\n if (!store) {\n return {\n uploaded: [],\n failed: arg.files.map((f) => ({\n name: f.name,\n error: \"Store not ready\",\n })),\n cancelled: false,\n aliased: [],\n };\n }\n try {\n const dispatch = store.dispatch as AppDispatch;\n const result = await dispatch(uploadFiles(arg)).unwrap();\n return { ...result, cancelled: false, aliased: [] };\n } catch (err) {\n return {\n uploaded: [],\n failed: arg.files.map((f) => ({\n name: f.name,\n error: err instanceof Error ? err.message : String(err),\n })),\n cancelled: false,\n aliased: [],\n };\n }\n}\n"],"mappings":"AAqDA,IAAI,aAAwC;AAGrC,SAAS,2BAA2B,SAAmC;AAC5E,eAAa;AACf;AAGO,SAAS,6BACd,SACM;AACN,MAAI,eAAe,QAAS,cAAa;AAC3C;AAeA,eAAsB,cACpB,KAC4B;AAC5B,MAAI,YAAY;AACd,WAAO,WAAW,GAAG;AAAA,EACvB;AAGA,MAAI,
|
|
1
|
+
{"version":3,"sources":["../../../../src/files/engine/upload/uploadGuardOpeners.ts"],"sourcesContent":["/**\n * features/files/upload/uploadGuardOpeners.ts\n *\n * Module-level imperative API for the upload-guard system. Same shape\n * as `cloudFilesPickerOpeners` — a single host component registers a\n * handler at mount, and any code anywhere can call `requestUpload`\n * without threading state.\n *\n * The host pre-flights uploaded files for duplicates (identical\n * checksum or filename collisions in the target folder), shows a\n * resolution dialog if needed, and only then dispatches the upload.\n * If the host hasn't mounted yet, `requestUpload` warns in dev and\n * falls back to a direct thunk dispatch so we don't break the upload\n * for the (rare) caller that fires during boot.\n *\n * Pure TypeScript — no React, no dialog markup. Safe to import from\n * anywhere, including the static graph of route entries.\n */\n\nimport type { UploadFilesArg } from \"../types\";\nimport type { AppDispatch } from \"../host/store\";\n\n/**\n * The handler the host registers at mount. Returns a promise that\n * resolves with the same shape as the underlying `uploadFiles` thunk\n * payload — `{ uploaded: string[], failed: [...] }`.\n */\nexport type UploadGuardHandler = (\n arg: UploadFilesArg,\n) => Promise<UploadGuardResult>;\n\nexport interface UploadGuardResult {\n uploaded: string[];\n failed: Array<{ name: string; error: string }>;\n /** True when the user dismissed the duplicate dialog. */\n cancelled: boolean;\n /**\n * Files the user chose to NOT re-upload because a duplicate already\n * exists, and they explicitly picked \"use the existing one.\" Each\n * entry maps the index of the original input file (in `arg.files`)\n * to the existing `cld_files.id` the user picked.\n *\n * The chat-resource-attach flow uses this to wire the existing\n * fileId into its parent context (e.g. attach the existing PDF to\n * the conversation) so the user gets a confirmed-and-attached file\n * with zero wasted bytes. Other callers can iterate `aliased` to\n * surface \"found N existing matches\" UI.\n *\n * Empty for the normal \"no duplicates\" upload path.\n */\n aliased: Array<{ inputIndex: number; existingFileId: string }>;\n}\n\nlet registered: UploadGuardHandler | null = null;\n\n/** Called once by the host's `useEffect` mount. */\nexport function registerUploadGuardHandler(handler: UploadGuardHandler): void {\n registered = handler;\n}\n\n/** Called by the host's cleanup. */\nexport function unregisterUploadGuardHandler(\n handler: UploadGuardHandler,\n): void {\n if (registered === handler) registered = null;\n}\n\n/**\n * Public entry point. Call from any UI surface that initiates an\n * upload (drag-drop, paste, FAB, picker). Returns the same shape as\n * `uploadFiles.unwrap()` plus a `cancelled` flag.\n *\n * Behaviour:\n * - If the host is mounted: pre-flight scan → optional dialog →\n * dispatch with the user's choices. Resolves when uploads finish\n * or when the user cancels (`cancelled: true`, no dispatch).\n * - If the host is NOT mounted: dispatches directly with no\n * duplicate check, so old call paths keep working. Logs a dev\n * warning so the missing host gets noticed.\n */\nexport async function requestUpload(\n arg: UploadFilesArg,\n): Promise<UploadGuardResult> {\n if (registered) {\n return registered(arg);\n }\n // Fallback path — should rarely fire in practice. Imported lazily\n // to keep this opener module dispatch-free at module load.\n if (process.env.NODE_ENV !== \"production\") {\n // eslint-disable-next-line no-console\n console.warn(\n \"[upload-guard] requestUpload called before <UploadGuardHost/> mounted — falling back to direct dispatch with no duplicate check.\",\n );\n }\n const [{ getStore }, { uploadFiles }] = await Promise.all([\n import(\"../host/store\"),\n import(\"../redux/thunks\"),\n ]);\n const store = getStore();\n if (!store) {\n return {\n uploaded: [],\n failed: arg.files.map((f) => ({\n name: f.name,\n error: \"Store not ready\",\n })),\n cancelled: false,\n aliased: [],\n };\n }\n try {\n const dispatch = store.dispatch as AppDispatch;\n const result = await dispatch(uploadFiles(arg)).unwrap();\n return { ...result, cancelled: false, aliased: [] };\n } catch (err) {\n return {\n uploaded: [],\n failed: arg.files.map((f) => ({\n name: f.name,\n error: err instanceof Error ? err.message : String(err),\n })),\n cancelled: false,\n aliased: [],\n };\n }\n}\n"],"mappings":"AAqDA,IAAI,aAAwC;AAGrC,SAAS,2BAA2B,SAAmC;AAC5E,eAAa;AACf;AAGO,SAAS,6BACd,SACM;AACN,MAAI,eAAe,QAAS,cAAa;AAC3C;AAeA,eAAsB,cACpB,KAC4B;AAC5B,MAAI,YAAY;AACd,WAAO,WAAW,GAAG;AAAA,EACvB;AAGA,MAAI,yBAAyB,cAAc;AAEzC,YAAQ;AAAA,MACN;AAAA,IACF;AAAA,EACF;AACA,QAAM,CAAC,EAAE,SAAS,GAAG,EAAE,YAAY,CAAC,IAAI,MAAM,QAAQ,IAAI;AAAA,IACxD,OAAO,eAAe;AAAA,IACtB,OAAO,iBAAiB;AAAA,EAC1B,CAAC;AACD,QAAM,QAAQ,SAAS;AACvB,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,MACL,UAAU,CAAC;AAAA,MACX,QAAQ,IAAI,MAAM,IAAI,CAAC,OAAO;AAAA,QAC5B,MAAM,EAAE;AAAA,QACR,OAAO;AAAA,MACT,EAAE;AAAA,MACF,WAAW;AAAA,MACX,SAAS,CAAC;AAAA,IACZ;AAAA,EACF;AACA,MAAI;AACF,UAAM,WAAW,MAAM;AACvB,UAAM,SAAS,MAAM,SAAS,YAAY,GAAG,CAAC,EAAE,OAAO;AACvD,WAAO,EAAE,GAAG,QAAQ,WAAW,OAAO,SAAS,CAAC,EAAE;AAAA,EACpD,SAAS,KAAK;AACZ,WAAO;AAAA,MACL,UAAU,CAAC;AAAA,MACX,QAAQ,IAAI,MAAM,IAAI,CAAC,OAAO;AAAA,QAC5B,MAAM,EAAE;AAAA,QACR,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,MACxD,EAAE;AAAA,MACF,WAAW;AAAA,MACX,SAAS,CAAC;AAAA,IACZ;AAAA,EACF;AACF;","names":[]}
|