@ai-matrx/media 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/files/engine/api/assets.d.ts +223 -0
- package/dist/files/engine/api/assets.js +124 -0
- package/dist/files/engine/api/assets.js.map +1 -0
- package/dist/files/engine/api/direct.d.ts +43 -0
- package/dist/files/engine/api/direct.js +73 -0
- package/dist/files/engine/api/direct.js.map +1 -0
- package/dist/files/engine/api/fileOrganization.d.ts +34 -0
- package/dist/files/engine/api/fileOrganization.js +38 -0
- package/dist/files/engine/api/fileOrganization.js.map +1 -0
- package/dist/files/engine/api/files.d.ts +249 -0
- package/dist/files/engine/api/files.js +231 -0
- package/dist/files/engine/api/files.js.map +1 -0
- package/dist/files/engine/api/folders.d.ts +63 -0
- package/dist/files/engine/api/folders.js +56 -0
- package/dist/files/engine/api/folders.js.map +1 -0
- package/dist/files/engine/api/office.d.ts +27 -0
- package/dist/files/engine/api/office.js +19 -0
- package/dist/files/engine/api/office.js.map +1 -0
- package/dist/files/engine/api/permissions.d.ts +37 -0
- package/dist/files/engine/api/permissions.js +109 -0
- package/dist/files/engine/api/permissions.js.map +1 -0
- package/dist/files/engine/api/versions.d.ts +26 -0
- package/dist/files/engine/api/versions.js +37 -0
- package/dist/files/engine/api/versions.js.map +1 -0
- package/dist/files/engine/cache/idb-store.d.ts +95 -0
- package/dist/files/engine/cache/idb-store.js +172 -0
- package/dist/files/engine/cache/idb-store.js.map +1 -0
- package/dist/files/engine/cache/policy.d.ts +14 -0
- package/dist/files/engine/cache/policy.js +40 -0
- package/dist/files/engine/cache/policy.js.map +1 -0
- package/dist/files/engine/cache/register-service-worker.d.ts +57 -0
- package/dist/files/engine/cache/register-service-worker.js +80 -0
- package/dist/files/engine/cache/register-service-worker.js.map +1 -0
- package/dist/files/engine/db-types.d.ts +48865 -0
- package/dist/files/engine/db-types.js +1 -0
- package/dist/files/engine/db-types.js.map +1 -0
- package/dist/files/engine/filesDb.d.ts +1913 -0
- package/dist/files/engine/filesDb.js +28 -0
- package/dist/files/engine/filesDb.js.map +1 -0
- package/dist/files/engine/handler/handler.d.ts +82 -0
- package/dist/files/engine/handler/handler.js +124 -0
- package/dist/files/engine/handler/handler.js.map +1 -0
- package/dist/files/engine/handler/hooks/useFile.d.ts +17 -0
- package/dist/files/engine/handler/hooks/useFile.js +75 -0
- package/dist/files/engine/handler/hooks/useFile.js.map +1 -0
- package/dist/files/engine/handler/hooks/useFileUpload.d.ts +76 -0
- package/dist/files/engine/handler/hooks/useFileUpload.js +67 -0
- package/dist/files/engine/handler/hooks/useFileUpload.js.map +1 -0
- package/dist/files/engine/handler/input/normalize.d.ts +14 -0
- package/dist/files/engine/handler/input/normalize.js +365 -0
- package/dist/files/engine/handler/input/normalize.js.map +1 -0
- package/dist/files/engine/handler/intelligence/access.d.ts +35 -0
- package/dist/files/engine/handler/intelligence/access.js +85 -0
- package/dist/files/engine/handler/intelligence/access.js.map +1 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.d.ts +21 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.js +67 -0
- package/dist/files/engine/handler/intelligence/magic-bytes.js.map +1 -0
- package/dist/files/engine/handler/output/target.d.ts +19 -0
- package/dist/files/engine/handler/output/target.js +222 -0
- package/dist/files/engine/handler/output/target.js.map +1 -0
- package/dist/files/engine/handler/resolver.d.ts +34 -0
- package/dist/files/engine/handler/resolver.js +133 -0
- package/dist/files/engine/handler/resolver.js.map +1 -0
- package/dist/files/engine/handler/types.d.ts +367 -0
- package/dist/files/engine/handler/types.js +1 -0
- package/dist/files/engine/handler/types.js.map +1 -0
- package/dist/files/engine/handler/upload.d.ts +32 -0
- package/dist/files/engine/handler/upload.js +295 -0
- package/dist/files/engine/handler/upload.js.map +1 -0
- package/dist/files/engine/handler/utils/classify.d.ts +14 -0
- package/dist/files/engine/handler/utils/classify.js +27 -0
- package/dist/files/engine/handler/utils/classify.js.map +1 -0
- package/dist/files/engine/handler/utils/prefer-locator.d.ts +32 -0
- package/dist/files/engine/handler/utils/prefer-locator.js +34 -0
- package/dist/files/engine/handler/utils/prefer-locator.js.map +1 -0
- package/dist/files/engine/handler/utils/python-base.d.ts +129 -0
- package/dist/files/engine/handler/utils/python-base.js +60 -0
- package/dist/files/engine/handler/utils/python-base.js.map +1 -0
- package/dist/files/engine/hooks/blob-cache.d.ts +130 -0
- package/dist/files/engine/hooks/blob-cache.js +162 -0
- package/dist/files/engine/hooks/blob-cache.js.map +1 -0
- package/dist/files/engine/hooks/office-extraction-cache.d.ts +28 -0
- package/dist/files/engine/hooks/office-extraction-cache.js +67 -0
- package/dist/files/engine/hooks/office-extraction-cache.js.map +1 -0
- package/dist/files/engine/host/configure.d.ts +166 -0
- package/dist/files/engine/host/configure.js +31 -0
- package/dist/files/engine/host/configure.js.map +1 -0
- package/dist/files/engine/host/org.d.ts +8 -0
- package/dist/files/engine/host/org.js +16 -0
- package/dist/files/engine/host/org.js.map +1 -0
- package/dist/files/engine/host/python-client.d.ts +60 -0
- package/dist/files/engine/host/python-client.js +46 -0
- package/dist/files/engine/host/python-client.js.map +1 -0
- package/dist/files/engine/host/share-links.d.ts +20 -0
- package/dist/files/engine/host/share-links.js +32 -0
- package/dist/files/engine/host/share-links.js.map +1 -0
- package/dist/files/engine/host/store.d.ts +24 -0
- package/dist/files/engine/host/store.js +23 -0
- package/dist/files/engine/host/store.js.map +1 -0
- package/dist/files/engine/host/supabase.d.ts +3808 -0
- package/dist/files/engine/host/supabase.js +33 -0
- package/dist/files/engine/host/supabase.js.map +1 -0
- package/dist/files/engine/host/toast.d.ts +14 -0
- package/dist/files/engine/host/toast.js +20 -0
- package/dist/files/engine/host/toast.js.map +1 -0
- package/dist/files/engine/host/typed-client.d.ts +136 -0
- package/dist/files/engine/host/typed-client.js +66 -0
- package/dist/files/engine/host/typed-client.js.map +1 -0
- package/dist/files/engine/index.d.ts +22 -0
- package/dist/files/engine/index.js +49 -0
- package/dist/files/engine/index.js.map +1 -0
- package/dist/files/engine/media/our-file-sources.d.ts +39 -0
- package/dist/files/engine/media/our-file-sources.js +83 -0
- package/dist/files/engine/media/our-file-sources.js.map +1 -0
- package/dist/files/engine/media/signed-url.d.ts +1 -0
- package/dist/files/engine/media/signed-url.js +6 -0
- package/dist/files/engine/media/signed-url.js.map +1 -0
- package/dist/files/engine/redux/converters.d.ts +73 -0
- package/dist/files/engine/redux/converters.js +286 -0
- package/dist/files/engine/redux/converters.js.map +1 -0
- package/dist/files/engine/redux/file-hydration.d.ts +15 -0
- package/dist/files/engine/redux/file-hydration.js +46 -0
- package/dist/files/engine/redux/file-hydration.js.map +1 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.d.ts +5 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.js +28 -0
- package/dist/files/engine/redux/file-tree-auth-boundary.js.map +1 -0
- package/dist/files/engine/redux/file-tree-timeout.d.ts +32 -0
- package/dist/files/engine/redux/file-tree-timeout.js +73 -0
- package/dist/files/engine/redux/file-tree-timeout.js.map +1 -0
- package/dist/files/engine/redux/mutation-toast-middleware.d.ts +27 -0
- package/dist/files/engine/redux/mutation-toast-middleware.js +103 -0
- package/dist/files/engine/redux/mutation-toast-middleware.js.map +1 -0
- package/dist/files/engine/redux/realtime-middleware.d.ts +61 -0
- package/dist/files/engine/redux/realtime-middleware.js +431 -0
- package/dist/files/engine/redux/realtime-middleware.js.map +1 -0
- package/dist/files/engine/redux/request-ledger.d.ts +68 -0
- package/dist/files/engine/redux/request-ledger.js +66 -0
- package/dist/files/engine/redux/request-ledger.js.map +1 -0
- package/dist/files/engine/redux/selectors.d.ts +3254 -0
- package/dist/files/engine/redux/selectors.js +428 -0
- package/dist/files/engine/redux/selectors.js.map +1 -0
- package/dist/files/engine/redux/slice.d.ts +157 -0
- package/dist/files/engine/redux/slice.js +836 -0
- package/dist/files/engine/redux/slice.js.map +1 -0
- package/dist/files/engine/redux/thunks.d.ts +449 -0
- package/dist/files/engine/redux/thunks.js +1684 -0
- package/dist/files/engine/redux/thunks.js.map +1 -0
- package/dist/files/engine/redux/tree-utils.d.ts +90 -0
- package/dist/files/engine/redux/tree-utils.js +200 -0
- package/dist/files/engine/redux/tree-utils.js.map +1 -0
- package/dist/files/engine/support/claimsUser.d.ts +77 -0
- package/dist/files/engine/support/claimsUser.js +48 -0
- package/dist/files/engine/support/claimsUser.js.map +1 -0
- package/dist/files/engine/support/datetime.d.ts +6 -0
- package/dist/files/engine/support/datetime.js +15 -0
- package/dist/files/engine/support/datetime.js.map +1 -0
- package/dist/files/engine/support/document-visibility.d.ts +14 -0
- package/dist/files/engine/support/document-visibility.js +28 -0
- package/dist/files/engine/support/document-visibility.js.map +1 -0
- package/dist/files/engine/support/logger.d.ts +28 -0
- package/dist/files/engine/support/logger.js +36 -0
- package/dist/files/engine/support/logger.js.map +1 -0
- package/dist/files/engine/types.d.ts +1081 -0
- package/dist/files/engine/types.js +30 -0
- package/dist/files/engine/types.js.map +1 -0
- package/dist/files/engine/upload/cloudUpload.d.ts +182 -0
- package/dist/files/engine/upload/cloudUpload.js +418 -0
- package/dist/files/engine/upload/cloudUpload.js.map +1 -0
- package/dist/files/engine/upload/tusUpload.d.ts +100 -0
- package/dist/files/engine/upload/tusUpload.js +258 -0
- package/dist/files/engine/upload/tusUpload.js.map +1 -0
- package/dist/files/engine/upload/uploadDedupGuard.d.ts +58 -0
- package/dist/files/engine/upload/uploadDedupGuard.js +41 -0
- package/dist/files/engine/upload/uploadDedupGuard.js.map +1 -0
- package/dist/files/engine/upload/uploadGuardOpeners.d.ts +70 -0
- package/dist/files/engine/upload/uploadGuardOpeners.js +54 -0
- package/dist/files/engine/upload/uploadGuardOpeners.js.map +1 -0
- package/dist/files/engine/utils/file-types.d.ts +243 -0
- package/dist/files/engine/utils/file-types.js +1722 -0
- package/dist/files/engine/utils/file-types.js.map +1 -0
- package/dist/files/engine/utils/user-visible.d.ts +133 -0
- package/dist/files/engine/utils/user-visible.js +115 -0
- package/dist/files/engine/utils/user-visible.js.map +1 -0
- package/package.json +392 -8
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as tus from "tus-js-client";
|
|
2
|
+
import { buildHeaders, resolveBaseUrlForPath } from "../host/python-client.js";
|
|
3
|
+
import * as Files from "../api/files.js";
|
|
4
|
+
import { extractErrorMessage } from "@ai-matrx/data/net";
|
|
5
|
+
const TUS_CHUNK_SIZE_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
const TUS_UPLOAD_PATH = "/files/upload/tus";
|
|
7
|
+
function buildUploadMetadataEnvelope(metadata) {
|
|
8
|
+
const envelope = {
|
|
9
|
+
// Origin tag aids backend log triage when something goes wrong.
|
|
10
|
+
origin: "cloudUpload",
|
|
11
|
+
...metadata ?? {}
|
|
12
|
+
};
|
|
13
|
+
if (!hasUploadOrganization(envelope)) {
|
|
14
|
+
console.warn(
|
|
15
|
+
"[buildUploadMetadataEnvelope] upload carries no scope.organization_id \u2014 it will be filed in an organization nobody chose. Route this upload through cloudUpload/cloudUploadRaw so the organization gate runs."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return envelope;
|
|
19
|
+
}
|
|
20
|
+
function hasUploadOrganization(envelope) {
|
|
21
|
+
if (typeof envelope.organization_id === "string" && envelope.organization_id)
|
|
22
|
+
return true;
|
|
23
|
+
const scope = envelope.scope;
|
|
24
|
+
return Boolean(
|
|
25
|
+
scope && typeof scope === "object" && typeof scope.organization_id === "string" && scope.organization_id
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const URL_DB_NAME = "mtx-tus-urls";
|
|
29
|
+
const URL_STORE = "urls";
|
|
30
|
+
let urlDbPromise = null;
|
|
31
|
+
function openUrlDb() {
|
|
32
|
+
if (urlDbPromise) return urlDbPromise;
|
|
33
|
+
urlDbPromise = new Promise((resolve, reject) => {
|
|
34
|
+
if (typeof indexedDB === "undefined") {
|
|
35
|
+
reject(new Error("[tusUpload] IndexedDB unavailable"));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const req = indexedDB.open(URL_DB_NAME, 1);
|
|
39
|
+
req.onupgradeneeded = () => {
|
|
40
|
+
const db = req.result;
|
|
41
|
+
if (!db.objectStoreNames.contains(URL_STORE)) {
|
|
42
|
+
const store = db.createObjectStore(URL_STORE, {
|
|
43
|
+
keyPath: "urlStorageKey"
|
|
44
|
+
});
|
|
45
|
+
store.createIndex("fingerprint", "fingerprint", { unique: false });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
req.onsuccess = () => resolve(req.result);
|
|
49
|
+
req.onerror = () => reject(req.error ?? new Error("[tusUpload] failed to open mtx-tus-urls"));
|
|
50
|
+
});
|
|
51
|
+
urlDbPromise.catch(() => {
|
|
52
|
+
urlDbPromise = null;
|
|
53
|
+
});
|
|
54
|
+
return urlDbPromise;
|
|
55
|
+
}
|
|
56
|
+
function idbRequest(req) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
req.onsuccess = () => resolve(req.result);
|
|
59
|
+
req.onerror = () => reject(req.error ?? new Error("IndexedDB failed"));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function createTusUrlStorage() {
|
|
63
|
+
return {
|
|
64
|
+
async findAllUploads() {
|
|
65
|
+
const db = await openUrlDb();
|
|
66
|
+
const tx = db.transaction(URL_STORE, "readonly");
|
|
67
|
+
const all = await idbRequest(
|
|
68
|
+
tx.objectStore(URL_STORE).getAll()
|
|
69
|
+
);
|
|
70
|
+
return all;
|
|
71
|
+
},
|
|
72
|
+
async findUploadsByFingerprint(fingerprint) {
|
|
73
|
+
const db = await openUrlDb();
|
|
74
|
+
const tx = db.transaction(URL_STORE, "readonly");
|
|
75
|
+
const matches = await idbRequest(
|
|
76
|
+
tx.objectStore(URL_STORE).index("fingerprint").getAll(fingerprint)
|
|
77
|
+
);
|
|
78
|
+
return matches;
|
|
79
|
+
},
|
|
80
|
+
async removeUpload(urlStorageKey) {
|
|
81
|
+
const db = await openUrlDb();
|
|
82
|
+
const tx = db.transaction(URL_STORE, "readwrite");
|
|
83
|
+
tx.objectStore(URL_STORE).delete(urlStorageKey);
|
|
84
|
+
await new Promise((resolve, reject) => {
|
|
85
|
+
tx.oncomplete = () => resolve();
|
|
86
|
+
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx failed"));
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
async addUpload(fingerprint, upload) {
|
|
90
|
+
const urlStorageKey = `tus::${fingerprint}::${Date.now().toString(36)}`;
|
|
91
|
+
const record = {
|
|
92
|
+
urlStorageKey,
|
|
93
|
+
fingerprint,
|
|
94
|
+
size: upload.size,
|
|
95
|
+
metadata: upload.metadata,
|
|
96
|
+
creationTime: upload.creationTime,
|
|
97
|
+
uploadUrl: upload.uploadUrl,
|
|
98
|
+
parallelUploadUrls: upload.parallelUploadUrls
|
|
99
|
+
};
|
|
100
|
+
const db = await openUrlDb();
|
|
101
|
+
const tx = db.transaction(URL_STORE, "readwrite");
|
|
102
|
+
tx.objectStore(URL_STORE).put(record);
|
|
103
|
+
await new Promise((resolve, reject) => {
|
|
104
|
+
tx.oncomplete = () => resolve();
|
|
105
|
+
tx.onerror = () => reject(tx.error ?? new Error("IndexedDB tx failed"));
|
|
106
|
+
});
|
|
107
|
+
return urlStorageKey;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async function listStoredTusUploads() {
|
|
112
|
+
try {
|
|
113
|
+
const db = await openUrlDb();
|
|
114
|
+
const tx = db.transaction(URL_STORE, "readonly");
|
|
115
|
+
const all = await idbRequest(
|
|
116
|
+
tx.objectStore(URL_STORE).getAll()
|
|
117
|
+
);
|
|
118
|
+
return all.map((r) => ({
|
|
119
|
+
urlStorageKey: r.urlStorageKey,
|
|
120
|
+
fingerprint: r.fingerprint,
|
|
121
|
+
size: r.size,
|
|
122
|
+
metadata: r.metadata,
|
|
123
|
+
creationTime: r.creationTime,
|
|
124
|
+
uploadUrl: r.uploadUrl
|
|
125
|
+
}));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error("[tusUpload] listStoredTusUploads failed:", err);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function tusUploadRaw(file, filePath, options, idempotencyKey, deps = {}) {
|
|
132
|
+
const endpoint = deps.endpointOverride ?? `${resolveBaseUrlForPath(TUS_UPLOAD_PATH, void 0, "POST")}${TUS_UPLOAD_PATH}`;
|
|
133
|
+
const metadataEnvelope = buildUploadMetadataEnvelope(options.metadata);
|
|
134
|
+
let cldFileId = null;
|
|
135
|
+
let aborted = false;
|
|
136
|
+
try {
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
const upload = new tus.Upload(file, {
|
|
139
|
+
endpoint,
|
|
140
|
+
chunkSize: TUS_CHUNK_SIZE_BYTES,
|
|
141
|
+
retryDelays: [0, 1e3, 3e3, 5e3, 1e4],
|
|
142
|
+
removeFingerprintOnSuccess: true,
|
|
143
|
+
urlStorage: deps.urlStorage ?? createTusUrlStorage(),
|
|
144
|
+
...deps.httpStack ? { httpStack: deps.httpStack } : {},
|
|
145
|
+
...deps.fileReader ? { fileReader: deps.fileReader } : {},
|
|
146
|
+
metadata: {
|
|
147
|
+
filename: file.name,
|
|
148
|
+
filepath: filePath,
|
|
149
|
+
// ONE validated JSON envelope — tus-js-client base64-encodes the
|
|
150
|
+
// value per the Upload-Metadata spec; the server parses and merges
|
|
151
|
+
// it exactly like the buffered `metadata_json` form field.
|
|
152
|
+
metadata_json: JSON.stringify(metadataEnvelope),
|
|
153
|
+
...options.visibility ? { visibility: options.visibility } : {}
|
|
154
|
+
},
|
|
155
|
+
onBeforeRequest: async (req) => {
|
|
156
|
+
const { headers } = await buildHeaders({}, false);
|
|
157
|
+
if (headers.Authorization) {
|
|
158
|
+
req.setHeader("Authorization", headers.Authorization);
|
|
159
|
+
}
|
|
160
|
+
if (headers["X-Guest-Fingerprint"]) {
|
|
161
|
+
req.setHeader("X-Guest-Fingerprint", headers["X-Guest-Fingerprint"]);
|
|
162
|
+
}
|
|
163
|
+
if (headers["X-Organization-Id"]) {
|
|
164
|
+
req.setHeader("X-Organization-Id", headers["X-Organization-Id"]);
|
|
165
|
+
}
|
|
166
|
+
if (req.getMethod() === "POST") {
|
|
167
|
+
req.setHeader("X-Idempotency-Key", idempotencyKey);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
onAfterResponse: (_req, res) => {
|
|
171
|
+
const id = res.getHeader("X-Cld-File-Id");
|
|
172
|
+
if (id) cldFileId = id;
|
|
173
|
+
},
|
|
174
|
+
onProgress: (bytesSent, bytesTotal) => {
|
|
175
|
+
options.onProgress?.({ loaded: bytesSent, total: bytesTotal });
|
|
176
|
+
},
|
|
177
|
+
onSuccess: () => resolve(),
|
|
178
|
+
onError: (error) => reject(error)
|
|
179
|
+
});
|
|
180
|
+
if (options.signal) {
|
|
181
|
+
if (options.signal.aborted) {
|
|
182
|
+
aborted = true;
|
|
183
|
+
reject(new Error("Upload cancelled"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
options.signal.addEventListener(
|
|
187
|
+
"abort",
|
|
188
|
+
() => {
|
|
189
|
+
aborted = true;
|
|
190
|
+
void upload.abort();
|
|
191
|
+
reject(new Error("Upload cancelled"));
|
|
192
|
+
},
|
|
193
|
+
{ once: true }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
void upload.findPreviousUploads().then((previous) => {
|
|
197
|
+
if (previous.length > 0) {
|
|
198
|
+
upload.resumeFromPreviousUpload(previous[0]);
|
|
199
|
+
}
|
|
200
|
+
upload.start();
|
|
201
|
+
}).catch((err) => {
|
|
202
|
+
console.warn(
|
|
203
|
+
"[tusUpload] previous-upload lookup failed \u2014 starting fresh:",
|
|
204
|
+
err
|
|
205
|
+
);
|
|
206
|
+
upload.start();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (cldFileId && !aborted) {
|
|
211
|
+
console.warn(
|
|
212
|
+
`[tusUpload] transfer reported an error but the server exposed X-Cld-File-Id=${cldFileId} \u2014 recovering the completed session.`
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: extractErrorMessage(err),
|
|
218
|
+
errorCode: aborted ? "upload_cancelled" : "tus_upload_failed",
|
|
219
|
+
fileName: file.name
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!cldFileId) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
error: "TUS upload finished but the server never exposed X-Cld-File-Id \u2014 the file cannot be resolved. Check server CORS expose_headers.",
|
|
227
|
+
errorCode: "tus_missing_file_id",
|
|
228
|
+
fileName: file.name
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const { data: record } = await Files.getFile(cldFileId);
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
fileId: record.id,
|
|
236
|
+
filePath: record.file_path,
|
|
237
|
+
fileSize: record.size_bytes ?? file.size,
|
|
238
|
+
versionNumber: record.current_version ?? 1,
|
|
239
|
+
url: null
|
|
240
|
+
};
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `TUS upload completed (file ${cldFileId}) but the record fetch failed: ${extractErrorMessage(err)}`,
|
|
245
|
+
errorCode: "tus_record_fetch_failed",
|
|
246
|
+
fileName: file.name
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export {
|
|
251
|
+
TUS_CHUNK_SIZE_BYTES,
|
|
252
|
+
TUS_UPLOAD_PATH,
|
|
253
|
+
buildUploadMetadataEnvelope,
|
|
254
|
+
createTusUrlStorage,
|
|
255
|
+
listStoredTusUploads,
|
|
256
|
+
tusUploadRaw
|
|
257
|
+
};
|
|
258
|
+
//# sourceMappingURL=tusUpload.js.map
|
|
@@ -0,0 +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":"AAkCA,YAAY,SAAS;AACrB,SAAS,cAAc,6BAA6B;AAEpD,YAAY,WAAW;AACvB,SAAS,2BAA2B;AAU7B,MAAM,uBAAuB,KAAK,OAAO;AAEzC,MAAM,kBAAkB;AAwBxB,SAAS,4BACd,UACyB;AACzB,QAAM,WAAoC;AAAA;AAAA,IAExC,QAAQ;AAAA,IACR,GAAI,YAAY,CAAC;AAAA,EACnB;AACA,MAEE,CAAC,sBAAsB,QAAQ,GAC/B;AAIA,YAAQ;AAAA,MACN;AAAA,IAGF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,sBAAsB,UAA4C;AACzE,MAAI,OAAO,SAAS,oBAAoB,YAAY,SAAS;AAC3D,WAAO;AACT,QAAM,QAAQ,SAAS;AACvB,SAAO;AAAA,IACL,SACE,OAAO,UAAU,YACjB,OAAQ,MAAkC,oBAAoB,YAC7D,MAAkC;AAAA,EACvC;AACF;AAIA,MAAM,cAAc;AACpB,MAAM,YAAY;AAYlB,IAAI,eAA4C;AAEhD,SAAS,YAAkC;AACzC,MAAI,aAAc,QAAO;AACzB,iBAAe,IAAI,QAAqB,CAAC,SAAS,WAAW;AAC3D,QAAI,OAAO,cAAc,aAAa;AACpC,aAAO,IAAI,MAAM,mCAAmC,CAAC;AACrD;AAAA,IACF;AACA,UAAM,MAAM,UAAU,KAAK,aAAa,CAAC;AACzC,QAAI,kBAAkB,MAAM;AAC1B,YAAM,KAAK,IAAI;AACf,UAAI,CAAC,GAAG,iBAAiB,SAAS,SAAS,GAAG;AAC5C,cAAM,QAAQ,GAAG,kBAAkB,WAAW;AAAA,UAC5C,SAAS;AAAA,QACX,CAAC;AACD,cAAM,YAAY,eAAe,eAAe,EAAE,QAAQ,MAAM,CAAC;AAAA,MACnE;AAAA,IACF;AACA,QAAI,YAAY,MAAM,QAAQ,IAAI,MAAM;AACxC,QAAI,UAAU,MACZ,OAAO,IAAI,SAAS,IAAI,MAAM,yCAAyC,CAAC;AAAA,EAC5E,CAAC;AACD,eAAa,MAAM,MAAM;AACvB,mBAAe;AAAA,EACjB,CAAC;AACD,SAAO;AACT;AAEA,SAAS,WAAc,KAAgC;AACrD,SAAO,IAAI,QAAW,CAAC,SAAS,WAAW;AACzC,QAAI,YAAY,MAAM,QAAQ,IAAI,MAAM;AACxC,QAAI,UAAU,MAAM,OAAO,IAAI,SAAS,IAAI,MAAM,kBAAkB,CAAC;AAAA,EACvE,CAAC;AACH;AAQO,SAAS,sBAEd;AACA,SAAO;AAAA,IACL,MAAM,iBAA4C;AAChD,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,KAAK,GAAG,YAAY,WAAW,UAAU;AAC/C,YAAM,MAAM,MAAM;AAAA,QAChB,GAAG,YAAY,SAAS,EAAE,OAAO;AAAA,MACnC;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,yBACJ,aAC2B;AAC3B,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,KAAK,GAAG,YAAY,WAAW,UAAU;AAC/C,YAAM,UAAU,MAAM;AAAA,QACpB,GACG,YAAY,SAAS,EACrB,MAAM,aAAa,EACnB,OAAO,WAAW;AAAA,MACvB;AACA,aAAO;AAAA,IACT;AAAA,IACA,MAAM,aAAa,eAAsC;AACvD,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,KAAK,GAAG,YAAY,WAAW,WAAW;AAChD,SAAG,YAAY,SAAS,EAAE,OAAO,aAAa;AAC9C,YAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,WAAG,aAAa,MAAM,QAAQ;AAC9B,WAAG,UAAU,MAAM,OAAO,GAAG,SAAS,IAAI,MAAM,qBAAqB,CAAC;AAAA,MACxE,CAAC;AAAA,IACH;AAAA,IACA,MAAM,UACJ,aACA,QACiB;AACjB,YAAM,gBAAgB,QAAQ,WAAW,KAAK,KAAK,IAAI,EAAE,SAAS,EAAE,CAAC;AACrE,YAAM,SAA0B;AAAA,QAC9B;AAAA,QACA;AAAA,QACA,MAAM,OAAO;AAAA,QACb,UAAU,OAAO;AAAA,QACjB,cAAc,OAAO;AAAA,QACrB,WAAW,OAAO;AAAA,QAClB,oBAAoB,OAAO;AAAA,MAC7B;AACA,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,KAAK,GAAG,YAAY,WAAW,WAAW;AAChD,SAAG,YAAY,SAAS,EAAE,IAAI,MAAM;AACpC,YAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,WAAG,aAAa,MAAM,QAAQ;AAC9B,WAAG,UAAU,MAAM,OAAO,GAAG,SAAS,IAAI,MAAM,qBAAqB,CAAC;AAAA,MACxE,CAAC;AACD,aAAO;AAAA,IACT;AAAA,EACF;AACF;AAoBA,eAAsB,uBAEpB;AACA,MAAI;AACF,UAAM,KAAK,MAAM,UAAU;AAC3B,UAAM,KAAK,GAAG,YAAY,WAAW,UAAU;AAC/C,UAAM,MAAM,MAAM;AAAA,MAChB,GAAG,YAAY,SAAS,EAAE,OAAO;AAAA,IACnC;AACA,WAAO,IAAI,IAAI,CAAC,OAAO;AAAA,MACrB,eAAe,EAAE;AAAA,MACjB,aAAa,EAAE;AAAA,MACf,MAAM,EAAE;AAAA,MACR,UAAU,EAAE;AAAA,MACZ,cAAc,EAAE;AAAA,MAChB,WAAW,EAAE;AAAA,IACf,EAAE;AAAA,EACJ,SAAS,KAAK;AACZ,YAAQ,MAAM,4CAA4C,GAAG;AAC7D,WAAO,CAAC;AAAA,EACV;AACF;AAwBA,eAAsB,aACpB,MACA,UACA,SACA,gBACA,OAAsB,CAAC,GACK;AAC5B,QAAM,WACJ,KAAK,oBACL,GAAG,sBAAsB,iBAAiB,QAAW,MAAM,CAAC,GAAG,eAAe;AAEhF,QAAM,mBAAmB,4BAA4B,QAAQ,QAAQ;AAErE,MAAI,YAA2B;AAC/B,MAAI,UAAU;AAEd,MAAI;AACF,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,YAAM,SAAqB,IAAI,IAAI,OAAO,MAAM;AAAA,QAC9C;AAAA,QACA,WAAW;AAAA,QACX,aAAa,CAAC,GAAG,KAAM,KAAM,KAAM,GAAK;AAAA,QACxC,4BAA4B;AAAA,QAC5B,YAAY,KAAK,cAAc,oBAAoB;AAAA,QACnD,GAAI,KAAK,YAAY,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,QACtD,GAAI,KAAK,aAAa,EAAE,YAAY,KAAK,WAAW,IAAI,CAAC;AAAA,QACzD,UAAU;AAAA,UACR,UAAU,KAAK;AAAA,UACf,UAAU;AAAA;AAAA;AAAA;AAAA,UAIV,eAAe,KAAK,UAAU,gBAAgB;AAAA,UAC9C,GAAI,QAAQ,aAAa,EAAE,YAAY,QAAQ,WAAW,IAAI,CAAC;AAAA,QACjE;AAAA,QACA,iBAAiB,OAAO,QAAQ;AAa9B,gBAAM,EAAE,QAAQ,IAAI,MAAM,aAAa,CAAC,GAAG,KAAK;AAChD,cAAI,QAAQ,eAAe;AACzB,gBAAI,UAAU,iBAAiB,QAAQ,aAAa;AAAA,UACtD;AACA,cAAI,QAAQ,qBAAqB,GAAG;AAClC,gBAAI,UAAU,uBAAuB,QAAQ,qBAAqB,CAAC;AAAA,UACrE;AACA,cAAI,QAAQ,mBAAmB,GAAG;AAChC,gBAAI,UAAU,qBAAqB,QAAQ,mBAAmB,CAAC;AAAA,UACjE;AACA,cAAI,IAAI,UAAU,MAAM,QAAQ;AAE9B,gBAAI,UAAU,qBAAqB,cAAc;AAAA,UACnD;AAAA,QACF;AAAA,QACA,iBAAiB,CAAC,MAAM,QAAQ;AAG9B,gBAAM,KAAK,IAAI,UAAU,eAAe;AACxC,cAAI,GAAI,aAAY;AAAA,QACtB;AAAA,QACA,YAAY,CAAC,WAAW,eAAe;AACrC,kBAAQ,aAAa,EAAE,QAAQ,WAAW,OAAO,WAAW,CAAC;AAAA,QAC/D;AAAA,QACA,WAAW,MAAM,QAAQ;AAAA,QACzB,SAAS,CAAC,UAAU,OAAO,KAAK;AAAA,MAClC,CAAC;AAED,UAAI,QAAQ,QAAQ;AAClB,YAAI,QAAQ,OAAO,SAAS;AAC1B,oBAAU;AACV,iBAAO,IAAI,MAAM,kBAAkB,CAAC;AACpC;AAAA,QACF;AACA,gBAAQ,OAAO;AAAA,UACb;AAAA,UACA,MAAM;AACJ,sBAAU;AACV,iBAAK,OAAO,MAAM;AAClB,mBAAO,IAAI,MAAM,kBAAkB,CAAC;AAAA,UACtC;AAAA,UACA,EAAE,MAAM,KAAK;AAAA,QACf;AAAA,MACF;AAKA,WAAK,OACF,oBAAoB,EACpB,KAAK,CAAC,aAAa;AAClB,YAAI,SAAS,SAAS,GAAG;AACvB,iBAAO,yBAAyB,SAAS,CAAC,CAAC;AAAA,QAC7C;AACA,eAAO,MAAM;AAAA,MACf,CAAC,EACA,MAAM,CAAC,QAAQ;AACd,gBAAQ;AAAA,UACN;AAAA,UACA;AAAA,QACF;AACA,eAAO,MAAM;AAAA,MACf,CAAC;AAAA,IACL,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,QAAI,aAAa,CAAC,SAAS;AAIzB,cAAQ;AAAA,QACN,+EACmB,SAAS;AAAA,MAC9B;AAAA,IACF,OAAO;AACL,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,OAAO,oBAAoB,GAAG;AAAA,QAC9B,WAAW,UAAU,qBAAqB;AAAA,QAC1C,UAAU,KAAK;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,CAAC,WAAW;AACd,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OACE;AAAA,MAEF,WAAW;AAAA,MACX,UAAU,KAAK;AAAA,IACjB;AAAA,EACF;AAGA,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":[]}
|
|
@@ -0,0 +1,58 @@
|
|
|
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;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const CLAIM_WINDOW_MS = 3 * 60 * 1e3;
|
|
2
|
+
const claims = /* @__PURE__ */ new Map();
|
|
3
|
+
function uploadDedupKey(folderPath, fileName, fileSize) {
|
|
4
|
+
return [folderPath, fileName, String(fileSize)].join("\\0");
|
|
5
|
+
}
|
|
6
|
+
function claimUpload(key) {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
const existing = claims.get(key);
|
|
9
|
+
if (existing && existing.expiresAt > now) {
|
|
10
|
+
return { shared: true, promise: existing.promise };
|
|
11
|
+
}
|
|
12
|
+
let settleFn;
|
|
13
|
+
const publicPromise = new Promise(
|
|
14
|
+
(resolve, reject) => {
|
|
15
|
+
settleFn = (outcome) => {
|
|
16
|
+
outcome.then(
|
|
17
|
+
(value) => {
|
|
18
|
+
resolve(value);
|
|
19
|
+
},
|
|
20
|
+
(err) => {
|
|
21
|
+
claims.delete(key);
|
|
22
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
publicPromise.catch(() => {
|
|
29
|
+
});
|
|
30
|
+
claims.set(key, { promise: publicPromise, expiresAt: now + CLAIM_WINDOW_MS });
|
|
31
|
+
return { shared: false, settle: settleFn };
|
|
32
|
+
}
|
|
33
|
+
function __resetUploadDedupGuardForTests() {
|
|
34
|
+
claims.clear();
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
__resetUploadDedupGuardForTests,
|
|
38
|
+
claimUpload,
|
|
39
|
+
uploadDedupKey
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=uploadDedupGuard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/files/engine/upload/uploadDedupGuard.ts"],"sourcesContent":["/**\n * features/files/upload/uploadDedupGuard.ts\n *\n * Root cause (`common-docs/projects/acquisition-frontier/own-files/VERIFICATION.md`\n * §12, 2026-09-18): in a 7-file pile, both zero-byte files were uploaded\n * TWICE, 18s apart, the second auto-renamed \"(1)\" by `uniqueName()` in\n * `features/files/redux/thunks.ts` — right after the dev server had refused\n * connections. A client-side retry of the whole `uploadFiles()` dispatch\n * after a transient failure re-sends a file whose first attempt may already\n * have landed server-side (the client only knows its OWN request failed —\n * a connection reset gives no proof the server never received the bytes).\n *\n * The obvious fix is the backend's OWN `X-Idempotency-Key` mechanism\n * (`lib/python-client.ts` already sends it, `aidream` already stores it in\n * `metadata._idempotency_key`) — but it is not the full answer here: the\n * key is only reused *within* a single `uploadFiles()` dispatch\n * (`newRequestId()` mints a fresh one per worker() call in thunks.ts), and\n * server-side `_replayable_412` in `aidream/aidream/api/routers/files/__init__.py`\n * only replays a 412 (precondition-failed) response under the key — a\n * SUCCESSFUL upload is never deduped by the server on replay. So a second,\n * independent `uploadFiles()` dispatch for the same intended file — the\n * shape a caller-level retry takes — is indistinguishable from a genuinely\n * new upload to both client and server.\n *\n * This guard closes the gap client-side, per (folder, name, size): the\n * FIRST claim for a signature performs the real network request; any\n * SECOND claim for the same signature while that request is still in\n * flight, or shortly after it landed, reuses the first attempt's outcome\n * instead of sending the bytes again. A claim that ends in failure is\n * cleared immediately, so a genuine \"it never landed, please retry\" case is\n * never blocked — only a request whose outcome is still pending, or already\n * known-good, is deduped.\n */\n\nexport interface DedupedUploadOutcome {\n fileId: string;\n filePath: string;\n}\n\ninterface Claim {\n promise: Promise<DedupedUploadOutcome>;\n expiresAt: number;\n}\n\n/**\n * How long a landed (or in-flight) upload's signature stays claimed. Long\n * enough to cover the 18s gap actually observed between the two identical\n * uploads in the verifier's run, with wide margin for a slower connection.\n */\nconst CLAIM_WINDOW_MS = 3 * 60 * 1000;\n\nconst claims = new Map<string, Claim>();\n\n/** The dedup key: same folder, same original name, same byte count. */\nexport function uploadDedupKey(\n folderPath: string,\n fileName: string,\n fileSize: number,\n): string {\n return [folderPath, fileName, String(fileSize)].join(\"\\\\0\");\n}\n\nexport type UploadClaim =\n | { shared: true; promise: Promise<DedupedUploadOutcome> }\n | {\n shared: false;\n /**\n * The caller that owns this claim MUST call `settle` exactly once\n * with the real upload's outcome (or a rejected promise on failure).\n */\n settle: (outcome: Promise<DedupedUploadOutcome>) => void;\n };\n\n/**\n * Claim a signature before starting an upload. See module doc for the\n * contract: a `shared: true` result means someone else already owns this\n * signature — await its `promise` instead of sending the file again.\n */\nexport function claimUpload(key: string): UploadClaim {\n const now = Date.now();\n const existing = claims.get(key);\n if (existing && existing.expiresAt > now) {\n return { shared: true, promise: existing.promise };\n }\n\n let settleFn!: (outcome: Promise<DedupedUploadOutcome>) => void;\n const publicPromise = new Promise<DedupedUploadOutcome>(\n (resolve, reject) => {\n settleFn = (outcome) => {\n outcome.then(\n (value) => {\n // Success stays claimed for the full window — a landed upload\n // must keep deduping late-arriving retries too.\n resolve(value);\n },\n (err) => {\n // A failed attempt must never haunt a real retry: clear the\n // slot immediately so the next claim for this signature runs\n // for real instead of replaying a failure forever.\n claims.delete(key);\n reject(err instanceof Error ? err : new Error(String(err)));\n },\n );\n };\n },\n );\n // Every claim gets a silent subscriber of its own: a claim nobody ever\n // shares (the common case — most uploads never race a retry) must not\n // trip Node's unhandled-rejection detector when it fails. This does not\n // swallow the failure for a real shared consumer — each `.then()`/`.catch()`\n // on the same promise still sees the rejection independently.\n publicPromise.catch(() => {});\n claims.set(key, { promise: publicPromise, expiresAt: now + CLAIM_WINDOW_MS });\n return { shared: false, settle: settleFn };\n}\n\n/** Test-only: clears every claim so suites don't leak state between tests. */\nexport function __resetUploadDedupGuardForTests(): void {\n claims.clear();\n}\n"],"mappings":"AAiDA,MAAM,kBAAkB,IAAI,KAAK;AAEjC,MAAM,SAAS,oBAAI,IAAmB;AAG/B,SAAS,eACd,YACA,UACA,UACQ;AACR,SAAO,CAAC,YAAY,UAAU,OAAO,QAAQ,CAAC,EAAE,KAAK,KAAK;AAC5D;AAkBO,SAAS,YAAY,KAA0B;AACpD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,WAAW,OAAO,IAAI,GAAG;AAC/B,MAAI,YAAY,SAAS,YAAY,KAAK;AACxC,WAAO,EAAE,QAAQ,MAAM,SAAS,SAAS,QAAQ;AAAA,EACnD;AAEA,MAAI;AACJ,QAAM,gBAAgB,IAAI;AAAA,IACxB,CAAC,SAAS,WAAW;AACnB,iBAAW,CAAC,YAAY;AACtB,gBAAQ;AAAA,UACN,CAAC,UAAU;AAGT,oBAAQ,KAAK;AAAA,UACf;AAAA,UACA,CAAC,QAAQ;AAIP,mBAAO,OAAO,GAAG;AACjB,mBAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,GAAG,CAAC,CAAC;AAAA,UAC5D;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAMA,gBAAc,MAAM,MAAM;AAAA,EAAC,CAAC;AAC5B,SAAO,IAAI,KAAK,EAAE,SAAS,eAAe,WAAW,MAAM,gBAAgB,CAAC;AAC5E,SAAO,EAAE,QAAQ,OAAO,QAAQ,SAAS;AAC3C;AAGO,SAAS,kCAAwC;AACtD,SAAO,MAAM;AACf;","names":[]}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* features/files/upload/uploadGuardOpeners.ts
|
|
3
|
+
*
|
|
4
|
+
* Module-level imperative API for the upload-guard system. Same shape
|
|
5
|
+
* as `cloudFilesPickerOpeners` — a single host component registers a
|
|
6
|
+
* handler at mount, and any code anywhere can call `requestUpload`
|
|
7
|
+
* without threading state.
|
|
8
|
+
*
|
|
9
|
+
* The host pre-flights uploaded files for duplicates (identical
|
|
10
|
+
* checksum or filename collisions in the target folder), shows a
|
|
11
|
+
* resolution dialog if needed, and only then dispatches the upload.
|
|
12
|
+
* If the host hasn't mounted yet, `requestUpload` warns in dev and
|
|
13
|
+
* falls back to a direct thunk dispatch so we don't break the upload
|
|
14
|
+
* for the (rare) caller that fires during boot.
|
|
15
|
+
*
|
|
16
|
+
* Pure TypeScript — no React, no dialog markup. Safe to import from
|
|
17
|
+
* anywhere, including the static graph of route entries.
|
|
18
|
+
*/
|
|
19
|
+
import type { UploadFilesArg } from "../types.js";
|
|
20
|
+
/**
|
|
21
|
+
* The handler the host registers at mount. Returns a promise that
|
|
22
|
+
* resolves with the same shape as the underlying `uploadFiles` thunk
|
|
23
|
+
* payload — `{ uploaded: string[], failed: [...] }`.
|
|
24
|
+
*/
|
|
25
|
+
export type UploadGuardHandler = (arg: UploadFilesArg) => Promise<UploadGuardResult>;
|
|
26
|
+
export interface UploadGuardResult {
|
|
27
|
+
uploaded: string[];
|
|
28
|
+
failed: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
error: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** True when the user dismissed the duplicate dialog. */
|
|
33
|
+
cancelled: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Files the user chose to NOT re-upload because a duplicate already
|
|
36
|
+
* exists, and they explicitly picked "use the existing one." Each
|
|
37
|
+
* entry maps the index of the original input file (in `arg.files`)
|
|
38
|
+
* to the existing `cld_files.id` the user picked.
|
|
39
|
+
*
|
|
40
|
+
* The chat-resource-attach flow uses this to wire the existing
|
|
41
|
+
* fileId into its parent context (e.g. attach the existing PDF to
|
|
42
|
+
* the conversation) so the user gets a confirmed-and-attached file
|
|
43
|
+
* with zero wasted bytes. Other callers can iterate `aliased` to
|
|
44
|
+
* surface "found N existing matches" UI.
|
|
45
|
+
*
|
|
46
|
+
* Empty for the normal "no duplicates" upload path.
|
|
47
|
+
*/
|
|
48
|
+
aliased: Array<{
|
|
49
|
+
inputIndex: number;
|
|
50
|
+
existingFileId: string;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
/** Called once by the host's `useEffect` mount. */
|
|
54
|
+
export declare function registerUploadGuardHandler(handler: UploadGuardHandler): void;
|
|
55
|
+
/** Called by the host's cleanup. */
|
|
56
|
+
export declare function unregisterUploadGuardHandler(handler: UploadGuardHandler): void;
|
|
57
|
+
/**
|
|
58
|
+
* Public entry point. Call from any UI surface that initiates an
|
|
59
|
+
* upload (drag-drop, paste, FAB, picker). Returns the same shape as
|
|
60
|
+
* `uploadFiles.unwrap()` plus a `cancelled` flag.
|
|
61
|
+
*
|
|
62
|
+
* Behaviour:
|
|
63
|
+
* - If the host is mounted: pre-flight scan → optional dialog →
|
|
64
|
+
* dispatch with the user's choices. Resolves when uploads finish
|
|
65
|
+
* or when the user cancels (`cancelled: true`, no dispatch).
|
|
66
|
+
* - If the host is NOT mounted: dispatches directly with no
|
|
67
|
+
* duplicate check, so old call paths keep working. Logs a dev
|
|
68
|
+
* warning so the missing host gets noticed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function requestUpload(arg: UploadFilesArg): Promise<UploadGuardResult>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
let registered = null;
|
|
2
|
+
function registerUploadGuardHandler(handler) {
|
|
3
|
+
registered = handler;
|
|
4
|
+
}
|
|
5
|
+
function unregisterUploadGuardHandler(handler) {
|
|
6
|
+
if (registered === handler) registered = null;
|
|
7
|
+
}
|
|
8
|
+
async function requestUpload(arg) {
|
|
9
|
+
if (registered) {
|
|
10
|
+
return registered(arg);
|
|
11
|
+
}
|
|
12
|
+
if (true) {
|
|
13
|
+
console.warn(
|
|
14
|
+
"[upload-guard] requestUpload called before <UploadGuardHost/> mounted \u2014 falling back to direct dispatch with no duplicate check."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const [{ getStore }, { uploadFiles }] = await Promise.all([
|
|
18
|
+
import("../host/store.js"),
|
|
19
|
+
import("../redux/thunks.js")
|
|
20
|
+
]);
|
|
21
|
+
const store = getStore();
|
|
22
|
+
if (!store) {
|
|
23
|
+
return {
|
|
24
|
+
uploaded: [],
|
|
25
|
+
failed: arg.files.map((f) => ({
|
|
26
|
+
name: f.name,
|
|
27
|
+
error: "Store not ready"
|
|
28
|
+
})),
|
|
29
|
+
cancelled: false,
|
|
30
|
+
aliased: []
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const dispatch = store.dispatch;
|
|
35
|
+
const result = await dispatch(uploadFiles(arg)).unwrap();
|
|
36
|
+
return { ...result, cancelled: false, aliased: [] };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
uploaded: [],
|
|
40
|
+
failed: arg.files.map((f) => ({
|
|
41
|
+
name: f.name,
|
|
42
|
+
error: err instanceof Error ? err.message : String(err)
|
|
43
|
+
})),
|
|
44
|
+
cancelled: false,
|
|
45
|
+
aliased: []
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export {
|
|
50
|
+
registerUploadGuardHandler,
|
|
51
|
+
requestUpload,
|
|
52
|
+
unregisterUploadGuardHandler
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=uploadGuardOpeners.js.map
|
|
@@ -0,0 +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,MAAuC;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":[]}
|