@stoneforge/quarry 1.13.0 → 1.14.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 (124) hide show
  1. package/dist/api/quarry-api.d.ts +9 -1
  2. package/dist/api/quarry-api.d.ts.map +1 -1
  3. package/dist/api/quarry-api.js +21 -2
  4. package/dist/api/quarry-api.js.map +1 -1
  5. package/dist/api/types.d.ts +8 -1
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -1
  9. package/dist/cli/commands/auto-link-helper.js +1 -0
  10. package/dist/cli/commands/auto-link-helper.js.map +1 -1
  11. package/dist/cli/commands/crud.d.ts +2 -0
  12. package/dist/cli/commands/crud.d.ts.map +1 -1
  13. package/dist/cli/commands/crud.js +100 -10
  14. package/dist/cli/commands/crud.js.map +1 -1
  15. package/dist/cli/commands/docs.js +2 -2
  16. package/dist/cli/commands/docs.js.map +1 -1
  17. package/dist/cli/commands/document.js +1 -1
  18. package/dist/cli/commands/document.js.map +1 -1
  19. package/dist/cli/commands/entity.js +1 -1
  20. package/dist/cli/commands/entity.js.map +1 -1
  21. package/dist/cli/commands/external-sync.d.ts +6 -5
  22. package/dist/cli/commands/external-sync.d.ts.map +1 -1
  23. package/dist/cli/commands/external-sync.js +1032 -180
  24. package/dist/cli/commands/external-sync.js.map +1 -1
  25. package/dist/cli/commands/library.js +1 -1
  26. package/dist/cli/commands/library.js.map +1 -1
  27. package/dist/cli/commands/message.js +2 -2
  28. package/dist/cli/commands/message.js.map +1 -1
  29. package/dist/cli/commands/serve.d.ts.map +1 -1
  30. package/dist/cli/commands/serve.js +2 -0
  31. package/dist/cli/commands/serve.js.map +1 -1
  32. package/dist/cli/commands/task.d.ts.map +1 -1
  33. package/dist/cli/commands/task.js +7 -4
  34. package/dist/cli/commands/task.js.map +1 -1
  35. package/dist/cli/commands/team.js +1 -1
  36. package/dist/cli/commands/team.js.map +1 -1
  37. package/dist/cli/commands/workflow.js +1 -1
  38. package/dist/cli/commands/workflow.js.map +1 -1
  39. package/dist/cli/utils/progress.d.ts +30 -0
  40. package/dist/cli/utils/progress.d.ts.map +1 -0
  41. package/dist/cli/utils/progress.js +47 -0
  42. package/dist/cli/utils/progress.js.map +1 -0
  43. package/dist/config/config.d.ts.map +1 -1
  44. package/dist/config/config.js +6 -0
  45. package/dist/config/config.js.map +1 -1
  46. package/dist/config/defaults.d.ts.map +1 -1
  47. package/dist/config/defaults.js +1 -0
  48. package/dist/config/defaults.js.map +1 -1
  49. package/dist/config/file.d.ts.map +1 -1
  50. package/dist/config/file.js +10 -0
  51. package/dist/config/file.js.map +1 -1
  52. package/dist/config/merge.d.ts.map +1 -1
  53. package/dist/config/merge.js +7 -1
  54. package/dist/config/merge.js.map +1 -1
  55. package/dist/config/types.d.ts +7 -2
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/config/types.js +3 -0
  58. package/dist/config/types.js.map +1 -1
  59. package/dist/config/validation.d.ts.map +1 -1
  60. package/dist/config/validation.js +13 -0
  61. package/dist/config/validation.js.map +1 -1
  62. package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
  63. package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
  64. package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
  65. package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
  66. package/dist/external-sync/index.d.ts +3 -0
  67. package/dist/external-sync/index.d.ts.map +1 -1
  68. package/dist/external-sync/index.js +4 -0
  69. package/dist/external-sync/index.js.map +1 -1
  70. package/dist/external-sync/provider-registry.d.ts +7 -3
  71. package/dist/external-sync/provider-registry.d.ts.map +1 -1
  72. package/dist/external-sync/provider-registry.js +20 -3
  73. package/dist/external-sync/provider-registry.js.map +1 -1
  74. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
  75. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
  76. package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
  77. package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
  78. package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
  79. package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
  80. package/dist/external-sync/providers/folder/folder-fs.js +300 -0
  81. package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
  82. package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
  83. package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
  84. package/dist/external-sync/providers/folder/folder-provider.js +87 -0
  85. package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
  86. package/dist/external-sync/providers/folder/index.d.ts +11 -0
  87. package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
  88. package/dist/external-sync/providers/folder/index.js +13 -0
  89. package/dist/external-sync/providers/folder/index.js.map +1 -0
  90. package/dist/external-sync/providers/index.d.ts +4 -0
  91. package/dist/external-sync/providers/index.d.ts.map +1 -1
  92. package/dist/external-sync/providers/index.js +5 -0
  93. package/dist/external-sync/providers/index.js.map +1 -1
  94. package/dist/external-sync/providers/notion/index.d.ts +19 -0
  95. package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
  96. package/dist/external-sync/providers/notion/index.js +20 -0
  97. package/dist/external-sync/providers/notion/index.js.map +1 -0
  98. package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
  99. package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
  100. package/dist/external-sync/providers/notion/notion-api.js +492 -0
  101. package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
  102. package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
  103. package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
  104. package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
  105. package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
  106. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
  107. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
  108. package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
  109. package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
  110. package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
  111. package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
  112. package/dist/external-sync/providers/notion/notion-provider.js +159 -0
  113. package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
  114. package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
  115. package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
  116. package/dist/external-sync/providers/notion/notion-types.js +47 -0
  117. package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
  118. package/dist/external-sync/sync-engine.d.ts +70 -4
  119. package/dist/external-sync/sync-engine.d.ts.map +1 -1
  120. package/dist/external-sync/sync-engine.js +436 -67
  121. package/dist/external-sync/sync-engine.js.map +1 -1
  122. package/dist/server/index.js +8 -8
  123. package/dist/server/index.js.map +1 -1
  124. package/package.json +4 -12
