studiograph 1.3.8 → 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.
- package/dist/agent/tools/sync-tools.js +4 -7
- package/dist/agent/tools/sync-tools.js.map +1 -1
- package/dist/cli/commands/sync-collection.js +3 -14
- package/dist/cli/commands/sync-collection.js.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/sync-api.js +14 -20
- package/dist/server/routes/sync-api.js.map +1 -1
- package/dist/services/sync/collection-sync.d.ts +22 -35
- package/dist/services/sync/collection-sync.js +123 -357
- package/dist/services/sync/collection-sync.js.map +1 -1
- package/dist/services/sync/structured-extractor.d.ts +2 -0
- package/dist/services/sync/structured-extractor.js +1 -1
- package/dist/services/sync/structured-extractor.js.map +1 -1
- package/dist/services/sync/types.d.ts +10 -5
- package/dist/web/_app/immutable/chunks/{hnbuI7Nz.js → 7pRCkuF5.js} +1 -1
- package/dist/web/_app/immutable/chunks/{C_8lGlxx.js → B-2G7IOL.js} +1 -1
- package/dist/web/_app/immutable/chunks/{DCGmlzGM.js → Bi-t3yHN.js} +1 -1
- package/dist/web/_app/immutable/chunks/{BxXlKFyx.js → BiuVYCkU.js} +1 -1
- package/dist/web/_app/immutable/chunks/BmPhvzn5.js +27 -0
- package/dist/web/_app/immutable/chunks/{DEzvDbcy.js → CMFOFkXK.js} +1 -1
- package/dist/web/_app/immutable/entry/{app.DJBDJcSh.js → app.CFW5bplI.js} +2 -2
- package/dist/web/_app/immutable/entry/start.7s9ZYPwM.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.CAcmB7D0.js → 0.DzgYLVo-.js} +1 -1
- package/dist/web/_app/immutable/nodes/{1.CTI57h4T.js → 1.DGm1MhwK.js} +1 -1
- package/dist/web/_app/immutable/nodes/{2.DLksu1-5.js → 2.CzXC4v8x.js} +1 -1
- package/dist/web/_app/immutable/nodes/{3.B5xlkeVI.js → 3.DuaI9dSH.js} +3 -3
- package/dist/web/_app/immutable/nodes/{4.b6iwPmuI.js → 4.BSv65LtF.js} +1 -1
- package/dist/web/_app/immutable/nodes/{5.BHhKeE_h.js → 5.DcKJMtC8.js} +1 -1
- package/dist/web/_app/immutable/nodes/{6.B3IKA9tf.js → 6.koFgxtvt.js} +1 -1
- package/dist/web/_app/immutable/nodes/7.Baev0HTo.js +2 -0
- package/dist/web/_app/immutable/nodes/{8.rJtfXi9L.js → 8.wJRie-EF.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +6 -6
- package/package.json +1 -1
- package/dist/web/_app/immutable/chunks/Bt3IOBgW.js +0 -27
- package/dist/web/_app/immutable/entry/start.3Q9lJ-bM.js +0 -1
- package/dist/web/_app/immutable/nodes/7.Eq6DAyo_.js +0 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Sync Runner
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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: [] };
|