@growthub/cli 0.9.13 → 0.9.14
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/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +692 -223
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +996 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1539 -433
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +79 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
- package/dist/index.js +1764 -40677
- package/package.json +1 -1
|
@@ -27,6 +27,33 @@ Use `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` when the deployed a
|
|
|
27
27
|
|
|
28
28
|
For first boot, the bundled app also supports a hybrid path: keep `GROWTHUB_WORKSPACE_INTEGRATION_ADAPTER=growthub-bridge` and set `WINDSOR_API_KEY` locally. That overlays connected state for Windsor AI and Google Sheets blended data without moving the rest of the workspace off the hosted bridge authority path.
|
|
29
29
|
|
|
30
|
+
## Data sources and API registry
|
|
31
|
+
|
|
32
|
+
Use the Data Model page to configure API-backed Data Sources and reusable API Registry records. Credentials stay in workspace settings or server env; Data Model records store only non-secret references such as `authRef`.
|
|
33
|
+
|
|
34
|
+
See [`docs/data-sources-api-registry.md`](./docs/data-sources-api-registry.md) for the setup guide, test flow, widget source eligibility rules, and the LeadShark example.
|
|
35
|
+
|
|
36
|
+
## Integration resolvers
|
|
37
|
+
|
|
38
|
+
Drop one `.js` file per integration into `lib/adapters/integrations/resolvers/`. Each file registers a provider-agnostic resolver that the workspace routes (`test-source`, `refresh-sources`) dispatch to. The bridge confirms which integrations are connected — resolvers call the provider API directly using env-var tokens.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# List registered resolvers (JSON)
|
|
42
|
+
curl -s http://localhost:3000/api/workspace/resolvers
|
|
43
|
+
|
|
44
|
+
# Test a resolver before saving to data model (JSON)
|
|
45
|
+
curl -s -X POST http://localhost:3000/api/workspace/test-source \
|
|
46
|
+
-H "Content-Type: application/json" \
|
|
47
|
+
-d '{"integrationId":"google-analytics","binding":{"entityType":"ga4.traffic","sourceStorage":"workspace-source-records","sourceId":"ga4-traffic"}}'
|
|
48
|
+
|
|
49
|
+
# Register a resolver-backed data model object (persists to growthub.config.json)
|
|
50
|
+
curl -s -X PATCH http://localhost:3000/api/workspace \
|
|
51
|
+
-H "Content-Type: application/json" \
|
|
52
|
+
-d '{"dataModel":{"objects":[...]}}'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See [`lib/adapters/integrations/resolvers/README.md`](./lib/adapters/integrations/resolvers/README.md) for the full resolver shape, all CLI commands with JSON response contracts, and the complete data model → source dropdown → refresh flow.
|
|
56
|
+
|
|
30
57
|
## Run
|
|
31
58
|
|
|
32
59
|
```bash
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
* 400 { error: string }
|
|
17
17
|
*/
|
|
18
18
|
import { NextResponse } from "next/server";
|
|
19
|
-
import { listEntityMetadataForIntegration } from "@/lib/adapters/integrations";
|
|
19
|
+
import { listEntityMetadataForIntegration, listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
|
|
20
20
|
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
21
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
22
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
21
23
|
|
|
22
24
|
async function GET(request) {
|
|
23
25
|
const { searchParams } = new URL(request.url);
|
|
@@ -30,21 +32,51 @@ async function GET(request) {
|
|
|
30
32
|
);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
const id = integrationId.trim();
|
|
33
36
|
const config = readAdapterConfig();
|
|
34
37
|
const isBridgeMode =
|
|
35
38
|
config.integrationAdapter === "growthub-bridge" &&
|
|
36
39
|
config.growthubBridge?.baseUrl &&
|
|
37
40
|
!!process.env.GROWTHUB_BRIDGE_ACCESS_TOKEN;
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
// 1. Try the Bridge / catalog path first
|
|
43
|
+
let entities = await listEntityMetadataForIntegration(id);
|
|
44
|
+
let source = entities.length ? "bridge" : "none";
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
// 2. If Bridge returned nothing, fall back to resolver registry listEntities()
|
|
47
|
+
if (!entities.length) {
|
|
48
|
+
try {
|
|
49
|
+
await loadAllResolvers();
|
|
50
|
+
const resolver = getSourceResolver(id);
|
|
51
|
+
if (resolver && typeof resolver.listEntities === "function") {
|
|
52
|
+
const allConnections = await listGovernedWorkspaceIntegrations().catch(() => []);
|
|
53
|
+
const connection = allConnections.find((c) => c.provider === id || c.id === id) || null;
|
|
54
|
+
const raw = await resolver.listEntities(config, connection);
|
|
55
|
+
if (Array.isArray(raw) && raw.length) {
|
|
56
|
+
entities = raw.map((item) => ({
|
|
57
|
+
id: String(item.id || item.entityId || item.propertyId || item.accountId || ""),
|
|
58
|
+
label: String(item.label || item.name || item.displayName || item.id || ""),
|
|
59
|
+
secondaryLabel: item.secondaryLabel || item.domain || item.accountId || undefined,
|
|
60
|
+
entityType: item.entityType || item.type || undefined,
|
|
61
|
+
provider: id,
|
|
62
|
+
status: item.status || undefined,
|
|
63
|
+
metadata: item.metadata || undefined
|
|
64
|
+
})).filter((e) => e.id);
|
|
65
|
+
source = "resolver";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// resolver not available or threw — leave entities empty
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return NextResponse.json({
|
|
74
|
+
integrationId: id,
|
|
75
|
+
entities,
|
|
76
|
+
source,
|
|
77
|
+
requiresObjectResolver: entities.length === 0,
|
|
78
|
+
authority: isBridgeMode ? "growthub-bridge" : "local"
|
|
79
|
+
});
|
|
48
80
|
}
|
|
49
81
|
|
|
50
82
|
export { GET };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/list-entities?integrationId=<id>
|
|
3
|
+
*
|
|
4
|
+
* Calls the registered resolver's `listEntities()` function and returns a
|
|
5
|
+
* normalized entity list. Fully composable — no provider-specific logic here.
|
|
6
|
+
* The resolver file is the only place that knows what an "entity" means for its
|
|
7
|
+
* integration.
|
|
8
|
+
*
|
|
9
|
+
* Response (success):
|
|
10
|
+
* { entities: { id: string, label: string, meta?: object }[], hasListEntities: true }
|
|
11
|
+
*
|
|
12
|
+
* Response (resolver has no listEntities):
|
|
13
|
+
* { entities: [], hasListEntities: false }
|
|
14
|
+
*
|
|
15
|
+
* Response (resolver not found):
|
|
16
|
+
* { error: "no-resolver", integrationId } — 404
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { NextResponse } from "next/server";
|
|
20
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
21
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
22
|
+
|
|
23
|
+
async function GET(request) {
|
|
24
|
+
const { searchParams } = new URL(request.url);
|
|
25
|
+
const integrationId = searchParams.get("integrationId");
|
|
26
|
+
|
|
27
|
+
if (!integrationId) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: "integrationId query param required" },
|
|
30
|
+
{ status: 400 }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await loadAllResolvers();
|
|
35
|
+
const resolver = getSourceResolver(integrationId);
|
|
36
|
+
|
|
37
|
+
if (!resolver) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ error: "no-resolver", integrationId },
|
|
40
|
+
{ status: 404 }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof resolver.listEntities !== "function") {
|
|
45
|
+
return NextResponse.json({ entities: [], hasListEntities: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const entities = await resolver.listEntities({}, {});
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
entities: Array.isArray(entities) ? entities : [],
|
|
52
|
+
hasListEntities: true,
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{
|
|
57
|
+
error: err.message || "Failed to list entities",
|
|
58
|
+
reason: "list-error",
|
|
59
|
+
entities: [],
|
|
60
|
+
hasListEntities: true,
|
|
61
|
+
},
|
|
62
|
+
{ status: 500 }
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { GET };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/refresh-source
|
|
3
|
+
*
|
|
4
|
+
* Calls the registered resolver's `fetchRecords()` with the supplied binding,
|
|
5
|
+
* then persists the resulting rows into the specified data model object.
|
|
6
|
+
*
|
|
7
|
+
* Fully composable — the route has no knowledge of any specific provider.
|
|
8
|
+
* All provider logic lives in the resolver file.
|
|
9
|
+
*
|
|
10
|
+
* Request body:
|
|
11
|
+
* {
|
|
12
|
+
* integrationId: string, // matches resolver.integrationId
|
|
13
|
+
* binding: object, // forwarded verbatim to resolver.fetchRecords
|
|
14
|
+
* objectId: string | null // data model object to persist rows into
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Response (success):
|
|
18
|
+
* {
|
|
19
|
+
* ok: true,
|
|
20
|
+
* rowCount: number,
|
|
21
|
+
* columns: string[],
|
|
22
|
+
* objectId: string | null,
|
|
23
|
+
* persisted: boolean, // true when rows were written to growthub.config.json
|
|
24
|
+
* dataModel: object | null // full updated dataModel (for local state sync)
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* Response (failure):
|
|
28
|
+
* { ok: false, reason: string, error: string }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { NextResponse } from "next/server";
|
|
32
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
33
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
34
|
+
import { readWorkspaceConfig, writeWorkspaceConfig, describePersistenceMode } from "@/lib/workspace-config";
|
|
35
|
+
|
|
36
|
+
async function POST(request) {
|
|
37
|
+
let body;
|
|
38
|
+
try {
|
|
39
|
+
body = await request.json();
|
|
40
|
+
} catch {
|
|
41
|
+
return NextResponse.json({ ok: false, reason: "invalid-json" }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { integrationId, binding, objectId } = body || {};
|
|
45
|
+
|
|
46
|
+
if (!integrationId) {
|
|
47
|
+
return NextResponse.json({ ok: false, reason: "integrationId required" }, { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await loadAllResolvers();
|
|
51
|
+
const resolver = getSourceResolver(integrationId);
|
|
52
|
+
|
|
53
|
+
if (!resolver) {
|
|
54
|
+
return NextResponse.json(
|
|
55
|
+
{ ok: false, reason: "no-resolver", integrationId },
|
|
56
|
+
{ status: 404 }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let rows;
|
|
61
|
+
try {
|
|
62
|
+
rows = await resolver.fetchRecords({}, {}, binding || {});
|
|
63
|
+
if (!Array.isArray(rows)) rows = [];
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return NextResponse.json(
|
|
66
|
+
{ ok: false, reason: "fetch-error", error: err.message || "Resolver threw" },
|
|
67
|
+
{ status: 500 }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const columns = rows.length > 0
|
|
72
|
+
? Object.keys(rows[0])
|
|
73
|
+
: (Array.isArray(binding?.columns) ? binding.columns : []);
|
|
74
|
+
|
|
75
|
+
// Persist rows to the data model object when objectId is provided and FS writes are allowed
|
|
76
|
+
const persistence = describePersistenceMode();
|
|
77
|
+
let persisted = false;
|
|
78
|
+
let dataModel = null;
|
|
79
|
+
|
|
80
|
+
if (objectId && persistence.canSave) {
|
|
81
|
+
try {
|
|
82
|
+
const current = await readWorkspaceConfig();
|
|
83
|
+
const wc = current.workspaceConfig || current;
|
|
84
|
+
const existingObjects = Array.isArray(wc.dataModel?.objects) ? wc.dataModel.objects : [];
|
|
85
|
+
|
|
86
|
+
const updatedObjects = existingObjects.map((obj) => {
|
|
87
|
+
if (obj.id !== objectId) return obj;
|
|
88
|
+
return {
|
|
89
|
+
...obj,
|
|
90
|
+
columns: columns.length ? columns : obj.columns,
|
|
91
|
+
rows,
|
|
92
|
+
refreshedAt: new Date().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// If objectId wasn't found in the list, nothing to update but don't error
|
|
97
|
+
dataModel = { ...(wc.dataModel || {}), objects: updatedObjects };
|
|
98
|
+
|
|
99
|
+
await writeWorkspaceConfig({ dataModel });
|
|
100
|
+
persisted = true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return NextResponse.json({
|
|
103
|
+
ok: true,
|
|
104
|
+
rowCount: rows.length,
|
|
105
|
+
columns,
|
|
106
|
+
objectId: objectId || null,
|
|
107
|
+
persisted: false,
|
|
108
|
+
persistError: err.message,
|
|
109
|
+
dataModel: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return NextResponse.json({
|
|
115
|
+
ok: true,
|
|
116
|
+
rowCount: rows.length,
|
|
117
|
+
columns,
|
|
118
|
+
objectId: objectId || null,
|
|
119
|
+
persisted,
|
|
120
|
+
dataModel,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { POST };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/refresh-sources
|
|
3
|
+
*
|
|
4
|
+
* Thin dispatcher for live-backed data sources. Zero provider-specific logic
|
|
5
|
+
* lives here. Each integration ships its own resolver file under
|
|
6
|
+
* `lib/adapters/integrations/resolvers/<id>.js` and registers itself via
|
|
7
|
+
* `registerSourceResolver()` from source-resolver-registry.js.
|
|
8
|
+
*
|
|
9
|
+
* This route never imports any provider directly. It reads the registry and
|
|
10
|
+
* dispatches to whatever resolvers the operator has registered. An upstream
|
|
11
|
+
* kit with no resolver files registered still works — it just skips sources
|
|
12
|
+
* that have no resolver and returns them in the `skipped` array.
|
|
13
|
+
*
|
|
14
|
+
* Request body:
|
|
15
|
+
* { sourceIds: string[] } — IDs of dataModel.objects to refresh
|
|
16
|
+
*
|
|
17
|
+
* Response shape:
|
|
18
|
+
* 200 { refreshed: SourceRefreshResult[], skipped: string[] }
|
|
19
|
+
* 400 { error: string }
|
|
20
|
+
* 500 { error: string }
|
|
21
|
+
*
|
|
22
|
+
* SourceRefreshResult:
|
|
23
|
+
* { sourceId: string, integrationId: string, recordCount: number, fetchedAt: string }
|
|
24
|
+
*
|
|
25
|
+
* Authority contract (GOVERNED_WORKSPACE_TOPOLOGY_V1):
|
|
26
|
+
* - Browser sends only non-secret sourceIds. No tokens, no provider auth.
|
|
27
|
+
* - Server reads env credentials via readAdapterConfig().
|
|
28
|
+
* - Normalized records are persisted via writeWorkspaceSourceRecords().
|
|
29
|
+
* - Raw provider payloads are never forwarded to the client.
|
|
30
|
+
* - The Growthub bridge owns provider auth — this route never holds tokens.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { NextResponse } from "next/server";
|
|
34
|
+
import { readAdapterConfig } from "@/lib/adapters/env";
|
|
35
|
+
import { listGovernedWorkspaceIntegrations } from "@/lib/adapters/integrations";
|
|
36
|
+
import { readWorkspaceConfig, writeWorkspaceSourceRecords } from "@/lib/workspace-config";
|
|
37
|
+
import { loadAllResolvers } from "@/lib/adapters/integrations/resolver-loader";
|
|
38
|
+
import { getSourceResolver } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
39
|
+
|
|
40
|
+
async function POST(request) {
|
|
41
|
+
let body;
|
|
42
|
+
try {
|
|
43
|
+
body = await request.json();
|
|
44
|
+
} catch {
|
|
45
|
+
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
49
|
+
return NextResponse.json({ error: "body must be a plain object" }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { sourceIds } = body;
|
|
53
|
+
if (!Array.isArray(sourceIds) || sourceIds.length === 0) {
|
|
54
|
+
return NextResponse.json({ error: "sourceIds must be a non-empty array" }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const invalidIds = sourceIds.filter((id) => typeof id !== "string" || !id.trim());
|
|
58
|
+
if (invalidIds.length) {
|
|
59
|
+
return NextResponse.json({ error: "every sourceId must be a non-empty string" }, { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let workspaceConfig;
|
|
63
|
+
try {
|
|
64
|
+
workspaceConfig = await readWorkspaceConfig();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return NextResponse.json({ error: `failed to read workspace config: ${err.message}` }, { status: 500 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await loadAllResolvers();
|
|
70
|
+
|
|
71
|
+
const adapterConfig = readAdapterConfig();
|
|
72
|
+
const dataObjects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
|
|
73
|
+
|
|
74
|
+
// Resolve bridge connections once for this batch so every resolver
|
|
75
|
+
// receives the full live connection object (connectionId, authPath, metadata).
|
|
76
|
+
let bridgeIntegrations = [];
|
|
77
|
+
try {
|
|
78
|
+
bridgeIntegrations = await listGovernedWorkspaceIntegrations();
|
|
79
|
+
} catch {
|
|
80
|
+
// Non-fatal — resolvers fall back to env-only auth
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const refreshed = [];
|
|
84
|
+
const skipped = [];
|
|
85
|
+
|
|
86
|
+
for (const sourceId of sourceIds) {
|
|
87
|
+
const obj = dataObjects.find((o) => o.id === sourceId);
|
|
88
|
+
if (!obj) {
|
|
89
|
+
skipped.push(sourceId);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const binding = obj.binding;
|
|
94
|
+
if (!binding || binding.sourceStorage !== "workspace-source-records") {
|
|
95
|
+
skipped.push(sourceId);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const integrationId = binding.integrationId;
|
|
100
|
+
if (!integrationId) {
|
|
101
|
+
skipped.push(sourceId);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const resolver = getSourceResolver(integrationId);
|
|
106
|
+
if (!resolver) {
|
|
107
|
+
skipped.push(sourceId);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const connection = bridgeIntegrations.find(
|
|
113
|
+
(i) => i.provider === integrationId || i.id === integrationId
|
|
114
|
+
) || null;
|
|
115
|
+
const records = await resolver.fetchRecords(adapterConfig, connection, binding);
|
|
116
|
+
const fetchedAt = new Date().toISOString();
|
|
117
|
+
await writeWorkspaceSourceRecords(sourceId, records, { integrationId, fetchedAt });
|
|
118
|
+
refreshed.push({ sourceId, integrationId, recordCount: records.length, fetchedAt });
|
|
119
|
+
} catch {
|
|
120
|
+
skipped.push(sourceId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return NextResponse.json({ refreshed, skipped });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { POST };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/workspace/register-resolver
|
|
3
|
+
*
|
|
4
|
+
* Accepts a `.js` resolver file upload and saves it to
|
|
5
|
+
* `lib/adapters/integrations/resolvers/<filename>.js`.
|
|
6
|
+
*
|
|
7
|
+
* Only available in filesystem mode (local development with
|
|
8
|
+
* WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or NODE_ENV=development).
|
|
9
|
+
* In read-only runtimes this returns 409 with guidance.
|
|
10
|
+
*
|
|
11
|
+
* The uploaded file must:
|
|
12
|
+
* - be a valid UTF-8 text JavaScript module
|
|
13
|
+
* - call registerSourceResolver() at module level
|
|
14
|
+
* - have a .js extension
|
|
15
|
+
*
|
|
16
|
+
* After upload, call POST /api/workspace/test-source to verify the resolver
|
|
17
|
+
* registers correctly and fetchRecords returns well-formed records.
|
|
18
|
+
*
|
|
19
|
+
* Request: multipart/form-data
|
|
20
|
+
* file — the .js resolver file
|
|
21
|
+
* filename — optional override for the saved filename (slug.js)
|
|
22
|
+
*
|
|
23
|
+
* Response:
|
|
24
|
+
* 201 { saved: true, filename, path }
|
|
25
|
+
* 400 { error }
|
|
26
|
+
* 409 { error, guidance }
|
|
27
|
+
* 500 { error }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { NextResponse } from "next/server";
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { describePersistenceMode } from "@/lib/workspace-config";
|
|
34
|
+
|
|
35
|
+
const MAX_RESOLVER_SIZE = 256 * 1024; // 256 KB — resolvers should be small
|
|
36
|
+
|
|
37
|
+
function slugify(name) {
|
|
38
|
+
return name
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/\.js$/, "")
|
|
41
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
42
|
+
.replace(/-+/g, "-")
|
|
43
|
+
.replace(/^-|-$/g, "")
|
|
44
|
+
.slice(0, 64) || "resolver";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function POST(request) {
|
|
48
|
+
const persistence = describePersistenceMode();
|
|
49
|
+
if (!persistence.canSave) {
|
|
50
|
+
return NextResponse.json({
|
|
51
|
+
error: "resolver upload requires a writable filesystem runtime",
|
|
52
|
+
guidance: persistence.guidance || "Set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true or use local Next.js development mode."
|
|
53
|
+
}, { status: 409 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let formData;
|
|
57
|
+
try {
|
|
58
|
+
formData = await request.formData();
|
|
59
|
+
} catch {
|
|
60
|
+
return NextResponse.json({ error: "expected multipart/form-data with a file field" }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const file = formData.get("file");
|
|
64
|
+
const filenameOverride = formData.get("filename");
|
|
65
|
+
|
|
66
|
+
if (!file || typeof file.text !== "function") {
|
|
67
|
+
return NextResponse.json({ error: "file field is required" }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const originalName = typeof file.name === "string" ? file.name : "resolver.js";
|
|
71
|
+
if (!originalName.endsWith(".js")) {
|
|
72
|
+
return NextResponse.json({ error: "resolver file must have a .js extension" }, { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (file.size > MAX_RESOLVER_SIZE) {
|
|
76
|
+
return NextResponse.json({ error: `resolver file must be smaller than ${MAX_RESOLVER_SIZE / 1024} KB` }, { status: 400 });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let text;
|
|
80
|
+
try {
|
|
81
|
+
text = await file.text();
|
|
82
|
+
} catch {
|
|
83
|
+
return NextResponse.json({ error: "could not read uploaded file" }, { status: 400 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!text.includes("registerSourceResolver")) {
|
|
87
|
+
return NextResponse.json({
|
|
88
|
+
error: "resolver file must call registerSourceResolver() — see lib/adapters/integrations/resolvers/README.md for the required shape"
|
|
89
|
+
}, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rawName = typeof filenameOverride === "string" && filenameOverride.trim()
|
|
93
|
+
? filenameOverride.trim()
|
|
94
|
+
: originalName;
|
|
95
|
+
const filename = `${slugify(rawName)}.js`;
|
|
96
|
+
|
|
97
|
+
const resolversDir = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "lib/adapters/integrations/resolvers");
|
|
98
|
+
const outPath = path.join(resolversDir, filename);
|
|
99
|
+
|
|
100
|
+
if (path.dirname(outPath) !== resolversDir) {
|
|
101
|
+
return NextResponse.json({ error: "invalid filename — path traversal not allowed" }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
await fs.mkdir(resolversDir, { recursive: true });
|
|
106
|
+
await fs.writeFile(outPath, text, "utf8");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return NextResponse.json({ error: `failed to write resolver file: ${err.message}` }, { status: 500 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return NextResponse.json({
|
|
112
|
+
saved: true,
|
|
113
|
+
filename,
|
|
114
|
+
path: `lib/adapters/integrations/resolvers/${filename}`,
|
|
115
|
+
hint: "Run POST /api/workspace/test-source with integrationId to verify the resolver registers and fetchRecords works."
|
|
116
|
+
}, { status: 201 });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export { POST };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/workspace/resolvers
|
|
3
|
+
*
|
|
4
|
+
* Lists resolver files present in lib/adapters/integrations/resolvers/ and
|
|
5
|
+
* returns provider-agnostic metadata for each registered resolver.
|
|
6
|
+
* Used by the generic resolver management panel and ResolverControlPanel in the
|
|
7
|
+
* widget inspector. No provider names appear in the response shape.
|
|
8
|
+
*
|
|
9
|
+
* Response:
|
|
10
|
+
* {
|
|
11
|
+
* files: string[],
|
|
12
|
+
* registeredIds: string[],
|
|
13
|
+
* resolvers: {
|
|
14
|
+
* integrationId: string,
|
|
15
|
+
* entityTypes: string[],
|
|
16
|
+
* hasListEntities: boolean,
|
|
17
|
+
* configSchema: SchemaField[] | null
|
|
18
|
+
* }[],
|
|
19
|
+
* canUpload: boolean
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { NextResponse } from "next/server";
|
|
24
|
+
import { loadAllResolvers, listResolverFiles } from "@/lib/adapters/integrations/resolver-loader";
|
|
25
|
+
import { describeRegisteredResolvers } from "@/lib/adapters/integrations/source-resolver-registry";
|
|
26
|
+
import { describePersistenceMode } from "@/lib/workspace-config";
|
|
27
|
+
|
|
28
|
+
async function GET() {
|
|
29
|
+
await loadAllResolvers();
|
|
30
|
+
const files = await listResolverFiles();
|
|
31
|
+
const resolvers = describeRegisteredResolvers();
|
|
32
|
+
const persistence = describePersistenceMode();
|
|
33
|
+
return NextResponse.json({
|
|
34
|
+
files,
|
|
35
|
+
registeredIds: resolvers.map((r) => r.integrationId),
|
|
36
|
+
resolvers,
|
|
37
|
+
canUpload: persistence.canSave
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export { GET };
|