@dinoxx/dinox-cli 1.0.22 → 1.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.
Files changed (147) hide show
  1. package/README.md +33 -10
  2. package/dist/auth/userInfo.d.ts +2 -0
  3. package/dist/auth/userInfo.js +55 -14
  4. package/dist/cli.js +36 -2
  5. package/dist/commands/auth/index.d.ts +35 -0
  6. package/dist/commands/auth/index.js +382 -83
  7. package/dist/commands/boxes/index.js +2 -6
  8. package/dist/commands/boxes/model.d.ts +3 -0
  9. package/dist/commands/boxes/model.js +16 -1
  10. package/dist/commands/boxes/operations.js +18 -8
  11. package/dist/commands/boxes/repo.js +13 -11
  12. package/dist/commands/boxes/store.js +1 -1
  13. package/dist/commands/config/index.js +7 -5
  14. package/dist/commands/descriptor/register.js +9 -0
  15. package/dist/commands/doctor.d.ts +123 -0
  16. package/dist/commands/doctor.js +539 -0
  17. package/dist/commands/graph/index.d.ts +1 -1
  18. package/dist/commands/graph/index.js +35 -21
  19. package/dist/commands/graph/repo.js +92 -17
  20. package/dist/commands/meta/index.js +4 -4
  21. package/dist/commands/meta/repo.js +4 -3
  22. package/dist/commands/notes/actions.d.ts +1 -1
  23. package/dist/commands/notes/actions.js +121 -78
  24. package/dist/commands/notes/bulk.d.ts +1 -0
  25. package/dist/commands/notes/bulk.js +23 -17
  26. package/dist/commands/notes/contentEngine.js +69 -11
  27. package/dist/commands/notes/contentPatch.d.ts +4 -1
  28. package/dist/commands/notes/contentPatch.js +105 -41
  29. package/dist/commands/notes/contentPatchCommand.js +28 -11
  30. package/dist/commands/notes/descriptors.js +50 -20
  31. package/dist/commands/notes/dryRun.d.ts +1 -0
  32. package/dist/commands/notes/dryRun.js +4 -7
  33. package/dist/commands/notes/export.d.ts +6 -0
  34. package/dist/commands/notes/export.js +26 -6
  35. package/dist/commands/notes/metadataMutation.d.ts +1 -0
  36. package/dist/commands/notes/metadataMutation.js +14 -3
  37. package/dist/commands/notes/repo.d.ts +46 -4
  38. package/dist/commands/notes/repo.js +113 -28
  39. package/dist/commands/notes/runtime.d.ts +2 -1
  40. package/dist/commands/notes/runtime.js +17 -1
  41. package/dist/commands/notes/searchSql.js +13 -6
  42. package/dist/commands/notes/writeReceipt.d.ts +20 -0
  43. package/dist/commands/notes/writeReceipt.js +82 -0
  44. package/dist/commands/prompt/index.js +3 -3
  45. package/dist/commands/prompt/repo.d.ts +4 -1
  46. package/dist/commands/prompt/repo.js +17 -7
  47. package/dist/commands/runtime.d.ts +14 -2
  48. package/dist/commands/runtime.js +89 -19
  49. package/dist/commands/schema/renderSkills.js +5 -2
  50. package/dist/commands/schema/spec.js +4 -0
  51. package/dist/commands/storage/index.js +8 -2
  52. package/dist/commands/storage/repo.d.ts +4 -0
  53. package/dist/commands/storage/repo.js +356 -92
  54. package/dist/commands/sync.d.ts +23 -1
  55. package/dist/commands/sync.js +105 -60
  56. package/dist/commands/tags/content.js +9 -0
  57. package/dist/commands/tags/index.js +2 -6
  58. package/dist/commands/tags/operations.js +15 -10
  59. package/dist/commands/tags/repo.d.ts +2 -0
  60. package/dist/commands/tags/repo.js +18 -5
  61. package/dist/commands/tags/store.js +1 -1
  62. package/dist/commands/todo/contentModel.d.ts +4 -3
  63. package/dist/commands/todo/contentModel.js +78 -28
  64. package/dist/commands/todo/dryRun.d.ts +1 -0
  65. package/dist/commands/todo/dryRun.js +17 -13
  66. package/dist/commands/todo/index.d.ts +6 -0
  67. package/dist/commands/todo/index.js +89 -123
  68. package/dist/commands/todo/repo.d.ts +36 -1
  69. package/dist/commands/todo/repo.js +146 -37
  70. package/dist/commands/todo/taskIdentity.d.ts +2 -0
  71. package/dist/commands/todo/taskIdentity.js +64 -0
  72. package/dist/commands/todo/taskModel.js +27 -17
  73. package/dist/commands/update.d.ts +13 -1
  74. package/dist/commands/update.js +135 -23
  75. package/dist/commands/writeDryRun.d.ts +3 -2
  76. package/dist/commands/writeDryRun.js +4 -3
  77. package/dist/commands/writeDurability.d.ts +5 -0
  78. package/dist/commands/writeDurability.js +22 -0
  79. package/dist/config/paths.js +6 -1
  80. package/dist/config/resolve.d.ts +2 -0
  81. package/dist/config/resolve.js +23 -9
  82. package/dist/config/store.d.ts +3 -0
  83. package/dist/config/store.js +61 -14
  84. package/dist/config/types.d.ts +4 -0
  85. package/dist/daemon/client.d.ts +26 -1
  86. package/dist/daemon/client.js +236 -12
  87. package/dist/daemon/ensure.js +74 -12
  88. package/dist/daemon/index.d.ts +42 -0
  89. package/dist/daemon/index.js +202 -52
  90. package/dist/daemon/lifecycle.d.ts +44 -0
  91. package/dist/daemon/lifecycle.js +118 -0
  92. package/dist/daemon/pid.d.ts +19 -6
  93. package/dist/daemon/pid.js +147 -13
  94. package/dist/daemon/route.d.ts +14 -0
  95. package/dist/daemon/route.js +40 -0
  96. package/dist/daemon/server.d.ts +11 -0
  97. package/dist/daemon/server.js +201 -50
  98. package/dist/daemon/types.d.ts +29 -1
  99. package/dist/daemon/types.js +1 -0
  100. package/dist/dinox.js +25 -1
  101. package/dist/powersync/connector.js +31 -16
  102. package/dist/powersync/exclusiveRuntime.d.ts +20 -0
  103. package/dist/powersync/exclusiveRuntime.js +48 -0
  104. package/dist/powersync/runtime.d.ts +25 -4
  105. package/dist/powersync/runtime.js +255 -35
  106. package/dist/powersync/syncGate.d.ts +4 -1
  107. package/dist/powersync/syncGate.js +11 -2
  108. package/dist/powersync/syncPass.d.ts +2 -0
  109. package/dist/powersync/syncPass.js +15 -4
  110. package/dist/powersync/tokenIndex.d.ts +13 -0
  111. package/dist/powersync/tokenIndex.js +216 -37
  112. package/dist/powersync/uploader.js +107 -52
  113. package/dist/utils/abort.js +0 -4
  114. package/dist/utils/asyncRelease.d.ts +2 -0
  115. package/dist/utils/asyncRelease.js +25 -0
  116. package/dist/utils/bidlinkCanonicalization.js +3 -0
  117. package/dist/utils/dryRun.d.ts +1 -1
  118. package/dist/utils/errors.d.ts +15 -1
  119. package/dist/utils/errors.js +223 -5
  120. package/dist/utils/output.d.ts +1 -0
  121. package/dist/utils/output.js +51 -15
  122. package/dist/utils/redact.js +1 -1
  123. package/dist/utils/sqliteLease.d.ts +8 -0
  124. package/dist/utils/sqliteLease.js +59 -0
  125. package/dist/utils/syncTimeout.d.ts +7 -1
  126. package/dist/utils/syncTimeout.js +8 -7
  127. package/dist/utils/terminalSanitization.js +6 -2
  128. package/dist/utils/tiptapMarkdown.js +49 -3
  129. package/dist/utils/tiptapMarkdownSyntax.d.ts +9 -0
  130. package/dist/utils/tiptapMarkdownSyntax.js +249 -0
  131. package/dist/utils/tokenize.js +24 -2
  132. package/dist/utils/updateNotice.js +60 -12
  133. package/package.json +14 -10
  134. package/dist/commands/mcp/auth.d.ts +0 -4
  135. package/dist/commands/mcp/auth.js +0 -75
  136. package/dist/commands/mcp/index.d.ts +0 -2
  137. package/dist/commands/mcp/index.js +0 -86
  138. package/dist/commands/mcp/noteContentTools.d.ts +0 -222
  139. package/dist/commands/mcp/noteContentTools.js +0 -390
  140. package/dist/commands/mcp/server.d.ts +0 -34
  141. package/dist/commands/mcp/server.js +0 -424
  142. package/dist/commands/mcp/tools.d.ts +0 -2
  143. package/dist/commands/mcp/tools.js +0 -32
  144. package/dist/commands/mcp/types.d.ts +0 -39
  145. package/dist/commands/mcp/types.js +0 -1
  146. package/dist/utils/noteContentEngine.d.ts +0 -333
  147. package/dist/utils/noteContentEngine.js +0 -740
