@dinoxx/dinox-cli 2.0.0 → 2.1.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/dist/commands/notes/repo/read.d.ts +8 -0
- package/dist/commands/notes/repo/read.js +321 -0
- package/dist/commands/notes/repo/resolve.d.ts +8 -0
- package/dist/commands/notes/repo/resolve.js +187 -0
- package/dist/commands/notes/repo/search.d.ts +42 -0
- package/dist/commands/notes/repo/search.js +513 -0
- package/dist/commands/notes/repo/shared.d.ts +190 -0
- package/dist/commands/notes/repo/shared.js +102 -0
- package/dist/commands/notes/repo/write.d.ts +24 -0
- package/dist/commands/notes/repo/write.js +251 -0
- package/dist/commands/notes/repo.d.ts +7 -186
- package/dist/commands/notes/repo.js +7 -1360
- package/dist/commands/schema/commandMetadata.js +4 -4
- package/dist/commands/schema/index.d.ts +1 -0
- package/dist/commands/schema/index.js +23 -2
- package/dist/commands/schema/renderSkills.d.ts +4 -0
- package/dist/commands/schema/renderSkills.js +41 -2
- package/dist/commands/skills/index.d.ts +26 -0
- package/dist/commands/skills/index.js +115 -11
- package/dist/commands/storage/repo/configs.d.ts +30 -0
- package/dist/commands/storage/repo/configs.js +259 -0
- package/dist/commands/storage/repo/operations.d.ts +25 -0
- package/dist/commands/storage/repo/operations.js +337 -0
- package/dist/commands/storage/repo/remote.d.ts +32 -0
- package/dist/commands/storage/repo/remote.js +132 -0
- package/dist/commands/storage/repo/resources.d.ts +12 -0
- package/dist/commands/storage/repo/resources.js +134 -0
- package/dist/commands/storage/repo/shared.d.ts +181 -0
- package/dist/commands/storage/repo/shared.js +36 -0
- package/dist/commands/storage/repo/targets.d.ts +12 -0
- package/dist/commands/storage/repo/targets.js +178 -0
- package/dist/commands/storage/repo.d.ts +3 -166
- package/dist/commands/storage/repo.js +4 -1034
- package/dist/contract/snapshot.d.ts +30 -0
- package/dist/contract/snapshot.js +114 -0
- package/dist/daemon/client.js +2 -2
- package/dist/powersync/uploader.js +27 -2
- package/dist/skills/commandRefs.d.ts +21 -0
- package/dist/skills/commandRefs.js +132 -0
- package/dist/skills/generate.d.ts +13 -0
- package/dist/skills/generate.js +80 -0
- package/dist/skills/registry.d.ts +13 -0
- package/dist/skills/registry.js +58 -0
- package/dist/utils/errorCodes.d.ts +311 -0
- package/dist/utils/errorCodes.js +92 -0
- package/dist/utils/errors.d.ts +13 -2
- package/dist/utils/errors.js +11 -0
- package/package.json +11 -4
|
@@ -1,1034 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { generateId } from '../../utils/id.js';
|
|
6
|
-
import { DinoxError } from '../../utils/errors.js';
|
|
7
|
-
const ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY = 'storage:active_custom_config_id';
|
|
8
|
-
const DEFAULT_STORAGE_PROTOCOL = 's3';
|
|
9
|
-
const DEFAULT_STORAGE_PATH_PREFIX = 'dinox_note/';
|
|
10
|
-
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
11
|
-
const OFFICIAL_STORAGE_PROVIDER = 'aliyun-official';
|
|
12
|
-
const OFFICIAL_STORAGE_PROVIDER_NAME = '官方云';
|
|
13
|
-
const UPLOAD_CATEGORY_VALUES = ['images', 'audios', 'files', 'videos'];
|
|
14
|
-
function normalizeText(value) {
|
|
15
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
16
|
-
}
|
|
17
|
-
function normalizeTextArray(raw) {
|
|
18
|
-
const input = normalizeText(raw);
|
|
19
|
-
if (!input) {
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
try {
|
|
23
|
-
const parsed = JSON.parse(input);
|
|
24
|
-
if (!Array.isArray(parsed)) {
|
|
25
|
-
return [];
|
|
26
|
-
}
|
|
27
|
-
return parsed
|
|
28
|
-
.map((entry) => normalizeText(String(entry)))
|
|
29
|
-
.filter(Boolean);
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function normalizeBool(value) {
|
|
36
|
-
return Number(value ?? 0) === 1;
|
|
37
|
-
}
|
|
38
|
-
function normalizeNumber(value) {
|
|
39
|
-
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
|
|
40
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
41
|
-
}
|
|
42
|
-
function normalizeStoragePathPrefix(value) {
|
|
43
|
-
const trimmed = value.trim();
|
|
44
|
-
if (!trimmed) {
|
|
45
|
-
return DEFAULT_STORAGE_PATH_PREFIX;
|
|
46
|
-
}
|
|
47
|
-
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
|
48
|
-
}
|
|
49
|
-
function finishStorageHost(value) {
|
|
50
|
-
const trimmed = value.trim();
|
|
51
|
-
if (!trimmed) {
|
|
52
|
-
throw new DinoxError('Storage endpoint is required.');
|
|
53
|
-
}
|
|
54
|
-
const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
55
|
-
try {
|
|
56
|
-
const parsed = new URL(candidate);
|
|
57
|
-
parsed.pathname = '';
|
|
58
|
-
parsed.search = '';
|
|
59
|
-
parsed.hash = '';
|
|
60
|
-
return parsed.toString().replace(/\/$/, '');
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
throw new DinoxError(`Invalid storage endpoint: ${trimmed}`, { cause: error });
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
function encodeStorageKeyForUrl(key) {
|
|
67
|
-
return key
|
|
68
|
-
.split('/')
|
|
69
|
-
.map((segment) => {
|
|
70
|
-
return encodeURIComponent(segment).replace(/[!'()*]/g, (character) => {
|
|
71
|
-
return `%${character.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
72
|
-
});
|
|
73
|
-
})
|
|
74
|
-
.join('/');
|
|
75
|
-
}
|
|
76
|
-
function assertStorageKeyUrlSafe(key) {
|
|
77
|
-
const unsafeSegment = key.split('/').find((segment) => segment === '.' || segment === '..');
|
|
78
|
-
if (!unsafeSegment) {
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
throw new DinoxError('Storage object key must not contain "." or ".." path segments.', {
|
|
82
|
-
code: 'INVALID_STORAGE_KEY',
|
|
83
|
-
suggestion: 'Remove dot-only path segments from --key or the configured storage path prefix.',
|
|
84
|
-
details: { key },
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
function buildVirtualHostedStyleUrl(bucket, endpoint, key) {
|
|
88
|
-
const parsed = new URL(endpoint);
|
|
89
|
-
const protocol = parsed.protocol || 'https:';
|
|
90
|
-
const port = parsed.port ? `:${parsed.port}` : '';
|
|
91
|
-
const host = parsed.hostname || parsed.host;
|
|
92
|
-
const hostWithBucket = host.startsWith(`${bucket}.`) ? host : `${bucket}.${host}`;
|
|
93
|
-
const encodedKey = encodeStorageKeyForUrl(key);
|
|
94
|
-
return {
|
|
95
|
-
finalUrl: `${protocol}//${hostWithBucket}${port}/${encodedKey}`,
|
|
96
|
-
domain: `${protocol}//${hostWithBucket}${port}`,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function buildPathStyleUrl(bucket, endpoint, key) {
|
|
100
|
-
const parsed = new URL(endpoint);
|
|
101
|
-
const protocol = parsed.protocol || 'https:';
|
|
102
|
-
const port = parsed.port ? `:${parsed.port}` : '';
|
|
103
|
-
const host = parsed.hostname || parsed.host;
|
|
104
|
-
const encodedKey = encodeStorageKeyForUrl(key);
|
|
105
|
-
return {
|
|
106
|
-
finalUrl: `${protocol}//${host}${port}/${bucket}/${encodedKey}`,
|
|
107
|
-
domain: `${protocol}//${host}${port}`,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
function getFileExtension(filePath) {
|
|
111
|
-
const extension = path.extname(filePath).trim().replace(/^\./, '');
|
|
112
|
-
return extension.toLowerCase();
|
|
113
|
-
}
|
|
114
|
-
function guessContentType(filePath) {
|
|
115
|
-
const extension = getFileExtension(filePath);
|
|
116
|
-
switch (extension) {
|
|
117
|
-
case 'jpg':
|
|
118
|
-
case 'jpeg':
|
|
119
|
-
return 'image/jpeg';
|
|
120
|
-
case 'png':
|
|
121
|
-
return 'image/png';
|
|
122
|
-
case 'gif':
|
|
123
|
-
return 'image/gif';
|
|
124
|
-
case 'webp':
|
|
125
|
-
return 'image/webp';
|
|
126
|
-
case 'svg':
|
|
127
|
-
return 'image/svg+xml';
|
|
128
|
-
case 'mp3':
|
|
129
|
-
return 'audio/mpeg';
|
|
130
|
-
case 'm4a':
|
|
131
|
-
return 'audio/mp4';
|
|
132
|
-
case 'wav':
|
|
133
|
-
return 'audio/wav';
|
|
134
|
-
case 'ogg':
|
|
135
|
-
case 'opus':
|
|
136
|
-
return 'audio/ogg';
|
|
137
|
-
case 'mp4':
|
|
138
|
-
return 'video/mp4';
|
|
139
|
-
case 'mov':
|
|
140
|
-
return 'video/quicktime';
|
|
141
|
-
case 'webm':
|
|
142
|
-
return 'video/webm';
|
|
143
|
-
case 'pdf':
|
|
144
|
-
return 'application/pdf';
|
|
145
|
-
case 'json':
|
|
146
|
-
return 'application/json';
|
|
147
|
-
case 'md':
|
|
148
|
-
return 'text/markdown';
|
|
149
|
-
case 'txt':
|
|
150
|
-
return 'text/plain';
|
|
151
|
-
case 'csv':
|
|
152
|
-
return 'text/csv';
|
|
153
|
-
default:
|
|
154
|
-
return DEFAULT_CONTENT_TYPE;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
function buildObjectId(userId, category, filePath) {
|
|
158
|
-
const ext = getFileExtension(filePath);
|
|
159
|
-
const suffix = ext ? `.${ext}` : '';
|
|
160
|
-
return `assets/${userId}/${category}/${generateId()}${suffix}`;
|
|
161
|
-
}
|
|
162
|
-
function generateThumbnailKey(originalKey) {
|
|
163
|
-
const lastSlashIndex = originalKey.lastIndexOf('/');
|
|
164
|
-
if (lastSlashIndex === -1) {
|
|
165
|
-
const filenameWithoutExt = originalKey.replace(/\.[^.]+$/, '');
|
|
166
|
-
return `thumb/${filenameWithoutExt}_thumbnail.webp`;
|
|
167
|
-
}
|
|
168
|
-
const dirPath = originalKey.slice(0, lastSlashIndex);
|
|
169
|
-
const filename = originalKey.slice(lastSlashIndex + 1);
|
|
170
|
-
const filenameWithoutExt = filename.replace(/\.[^.]+$/, '');
|
|
171
|
-
return `${dirPath}/thumb/${filenameWithoutExt}_thumbnail.webp`;
|
|
172
|
-
}
|
|
173
|
-
function buildStorageTarget(config, key) {
|
|
174
|
-
assertStorageKeyUrlSafe(key);
|
|
175
|
-
const resolvedEndpoint = finishStorageHost(config.endpoint);
|
|
176
|
-
if (config.customDomain) {
|
|
177
|
-
const domain = finishStorageHost(config.customDomain);
|
|
178
|
-
return {
|
|
179
|
-
storageUrl: `${domain}/${encodeStorageKeyForUrl(key)}`,
|
|
180
|
-
domain,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
const target = config.forcePathStyle
|
|
184
|
-
? buildPathStyleUrl(config.bucket, resolvedEndpoint, key)
|
|
185
|
-
: buildVirtualHostedStyleUrl(config.bucket, resolvedEndpoint, key);
|
|
186
|
-
return {
|
|
187
|
-
storageUrl: target.finalUrl,
|
|
188
|
-
domain: target.domain,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
function isOfficialStorageProvider(provider) {
|
|
192
|
-
return provider === OFFICIAL_STORAGE_PROVIDER;
|
|
193
|
-
}
|
|
194
|
-
function getStorageProviderName(provider) {
|
|
195
|
-
if (isOfficialStorageProvider(provider)) {
|
|
196
|
-
return OFFICIAL_STORAGE_PROVIDER_NAME;
|
|
197
|
-
}
|
|
198
|
-
switch (provider) {
|
|
199
|
-
case 'bitiful':
|
|
200
|
-
return '缤纷云';
|
|
201
|
-
case 'tencent':
|
|
202
|
-
return '腾讯云';
|
|
203
|
-
case 'aliyun':
|
|
204
|
-
return '阿里云';
|
|
205
|
-
case 'aws':
|
|
206
|
-
return 'AWS';
|
|
207
|
-
case 'qiniu':
|
|
208
|
-
return '七牛云';
|
|
209
|
-
case 'cloudflare':
|
|
210
|
-
return 'Cloudflare';
|
|
211
|
-
default:
|
|
212
|
-
return provider;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
function toSanitizedStorage(config, isActive) {
|
|
216
|
-
return {
|
|
217
|
-
id: config.id,
|
|
218
|
-
aliasName: config.aliasName,
|
|
219
|
-
provider: config.provider,
|
|
220
|
-
endpoint: config.endpoint,
|
|
221
|
-
bucket: config.bucket,
|
|
222
|
-
region: config.region,
|
|
223
|
-
forcePathStyle: config.forcePathStyle,
|
|
224
|
-
pathPrefix: config.pathPrefix,
|
|
225
|
-
customDomain: config.customDomain,
|
|
226
|
-
legacyDomains: config.legacyDomains,
|
|
227
|
-
isPrivate: config.isPrivate,
|
|
228
|
-
hasAccessKeyId: config.accessKeyId.length > 0,
|
|
229
|
-
hasSecretAccessKey: config.secretAccessKey.length > 0,
|
|
230
|
-
isActive,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
function buildStorageUsageLabel(args) {
|
|
234
|
-
const { provider, bucket, configsByKey, configsByProvider } = args;
|
|
235
|
-
if (isOfficialStorageProvider(provider)) {
|
|
236
|
-
return {
|
|
237
|
-
label: OFFICIAL_STORAGE_PROVIDER_NAME,
|
|
238
|
-
storageConfigId: null,
|
|
239
|
-
aliasName: OFFICIAL_STORAGE_PROVIDER_NAME,
|
|
240
|
-
isOfficial: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
const exactConfig = configsByKey.get(`${provider}::${bucket}`);
|
|
244
|
-
if (exactConfig?.aliasName) {
|
|
245
|
-
return {
|
|
246
|
-
label: exactConfig.aliasName,
|
|
247
|
-
storageConfigId: exactConfig.id,
|
|
248
|
-
aliasName: exactConfig.aliasName,
|
|
249
|
-
isOfficial: false,
|
|
250
|
-
};
|
|
251
|
-
}
|
|
252
|
-
const sameProviderConfigs = configsByProvider.get(provider) ?? [];
|
|
253
|
-
if (sameProviderConfigs.length === 1 && sameProviderConfigs[0]?.aliasName) {
|
|
254
|
-
return {
|
|
255
|
-
label: sameProviderConfigs[0].aliasName,
|
|
256
|
-
storageConfigId: sameProviderConfigs[0].id,
|
|
257
|
-
aliasName: sameProviderConfigs[0].aliasName,
|
|
258
|
-
isOfficial: false,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
label: bucket ? `${getStorageProviderName(provider)} · ${bucket}` : getStorageProviderName(provider),
|
|
263
|
-
storageConfigId: exactConfig?.id ?? null,
|
|
264
|
-
aliasName: exactConfig?.aliasName ?? null,
|
|
265
|
-
isOfficial: false,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
async function getTableColumns(db, tableName) {
|
|
269
|
-
const rows = await db.getAll(`PRAGMA table_info(${tableName})`, []);
|
|
270
|
-
return new Set(rows.map((row) => row.name).filter(Boolean));
|
|
271
|
-
}
|
|
272
|
-
async function listStoredCustomStorageConfigs(db, userId) {
|
|
273
|
-
const rows = await db.getAll(`
|
|
274
|
-
SELECT
|
|
275
|
-
TRIM(COALESCE(id, '')) AS id,
|
|
276
|
-
TRIM(COALESCE(alias_name, '')) AS alias_name,
|
|
277
|
-
TRIM(COALESCE(protocol, '')) AS protocol,
|
|
278
|
-
TRIM(COALESCE(provider, '')) AS provider,
|
|
279
|
-
TRIM(COALESCE(endpoint, '')) AS endpoint,
|
|
280
|
-
TRIM(COALESCE(bucket, '')) AS bucket,
|
|
281
|
-
TRIM(COALESCE(region, '')) AS region,
|
|
282
|
-
TRIM(COALESCE(access_key_id, '')) AS access_key_id,
|
|
283
|
-
TRIM(COALESCE(secret_access_key, '')) AS secret_access_key,
|
|
284
|
-
force_path_style,
|
|
285
|
-
TRIM(COALESCE(path_prefix, '')) AS path_prefix,
|
|
286
|
-
TRIM(COALESCE(custom_domain, '')) AS custom_domain,
|
|
287
|
-
legacy_domains,
|
|
288
|
-
is_private,
|
|
289
|
-
updated_at,
|
|
290
|
-
deleted_at
|
|
291
|
-
FROM c_storage
|
|
292
|
-
WHERE TRIM(COALESCE(user_id, '')) = ?
|
|
293
|
-
AND TRIM(COALESCE(protocol, '')) = ?
|
|
294
|
-
AND deleted_at IS NULL
|
|
295
|
-
AND TRIM(COALESCE(id, '')) <> ''
|
|
296
|
-
ORDER BY COALESCE(sort_rank, 2147483647) ASC, updated_at DESC, id ASC
|
|
297
|
-
`, [userId, DEFAULT_STORAGE_PROTOCOL]);
|
|
298
|
-
return rows
|
|
299
|
-
.map((row) => ({
|
|
300
|
-
id: normalizeText(row.id),
|
|
301
|
-
aliasName: normalizeText(row.alias_name),
|
|
302
|
-
provider: normalizeText(row.provider),
|
|
303
|
-
endpoint: normalizeText(row.endpoint),
|
|
304
|
-
bucket: normalizeText(row.bucket),
|
|
305
|
-
region: normalizeText(row.region),
|
|
306
|
-
accessKeyId: normalizeText(row.access_key_id),
|
|
307
|
-
secretAccessKey: normalizeText(row.secret_access_key),
|
|
308
|
-
forcePathStyle: normalizeBool(row.force_path_style),
|
|
309
|
-
pathPrefix: normalizeStoragePathPrefix(normalizeText(row.path_prefix)),
|
|
310
|
-
customDomain: normalizeText(row.custom_domain),
|
|
311
|
-
legacyDomains: normalizeTextArray(row.legacy_domains),
|
|
312
|
-
isPrivate: normalizeBool(row.is_private),
|
|
313
|
-
}))
|
|
314
|
-
.filter((row) => row.id.length > 0);
|
|
315
|
-
}
|
|
316
|
-
async function getActiveCustomStorageConfigId(db, userId) {
|
|
317
|
-
const rows = await db.getAll(`
|
|
318
|
-
SELECT c_value
|
|
319
|
-
FROM c_config_property
|
|
320
|
-
WHERE TRIM(COALESCE(c_key, '')) = ?
|
|
321
|
-
AND TRIM(COALESCE(user_id, '')) = ?
|
|
322
|
-
AND (is_del IS NULL OR is_del = 0)
|
|
323
|
-
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
324
|
-
LIMIT 1
|
|
325
|
-
`, [ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY, userId]);
|
|
326
|
-
return normalizeText(rows[0]?.c_value ?? null) || null;
|
|
327
|
-
}
|
|
328
|
-
function resolveStorageSelection(args) {
|
|
329
|
-
const explicitId = normalizeText(args.storageId);
|
|
330
|
-
if (explicitId) {
|
|
331
|
-
const config = args.configs.find((item) => item.id === explicitId);
|
|
332
|
-
if (!config) {
|
|
333
|
-
throw new DinoxError(`Storage config not found: ${explicitId}`, {
|
|
334
|
-
suggestion: 'Run `dino storage list --format json` to inspect available storage ids.',
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
return { config, selectionSource: 'storage_id' };
|
|
338
|
-
}
|
|
339
|
-
const activeId = normalizeText(args.activeCustomStorageConfigId);
|
|
340
|
-
if (activeId) {
|
|
341
|
-
const config = args.configs.find((item) => item.id === activeId);
|
|
342
|
-
if (config) {
|
|
343
|
-
return { config, selectionSource: 'active' };
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
if (args.configs.length === 1) {
|
|
347
|
-
return { config: args.configs[0], selectionSource: 'single_available_config' };
|
|
348
|
-
}
|
|
349
|
-
throw new DinoxError('No active custom storage config is available for upload.', {
|
|
350
|
-
suggestion: 'Set an active custom storage config in the app, or pass `--storage-id <id>`.',
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
function buildStorageUploadPlan(args) {
|
|
354
|
-
const storageKey = normalizeText(args.key) || `${args.config.pathPrefix}${buildObjectId(args.userId, args.category, args.filePath)}`;
|
|
355
|
-
const target = buildStorageTarget(args.config, storageKey);
|
|
356
|
-
return {
|
|
357
|
-
storage: toSanitizedStorage({
|
|
358
|
-
...args.config,
|
|
359
|
-
endpoint: finishStorageHost(args.config.endpoint),
|
|
360
|
-
}, args.selectionSource === 'active'),
|
|
361
|
-
selectionSource: args.selectionSource,
|
|
362
|
-
filePath: args.filePath,
|
|
363
|
-
fileName: path.basename(args.filePath),
|
|
364
|
-
sizeBytes: args.sizeBytes,
|
|
365
|
-
category: args.category,
|
|
366
|
-
contentType: guessContentType(args.filePath),
|
|
367
|
-
storageKey,
|
|
368
|
-
storageUrl: target.storageUrl,
|
|
369
|
-
domain: target.domain,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
function buildStorageTestPlan(args) {
|
|
373
|
-
const storageKey = `${args.config.pathPrefix}dinox-cli-test/${args.userId}/${generateId()}.txt`;
|
|
374
|
-
const target = buildStorageTarget(args.config, storageKey);
|
|
375
|
-
const payload = Buffer.from(`dinox-cli storage test\nprovider=${args.config.provider}\nuser=${args.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
|
|
376
|
-
return {
|
|
377
|
-
storage: toSanitizedStorage({
|
|
378
|
-
...args.config,
|
|
379
|
-
endpoint: finishStorageHost(args.config.endpoint),
|
|
380
|
-
}, args.selectionSource === 'active'),
|
|
381
|
-
selectionSource: args.selectionSource,
|
|
382
|
-
contentType: 'text/plain',
|
|
383
|
-
sizeBytes: payload.byteLength,
|
|
384
|
-
storageKey,
|
|
385
|
-
storageUrl: target.storageUrl,
|
|
386
|
-
domain: target.domain,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
async function defaultUploadObject(input) {
|
|
390
|
-
const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
|
|
391
|
-
const client = new S3Client({
|
|
392
|
-
endpoint: finishStorageHost(input.storage.endpoint),
|
|
393
|
-
region: input.storage.region,
|
|
394
|
-
forcePathStyle: input.storage.forcePathStyle,
|
|
395
|
-
credentials: {
|
|
396
|
-
accessKeyId: input.storage.accessKeyId,
|
|
397
|
-
secretAccessKey: input.storage.secretAccessKey,
|
|
398
|
-
},
|
|
399
|
-
});
|
|
400
|
-
const response = await client.send(new PutObjectCommand({
|
|
401
|
-
Bucket: input.bucket,
|
|
402
|
-
Key: input.key,
|
|
403
|
-
Body: fs.createReadStream(input.localPath),
|
|
404
|
-
ContentType: input.contentType,
|
|
405
|
-
ContentLength: input.contentLength,
|
|
406
|
-
IfNoneMatch: input.overwrite ? undefined : '*',
|
|
407
|
-
}));
|
|
408
|
-
return {
|
|
409
|
-
etag: normalizeText(response.ETag) || null,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
function isObjectCollisionError(error) {
|
|
413
|
-
if (!error || typeof error !== 'object') {
|
|
414
|
-
return false;
|
|
415
|
-
}
|
|
416
|
-
const record = error;
|
|
417
|
-
return record.$metadata?.httpStatusCode === 409
|
|
418
|
-
|| record.$metadata?.httpStatusCode === 412
|
|
419
|
-
|| record.name === 'ConditionalRequestConflict'
|
|
420
|
-
|| record.name === 'PreconditionFailed'
|
|
421
|
-
|| record.Code === 'PreconditionFailed'
|
|
422
|
-
|| record.code === 'PreconditionFailed';
|
|
423
|
-
}
|
|
424
|
-
function createStorageCollisionError(bucket, keys, failureDetails = {}) {
|
|
425
|
-
return new DinoxError(`Storage object already exists: ${keys.join(', ')}`, {
|
|
426
|
-
code: 'STORAGE_OBJECT_ALREADY_EXISTS',
|
|
427
|
-
suggestion: 'Choose a different --key, or pass --overwrite to replace the existing object.',
|
|
428
|
-
recoverable: true,
|
|
429
|
-
details: {
|
|
430
|
-
bucket,
|
|
431
|
-
keys,
|
|
432
|
-
...failureDetails,
|
|
433
|
-
},
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
function createStorageUploadOutcomeUnknownError(input) {
|
|
437
|
-
return new DinoxError(input.message, {
|
|
438
|
-
cause: input.cause,
|
|
439
|
-
code: 'STORAGE_UPLOAD_OUTCOME_UNKNOWN',
|
|
440
|
-
recoverable: true,
|
|
441
|
-
suggestion: 'Inspect the reported object key in remote storage before retrying; do not assume the failed request left the object unchanged.',
|
|
442
|
-
details: {
|
|
443
|
-
bucket: input.bucket,
|
|
444
|
-
storageKey: input.storageKey,
|
|
445
|
-
operation: input.operation,
|
|
446
|
-
overwrite: input.overwrite,
|
|
447
|
-
remoteOutcome: 'unknown',
|
|
448
|
-
remoteObjectChanged: null,
|
|
449
|
-
affectedKeys: [],
|
|
450
|
-
possiblyAffectedKeys: [...new Set(input.possiblyAffectedKeys)],
|
|
451
|
-
},
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
async function defaultDeleteObject(input) {
|
|
455
|
-
const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
|
|
456
|
-
const client = new S3Client({
|
|
457
|
-
endpoint: finishStorageHost(input.storage.endpoint),
|
|
458
|
-
region: input.storage.region,
|
|
459
|
-
forcePathStyle: input.storage.forcePathStyle,
|
|
460
|
-
credentials: {
|
|
461
|
-
accessKeyId: input.storage.accessKeyId,
|
|
462
|
-
secretAccessKey: input.storage.secretAccessKey,
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
await client.send(new DeleteObjectCommand({
|
|
466
|
-
Bucket: input.bucket,
|
|
467
|
-
Key: input.key,
|
|
468
|
-
IfMatch: normalizeText(input.etag) || undefined,
|
|
469
|
-
}));
|
|
470
|
-
}
|
|
471
|
-
async function defaultUploadBytes(input) {
|
|
472
|
-
const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
|
|
473
|
-
const client = new S3Client({
|
|
474
|
-
endpoint: finishStorageHost(input.storage.endpoint),
|
|
475
|
-
region: input.storage.region,
|
|
476
|
-
forcePathStyle: input.storage.forcePathStyle,
|
|
477
|
-
credentials: {
|
|
478
|
-
accessKeyId: input.storage.accessKeyId,
|
|
479
|
-
secretAccessKey: input.storage.secretAccessKey,
|
|
480
|
-
},
|
|
481
|
-
});
|
|
482
|
-
const response = await client.send(new PutObjectCommand({
|
|
483
|
-
Bucket: input.bucket,
|
|
484
|
-
Key: input.key,
|
|
485
|
-
Body: input.body,
|
|
486
|
-
ContentType: input.contentType,
|
|
487
|
-
ContentLength: input.body.byteLength,
|
|
488
|
-
IfNoneMatch: input.overwrite ? undefined : '*',
|
|
489
|
-
}));
|
|
490
|
-
return {
|
|
491
|
-
etag: normalizeText(response.ETag) || null,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
async function generateThumbnailBuffer(localPath) {
|
|
495
|
-
const { default: sharp } = await import('sharp');
|
|
496
|
-
return sharp(localPath)
|
|
497
|
-
.resize({ width: 400, withoutEnlargement: true })
|
|
498
|
-
.webp({ quality: 50 })
|
|
499
|
-
.toBuffer();
|
|
500
|
-
}
|
|
501
|
-
function buildUploadedResourceEntries(columns, args, id, now) {
|
|
502
|
-
const entries = [];
|
|
503
|
-
const push = (column, value) => {
|
|
504
|
-
if (columns.has(column)) {
|
|
505
|
-
entries.push([column, value]);
|
|
506
|
-
}
|
|
507
|
-
};
|
|
508
|
-
push('id', id);
|
|
509
|
-
push('user_id', args.userId);
|
|
510
|
-
push('name', args.plan.fileName);
|
|
511
|
-
push('mime_type', args.plan.contentType);
|
|
512
|
-
push('size_bytes', args.plan.sizeBytes);
|
|
513
|
-
push('storage_config_id', args.storage.id);
|
|
514
|
-
push('storage_key', args.plan.storageKey);
|
|
515
|
-
push('storage_url', args.plan.storageUrl);
|
|
516
|
-
push('storage_provider', args.storage.provider);
|
|
517
|
-
push('bucket', args.storage.bucket);
|
|
518
|
-
push('is_public', args.storage.isPrivate ? 0 : 1);
|
|
519
|
-
push('created_at', now);
|
|
520
|
-
push('updated_at', now);
|
|
521
|
-
push('is_del', 0);
|
|
522
|
-
push('status', 'uploaded');
|
|
523
|
-
push('uploaded_at', now);
|
|
524
|
-
push('etag', args.etag);
|
|
525
|
-
push('checksum', null);
|
|
526
|
-
push('category', args.plan.category);
|
|
527
|
-
push('endpoint', args.storage.endpoint);
|
|
528
|
-
push('domain', args.plan.domain);
|
|
529
|
-
push('extra_data', '{}');
|
|
530
|
-
push('deleted_at', null);
|
|
531
|
-
push('content_sha256', null);
|
|
532
|
-
push('content', null);
|
|
533
|
-
push('content_md', null);
|
|
534
|
-
push('summary', null);
|
|
535
|
-
return entries;
|
|
536
|
-
}
|
|
537
|
-
async function insertResourceRow(db, entries) {
|
|
538
|
-
const insertColumns = entries.map(([column]) => column);
|
|
539
|
-
const placeholders = insertColumns.map(() => '?').join(', ');
|
|
540
|
-
await db.execute(`
|
|
541
|
-
INSERT INTO c_resource (${insertColumns.join(', ')})
|
|
542
|
-
VALUES (${placeholders})
|
|
543
|
-
`, entries.map(([, value]) => value));
|
|
544
|
-
}
|
|
545
|
-
async function persistUploadedResource(db, args) {
|
|
546
|
-
const columns = await getTableColumns(db, 'c_resource');
|
|
547
|
-
const requiredColumns = [
|
|
548
|
-
'id',
|
|
549
|
-
'user_id',
|
|
550
|
-
'name',
|
|
551
|
-
'mime_type',
|
|
552
|
-
'size_bytes',
|
|
553
|
-
'storage_key',
|
|
554
|
-
'storage_url',
|
|
555
|
-
'storage_provider',
|
|
556
|
-
'bucket',
|
|
557
|
-
'created_at',
|
|
558
|
-
'updated_at',
|
|
559
|
-
'is_del',
|
|
560
|
-
];
|
|
561
|
-
const missingRequired = requiredColumns.filter((column) => !columns.has(column));
|
|
562
|
-
if (missingRequired.length > 0) {
|
|
563
|
-
throw new DinoxError(`c_resource table is missing required columns: ${missingRequired.join(', ')}`);
|
|
564
|
-
}
|
|
565
|
-
const id = generateId();
|
|
566
|
-
const now = new Date().toISOString();
|
|
567
|
-
const entries = buildUploadedResourceEntries(columns, args, id, now);
|
|
568
|
-
if (!args.overwrite) {
|
|
569
|
-
await insertResourceRow(db, entries);
|
|
570
|
-
return id;
|
|
571
|
-
}
|
|
572
|
-
return withPowerSyncWriteTransaction(db, async (tx) => {
|
|
573
|
-
const identityClauses = [
|
|
574
|
-
'user_id = ?',
|
|
575
|
-
'storage_key = ?',
|
|
576
|
-
'COALESCE(is_del, 0) = 0',
|
|
577
|
-
];
|
|
578
|
-
const identityParams = [args.userId, args.plan.storageKey];
|
|
579
|
-
if (columns.has('storage_config_id')) {
|
|
580
|
-
identityClauses.push('storage_config_id = ?');
|
|
581
|
-
identityParams.push(args.storage.id);
|
|
582
|
-
}
|
|
583
|
-
else {
|
|
584
|
-
identityClauses.push('storage_provider = ?', 'bucket = ?');
|
|
585
|
-
identityParams.push(args.storage.provider, args.storage.bucket);
|
|
586
|
-
if (columns.has('endpoint')) {
|
|
587
|
-
identityClauses.push('endpoint = ?');
|
|
588
|
-
identityParams.push(args.storage.endpoint);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
const existingRows = await tx.getAll(`
|
|
592
|
-
SELECT id
|
|
593
|
-
FROM c_resource
|
|
594
|
-
WHERE ${identityClauses.join('\n AND ')}
|
|
595
|
-
ORDER BY created_at ASC, id ASC
|
|
596
|
-
`, identityParams);
|
|
597
|
-
const existingIds = [...new Set(existingRows.map((row) => normalizeText(row.id)).filter(Boolean))];
|
|
598
|
-
const existingId = existingIds[0];
|
|
599
|
-
if (!existingId) {
|
|
600
|
-
await insertResourceRow(tx, entries);
|
|
601
|
-
return id;
|
|
602
|
-
}
|
|
603
|
-
const updateEntries = entries.filter(([column]) => (column !== 'id' && column !== 'user_id' && column !== 'created_at'));
|
|
604
|
-
await tx.execute(`
|
|
605
|
-
UPDATE c_resource
|
|
606
|
-
SET ${updateEntries.map(([column]) => `${column} = ?`).join(', ')}
|
|
607
|
-
WHERE id = ?
|
|
608
|
-
AND user_id = ?
|
|
609
|
-
AND COALESCE(is_del, 0) = 0
|
|
610
|
-
`, [...updateEntries.map(([, value]) => value), existingId, args.userId]);
|
|
611
|
-
const duplicateIds = existingIds.slice(1);
|
|
612
|
-
if (duplicateIds.length > 0) {
|
|
613
|
-
const duplicateUpdates = [
|
|
614
|
-
['updated_at', now],
|
|
615
|
-
['is_del', 1],
|
|
616
|
-
...(columns.has('deleted_at') ? [['deleted_at', now]] : []),
|
|
617
|
-
];
|
|
618
|
-
await tx.execute(`
|
|
619
|
-
UPDATE c_resource
|
|
620
|
-
SET ${duplicateUpdates.map(([column]) => `${column} = ?`).join(', ')}
|
|
621
|
-
WHERE user_id = ?
|
|
622
|
-
AND COALESCE(is_del, 0) = 0
|
|
623
|
-
AND id IN (${duplicateIds.map(() => '?').join(', ')})
|
|
624
|
-
`, [...duplicateUpdates.map(([, value]) => value), args.userId, ...duplicateIds]);
|
|
625
|
-
}
|
|
626
|
-
return existingId;
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
export function parseStorageUploadCategory(value) {
|
|
630
|
-
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
631
|
-
if (UPLOAD_CATEGORY_VALUES.includes(normalized)) {
|
|
632
|
-
return normalized;
|
|
633
|
-
}
|
|
634
|
-
throw new DinoxError(`--category must be one of: ${UPLOAD_CATEGORY_VALUES.join(', ')}`);
|
|
635
|
-
}
|
|
636
|
-
export async function listStorageConfigs(db, userId) {
|
|
637
|
-
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, userId);
|
|
638
|
-
const configs = await listStoredCustomStorageConfigs(db, userId);
|
|
639
|
-
return {
|
|
640
|
-
activeCustomStorageConfigId,
|
|
641
|
-
usesOfficialStorage: !activeCustomStorageConfigId,
|
|
642
|
-
items: configs.map((config) => toSanitizedStorage(config, config.id === activeCustomStorageConfigId)),
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
export async function getStorageStats(db, userId) {
|
|
646
|
-
const rows = await db.getAll(`
|
|
647
|
-
SELECT
|
|
648
|
-
TRIM(COALESCE(storage_provider, '')) AS provider,
|
|
649
|
-
TRIM(COALESCE(bucket, '')) AS bucket,
|
|
650
|
-
COALESCE(SUM(COALESCE(size_bytes, 0)), 0) AS total_bytes,
|
|
651
|
-
COUNT(1) AS file_count
|
|
652
|
-
FROM c_resource
|
|
653
|
-
WHERE (is_del IS NULL OR is_del = 0)
|
|
654
|
-
AND TRIM(COALESCE(user_id, '')) = ?
|
|
655
|
-
AND TRIM(COALESCE(storage_provider, '')) <> ''
|
|
656
|
-
AND TRIM(COALESCE(storage_provider, '')) <> 'local'
|
|
657
|
-
AND TRIM(COALESCE(status, '')) = 'uploaded'
|
|
658
|
-
GROUP BY storage_provider, bucket
|
|
659
|
-
ORDER BY total_bytes DESC, provider ASC, bucket ASC
|
|
660
|
-
`, [userId]);
|
|
661
|
-
const configs = await listStoredCustomStorageConfigs(db, userId);
|
|
662
|
-
const configsByKey = new Map();
|
|
663
|
-
const configsByProvider = new Map();
|
|
664
|
-
for (const config of configs) {
|
|
665
|
-
configsByKey.set(`${config.provider}::${config.bucket}`, config);
|
|
666
|
-
const current = configsByProvider.get(config.provider);
|
|
667
|
-
if (current) {
|
|
668
|
-
current.push(config);
|
|
669
|
-
}
|
|
670
|
-
else {
|
|
671
|
-
configsByProvider.set(config.provider, [config]);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const entries = rows
|
|
675
|
-
.map((row) => {
|
|
676
|
-
const provider = normalizeText(row.provider);
|
|
677
|
-
const bucket = normalizeText(row.bucket);
|
|
678
|
-
const labelInfo = buildStorageUsageLabel({
|
|
679
|
-
provider,
|
|
680
|
-
bucket,
|
|
681
|
-
configsByKey,
|
|
682
|
-
configsByProvider,
|
|
683
|
-
});
|
|
684
|
-
return {
|
|
685
|
-
provider,
|
|
686
|
-
bucket,
|
|
687
|
-
label: labelInfo.label,
|
|
688
|
-
totalBytes: normalizeNumber(row.total_bytes),
|
|
689
|
-
fileCount: normalizeNumber(row.file_count),
|
|
690
|
-
storageConfigId: labelInfo.storageConfigId,
|
|
691
|
-
aliasName: labelInfo.aliasName,
|
|
692
|
-
isOfficial: labelInfo.isOfficial,
|
|
693
|
-
};
|
|
694
|
-
})
|
|
695
|
-
.filter((entry) => entry.provider.length > 0 && entry.fileCount > 0)
|
|
696
|
-
.sort((left, right) => {
|
|
697
|
-
if (left.isOfficial !== right.isOfficial) {
|
|
698
|
-
return left.isOfficial ? -1 : 1;
|
|
699
|
-
}
|
|
700
|
-
return right.totalBytes - left.totalBytes;
|
|
701
|
-
});
|
|
702
|
-
return {
|
|
703
|
-
totalBytes: entries.reduce((sum, entry) => sum + entry.totalBytes, 0),
|
|
704
|
-
totalFiles: entries.reduce((sum, entry) => sum + entry.fileCount, 0),
|
|
705
|
-
entries,
|
|
706
|
-
};
|
|
707
|
-
}
|
|
708
|
-
export async function inspectStorageUpload(db, input, deps) {
|
|
709
|
-
const statFile = deps?.statFile ?? fsp.stat;
|
|
710
|
-
const filePath = input.filePath.trim();
|
|
711
|
-
if (!filePath) {
|
|
712
|
-
throw new DinoxError('File path is required.');
|
|
713
|
-
}
|
|
714
|
-
const stats = await statFile(filePath).catch((error) => {
|
|
715
|
-
throw new DinoxError(`File not found: ${filePath}`, { cause: error });
|
|
716
|
-
});
|
|
717
|
-
if (!stats.isFile()) {
|
|
718
|
-
throw new DinoxError(`Not a regular file: ${filePath}`);
|
|
719
|
-
}
|
|
720
|
-
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
721
|
-
if (configs.length === 0) {
|
|
722
|
-
throw new DinoxError('No custom storage configs are available.', {
|
|
723
|
-
suggestion: 'Configure a custom storage target in the app first.',
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
|
|
727
|
-
const { config, selectionSource } = resolveStorageSelection({
|
|
728
|
-
configs,
|
|
729
|
-
storageId: input.storageId,
|
|
730
|
-
activeCustomStorageConfigId,
|
|
731
|
-
});
|
|
732
|
-
if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
|
|
733
|
-
throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
|
|
734
|
-
suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
return buildStorageUploadPlan({
|
|
738
|
-
config,
|
|
739
|
-
selectionSource,
|
|
740
|
-
userId: input.userId,
|
|
741
|
-
filePath,
|
|
742
|
-
category: input.category,
|
|
743
|
-
key: input.key,
|
|
744
|
-
sizeBytes: stats.size,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
export async function inspectStorageTest(db, input) {
|
|
748
|
-
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
749
|
-
if (configs.length === 0) {
|
|
750
|
-
throw new DinoxError('No custom storage configs are available.', {
|
|
751
|
-
suggestion: 'Configure a custom storage target in the app first.',
|
|
752
|
-
});
|
|
753
|
-
}
|
|
754
|
-
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
|
|
755
|
-
const { config, selectionSource } = resolveStorageSelection({
|
|
756
|
-
configs,
|
|
757
|
-
storageId: input.storageId,
|
|
758
|
-
activeCustomStorageConfigId,
|
|
759
|
-
});
|
|
760
|
-
if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
|
|
761
|
-
throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
|
|
762
|
-
suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
return buildStorageTestPlan({
|
|
766
|
-
config,
|
|
767
|
-
selectionSource,
|
|
768
|
-
userId: input.userId,
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
export async function testStorageUpload(db, input, deps) {
|
|
772
|
-
const plan = await inspectStorageTest(db, input);
|
|
773
|
-
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
774
|
-
const storage = configs.find((item) => item.id === plan.storage.id);
|
|
775
|
-
if (!storage) {
|
|
776
|
-
throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
|
|
777
|
-
}
|
|
778
|
-
const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
|
|
779
|
-
const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
|
|
780
|
-
const payload = Buffer.from(`dinox-cli storage test\nprovider=${storage.provider}\nuser=${input.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
|
|
781
|
-
let uploaded;
|
|
782
|
-
try {
|
|
783
|
-
uploaded = await uploadBytes({
|
|
784
|
-
body: payload,
|
|
785
|
-
bucket: storage.bucket,
|
|
786
|
-
key: plan.storageKey,
|
|
787
|
-
contentType: plan.contentType,
|
|
788
|
-
storage,
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
catch (error) {
|
|
792
|
-
if (isObjectCollisionError(error)) {
|
|
793
|
-
throw error;
|
|
794
|
-
}
|
|
795
|
-
throw createStorageUploadOutcomeUnknownError({
|
|
796
|
-
message: 'Storage test upload outcome could not be confirmed.',
|
|
797
|
-
cause: error,
|
|
798
|
-
bucket: storage.bucket,
|
|
799
|
-
storageKey: plan.storageKey,
|
|
800
|
-
possiblyAffectedKeys: [plan.storageKey],
|
|
801
|
-
overwrite: false,
|
|
802
|
-
operation: 'storage_test',
|
|
803
|
-
});
|
|
804
|
-
}
|
|
805
|
-
const cleanupEtag = normalizeText(uploaded.etag);
|
|
806
|
-
if (!cleanupEtag) {
|
|
807
|
-
throw new DinoxError('Storage test uploaded the temporary object but could not clean it up safely.', {
|
|
808
|
-
code: 'STORAGE_TEST_CLEANUP_FAILED',
|
|
809
|
-
details: {
|
|
810
|
-
storageKey: plan.storageKey,
|
|
811
|
-
bucket: storage.bucket,
|
|
812
|
-
remoteObjectChanged: true,
|
|
813
|
-
affectedKeys: [plan.storageKey],
|
|
814
|
-
cleanupErrors: [
|
|
815
|
-
`${plan.storageKey} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`,
|
|
816
|
-
],
|
|
817
|
-
},
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
try {
|
|
821
|
-
await deleteObject({
|
|
822
|
-
bucket: storage.bucket,
|
|
823
|
-
key: plan.storageKey,
|
|
824
|
-
etag: cleanupEtag,
|
|
825
|
-
storage,
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
catch (error) {
|
|
829
|
-
throw new DinoxError('Storage test uploaded the temporary object but failed to clean it up.', {
|
|
830
|
-
cause: error,
|
|
831
|
-
code: 'STORAGE_TEST_CLEANUP_FAILED',
|
|
832
|
-
details: {
|
|
833
|
-
storageKey: plan.storageKey,
|
|
834
|
-
bucket: storage.bucket,
|
|
835
|
-
remoteObjectChanged: true,
|
|
836
|
-
affectedKeys: [plan.storageKey],
|
|
837
|
-
cleanupErrors: [
|
|
838
|
-
`${plan.storageKey} cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
839
|
-
],
|
|
840
|
-
},
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
return {
|
|
844
|
-
storageConfigId: storage.id,
|
|
845
|
-
aliasName: storage.aliasName,
|
|
846
|
-
provider: storage.provider,
|
|
847
|
-
bucket: storage.bucket,
|
|
848
|
-
endpoint: storage.endpoint,
|
|
849
|
-
domain: plan.domain,
|
|
850
|
-
storageKey: plan.storageKey,
|
|
851
|
-
storageUrl: plan.storageUrl,
|
|
852
|
-
contentType: plan.contentType,
|
|
853
|
-
sizeBytes: plan.sizeBytes,
|
|
854
|
-
selectionSource: plan.selectionSource,
|
|
855
|
-
isPrivate: storage.isPrivate,
|
|
856
|
-
};
|
|
857
|
-
}
|
|
858
|
-
export async function uploadFileToStorage(db, input, deps) {
|
|
859
|
-
if (input.overwrite && !normalizeText(input.key)) {
|
|
860
|
-
throw new DinoxError('--overwrite requires an explicit --key.', {
|
|
861
|
-
code: 'USAGE_ERROR',
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
const plan = await inspectStorageUpload(db, input, { statFile: deps?.statFile });
|
|
865
|
-
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
866
|
-
const storage = configs.find((item) => item.id === plan.storage.id);
|
|
867
|
-
if (!storage) {
|
|
868
|
-
throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
|
|
869
|
-
}
|
|
870
|
-
const overwrite = input.overwrite === true;
|
|
871
|
-
const uploadObject = deps?.uploadObject ?? defaultUploadObject;
|
|
872
|
-
const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
|
|
873
|
-
const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
|
|
874
|
-
const thumbnailKey = plan.contentType.startsWith('image/')
|
|
875
|
-
? generateThumbnailKey(plan.storageKey)
|
|
876
|
-
: null;
|
|
877
|
-
let uploaded;
|
|
878
|
-
try {
|
|
879
|
-
uploaded = await uploadObject({
|
|
880
|
-
localPath: plan.filePath,
|
|
881
|
-
bucket: storage.bucket,
|
|
882
|
-
key: plan.storageKey,
|
|
883
|
-
contentType: plan.contentType,
|
|
884
|
-
contentLength: plan.sizeBytes,
|
|
885
|
-
overwrite,
|
|
886
|
-
storage,
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
catch (error) {
|
|
890
|
-
if (!overwrite && isObjectCollisionError(error)) {
|
|
891
|
-
throw createStorageCollisionError(storage.bucket, [plan.storageKey]);
|
|
892
|
-
}
|
|
893
|
-
throw createStorageUploadOutcomeUnknownError({
|
|
894
|
-
message: 'Storage upload outcome could not be confirmed.',
|
|
895
|
-
cause: error,
|
|
896
|
-
bucket: storage.bucket,
|
|
897
|
-
storageKey: plan.storageKey,
|
|
898
|
-
possiblyAffectedKeys: [plan.storageKey],
|
|
899
|
-
overwrite,
|
|
900
|
-
operation: 'upload',
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
let thumbnailUrl = null;
|
|
904
|
-
const possiblyAffectedKeys = new Set();
|
|
905
|
-
const remoteWrites = [{
|
|
906
|
-
key: plan.storageKey,
|
|
907
|
-
etag: uploaded.etag,
|
|
908
|
-
cleanupAllowed: !overwrite,
|
|
909
|
-
cleaned: false,
|
|
910
|
-
}];
|
|
911
|
-
const cleanupUploadedObjects = async () => {
|
|
912
|
-
const cleanupErrors = [];
|
|
913
|
-
for (const remoteWrite of [...remoteWrites].reverse()) {
|
|
914
|
-
if (!remoteWrite.cleanupAllowed || remoteWrite.cleaned) {
|
|
915
|
-
continue;
|
|
916
|
-
}
|
|
917
|
-
const cleanupEtag = normalizeText(remoteWrite.etag);
|
|
918
|
-
if (!cleanupEtag) {
|
|
919
|
-
cleanupErrors.push(`${remoteWrite.key} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`);
|
|
920
|
-
continue;
|
|
921
|
-
}
|
|
922
|
-
await deleteObject({
|
|
923
|
-
bucket: storage.bucket,
|
|
924
|
-
key: remoteWrite.key,
|
|
925
|
-
etag: cleanupEtag,
|
|
926
|
-
storage,
|
|
927
|
-
}).then(() => {
|
|
928
|
-
remoteWrite.cleaned = true;
|
|
929
|
-
}, (cleanupError) => {
|
|
930
|
-
cleanupErrors.push(`${remoteWrite.key} cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
return cleanupErrors;
|
|
934
|
-
};
|
|
935
|
-
const buildRemoteFailureDetails = (cleanupErrors) => {
|
|
936
|
-
const affectedKeys = remoteWrites
|
|
937
|
-
.filter((remoteWrite) => !remoteWrite.cleaned)
|
|
938
|
-
.map((remoteWrite) => remoteWrite.key);
|
|
939
|
-
const possibleKeys = [...possiblyAffectedKeys];
|
|
940
|
-
return {
|
|
941
|
-
bucket: storage.bucket,
|
|
942
|
-
storageKey: plan.storageKey,
|
|
943
|
-
remoteObjectChanged: possibleKeys.length > 0
|
|
944
|
-
? (affectedKeys.length > 0 ? true : null)
|
|
945
|
-
: affectedKeys.length > 0,
|
|
946
|
-
affectedKeys,
|
|
947
|
-
...(possibleKeys.length > 0
|
|
948
|
-
? { remoteOutcome: 'unknown', possiblyAffectedKeys: possibleKeys }
|
|
949
|
-
: {}),
|
|
950
|
-
...(cleanupErrors.length > 0 ? { cleanupErrors } : {}),
|
|
951
|
-
};
|
|
952
|
-
};
|
|
953
|
-
if (plan.contentType.startsWith('image/')) {
|
|
954
|
-
try {
|
|
955
|
-
if (!thumbnailKey) {
|
|
956
|
-
throw new Error('Thumbnail key could not be generated.');
|
|
957
|
-
}
|
|
958
|
-
const thumbnailBuffer = await generateThumbnailBuffer(plan.filePath);
|
|
959
|
-
const thumbnailTarget = buildStorageTarget(storage, thumbnailKey);
|
|
960
|
-
let uploadedThumbnail;
|
|
961
|
-
try {
|
|
962
|
-
uploadedThumbnail = await uploadBytes({
|
|
963
|
-
body: thumbnailBuffer,
|
|
964
|
-
bucket: storage.bucket,
|
|
965
|
-
key: thumbnailKey,
|
|
966
|
-
contentType: 'image/webp',
|
|
967
|
-
overwrite,
|
|
968
|
-
storage,
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
catch (error) {
|
|
972
|
-
if (!isObjectCollisionError(error)) {
|
|
973
|
-
possiblyAffectedKeys.add(thumbnailKey);
|
|
974
|
-
}
|
|
975
|
-
throw error;
|
|
976
|
-
}
|
|
977
|
-
remoteWrites.push({
|
|
978
|
-
key: thumbnailKey,
|
|
979
|
-
etag: uploadedThumbnail.etag,
|
|
980
|
-
cleanupAllowed: !overwrite,
|
|
981
|
-
cleaned: false,
|
|
982
|
-
});
|
|
983
|
-
thumbnailUrl = thumbnailTarget.storageUrl;
|
|
984
|
-
}
|
|
985
|
-
catch (error) {
|
|
986
|
-
const cleanupErrors = await cleanupUploadedObjects();
|
|
987
|
-
if (!overwrite && isObjectCollisionError(error) && thumbnailKey) {
|
|
988
|
-
throw createStorageCollisionError(storage.bucket, [thumbnailKey], buildRemoteFailureDetails(cleanupErrors));
|
|
989
|
-
}
|
|
990
|
-
throw new DinoxError('Storage upload failed while generating the required thumbnail.', {
|
|
991
|
-
cause: error,
|
|
992
|
-
code: 'STORAGE_THUMBNAIL_UPLOAD_FAILED',
|
|
993
|
-
details: buildRemoteFailureDetails(cleanupErrors),
|
|
994
|
-
});
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
try {
|
|
998
|
-
const resourceId = await persistUploadedResource(db, {
|
|
999
|
-
userId: input.userId,
|
|
1000
|
-
storage,
|
|
1001
|
-
plan,
|
|
1002
|
-
etag: uploaded.etag,
|
|
1003
|
-
thumbnailUrl,
|
|
1004
|
-
overwrite,
|
|
1005
|
-
});
|
|
1006
|
-
return {
|
|
1007
|
-
resourceId,
|
|
1008
|
-
storageConfigId: storage.id,
|
|
1009
|
-
aliasName: storage.aliasName,
|
|
1010
|
-
provider: storage.provider,
|
|
1011
|
-
bucket: storage.bucket,
|
|
1012
|
-
endpoint: storage.endpoint,
|
|
1013
|
-
domain: plan.domain,
|
|
1014
|
-
storageKey: plan.storageKey,
|
|
1015
|
-
storageUrl: plan.storageUrl,
|
|
1016
|
-
fileName: plan.fileName,
|
|
1017
|
-
sizeBytes: plan.sizeBytes,
|
|
1018
|
-
contentType: plan.contentType,
|
|
1019
|
-
category: plan.category,
|
|
1020
|
-
etag: uploaded.etag,
|
|
1021
|
-
thumbnailUrl,
|
|
1022
|
-
isPrivate: storage.isPrivate,
|
|
1023
|
-
selectionSource: plan.selectionSource,
|
|
1024
|
-
};
|
|
1025
|
-
}
|
|
1026
|
-
catch (error) {
|
|
1027
|
-
const cleanupErrors = await cleanupUploadedObjects();
|
|
1028
|
-
throw new DinoxError('Storage upload failed while recording resource metadata.', {
|
|
1029
|
-
cause: error,
|
|
1030
|
-
code: 'STORAGE_RESOURCE_INSERT_FAILED',
|
|
1031
|
-
details: buildRemoteFailureDetails(cleanupErrors),
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1
|
+
// Public repository API. Implementation lives in ./repo/*; this module re-exports
|
|
2
|
+
// only the original public surface so callers do not depend on the internal split.
|
|
3
|
+
export { getStorageStats, listStorageConfigs, parseStorageUploadCategory, } from './repo/configs.js';
|
|
4
|
+
export { inspectStorageTest, inspectStorageUpload, testStorageUpload, uploadFileToStorage, } from './repo/operations.js';
|