@ai-matrx/media 0.11.0 → 0.13.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 +22 -0
- package/dist/files/engine/db-types.d.ts +3 -50128
- package/dist/files/engine/filesDb.d.ts +168 -168
- package/dist/files/engine/handler/utils/python-base.d.ts +2 -13
- package/dist/files/engine/handler/utils/python-base.js +2 -2
- package/dist/files/engine/handler/utils/python-base.js.map +1 -1
- package/dist/files/engine/host/supabase.d.ts +231 -214
- package/dist/files/engine/upload/tusUpload.d.ts +9 -54
- package/dist/files/engine/upload/tusUpload.js +29 -221
- package/dist/files/engine/upload/tusUpload.js.map +1 -1
- package/dist/files/engine/upload/uploadDedupGuard.d.ts +1 -58
- package/dist/files/engine/upload/uploadDedupGuard.js +5 -35
- package/dist/files/engine/upload/uploadDedupGuard.js.map +1 -1
- package/package.json +5 -6
|
@@ -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
|
|
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
|
|
34
|
+
import { type TusHttpStack, type TusFileReader, type TusUrlStorage } from "@ai-matrx/data/files/tus";
|
|
35
35
|
import type { CloudUploadOptions, CloudUploadResult } from "./cloudUpload.js";
|
|
36
|
-
|
|
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?:
|
|
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?:
|
|
42
|
+
fileReader?: TusFileReader;
|
|
87
43
|
/** Override the endpoint (tests). */
|
|
88
44
|
endpointOverride?: string;
|
|
89
45
|
/** Override URL storage (tests). */
|
|
90
|
-
urlStorage?:
|
|
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.
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
227
|
-
errorCode:
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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":["
|
|
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.
|
|
3
|
+
"version": "0.13.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,13 +757,12 @@
|
|
|
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
|
-
"@ai-matrx/agents": "latest",
|
|
762
760
|
"@ai-matrx/data": "latest",
|
|
763
|
-
"@ai-matrx/associations": "latest",
|
|
764
761
|
"@ai-matrx/design-system": "latest",
|
|
765
762
|
"@ai-matrx/kit": "latest",
|
|
766
|
-
"@ai-matrx/realtime": "latest"
|
|
763
|
+
"@ai-matrx/realtime": "latest",
|
|
764
|
+
"@ai-matrx/agents": "latest",
|
|
765
|
+
"@ai-matrx/associations": "latest"
|
|
767
766
|
},
|
|
768
767
|
"peerDependencies": {
|
|
769
768
|
"@reduxjs/toolkit": ">=2.0.0",
|
|
@@ -833,6 +832,6 @@
|
|
|
833
832
|
"test": "vitest run",
|
|
834
833
|
"test:watch": "vitest",
|
|
835
834
|
"verify:tarball": "node ./scripts/verify-tarball.mjs",
|
|
836
|
-
"db-types": "node
|
|
835
|
+
"db-types": "node ../data/scripts/generate-db-types.mjs"
|
|
837
836
|
}
|
|
838
837
|
}
|