@@ -1,8 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
5
- import sharp from 'sharp';
4
+ import { withPowerSyncWriteTransaction } from '../../powersync/runtime.js';
6
5
  import { generateId } from '../../utils/id.js';
7
6
  import { DinoxError } from '../../utils/errors.js';
8
7
  const ACTIVE_CUSTOM_STORAGE_CONFIG_ID_KEY = 'storage:active_custom_config_id';
@@ -64,14 +63,36 @@ function finishStorageHost(value) {
64
63
  throw new DinoxError(`Invalid storage endpoint: ${trimmed}`, { cause: error });
65
64
  }
66
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
+ }
67
87
  function buildVirtualHostedStyleUrl(bucket, endpoint, key) {
68
88
  const parsed = new URL(endpoint);
69
89
  const protocol = parsed.protocol || 'https:';
70
90
  const port = parsed.port ? `:${parsed.port}` : '';
71
91
  const host = parsed.hostname || parsed.host;
72
92
  const hostWithBucket = host.startsWith(`${bucket}.`) ? host : `${bucket}.${host}`;
93
+ const encodedKey = encodeStorageKeyForUrl(key);
73
94
  return {
74
- finalUrl: `${protocol}//${hostWithBucket}${port}/${key}`,
95
+ finalUrl: `${protocol}//${hostWithBucket}${port}/${encodedKey}`,
75
96
  domain: `${protocol}//${hostWithBucket}${port}`,
76
97
  };
77
98
  }
