@dinoxx/dinox-cli 1.1.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -3
- package/dist/cli.js +4 -0
- package/dist/commands/auth/index.js +1 -2
- package/dist/commands/boxes/index.js +37 -13
- package/dist/commands/boxes/operations.d.ts +3 -3
- package/dist/commands/boxes/operations.js +51 -28
- package/dist/commands/info/index.js +30 -5
- package/dist/commands/meta/repo.js +2 -1
- package/dist/commands/notes/actions.js +20 -37
- package/dist/commands/notes/bulk.d.ts +2 -2
- package/dist/commands/notes/bulk.js +3 -18
- package/dist/commands/notes/contentPatch.d.ts +5 -1
- package/dist/commands/notes/contentPatch.js +12 -1
- package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
- package/dist/commands/notes/contentPatchCommand.js +29 -8
- package/dist/commands/notes/descriptors.js +19 -16
- package/dist/commands/notes/export.js +3 -0
- 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 -177
- package/dist/commands/notes/repo.js +7 -1310
- package/dist/commands/notes/searchOutput.d.ts +2 -1
- package/dist/commands/notes/searchOutput.js +2 -1
- package/dist/commands/notes/searchSql.js +9 -3
- package/dist/commands/prompt/index.d.ts +1 -0
- package/dist/commands/prompt/index.js +26 -11
- package/dist/commands/savedViews/index.d.ts +7 -0
- package/dist/commands/savedViews/index.js +458 -0
- package/dist/commands/savedViews/model.d.ts +87 -0
- package/dist/commands/savedViews/model.js +306 -0
- package/dist/commands/savedViews/repo.d.ts +123 -0
- package/dist/commands/savedViews/repo.js +512 -0
- package/dist/commands/savedViews/semantics.d.ts +46 -0
- package/dist/commands/savedViews/semantics.js +738 -0
- 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 +6 -0
- package/dist/commands/schema/renderSkills.js +109 -11
- package/dist/commands/schema/spec.js +4 -0
- package/dist/commands/skills/index.d.ts +80 -0
- package/dist/commands/skills/index.js +572 -0
- package/dist/commands/storage/index.js +2 -2
- 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/commands/sync.js +2 -1
- package/dist/commands/tags/index.js +30 -11
- package/dist/commands/tags/operations.d.ts +3 -3
- package/dist/commands/tags/operations.js +45 -20
- package/dist/commands/templates/model.d.ts +50 -0
- package/dist/commands/templates/model.js +239 -0
- package/dist/commands/todo/index.js +3 -3
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +46 -9
- package/dist/commands/writeConfirmation.d.ts +13 -0
- package/dist/commands/writeConfirmation.js +54 -0
- package/dist/config/resolve.js +5 -10
- package/dist/config/types.d.ts +3 -6
- package/dist/constants/links.d.ts +18 -1
- package/dist/constants/links.js +26 -1
- package/dist/contract/snapshot.d.ts +30 -0
- package/dist/contract/snapshot.js +114 -0
- package/dist/daemon/client.d.ts +2 -2
- package/dist/daemon/client.js +108 -54
- package/dist/daemon/index.d.ts +5 -3
- package/dist/daemon/index.js +28 -5
- package/dist/daemon/lifecycle.d.ts +7 -2
- package/dist/daemon/lifecycle.js +53 -13
- package/dist/daemon/pid.d.ts +1 -1
- package/dist/daemon/pid.js +6 -3
- package/dist/dinox.js +9 -1
- package/dist/powersync/exclusiveRuntime.d.ts +1 -0
- package/dist/powersync/exclusiveRuntime.js +26 -8
- package/dist/powersync/runtime.d.ts +3 -2
- package/dist/powersync/runtime.js +19 -3
- package/dist/powersync/schema/content.d.ts +1 -78
- package/dist/powersync/schema/content.js +1 -78
- package/dist/powersync/schema/electronCanonical.d.ts +1561 -0
- package/dist/powersync/schema/electronCanonical.js +1154 -0
- package/dist/powersync/schema/index.d.ts +573 -129
- package/dist/powersync/schema/index.js +3 -32
- package/dist/powersync/schema/local.d.ts +1 -56
- package/dist/powersync/schema/local.js +1 -60
- package/dist/powersync/schema/note.d.ts +1 -35
- package/dist/powersync/schema/note.js +1 -35
- package/dist/powersync/schema/notesExtras.d.ts +1 -62
- package/dist/powersync/schema/notesExtras.js +1 -71
- package/dist/powersync/schema/projects.d.ts +1 -104
- package/dist/powersync/schema/projects.js +1 -104
- package/dist/powersync/schema/savedViews.d.ts +1 -0
- package/dist/powersync/schema/savedViews.js +1 -0
- package/dist/powersync/schema/storage.d.ts +1 -24
- package/dist/powersync/schema/storage.js +1 -24
- package/dist/powersync/schema/tags.d.ts +1 -37
- package/dist/powersync/schema/tags.js +1 -37
- package/dist/powersync/syncGate.d.ts +20 -2
- package/dist/powersync/uploader.d.ts +16 -2
- package/dist/powersync/uploader.js +152 -158
- package/dist/skills/check.d.ts +1 -0
- package/dist/skills/check.js +538 -70
- 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/skills/retired.d.ts +60 -0
- package/dist/skills/retired.js +58 -0
- package/dist/utils/deadline.d.ts +8 -0
- package/dist/utils/deadline.js +31 -0
- package/dist/utils/dryRun.d.ts +20 -1
- package/dist/utils/dryRun.js +22 -3
- package/dist/utils/errorCodes.d.ts +311 -0
- package/dist/utils/errorCodes.js +92 -0
- package/dist/utils/errors.d.ts +15 -2
- package/dist/utils/errors.js +13 -0
- package/dist/utils/processExit.d.ts +1 -0
- package/dist/utils/processExit.js +33 -0
- package/dist/utils/updateNotice.d.ts +1 -0
- package/dist/utils/updateNotice.js +1 -1
- package/package.json +18 -8
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Custom storage config listing, selection, upload/test planning, and usage stats.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { generateId } from '../../../utils/id.js';
|
|
4
|
+
import { DinoxError } from '../../../utils/errors.js';
|
|
5
|
+
import { ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY, DEFAULT_STORAGE_PROTOCOL, OFFICIAL_STORAGE_PROVIDER_NAME, UPLOAD_CATEGORY_VALUES, normalizeBool, normalizeNumber, normalizeText, normalizeTextArray, } from './shared.js';
|
|
6
|
+
import { buildObjectId, buildStorageTarget, finishStorageHost, getStorageProviderName, guessContentType, isOfficialStorageProvider, normalizeStoragePathPrefix, } from './targets.js';
|
|
7
|
+
function toSanitizedStorage(config, isActive) {
|
|
8
|
+
return {
|
|
9
|
+
id: config.id,
|
|
10
|
+
aliasName: config.aliasName,
|
|
11
|
+
provider: config.provider,
|
|
12
|
+
endpoint: config.endpoint,
|
|
13
|
+
bucket: config.bucket,
|
|
14
|
+
region: config.region,
|
|
15
|
+
forcePathStyle: config.forcePathStyle,
|
|
16
|
+
pathPrefix: config.pathPrefix,
|
|
17
|
+
customDomain: config.customDomain,
|
|
18
|
+
legacyDomains: config.legacyDomains,
|
|
19
|
+
isPrivate: config.isPrivate,
|
|
20
|
+
hasAccessKeyId: config.accessKeyId.length > 0,
|
|
21
|
+
hasSecretAccessKey: config.secretAccessKey.length > 0,
|
|
22
|
+
isActive,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function buildStorageUsageLabel(args) {
|
|
26
|
+
const { provider, bucket, configsByKey, configsByProvider } = args;
|
|
27
|
+
if (isOfficialStorageProvider(provider)) {
|
|
28
|
+
return {
|
|
29
|
+
label: OFFICIAL_STORAGE_PROVIDER_NAME,
|
|
30
|
+
storageConfigId: null,
|
|
31
|
+
aliasName: OFFICIAL_STORAGE_PROVIDER_NAME,
|
|
32
|
+
isOfficial: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const exactConfig = configsByKey.get(`${provider}::${bucket}`);
|
|
36
|
+
if (exactConfig?.aliasName) {
|
|
37
|
+
return {
|
|
38
|
+
label: exactConfig.aliasName,
|
|
39
|
+
storageConfigId: exactConfig.id,
|
|
40
|
+
aliasName: exactConfig.aliasName,
|
|
41
|
+
isOfficial: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const sameProviderConfigs = configsByProvider.get(provider) ?? [];
|
|
45
|
+
if (sameProviderConfigs.length === 1 && sameProviderConfigs[0]?.aliasName) {
|
|
46
|
+
return {
|
|
47
|
+
label: sameProviderConfigs[0].aliasName,
|
|
48
|
+
storageConfigId: sameProviderConfigs[0].id,
|
|
49
|
+
aliasName: sameProviderConfigs[0].aliasName,
|
|
50
|
+
isOfficial: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
label: bucket ? `${getStorageProviderName(provider)} · ${bucket}` : getStorageProviderName(provider),
|
|
55
|
+
storageConfigId: exactConfig?.id ?? null,
|
|
56
|
+
aliasName: exactConfig?.aliasName ?? null,
|
|
57
|
+
isOfficial: false,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function getTableColumns(db, tableName) {
|
|
61
|
+
const rows = await db.getAll(`PRAGMA table_info(${tableName})`, []);
|
|
62
|
+
return new Set(rows.map((row) => row.name).filter(Boolean));
|
|
63
|
+
}
|
|
64
|
+
export async function listStoredCustomStorageConfigs(db, userId) {
|
|
65
|
+
const rows = await db.getAll(`
|
|
66
|
+
SELECT
|
|
67
|
+
TRIM(COALESCE(id, '')) AS id,
|
|
68
|
+
TRIM(COALESCE(alias_name, '')) AS alias_name,
|
|
69
|
+
TRIM(COALESCE(protocol, '')) AS protocol,
|
|
70
|
+
TRIM(COALESCE(provider, '')) AS provider,
|
|
71
|
+
TRIM(COALESCE(endpoint, '')) AS endpoint,
|
|
72
|
+
TRIM(COALESCE(bucket, '')) AS bucket,
|
|
73
|
+
TRIM(COALESCE(region, '')) AS region,
|
|
74
|
+
TRIM(COALESCE(access_key_id, '')) AS access_key_id,
|
|
75
|
+
TRIM(COALESCE(secret_access_key, '')) AS secret_access_key,
|
|
76
|
+
force_path_style,
|
|
77
|
+
TRIM(COALESCE(path_prefix, '')) AS path_prefix,
|
|
78
|
+
TRIM(COALESCE(custom_domain, '')) AS custom_domain,
|
|
79
|
+
legacy_domains,
|
|
80
|
+
is_private,
|
|
81
|
+
updated_at,
|
|
82
|
+
deleted_at
|
|
83
|
+
FROM c_storage
|
|
84
|
+
WHERE TRIM(COALESCE(user_id, '')) = ?
|
|
85
|
+
AND TRIM(COALESCE(protocol, '')) = ?
|
|
86
|
+
AND deleted_at IS NULL
|
|
87
|
+
AND TRIM(COALESCE(id, '')) <> ''
|
|
88
|
+
ORDER BY COALESCE(sort_rank, 2147483647) ASC, updated_at DESC, id ASC
|
|
89
|
+
`, [userId, DEFAULT_STORAGE_PROTOCOL]);
|
|
90
|
+
return rows
|
|
91
|
+
.map((row) => ({
|
|
92
|
+
id: normalizeText(row.id),
|
|
93
|
+
aliasName: normalizeText(row.alias_name),
|
|
94
|
+
provider: normalizeText(row.provider),
|
|
95
|
+
endpoint: normalizeText(row.endpoint),
|
|
96
|
+
bucket: normalizeText(row.bucket),
|
|
97
|
+
region: normalizeText(row.region),
|
|
98
|
+
accessKeyId: normalizeText(row.access_key_id),
|
|
99
|
+
secretAccessKey: normalizeText(row.secret_access_key),
|
|
100
|
+
forcePathStyle: normalizeBool(row.force_path_style),
|
|
101
|
+
pathPrefix: normalizeStoragePathPrefix(normalizeText(row.path_prefix)),
|
|
102
|
+
customDomain: normalizeText(row.custom_domain),
|
|
103
|
+
legacyDomains: normalizeTextArray(row.legacy_domains),
|
|
104
|
+
isPrivate: normalizeBool(row.is_private),
|
|
105
|
+
}))
|
|
106
|
+
.filter((row) => row.id.length > 0);
|
|
107
|
+
}
|
|
108
|
+
export async function getActiveCustomStorageConfigId(db, userId) {
|
|
109
|
+
const rows = await db.getAll(`
|
|
110
|
+
SELECT c_value
|
|
111
|
+
FROM c_config_property
|
|
112
|
+
WHERE TRIM(COALESCE(c_key, '')) = ?
|
|
113
|
+
AND TRIM(COALESCE(user_id, '')) = ?
|
|
114
|
+
AND (is_del IS NULL OR is_del = 0)
|
|
115
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
116
|
+
LIMIT 1
|
|
117
|
+
`, [ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY, userId]);
|
|
118
|
+
return normalizeText(rows[0]?.c_value ?? null) || null;
|
|
119
|
+
}
|
|
120
|
+
export function resolveStorageSelection(args) {
|
|
121
|
+
const explicitId = normalizeText(args.storageId);
|
|
122
|
+
if (explicitId) {
|
|
123
|
+
const config = args.configs.find((item) => item.id === explicitId);
|
|
124
|
+
if (!config) {
|
|
125
|
+
throw new DinoxError(`Storage config not found: ${explicitId}`, {
|
|
126
|
+
suggestion: 'Run `dino storage list --format json` to inspect available storage ids.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { config, selectionSource: 'storage_id' };
|
|
130
|
+
}
|
|
131
|
+
const activeId = normalizeText(args.activeCustomStorageConfigId);
|
|
132
|
+
if (activeId) {
|
|
133
|
+
const config = args.configs.find((item) => item.id === activeId);
|
|
134
|
+
if (config) {
|
|
135
|
+
return { config, selectionSource: 'active' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (args.configs.length === 1) {
|
|
139
|
+
return { config: args.configs[0], selectionSource: 'single_available_config' };
|
|
140
|
+
}
|
|
141
|
+
throw new DinoxError('No active custom storage config is available for upload.', {
|
|
142
|
+
suggestion: 'Set an active custom storage config in the app, or pass `--storage-id <id>`.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function buildStorageUploadPlan(args) {
|
|
146
|
+
const storageKey = normalizeText(args.key) || `${args.config.pathPrefix}${buildObjectId(args.userId, args.category, args.filePath)}`;
|
|
147
|
+
const target = buildStorageTarget(args.config, storageKey);
|
|
148
|
+
return {
|
|
149
|
+
storage: toSanitizedStorage({
|
|
150
|
+
...args.config,
|
|
151
|
+
endpoint: finishStorageHost(args.config.endpoint),
|
|
152
|
+
}, args.selectionSource === 'active'),
|
|
153
|
+
selectionSource: args.selectionSource,
|
|
154
|
+
filePath: args.filePath,
|
|
155
|
+
fileName: path.basename(args.filePath),
|
|
156
|
+
sizeBytes: args.sizeBytes,
|
|
157
|
+
category: args.category,
|
|
158
|
+
contentType: guessContentType(args.filePath),
|
|
159
|
+
storageKey,
|
|
160
|
+
storageUrl: target.storageUrl,
|
|
161
|
+
domain: target.domain,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export function buildStorageTestPlan(args) {
|
|
165
|
+
const storageKey = `${args.config.pathPrefix}dinox-cli-test/${args.userId}/${generateId()}.txt`;
|
|
166
|
+
const target = buildStorageTarget(args.config, storageKey);
|
|
167
|
+
const payload = Buffer.from(`dinox-cli storage test\nprovider=${args.config.provider}\nuser=${args.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
|
|
168
|
+
return {
|
|
169
|
+
storage: toSanitizedStorage({
|
|
170
|
+
...args.config,
|
|
171
|
+
endpoint: finishStorageHost(args.config.endpoint),
|
|
172
|
+
}, args.selectionSource === 'active'),
|
|
173
|
+
selectionSource: args.selectionSource,
|
|
174
|
+
contentType: 'text/plain',
|
|
175
|
+
sizeBytes: payload.byteLength,
|
|
176
|
+
storageKey,
|
|
177
|
+
storageUrl: target.storageUrl,
|
|
178
|
+
domain: target.domain,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export function parseStorageUploadCategory(value) {
|
|
182
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
183
|
+
if (UPLOAD_CATEGORY_VALUES.includes(normalized)) {
|
|
184
|
+
return normalized;
|
|
185
|
+
}
|
|
186
|
+
throw new DinoxError(`--category must be one of: ${UPLOAD_CATEGORY_VALUES.join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
export async function listStorageConfigs(db, userId) {
|
|
189
|
+
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, userId);
|
|
190
|
+
const configs = await listStoredCustomStorageConfigs(db, userId);
|
|
191
|
+
return {
|
|
192
|
+
activeCustomStorageConfigId,
|
|
193
|
+
usesOfficialStorage: !activeCustomStorageConfigId,
|
|
194
|
+
items: configs.map((config) => toSanitizedStorage(config, config.id === activeCustomStorageConfigId)),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
export async function getStorageStats(db, userId) {
|
|
198
|
+
const rows = await db.getAll(`
|
|
199
|
+
SELECT
|
|
200
|
+
TRIM(COALESCE(storage_provider, '')) AS provider,
|
|
201
|
+
TRIM(COALESCE(bucket, '')) AS bucket,
|
|
202
|
+
COALESCE(SUM(COALESCE(size_bytes, 0)), 0) AS total_bytes,
|
|
203
|
+
COUNT(1) AS file_count
|
|
204
|
+
FROM c_resource
|
|
205
|
+
WHERE (is_del IS NULL OR is_del = 0)
|
|
206
|
+
AND TRIM(COALESCE(user_id, '')) = ?
|
|
207
|
+
AND TRIM(COALESCE(storage_provider, '')) <> ''
|
|
208
|
+
AND TRIM(COALESCE(storage_provider, '')) <> 'local'
|
|
209
|
+
AND TRIM(COALESCE(status, '')) = 'uploaded'
|
|
210
|
+
GROUP BY storage_provider, bucket
|
|
211
|
+
ORDER BY total_bytes DESC, provider ASC, bucket ASC
|
|
212
|
+
`, [userId]);
|
|
213
|
+
const configs = await listStoredCustomStorageConfigs(db, userId);
|
|
214
|
+
const configsByKey = new Map();
|
|
215
|
+
const configsByProvider = new Map();
|
|
216
|
+
for (const config of configs) {
|
|
217
|
+
configsByKey.set(`${config.provider}::${config.bucket}`, config);
|
|
218
|
+
const current = configsByProvider.get(config.provider);
|
|
219
|
+
if (current) {
|
|
220
|
+
current.push(config);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
configsByProvider.set(config.provider, [config]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const entries = rows
|
|
227
|
+
.map((row) => {
|
|
228
|
+
const provider = normalizeText(row.provider);
|
|
229
|
+
const bucket = normalizeText(row.bucket);
|
|
230
|
+
const labelInfo = buildStorageUsageLabel({
|
|
231
|
+
provider,
|
|
232
|
+
bucket,
|
|
233
|
+
configsByKey,
|
|
234
|
+
configsByProvider,
|
|
235
|
+
});
|
|
236
|
+
return {
|
|
237
|
+
provider,
|
|
238
|
+
bucket,
|
|
239
|
+
label: labelInfo.label,
|
|
240
|
+
totalBytes: normalizeNumber(row.total_bytes),
|
|
241
|
+
fileCount: normalizeNumber(row.file_count),
|
|
242
|
+
storageConfigId: labelInfo.storageConfigId,
|
|
243
|
+
aliasName: labelInfo.aliasName,
|
|
244
|
+
isOfficial: labelInfo.isOfficial,
|
|
245
|
+
};
|
|
246
|
+
})
|
|
247
|
+
.filter((entry) => entry.provider.length > 0 && entry.fileCount > 0)
|
|
248
|
+
.sort((left, right) => {
|
|
249
|
+
if (left.isOfficial !== right.isOfficial) {
|
|
250
|
+
return left.isOfficial ? -1 : 1;
|
|
251
|
+
}
|
|
252
|
+
return right.totalBytes - left.totalBytes;
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
totalBytes: entries.reduce((sum, entry) => sum + entry.totalBytes, 0),
|
|
256
|
+
totalFiles: entries.reduce((sum, entry) => sum + entry.fileCount, 0),
|
|
257
|
+
entries,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AbstractPowerSyncDatabase } from '@powersync/common';
|
|
2
|
+
import { type StorageTestPlan, type StorageTestResult, type StorageUploadCategory, type StorageUploadPlan, type StorageUploadResult, type UploadDeps } from './shared.js';
|
|
3
|
+
export declare function inspectStorageUpload(db: AbstractPowerSyncDatabase, input: {
|
|
4
|
+
userId: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
category: StorageUploadCategory;
|
|
7
|
+
storageId?: string;
|
|
8
|
+
key?: string;
|
|
9
|
+
}, deps?: Pick<UploadDeps, 'statFile'>): Promise<StorageUploadPlan>;
|
|
10
|
+
export declare function inspectStorageTest(db: AbstractPowerSyncDatabase, input: {
|
|
11
|
+
userId: string;
|
|
12
|
+
storageId?: string;
|
|
13
|
+
}): Promise<StorageTestPlan>;
|
|
14
|
+
export declare function testStorageUpload(db: AbstractPowerSyncDatabase, input: {
|
|
15
|
+
userId: string;
|
|
16
|
+
storageId?: string;
|
|
17
|
+
}, deps?: Pick<UploadDeps, 'deleteObject' | 'uploadBytes'>): Promise<StorageTestResult>;
|
|
18
|
+
export declare function uploadFileToStorage(db: AbstractPowerSyncDatabase, input: {
|
|
19
|
+
userId: string;
|
|
20
|
+
filePath: string;
|
|
21
|
+
category: StorageUploadCategory;
|
|
22
|
+
storageId?: string;
|
|
23
|
+
key?: string;
|
|
24
|
+
overwrite?: boolean;
|
|
25
|
+
}, deps?: UploadDeps): Promise<StorageUploadResult>;
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// Storage commands: inspect/test/upload orchestration with cleanup on partial failure.
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import { DinoxError } from '../../../utils/errors.js';
|
|
4
|
+
import { buildStorageTestPlan, buildStorageUploadPlan, getActiveCustomStorageConfigId, listStoredCustomStorageConfigs, resolveStorageSelection, } from './configs.js';
|
|
5
|
+
import { createStorageCollisionError, createStorageUploadOutcomeUnknownError, defaultDeleteObject, defaultUploadBytes, defaultUploadObject, generateThumbnailBuffer, isObjectCollisionError, loadSharp, } from './remote.js';
|
|
6
|
+
import { normalizeText, } from './shared.js';
|
|
7
|
+
import { persistUploadedResource } from './resources.js';
|
|
8
|
+
import { buildStorageTarget, generateThumbnailKey } from './targets.js';
|
|
9
|
+
export async function inspectStorageUpload(db, input, deps) {
|
|
10
|
+
const statFile = deps?.statFile ?? fsp.stat;
|
|
11
|
+
const filePath = input.filePath.trim();
|
|
12
|
+
if (!filePath) {
|
|
13
|
+
throw new DinoxError('File path is required.');
|
|
14
|
+
}
|
|
15
|
+
const stats = await statFile(filePath).catch((error) => {
|
|
16
|
+
throw new DinoxError(`File not found: ${filePath}`, { cause: error });
|
|
17
|
+
});
|
|
18
|
+
if (!stats.isFile()) {
|
|
19
|
+
throw new DinoxError(`Not a regular file: ${filePath}`);
|
|
20
|
+
}
|
|
21
|
+
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
22
|
+
if (configs.length === 0) {
|
|
23
|
+
throw new DinoxError('No custom storage configs are available.', {
|
|
24
|
+
suggestion: 'Configure a custom storage target in the app first.',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
|
|
28
|
+
const { config, selectionSource } = resolveStorageSelection({
|
|
29
|
+
configs,
|
|
30
|
+
storageId: input.storageId,
|
|
31
|
+
activeCustomStorageConfigId,
|
|
32
|
+
});
|
|
33
|
+
if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
|
|
34
|
+
throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
|
|
35
|
+
suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return buildStorageUploadPlan({
|
|
39
|
+
config,
|
|
40
|
+
selectionSource,
|
|
41
|
+
userId: input.userId,
|
|
42
|
+
filePath,
|
|
43
|
+
category: input.category,
|
|
44
|
+
key: input.key,
|
|
45
|
+
sizeBytes: stats.size,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export async function inspectStorageTest(db, input) {
|
|
49
|
+
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
50
|
+
if (configs.length === 0) {
|
|
51
|
+
throw new DinoxError('No custom storage configs are available.', {
|
|
52
|
+
suggestion: 'Configure a custom storage target in the app first.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const activeCustomStorageConfigId = await getActiveCustomStorageConfigId(db, input.userId);
|
|
56
|
+
const { config, selectionSource } = resolveStorageSelection({
|
|
57
|
+
configs,
|
|
58
|
+
storageId: input.storageId,
|
|
59
|
+
activeCustomStorageConfigId,
|
|
60
|
+
});
|
|
61
|
+
if (!config.endpoint || !config.bucket || !config.region || !config.accessKeyId || !config.secretAccessKey) {
|
|
62
|
+
throw new DinoxError(`Storage config "${config.aliasName || config.id}" is incomplete for upload.`, {
|
|
63
|
+
suggestion: 'Fill in endpoint, region, bucket, access key id, and secret access key in the app.',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return buildStorageTestPlan({
|
|
67
|
+
config,
|
|
68
|
+
selectionSource,
|
|
69
|
+
userId: input.userId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function testStorageUpload(db, input, deps) {
|
|
73
|
+
const plan = await inspectStorageTest(db, input);
|
|
74
|
+
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
75
|
+
const storage = configs.find((item) => item.id === plan.storage.id);
|
|
76
|
+
if (!storage) {
|
|
77
|
+
throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
|
|
78
|
+
}
|
|
79
|
+
const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
|
|
80
|
+
const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
|
|
81
|
+
const payload = Buffer.from(`dinox-cli storage test\nprovider=${storage.provider}\nuser=${input.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
|
|
82
|
+
let uploaded;
|
|
83
|
+
try {
|
|
84
|
+
uploaded = await uploadBytes({
|
|
85
|
+
body: payload,
|
|
86
|
+
bucket: storage.bucket,
|
|
87
|
+
key: plan.storageKey,
|
|
88
|
+
contentType: plan.contentType,
|
|
89
|
+
storage,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isObjectCollisionError(error)) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
throw createStorageUploadOutcomeUnknownError({
|
|
97
|
+
message: 'Storage test upload outcome could not be confirmed.',
|
|
98
|
+
cause: error,
|
|
99
|
+
bucket: storage.bucket,
|
|
100
|
+
storageKey: plan.storageKey,
|
|
101
|
+
possiblyAffectedKeys: [plan.storageKey],
|
|
102
|
+
overwrite: false,
|
|
103
|
+
operation: 'storage_test',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
const cleanupEtag = normalizeText(uploaded.etag);
|
|
107
|
+
if (!cleanupEtag) {
|
|
108
|
+
throw new DinoxError('Storage test uploaded the temporary object but could not clean it up safely.', {
|
|
109
|
+
code: 'STORAGE_TEST_CLEANUP_FAILED',
|
|
110
|
+
details: {
|
|
111
|
+
storageKey: plan.storageKey,
|
|
112
|
+
bucket: storage.bucket,
|
|
113
|
+
remoteObjectChanged: true,
|
|
114
|
+
affectedKeys: [plan.storageKey],
|
|
115
|
+
cleanupErrors: [
|
|
116
|
+
`${plan.storageKey} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`,
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
await deleteObject({
|
|
123
|
+
bucket: storage.bucket,
|
|
124
|
+
key: plan.storageKey,
|
|
125
|
+
etag: cleanupEtag,
|
|
126
|
+
storage,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw new DinoxError('Storage test uploaded the temporary object but failed to clean it up.', {
|
|
131
|
+
cause: error,
|
|
132
|
+
code: 'STORAGE_TEST_CLEANUP_FAILED',
|
|
133
|
+
details: {
|
|
134
|
+
storageKey: plan.storageKey,
|
|
135
|
+
bucket: storage.bucket,
|
|
136
|
+
remoteObjectChanged: true,
|
|
137
|
+
affectedKeys: [plan.storageKey],
|
|
138
|
+
cleanupErrors: [
|
|
139
|
+
`${plan.storageKey} cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
storageConfigId: storage.id,
|
|
146
|
+
aliasName: storage.aliasName,
|
|
147
|
+
provider: storage.provider,
|
|
148
|
+
bucket: storage.bucket,
|
|
149
|
+
endpoint: storage.endpoint,
|
|
150
|
+
domain: plan.domain,
|
|
151
|
+
storageKey: plan.storageKey,
|
|
152
|
+
storageUrl: plan.storageUrl,
|
|
153
|
+
contentType: plan.contentType,
|
|
154
|
+
sizeBytes: plan.sizeBytes,
|
|
155
|
+
selectionSource: plan.selectionSource,
|
|
156
|
+
isPrivate: storage.isPrivate,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export async function uploadFileToStorage(db, input, deps) {
|
|
160
|
+
if (input.overwrite && !normalizeText(input.key)) {
|
|
161
|
+
throw new DinoxError('--overwrite requires an explicit --key.', {
|
|
162
|
+
code: 'USAGE_ERROR',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const plan = await inspectStorageUpload(db, input, { statFile: deps?.statFile });
|
|
166
|
+
const configs = await listStoredCustomStorageConfigs(db, input.userId);
|
|
167
|
+
const storage = configs.find((item) => item.id === plan.storage.id);
|
|
168
|
+
if (!storage) {
|
|
169
|
+
throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
|
|
170
|
+
}
|
|
171
|
+
const overwrite = input.overwrite === true;
|
|
172
|
+
const uploadObject = deps?.uploadObject ?? defaultUploadObject;
|
|
173
|
+
const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
|
|
174
|
+
const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
|
|
175
|
+
const thumbnailKey = plan.contentType.startsWith('image/')
|
|
176
|
+
? generateThumbnailKey(plan.storageKey)
|
|
177
|
+
: null;
|
|
178
|
+
// Fail before any remote write when the required thumbnail cannot be produced.
|
|
179
|
+
const sharp = plan.contentType.startsWith('image/') ? await loadSharp() : null;
|
|
180
|
+
let uploaded;
|
|
181
|
+
try {
|
|
182
|
+
uploaded = await uploadObject({
|
|
183
|
+
localPath: plan.filePath,
|
|
184
|
+
bucket: storage.bucket,
|
|
185
|
+
key: plan.storageKey,
|
|
186
|
+
contentType: plan.contentType,
|
|
187
|
+
contentLength: plan.sizeBytes,
|
|
188
|
+
overwrite,
|
|
189
|
+
storage,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!overwrite && isObjectCollisionError(error)) {
|
|
194
|
+
throw createStorageCollisionError(storage.bucket, [plan.storageKey]);
|
|
195
|
+
}
|
|
196
|
+
throw createStorageUploadOutcomeUnknownError({
|
|
197
|
+
message: 'Storage upload outcome could not be confirmed.',
|
|
198
|
+
cause: error,
|
|
199
|
+
bucket: storage.bucket,
|
|
200
|
+
storageKey: plan.storageKey,
|
|
201
|
+
possiblyAffectedKeys: [plan.storageKey],
|
|
202
|
+
overwrite,
|
|
203
|
+
operation: 'upload',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
let thumbnailUrl = null;
|
|
207
|
+
const possiblyAffectedKeys = new Set();
|
|
208
|
+
const remoteWrites = [{
|
|
209
|
+
key: plan.storageKey,
|
|
210
|
+
etag: uploaded.etag,
|
|
211
|
+
cleanupAllowed: !overwrite,
|
|
212
|
+
cleaned: false,
|
|
213
|
+
}];
|
|
214
|
+
const cleanupUploadedObjects = async () => {
|
|
215
|
+
const cleanupErrors = [];
|
|
216
|
+
for (const remoteWrite of [...remoteWrites].reverse()) {
|
|
217
|
+
if (!remoteWrite.cleanupAllowed || remoteWrite.cleaned) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const cleanupEtag = normalizeText(remoteWrite.etag);
|
|
221
|
+
if (!cleanupEtag) {
|
|
222
|
+
cleanupErrors.push(`${remoteWrite.key} cleanup skipped: upload response did not include an ETag required for safe conditional deletion`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
await deleteObject({
|
|
226
|
+
bucket: storage.bucket,
|
|
227
|
+
key: remoteWrite.key,
|
|
228
|
+
etag: cleanupEtag,
|
|
229
|
+
storage,
|
|
230
|
+
}).then(() => {
|
|
231
|
+
remoteWrite.cleaned = true;
|
|
232
|
+
}, (cleanupError) => {
|
|
233
|
+
cleanupErrors.push(`${remoteWrite.key} cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return cleanupErrors;
|
|
237
|
+
};
|
|
238
|
+
const buildRemoteFailureDetails = (cleanupErrors) => {
|
|
239
|
+
const affectedKeys = remoteWrites
|
|
240
|
+
.filter((remoteWrite) => !remoteWrite.cleaned)
|
|
241
|
+
.map((remoteWrite) => remoteWrite.key);
|
|
242
|
+
const possibleKeys = [...possiblyAffectedKeys];
|
|
243
|
+
return {
|
|
244
|
+
bucket: storage.bucket,
|
|
245
|
+
storageKey: plan.storageKey,
|
|
246
|
+
remoteObjectChanged: possibleKeys.length > 0
|
|
247
|
+
? (affectedKeys.length > 0 ? true : null)
|
|
248
|
+
: affectedKeys.length > 0,
|
|
249
|
+
affectedKeys,
|
|
250
|
+
...(possibleKeys.length > 0
|
|
251
|
+
? { remoteOutcome: 'unknown', possiblyAffectedKeys: possibleKeys }
|
|
252
|
+
: {}),
|
|
253
|
+
...(cleanupErrors.length > 0 ? { cleanupErrors } : {}),
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
if (plan.contentType.startsWith('image/')) {
|
|
257
|
+
try {
|
|
258
|
+
if (!thumbnailKey || !sharp) {
|
|
259
|
+
throw new Error('Thumbnail key could not be generated.');
|
|
260
|
+
}
|
|
261
|
+
const thumbnailBuffer = await generateThumbnailBuffer(sharp, plan.filePath);
|
|
262
|
+
const thumbnailTarget = buildStorageTarget(storage, thumbnailKey);
|
|
263
|
+
let uploadedThumbnail;
|
|
264
|
+
try {
|
|
265
|
+
uploadedThumbnail = await uploadBytes({
|
|
266
|
+
body: thumbnailBuffer,
|
|
267
|
+
bucket: storage.bucket,
|
|
268
|
+
key: thumbnailKey,
|
|
269
|
+
contentType: 'image/webp',
|
|
270
|
+
overwrite,
|
|
271
|
+
storage,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (!isObjectCollisionError(error)) {
|
|
276
|
+
possiblyAffectedKeys.add(thumbnailKey);
|
|
277
|
+
}
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
remoteWrites.push({
|
|
281
|
+
key: thumbnailKey,
|
|
282
|
+
etag: uploadedThumbnail.etag,
|
|
283
|
+
cleanupAllowed: !overwrite,
|
|
284
|
+
cleaned: false,
|
|
285
|
+
});
|
|
286
|
+
thumbnailUrl = thumbnailTarget.storageUrl;
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const cleanupErrors = await cleanupUploadedObjects();
|
|
290
|
+
if (!overwrite && isObjectCollisionError(error) && thumbnailKey) {
|
|
291
|
+
throw createStorageCollisionError(storage.bucket, [thumbnailKey], buildRemoteFailureDetails(cleanupErrors));
|
|
292
|
+
}
|
|
293
|
+
throw new DinoxError('Storage upload failed while generating the required thumbnail.', {
|
|
294
|
+
cause: error,
|
|
295
|
+
code: 'STORAGE_THUMBNAIL_UPLOAD_FAILED',
|
|
296
|
+
details: buildRemoteFailureDetails(cleanupErrors),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const resourceId = await persistUploadedResource(db, {
|
|
302
|
+
userId: input.userId,
|
|
303
|
+
storage,
|
|
304
|
+
plan,
|
|
305
|
+
etag: uploaded.etag,
|
|
306
|
+
thumbnailUrl,
|
|
307
|
+
overwrite,
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
resourceId,
|
|
311
|
+
storageConfigId: storage.id,
|
|
312
|
+
aliasName: storage.aliasName,
|
|
313
|
+
provider: storage.provider,
|
|
314
|
+
bucket: storage.bucket,
|
|
315
|
+
endpoint: storage.endpoint,
|
|
316
|
+
domain: plan.domain,
|
|
317
|
+
storageKey: plan.storageKey,
|
|
318
|
+
storageUrl: plan.storageUrl,
|
|
319
|
+
fileName: plan.fileName,
|
|
320
|
+
sizeBytes: plan.sizeBytes,
|
|
321
|
+
contentType: plan.contentType,
|
|
322
|
+
category: plan.category,
|
|
323
|
+
etag: uploaded.etag,
|
|
324
|
+
thumbnailUrl,
|
|
325
|
+
isPrivate: storage.isPrivate,
|
|
326
|
+
selectionSource: plan.selectionSource,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
const cleanupErrors = await cleanupUploadedObjects();
|
|
331
|
+
throw new DinoxError('Storage upload failed while recording resource metadata.', {
|
|
332
|
+
cause: error,
|
|
333
|
+
code: 'STORAGE_RESOURCE_INSERT_FAILED',
|
|
334
|
+
details: buildRemoteFailureDetails(cleanupErrors),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DinoxError } from '../../../utils/errors.js';
|
|
2
|
+
import { type StoredCustomStorageConfig, type UploadObjectInput, type UploadObjectResult } from './shared.js';
|
|
3
|
+
export declare function defaultUploadObject(input: UploadObjectInput): Promise<UploadObjectResult>;
|
|
4
|
+
export declare function isObjectCollisionError(error: unknown): boolean;
|
|
5
|
+
export declare function createStorageCollisionError(bucket: string, keys: string[], failureDetails?: Record<string, unknown>): DinoxError;
|
|
6
|
+
export declare function createStorageUploadOutcomeUnknownError(input: {
|
|
7
|
+
message: string;
|
|
8
|
+
cause: unknown;
|
|
9
|
+
bucket: string;
|
|
10
|
+
storageKey: string;
|
|
11
|
+
possiblyAffectedKeys: string[];
|
|
12
|
+
overwrite: boolean;
|
|
13
|
+
operation: 'upload' | 'storage_test';
|
|
14
|
+
}): DinoxError;
|
|
15
|
+
export declare function defaultDeleteObject(input: {
|
|
16
|
+
bucket: string;
|
|
17
|
+
key: string;
|
|
18
|
+
etag?: string | null;
|
|
19
|
+
storage: StoredCustomStorageConfig;
|
|
20
|
+
}): Promise<void>;
|
|
21
|
+
export declare function defaultUploadBytes(input: {
|
|
22
|
+
body: Buffer;
|
|
23
|
+
bucket: string;
|
|
24
|
+
key: string;
|
|
25
|
+
contentType: string;
|
|
26
|
+
overwrite?: boolean;
|
|
27
|
+
storage: StoredCustomStorageConfig;
|
|
28
|
+
}): Promise<UploadObjectResult>;
|
|
29
|
+
type SharpFactory = typeof import('sharp');
|
|
30
|
+
export declare function loadSharp(): Promise<SharpFactory>;
|
|
31
|
+
export declare function generateThumbnailBuffer(sharp: SharpFactory, localPath: string): Promise<Buffer>;
|
|
32
|
+
export {};
|