@@ -30,11 +30,14 @@ import { createTimestamp } from '@stoneforge/core';
30
30
  import { createHash } from 'crypto';
31
31
  import { computeContentHashSync } from '../sync/hash.js';
32
32
  import { taskToExternalTask, externalTaskToTaskUpdates, getFieldMapConfigForProvider } from './adapters/task-sync-adapter.js';
33
+ import { documentToExternalDocumentInput, externalDocumentToDocumentUpdates, computeExternalDocumentHash, resolveDocumentLibraryPaths } from './adapters/document-sync-adapter.js';
33
34
  // ============================================================================
34
35
  // Settings Keys
35
36
  // ============================================================================
36
37
  /** Key prefix for sync cursor settings */
37
38
  const SYNC_CURSOR_KEY_PREFIX = 'external_sync.cursor';
39
+ /** Number of documents to push concurrently (balances throughput vs API rate limits) */
40
+ const PUSH_CONCURRENCY = 3;
38
41
  /**
39
42
  * Build a settings key for a sync cursor.
40
43
  * Format: external_sync.cursor.<provider>.<project>.<adapterType>
@@ -118,47 +121,65 @@ export class SyncEngine {
118
121
  const errors = [];
119
122
  const now = createTimestamp();
120
123
  // Step 1: Find elements to push
121
- const elements = await this.findLinkedElements(options);
122
- // Step 2: Process each element
123
- for (const element of elements) {
124
- const syncState = getExternalSyncState(element.metadata);
125
- if (!syncState) {
126
- // No sync state — shouldn't happen since we filtered for it, but guard
127
- skipped.push(1);
128
- continue;
129
- }
130
- // Skip if direction is pull-only
131
- if (syncState.direction === 'pull') {
132
- skipped.push(1);
133
- continue;
134
- }
135
- // Skip closed/tombstone tasks — they're done and shouldn't sync.
136
- // If a task is later reopened (status changes away from closed/tombstone),
137
- // it will be picked up again naturally since the filter no longer applies.
138
- const elementStatus = element.status;
139
- if (elementStatus === 'closed' || elementStatus === 'tombstone') {
140
- skipped.push(1);
141
- continue;
142
- }
143
- try {
144
- const result = await this.pushElement(element, syncState, options, now);
145
- if (result === 'pushed') {
146
- pushed.push(1);
124
+ let elements = await this.findLinkedElements(options);
125
+ // Step 1.5: Batch-resolve library paths for all document elements
126
+ // This avoids N+1 queries by resolving all paths upfront
127
+ const documentElements = elements.filter(el => {
128
+ const syncState = getExternalSyncState(el.metadata);
129
+ return syncState?.adapterType === 'document';
130
+ });
131
+ const libraryPaths = documentElements.length > 0
132
+ ? await resolveDocumentLibraryPaths(this.api, documentElements.map(el => el.id))
133
+ : new Map();
134
+ // Step 1.6: Filter out documents without a library unless includeNoLibrary is set
135
+ let noLibrarySkipped = 0;
136
+ if (!options.includeNoLibrary && documentElements.length > 0) {
137
+ const beforeCount = elements.length;
138
+ elements = elements.filter(el => {
139
+ const syncState = getExternalSyncState(el.metadata);
140
+ if (syncState?.adapterType !== 'document')
141
+ return true; // keep non-documents
142
+ return libraryPaths.has(el.id);
143
+ });
144
+ noLibrarySkipped = beforeCount - elements.length;
145
+ }
146
+ // Notify caller of total count before processing begins
147
+ options.onBeforeProcess?.(elements.length);
148
+ // Step 2: Process elements concurrently in batches
149
+ let processedCount = 0;
150
+ // Notify caller of total element count on first progress callback
151
+ options.onProgress?.(0, elements.length);
152
+ for (let i = 0; i < elements.length; i += PUSH_CONCURRENCY) {
153
+ const batch = elements.slice(i, i + PUSH_CONCURRENCY);
154
+ const results = await Promise.allSettled(batch.map(element => this.pushSingleElement(element, options, now, libraryPaths)));
155
+ for (let j = 0; j < results.length; j++) {
156
+ const result = results[j];
157
+ if (result.status === 'fulfilled') {
158
+ const outcome = result.value;
159
+ if (outcome === 'pushed') {
160
+ pushed.push(1);
161
+ }
162
+ else if (outcome === 'skipped') {
163
+ skipped.push(1);
164
+ }
147
165
  }
148
- else if (result === 'skipped') {
149
- skipped.push(1);
166
+ else {
167
+ // rejected — extract error info
168
+ const element = batch[j];
169
+ const syncState = getExternalSyncState(element.metadata);
170
+ const err = result.reason;
171
+ errors.push({
172
+ elementId: element.id,
173
+ externalId: syncState?.externalId ?? '',
174
+ provider: syncState?.provider ?? 'unknown',
175
+ project: syncState?.project ?? 'unknown',
176
+ message: err instanceof Error ? err.message : String(err),
177
+ retryable: isRetryableError(err),
178
+ });
150
179
  }
151
180
  }
152
- catch (err) {
153
- errors.push({
154
- elementId: element.id,
155
- externalId: syncState.externalId,
156
- provider: syncState.provider,
157
- project: syncState.project,
158
- message: err instanceof Error ? err.message : String(err),
159
- retryable: isRetryableError(err),
160
- });
161
- }
181
+ processedCount += batch.length;
182
+ options.onProgress?.(processedCount, elements.length);
162
183
  }
163
184
  return buildResult({
164
185
  pushed: pushed.length,
@@ -168,8 +189,43 @@ export class SyncEngine {
168
189
  errors,
169
190
  provider: this.getPrimaryProvider(),
170
191
  project: this.getPrimaryProject(),
192
+ noLibrarySkipped,
171
193
  });
172
194
  }
195
+ /**
196
+ * Process a single element for push — validates sync state, checks skip conditions,
197
+ * and routes to the appropriate push method (pushElement or pushDocument).
198
+ *
199
+ * Extracted from the push() loop body to enable concurrent execution via Promise.allSettled.
200
+ *
201
+ * @returns 'pushed' if changes were sent, 'skipped' if element was skipped
202
+ */
203
+ async pushSingleElement(element, options, now, libraryPaths = new Map()) {
204
+ const syncState = getExternalSyncState(element.metadata);
205
+ if (!syncState) {
206
+ // No sync state — shouldn't happen since we filtered for it, but guard
207
+ return 'skipped';
208
+ }
209
+ // Skip if direction is pull-only
210
+ if (syncState.direction === 'pull') {
211
+ return 'skipped';
212
+ }
213
+ // Skip closed/tombstone tasks and archived documents — they're done
214
+ // and shouldn't sync. If an element is later reopened/reactivated,
215
+ // it will be picked up again naturally since the filter no longer applies.
216
+ const elementStatus = element.status;
217
+ if (elementStatus === 'closed' || elementStatus === 'tombstone' || elementStatus === 'archived') {
218
+ return 'skipped';
219
+ }
220
+ // Route by adapter type: tasks → pushElement(), documents → pushDocument()
221
+ if (syncState.adapterType === 'document') {
222
+ const libraryPath = libraryPaths.get(element.id);
223
+ return this.pushDocument(element, syncState, options, now, libraryPath);
224
+ }
225
+ else {
226
+ return this.pushElement(element, syncState, options, now);
227
+ }
228
+ }
173
229
  /**
174
230
  * Push a single element to its external service.
175
231
  *
@@ -219,6 +275,55 @@ export class SyncEngine {
219
275
  });
220
276
  return 'pushed';
221
277
  }
278
+ /**
279
+ * Push a single document to its external service.
280
+ *
281
+ * Parallel to pushElement(), but uses document-specific conversion
282
+ * (documentToExternalDocumentInput) and the DocumentSyncAdapter.
283
+ *
284
+ * @returns 'pushed' if changes were sent, 'skipped' if no changes detected
285
+ */
286
+ async pushDocument(element, syncState, options, now, libraryPath) {
287
+ // Check for actual content change via hash (skip when force is true)
288
+ const currentHash = computeContentHashSync(element).hash;
289
+ if (!options.force && syncState.lastPushedHash && currentHash === syncState.lastPushedHash) {
290
+ return 'skipped';
291
+ }
292
+ // Verify events have occurred since last push (skip when force is true)
293
+ if (!options.force && syncState.lastPushedAt) {
294
+ const events = await this.api.listEvents({
295
+ elementId: element.id,
296
+ eventType: ['updated'],
297
+ after: syncState.lastPushedAt,
298
+ });
299
+ if (events.length === 0) {
300
+ return 'skipped';
301
+ }
302
+ }
303
+ // Dry run — report but don't actually push
304
+ if (options.dryRun) {
305
+ return 'pushed';
306
+ }
307
+ // Get the document adapter for this element's provider
308
+ const adapter = this.getDocumentAdapter(syncState.provider);
309
+ if (!adapter) {
310
+ throw new Error(`No document adapter found for provider '${syncState.provider}'`);
311
+ }
312
+ // Build external document input using the shared field mapping utilities
313
+ const docInput = documentToExternalDocumentInput(element, libraryPath);
314
+ // Push to external service
315
+ await adapter.updatePage(syncState.project, syncState.externalId, docInput);
316
+ // Update sync state on element
317
+ const updatedSyncState = {
318
+ ...syncState,
319
+ lastPushedAt: now,
320
+ lastPushedHash: currentHash,
321
+ };
322
+ await this.api.update(element.id, {
323
+ metadata: setExternalSyncState(element.metadata, updatedSyncState),
324
+ });
325
+ return 'pushed';
326
+ }
222
327
  // --------------------------------------------------------------------------