@@ -80,8 +101,9 @@ function buildPathStyleUrl(bucket, endpoint, key) {
80
101
  const protocol = parsed.protocol || 'https:';
81
102
  const port = parsed.port ? `:${parsed.port}` : '';
82
103
  const host = parsed.hostname || parsed.host;
104
+ const encodedKey = encodeStorageKeyForUrl(key);
83
105
  return {
84
- finalUrl: `${protocol}//${host}${port}/${bucket}/${key}`,
106
+ finalUrl: `${protocol}//${host}${port}/${bucket}/${encodedKey}`,
85
107
  domain: `${protocol}//${host}${port}`,
86
108
  };
87
109
  }
@@ -149,11 +171,12 @@ function generateThumbnailKey(originalKey) {
149
171
  return `${dirPath}/thumb/${filenameWithoutExt}_thumbnail.webp`;
150
172
  }
151
173
  function buildStorageTarget(config, key) {
174
+ assertStorageKeyUrlSafe(key);
152
175
  const resolvedEndpoint = finishStorageHost(config.endpoint);
153
176
  if (config.customDomain) {
154
177
  const domain = finishStorageHost(config.customDomain);
155
178
  return {
156
- storageUrl: `${domain}/${key}`,
179
+ storageUrl: `${domain}/${encodeStorageKeyForUrl(key)}`,
157
180
  domain,
158
181
  };
159
182
  }
@@ -364,6 +387,7 @@ function buildStorageTestPlan(args) {
364
387
  };
365
388
  }
