studiograph 1.3.3 → 1.3.4
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/orchestrator.d.ts +10 -0
- package/dist/agent/orchestrator.js +26 -7
- package/dist/agent/orchestrator.js.map +1 -1
- package/dist/agent/skills/sync-configuration.md +4 -29
- package/dist/agent/skills/sync-setup.md +2 -4
- package/dist/agent/tools/graph-tools.d.ts +5 -1
- package/dist/agent/tools/graph-tools.js +161 -9
- package/dist/agent/tools/graph-tools.js.map +1 -1
- package/dist/agent/tools/ops-tools.js +15 -126
- package/dist/agent/tools/ops-tools.js.map +1 -1
- package/dist/agent/tools/permission-tools.d.ts +15 -14
- package/dist/agent/tools/permission-tools.js +65 -128
- package/dist/agent/tools/permission-tools.js.map +1 -1
- package/dist/agent/tools/sync-tools.d.ts +7 -6
- package/dist/agent/tools/sync-tools.js +205 -178
- package/dist/agent/tools/sync-tools.js.map +1 -1
- package/dist/cli/commands/about.d.ts +13 -0
- package/dist/cli/commands/about.js +97 -0
- package/dist/cli/commands/about.js.map +1 -0
- package/dist/cli/commands/clone.d.ts +5 -2
- package/dist/cli/commands/clone.js +131 -62
- package/dist/cli/commands/clone.js.map +1 -1
- package/dist/cli/commands/connector.d.ts +2 -16
- package/dist/cli/commands/connector.js +32 -109
- package/dist/cli/commands/connector.js.map +1 -1
- package/dist/cli/commands/deploy.d.ts +0 -1
- package/dist/cli/commands/deploy.js +13 -103
- package/dist/cli/commands/deploy.js.map +1 -1
- package/dist/cli/commands/init.js +6 -93
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/join.d.ts +3 -2
- package/dist/cli/commands/join.js +142 -97
- package/dist/cli/commands/join.js.map +1 -1
- package/dist/cli/commands/redeploy.js +1 -2
- package/dist/cli/commands/redeploy.js.map +1 -1
- package/dist/cli/commands/serve.d.ts +1 -3
- package/dist/cli/commands/serve.js +29 -109
- package/dist/cli/commands/serve.js.map +1 -1
- package/dist/cli/commands/start.js +1 -1
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/sync-collection.d.ts +14 -0
- package/dist/cli/commands/sync-collection.js +366 -0
- package/dist/cli/commands/sync-collection.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +4 -2
- package/dist/cli/commands/sync.js +529 -94
- package/dist/cli/commands/sync.js.map +1 -1
- package/dist/cli/index.js +15 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/setup-wizard.d.ts +0 -13
- package/dist/cli/setup-wizard.js +6 -81
- package/dist/cli/setup-wizard.js.map +1 -1
- package/dist/core/graph.d.ts +8 -2
- package/dist/core/graph.js +11 -7
- package/dist/core/graph.js.map +1 -1
- package/dist/core/types.d.ts +149 -21
- package/dist/core/types.js +16 -4
- package/dist/core/types.js.map +1 -1
- package/dist/core/workspace-manager.js +1 -5
- package/dist/core/workspace-manager.js.map +1 -1
- package/dist/core/workspace.d.ts +11 -4
- package/dist/core/workspace.js +61 -26
- package/dist/core/workspace.js.map +1 -1
- package/dist/integrations/asana.d.ts +26 -0
- package/dist/integrations/asana.js +77 -0
- package/dist/integrations/asana.js.map +1 -0
- package/dist/integrations/figma-local.d.ts +16 -0
- package/dist/integrations/figma-local.js +10 -0
- package/dist/integrations/figma-local.js.map +1 -0
- package/dist/integrations/figma.d.ts +17 -0
- package/dist/integrations/figma.js +16 -0
- package/dist/integrations/figma.js.map +1 -0
- package/dist/integrations/granola.d.ts +24 -0
- package/dist/integrations/granola.js +59 -0
- package/dist/integrations/granola.js.map +1 -0
- package/dist/integrations/linear.d.ts +14 -0
- package/dist/integrations/linear.js +70 -0
- package/dist/integrations/linear.js.map +1 -0
- package/dist/integrations/paper-local.d.ts +14 -0
- package/dist/integrations/paper-local.js +10 -0
- package/dist/integrations/paper-local.js.map +1 -0
- package/dist/integrations/paper.d.ts +2 -0
- package/dist/integrations/paper.js +10 -0
- package/dist/integrations/paper.js.map +1 -0
- package/dist/integrations/pipedrive.d.ts +26 -0
- package/dist/integrations/pipedrive.js +97 -0
- package/dist/integrations/pipedrive.js.map +1 -0
- package/dist/integrations/registry.d.ts +15 -0
- package/dist/integrations/registry.js +27 -0
- package/dist/integrations/registry.js.map +1 -0
- package/dist/integrations/types.d.ts +34 -0
- package/dist/integrations/types.js +9 -0
- package/dist/integrations/types.js.map +1 -0
- package/dist/mcp/connector-manager.d.ts +45 -31
- package/dist/mcp/connector-manager.js +164 -116
- package/dist/mcp/connector-manager.js.map +1 -1
- package/dist/mcp/server-oauth-provider.d.ts +56 -0
- package/dist/mcp/server-oauth-provider.js +138 -0
- package/dist/mcp/server-oauth-provider.js.map +1 -0
- package/dist/server/chrome/chrome.css +142 -28
- package/dist/server/chrome/chrome.js +46 -220
- package/dist/server/collab-authority.d.ts +70 -0
- package/dist/server/collab-authority.js +218 -0
- package/dist/server/collab-authority.js.map +1 -0
- package/dist/server/collab.d.ts +29 -0
- package/dist/server/collab.js +195 -0
- package/dist/server/collab.js.map +1 -0
- package/dist/server/commit-scheduler.d.ts +39 -0
- package/dist/server/commit-scheduler.js +113 -0
- package/dist/server/commit-scheduler.js.map +1 -0
- package/dist/server/index.d.ts +0 -2
- package/dist/server/index.js +166 -55
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/auth-api.d.ts +6 -0
- package/dist/server/routes/auth-api.js +78 -0
- package/dist/server/routes/auth-api.js.map +1 -1
- package/dist/server/routes/chat.js +4 -0
- package/dist/server/routes/chat.js.map +1 -1
- package/dist/server/routes/collab.d.ts +6 -0
- package/dist/server/routes/collab.js +10 -0
- package/dist/server/routes/collab.js.map +1 -0
- package/dist/server/routes/git-http.d.ts +23 -0
- package/dist/server/routes/git-http.js +251 -0
- package/dist/server/routes/git-http.js.map +1 -0
- package/dist/server/routes/graph-api.d.ts +6 -2
- package/dist/server/routes/graph-api.js +266 -82
- package/dist/server/routes/graph-api.js.map +1 -1
- package/dist/server/routes/mcp.d.ts +12 -0
- package/dist/server/routes/mcp.js +35 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/permissions-api.d.ts +6 -4
- package/dist/server/routes/permissions-api.js +53 -167
- package/dist/server/routes/permissions-api.js.map +1 -1
- package/dist/server/routes/sync-api.d.ts +26 -0
- package/dist/server/routes/sync-api.js +757 -0
- package/dist/server/routes/sync-api.js.map +1 -0
- package/dist/server/routes/ws.d.ts +9 -0
- package/dist/server/routes/ws.js +131 -0
- package/dist/server/routes/ws.js.map +1 -0
- package/dist/server/session-manager.d.ts +40 -0
- package/dist/server/session-manager.js +132 -0
- package/dist/server/session-manager.js.map +1 -0
- package/dist/server/ws-hub.d.ts +130 -0
- package/dist/server/ws-hub.js +250 -0
- package/dist/server/ws-hub.js.map +1 -0
- package/dist/server/yjs-manager.d.ts +59 -0
- package/dist/server/yjs-manager.js +194 -0
- package/dist/server/yjs-manager.js.map +1 -0
- package/dist/services/auth-service.d.ts +74 -0
- package/dist/services/auth-service.js +286 -6
- package/dist/services/auth-service.js.map +1 -1
- package/dist/services/git.d.ts +6 -0
- package/dist/services/git.js +32 -2
- package/dist/services/git.js.map +1 -1
- package/dist/services/sync/collection-sync.d.ts +73 -0
- package/dist/services/sync/collection-sync.js +726 -0
- package/dist/services/sync/collection-sync.js.map +1 -0
- package/dist/services/sync/commit.js +5 -20
- package/dist/services/sync/commit.js.map +1 -1
- package/dist/services/sync/data-fetcher.d.ts +31 -0
- package/dist/services/sync/data-fetcher.js +12 -0
- package/dist/services/sync/data-fetcher.js.map +1 -0
- package/dist/services/sync/entity-refresh.d.ts +30 -0
- package/dist/services/sync/entity-refresh.js +275 -0
- package/dist/services/sync/entity-refresh.js.map +1 -0
- package/dist/services/sync/frontmatter-extractor.d.ts +2 -2
- package/dist/services/sync/frontmatter-extractor.js +1 -2
- package/dist/services/sync/frontmatter-extractor.js.map +1 -1
- package/dist/services/sync/graph-match.js +1 -1
- package/dist/services/sync/graph-match.js.map +1 -1
- package/dist/services/sync/mcp-client.d.ts +16 -4
- package/dist/services/sync/mcp-client.js +34 -20
- package/dist/services/sync/mcp-client.js.map +1 -1
- package/dist/services/sync/prompts.js +1 -1
- package/dist/services/sync/reconciler.js +1 -2
- package/dist/services/sync/reconciler.js.map +1 -1
- package/dist/services/sync/rest-client.d.ts +40 -0
- package/dist/services/sync/rest-client.js +100 -0
- package/dist/services/sync/rest-client.js.map +1 -0
- package/dist/services/sync/source-config.d.ts +23 -1
- package/dist/services/sync/source-config.js +112 -16
- package/dist/services/sync/source-config.js.map +1 -1
- package/dist/services/sync/source-definitions/asana.d.ts +3 -4
- package/dist/services/sync/source-definitions/asana.js +7 -13
- package/dist/services/sync/source-definitions/asana.js.map +1 -1
- package/dist/services/sync/source-definitions/definitions.d.ts +5 -18
- package/dist/services/sync/source-definitions/definitions.js +4 -22
- package/dist/services/sync/source-definitions/definitions.js.map +1 -1
- package/dist/services/sync/source-definitions/granola.d.ts +1 -1
- package/dist/services/sync/source-definitions/granola.js +11 -18
- package/dist/services/sync/source-definitions/granola.js.map +1 -1
- package/dist/services/sync/source-definitions/linear.d.ts +1 -1
- package/dist/services/sync/source-definitions/linear.js +17 -15
- package/dist/services/sync/source-definitions/linear.js.map +1 -1
- package/dist/services/sync/source-definitions/pipedrive.d.ts +1 -1
- package/dist/services/sync/source-definitions/pipedrive.js +6 -15
- package/dist/services/sync/source-definitions/pipedrive.js.map +1 -1
- package/dist/services/sync/staging.js +1 -2
- package/dist/services/sync/staging.js.map +1 -1
- package/dist/services/sync/structured-extractor.d.ts +8 -2
- package/dist/services/sync/structured-extractor.js +243 -35
- package/dist/services/sync/structured-extractor.js.map +1 -1
- package/dist/services/sync/types.d.ts +192 -23
- package/dist/services/sync/unstructured-extractor.d.ts +1 -3
- package/dist/services/sync/unstructured-extractor.js +2 -14
- package/dist/services/sync/unstructured-extractor.js.map +1 -1
- package/dist/utils/git.d.ts +33 -20
- package/dist/utils/git.js +119 -62
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/preflight.d.ts +1 -15
- package/dist/utils/preflight.js +1 -35
- package/dist/utils/preflight.js.map +1 -1
- package/dist/web/_app/immutable/assets/0.CupILLQs.css +1 -0
- package/dist/web/_app/immutable/assets/3.CtJi4Cy9.css +1 -0
- package/dist/web/_app/immutable/assets/5.CydFyZSu.css +1 -0
- package/dist/web/_app/immutable/assets/6.kqeOo0OW.css +1 -0
- package/dist/web/_app/immutable/assets/7.CseIx7qQ.css +1 -0
- package/dist/web/_app/immutable/assets/8.BYpFDZHK.css +1 -0
- package/dist/web/_app/immutable/assets/AppShell.Ch_ef9hJ.css +1 -0
- package/dist/web/_app/immutable/assets/ChatPanel.CP-_8txt.css +1 -0
- package/dist/web/_app/immutable/chunks/0oxpWEgM.js +1 -0
- package/dist/web/_app/immutable/chunks/B1y7Wy5O.js +18 -0
- package/dist/web/_app/immutable/chunks/B7eduG_j.js +64 -0
- package/dist/web/_app/immutable/chunks/BBLgaWN8.js +1 -0
- package/dist/web/_app/immutable/chunks/BCB5cYCz.js +2 -0
- package/dist/web/_app/immutable/chunks/{aosHekRC.js → BPUy9_sS.js} +1 -1
- package/dist/web/_app/immutable/chunks/BVBRzmeQ.js +7 -0
- package/dist/web/_app/immutable/chunks/{CUzqHQY_.js → BXuvR8Ks.js} +2 -1
- package/dist/web/_app/immutable/chunks/BeBar3OL.js +1 -0
- package/dist/web/_app/immutable/chunks/BuOTIbJu.js +1 -0
- package/dist/web/_app/immutable/chunks/CLFba8FK.js +5 -0
- package/dist/web/_app/immutable/chunks/CQCkXCml.js +1 -0
- package/dist/web/_app/immutable/chunks/CXuhHL4d.js +1 -0
- package/dist/web/_app/immutable/chunks/Cg9NOuOl.js +27 -0
- package/dist/web/_app/immutable/chunks/Cs5oz2oJ.js +5 -0
- package/dist/web/_app/immutable/chunks/Cs_ROD7H.js +2 -0
- package/dist/web/_app/immutable/chunks/D2aTbzFm.js +3 -0
- package/dist/web/_app/immutable/chunks/D4FXhiC2.js +1 -0
- package/dist/web/_app/immutable/chunks/D4VHRYeB.js +1 -0
- package/dist/web/_app/immutable/chunks/DCGSm8Hl.js +1 -0
- package/dist/web/_app/immutable/chunks/DP09rP34.js +2 -0
- package/dist/web/_app/immutable/chunks/DiP47fAp.js +1 -0
- package/dist/web/_app/immutable/chunks/DptGlK8O.js +1 -0
- package/dist/web/_app/immutable/chunks/O0fx2ss6.js +1 -0
- package/dist/web/_app/immutable/chunks/xBRYfpah.js +1 -0
- package/dist/web/_app/immutable/entry/app.FgnywZP_.js +2 -0
- package/dist/web/_app/immutable/entry/start.Bsa-zlPf.js +1 -0
- package/dist/web/_app/immutable/nodes/0.D3SW-LMc.js +10 -0
- package/dist/web/_app/immutable/nodes/1.y0c5TQTP.js +1 -0
- package/dist/web/_app/immutable/nodes/2.BQfSep9-.js +1 -0
- package/dist/web/_app/immutable/nodes/3.CC4Y-xMM.js +11 -0
- package/dist/web/_app/immutable/nodes/{5.BBpmYkAu.js → 4.Dp0Z-oPW.js} +2 -2
- package/dist/web/_app/immutable/nodes/5.gjZ03DON.js +2 -0
- package/dist/web/_app/immutable/nodes/6.dRNIwcJQ.js +1 -0
- package/dist/web/_app/immutable/nodes/7.I4Gjes3o.js +2 -0
- package/dist/web/_app/immutable/nodes/8.Dj14D7uH.js +1 -0
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +10 -12
- package/package.json +12 -2
- package/dist/web/_app/immutable/assets/0.CDbX4Cwz.css +0 -1
- package/dist/web/_app/immutable/assets/3.BJy7pVXi.css +0 -1
- package/dist/web/_app/immutable/assets/4.Ad16uh9o.css +0 -1
- package/dist/web/_app/immutable/assets/6.Bm2i7O0j.css +0 -1
- package/dist/web/_app/immutable/assets/AppShell.D0rmbdqF.css +0 -1
- package/dist/web/_app/immutable/assets/ChatPanel.RFD5GGYI.css +0 -1
- package/dist/web/_app/immutable/assets/editor.CPAf2SRV.css +0 -1
- package/dist/web/_app/immutable/chunks/479TgXB4.js +0 -1
- package/dist/web/_app/immutable/chunks/4QY4j-jX.js +0 -1
- package/dist/web/_app/immutable/chunks/BFb0g4TQ.js +0 -64
- package/dist/web/_app/immutable/chunks/Bopa-Ask.js +0 -1
- package/dist/web/_app/immutable/chunks/COwytaCP.js +0 -1
- package/dist/web/_app/immutable/chunks/DEJSHbC3.js +0 -1
- package/dist/web/_app/immutable/chunks/DNywhIex.js +0 -23
- package/dist/web/_app/immutable/chunks/DTUXhwEY.js +0 -1
- package/dist/web/_app/immutable/chunks/DThXpa0U.js +0 -6
- package/dist/web/_app/immutable/chunks/Dh_H7Owr.js +0 -18
- package/dist/web/_app/immutable/chunks/Dml-u95b.js +0 -2
- package/dist/web/_app/immutable/chunks/DnlgZ_Tk.js +0 -5
- package/dist/web/_app/immutable/chunks/DtVH--hH.js +0 -6
- package/dist/web/_app/immutable/chunks/DvKVaE7M.js +0 -1
- package/dist/web/_app/immutable/chunks/MbiSz-iW.js +0 -2
- package/dist/web/_app/immutable/chunks/bSAC733J.js +0 -1
- package/dist/web/_app/immutable/entry/app.BvodXQQ0.js +0 -2
- package/dist/web/_app/immutable/entry/start.Bkui3Kyw.js +0 -1
- package/dist/web/_app/immutable/nodes/0.DfbCOBhn.js +0 -2
- package/dist/web/_app/immutable/nodes/1.vtxUGpe6.js +0 -1
- package/dist/web/_app/immutable/nodes/2.Cq29oW4h.js +0 -1
- package/dist/web/_app/immutable/nodes/3.SquslPZy.js +0 -1
- package/dist/web/_app/immutable/nodes/4.COV8FR8b.js +0 -16
- package/dist/web/_app/immutable/nodes/6.BBbh6z9I.js +0 -2
- /package/dist/web/_app/immutable/assets/{5.BhKgiXd2.css → 4.BhKgiXd2.css} +0 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection-Scoped Sync Runner
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* Sync-once semantics: existing entities (matched by source_ref) are
|
|
9
|
+
* never updated during a pull. Use entity-refresh for single-entity updates.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { SyncMCPClient } from './mcp-client.js';
|
|
14
|
+
import { StructuredExtractor, slugify, resolveFieldPath, applyTransform } from './structured-extractor.js';
|
|
15
|
+
import { FrontmatterExtractor } from './frontmatter-extractor.js';
|
|
16
|
+
import { SyncStateManager } from './sync-state.js';
|
|
17
|
+
import { SourceConfigManager } from './source-config.js';
|
|
18
|
+
import { BaseGraphManager } from '../../core/graph.js';
|
|
19
|
+
import { SchemaRegistry } from '../../core/schema-registry.js';
|
|
20
|
+
import { ConnectorManager, KNOWN_CONNECTORS } from '../../mcp/connector-manager.js';
|
|
21
|
+
import { ServerOAuthProvider } from '../../mcp/server-oauth-provider.js';
|
|
22
|
+
import { RESTClient } from './rest-client.js';
|
|
23
|
+
import { SOURCE_DEFINITIONS } from './source-definitions/definitions.js';
|
|
24
|
+
/** Create a DataFetcher — REST or MCP depending on source definition's adapter field. */
|
|
25
|
+
function createDataFetcher(connectorConfig, definition, opts) {
|
|
26
|
+
// REST adapter — direct HTTP, no MCP
|
|
27
|
+
if (definition?.adapter === 'rest' && definition.rest) {
|
|
28
|
+
const headers = {};
|
|
29
|
+
if (connectorConfig.headers) {
|
|
30
|
+
for (const [k, v] of Object.entries(connectorConfig.headers)) {
|
|
31
|
+
headers[k] = ConnectorManager.resolveEnvVar(v);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Fall back to OAuth tokens if no manual headers
|
|
35
|
+
if (!headers['Authorization'] && opts?.userId && opts?.authService) {
|
|
36
|
+
const oauthData = opts.authService.getOAuthData(opts.userId, connectorConfig.name);
|
|
37
|
+
if (oauthData?.tokens?.access_token) {
|
|
38
|
+
headers['Authorization'] = `Bearer ${oauthData.tokens.access_token}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Resolve query-param auth (e.g. api_token for Pipedrive)
|
|
42
|
+
const queryAuth = {};
|
|
43
|
+
if (definition.rest.query_auth) {
|
|
44
|
+
for (const [k, v] of Object.entries(definition.rest.query_auth)) {
|
|
45
|
+
queryAuth[k] = ConnectorManager.resolveEnvVar(v);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return new RESTClient(definition.rest.base_url, headers, definition.entities, queryAuth);
|
|
49
|
+
}
|
|
50
|
+
// MCP adapter (default)
|
|
51
|
+
const known = KNOWN_CONNECTORS[connectorConfig.name];
|
|
52
|
+
const auth = connectorConfig.auth ?? known?.auth;
|
|
53
|
+
if (auth === 'oauth' && opts?.userId && opts?.authService && opts?.serverUrl) {
|
|
54
|
+
const provider = new ServerOAuthProvider(connectorConfig.name, opts.userId, opts.authService, opts.serverUrl, known?.oauth);
|
|
55
|
+
return new SyncMCPClient(connectorConfig, provider);
|
|
56
|
+
}
|
|
57
|
+
if (auth === 'oauth') {
|
|
58
|
+
throw new Error(`OAuth authorization required for "${connectorConfig.name}". Please authorize in Settings > Connectors.`);
|
|
59
|
+
}
|
|
60
|
+
return new SyncMCPClient(connectorConfig);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
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
|
|
113
|
+
*
|
|
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.
|
|
166
|
+
*/
|
|
167
|
+
export async function syncWorkspaceRule(options) {
|
|
168
|
+
const { workspacePath, rule, workspaceConfig, schemaExtensions, onProgress, userId, authService, serverUrl } = options;
|
|
169
|
+
const log = onProgress ?? (() => { });
|
|
170
|
+
const result = { source: rule.source, targets: [] };
|
|
171
|
+
const configMgr = new SourceConfigManager(workspacePath);
|
|
172
|
+
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/`);
|
|
176
|
+
}
|
|
177
|
+
const connectorName = definition?.connector ?? oldConfig.connector;
|
|
178
|
+
const connectorConfigs = ConnectorManager.loadConfigs(workspacePath);
|
|
179
|
+
const connectorConfig = connectorConfigs.find(c => c.name === connectorName);
|
|
180
|
+
if (!connectorConfig) {
|
|
181
|
+
throw new Error(`Connector "${connectorName}" not configured. Run: studiograph connector add ${connectorName}`);
|
|
182
|
+
}
|
|
183
|
+
const client = createDataFetcher(connectorConfig, definition, { userId, authService, serverUrl });
|
|
184
|
+
try {
|
|
185
|
+
log(` Connecting to ${connectorName}${definition?.adapter === 'rest' ? ' (REST)' : ''}...`);
|
|
186
|
+
try {
|
|
187
|
+
await client.connect();
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
191
|
+
throw new Error(`Failed to connect to connector "${connectorName}" at ${connectorConfig.url}. (${msg})`);
|
|
192
|
+
}
|
|
193
|
+
log(` Available tools: ${client.getTools().map(t => t.name).join(', ')}`);
|
|
194
|
+
const schemaRegistry = new SchemaRegistry(schemaExtensions);
|
|
195
|
+
const syncState = new SyncStateManager(workspacePath);
|
|
196
|
+
// ── Extract ALL records once (no target filter at this stage) ──────────────
|
|
197
|
+
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
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Old-format source
|
|
226
|
+
const mapping = { ...oldConfig.entity_mappings[0], target_repo: '_workspace_' };
|
|
227
|
+
const extractor = new StructuredExtractor(client, oldConfig, syncState);
|
|
228
|
+
allRecords = await extractor.extract(mapping, false, onProgress);
|
|
229
|
+
}
|
|
230
|
+
log(` Extracted ${allRecords.length} records (total)`);
|
|
231
|
+
// ── Write to each target ──────────────────────────────────────────────────
|
|
232
|
+
for (const target of rule.targets) {
|
|
233
|
+
const repoConfig = workspaceConfig.repos.find(r => r.name === target.repo);
|
|
234
|
+
if (!repoConfig) {
|
|
235
|
+
result.targets.push({ repo: target.repo, created: 0, skipped: 0, derived: 0, errors: [`Collection "${target.repo}" not found`] });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const repoPath = join(workspacePath, repoConfig.path);
|
|
239
|
+
if (!existsSync(repoPath)) {
|
|
240
|
+
result.targets.push({ repo: target.repo, created: 0, skipped: 0, derived: 0, errors: [`Collection path does not exist: ${repoPath}`] });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
log(` Target: ${target.repo}${target.filter ? ` (filtered)` : ' (all records)'}`);
|
|
244
|
+
const graph = new BaseGraphManager({
|
|
245
|
+
repoPath,
|
|
246
|
+
repoName: target.repo,
|
|
247
|
+
gitUser: { id: 'sync', name: 'Studiograph Sync', email: 'sync@studiograph.local' },
|
|
248
|
+
schemaRegistry,
|
|
249
|
+
});
|
|
250
|
+
const existingSourceRefs = buildSourceRefIndex(graph);
|
|
251
|
+
// Apply target-specific filter
|
|
252
|
+
let targetRecords = applyClientFilters(allRecords, target.filter, filterFieldDefs, log);
|
|
253
|
+
// Override target_repo on records before writing
|
|
254
|
+
targetRecords = targetRecords.map(r => ({ ...r, target_repo: target.repo }));
|
|
255
|
+
const targetResult = { repo: target.repo, created: 0, skipped: 0, derived: 0, errors: [] };
|
|
256
|
+
const writeResults = await writeEntities(targetRecords, rule.source, graph, existingSourceRefs, log);
|
|
257
|
+
targetResult.created += writeResults.created;
|
|
258
|
+
targetResult.skipped += writeResults.skipped;
|
|
259
|
+
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))
|
|
273
|
+
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
|
+
}
|
|
286
|
+
}
|
|
287
|
+
result.targets.push(targetResult);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await client.close();
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
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/`);
|
|
313
|
+
}
|
|
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}`);
|
|
320
|
+
}
|
|
321
|
+
// Connect to source
|
|
322
|
+
const client = createDataFetcher(connectorConfig, definition, { userId, authService, serverUrl });
|
|
323
|
+
try {
|
|
324
|
+
log(` Connecting to ${connectorName}${definition?.adapter === 'rest' ? ' (REST)' : ''}...`);
|
|
325
|
+
try {
|
|
326
|
+
await client.connect();
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
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
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
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);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function writeEntities(records, sourceName, graph, existingSourceRefs, log) {
|
|
490
|
+
const results = { created: 0, skipped: 0, errors: [] };
|
|
491
|
+
for (const record of records) {
|
|
492
|
+
const sourceRef = `${sourceName}:${record.source_ref}`;
|
|
493
|
+
// Skip if this source_ref already exists
|
|
494
|
+
if (existingSourceRefs.has(sourceRef)) {
|
|
495
|
+
results.skipped++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const now = new Date().toISOString();
|
|
500
|
+
await graph.create({
|
|
501
|
+
entityType: record.entity_type,
|
|
502
|
+
entityId: record.entity_id,
|
|
503
|
+
data: {
|
|
504
|
+
...record.frontmatter,
|
|
505
|
+
entity_type: record.entity_type,
|
|
506
|
+
entity_id: record.entity_id,
|
|
507
|
+
created_at: now,
|
|
508
|
+
updated_at: now,
|
|
509
|
+
created_by: 'sync',
|
|
510
|
+
updated_by: 'sync',
|
|
511
|
+
source_ref: sourceRef,
|
|
512
|
+
sync_source: sourceName,
|
|
513
|
+
synced_at: now,
|
|
514
|
+
},
|
|
515
|
+
content: record.content,
|
|
516
|
+
skipCommit: true,
|
|
517
|
+
});
|
|
518
|
+
existingSourceRefs.set(sourceRef, record.entity_id);
|
|
519
|
+
results.created++;
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
523
|
+
// Entity already exists (duplicate entity_id) — skip rather than fail
|
|
524
|
+
if (msg.includes('already exists')) {
|
|
525
|
+
results.skipped++;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
results.errors.push(`${record.entity_id}: ${msg}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (results.errors.length > 0) {
|
|
533
|
+
log(` Errors (first 5): ${results.errors.slice(0, 5).join('; ')}`);
|
|
534
|
+
}
|
|
535
|
+
log(` Created ${results.created}, skipped ${results.skipped}`);
|
|
536
|
+
return results;
|
|
537
|
+
}
|
|
538
|
+
// ── Utility functions ────────────────────────────────────────────────────────
|
|
539
|
+
/**
|
|
540
|
+
* Build a map of source_ref → entity_id for all entities in a collection.
|
|
541
|
+
*/
|
|
542
|
+
function buildSourceRefIndex(graph) {
|
|
543
|
+
const index = new Map();
|
|
544
|
+
try {
|
|
545
|
+
const entities = graph.list();
|
|
546
|
+
for (const entity of entities) {
|
|
547
|
+
const sourceRef = entity.data?.source_ref;
|
|
548
|
+
if (sourceRef && typeof sourceRef === 'string') {
|
|
549
|
+
index.set(sourceRef, entity.data.entity_id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
// Collection may be empty
|
|
555
|
+
}
|
|
556
|
+
return index;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Build a map of dedupe_field values for existing entities of a given type.
|
|
560
|
+
*/
|
|
561
|
+
function buildDedupeIndex(graph, entityType, dedupeField) {
|
|
562
|
+
const index = new Map();
|
|
563
|
+
try {
|
|
564
|
+
const entities = graph.list(entityType);
|
|
565
|
+
for (const entity of entities) {
|
|
566
|
+
const val = entity.data?.[dedupeField];
|
|
567
|
+
if (val && typeof val === 'string') {
|
|
568
|
+
index.set(val.toLowerCase(), entity.data.entity_id);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
// Entity type folder may not exist yet
|
|
574
|
+
}
|
|
575
|
+
return index;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Find the primary entity type in a source definition.
|
|
579
|
+
*/
|
|
580
|
+
function findPrimaryType(def) {
|
|
581
|
+
const entries = Object.entries(def.entities);
|
|
582
|
+
const primary = entries.find(([_, config]) => config.primary);
|
|
583
|
+
if (primary)
|
|
584
|
+
return primary[0];
|
|
585
|
+
// If only one entity type, it's implicitly primary
|
|
586
|
+
if (entries.length === 1)
|
|
587
|
+
return entries[0][0];
|
|
588
|
+
throw new Error('Source definition has multiple entity types but none marked as primary');
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Convert a new-format SourceEntityConfig to an old-format EntityMapping
|
|
592
|
+
* so existing extractors work unchanged.
|
|
593
|
+
*/
|
|
594
|
+
export function sourceEntityConfigToMapping(entityType, config, sourceName, targetRepo, filter) {
|
|
595
|
+
const mapping = {
|
|
596
|
+
source_type: config.source_type ?? entityType,
|
|
597
|
+
entity_type: entityType,
|
|
598
|
+
target_repo: targetRepo,
|
|
599
|
+
extraction_mode: config.extraction_mode ?? 'structured',
|
|
600
|
+
field_map: config.field_map,
|
|
601
|
+
status_map: config.status_map,
|
|
602
|
+
id_from: config.id_from,
|
|
603
|
+
content_from: config.content_from,
|
|
604
|
+
fetch_details: config.fetch_details,
|
|
605
|
+
list_limit: config.list_limit,
|
|
606
|
+
list_tool: config.list_tool,
|
|
607
|
+
read_tool: config.read_tool,
|
|
608
|
+
list_endpoint: config.list_endpoint,
|
|
609
|
+
detail_endpoint: config.detail_endpoint,
|
|
610
|
+
detail_params: config.detail_params,
|
|
611
|
+
page_size: config.page_size,
|
|
612
|
+
content_tool: config.content_tool,
|
|
613
|
+
directory_patterns: config.directory_patterns,
|
|
614
|
+
};
|
|
615
|
+
// Merge filter into list_params
|
|
616
|
+
if (filter || config.list_params) {
|
|
617
|
+
const baseParams = config.list_params ?? {};
|
|
618
|
+
if (filter) {
|
|
619
|
+
mapping.list_params = Array.isArray(baseParams)
|
|
620
|
+
? baseParams.map(p => ({ ...p, ...filter }))
|
|
621
|
+
: { ...baseParams, ...filter };
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
mapping.list_params = baseParams;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return mapping;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Apply client-side filters to extracted records based on source filter field definitions.
|
|
631
|
+
* Fields with mode='client' are matched against frontmatter after extraction.
|
|
632
|
+
*/
|
|
633
|
+
function applyClientFilters(records, filter, filterFieldDefs, log) {
|
|
634
|
+
if (!filter || filterFieldDefs.length === 0)
|
|
635
|
+
return records;
|
|
636
|
+
let result = records;
|
|
637
|
+
for (const fieldDef of filterFieldDefs) {
|
|
638
|
+
if (fieldDef.mode !== 'client')
|
|
639
|
+
continue;
|
|
640
|
+
const filterValue = filter[fieldDef.key];
|
|
641
|
+
if (!filterValue)
|
|
642
|
+
continue;
|
|
643
|
+
const keyword = String(filterValue).toLowerCase();
|
|
644
|
+
const before = result.length;
|
|
645
|
+
const checkFields = fieldDef.fields ?? ['title', 'name'];
|
|
646
|
+
// Normalize for comparison: strip wikilink brackets, replace hyphens with spaces
|
|
647
|
+
const normalize = (s) => s.toLowerCase().replace(/^\[\[|\]\]$/g, '').replace(/-/g, ' ');
|
|
648
|
+
const normalizedKeyword = normalize(keyword);
|
|
649
|
+
result = result.filter(rec => {
|
|
650
|
+
for (const field of checkFields) {
|
|
651
|
+
const val = rec.frontmatter[field];
|
|
652
|
+
if (val != null) {
|
|
653
|
+
// Handle array values (e.g. folders: ["Ridge AI", "Other"])
|
|
654
|
+
const values = Array.isArray(val) ? val.map(v => normalize(String(v))) : [normalize(String(val))];
|
|
655
|
+
for (const str of values) {
|
|
656
|
+
if (fieldDef.op === 'exact' ? str === normalizedKeyword : str.includes(normalizedKeyword))
|
|
657
|
+
return true;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return normalize(rec.entity_id).includes(normalizedKeyword);
|
|
662
|
+
});
|
|
663
|
+
log(` Filter "${fieldDef.key}=${filterValue}": ${result.length} of ${before} match`);
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Derive secondary entities from primary extracted records.
|
|
669
|
+
* Uses the derive_from field in the source entity config to find
|
|
670
|
+
* the source field containing the data to derive from.
|
|
671
|
+
*/
|
|
672
|
+
function deriveEntities(primaryRecords, entityType, config, sourceName) {
|
|
673
|
+
if (!config.derive_from)
|
|
674
|
+
return [];
|
|
675
|
+
const derived = [];
|
|
676
|
+
const seenIds = new Set();
|
|
677
|
+
for (const record of primaryRecords) {
|
|
678
|
+
const sourceData = record.frontmatter[config.derive_from];
|
|
679
|
+
if (!sourceData)
|
|
680
|
+
continue;
|
|
681
|
+
// Handle both single values and arrays
|
|
682
|
+
const items = Array.isArray(sourceData) ? sourceData : [sourceData];
|
|
683
|
+
for (const item of items) {
|
|
684
|
+
if (!item)
|
|
685
|
+
continue;
|
|
686
|
+
const frontmatter = { entity_type: entityType };
|
|
687
|
+
// Apply field map to the derived item
|
|
688
|
+
for (const [sourceField, target] of Object.entries(config.field_map)) {
|
|
689
|
+
const value = typeof item === 'object' ? resolveFieldPath(item, sourceField) : item;
|
|
690
|
+
if (value === undefined || value === null)
|
|
691
|
+
continue;
|
|
692
|
+
if (typeof target === 'string') {
|
|
693
|
+
frontmatter[target] = value;
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
const transform = target;
|
|
697
|
+
frontmatter[transform.field] = applyTransform(value, transform.transform);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Generate entity_id
|
|
701
|
+
const idField = config.id_from ?? 'name';
|
|
702
|
+
const idValue = frontmatter[idField] ?? frontmatter.name ?? frontmatter.email;
|
|
703
|
+
if (!idValue)
|
|
704
|
+
continue;
|
|
705
|
+
const entityId = slugify(String(idValue));
|
|
706
|
+
if (!entityId || seenIds.has(entityId))
|
|
707
|
+
continue;
|
|
708
|
+
seenIds.add(entityId);
|
|
709
|
+
frontmatter.entity_id = entityId;
|
|
710
|
+
// Generate synthetic source_ref
|
|
711
|
+
const sourceRef = `#${entityType}/${entityId}`;
|
|
712
|
+
derived.push({
|
|
713
|
+
entity_type: entityType,
|
|
714
|
+
entity_id: entityId,
|
|
715
|
+
target_repo: record.target_repo,
|
|
716
|
+
frontmatter,
|
|
717
|
+
content: '',
|
|
718
|
+
source_name: sourceName,
|
|
719
|
+
source_ref: sourceRef,
|
|
720
|
+
confidence: 0.7,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return derived;
|
|
725
|
+
}
|
|
726
|
+
//# sourceMappingURL=collection-sync.js.map
|