@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,12 +1,14 @@
1
+ import { resolveAuthIdentity } from '../../auth/userInfo.js';
1
2
  import { printDryRunPreview } from '../../utils/dryRun.js';
2
3
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
3
4
  import { DinoxError } from '../../utils/errors.js';
4
- import { commandStale, printStaleCacheWarning, printUploadDisabledWarning, waitForMutationUpload, } from '../runtime.js';
5
+ import { commandStale, printStaleCacheWarning, resolveWriteDurabilityForRuntime, waitForMutationUpload, } from '../runtime.js';
5
6
  import { resolveZettelBoxIdsForBulk, resolveZettelBoxIdsForSearch, searchNotes, validateTagsForBulk, } from './repo.js';
6
7
  import { applyNoteMetadataMutation, planNoteMetadataMutation, } from './metadataMutation.js';
7
8
  import { parseCreatedAtRange } from './searchTime.js';
8
9
  import { normalizeDaysForMeta, } from './searchResponse.js';
9
- import { parseSearchListInput, parseStarredOption, parseStringListInput, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
10
+ import { parseMutationListInput, parseSearchListInput, parseStarredOption, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
11
+ import { buildBatchWriteReceiptFields, readNoteRevisionReceipts, } from './writeReceipt.js';
10
12
  const BULK_PREVIEW_LIMIT = 100;
11
13
  export function selectBulkMutation(options) {
12
14
  const candidates = [
@@ -42,13 +44,14 @@ export function parseExpectedCount(value) {
42
44
  }
43
45
  function hasConcreteTargetFilter(input) {
44
46
  const { query, options } = input;
45
- return Boolean(query ||
46
- options.tags ||
47
- options.from ||
48
- options.to ||
49
- options.days ||
50
- options.starred ||
51
- options.boxes);
47
+ const hasValue = (value) => typeof value === 'string' && value.trim().length > 0;
48
+ return Boolean(hasValue(query) ||
49
+ hasValue(options.tags) ||
50
+ hasValue(options.from) ||
51
+ hasValue(options.to) ||
52
+ hasValue(options.days) ||
53
+ hasValue(options.starred) ||
54
+ hasValue(options.boxes));
52
55
  }
53
56
  export function assertBulkTargetFilter(input) {
54
57
  const hasFilter = hasConcreteTargetFilter(input);
@@ -64,9 +67,6 @@ function buildExecutionWarnings(input) {
64
67
  if (input.offline) {
65
68
  warnings.push('Execution would run in offline mode and only use the local cache.');
66
69
  }
67
- if (!input.offline && !input.uploadEnabled) {
68
- warnings.push('Execution would be local-only because powersync.uploadBaseUrl is unset.');
69
- }
70
70
  if (input.total > BULK_PREVIEW_LIMIT) {
71
71
  warnings.push(`Preview is truncated to the first ${BULK_PREVIEW_LIMIT} matched notes.`);
72
72
  }
@@ -81,12 +81,16 @@ export async function runNoteBulk({ command, options, args }) {
81
81
  await withNoteDatabase(command, async (runtime) => {
82
82
  const { db, config, offline } = runtime;
83
83
  await syncBeforeNoteCommand(runtime);
84
+ const durability = resolveWriteDurabilityForRuntime(runtime, bulkOptions.durability);
84
85
  const query = args.query?.trim() ?? '';
85
86
  assertBulkTargetFilter({ query, options: bulkOptions });
86
87
  const selected = selectBulkMutation(bulkOptions);
87
- const requestedValues = await parseStringListInput(selected.rawValues, selected.optionName);
88
+ const requestedValues = await parseMutationListInput(selected.rawValues, selected.optionName);
89
+ const tagUserId = selected.field === 'tags'
90
+ ? (await resolveAuthIdentity(config)).userId
91
+ : undefined;
88
92
  const values = selected.field === 'tags'
89
- ? await validateTagsForBulk(db, requestedValues)
93
+ ? await validateTagsForBulk(db, requestedValues, tagUserId)
90
94
  : await resolveZettelBoxIdsForBulk(db, requestedValues);
91
95
  const tagsExpression = typeof bulkOptions.tags === 'string' ? bulkOptions.tags.trim() : '';
92
96
  const { createdAtFrom, createdAtTo } = parseCreatedAtRange({
@@ -178,8 +182,9 @@ export async function runNoteBulk({ command, options, args }) {
178
182
  throw new DinoxError(`--expected-count ${expectedCount} does not match current target count ${plan.total}`);
179
183
  }
180
184
  await applyNoteMetadataMutation(db, plan);
181
- printUploadDisabledWarning(runtime);
182
- await waitForMutationUpload(runtime);
185
+ const upload = await waitForMutationUpload(runtime, { durability });
186
+ const stale = commandStale(runtime);
187
+ const revisions = await readNoteRevisionReceipts(db, plan.results.map((item) => item.id), new Map(plan.results.map((item) => [item.id, item.changed ? [selected.field] : []])));
183
188
  const payload = {
184
189
  ok: true,
185
190
  field: selected.field,
@@ -187,12 +192,13 @@ export async function runNoteBulk({ command, options, args }) {
187
192
  total: plan.total,
188
193
  updatedCount: plan.changedCount,
189
194
  filters: filterMeta,
195
+ ...buildBatchWriteReceiptFields(upload, stale),
190
196
  results: plan.results.map((item) => ({
191
197
  id: item.id,
192
198
  updated: item.changed,
193
199
  [selected.field]: item.after,
200
+ ...revisions.get(item.id),
194
201
  })),
195
- stale: commandStale(runtime),
196
202
  };
197
203
  if (structuredOutput) {
198
204
  printSuccess(payload);
@@ -2,6 +2,8 @@ import crypto from 'node:crypto';
2
2
  import { DinoxError } from '../../utils/errors.js';
3
3
  import { generateId } from '../../utils/id.js';
4
4
  import { convertMarkdownToNoteContent } from '../../utils/tiptapMarkdown.js';
5
+ import { normalizeStoredImageWidthPercent } from '../../utils/tiptapMarkdownSchemaUtils.js';
6
+ import { escapeLiteralMarkdownText } from '../../utils/tiptapMarkdownSyntax.js';
5
7
  const CONTENT_TEXT_MAX_LENGTH = 300;
6
8
  const MAX_REPLACE_BLOCK_RANGE = 50;
7
9
  const ANCHOR_ATTRIBUTE_KEYS = ['anchorKey', 'data-toc-id', 'id', 'uniqueId'];
@@ -208,11 +210,18 @@ function findSerializedBlockRanges(nodes, match) {
208
210
  return ranges;
209
211
  }
210
212
  function assertProtectedReplacementAllowed(nodes, allowed) {
211
- const protectedNodes = nodes.filter((node) => isProtectedBlock(node.type));
212
- if (!protectedNodes.length || allowed) {
213
+ const protectedTypes = new Set();
214
+ for (const node of nodes) {
215
+ visit(node, (candidate) => {
216
+ if (isProtectedBlock(candidate.type)) {
217
+ protectedTypes.add(candidate.type);
218
+ }
219
+ });
220
+ }
221
+ if (!protectedTypes.size || allowed) {
213
222
  return;
214
223
  }
215
- const types = Array.from(new Set(protectedNodes.map((node) => node.type))).join(', ');
224
+ const types = Array.from(protectedTypes).join(', ');
216
225
  throw new DinoxError(`Target includes protected block(s): ${types}. Re-run with --allow-protected-replace after content-read if this replacement is intentional.`);
217
226
  }
218
227
  function buildHeadingRanges(document) {
@@ -425,7 +434,7 @@ function blockToMarkdown(node, depth) {
425
434
  case 'heading': return `${'#'.repeat(normalizeHeadingLevel(node.attrs?.level))} ${text}`.trim();
426
435
  case 'paragraph': return text;
427
436
  case 'blockquote': return childBlocks(node).map((child) => blockToMarkdown(child, depth)).join('\n').split('\n').map((line) => `> ${line}`).join('\n').trim();
428
- case 'codeBlock': return `\`\`\`${stringAttr(node, 'language') || stringAttr(node, 'lang')}\n${extractPlainText(node)}\n\`\`\``;
437
+ case 'codeBlock': return fencedMarkdown(stringAttr(node, 'language') || stringAttr(node, 'lang'), extractPlainText(node));
429
438
  case 'bulletList': return listToMarkdown(node, depth, '-');
430
439
  case 'orderedList': return listToMarkdown(node, depth, '1.');
431
440
  case 'taskList': return taskListToMarkdown(node, depth);
@@ -462,8 +471,10 @@ function inlineMarkdown(nodes) {
462
471
  }).join('');
463
472
  }
464
473
  function markedText(node) {
465
- let text = node.text ?? '';
466
- for (const mark of Array.isArray(node.marks) ? node.marks : []) {
474
+ const marks = Array.isArray(node.marks) ? node.marks : [];
475
+ const codeMark = marks.find((mark) => mark?.type === 'code');
476
+ let text = codeMark ? inlineCodeMarkdown(node.text ?? '') : escapeLiteralMarkdownText(node.text ?? '');
477
+ for (const mark of marks) {
467
478
  if (mark?.type === 'bold')
468
479
  text = `**${text}**`;
469
480
  if (mark?.type === 'italic')
@@ -471,12 +482,14 @@ function markedText(node) {
471
482
  if (mark?.type === 'strike')
472
483
  text = `~~${text}~~`;
473
484
  if (mark?.type === 'code')
474
- text = `\`${text.replace(/`/g, '\\`')}\``;
485
+ continue;
475
486
  if (mark?.type === 'link') {
476
487
  const href = stringFromAttrs(mark.attrs, 'href');
477
488
  const title = stringFromAttrs(mark.attrs, 'title');
478
- if (href)
479
- text = `[${text}](${href}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`;
489
+ if (href) {
490
+ const serializedTitle = title ? ` "${escapeMarkdownLinkTitle(title)}"` : '';
491
+ text = `[${text}](${markdownLinkDestination(href)}${serializedTitle})`;
492
+ }
480
493
  }
481
494
  }
482
495
  return text;
@@ -515,7 +528,34 @@ function imageToMarkdown(node) {
515
528
  const src = stringAttr(node, 'src');
516
529
  const alt = stringAttr(node, 'alt') || stringAttr(node, 'title') || inlineMarkdown(node.content ?? []);
517
530
  const caption = stringAttr(node, 'caption') || inlineMarkdown(node.content ?? []);
518
- return [src ? `![${alt}](${src})` : placeholderMarkdown('image', node), caption && caption !== alt ? caption : ''].filter(Boolean).join('\n');
531
+ const image = src
532
+ ? `![${escapeLiteralMarkdownText(alt)}](${markdownLinkDestination(src)})${imageMetadataComment(node)}`
533
+ : placeholderMarkdown('image', node);
534
+ return [image, caption && caption !== alt ? caption : ''].filter(Boolean).join('\n');
535
+ }
536
+ function markdownLinkDestination(value) {
537
+ const safe = value.replace(/[\u0000-\u001F\u007F<>\\]/g, (character) => {
538
+ return `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`;
539
+ });
540
+ return `<${safe}>`;
541
+ }
542
+ function escapeMarkdownLinkTitle(value) {
543
+ return value
544
+ .replace(/\\/g, '\\\\')
545
+ .replace(/"/g, '\\"')
546
+ .replace(/\r?\n/g, ' ');
547
+ }
548
+ function imageMetadataComment(node) {
549
+ const resourceId = stringAttr(node, 'resourceId');
550
+ const widthPercent = normalizeStoredImageWidthPercent(node.attrs?.widthPercent);
551
+ if (!resourceId && widthPercent == null) {
552
+ return '';
553
+ }
554
+ return `<!-- ${JSON.stringify({
555
+ kind: 'image',
556
+ ...(resourceId ? { resourceId } : {}),
557
+ ...(widthPercent == null ? {} : { widthPercent }),
558
+ })} -->`;
519
559
  }
520
560
  function atomicReferenceMarkdown(node) {
521
561
  if (node.type === 'video' || node.type === 'audio' || node.type === 'file' || node.type === 'bookmark' || node.type === 'iframe' || node.type === 'audiosegment' || node.type === 'pdfAnnotation') {
@@ -524,7 +564,25 @@ function atomicReferenceMarkdown(node) {
524
564
  return '';
525
565
  }
526
566
  function fencedAttrMarkdown(language, text) {
527
- return text ? `\`\`\`${language}\n${text}\n\`\`\`` : `[${language}]`;
567
+ return text ? fencedMarkdown(language, text) : `[${language}]`;
568
+ }
569
+ function longestBacktickRun(value) {
570
+ let longest = 0;
571
+ for (const match of value.matchAll(/`+/g)) {
572
+ longest = Math.max(longest, match[0].length);
573
+ }
574
+ return longest;
575
+ }
576
+ function fencedMarkdown(language, value) {
577
+ const fence = '`'.repeat(Math.max(3, longestBacktickRun(value) + 1));
578
+ const safeLanguage = language.replace(/[`\r\n]/g, '').trim();
579
+ return `${fence}${safeLanguage}\n${value}${value.endsWith('\n') ? '' : '\n'}${fence}`;
580
+ }
581
+ function inlineCodeMarkdown(value) {
582
+ const fence = '`'.repeat(Math.max(1, longestBacktickRun(value) + 1));
583
+ const needsPadding = value.startsWith('`') || value.endsWith('`') ||
584
+ (value.startsWith(' ') && value.endsWith(' ') && value.trim().length > 0);
585
+ return `${fence}${needsPadding ? ` ${value} ` : value}${fence}`;
528
586
  }
529
587
  function placeholderMarkdown(type, node, label) {
530
588
  const attrs = [
@@ -72,13 +72,16 @@ export declare function planMarkdownPatch(input: {
72
72
  contentMd: string;
73
73
  operation: NoteContentPatchOperation;
74
74
  }): NoteContentPatchPlan;
75
- export declare function readNoteContentForPatch(db: AbstractPowerSyncDatabase, id: string): Promise<NoteContentReadResult>;
75
+ export declare function readNoteContentForPatch(db: AbstractPowerSyncDatabase, id: string, databaseScope: string, userId: string): Promise<NoteContentReadResult>;
76
76
  export declare function planNoteContentPatch(db: AbstractPowerSyncDatabase, input: {
77
77
  id: string;
78
+ userId: string;
78
79
  operation: NoteContentPatchOperation;
79
80
  }): Promise<NoteContentPatchPlan>;
80
81
  export declare function applyNoteContentPatch(db: AbstractPowerSyncDatabase, input: {
81
82
  id: string;
83
+ databaseScope: string;
84
+ userId: string;
82
85
  readToken: string;
83
86
  operation: NoteContentPatchOperation;
84
87
  }): Promise<NoteContentPatchResult>;
@@ -1,18 +1,31 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { getDataDir } from '../../config/paths.js';
4
5
  import { withPowerSyncWriteTransaction } from '../../powersync/runtime.js';
5
6
  import { DinoxError } from '../../utils/errors.js';
6
7
  import { generateId } from '../../utils/id.js';
8
+ import { acquireExclusiveSqliteLease } from '../../utils/sqliteLease.js';
9
+ import { withAsyncRelease } from '../../utils/asyncRelease.js';
7
10
  import { nowIsoUtc } from '../../utils/time.js';
11
+ import { deriveImageDetailFromContentJson } from '../todo/contentModel.js';
8
12
  import { applyJsonPatchToDocument, buildNoteContentBlocks, buildNoteContentOutline, getNoteContentHash, parseStoredNoteDocument, projectNoteContent, } from './contentEngine.js';
13
+ import { parseStoredTags } from './repo.js';
9
14
  const READ_TOKEN_TTL_MS = 30 * 60 * 1000;
10
15
  const MAX_STORED_READ_TOKENS = 500;
11
16
  const TOKEN_STORE_FILE = 'note-content-read-tokens.json';
17
+ const TOKEN_STORE_LOCK_TIMEOUT_MS = 5_000;
12
18
  function normalizeVersion(value) {
13
19
  const parsed = Number(value);
14
20
  return Number.isFinite(parsed) ? parsed : null;
15
21
  }
22
+ function fingerprintDatabaseScope(value) {
23
+ const normalized = value.trim();
24
+ if (!normalized) {
25
+ throw new DinoxError('Database scope is required for note content read tokens.');
26
+ }
27
+ return `sha256:${crypto.createHash('sha256').update(normalized, 'utf8').digest('hex')}`;
28
+ }
16
29
  function canonicalTagPath(value) {
17
30
  return value
18
31
  .split(/[\\/]+/g)
@@ -23,7 +36,11 @@ function canonicalTagPath(value) {
23
36
  function tagPathKey(value) {
24
37
  return canonicalTagPath(value).toLowerCase();
25
38
  }
26
- async function loadTagReferenceLookup(db) {
39
+ async function loadTagReferenceLookup(db, userId) {
40
+ const normalizedUserId = userId.trim();
41
+ if (!normalizedUserId) {
42
+ throw new DinoxError('Note content tag resolution requires the current userId. Run `dino auth login` first, or set DINOX_TOKEN and run an online command so the CLI can resolve the token identity.');
43
+ }
27
44
  const rows = await db.getAll(`
28
45
  SELECT
29
46
  TRIM(COALESCE(id, '')) AS id,
@@ -34,11 +51,12 @@ async function loadTagReferenceLookup(db) {
34
51
  AND TRIM(COALESCE(full_path, path_key, '')) <> ''
35
52
  AND (is_del IS NULL OR is_del = 0)
36
53
  AND (is_actual IS NULL OR is_actual != 0)
54
+ AND TRIM(COALESCE(user_id, '')) = ?
37
55
  ORDER BY
38
56
  updated_at DESC,
39
57
  created_at DESC,
40
58
  id DESC
41
- `, []);
59
+ `, [normalizedUserId]);
42
60
  const byId = new Map();
43
61
  const byPathKey = new Map();
44
62
  for (const row of rows) {
@@ -75,6 +93,20 @@ function createHashtagReferenceResolver(lookup, options) {
75
93
  function tokenStorePath() {
76
94
  return path.join(getDataDir(), TOKEN_STORE_FILE);
77
95
  }
96
+ function tokenStoreLockPath() {
97
+ return `${tokenStorePath()}.lock.sqlite`;
98
+ }
99
+ async function waitForTokenStoreLock() {
100
+ return acquireExclusiveSqliteLease({
101
+ lockPath: tokenStoreLockPath(),
102
+ timeoutMs: TOKEN_STORE_LOCK_TIMEOUT_MS,
103
+ pollIntervalMs: 20,
104
+ createBusyError: () => new DinoxError('Timed out waiting for the note content token store lock. Retry the command.'),
105
+ });
106
+ }
107
+ async function withTokenStoreLock(run) {
108
+ return withAsyncRelease(waitForTokenStoreLock, run);
109
+ }
78
110
  async function loadTokenStore(nowMs = Date.now()) {
79
111
  const filePath = tokenStorePath();
80
112
  let tokens = [];
@@ -83,6 +115,7 @@ async function loadTokenStore(nowMs = Date.now()) {
83
115
  const parsed = JSON.parse(raw);
84
116
  if (Array.isArray(parsed.tokens)) {
85
117
  tokens = parsed.tokens.filter((token) => typeof token?.token === 'string' &&
118
+ typeof token.databaseScopeFingerprint === 'string' &&
86
119
  typeof token.noteId === 'string' &&
87
120
  typeof token.contentHash === 'string' &&
88
121
  typeof token.expiresAt === 'string');
@@ -110,41 +143,42 @@ async function saveTokenStore(store) {
110
143
  await fs.rename(tmpPath, filePath);
111
144
  }
112
145
  async function rememberReadToken(input) {
113
- const nowMs = Date.now();
114
- const store = await loadTokenStore(nowMs);
115
- const expiresAt = new Date(nowMs + READ_TOKEN_TTL_MS).toISOString();
116
- const token = {
117
- token: generateId(),
118
- noteId: input.noteId,
119
- contentHash: input.contentHash,
120
- updatedAt: input.updatedAt,
121
- version: input.version,
122
- createdAt: new Date(nowMs).toISOString(),
123
- expiresAt,
124
- };
125
- store.tokens.push(token);
126
- store.tokens = store.tokens.slice(-MAX_STORED_READ_TOKENS);
127
- await saveTokenStore(store);
128
- return token;
146
+ return withTokenStoreLock(async () => {
147
+ const nowMs = Date.now();
148
+ const store = await loadTokenStore(nowMs);
149
+ const expiresAt = new Date(nowMs + READ_TOKEN_TTL_MS).toISOString();
150
+ const token = {
151
+ token: generateId(),
152
+ databaseScopeFingerprint: fingerprintDatabaseScope(input.databaseScope),
153
+ noteId: input.noteId,
154
+ contentHash: input.contentHash,
155
+ updatedAt: input.updatedAt,
156
+ version: input.version,
157
+ createdAt: new Date(nowMs).toISOString(),
158
+ expiresAt,
159
+ };
160
+ store.tokens.push(token);
161
+ store.tokens = store.tokens.slice(-MAX_STORED_READ_TOKENS);
162
+ await saveTokenStore(store);
163
+ return token;
164
+ });
129
165
  }
130
166
  async function consumeReadToken(tokenId) {
131
167
  const normalized = tokenId.trim();
132
168
  if (!normalized) {
133
169
  throw new DinoxError('Missing --read-token. Run `dino note content-read <id>` before editing.');
134
170
  }
135
- const store = await loadTokenStore();
136
- const token = store.tokens.find((candidate) => candidate.token === normalized);
137
- if (!token) {
138
- throw new DinoxError('read token is expired or unknown. Run `dino note content-read <id>` again.');
139
- }
140
- return token;
141
- }
142
- async function forgetReadToken(tokenId) {
143
- const store = await loadTokenStore();
144
- const next = store.tokens.filter((token) => token.token !== tokenId);
145
- if (next.length !== store.tokens.length) {
146
- await saveTokenStore({ tokens: next });
147
- }
171
+ return withTokenStoreLock(async () => {
172
+ const store = await loadTokenStore();
173
+ const token = store.tokens.find((candidate) => candidate.token === normalized);
174
+ if (!token) {
175
+ throw new DinoxError('read token is expired, unknown, or already used. Run `dino note content-read <id>` again.');
176
+ }
177
+ await saveTokenStore({
178
+ tokens: store.tokens.filter((candidate) => candidate.token !== normalized),
179
+ });
180
+ return token;
181
+ });
148
182
  }
149
183
  async function readNoteContentRow(db, id) {
150
184
  const noteId = id.trim();
@@ -152,7 +186,7 @@ async function readNoteContentRow(db, id) {
152
186
  throw new DinoxError('Note id is required');
153
187
  }
154
188
  const row = await db.getOptional(`
155
- SELECT id, title, content_md, content_json, content_text, updated_at, version, is_del
189
+ SELECT id, title, content_md, content_json, content_text, tags, updated_at, version, is_del
156
190
  FROM c_note
157
191
  WHERE id = ?
158
192
  `, [noteId]);
@@ -180,6 +214,10 @@ async function updateNoteContentProjection(tx, input) {
180
214
  setParts.push('images = ?');
181
215
  params.push(JSON.stringify(input.images));
182
216
  }
217
+ if (columns.has('image_detail')) {
218
+ setParts.push('image_detail = ?');
219
+ params.push(JSON.stringify(input.imageDetail));
220
+ }
183
221
  if (columns.has('tags')) {
184
222
  setParts.push('tags = ?');
185
223
  params.push(JSON.stringify(input.tags));
@@ -191,12 +229,33 @@ async function updateNoteContentProjection(tx, input) {
191
229
  setParts.push('updated_at = ?');
192
230
  params.push(nowIsoUtc());
193
231
  setParts.push('version = COALESCE(version, 0) + 1');
194
- params.push(input.rowId);
195
- await tx.execute(`
232
+ params.push(input.rowId, input.expectedVersion);
233
+ const result = await tx.execute(`
196
234
  UPDATE c_note
197
235
  SET ${setParts.join(',\n ')}
198
236
  WHERE id = ?
237
+ AND COALESCE(version, 0) = ?
238
+ AND (is_del IS NULL OR is_del = 0)
239
+ RETURNING id
199
240
  `, params);
241
+ if (result.rows?.length !== 1) {
242
+ throw new DinoxError('Note content changed while applying the patch. Run `dino note content-read <id>` again.');
243
+ }
244
+ }
245
+ function mergeProjectedTags(storedTags, beforeHashtags, afterHashtags) {
246
+ const contentTagKeys = new Set(beforeHashtags.map((tag) => tag.trim().toLowerCase()).filter(Boolean));
247
+ const result = storedTags.filter((tag) => !contentTagKeys.has(tag.trim().toLowerCase()));
248
+ const seen = new Set(result.map((tag) => tag.trim().toLowerCase()).filter(Boolean));
249
+ for (const tag of afterHashtags) {
250
+ const normalized = tag.trim();
251
+ const key = normalized.toLowerCase();
252
+ if (!normalized || seen.has(key)) {
253
+ continue;
254
+ }
255
+ result.push(normalized);
256
+ seen.add(key);
257
+ }
258
+ return result;
200
259
  }
201
260
  function buildPatchPlan(input) {
202
261
  const document = parseStoredNoteDocument({
@@ -237,9 +296,9 @@ export function planMarkdownPatch(input) {
237
296
  });
238
297
  return plan;
239
298
  }
240
- export async function readNoteContentForPatch(db, id) {
299
+ export async function readNoteContentForPatch(db, id, databaseScope, userId) {
241
300
  const row = await readNoteContentRow(db, id);
242
- const tagLookup = await loadTagReferenceLookup(db);
301
+ const tagLookup = await loadTagReferenceLookup(db, userId);
243
302
  const document = parseStoredNoteDocument({
244
303
  contentJson: row.content_json,
245
304
  contentMd: row.content_md,
@@ -250,6 +309,7 @@ export async function readNoteContentForPatch(db, id) {
250
309
  const contentHash = getNoteContentHash(document);
251
310
  const version = normalizeVersion(row.version);
252
311
  const token = await rememberReadToken({
312
+ databaseScope,
253
313
  noteId: row.id,
254
314
  contentHash,
255
315
  updatedAt: row.updated_at ?? null,
@@ -277,7 +337,7 @@ export async function readNoteContentForPatch(db, id) {
277
337
  }
278
338
  export async function planNoteContentPatch(db, input) {
279
339
  const row = await readNoteContentRow(db, input.id);
280
- const tagLookup = await loadTagReferenceLookup(db);
340
+ const tagLookup = await loadTagReferenceLookup(db, input.userId);
281
341
  const { afterProjection: _afterProjection, ...plan } = buildPatchPlan({
282
342
  id: row.id,
283
343
  title: row.title?.trim() ?? '',
@@ -290,8 +350,11 @@ export async function planNoteContentPatch(db, input) {
290
350
  }
291
351
  export async function applyNoteContentPatch(db, input) {
292
352
  const token = await consumeReadToken(input.readToken);
353
+ if (token.databaseScopeFingerprint !== fingerprintDatabaseScope(input.databaseScope)) {
354
+ throw new DinoxError('read token was issued for a different local account database. Run `dino note content-read <id>` again.');
355
+ }
293
356
  const result = await withPowerSyncWriteTransaction(db, async (tx) => {
294
- const tagLookup = await loadTagReferenceLookup(tx);
357
+ const tagLookup = await loadTagReferenceLookup(tx, input.userId);
295
358
  const row = await readNoteContentRow(tx, input.id);
296
359
  if (row.id !== token.noteId) {
297
360
  throw new DinoxError('read token does not match target note. Run `dino note content-read <id>` again.');
@@ -303,7 +366,6 @@ export async function applyNoteContentPatch(db, input) {
303
366
  if (token.updatedAt !== (row.updated_at ?? null) ||
304
367
  token.version !== normalizeVersion(row.version) ||
305
368
  token.contentHash !== getNoteContentHash(currentDocument)) {
306
- await forgetReadToken(token.token);
307
369
  throw new DinoxError('Note content changed after content-read. Run `dino note content-read <id>` again.');
308
370
  }
309
371
  const plan = buildPatchPlan({
@@ -315,20 +377,22 @@ export async function applyNoteContentPatch(db, input) {
315
377
  resolveHashtagReference: createHashtagReferenceResolver(tagLookup, { strictMissingPaths: true }),
316
378
  });
317
379
  if (plan.changed) {
380
+ const nextTags = mergeProjectedTags(parseStoredTags(row.tags), plan.derived.beforeHashtags, plan.derived.afterHashtags);
318
381
  await updateNoteContentProjection(tx, {
319
382
  rowId: row.id,
320
383
  contentMd: plan.afterProjection.contentMd,
321
384
  contentJson: plan.afterProjection.contentJson,
322
385
  contentText: plan.afterProjection.contentText,
323
- tags: plan.afterProjection.hashtags,
386
+ tags: nextTags,
324
387
  images: plan.afterProjection.images,
325
388
  bidLinks: plan.afterProjection.bidLinks,
389
+ imageDetail: deriveImageDetailFromContentJson(plan.afterProjection.document, row.id),
390
+ expectedVersion: normalizeVersion(row.version) ?? 0,
326
391
  });
327
392
  }
328
393
  const { afterProjection: _afterProjection, ...publicPlan } = plan;
329
394
  return publicPlan;
330
395
  });
331
- await forgetReadToken(token.token);
332
396
  const { beforeContent: _beforeContent, afterContent: _afterContent, ...publicResult } = result;
333
397
  return {
334
398
  ...publicResult,
@@ -1,7 +1,10 @@
1
+ import { resolveAuthIdentity } from '../../auth/userInfo.js';
2
+ import { getPowerSyncDbPath } from '../../config/paths.js';
1
3
  import { printDryRunPreview } from '../../utils/dryRun.js';
2
4
  import { DinoxError } from '../../utils/errors.js';
3
5
  import { getRequestedStructuredOutputFormat, printHumanReadableLine, printSuccess, } from '../../utils/output.js';
4
- import { commandStale, printStaleCacheWarning, printUploadDisabledWarning, waitForMutationUpload, } from '../runtime.js';
6
+ import { commandStale, printStaleCacheWarning, resolveWriteDurabilityForRuntime, waitForMutationUpload, } from '../runtime.js';
7
+ import { readNoteWriteReceipt } from './writeReceipt.js';
5
8
  import { applyNoteContentPatch, planNoteContentPatch, readNoteContentForPatch, } from './contentPatch.js';
6
9
  import { parseStringInput, syncBeforeNoteCommand, withNoteDatabase, } from './runtime.js';
7
10
  function requireNoteId(id) {
@@ -56,7 +59,8 @@ export async function runNoteContentRead({ command, options, args }) {
56
59
  const id = requireNoteId(args.id);
57
60
  await withNoteDatabase(command, async (runtime) => {
58
61
  await syncBeforeNoteCommand(runtime);
59
- const result = await readNoteContentForPatch(runtime.db, id);
62
+ const identity = await resolveAuthIdentity(runtime.config);
63
+ const result = await readNoteContentForPatch(runtime.db, id, getPowerSyncDbPath(identity.userId), identity.userId);
60
64
  const { content, ...rest } = result;
61
65
  const payload = readOptions.includeContent
62
66
  ? { ...rest, content, stale: commandStale(runtime) }
@@ -72,8 +76,10 @@ export async function runNotePatch({ command, options, args }) {
72
76
  await withNoteDatabase(command, async (runtime) => {
73
77
  const { db, config, offline } = runtime;
74
78
  await syncBeforeNoteCommand(runtime);
79
+ const identity = await resolveAuthIdentity(config);
80
+ const durability = resolveWriteDurabilityForRuntime(runtime, patchOptions.durability);
75
81
  if (patchOptions.dryRun) {
76
- const plan = await planNoteContentPatch(db, { id, operation });
82
+ const plan = await planNoteContentPatch(db, { id, userId: identity.userId, operation });
77
83
  printDryRunPreview({
78
84
  dry_run: true,
79
85
  command: 'dino note patch',
@@ -83,9 +89,6 @@ export async function runNotePatch({ command, options, args }) {
83
89
  warnings: [
84
90
  'Run `dino note content-read <id> --format json` before the real edit and pass the returned readToken as --read-token.',
85
91
  ...(offline ? ['Execution would run in offline mode and only use the local cache.'] : []),
86
- ...(!offline && !config.powersync.uploadBaseUrl
87
- ? ['Execution would be local-only because powersync.uploadBaseUrl is unset.']
88
- : []),
89
92
  ],
90
93
  inputs: {
91
94
  id,
@@ -106,13 +109,27 @@ export async function runNotePatch({ command, options, args }) {
106
109
  if (!readToken) {
107
110
  throw new DinoxError('Real note content edits require --read-token from `dino note content-read <id>`.');
108
111
  }
109
- const result = await applyNoteContentPatch(db, { id, readToken, operation });
110
- printUploadDisabledWarning(runtime);
111
- await waitForMutationUpload(runtime);
112
+ const result = await applyNoteContentPatch(db, {
113
+ id,
114
+ databaseScope: getPowerSyncDbPath(identity.userId),
115
+ userId: identity.userId,
116
+ readToken,
117
+ operation,
118
+ });
119
+ const upload = await waitForMutationUpload(runtime, { durability });
120
+ const receipt = await readNoteWriteReceipt(db, id, {
121
+ changed: result.saved
122
+ ? ['content_md', 'content_json', 'content_text', 'images', 'image_detail', 'tags', 'bid_links']
123
+ : [],
124
+ upload,
125
+ stale: commandStale(runtime),
126
+ });
127
+ const { changed: contentChanged, ...publicResult } = result;
112
128
  const payload = {
113
129
  ok: true,
114
- ...result,
115
- stale: commandStale(runtime),
130
+ ...publicResult,
131
+ content_changed: contentChanged,
132
+ ...receipt,
116
133
  };
117
134
  if (structuredOutput) {
118
135
  printSuccess(payload);