366
389
  async function defaultUploadObject(input) {
390
+ const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
367
391
  const client = new S3Client({
368
392
  endpoint: finishStorageHost(input.storage.endpoint),
369
393
  region: input.storage.region,
@@ -379,12 +403,56 @@ async function defaultUploadObject(input) {
379
403
  Body: fs.createReadStream(input.localPath),
380
404
  ContentType: input.contentType,
381
405
  ContentLength: input.contentLength,
406
+ IfNoneMatch: input.overwrite ? undefined : '*',
382
407
  }));
383
408
  return {
384
409
  etag: normalizeText(response.ETag) || null,
385
410
  };
386
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
+ }
387
454
  async function defaultDeleteObject(input) {
455
+ const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
388
456
  const client = new S3Client({
389
457
  endpoint: finishStorageHost(input.storage.endpoint),
390
458
  region: input.storage.region,
@@ -397,9 +465,11 @@ async function defaultDeleteObject(input) {
397
465
  await client.send(new DeleteObjectCommand({
398
466
  Bucket: input.bucket,
399
467
  Key: input.key,
468
+ IfMatch: normalizeText(input.etag) || undefined,
400
469
  }));
401
470
  }
402
471
  async function defaultUploadBytes(input) {
472
+ const { PutObjectCommand, S3Client } = await import('@aws-sdk/client-s3');
403
473
  const client = new S3Client({
404
474
  endpoint: finishStorageHost(input.storage.endpoint),
405
475
  region: input.storage.region,
@@ -415,18 +485,64 @@ async function defaultUploadBytes(input) {
415
485
  Body: input.body,
416
486
  ContentType: input.contentType,
417
487
  ContentLength: input.body.byteLength,
488
+ IfNoneMatch: input.overwrite ? undefined : '*',
418
489
  }));
419
490
  return {
420
491
  etag: normalizeText(response.ETag) || null,
421
492
  };
422
493
  }
423
494
  async function generateThumbnailBuffer(localPath) {
495
+ const { default: sharp } = await import('sharp');
424
496
  return sharp(localPath)
425
497
  .resize({ width: 400, withoutEnlargement: true })
426
498
  .webp({ quality: 50 })
427
499
  .toBuffer();
428
500
  }
429
- async function insertUploadedResource(db, args) {
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) {
430
546
  const columns = await getTableColumns(db, 'c_resource');
431
547
  const requiredColumns = [
432
548
  'id',
@@ -448,47 +564,67 @@ async function insertUploadedResource(db, args) {
448
564
  }
449
565
  const id = generateId();
450
566
  const now = new Date().toISOString();
451
- const insertEntries = [];
452
- const pushInsert = (column, value) => {
453
- if (columns.has(column)) {
454
- insertEntries.push([column, value]);
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);
455
582
  }
456
- };
457
- pushInsert('id', id);
458
- pushInsert('user_id', args.userId);
459
- pushInsert('name', args.plan.fileName);
460
- pushInsert('mime_type', args.plan.contentType);
461
- pushInsert('size_bytes', args.plan.sizeBytes);
462
- pushInsert('storage_config_id', args.storage.id);
463
- pushInsert('storage_key', args.plan.storageKey);
464
- pushInsert('storage_url', args.plan.storageUrl);
465
- pushInsert('storage_provider', args.storage.provider);
466
- pushInsert('bucket', args.storage.bucket);
467
- pushInsert('is_public', args.storage.isPrivate ? 0 : 1);
468
- pushInsert('created_at', now);
469
- pushInsert('updated_at', now);
470
- pushInsert('is_del', 0);
471
- pushInsert('status', 'uploaded');
472
- pushInsert('uploaded_at', now);
473
- pushInsert('etag', args.etag);
474
- pushInsert('checksum', args.thumbnailUrl);
475
- pushInsert('category', args.plan.category);
476
- pushInsert('endpoint', args.storage.endpoint);
477
- pushInsert('domain', args.plan.domain);
478
- pushInsert('extra_data', '{}');
479
- pushInsert('deleted_at', null);
480
- pushInsert('content_sha256', null);
481
- pushInsert('content', null);
482
- pushInsert('content_md', null);
483
- pushInsert('summary', null);
484
- const insertColumns = insertEntries.map(([column]) => column);
485
- const placeholders = insertColumns.map(() => '?').join(', ');
486
- const params = insertEntries.map(([, value]) => value);
487
- await db.execute(`
488
- INSERT INTO c_resource (${insertColumns.join(', ')})
489
- VALUES (${placeholders})
490
- `, params);
491
- return id;
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
+ });
492
628
  }
493
629
  export function parseStorageUploadCategory(value) {
494
630
  const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
@@ -642,17 +778,50 @@ export async function testStorageUpload(db, input, deps) {
642
778
  const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
643
779
  const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
644
780
  const payload = Buffer.from(`dinox-cli storage test\nprovider=${storage.provider}\nuser=${input.userId}\ncreated_at=${new Date().toISOString()}\n`, 'utf8');
645
- await uploadBytes({
646
- body: payload,
647
- bucket: storage.bucket,
648
- key: plan.storageKey,
649
- contentType: plan.contentType,
650
- storage,
651
- });
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
+ }
652
820
  try {
653
821
  await deleteObject({
654
822
  bucket: storage.bucket,
655
823
  key: plan.storageKey,
824
+ etag: cleanupEtag,
656
825
  storage,
657
826
  });
658
827
  }
@@ -663,6 +832,11 @@ export async function testStorageUpload(db, input, deps) {
663
832
  details: {
664
833
  storageKey: plan.storageKey,
665
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
+ ],
666
840
  },
667
841
  });
