@growthub/cli 0.9.12 → 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.
Files changed (36) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +27 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/apis-webhooks/route.js +59 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/settings/workspace/route.js +70 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +41 -9
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/list-entities/route.js +67 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-source/route.js +124 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/refresh-sources/route.js +127 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/register-resolver/route.js +119 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/resolvers/route.js +41 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +126 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-source/route.js +130 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/page.jsx +700 -214
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/global-error.jsx +21 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +2468 -793
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/apis-webhooks-form.jsx +208 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apis-webhooks/page.jsx +19 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/apps-list.jsx +43 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/apps/page.jsx +109 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/general-settings-form.jsx +134 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/general/page.jsx +25 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +22 -3
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/page.jsx +25 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +33 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +1558 -437
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/docs/data-sources-api-registry.md +139 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolver-loader.js +57 -0
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/README.md +133 -0
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/resolvers/google-analytics.js +160 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/source-resolver-registry.js +85 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +264 -1
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +104 -0
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +23 -6
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +1 -0
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +7 -0
  35. package/dist/index.js +1764 -40675
  36. package/package.json +1 -1
@@ -155,6 +155,265 @@ async function writeWorkspaceConfig(patch) {
155
155
  return next;
156
156
  }
157
157
 
158
+ function normalizeWorkspaceIdentityPatch(patch) {
159
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
160
+ const error = new Error("settings patch must be a plain object");
161
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
162
+ throw error;
163
+ }
164
+
165
+ const allowed = new Set(["name", "branding"]);
166
+ const unknown = Object.keys(patch).filter((key) => !allowed.has(key));
167
+ if (unknown.length) {
168
+ const error = new Error("settings patch contains unknown fields");
169
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
170
+ error.details = unknown;
171
+ throw error;
172
+ }
173
+
174
+ const normalized = {};
175
+ if (Object.prototype.hasOwnProperty.call(patch, "name")) {
176
+ if (typeof patch.name !== "string") {
177
+ const error = new Error("name must be a string");
178
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
179
+ throw error;
180
+ }
181
+ normalized.name = patch.name.trim() || "Growthub Workspace";
182
+ }
183
+
184
+ if (Object.prototype.hasOwnProperty.call(patch, "branding")) {
185
+ if (!patch.branding || typeof patch.branding !== "object" || Array.isArray(patch.branding)) {
186
+ const error = new Error("branding must be a plain object");
187
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
188
+ throw error;
189
+ }
190
+ const brandingAllowed = new Set(["name", "logoUrl", "accent"]);
191
+ const brandingUnknown = Object.keys(patch.branding).filter((key) => !brandingAllowed.has(key));
192
+ if (brandingUnknown.length) {
193
+ const error = new Error("branding patch contains unknown fields");
194
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
195
+ error.details = brandingUnknown.map((key) => `branding.${key}`);
196
+ throw error;
197
+ }
198
+ normalized.branding = {};
199
+ for (const key of brandingAllowed) {
200
+ if (Object.prototype.hasOwnProperty.call(patch.branding, key)) {
201
+ if (typeof patch.branding[key] !== "string") {
202
+ const error = new Error(`branding.${key} must be a string`);
203
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
204
+ throw error;
205
+ }
206
+ normalized.branding[key] = patch.branding[key].trim();
207
+ }
208
+ }
209
+ }
210
+
211
+ return normalized;
212
+ }
213
+
214
+ async function writeWorkspaceIdentitySettings(patch) {
215
+ const persistence = describePersistenceMode();
216
+ const adapter = readAdapterConfig();
217
+ if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
218
+ const error = new Error(persistence.reason);
219
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
220
+ error.adapter = adapter.integrationAdapter;
221
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
222
+ throw error;
223
+ }
224
+
225
+ const normalized = normalizeWorkspaceIdentityPatch(patch);
226
+ const current = await readWorkspaceConfig();
227
+ const next = { ...current };
228
+ if (normalized.name !== undefined) {
229
+ next.name = normalized.name;
230
+ }
231
+ if (normalized.branding) {
232
+ next.branding = {
233
+ ...(current.branding && typeof current.branding === "object" && !Array.isArray(current.branding)
234
+ ? current.branding
235
+ : {}),
236
+ ...normalized.branding
237
+ };
238
+ if (!next.branding.name) {
239
+ next.branding.name = next.name || "Growthub Workspace";
240
+ }
241
+ }
242
+
243
+ validateWorkspaceConfig({
244
+ dashboards: next.dashboards,
245
+ widgetTypes: next.widgetTypes,
246
+ canvas: next.canvas,
247
+ dataModel: next.dataModel
248
+ });
249
+
250
+ const configPath = resolveWorkspaceConfigPath();
251
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
252
+ if (path.dirname(configPath) !== expectedDir) {
253
+ const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
254
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
255
+ throw error;
256
+ }
257
+ await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
258
+ return next;
259
+ }
260
+
261
+ function normalizeApiWebhookRefs(refs) {
262
+ if (!Array.isArray(refs)) {
263
+ const error = new Error("refs must be an array");
264
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
265
+ throw error;
266
+ }
267
+ return refs
268
+ .map((item, index) => {
269
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
270
+ const error = new Error("each ref must be a plain object");
271
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
272
+ throw error;
273
+ }
274
+ const allowed = new Set(["id", "label", "kind", "endpointRef", "status", "hasSecret", "url"]);
275
+ const unknown = Object.keys(item).filter((key) => !allowed.has(key));
276
+ if (unknown.length) {
277
+ const error = new Error("ref contains unknown fields");
278
+ error.code = "INVALID_WORKSPACE_SETTINGS_PATCH";
279
+ error.details = unknown;
280
+ throw error;
281
+ }
282
+ const kind = item.kind === "webhook" ? "webhook" : "api";
283
+ const label = typeof item.label === "string" ? item.label.trim() : "";
284
+ const endpointRef = typeof item.endpointRef === "string" ? item.endpointRef.trim() : "";
285
+ const url = typeof item.url === "string" ? item.url.trim() : "";
286
+ if (!label && !endpointRef && !url && item.hasSecret !== true) return null;
287
+ return {
288
+ id: typeof item.id === "string" && item.id.trim() ? item.id.trim() : `custom-${kind}-${index + 1}`,
289
+ label: label || endpointRef,
290
+ kind,
291
+ sourceType: "custom-api-webhooks",
292
+ endpointRef,
293
+ url,
294
+ status: typeof item.status === "string" && item.status.trim() ? item.status.trim() : "configured",
295
+ hasSecret: item.hasSecret === true
296
+ };
297
+ })
298
+ .filter(Boolean);
299
+ }
300
+
301
+ async function writeWorkspaceApiWebhookSettings(patch) {
302
+ const persistence = describePersistenceMode();
303
+ const adapter = readAdapterConfig();
304
+ if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
305
+ const error = new Error(persistence.reason);
306
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
307
+ error.adapter = adapter.integrationAdapter;
308
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
309
+ throw error;
310
+ }
311
+
312
+ const refs = normalizeApiWebhookRefs(patch?.refs);
313
+ const current = await readWorkspaceConfig();
314
+ const existing = Array.isArray(current.integrations) ? current.integrations : [];
315
+ const next = {
316
+ ...current,
317
+ integrations: [
318
+ ...existing.filter((item) => item?.sourceType !== "custom-api-webhooks"),
319
+ ...refs
320
+ ]
321
+ };
322
+
323
+ validateWorkspaceConfig({
324
+ dashboards: next.dashboards,
325
+ widgetTypes: next.widgetTypes,
326
+ canvas: next.canvas,
327
+ dataModel: next.dataModel
328
+ });
329
+
330
+ const configPath = resolveWorkspaceConfigPath();
331
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
332
+ if (path.dirname(configPath) !== expectedDir) {
333
+ const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
334
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
335
+ throw error;
336
+ }
337
+ await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
338
+ return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
339
+ }
340
+
341
+ /**
342
+ * Source Records persistence — sidecar store for live-backed dataModel objects.
343
+ *
344
+ * Records are written by POST /api/workspace/refresh-sources when a resolver
345
+ * fetches live data for a source with `binding.sourceStorage: "workspace-source-records"`.
346
+ *
347
+ * Persistence is keyed by `sourceId` and stored in a JSON sidecar file
348
+ * (`growthub.source-records.json`) beside `growthub.config.json`. The same
349
+ * filesystem / read-only / database mode rules apply: in read-only mode writes
350
+ * are rejected gracefully so the refresh button surface is disabled.
351
+ *
352
+ * Shape: { [sourceId]: { records: Record[], integrationId: string, fetchedAt: string } }
353
+ */
354
+
355
+ const SOURCE_RECORDS_FILENAME = "growthub.source-records.json";
356
+
357
+ function resolveSourceRecordsPath() {
358
+ return path.resolve(/*turbopackIgnore: true*/ process.cwd(), SOURCE_RECORDS_FILENAME);
359
+ }
360
+
361
+ async function readWorkspaceSourceRecords(sourceId) {
362
+ const recordsPath = resolveSourceRecordsPath();
363
+ try {
364
+ const raw = await fs.readFile(recordsPath, "utf8");
365
+ const all = JSON.parse(raw);
366
+ if (sourceId) {
367
+ return all[sourceId] || null;
368
+ }
369
+ return all;
370
+ } catch {
371
+ return sourceId ? null : {};
372
+ }
373
+ }
374
+
375
+ async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
376
+ const persistence = describePersistenceMode();
377
+ if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
378
+ const error = new Error(persistence.reason);
379
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
380
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
381
+ throw error;
382
+ }
383
+ if (typeof sourceId !== "string" || !sourceId.trim()) {
384
+ const error = new Error("sourceId must be a non-empty string");
385
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
386
+ throw error;
387
+ }
388
+ if (!Array.isArray(records)) {
389
+ const error = new Error("records must be an array");
390
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
391
+ throw error;
392
+ }
393
+ const recordsPath = resolveSourceRecordsPath();
394
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
395
+ if (path.dirname(recordsPath) !== expectedDir) {
396
+ const error = new Error(`refused to write outside workspace cwd: ${recordsPath}`);
397
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
398
+ throw error;
399
+ }
400
+ let all = {};
401
+ try {
402
+ const raw = await fs.readFile(recordsPath, "utf8");
403
+ all = JSON.parse(raw);
404
+ } catch {
405
+ all = {};
406
+ }
407
+ all[sourceId.trim()] = {
408
+ records,
409
+ integrationId: metadata.integrationId || null,
410
+ fetchedAt: metadata.fetchedAt || new Date().toISOString(),
411
+ recordCount: records.length
412
+ };
413
+ await fs.writeFile(recordsPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
414
+ return all[sourceId.trim()];
415
+ }
416
+
158
417
  export {
159
418
  GRID_COLUMNS,
160
419
  GRID_ROWS,
@@ -163,7 +422,11 @@ export {
163
422
  READ_ONLY_GUIDANCE,
164
423
  describePersistenceMode,
165
424
  readWorkspaceConfig,
425
+ readWorkspaceSourceRecords,
166
426
  resolveWorkspaceConfigPath,
167
427
  validateWorkspaceConfig,
168
- writeWorkspaceConfig
428
+ writeWorkspaceConfig,
429
+ writeWorkspaceApiWebhookSettings,
430
+ writeWorkspaceIdentitySettings,
431
+ writeWorkspaceSourceRecords
169
432
  };
@@ -171,9 +171,12 @@ function deriveManualObjectTable(object) {
171
171
  id: `manual-object:${object.id || source}`,
172
172
  label: object.label || object.name || source,
173
173
  source,
174
+ objectType: object.objectType || "custom",
175
+ icon: object.icon || null,
174
176
  columns,
175
177
  rows,
176
178
  binding: object.binding || { mode: "manual", source: "Data Model" },
179
+ relations: Array.isArray(object.relations) ? object.relations : [],
177
180
  mutable: true,
178
181
  storage: "manual-object",
179
182
  objectId: object.id,
@@ -313,6 +316,105 @@ function uniqueObjectId(workspaceConfig, name) {
313
316
  return `${base}-${index}`;
314
317
  }
315
318
 
319
+ /**
320
+ * Top-level object type presets.
321
+ * Each entry defines: label, icon (Lucide name), description, default columns, and
322
+ * any built-in relations. These are the five first-class types the UI offers when
323
+ * a user clicks "New object" — they act like schema templates, not hard constraints.
324
+ *
325
+ * Relation shape:
326
+ * {
327
+ * id: string, // stable slug within this object
328
+ * name: string, // display label
329
+ * field: string, // FK column on THIS object
330
+ * targetObjectType:string, // objectType of the referenced object
331
+ * type: "belongs-to" | "has-many",
332
+ * description: string
333
+ * }
334
+ */
335
+ const OBJECT_TYPE_PRESETS = {
336
+ "data-source": {
337
+ label: "Data Source",
338
+ icon: "Globe",
339
+ description: "Custom API, webhook, or external feed. References an API Registry record while credentials stay in workspace settings.",
340
+ columns: ["Name", "registryId", "endpoint", "authRef", "baseUrl", "status", "lastTested", "lastResponse"],
341
+ relations: [
342
+ {
343
+ id: "resolver-binding",
344
+ name: "Resolver",
345
+ field: "registryId",
346
+ targetObjectType: "api-registry",
347
+ type: "belongs-to",
348
+ description: "The API Registry entry whose fetchRecords function resolves this source. Set registryId to match the resolver integrationId."
349
+ }
350
+ ]
351
+ },
352
+ "api-registry": {
353
+ label: "API Registry",
354
+ icon: "Code2",
355
+ description: "HTTP API records with endpoint config, auth references, connection status, and stored test output.",
356
+ columns: ["integrationId", "authRef", "baseUrl", "endpoint", "method", "status", "lastTested", "lastResponse", "entityTypes", "description"],
357
+ relations: []
358
+ },
359
+ "people": {
360
+ label: "People",
361
+ icon: "Users",
362
+ description: "Contacts, leads, or team members with standard CRM fields.",
363
+ columns: ["Name", "Email", "Phone", "Company", "Status", "Role"],
364
+ relations: []
365
+ },
366
+ "tasks": {
367
+ label: "Tasks",
368
+ icon: "CheckSquare",
369
+ description: "Action items, to-dos, or work items.",
370
+ columns: ["Name", "Status", "DueDate", "Assignee", "Priority"],
371
+ relations: []
372
+ },
373
+ "custom": {
374
+ label: "Custom",
375
+ icon: "Plus",
376
+ description: "Start with a blank table — define your own fields and records.",
377
+ columns: ["Name"],
378
+ relations: []
379
+ }
380
+ };
381
+
382
+ /**
383
+ * Create a typed business object from a preset template.
384
+ * Accepts objectType (one of the OBJECT_TYPE_PRESETS keys) and an optional icon override.
385
+ * The object is stored in dataModel.objects[] alongside manual objects.
386
+ */
387
+ function createTypedBusinessObject(workspaceConfig, { name, objectType = "custom", icon } = {}) {
388
+ const label = String(name || "").trim();
389
+ if (!label) return workspaceConfig;
390
+ const preset = OBJECT_TYPE_PRESETS[objectType] || OBJECT_TYPE_PRESETS.custom;
391
+ const columns = [...preset.columns];
392
+ const dataModel =
393
+ workspaceConfig.dataModel && typeof workspaceConfig.dataModel === "object" && !Array.isArray(workspaceConfig.dataModel)
394
+ ? workspaceConfig.dataModel
395
+ : {};
396
+ const id = uniqueObjectId(workspaceConfig, label);
397
+ const object = {
398
+ id,
399
+ label,
400
+ source: label,
401
+ objectType,
402
+ icon: icon || preset.icon,
403
+ columns,
404
+ rows: [],
405
+ binding: { mode: "manual", source: "Data Model" },
406
+ relations: preset.relations ? preset.relations.map((r) => ({ ...r })) : [],
407
+ fieldSettings: { hidden: [], order: columns }
408
+ };
409
+ return {
410
+ ...workspaceConfig,
411
+ dataModel: {
412
+ ...dataModel,
413
+ objects: [...normalizeManualObjects(workspaceConfig), object]
414
+ }
415
+ };
416
+ }
417
+
316
418
  function createManualBusinessObject(workspaceConfig, { name, fields } = {}) {
317
419
  const label = String(name || "").trim();
318
420
  const columns = Array.from(new Set((Array.isArray(fields) ? fields : String(fields || "").split(","))
@@ -417,10 +519,12 @@ function describeBindingMode(binding) {
417
519
  }
418
520
 
419
521
  export {
522
+ OBJECT_TYPE_PRESETS,
420
523
  addTableField,
421
524
  addTableRow,
422
525
  appendRowsToTable,
423
526
  createManualBusinessObject,
527
+ createTypedBusinessObject,
424
528
  deleteTableRow,
425
529
  describeBindingLane,
426
530
  describeBindingMode,
@@ -152,7 +152,9 @@ const WIDGET_SCHEMA_CONTRACTS = {
152
152
  lane: "string optional (when mode === 'integration')",
153
153
  entityId: "string optional — stable source object ID (never a token or credential)",
154
154
  entityType: "string optional — adapter-provided object type",
155
- entityLabel: "string optional — display-only resolved label, not authoritative"
155
+ entityLabel: "string optional — display-only resolved label, not authoritative",
156
+ sourceStorage: "'workspace-source-records' optional — marks this binding as live-backed; records are written by POST /api/workspace/refresh-sources and keyed by dataModel.objects[].sourceId",
157
+ sourceId: "string optional — stable key in growthub.source-records.json; required when sourceStorage === 'workspace-source-records'"
156
158
  },
157
159
  NormalizedIntegrationEntity: {
158
160
  id: "non-empty string — stable source object ID",
@@ -200,19 +202,19 @@ const SAMPLE_DATA_BINDINGS = {
200
202
  function defaultConfigFor(kind) {
201
203
  switch (kind) {
202
204
  case "chart":
203
- return { values: [58, 36, 72, 48, 64], binding: SAMPLE_DATA_BINDINGS.reportingJson };
205
+ return { values: [], binding: { mode: "manual", source: "", rows: [] } };
204
206
  case "view":
205
207
  return {
206
208
  source: "",
207
209
  layout: "Table",
208
210
  columns: [],
209
211
  rows: [],
210
- binding: { mode: "manual", source: "Static rows", rows: [] }
212
+ binding: { mode: "manual", source: "", rows: [] }
211
213
  };
212
214
  case "iframe":
213
215
  return { url: "" };
214
216
  case "rich-text":
215
- return { text: "", binding: { mode: "manual", source: "Manual text", rows: [] } };
217
+ return { text: "", binding: { mode: "manual", source: "", rows: [] } };
216
218
  default:
217
219
  return {};
218
220
  }
@@ -396,8 +398,11 @@ function validateStaticDataBinding(binding, path, errors) {
396
398
  if (typeof binding.integrationId !== "string" || !binding.integrationId.trim()) {
397
399
  errors.push(`${path}.integrationId is required when mode is integration`);
398
400
  }
399
- if (typeof binding.lane !== "string" || !binding.lane.trim()) {
400
- errors.push(`${path}.lane is required when mode is integration`);
401
+ // lane is not required when sourceStorage delegates routing to a registry resolver
402
+ if (binding.sourceStorage !== "workspace-source-records") {
403
+ if (typeof binding.lane !== "string" || !binding.lane.trim()) {
404
+ errors.push(`${path}.lane is required when mode is integration`);
405
+ }
401
406
  }
402
407
  }
403
408
  if (binding.source !== undefined && typeof binding.source !== "string") {
@@ -439,6 +444,14 @@ function validateStaticDataBinding(binding, path, errors) {
439
444
  if (binding.entityLabel !== undefined && typeof binding.entityLabel !== "string") {
440
445
  errors.push(`${path}.entityLabel must be a string`);
441
446
  }
447
+ if (binding.sourceStorage !== undefined) {
448
+ if (binding.sourceStorage !== "workspace-source-records") {
449
+ errors.push(`${path}.sourceStorage must be "workspace-source-records" when present`);
450
+ }
451
+ }
452
+ if (binding.sourceId !== undefined && typeof binding.sourceId !== "string") {
453
+ errors.push(`${path}.sourceId must be a string`);
454
+ }
442
455
  }
443
456
 
444
457
  function validateFieldSettings(fieldSettings, path, errors) {
@@ -805,6 +818,7 @@ function validateDataModelConfig(dataModel, errors) {
805
818
  }
806
819
  if (typeof object.label !== "string" || !object.label.trim()) errors.push(`${prefix}.label must be a non-empty string`);
807
820
  if (object.source !== undefined && typeof object.source !== "string") errors.push(`${prefix}.source must be a string`);
821
+ if (object.sourceId !== undefined && typeof object.sourceId !== "string") errors.push(`${prefix}.sourceId must be a string`);
808
822
  validateStringArray(object.columns, `${prefix}.columns`, errors);
809
823
  if (!Array.isArray(object.rows)) {
810
824
  errors.push(`${prefix}.rows must be an array`);
@@ -814,6 +828,9 @@ function validateDataModelConfig(dataModel, errors) {
814
828
  });
815
829
  }
816
830
  validateStaticDataBinding(object.binding, `${prefix}.binding`, errors);
831
+ if (object.binding?.sourceStorage === "workspace-source-records" && typeof object.sourceId !== "string") {
832
+ errors.push(`${prefix}.sourceId is required when binding.sourceStorage is "workspace-source-records"`);
833
+ }
817
834
  validateFieldSettings(object.fieldSettings, `${prefix}.fieldSettings`, errors);
818
835
  });
819
836
  }
@@ -10,6 +10,7 @@
10
10
  "lint": "next lint"
11
11
  },
12
12
  "dependencies": {
13
+ "@tanstack/react-table": "^8.21.3",
13
14
  "lucide-react": "^0.468.0",
14
15
  "next": "16.2.4",
15
16
  "react": "19.2.4",
@@ -76,6 +76,10 @@
76
76
  "apps/workspace/app/workspace-builder.jsx",
77
77
  "apps/workspace/app/settings/integrations/page.jsx",
78
78
  "apps/workspace/app/api/workspace/route.js",
79
+ "apps/workspace/app/api/workspace/refresh-sources/route.js",
80
+ "apps/workspace/app/api/workspace/test-source/route.js",
81
+ "apps/workspace/app/api/workspace/register-resolver/route.js",
82
+ "apps/workspace/app/api/workspace/resolvers/route.js",
79
83
  "apps/workspace/app/api/settings/integrations/route.js",
80
84
  "apps/workspace/lib/workspace-schema.js",
81
85
  "apps/workspace/lib/workspace-config.js",
@@ -85,6 +89,9 @@
85
89
  "apps/workspace/lib/adapters/auth/index.js",
86
90
  "apps/workspace/lib/adapters/integrations/index.js",
87
91
  "apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js",
92
+ "apps/workspace/lib/adapters/integrations/source-resolver-registry.js",
93
+ "apps/workspace/lib/adapters/integrations/resolver-loader.js",
94
+ "apps/workspace/lib/adapters/integrations/resolvers/README.md",
88
95
  "apps/workspace/lib/adapters/payments/index.js",
89
96
  "apps/workspace/lib/adapters/persistence/index.js",
90
97
  "apps/workspace/lib/adapters/persistence/postgres.js",