223
328
  // Pull — pull externally-changed items into Stoneforge
224
329
  // --------------------------------------------------------------------------
@@ -243,28 +348,58 @@ export class SyncEngine {
243
348
  const conflicts = [];
244
349
  const errors = [];
245
350
  const now = createTimestamp();
246
- // Get all task adapters from the registry
247
- const adapterEntries = this.registry.getAdaptersOfType('task');
248
- for (const { provider, adapter } of adapterEntries) {
249
- const taskAdapter = adapter;
250
- const providerConfig = this.getProviderConfig(provider.name);
251
- const project = providerConfig?.defaultProject;
252
- if (!project) {
253
- // No project configured for this provider — skip
254
- continue;
255
- }
256
- try {
257
- const result = await this.pullFromProvider(provider, taskAdapter, project, options, now, conflicts, errors);
258
- pulled.push(result.pulled);
259
- skipped.push(result.skipped);
351
+ const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
352
+ ? new Set(options.adapterTypes)
353
+ : null;
354
+ // Get all task adapters from the registry (skip if filtered out)
355
+ if (!adapterFilter || adapterFilter.has('task')) {
356
+ const taskAdapterEntries = this.registry.getAdaptersOfType('task');
357
+ for (const { provider, adapter } of taskAdapterEntries) {
358
+ const taskAdapter = adapter;
359
+ const providerConfig = this.getProviderConfig(provider.name);
360
+ const project = providerConfig?.defaultProject;
361
+ if (!project) {
362
+ // No project configured for this provider skip
363
+ continue;
364
+ }
365
+ try {
366
+ const result = await this.pullFromProvider(provider, taskAdapter, project, options, now, conflicts, errors);
367
+ pulled.push(result.pulled);
368
+ skipped.push(result.skipped);
369
+ }
370
+ catch (err) {
371
+ errors.push({
372
+ provider: provider.name,
373
+ project,
374
+ message: err instanceof Error ? err.message : String(err),
375
+ retryable: isRetryableError(err),
376
+ });
377
+ }
260
378
  }
261
- catch (err) {
262
- errors.push({
263
- provider: provider.name,
264
- project,
265
- message: err instanceof Error ? err.message : String(err),
266
- retryable: isRetryableError(err),
267
- });
379
+ }
380
+ // Get all document adapters from the registry (skip if filtered out)
381
+ if (!adapterFilter || adapterFilter.has('document')) {
382
+ const docAdapterEntries = this.registry.getAdaptersOfType('document');
383
+ for (const { provider, adapter } of docAdapterEntries) {
384
+ const docAdapter = adapter;
385
+ const providerConfig = this.getProviderConfig(provider.name);
386
+ const project = providerConfig?.defaultProject;
387
+ if (!project) {
388
+ continue;
389
+ }
390
+ try {
391
+ const result = await this.pullDocumentsFromProvider(provider, docAdapter, project, options, now, conflicts, errors);
392
+ pulled.push(result.pulled);
393
+ skipped.push(result.skipped);
394
+ }
395
+ catch (err) {
396
+ errors.push({
397
+ provider: provider.name,
398
+ project,
399
+ message: err instanceof Error ? err.message : String(err),
400
+ retryable: isRetryableError(err),
401
+ });
402
+ }
268
403
  }
269
404
  }
270
405
  return buildResult({
@@ -441,6 +576,206 @@ export class SyncEngine {
441
576
  return 'pulled';
442
577
  }
443
578
  // --------------------------------------------------------------------------
579
+ // Pull — document-specific helpers
580
+ // --------------------------------------------------------------------------
581
+ /**
582
+ * Pull document changes from a specific provider.
583
+ * Parallel to pullFromProvider(), but uses the DocumentSyncAdapter.
584
+ */
585
+ async pullDocumentsFromProvider(provider, adapter, project, options, now, conflicts, errors) {
586
+ let pulledCount = 0;
587
+ let skippedCount = 0;
588
+ // Get the sync cursor for this provider+project for documents
589
+ const syncCursor = this.getSyncCursor(provider.name, project, 'document');
590
+ // Fetch changed documents since cursor
591
+ const allExternalDocs = await adapter.listPagesSince(project, syncCursor);
592
+ // Skip external documents with empty or whitespace-only titles
593
+ const externalDocs = allExternalDocs.filter((doc) => doc.title && doc.title.trim().length > 0);
594
+ // Get all locally-linked document elements for matching
595
+ const linkedElements = await this.findLinkedElementsForProvider(provider.name, project);
596
+ // Filter to only document-type linked elements
597
+ const linkedDocuments = linkedElements.filter((el) => {
598
+ const state = getExternalSyncState(el.metadata);
599
+ return state && state.adapterType === 'document';
600
+ });
601
+ // Build a map of externalId → local element for fast lookup
602
+ const linkedByExternalId = new Map();
603
+ for (const el of linkedDocuments) {
604
+ const state = getExternalSyncState(el.metadata);
605
+ if (state) {
606
+ linkedByExternalId.set(state.externalId, el);
607
+ }
608
+ }
609
+ for (const externalDoc of externalDocs) {
610
+ // If taskIds filter is set (used generically for element IDs), check if this external doc matches
611
+ if (options.taskIds) {
612
+ const localEl = linkedByExternalId.get(externalDoc.externalId);
613
+ if (!localEl || !options.taskIds.includes(localEl.id)) {
614
+ skippedCount++;
615
+ continue;
616
+ }
617
+ }
618
+ try {
619
+ const result = await this.pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts);
620
+ if (result === 'pulled') {
621
+ pulledCount++;
622
+ }
623
+ else if (result === 'skipped') {
624
+ skippedCount++;
625
+ }
626
+ else if (result === 'created') {
627
+ pulledCount++;
628
+ }
629
+ }
630
+ catch (err) {
631
+ errors.push({
632
+ externalId: externalDoc.externalId,
633
+ provider: provider.name,
634
+ project,
635
+ message: err instanceof Error ? err.message : String(err),
636
+ retryable: isRetryableError(err),
637
+ });
638
+ }
639
+ }
640
+ // Update sync cursor (unless dry run)
641
+ if (!options.dryRun && externalDocs.length > 0) {
642
+ this.setSyncCursor(provider.name, project, 'document', now);
643
+ }
644
+ return { pulled: pulledCount, skipped: skippedCount };
645
+ }
646
+ /**
647
+ * Pull a single external document into Stoneforge.
648
+ * Parallel to pullItem(), but uses document-specific conversion.
649
+ *
650
+ * @returns 'pulled' if local element was updated, 'skipped' if no changes,
651
+ * 'created' if a new document was created
652
+ */
653
+ async pullDocumentItem(provider, project, externalDoc, linkedByExternalId, options, now, conflicts) {
654
+ const localElement = linkedByExternalId.get(externalDoc.externalId);
655
+ if (!localElement) {
656
+ // Unlinked external document — create new document if --all flag is set
657
+ if (options.all) {
658
+ if (options.dryRun) {
659
+ return 'created';
660
+ }
661
+ await this.createDocumentFromExternal(provider, project, externalDoc, now);
662
+ return 'created';
663
+ }
664
+ return 'skipped';
665
+ }
666
+ // Linked element — check for actual change via hash
667
+ const syncState = getExternalSyncState(localElement.metadata);
668
+ // Skip if direction is push-only
669
+ if (syncState.direction === 'push') {
670
+ return 'skipped';
671
+ }
672
+ // Skip updates to archived documents
673
+ const localStatus = localElement.status;
674
+ if (localStatus === 'archived') {
675
+ return 'skipped';
676
+ }
677
+ // Compute a hash of the external document content to detect real changes
678
+ const remoteContentKey = computeExternalDocumentHash(externalDoc);
679
+ if (syncState.lastPulledHash && remoteContentKey === syncState.lastPulledHash) {
680
+ return 'skipped';
681
+ }
682
+ // Check for conflict: has local also changed since last pull?
683
+ const localHash = computeContentHashSync(localElement).hash;
684
+ const localChanged = syncState.lastPushedHash !== undefined && localHash !== syncState.lastPushedHash;
685
+ if (localChanged) {
686
+ // Both sides changed — use conflict resolver
687
+ const resolution = this.conflictResolver.resolve(localElement, externalDoc, this.defaultStrategy);
688
+ conflicts.push({
689
+ elementId: localElement.id,
690
+ externalId: externalDoc.externalId,
691
+ provider: provider.name,
692
+ project,
693
+ localUpdatedAt: localElement.updatedAt,
694
+ remoteUpdatedAt: externalDoc.updatedAt,
695
+ strategy: this.defaultStrategy,
696
+ resolved: resolution.resolved,
697
+ winner: resolution.resolved ? resolution.winner : undefined,
698
+ });
699
+ if (!resolution.resolved) {
700
+ // Manual resolution needed — tag the element
701
+ if (!options.dryRun) {
702
+ const tags = localElement.tags.includes('sync-conflict')
703
+ ? localElement.tags
704
+ : [...localElement.tags, 'sync-conflict'];
705
+ await this.api.update(localElement.id, { tags });
706
+ }
707
+ return 'skipped';
708
+ }
709
+ if (resolution.winner === 'local') {
710
+ // Local wins — skip the pull, but update the pulled hash
711
+ if (!options.dryRun) {
712
+ const updatedSyncState = {
713
+ ...syncState,
714
+ lastPulledAt: now,
715
+ lastPulledHash: remoteContentKey,
716
+ };
717
+ await this.api.update(localElement.id, {
718
+ metadata: setExternalSyncState(localElement.metadata, updatedSyncState),
719
+ });
720
+ }
721
+ return 'skipped';
722
+ }
723
+ // Remote wins — fall through to apply remote changes
724
+ }
725
+ // Dry run — report but don't actually apply
726
+ if (options.dryRun) {
727
+ return 'pulled';
728
+ }
729
+ // Apply remote changes to local document using document field mapping.
730
+ // Pass the existing document for diff mode (only changed fields are returned).
731
+ const updates = externalDocumentToDocumentUpdates(externalDoc, localElement);
732
+ const updatedSyncState = {
733
+ ...syncState,
734
+ lastPulledAt: now,
735
+ lastPulledHash: remoteContentKey,
736
+ };
737
+ const mergedMetadata = setExternalSyncState({ ...localElement.metadata }, updatedSyncState);
738
+ await this.api.update(localElement.id, {
739
+ ...updates,
740
+ metadata: mergedMetadata,
741
+ });
742
+ return 'pulled';
743
+ }
744
+ /**
745
+ * Create a new Stoneforge document from an unlinked external document.
746
+ */
747
+ async createDocumentFromExternal(provider, project, externalDoc, now) {
748
+ const syncState = {
749
+ provider: provider.name,
750
+ project,
751
+ externalId: externalDoc.externalId,
752
+ url: externalDoc.url,
753
+ lastPulledAt: now,
754
+ lastPulledHash: computeExternalDocumentHash(externalDoc),
755
+ direction: 'bidirectional',
756
+ adapterType: 'document',
757
+ };
758
+ // Use the document field mapping for conversion
759
+ const docUpdates = externalDocumentToDocumentUpdates(externalDoc);
760
+ const createInput = {
761
+ type: 'document',
762
+ title: docUpdates.title ?? externalDoc.title,
763
+ content: docUpdates.content ?? externalDoc.content,
764
+ contentType: docUpdates.contentType ?? 'markdown',
765
+ status: 'active',
766
+ category: 'other',
767
+ version: 1,
768
+ previousVersionId: null,
769
+ immutable: false,
770
+ tags: [],
771
+ externalRef: externalDoc.url,
772
+ createdBy: 'system',
773
+ metadata: { _externalSync: syncState },
774
+ };
775
+ const element = await this.api.create(createInput);
776
+ return element;
777
+ }
778
+ // --------------------------------------------------------------------------
444
779
  // Sync — bidirectional (push then pull)
445
780
  // --------------------------------------------------------------------------
446
781
  /**
@@ -459,7 +794,7 @@ export class SyncEngine {
459
794
  success: pushResult.success && pullResult.success,
460
795
  provider: pushResult.provider || pullResult.provider,
461
796
  project: pushResult.project || pullResult.project,
462
- adapterType: 'task',
797
+ adapterType: pushResult.adapterType || pullResult.adapterType,
463
798
  pushed: pushResult.pushed,
464
799
  pulled: pullResult.pulled,
465
800
  skipped: pushResult.skipped + pullResult.skipped,
@@ -478,27 +813,43 @@ export class SyncEngine {
478
813
  * - Otherwise, returns tasks with _externalSync that have been updated
479
814
  */
480
815
  async findLinkedElements(options) {
816
+ const adapterFilter = options.adapterTypes && options.adapterTypes.length > 0
817
+ ? new Set(options.adapterTypes)
818
+ : null;
481
819
  if (options.taskIds && options.taskIds.length > 0) {
482
- // Fetch specific tasks
820
+ // Fetch specific elements (tasks or documents)
483
821
  const elements = [];
484
822
  for (const id of options.taskIds) {
485
823
  const element = await this.api.get(id);
486
- if (element && getExternalSyncState(element.metadata)) {
487
- elements.push(element);
824
+ if (element) {
825
+ const state = getExternalSyncState(element.metadata);
826
+ if (state && (!adapterFilter || adapterFilter.has(state.adapterType))) {
827
+ elements.push(element);
828
+ }
488
829
  }
489
830
  }
490
831
  return elements;
491
832
  }
492
- // Fetch all tasks, then filter to those with _externalSync metadata
493
- const allTasks = await this.api.list({ type: 'task' });
494
- return allTasks.filter((el) => getExternalSyncState(el.metadata) !== undefined);
833
+ // Fetch elements based on adapter type filter
834
+ const collections = [];
835
+ if (!adapterFilter || adapterFilter.has('task')) {
836
+ collections.push(await this.api.list({ type: 'task' }));
837
+ }
838
+ if (!adapterFilter || adapterFilter.has('document')) {
839
+ collections.push(await this.api.list({ type: 'document' }));
840
+ }
841
+ return collections.flat().filter((el) => {
842
+ const state = getExternalSyncState(el.metadata);
843
+ return state !== undefined && (!adapterFilter || adapterFilter.has(state.adapterType));
844
+ });
495
845
  }
496
846
  /**
497
847
  * Find elements linked to a specific provider and project.
498
848
  */
499
849
  async findLinkedElementsForProvider(providerName, project) {
500
850
  const allTasks = await this.api.list({ type: 'task' });
501
- return allTasks.filter((el) => {
851
+ const allDocs = await this.api.list({ type: 'document' });
852
+ return [...allTasks, ...allDocs].filter((el) => {
502
853
  const state = getExternalSyncState(el.metadata);
503
854
  return state && state.provider === providerName && state.project === project;
504
855
  });
@@ -674,6 +1025,15 @@ export class SyncEngine {
674
1025
  return undefined;
675
1026
  return provider.getTaskAdapter?.();
676
1027
  }
1028
+ /**
1029
+ * Get a DocumentSyncAdapter for a given provider name.
1030
+ */
1031
+ getDocumentAdapter(providerName) {
1032
+ const provider = this.registry.get(providerName);
1033
+ if (!provider)
1034
+ return undefined;
1035
+ return provider.getDocumentAdapter?.();
1036
+ }
677
1037
  /**
678
1038
  * Get the ProviderConfig for a given provider name.
679
1039
  */
@@ -730,10 +1090,14 @@ function isRetryableError(err) {
730
1090
  const message = err.message.toLowerCase();
731
1091
  return (message.includes('rate limit') ||
732
1092
  message.includes('timeout') ||
1093
+ message.includes('gateway timeout') ||
1094
+ message.includes('bad gateway') ||
733
1095
  message.includes('econnrefused') ||
734
1096
  message.includes('enotfound') ||
735
1097
  message.includes('network') ||
1098
+ message.includes('502') ||
736
1099
  message.includes('503') ||
1100
+ message.includes('504') ||
737
1101
  message.includes('429'));
738
1102
  }
739
1103
  /**
@@ -750,6 +1114,9 @@ function buildResult(params) {
750
1114
  skipped: params.skipped,
751
1115
  conflicts: params.conflicts,
752
1116
  errors: params.errors,
1117
+ ...(params.noLibrarySkipped !== undefined && params.noLibrarySkipped > 0
1118
+ ? { noLibrarySkipped: params.noLibrarySkipped }
1119
+ : {}),
753
1120
  };
754
1121
  }
755
1122
  // ============================================================================
@@ -782,4 +1149,6 @@ function buildResult(params) {
782
1149
  export function createSyncEngine(config) {
783
1150
  return new SyncEngine(config);
784
1151
  }
1152
+ // Export constants for testing
1153
+ export { PUSH_CONCURRENCY };
785
1154
  //# sourceMappingURL=sync-engine.js.map