668
842
  }
@@ -682,46 +856,152 @@ export async function testStorageUpload(db, input, deps) {
682
856
  };
683
857
  }
684
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
+ }
685
864
  const plan = await inspectStorageUpload(db, input, { statFile: deps?.statFile });
686
865
  const configs = await listStoredCustomStorageConfigs(db, input.userId);
687
866
  const storage = configs.find((item) => item.id === plan.storage.id);
688
867
  if (!storage) {
689
868
  throw new DinoxError(`Storage config not found: ${plan.storage.id}`);
690
869
  }
870
+ const overwrite = input.overwrite === true;
691
871
  const uploadObject = deps?.uploadObject ?? defaultUploadObject;
692
872
  const uploadBytes = deps?.uploadBytes ?? defaultUploadBytes;
693
873
  const deleteObject = deps?.deleteObject ?? defaultDeleteObject;
694
- const uploaded = await uploadObject({
695
- localPath: plan.filePath,
696
- bucket: storage.bucket,
697
- key: plan.storageKey,
698
- contentType: plan.contentType,
699
- contentLength: plan.sizeBytes,
700
- storage,
701
- });
702
- let thumbnailUrl = null;
703
- let uploadedThumbnailKey = null;
874
+ const thumbnailKey = plan.contentType.startsWith('image/')
875
+ ? generateThumbnailKey(plan.storageKey)
876
+ : null;
877
+ let uploaded;
704
878
  try {
705
- if (plan.contentType.startsWith('image/')) {
706
- const thumbnailKey = generateThumbnailKey(plan.storageKey);
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
+ }
707
958
  const thumbnailBuffer = await generateThumbnailBuffer(plan.filePath);
708
959
  const thumbnailTarget = buildStorageTarget(storage, thumbnailKey);
709
- await uploadBytes({
710
- body: thumbnailBuffer,
711
- bucket: storage.bucket,
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({
712
978
  key: thumbnailKey,
713
- contentType: 'image/webp',
714
- storage,
979
+ etag: uploadedThumbnail.etag,
980
+ cleanupAllowed: !overwrite,
981
+ cleaned: false,
715
982
  });
716
- uploadedThumbnailKey = thumbnailKey;
717
983
  thumbnailUrl = thumbnailTarget.storageUrl;
718
984
  }
719
- const resourceId = await insertUploadedResource(db, {
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, {
720
999
  userId: input.userId,
721
1000
  storage,
722
1001
  plan,
723
1002
  etag: uploaded.etag,
724
1003
  thumbnailUrl,
1004
+ overwrite,
725
1005
  });
726
1006
  return {
727
1007
  resourceId,
@@ -744,27 +1024,11 @@ export async function uploadFileToStorage(db, input, deps) {
744
1024
  };
745
1025
  }
746
1026
  catch (error) {
747
- const cleanupErrors = [];
748
- if (uploadedThumbnailKey) {
749
- await deleteObject({
750
- bucket: storage.bucket,
751
- key: uploadedThumbnailKey,
752
- storage,
753
- }).catch((cleanupError) => {
754
- cleanupErrors.push(`thumbnail cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
755
- });
756
- }
757
- await deleteObject({
758
- bucket: storage.bucket,
759
- key: plan.storageKey,
760
- storage,
761
- }).catch((cleanupError) => {
762
- cleanupErrors.push(`main object cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
763
- });
764
- throw new DinoxError('Storage upload failed while generating the required thumbnail.', {
1027
+ const cleanupErrors = await cleanupUploadedObjects();
1028
+ throw new DinoxError('Storage upload failed while recording resource metadata.', {
765
1029
  cause: error,
766
- code: 'STORAGE_THUMBNAIL_UPLOAD_FAILED',
767
- ...(cleanupErrors.length > 0 ? { details: { cleanupErrors } } : {}),
1030
+ code: 'STORAGE_RESOURCE_INSERT_FAILED',
1031
+ details: buildRemoteFailureDetails(cleanupErrors),
768
1032
  });
769
1033
  }
770
1034
  }
@@ -1,4 +1,26 @@
1
1
  import { Command } from 'commander';
2
- import type { CommandDescriptor } from './descriptor/types.js';
2
+ import { loadConfig } from '../config/store.js';
3
+ import { resolveConfig } from '../config/resolve.js';
4
+ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
5
+ import { withExclusivePowerSync } from '../powersync/exclusiveRuntime.js';
6
+ import type { CommandDescriptor, CommandExecutionContext } from './descriptor/types.js';
7
+ import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess } from '../utils/output.js';
8
+ import { getStatusSnapshot, waitForLastSyncedAfter } from '../powersync/runtime.js';
9
+ import { runSingleSyncPass } from '../powersync/syncPass.js';
10
+ type SyncCommandDependencies = {
11
+ loadConfig: typeof loadConfig;
12
+ resolveConfig: typeof resolveConfig;
13
+ hydrateResolvedAuthIdentity: typeof hydrateResolvedAuthIdentity;
14
+ withExclusivePowerSync: typeof withExclusivePowerSync;
15
+ getRequestedStructuredOutputFormat: typeof getRequestedStructuredOutputFormat;
16
+ getStatusSnapshot: typeof getStatusSnapshot;
17
+ waitForLastSyncedAfter: typeof waitForLastSyncedAfter;
18
+ runSingleSyncPass: typeof runSingleSyncPass;
19
+ printHumanReadableLine: typeof printHumanReadableLine;
20
+ printSuccess: typeof printSuccess;
21
+ now: () => number;
22
+ };
23
+ export declare function executeSyncCommand(context: CommandExecutionContext, overrides?: Partial<SyncCommandDependencies>): Promise<void>;
3
24
  export declare const syncCommandDescriptor: CommandDescriptor;
4
25
  export declare function registerSyncCommand(program: Command): void;
26
+ export {};