studiograph 1.3.7 → 1.3.9

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 (38) hide show
  1. package/dist/agent/tools/sync-tools.js +4 -7
  2. package/dist/agent/tools/sync-tools.js.map +1 -1
  3. package/dist/cli/commands/sync-collection.js +3 -14
  4. package/dist/cli/commands/sync-collection.js.map +1 -1
  5. package/dist/server/index.js +4 -1
  6. package/dist/server/index.js.map +1 -1
  7. package/dist/server/routes/sync-api.js +14 -20
  8. package/dist/server/routes/sync-api.js.map +1 -1
  9. package/dist/services/sync/collection-sync.d.ts +22 -35
  10. package/dist/services/sync/collection-sync.js +123 -357
  11. package/dist/services/sync/collection-sync.js.map +1 -1
  12. package/dist/services/sync/structured-extractor.d.ts +2 -0
  13. package/dist/services/sync/structured-extractor.js +1 -1
  14. package/dist/services/sync/structured-extractor.js.map +1 -1
  15. package/dist/services/sync/types.d.ts +10 -5
  16. package/dist/web/_app/immutable/chunks/{CgeRpKxa.js → 7pRCkuF5.js} +1 -1
  17. package/dist/web/_app/immutable/chunks/{CzGIS76D.js → B-2G7IOL.js} +1 -1
  18. package/dist/web/_app/immutable/chunks/{CIMAAqkt.js → Bi-t3yHN.js} +1 -1
  19. package/dist/web/_app/immutable/chunks/{C7axjIkO.js → BiuVYCkU.js} +1 -1
  20. package/dist/web/_app/immutable/chunks/BmPhvzn5.js +27 -0
  21. package/dist/web/_app/immutable/chunks/{RaFtQ6Nr.js → CMFOFkXK.js} +1 -1
  22. package/dist/web/_app/immutable/entry/{app.MspBiFtM.js → app.CFW5bplI.js} +2 -2
  23. package/dist/web/_app/immutable/entry/start.7s9ZYPwM.js +1 -0
  24. package/dist/web/_app/immutable/nodes/{0.Ce8kXvq7.js → 0.DzgYLVo-.js} +1 -1
  25. package/dist/web/_app/immutable/nodes/{1.CL4Veu7q.js → 1.DGm1MhwK.js} +1 -1
  26. package/dist/web/_app/immutable/nodes/{2.Z0cN7pjd.js → 2.CzXC4v8x.js} +1 -1
  27. package/dist/web/_app/immutable/nodes/{3.CHRkLKHQ.js → 3.DuaI9dSH.js} +3 -3
  28. package/dist/web/_app/immutable/nodes/{4.0GYr-_DC.js → 4.BSv65LtF.js} +1 -1
  29. package/dist/web/_app/immutable/nodes/{5.B1USZ_5d.js → 5.DcKJMtC8.js} +1 -1
  30. package/dist/web/_app/immutable/nodes/{6.G72u2W15.js → 6.koFgxtvt.js} +1 -1
  31. package/dist/web/_app/immutable/nodes/7.Baev0HTo.js +2 -0
  32. package/dist/web/_app/immutable/nodes/{8.f-lZ1U9h.js → 8.wJRie-EF.js} +1 -1
  33. package/dist/web/_app/version.json +1 -1
  34. package/dist/web/index.html +6 -6
  35. package/package.json +1 -1
  36. package/dist/web/_app/immutable/chunks/DcaJM5Zr.js +0 -27
  37. package/dist/web/_app/immutable/entry/start.BMnf5AKO.js +0 -1
  38. package/dist/web/_app/immutable/nodes/7.C29v8Chj.js +0 -2
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Collection-Scoped Sync Runner
2
+ * Sync Runner
3
3
  *
