@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.
- package/dist/api/quarry-api.d.ts +9 -1
- package/dist/api/quarry-api.d.ts.map +1 -1
- package/dist/api/quarry-api.js +21 -2
- package/dist/api/quarry-api.js.map +1 -1
- package/dist/api/types.d.ts +8 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/cli/commands/auto-link-helper.d.ts.map +1 -1
- package/dist/cli/commands/auto-link-helper.js +1 -0
- package/dist/cli/commands/auto-link-helper.js.map +1 -1
- package/dist/cli/commands/crud.d.ts +2 -0
- package/dist/cli/commands/crud.d.ts.map +1 -1
- package/dist/cli/commands/crud.js +100 -10
- package/dist/cli/commands/crud.js.map +1 -1
- package/dist/cli/commands/docs.js +2 -2
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/document.js +1 -1
- package/dist/cli/commands/document.js.map +1 -1
- package/dist/cli/commands/entity.js +1 -1
- package/dist/cli/commands/entity.js.map +1 -1
- package/dist/cli/commands/external-sync.d.ts +6 -5
- package/dist/cli/commands/external-sync.d.ts.map +1 -1
- package/dist/cli/commands/external-sync.js +1032 -180
- package/dist/cli/commands/external-sync.js.map +1 -1
- package/dist/cli/commands/library.js +1 -1
- package/dist/cli/commands/library.js.map +1 -1
- package/dist/cli/commands/message.js +2 -2
- package/dist/cli/commands/message.js.map +1 -1
- package/dist/cli/commands/serve.d.ts.map +1 -1
- package/dist/cli/commands/serve.js +2 -0
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/task.d.ts.map +1 -1
- package/dist/cli/commands/task.js +7 -4
- package/dist/cli/commands/task.js.map +1 -1
- package/dist/cli/commands/team.js +1 -1
- package/dist/cli/commands/team.js.map +1 -1
- package/dist/cli/commands/workflow.js +1 -1
- package/dist/cli/commands/workflow.js.map +1 -1
- package/dist/cli/utils/progress.d.ts +30 -0
- package/dist/cli/utils/progress.d.ts.map +1 -0
- package/dist/cli/utils/progress.js +47 -0
- package/dist/cli/utils/progress.js.map +1 -0
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +6 -0
- package/dist/config/config.js.map +1 -1
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +1 -0
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/file.d.ts.map +1 -1
- package/dist/config/file.js +10 -0
- package/dist/config/file.js.map +1 -1
- package/dist/config/merge.d.ts.map +1 -1
- package/dist/config/merge.js +7 -1
- package/dist/config/merge.js.map +1 -1
- package/dist/config/types.d.ts +7 -2
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +3 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +13 -0
- package/dist/config/validation.js.map +1 -1
- package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
- package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
- package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
- package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
- package/dist/external-sync/index.d.ts +3 -0
- package/dist/external-sync/index.d.ts.map +1 -1
- package/dist/external-sync/index.js +4 -0
- package/dist/external-sync/index.js.map +1 -1
- package/dist/external-sync/provider-registry.d.ts +7 -3
- package/dist/external-sync/provider-registry.d.ts.map +1 -1
- package/dist/external-sync/provider-registry.js +20 -3
- package/dist/external-sync/provider-registry.js.map +1 -1
- package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
- package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
- package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
- package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-fs.js +300 -0
- package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
- package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
- package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/folder-provider.js +87 -0
- package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
- package/dist/external-sync/providers/folder/index.d.ts +11 -0
- package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
- package/dist/external-sync/providers/folder/index.js +13 -0
- package/dist/external-sync/providers/folder/index.js.map +1 -0
- package/dist/external-sync/providers/index.d.ts +4 -0
- package/dist/external-sync/providers/index.d.ts.map +1 -1
- package/dist/external-sync/providers/index.js +5 -0
- package/dist/external-sync/providers/index.js.map +1 -1
- package/dist/external-sync/providers/notion/index.d.ts +19 -0
- package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/index.js +20 -0
- package/dist/external-sync/providers/notion/index.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
- package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-api.js +492 -0
- package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
- package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
- package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
- package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
- package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-provider.js +159 -0
- package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
- package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
- package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
- package/dist/external-sync/providers/notion/notion-types.js +47 -0
- package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
- package/dist/external-sync/sync-engine.d.ts +70 -4
- package/dist/external-sync/sync-engine.d.ts.map +1 -1
- package/dist/external-sync/sync-engine.js +436 -67
- package/dist/external-sync/sync-engine.js.map +1 -1
- package/dist/server/index.js +8 -8
- package/dist/server/index.js.map +1 -1
- 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
|
-
|
|
122
|
-
// Step
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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:
|
|
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
|
|
487
|
-
|
|
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
|
|
493
|
-
const
|
|
494
|
-
|
|
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
|
-
|
|
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
|