@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.
Files changed (141) hide show
  1. package/README.md +44 -3
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/auth/index.js +1 -2
  4. package/dist/commands/boxes/index.js +37 -13
  5. package/dist/commands/boxes/operations.d.ts +3 -3
  6. package/dist/commands/boxes/operations.js +51 -28
  7. package/dist/commands/info/index.js +30 -5
  8. package/dist/commands/meta/repo.js +2 -1
  9. package/dist/commands/notes/actions.js +20 -37
  10. package/dist/commands/notes/bulk.d.ts +2 -2
  11. package/dist/commands/notes/bulk.js +3 -18
  12. package/dist/commands/notes/contentPatch.d.ts +5 -1
  13. package/dist/commands/notes/contentPatch.js +12 -1
  14. package/dist/commands/notes/contentPatchCommand.d.ts +3 -0
  15. package/dist/commands/notes/contentPatchCommand.js +29 -8
  16. package/dist/commands/notes/descriptors.js +19 -16
  17. package/dist/commands/notes/export.js +3 -0
  18. package/dist/commands/notes/repo/read.d.ts +8 -0
  19. package/dist/commands/notes/repo/read.js +321 -0
  20. package/dist/commands/notes/repo/resolve.d.ts +8 -0
  21. package/dist/commands/notes/repo/resolve.js +187 -0
  22. package/dist/commands/notes/repo/search.d.ts +42 -0
  23. package/dist/commands/notes/repo/search.js +513 -0
  24. package/dist/commands/notes/repo/shared.d.ts +190 -0
  25. package/dist/commands/notes/repo/shared.js +102 -0
  26. package/dist/commands/notes/repo/write.d.ts +24 -0
  27. package/dist/commands/notes/repo/write.js +251 -0
  28. package/dist/commands/notes/repo.d.ts +7 -177
  29. package/dist/commands/notes/repo.js +7 -1310
  30. package/dist/commands/notes/searchOutput.d.ts +2 -1
  31. package/dist/commands/notes/searchOutput.js +2 -1
  32. package/dist/commands/notes/searchSql.js +9 -3
  33. package/dist/commands/prompt/index.d.ts +1 -0
  34. package/dist/commands/prompt/index.js +26 -11
  35. package/dist/commands/savedViews/index.d.ts +7 -0
  36. package/dist/commands/savedViews/index.js +458 -0
  37. package/dist/commands/savedViews/model.d.ts +87 -0
  38. package/dist/commands/savedViews/model.js +306 -0
  39. package/dist/commands/savedViews/repo.d.ts +123 -0
  40. package/dist/commands/savedViews/repo.js +512 -0
  41. package/dist/commands/savedViews/semantics.d.ts +46 -0
  42. package/dist/commands/savedViews/semantics.js +738 -0
  43. package/dist/commands/schema/commandMetadata.js +4 -4
  44. package/dist/commands/schema/index.d.ts +1 -0
  45. package/dist/commands/schema/index.js +23 -2
  46. package/dist/commands/schema/renderSkills.d.ts +6 -0
  47. package/dist/commands/schema/renderSkills.js +109 -11
  48. package/dist/commands/schema/spec.js +4 -0
  49. package/dist/commands/skills/index.d.ts +80 -0
  50. package/dist/commands/skills/index.js +572 -0
  51. package/dist/commands/storage/index.js +2 -2
  52. package/dist/commands/storage/repo/configs.d.ts +30 -0
  53. package/dist/commands/storage/repo/configs.js +259 -0
  54. package/dist/commands/storage/repo/operations.d.ts +25 -0
  55. package/dist/commands/storage/repo/operations.js +337 -0
  56. package/dist/commands/storage/repo/remote.d.ts +32 -0
  57. package/dist/commands/storage/repo/remote.js +132 -0
  58. package/dist/commands/storage/repo/resources.d.ts +12 -0
  59. package/dist/commands/storage/repo/resources.js +134 -0
  60. package/dist/commands/storage/repo/shared.d.ts +181 -0
  61. package/dist/commands/storage/repo/shared.js +36 -0
  62. package/dist/commands/storage/repo/targets.d.ts +12 -0
  63. package/dist/commands/storage/repo/targets.js +178 -0
  64. package/dist/commands/storage/repo.d.ts +3 -166
  65. package/dist/commands/storage/repo.js +4 -1034
  66. package/dist/commands/sync.js +2 -1
  67. package/dist/commands/tags/index.js +30 -11
  68. package/dist/commands/tags/operations.d.ts +3 -3
  69. package/dist/commands/tags/operations.js +45 -20
  70. package/dist/commands/templates/model.d.ts +50 -0
  71. package/dist/commands/templates/model.js +239 -0
  72. package/dist/commands/todo/index.js +3 -3
  73. package/dist/commands/update.d.ts +1 -0
  74. package/dist/commands/update.js +46 -9
  75. package/dist/commands/writeConfirmation.d.ts +13 -0
  76. package/dist/commands/writeConfirmation.js +54 -0
  77. package/dist/config/resolve.js +5 -10
  78. package/dist/config/types.d.ts +3 -6
  79. package/dist/constants/links.d.ts +18 -1
  80. package/dist/constants/links.js +26 -1
  81. package/dist/contract/snapshot.d.ts +30 -0
  82. package/dist/contract/snapshot.js +114 -0
  83. package/dist/daemon/client.d.ts +2 -2
  84. package/dist/daemon/client.js +108 -54
  85. package/dist/daemon/index.d.ts +5 -3
  86. package/dist/daemon/index.js +28 -5
  87. package/dist/daemon/lifecycle.d.ts +7 -2
  88. package/dist/daemon/lifecycle.js +53 -13
  89. package/dist/daemon/pid.d.ts +1 -1
  90. package/dist/daemon/pid.js +6 -3
  91. package/dist/dinox.js +9 -1
  92. package/dist/powersync/exclusiveRuntime.d.ts +1 -0
  93. package/dist/powersync/exclusiveRuntime.js +26 -8
  94. package/dist/powersync/runtime.d.ts +3 -2
  95. package/dist/powersync/runtime.js +19 -3
  96. package/dist/powersync/schema/content.d.ts +1 -78
  97. package/dist/powersync/schema/content.js +1 -78
  98. package/dist/powersync/schema/electronCanonical.d.ts +1561 -0
  99. package/dist/powersync/schema/electronCanonical.js +1154 -0
  100. package/dist/powersync/schema/index.d.ts +573 -129
  101. package/dist/powersync/schema/index.js +3 -32
  102. package/dist/powersync/schema/local.d.ts +1 -56
  103. package/dist/powersync/schema/local.js +1 -60
  104. package/dist/powersync/schema/note.d.ts +1 -35
  105. package/dist/powersync/schema/note.js +1 -35
  106. package/dist/powersync/schema/notesExtras.d.ts +1 -62
  107. package/dist/powersync/schema/notesExtras.js +1 -71
  108. package/dist/powersync/schema/projects.d.ts +1 -104
  109. package/dist/powersync/schema/projects.js +1 -104
  110. package/dist/powersync/schema/savedViews.d.ts +1 -0
  111. package/dist/powersync/schema/savedViews.js +1 -0
  112. package/dist/powersync/schema/storage.d.ts +1 -24
  113. package/dist/powersync/schema/storage.js +1 -24
  114. package/dist/powersync/schema/tags.d.ts +1 -37
  115. package/dist/powersync/schema/tags.js +1 -37
  116. package/dist/powersync/syncGate.d.ts +20 -2
  117. package/dist/powersync/uploader.d.ts +16 -2
  118. package/dist/powersync/uploader.js +152 -158
  119. package/dist/skills/check.d.ts +1 -0
  120. package/dist/skills/check.js +538 -70
  121. package/dist/skills/commandRefs.d.ts +21 -0
  122. package/dist/skills/commandRefs.js +132 -0
  123. package/dist/skills/generate.d.ts +13 -0
  124. package/dist/skills/generate.js +80 -0
  125. package/dist/skills/registry.d.ts +13 -0
  126. package/dist/skills/registry.js +58 -0
  127. package/dist/skills/retired.d.ts +60 -0
  128. package/dist/skills/retired.js +58 -0
  129. package/dist/utils/deadline.d.ts +8 -0
  130. package/dist/utils/deadline.js +31 -0
  131. package/dist/utils/dryRun.d.ts +20 -1
  132. package/dist/utils/dryRun.js +22 -3
  133. package/dist/utils/errorCodes.d.ts +311 -0
  134. package/dist/utils/errorCodes.js +92 -0
  135. package/dist/utils/errors.d.ts +15 -2
  136. package/dist/utils/errors.js +13 -0
  137. package/dist/utils/processExit.d.ts +1 -0
  138. package/dist/utils/processExit.js +33 -0
  139. package/dist/utils/updateNotice.d.ts +1 -0
  140. package/dist/utils/updateNotice.js +1 -1
  141. 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 {};