4
- * Given a collection with sync rules, connects to sources via MCP,
5
- * extracts records, derives secondary entities, deduplicates against
6
- * existing entities, and writes new entities to the collection.
4
+ * Unified sync pipeline: fetch from source once, distribute to target collections.
5
+ * Both collection-level and workspace-level rules use the same syncSource() function.
6
+ * Collection rules are converted to workspace-rule shape with a single target.
7
+ *
8
+ * Supports server-side filter optimization: when there is exactly one target with
9
+ * server-mode filter keys, those are merged into list_params before extraction.
7
10
  *
8
11
  * Sync-once semantics: existing entities (matched by source_ref) are
9
12
  * never updated during a pull. Use entity-refresh for single-entity updates.
@@ -38,7 +41,7 @@ function createDataFetcher(connectorConfig, definition, opts) {
38
41
  headers['Authorization'] = `Bearer ${oauthData.tokens.access_token}`;
39
42
  }
40
43
  }
41
- // Resolve query-param auth (e.g. api_token for Pipedrive)
44
+ // Resolve env vars in query auth params
42
45
  const queryAuth = {};
43
46
  if (definition.rest.query_auth) {
44
47
  for (const [k, v] of Object.entries(definition.rest.query_auth)) {
@@ -48,10 +51,9 @@ function createDataFetcher(connectorConfig, definition, opts) {
48
51
  return new RESTClient(definition.rest.base_url, headers, definition.entities, queryAuth);
49
52
  }
50
53
  // MCP adapter (default)
51
- const known = KNOWN_CONNECTORS[connectorConfig.name];
52
- const auth = connectorConfig.auth ?? known?.auth;
54
+ const auth = connectorConfig.auth ?? KNOWN_CONNECTORS[connectorConfig.name]?.auth;
53
55
  if (auth === 'oauth' && opts?.userId && opts?.authService && opts?.serverUrl) {
54
- const provider = new ServerOAuthProvider(connectorConfig.name, opts.userId, opts.authService, opts.serverUrl, known?.oauth);
56
+ const provider = new ServerOAuthProvider(connectorConfig.name, opts.userId, opts.authService, opts.serverUrl);
55
57
  return new SyncMCPClient(connectorConfig, provider);
56
58
  }
57
59
  if (auth === 'oauth') {
@@ -60,129 +62,31 @@ function createDataFetcher(connectorConfig, definition, opts) {
60
62
  return new SyncMCPClient(connectorConfig);
61
63
  }
62
64
  /**
63
- * Run sync for a collection. Processes each sync rule in the collection's config.
64
- */
65
- export async function syncCollection(options) {
66
- const { workspacePath, collectionName, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
67
- const log = onProgress ?? (() => { });
68
- // Find collection config
69
- const repoConfig = workspaceConfig.repos.find(r => r.name === collectionName);
70
- if (!repoConfig) {
71
- throw new Error(`Collection "${collectionName}" not found in workspace config`);
72
- }
73
- const syncRules = repoConfig.sync;
74
- if (!syncRules || syncRules.length === 0) {
75
- throw new Error(`Collection "${collectionName}" has no sync rules configured`);
76
- }
77
- const results = [];
78
- for (const rule of syncRules) {
79
- log(`\nSyncing from source: ${rule.source}`);
80
- try {
81
- const result = await syncOneRule({
82
- workspacePath,
83
- repoConfig,
84
- rule,
85
- workspaceConfig,
86
- schemaExtensions,
87
- onProgress,
88
- userId,
89
- authService,
90
- serverUrl,
91
- });
92
- results.push(result);
93
- }
94
- catch (err) {
95
- const msg = err instanceof Error ? err.message : String(err);
96
- log(` Error: ${msg}`);
97
- results.push({
98
- collection: collectionName,
99
- source: rule.source,
100
- created: 0,
101
- skipped: 0,
102
- derived: 0,
103
- errors: [msg],
104
- });
105
- }
106
- }
107
- return results;
108
- }
109
- /**
110
- * Run all sync rules that apply to a collection:
111
- * - Collection-level rules (if the collection has its own sync array)
112
- * - Workspace-level rules that include this collection as a target
65
+ * Unified sync function: fetch from source once, distribute to all targets.
113
66
  *
114
- * This is the preferred entry point for per-collection sync — it handles both
115
- * rule types without callers needing to know the distinction.
116
- */
117
- export async function syncRepo(options) {
118
- const { workspacePath, collectionName, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
119
- const log = onProgress ?? (() => { });
120
- const collectionResults = [];
121
- const workspaceResults = [];
122
- // Run collection-level rules (if any)
123
- const repoConfig = workspaceConfig.repos.find(r => r.name === collectionName);
124
- const collectionRules = repoConfig?.sync;
125
- if (collectionRules && collectionRules.length > 0) {
126
- const results = await syncCollection({ workspacePath, collectionName, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl });
127
- collectionResults.push(...results);
128
- }
129
- // Run workspace-level rules that target this collection
130
- const workspaceRules = workspaceConfig.sync ?? [];
131
- for (const rule of workspaceRules) {
132
- const matchingTargets = rule.targets.filter(t => t.repo === collectionName);
133
- if (matchingTargets.length === 0)
134
- continue;
135
- log(`\nSyncing workspace rule: ${rule.source} → ${collectionName}`);
136
- try {
137
- const result = await syncWorkspaceRule({
138
- workspacePath,
139
- rule: { ...rule, targets: matchingTargets },
140
- workspaceConfig,
141
- schemaExtensions,
142
- onProgress,
143
- userId,
144
- authService,
145
- serverUrl,
146
- });
147
- workspaceResults.push(result);
148
- }
149
- catch (err) {
150
- const msg = err instanceof Error ? err.message : String(err);
151
- log(` Error: ${msg}`);
152
- workspaceResults.push({
153
- source: rule.source,
154
- targets: matchingTargets.map(t => ({ repo: t.repo, created: 0, skipped: 0, derived: 0, errors: [msg] })),
155
- });
156
- }
157
- }
158
- if (collectionResults.length === 0 && workspaceResults.length === 0) {
159
- throw new Error(`Collection "${collectionName}" has no sync rules configured (collection-level or workspace-level)`);
160
- }
161
- return { collectionResults, workspaceResults };
162
- }
163
- /**
164
- * Run one workspace-level sync rule: fetch from source once, distribute to all targets.
165
- * Each target receives a filtered subset of the extracted records.
67
+ * Both collection-level and workspace-level rules use this single code path.
68
+ * Collection rules are converted to workspace rules with a single target by syncRepo().
166
69
  */
167
- export async function syncWorkspaceRule(options) {
70
+ export async function syncSource(options) {
168
71
  const { workspacePath, rule, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
169
72
  const log = onProgress ?? (() => { });
170
73
  const result = { source: rule.source, targets: [] };
74
+ // ── Resolve source definition ───────────────────────────────────────────────
171
75
  const configMgr = new SourceConfigManager(workspacePath);
172
76
  const definition = configMgr.getDefinition(rule.source);
173
- const oldConfig = !definition ? configMgr.get(rule.source) : null;
174
- if (!definition && !oldConfig) {
175
- throw new Error(`Source "${rule.source}" not found in .studiograph/sources/`);
77
+ if (!definition) {
78
+ throw new Error(`Source "${rule.source}" not found. Ensure a source definition exists (built-in or .studiograph/sources/).`);
176
79
  }
177
- const connectorName = definition?.connector ?? oldConfig.connector;
80
+ const connectorName = definition.connector;
178
81
  const connectorConfigs = ConnectorManager.loadConfigs(workspacePath);
179
82
  const connectorConfig = connectorConfigs.find(c => c.name === connectorName);
180
83
  if (!connectorConfig) {
181
84
  throw new Error(`Connector "${connectorName}" not configured. Run: studiograph connector add ${connectorName}`);
182
85
  }
86
+ // ── Connect ─────────────────────────────────────────────────────────────────
183
87
  const client = createDataFetcher(connectorConfig, definition, { userId, authService, serverUrl });
184
88
  try {
185
- log(` Connecting to ${connectorName}${definition?.adapter === 'rest' ? ' (REST)' : ''}...`);
89
+ log(` Connecting to ${connectorName}${definition.adapter === 'rest' ? ' (REST)' : ''}...`);
186
90
  try {
187
91
  await client.connect();
188
92
  }
@@ -193,41 +97,36 @@ export async function syncWorkspaceRule(options) {
193
97
  log(` Available tools: ${client.getTools().map(t => t.name).join(', ')}`);
194
98
  const schemaRegistry = new SchemaRegistry(schemaExtensions);
195
99
  const syncState = new SyncStateManager(workspacePath);
196
- // ── Extract ALL records once (no target filter at this stage) ──────────────
100
+ const filterFieldDefs = definition.filter_fields ?? SOURCE_DEFINITIONS[connectorName]?.filter_fields ?? [];
101
+ // ── Resolve primary entity type ───────────────────────────────────────────
102
+ const primaryType = rule.entity_type ?? findPrimaryType(definition);
103
+ const primaryConfig = definition.entities[primaryType];
104
+ if (!primaryConfig) {
105
+ throw new Error(`Entity type "${primaryType}" not found in source definition "${rule.source}"`);
106
+ }
107
+ // ── Server-side filter optimization ───────────────────────────────────────
108
+ // When there is exactly one target, merge server-mode filter keys into list_params
109
+ const serverFilter = computeServerFilter(rule.targets, filterFieldDefs);
110
+ const mapping = sourceEntityConfigToMapping(primaryType, primaryConfig, rule.source, '_sync_', serverFilter);
111
+ const extractorConfig = {
112
+ name: rule.source,
113
+ connector: definition.connector,
114
+ enabled: true,
115
+ added_at: new Date().toISOString(),
116
+ entity_mappings: [mapping],
117
+ };
118
+ // ── Extract ─────────────────────────────────────────────────────────────────
119
+ log(` Extracting ${primaryType} records...`);
197
120
  let allRecords;
198
- const filterFieldDefs = definition?.filter_fields ?? SOURCE_DEFINITIONS[connectorName]?.filter_fields ?? [];
199
- if (definition) {
200
- const primaryType = rule.entity_type ?? findPrimaryType(definition);
201
- const primaryConfig = definition.entities[primaryType];
202
- if (!primaryConfig) {
203
- throw new Error(`Entity type "${primaryType}" not found in source definition`);
204
- }
205
- // Extract without any filter — filtering happens per-target below
206
- const mapping = sourceEntityConfigToMapping(primaryType, primaryConfig, rule.source, '_workspace_', undefined);
207
- const oldStyleConfig = {
208
- name: rule.source,
209
- connector: definition.connector,
210
- enabled: true,
211
- added_at: new Date().toISOString(),
212
- entity_mappings: [mapping],
213
- };
214
- log(` Extracting ${primaryType} records...`);
215
- if (primaryConfig.extraction_mode === 'frontmatter') {
216
- const extractor = new FrontmatterExtractor(client, oldStyleConfig);
217
- allRecords = await extractor.extract(mapping, onProgress);
218
- }
219
- else {
220
- const extractor = new StructuredExtractor(client, oldStyleConfig, syncState);
221
- allRecords = await extractor.extract(mapping, false, onProgress);
222
- }
121
+ if (primaryConfig.extraction_mode === 'frontmatter') {
122
+ const extractor = new FrontmatterExtractor(client, extractorConfig);
123
+ allRecords = await extractor.extract(mapping, onProgress);
223
124
  }
224
125
  else {
225
- // Old-format source
226
- const mapping = { ...oldConfig.entity_mappings[0], target_repo: '_workspace_' };
227
- const extractor = new StructuredExtractor(client, oldConfig, syncState);
126
+ const extractor = new StructuredExtractor(client, extractorConfig, syncState);
228
127
  allRecords = await extractor.extract(mapping, false, onProgress);
229
128
  }
230
- log(` Extracted ${allRecords.length} records (total)`);
129
+ log(` Extracted ${allRecords.length} records`);
231
130
  // ── Write to each target ──────────────────────────────────────────────────
232
131
  for (const target of rule.targets) {
233
132
  const repoConfig = workspaceConfig.repos.find(r => r.name === target.repo);
@@ -248,41 +147,37 @@ export async function syncWorkspaceRule(options) {
248
147
  schemaRegistry,
249
148
  });
250
149
  const existingSourceRefs = buildSourceRefIndex(graph);
251
- // Apply target-specific filter
150
+ // Apply client-side filters (always per-target)
252
151
  let targetRecords = applyClientFilters(allRecords, target.filter, filterFieldDefs, log);
253
- // Override target_repo on records before writing
254
152
  targetRecords = targetRecords.map(r => ({ ...r, target_repo: target.repo }));
255
153
  const targetResult = { repo: target.repo, created: 0, skipped: 0, derived: 0, errors: [] };
256
154
  const writeResults = await writeEntities(targetRecords, rule.source, graph, existingSourceRefs, log);
257
155
  targetResult.created += writeResults.created;
258
156
  targetResult.skipped += writeResults.skipped;
259
157
  targetResult.errors.push(...writeResults.errors);
260
- // Derive secondary entities (if definition has derive_from configs)
261
- if (definition) {
262
- const primaryType = rule.entity_type ?? findPrimaryType(definition);
263
- for (const [entityType, entityConfig] of Object.entries(definition.entities)) {
264
- if (entityType === primaryType || !entityConfig.derive_from)
265
- continue;
266
- log(` Deriving ${entityType} entities for ${target.repo}...`);
267
- const derived = deriveEntities(targetRecords, entityType, entityConfig, rule.source);
268
- const existingDedupeValues = entityConfig.dedupe_field
269
- ? buildDedupeIndex(graph, entityType, entityConfig.dedupe_field)
270
- : new Map();
271
- const deduped = derived.filter(rec => {
272
- if (existingSourceRefs.has(rec.source_ref))
158
+ // Derive secondary entities
159
+ for (const [entityType, entityConfig] of Object.entries(definition.entities)) {
160
+ if (entityType === primaryType || !entityConfig.derive_from)
161
+ continue;
162
+ log(` Deriving ${entityType} entities for ${target.repo}...`);
163
+ const derived = deriveEntities(targetRecords, entityType, entityConfig, rule.source);
164
+ const existingDedupeValues = entityConfig.dedupe_field
165
+ ? buildDedupeIndex(graph, entityType, entityConfig.dedupe_field)
166
+ : new Map();
167
+ const deduped = derived.filter(rec => {
168
+ if (existingSourceRefs.has(rec.source_ref))
169
+ return false;
170
+ if (entityConfig.dedupe_field) {
171
+ const val = rec.frontmatter[entityConfig.dedupe_field];
172
+ if (val && existingDedupeValues.has(String(val).toLowerCase()))
273
173
  return false;
274
- if (entityConfig.dedupe_field) {
275
- const val = rec.frontmatter[entityConfig.dedupe_field];
276
- if (val && existingDedupeValues.has(String(val).toLowerCase()))
277
- return false;
278
- }
279
- return true;
280
- });
281
- const derivedResults = await writeEntities(deduped, rule.source, graph, existingSourceRefs, log);
282
- targetResult.derived += derivedResults.created;
283
- targetResult.skipped += derivedResults.skipped;
284
- targetResult.errors.push(...derivedResults.errors);
285
- }
174
+ }
175
+ return true;
176
+ });
177
+ const derivedResults = await writeEntities(deduped, rule.source, graph, existingSourceRefs, log);
178
+ targetResult.derived += derivedResults.created;
179
+ targetResult.skipped += derivedResults.skipped;
180
+ targetResult.errors.push(...derivedResults.errors);
286
181
  }
287
182
  result.targets.push(targetResult);
288
183
  }
@@ -292,199 +187,70 @@ export async function syncWorkspaceRule(options) {
292
187
  }
293
188
  return result;
294
189
  }
295
- async function syncOneRule(options) {
296
- const { workspacePath, repoConfig, rule, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
297
- const log = onProgress ?? (() => { });
298
- const collectionName = repoConfig.name;
299
- const configMgr = new SourceConfigManager(workspacePath);
300
- const result = {
301
- collection: collectionName,
302
- source: rule.source,
303
- created: 0,
304
- skipped: 0,
305
- derived: 0,
306
- errors: [],
307
- };
308
- // Load source definition (try new format first, then old format)
309
- const definition = configMgr.getDefinition(rule.source);
310
- const oldConfig = !definition ? configMgr.get(rule.source) : null;
311
- if (!definition && !oldConfig) {
312
- throw new Error(`Source "${rule.source}" not found in .studiograph/sources/`);
190
+ /** @deprecated Use syncSource() */
191
+ export const syncWorkspaceRule = syncSource;
192
+ /** Extract server-mode filter keys when there is exactly one target. */
193
+ function computeServerFilter(targets, filterFieldDefs) {
194
+ if (targets.length !== 1 || !targets[0].filter)
195
+ return undefined;
196
+ const serverKeys = filterFieldDefs.filter(f => f.mode === 'server').map(f => f.key);
197
+ const out = {};
198
+ for (const key of serverKeys) {
199
+ if (key in targets[0].filter)
200
+ out[key] = targets[0].filter[key];
313
201
  }
314
- // Resolve connector config
315
- const connectorName = definition?.connector ?? oldConfig.connector;
316
- const connectorConfigs = ConnectorManager.loadConfigs(workspacePath);
317
- const connectorConfig = connectorConfigs.find(c => c.name === connectorName);
318
- if (!connectorConfig) {
319
- throw new Error(`Connector "${connectorName}" not configured. Run: studiograph connector add ${connectorName}`);
202
+ return Object.keys(out).length > 0 ? out : undefined;
203
+ }
204
+ /**
205
+ * Run sync for a collection — both collection-level and workspace-level rules.
206
+ * Collection rules are converted to workspace-rule shape with a single target.
207
+ */
208
+ export async function syncRepo(options) {
209
+ const { workspacePath, collectionName, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
210
+ const log = onProgress ?? (() => { });
211
+ const results = [];
212
+ const syncOpts = { workspacePath, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl };
213
+ // Collection-level rules → synthetic workspace rules with single target
214
+ const repoConfig = workspaceConfig.repos.find(r => r.name === collectionName);
215
+ const collectionRules = repoConfig?.sync;
216
+ if (collectionRules && collectionRules.length > 0) {
217
+ for (const rule of collectionRules) {
218
+ log(`\nSyncing from source: ${rule.source}`);
219
+ try {
220
+ const wsRule = {
221
+ source: rule.source,
222
+ entity_type: rule.entity_type,
223
+ targets: [{ repo: collectionName, filter: rule.filter }],
224
+ };
225
+ results.push(await syncSource({ ...syncOpts, rule: wsRule }));
226
+ }
227
+ catch (err) {
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ log(` Error: ${msg}`);
230
+ results.push({ source: rule.source, targets: [{ repo: collectionName, created: 0, skipped: 0, derived: 0, errors: [msg] }] });
231
+ }
232
+ }
320
233
  }
321
- // Connect to source
322
- const client = createDataFetcher(connectorConfig, definition, { userId, authService, serverUrl });
323
- try {
324
- log(` Connecting to ${connectorName}${definition?.adapter === 'rest' ? ' (REST)' : ''}...`);
234
+ // Workspace-level rules that target this collection
235
+ const workspaceRules = workspaceConfig.sync ?? [];
236
+ for (const rule of workspaceRules) {
237
+ const matchingTargets = rule.targets.filter(t => t.repo === collectionName);
238
+ if (matchingTargets.length === 0)
239
+ continue;
240
+ log(`\nSyncing workspace rule: ${rule.source} → ${collectionName}`);
325
241
  try {
326
- await client.connect();
242
+ results.push(await syncSource({ ...syncOpts, rule: { ...rule, targets: matchingTargets } }));
327
243
  }
328
244
  catch (err) {
329
245
  const msg = err instanceof Error ? err.message : String(err);
330
- throw new Error(`Failed to connect to connector "${connectorName}" at ${connectorConfig.url}. (${msg})`);
331
- }
332
- log(` Available tools: ${client.getTools().map(t => t.name).join(', ')}`);
333
- // Build graph manager for the collection
334
- const repoPath = join(workspacePath, repoConfig.path);
335
- if (!existsSync(repoPath)) {
336
- throw new Error(`Collection path does not exist: ${repoPath}`);
337
- }
338
- const schemaRegistry = new SchemaRegistry(schemaExtensions);
339
- const graph = new BaseGraphManager({
340
- repoPath,
341
- repoName: collectionName,
342
- gitUser: { id: 'sync', name: 'Studiograph Sync', email: 'sync@studiograph.local' },
343
- schemaRegistry,
344
- });
345
- // Build index of existing source_refs in this collection
346
- const existingSourceRefs = buildSourceRefIndex(graph);
347
- log(` ${existingSourceRefs.size} existing synced entities in collection`);
348
- if (definition) {
349
- // ── New-format source definition ──
350
- await syncNewFormat({
351
- definition,
352
- sourceName: rule.source,
353
- rule,
354
- graph,
355
- collectionName,
356
- client,
357
- existingSourceRefs,
358
- schemaRegistry,
359
- workspacePath,
360
- schemaExtensions,
361
- result,
362
- onProgress,
363
- });
364
- }
365
- else {
366
- // ── Old-format source config (backwards compatibility) ──
367
- await syncOldFormat({
368
- config: oldConfig,
369
- rule,
370
- graph,
371
- collectionName,
372
- client,
373
- existingSourceRefs,
374
- schemaRegistry,
375
- workspacePath,
376
- schemaExtensions,
377
- result,
378
- onProgress,
379
- });
246
+ log(` Error: ${msg}`);
247
+ results.push({ source: rule.source, targets: matchingTargets.map(t => ({ repo: t.repo, created: 0, skipped: 0, derived: 0, errors: [msg] })) });
380
248
  }
381
249
  }
382
- finally {
383
- await client.close();
384
- }
385
- return result;
386
- }
387
- async function syncNewFormat(options) {
388
- const { definition, sourceName, rule, graph, collectionName, client, existingSourceRefs, schemaRegistry, workspacePath, schemaExtensions, result, onProgress } = options;
389
- const log = onProgress ?? (() => { });
390
- // Find primary entity config
391
- const primaryType = rule.entity_type ?? findPrimaryType(definition);
392
- const primaryConfig = definition.entities[primaryType];
393
- if (!primaryConfig) {
394
- throw new Error(`Entity type "${primaryType}" not found in source definition`);
395
- }
396
- // Convert to EntityMapping for the extractor
397
- const mapping = sourceEntityConfigToMapping(primaryType, primaryConfig, sourceName, collectionName, rule.filter);
398
- // Extract records
399
- const syncState = new SyncStateManager(workspacePath);
400
- const oldStyleConfig = {
401
- name: sourceName,
402
- connector: definition.connector,
403
- enabled: true,
404
- added_at: new Date().toISOString(),
405
- entity_mappings: [mapping],
406
- };
407
- log(` Extracting ${primaryType} records...`);
408
- let records;
409
- if (primaryConfig.extraction_mode === 'frontmatter') {
410
- const extractor = new FrontmatterExtractor(client, oldStyleConfig);
411
- records = await extractor.extract(mapping, onProgress);
412
- }
413
- else {
414
- const extractor = new StructuredExtractor(client, oldStyleConfig, syncState);
415
- records = await extractor.extract(mapping, false, onProgress);
416
- }
417
- log(` Extracted ${records.length} records`);
418
- // Apply client-side filters declared by the source definition
419
- records = applyClientFilters(records, rule.filter, definition.filter_fields ?? [], log);
420
- // Write primary entities
421
- log(` Writing ${records.length} records...`);
422
- const writeResults = await writeEntities(records, sourceName, graph, existingSourceRefs, log);
423
- result.created += writeResults.created;
424
- result.skipped += writeResults.skipped;
425
- result.errors.push(...writeResults.errors);
426
- // Derive secondary entities
427
- for (const [entityType, entityConfig] of Object.entries(definition.entities)) {
428
- if (entityType === primaryType || !entityConfig.derive_from)
429
- continue;
430
- log(` Deriving ${entityType} entities...`);
431
- const derived = deriveEntities(records, entityType, entityConfig, sourceName);
432
- log(` Found ${derived.length} ${entityType} candidates`);
433
- // Dedup derived entities against existing collection
434
- const existingDedupeValues = entityConfig.dedupe_field
435
- ? buildDedupeIndex(graph, entityType, entityConfig.dedupe_field)
436
- : new Map();
437
- const dedupedDerived = derived.filter(rec => {
438
- // Skip if source_ref already exists
439
- if (existingSourceRefs.has(rec.source_ref))
440
- return false;
441
- // Skip if dedupe field matches existing entity
442
- if (entityConfig.dedupe_field) {
443
- const dedupeVal = rec.frontmatter[entityConfig.dedupe_field];
444
- if (dedupeVal && existingDedupeValues.has(String(dedupeVal).toLowerCase()))
445
- return false;
446
- }
447
- return true;
448
- });
449
- const derivedResults = await writeEntities(dedupedDerived, sourceName, graph, existingSourceRefs, log);
450
- result.derived += derivedResults.created;
451
- result.skipped += derivedResults.skipped;
452
- result.errors.push(...derivedResults.errors);
453
- }
454
- }
455
- async function syncOldFormat(options) {
456
- const { config, rule, graph, collectionName, client, existingSourceRefs, schemaRegistry, workspacePath, result, onProgress } = options;
457
- const log = onProgress ?? (() => { });
458
- const syncState = new SyncStateManager(workspacePath);
459
- for (const mapping of config.entity_mappings) {
460
- // Override target_repo to write to this collection
461
- const adjustedMapping = { ...mapping, target_repo: collectionName };
462
- // Merge rule filter into list_params
463
- if (rule.filter) {
464
- const existing = adjustedMapping.list_params ?? {};
465
- adjustedMapping.list_params = Array.isArray(existing)
466
- ? existing.map(p => ({ ...p, ...rule.filter }))
467
- : { ...existing, ...rule.filter };
468
- }
469
- log(` Extracting ${mapping.entity_type} from ${mapping.source_type}...`);
470
- let records;
471
- if (adjustedMapping.extraction_mode === 'frontmatter') {
472
- const extractor = new FrontmatterExtractor(client, config);
473
- records = await extractor.extract(adjustedMapping, onProgress);
474
- }
475
- else {
476
- const extractor = new StructuredExtractor(client, config, syncState);
477
- records = await extractor.extract(adjustedMapping, false, onProgress);
478
- }
479
- log(` Extracted ${records.length} records`);
480
- // Apply client-side filters declared by the connector's source template
481
- const templateDef = SOURCE_DEFINITIONS[config.connector];
482
- records = applyClientFilters(records, rule.filter, templateDef?.filter_fields ?? [], log);
483
- const writeResults = await writeEntities(records, config.name, graph, existingSourceRefs, log);
484
- result.created += writeResults.created;
485
- result.skipped += writeResults.skipped;
486
- result.errors.push(...writeResults.errors);
250
+ if (results.length === 0) {
251
+ throw new Error(`Collection "${collectionName}" has no sync rules configured`);
487
252
  }
253
+ return { results };
488
254
  }
489
255
  async function writeEntities(records, sourceName, graph, existingSourceRefs, log) {
490
256
  const results = { created: 0, skipped: 0, errors: [] };