@growthub/cli 0.14.10 → 0.14.11

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 (52) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/callback/route.js +35 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/failure/route.js +35 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/[providerId]/schedule/route.js +423 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/connect/route.js +78 -0
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/credentials/route.js +276 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/[productId]/resources/route.js +173 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/products/sync/route.js +347 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/providers/[providerId]/sync/route.js +293 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/connect/route.js +7 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/provider/sync/route.js +7 -0
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/add-ons/upstash/sync/route.js +197 -0
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/apps/route.js +1 -1
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/metadata-graph/route.js +1 -49
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +3 -20
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/test-api-record/route.js +3 -20
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflow/publish/route.js +407 -290
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/workflows/[providerId]/route.js +209 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceAddOnsMarketplace.jsx +806 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ApiRegistryActionCard.jsx +141 -0
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/CeoCockpit.jsx +15 -3
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +42 -5
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +5 -1
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationNodeConfigPanel.jsx +86 -20
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ScheduleCockpit.jsx +363 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/helper-commands.js +8 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +322 -1
  27. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +2 -2
  28. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/add-ons-client.jsx +197 -0
  29. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/add-ons/page.jsx +23 -0
  30. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +1 -0
  31. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +734 -61
  32. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +15 -10
  33. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/env-status.js +2 -7
  34. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +2 -19
  35. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/sandbox-serverless-flow.js +8 -4
  36. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/schedule-cockpit-console.js +287 -0
  37. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/scheduler-orchestration.js +449 -0
  38. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/server-secrets.js +77 -0
  39. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/serverless-readiness.js +583 -0
  40. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-callback.js +63 -0
  41. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-on-scheduler.js +519 -0
  42. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-add-ons.js +957 -0
  43. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-config.js +607 -63
  44. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +21 -0
  45. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-operator-auth.js +32 -0
  46. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/provider.png +0 -0
  47. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/qstash.png +0 -0
  48. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/redis.png +0 -0
  49. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/search.png +0 -0
  50. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/public/integrations/upstash/vector.png +0 -0
  51. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/scripts/scheduler-ingress-smoke.mjs +26 -0
  52. package/package.json +1 -1
@@ -9,9 +9,9 @@
9
9
  * - `read-only` Vercel / Netlify-style runtimes where the bundle is
10
10
  * immutable. `PATCH /api/workspace` returns 409 with the
11
11
  * same `guidance` string the no-code Save UI surfaces.
12
- * - `database` (future) Reserved adapter slot. Not implemented in V1 — the
13
- * return shape is stable so a hosted adapter can be wired
14
- * without changing UI or API contracts.
12
+ * - `database` Postgres-compatible hosted persistence. Supabase is
13
+ * used through DATABASE_URL (or POSTGRES_URL, etc.) behind
14
+ * the postgres adapter, without adding a provider SDK.
15
15
  *
16
16
  * `describePersistenceMode()` is the single source of truth the GET payload,
17
17
  * the no-code Settings/Readiness panel, and the PATCH 409 path all read.
@@ -36,14 +36,130 @@ const PERSISTENCE_ADAPTERS = Object.freeze({
36
36
  const READ_ONLY_GUIDANCE =
37
37
  "Edit growthub.config.json locally, or set WORKSPACE_CONFIG_ALLOW_FS_WRITE=true on a writable runtime.";
38
38
 
39
+ const WORKSPACE_CONFIG_TABLE = "growthub_workspace_configs";
40
+ const DEFAULT_WORKSPACE_OWNER = "local-workspace";
41
+
42
+ function stripSandboxRowViewMetadata(row) {
43
+ if (!row || typeof row !== "object" || Array.isArray(row)) return row;
44
+ const { views: _views, activeViewId: _activeViewId, fieldSettings: _fieldSettings, ...rest } = row;
45
+ return rest;
46
+ }
47
+
48
+ function cloneJson(value) {
49
+ return JSON.parse(JSON.stringify(value));
50
+ }
51
+
52
+ /** Drop deprecated top-level keys so the only persistence surface is `dataModel` inside `config`. */
53
+ function normalizeConfigForPersistence(config) {
54
+ if (!config || typeof config !== "object" || Array.isArray(config)) return config;
55
+ const {
56
+ workspaceSourceRecords: _removedWorkspaceSourceRecords,
57
+ auxiliarySourceRecords: _removedAuxiliarySourceRecords,
58
+ ...rest
59
+ } = config;
60
+ return rest;
61
+ }
62
+
63
+ function mapAllWidgetsInWorkspaceConfig(config, mapWidget) {
64
+ let next = { ...config };
65
+ if (Array.isArray(next.dashboards)) {
66
+ next.dashboards = next.dashboards.map((dash) => ({
67
+ ...dash,
68
+ tabs: (dash.tabs || []).map((tab) => ({
69
+ ...tab,
70
+ widgets: (tab.widgets || []).map(mapWidget)
71
+ }))
72
+ }));
73
+ }
74
+ if (next.canvas) {
75
+ const canvas = { ...next.canvas };
76
+ if (Array.isArray(canvas.widgets)) canvas.widgets = canvas.widgets.map(mapWidget);
77
+ if (Array.isArray(canvas.tabs)) {
78
+ canvas.tabs = canvas.tabs.map((tab) => ({
79
+ ...tab,
80
+ widgets: (tab.widgets || []).map(mapWidget)
81
+ }));
82
+ }
83
+ next = { ...next, canvas };
84
+ }
85
+ return next;
86
+ }
87
+
88
+ /** Keep widget bindings aligned with the governed data model row store (same JSONB row). */
89
+ function syncWidgetsToLiveObjectRows(config, objectId, rows, fetchedAt) {
90
+ const pid = String(objectId).trim();
91
+ return mapAllWidgetsInWorkspaceConfig(config, (widget) => {
92
+ const b = widget?.config?.binding;
93
+ if (!b || b.sourceType !== "workspace-data-model") return widget;
94
+ const oid = typeof b.objectId === "string" ? b.objectId.trim() : "";
95
+ if (oid !== pid) return widget;
96
+ const nextBinding = { ...b, lastFetchedAt: fetchedAt, recordCount: rows.length };
97
+ if ("rowSource" in nextBinding) delete nextBinding.rowSource;
98
+ const base = { ...widget, config: { ...(widget.config || {}), binding: nextBinding } };
99
+ if (widget.kind === "view") {
100
+ return { ...base, config: { ...base.config, rows: [...rows] } };
101
+ }
102
+ return base;
103
+ });
104
+ }
105
+
106
+ function resolveWorkspaceDatabaseUrl() {
107
+ const keys = [
108
+ "DATABASE_URL",
109
+ "POSTGRES_URL",
110
+ "POSTGRES_PRISMA_URL",
111
+ "POSTGRES_URL_NON_POOLING",
112
+ "SUPABASE_DATABASE_URL",
113
+ "AGENCY_PORTAL_DATABASE_URL",
114
+ "GROWTHUB_WORKSPACE_DATABASE_URL"
115
+ ];
116
+ for (const k of keys) {
117
+ const v = process.env[k];
118
+ if (typeof v === "string" && v.trim()) return v.trim();
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function bundleFromLiveDataModelObject(o) {
124
+ if (!o) return null;
125
+ const rows = Array.isArray(o.rows) ? o.rows : [];
126
+ return {
127
+ records: rows,
128
+ integrationId: o.binding?.integrationId ?? null,
129
+ fetchedAt: o.binding?.lastFetchedAt ?? null,
130
+ recordCount: typeof o.binding?.recordCount === "number" ? o.binding.recordCount : rows.length
131
+ };
132
+ }
133
+
134
+ function auxiliarySourceRecordsFromConfig(config) {
135
+ return config &&
136
+ config.auxiliarySourceRecords &&
137
+ typeof config.auxiliarySourceRecords === "object" &&
138
+ !Array.isArray(config.auxiliarySourceRecords)
139
+ ? cloneJson(config.auxiliarySourceRecords)
140
+ : {};
141
+ }
142
+
143
+ function withAuxiliarySourceRecords(nextConfig, rawBasis) {
144
+ const auxiliarySourceRecords = auxiliarySourceRecordsFromConfig(rawBasis);
145
+ return Object.keys(auxiliarySourceRecords).length > 0
146
+ ? { ...nextConfig, auxiliarySourceRecords }
147
+ : nextConfig;
148
+ }
149
+
39
150
  function resolveWorkspaceConfigPath() {
40
151
  return path.resolve(/*turbopackIgnore: true*/ process.cwd(), "growthub.config.json");
41
152
  }
42
153
 
43
154
  async function readWorkspaceConfig() {
155
+ if (shouldUseDatabasePersistence()) {
156
+ return readDatabaseWorkspaceConfig();
157
+ }
44
158
  const configPath = resolveWorkspaceConfigPath();
45
159
  const raw = await fs.readFile(configPath, "utf8");
46
- return JSON.parse(raw);
160
+ let c = JSON.parse(raw);
161
+ if (c.dataModel) c.dataModel = healDataModelObjects(c.dataModel);
162
+ return normalizeConfigForPersistence(c);
47
163
  }
48
164
 
49
165
  /**
@@ -56,6 +172,20 @@ async function readWorkspaceConfig() {
56
172
  * path and gets verbatim `guidance` instead.
57
173
  */
58
174
  function describePersistenceMode() {
175
+ if (shouldUseDatabasePersistence()) {
176
+ return {
177
+ mode: PERSISTENCE_ADAPTERS.DATABASE,
178
+ adapter: "postgres",
179
+ canSave: true,
180
+ saveLabel: "Save writes workspace config to Postgres (e.g. Supabase).",
181
+ reason:
182
+ "postgres workspace data adapter with a configured Postgres URL (DATABASE_URL, POSTGRES_URL, …)",
183
+ nextAction: null,
184
+ guidance: null,
185
+ /** Row key in `growthub_workspace_configs.workspace_id` — same as `GROWTHUB_WORKSPACE_CONFIG_ID` when set. */
186
+ storageWorkspaceId: process.env.GROWTHUB_WORKSPACE_CONFIG_ID || null
187
+ };
188
+ }
59
189
  const target = process.env.GROWTHUB_WORKSPACE_DEPLOY_TARGET || process.env.AGENCY_PORTAL_DEPLOY_TARGET || "vercel";
60
190
  const isReadOnlyDeploy = target === "vercel" || target === "netlify";
61
191
  const allowFsWrite = process.env.WORKSPACE_CONFIG_ALLOW_FS_WRITE === "true";
@@ -89,6 +219,29 @@ function describePersistenceMode() {
89
219
  return baseFilesystem("Local development");
90
220
  }
91
221
 
222
+ function healDataModelObjects(dataModel) {
223
+ if (!dataModel || !Array.isArray(dataModel.objects)) return dataModel;
224
+ const healed = dataModel.objects.map((object) => {
225
+ let next = object;
226
+ if (
227
+ object &&
228
+ object.binding?.sourceStorage === "workspace-source-records" &&
229
+ (typeof object.sourceId !== "string" || !object.sourceId.trim()) &&
230
+ typeof object.id === "string" &&
231
+ object.id.trim()
232
+ ) {
233
+ next = { ...next, sourceId: object.id.trim() };
234
+ }
235
+ if (next?.objectType === "sandbox-environment" && Array.isArray(next.rows)) {
236
+ const rows = next.rows.map((row) => stripSandboxRowViewMetadata(row));
237
+ const changed = rows.some((row, index) => row !== next.rows[index]);
238
+ if (changed) next = { ...next, rows };
239
+ }
240
+ return next;
241
+ });
242
+ return { ...dataModel, objects: healed };
243
+ }
244
+
92
245
  /**
93
246
  * Pure merge step shared by the real write path and the preflight dry-run
94
247
  * (`POST /api/workspace/patch/preflight`). Canvas patches MERGE over the
@@ -101,7 +254,7 @@ function applyWorkspaceConfigPatch(currentConfig, patch) {
101
254
  const next = { ...currentConfig };
102
255
  if (patch.dashboards !== undefined) next.dashboards = patch.dashboards;
103
256
  if (patch.widgetTypes !== undefined) next.widgetTypes = patch.widgetTypes;
104
- if (patch.dataModel !== undefined) next.dataModel = patch.dataModel;
257
+ if (patch.dataModel !== undefined) next.dataModel = healDataModelObjects(patch.dataModel);
105
258
  if (patch.canvas !== undefined && patch.canvas !== null) {
106
259
  const patchCanvas = { ...patch.canvas };
107
260
  if (Array.isArray(patchCanvas.tabs)) {
@@ -137,6 +290,19 @@ function applyWorkspaceConfigPatch(currentConfig, patch) {
137
290
  async function writeWorkspaceConfig(patch) {
138
291
  const persistence = describePersistenceMode();
139
292
  const adapter = readAdapterConfig();
293
+ if (persistence.mode === PERSISTENCE_ADAPTERS.DATABASE && persistence.canSave) {
294
+ const rawCurrent = await readDatabaseWorkspaceConfigRaw();
295
+ const current = normalizeConfigForPersistence(rawCurrent);
296
+ const next = normalizeConfigForPersistence(applyWorkspaceConfigPatch(current, patch));
297
+ validateWorkspaceConfig({
298
+ dashboards: next.dashboards,
299
+ widgetTypes: next.widgetTypes,
300
+ canvas: next.canvas,
301
+ dataModel: next.dataModel
302
+ });
303
+ await writeDatabaseWorkspaceConfig(withAuxiliarySourceRecords(next, rawCurrent));
304
+ return next;
305
+ }
140
306
  if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
141
307
  const error = new Error(persistence.reason);
142
308
  error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
@@ -144,8 +310,13 @@ async function writeWorkspaceConfig(patch) {
144
310
  error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
145
311
  throw error;
146
312
  }
147
- const current = await readWorkspaceConfig();
148
- const next = applyWorkspaceConfigPatch(current, patch);
313
+ const rawCurrent = persistence.mode === PERSISTENCE_ADAPTERS.DATABASE
314
+ ? await readDatabaseWorkspaceConfigRaw()
315
+ : null;
316
+ const current = normalizeConfigForPersistence(
317
+ rawCurrent || await readWorkspaceConfig()
318
+ );
319
+ const next = normalizeConfigForPersistence(applyWorkspaceConfigPatch(current, patch));
149
320
  validateWorkspaceConfig({
150
321
  dashboards: next.dashboards,
151
322
  widgetTypes: next.widgetTypes,
@@ -222,7 +393,7 @@ function normalizeWorkspaceIdentityPatch(patch) {
222
393
  async function writeWorkspaceIdentitySettings(patch) {
223
394
  const persistence = describePersistenceMode();
224
395
  const adapter = readAdapterConfig();
225
- if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
396
+ if (![PERSISTENCE_ADAPTERS.FILESYSTEM, PERSISTENCE_ADAPTERS.DATABASE].includes(persistence.mode) || !persistence.canSave) {
226
397
  const error = new Error(persistence.reason);
227
398
  error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
228
399
  error.adapter = adapter.integrationAdapter;
@@ -231,11 +402,14 @@ async function writeWorkspaceIdentitySettings(patch) {
231
402
  }
232
403
 
233
404
  const normalized = normalizeWorkspaceIdentityPatch(patch);
234
- const current = await readWorkspaceConfig();
405
+ const rawCurrent = persistence.mode === PERSISTENCE_ADAPTERS.DATABASE
406
+ ? await readDatabaseWorkspaceConfigRaw()
407
+ : null;
408
+ const current = normalizeConfigForPersistence(
409
+ rawCurrent || await readWorkspaceConfig()
410
+ );
235
411
  const next = { ...current };
236
- if (normalized.name !== undefined) {
237
- next.name = normalized.name;
238
- }
412
+ if (normalized.name !== undefined) next.name = normalized.name;
239
413
  if (normalized.branding) {
240
414
  next.branding = {
241
415
  ...(current.branding && typeof current.branding === "object" && !Array.isArray(current.branding)
@@ -243,9 +417,7 @@ async function writeWorkspaceIdentitySettings(patch) {
243
417
  : {}),
244
418
  ...normalized.branding
245
419
  };
246
- if (!next.branding.name) {
247
- next.branding.name = next.name || "Growthub Workspace";
248
- }
420
+ if (!next.branding.name) next.branding.name = next.name || "Growthub Workspace";
249
421
  }
250
422
 
251
423
  validateWorkspaceConfig({
@@ -255,6 +427,11 @@ async function writeWorkspaceIdentitySettings(patch) {
255
427
  dataModel: next.dataModel
256
428
  });
257
429
 
430
+ if (persistence.mode === PERSISTENCE_ADAPTERS.DATABASE) {
431
+ await writeDatabaseWorkspaceConfig(withAuxiliarySourceRecords(next, rawCurrent));
432
+ return next;
433
+ }
434
+
258
435
  const configPath = resolveWorkspaceConfigPath();
259
436
  const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
260
437
  if (path.dirname(configPath) !== expectedDir) {
@@ -309,7 +486,7 @@ function normalizeApiWebhookRefs(refs) {
309
486
  async function writeWorkspaceApiWebhookSettings(patch) {
310
487
  const persistence = describePersistenceMode();
311
488
  const adapter = readAdapterConfig();
312
- if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
489
+ if (![PERSISTENCE_ADAPTERS.FILESYSTEM, PERSISTENCE_ADAPTERS.DATABASE].includes(persistence.mode) || !persistence.canSave) {
313
490
  const error = new Error(persistence.reason);
314
491
  error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
315
492
  error.adapter = adapter.integrationAdapter;
@@ -318,7 +495,12 @@ async function writeWorkspaceApiWebhookSettings(patch) {
318
495
  }
319
496
 
320
497
  const refs = normalizeApiWebhookRefs(patch?.refs);
321
- const current = await readWorkspaceConfig();
498
+ const rawCurrent = persistence.mode === PERSISTENCE_ADAPTERS.DATABASE
499
+ ? await readDatabaseWorkspaceConfigRaw()
500
+ : null;
501
+ const current = normalizeConfigForPersistence(
502
+ rawCurrent || await readWorkspaceConfig()
503
+ );
322
504
  const existing = Array.isArray(current.integrations) ? current.integrations : [];
323
505
  const next = {
324
506
  ...current,
@@ -335,6 +517,11 @@ async function writeWorkspaceApiWebhookSettings(patch) {
335
517
  dataModel: next.dataModel
336
518
  });
337
519
 
520
+ if (persistence.mode === PERSISTENCE_ADAPTERS.DATABASE) {
521
+ await writeDatabaseWorkspaceConfig(withAuxiliarySourceRecords(next, rawCurrent));
522
+ return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
523
+ }
524
+
338
525
  const configPath = resolveWorkspaceConfigPath();
339
526
  const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
340
527
  if (path.dirname(configPath) !== expectedDir) {
@@ -346,43 +533,170 @@ async function writeWorkspaceApiWebhookSettings(patch) {
346
533
  return next.integrations.filter((item) => item?.sourceType === "custom-api-webhooks");
347
534
  }
348
535
 
349
- /**
350
- * Source Records persistence — sidecar store for live-backed dataModel objects.
351
- *
352
- * Records are written by POST /api/workspace/refresh-sources when a resolver
353
- * fetches live data for a source with `binding.sourceStorage: "workspace-source-records"`.
354
- *
355
- * Persistence is keyed by `sourceId` and stored in a JSON sidecar file
356
- * (`growthub.source-records.json`) beside `growthub.config.json`. The same
357
- * filesystem / read-only / database mode rules apply: in read-only mode writes
358
- * are rejected gracefully so the refresh button surface is disabled.
359
- *
360
- * Shape: { [sourceId]: { records: Record[], integrationId: string, fetchedAt: string } }
361
- */
536
+ function shouldUseDatabasePersistence() {
537
+ const adapter = readAdapterConfig();
538
+ return adapter.dataAdapter === "postgres" && Boolean(resolveWorkspaceDatabaseUrl());
539
+ }
362
540
 
363
- const SOURCE_RECORDS_FILENAME = "growthub.source-records.json";
541
+ /** Hosted workspace row key: explicit env, then Growthub bridge user id (same value many kits already set in `.env`). */
542
+ function resolveWorkspaceOwnerIdFromEnv() {
543
+ const id =
544
+ process.env.GROWTHUB_WORKSPACE_USER_ID ||
545
+ process.env.AGENCY_PORTAL_WORKSPACE_USER_ID ||
546
+ process.env.GROWTHUB_BRIDGE_USER_ID ||
547
+ "";
548
+ return typeof id === "string" && id.trim() ? id.trim() : DEFAULT_WORKSPACE_OWNER;
549
+ }
550
+
551
+ async function readWorkspaceOwner() {
552
+ const sessionPath = process.env.GROWTHUB_WORKSPACE_SESSION_PATH || process.env.AGENCY_PORTAL_WORKSPACE_SESSION_PATH;
553
+ if (sessionPath) {
554
+ try {
555
+ const raw = await fs.readFile(path.resolve(/*turbopackIgnore: true*/ sessionPath), "utf8");
556
+ const session = JSON.parse(raw);
557
+ const sid = session.userId || session.email;
558
+ return {
559
+ ownerId: (typeof sid === "string" && sid.trim() ? sid.trim() : null) || resolveWorkspaceOwnerIdFromEnv(),
560
+ ownerEmail: session.email || null,
561
+ hostedBaseUrl: session.hostedBaseUrl || null,
562
+ sessionPath
563
+ };
564
+ } catch {
565
+ return {
566
+ ownerId: resolveWorkspaceOwnerIdFromEnv(),
567
+ ownerEmail: null,
568
+ hostedBaseUrl: null,
569
+ sessionPath
570
+ };
571
+ }
572
+ }
573
+ return {
574
+ ownerId: resolveWorkspaceOwnerIdFromEnv(),
575
+ ownerEmail: null,
576
+ hostedBaseUrl: null,
577
+ sessionPath: null
578
+ };
579
+ }
364
580
 
365
- function resolveSourceRecordsPath() {
366
- return path.resolve(/*turbopackIgnore: true*/ process.cwd(), SOURCE_RECORDS_FILENAME);
581
+ async function connectPostgres() {
582
+ const { Client } = await import("pg");
583
+ const rawUrl = resolveWorkspaceDatabaseUrl();
584
+ const connectionString = normalizePostgresConnectionString(rawUrl);
585
+ const usesSsl = process.env.DATABASE_SSL === "true" || /supabase\.(co|com)|sslmode=require/.test(String(connectionString || ""));
586
+ const client = new Client({
587
+ connectionString,
588
+ ssl: usesSsl ? { rejectUnauthorized: process.env.DATABASE_SSL_REJECT_UNAUTHORIZED === "true" } : undefined
589
+ });
590
+ await client.connect();
591
+ return client;
367
592
  }
368
593
 
369
- async function readWorkspaceSourceRecords(sourceId) {
370
- const recordsPath = resolveSourceRecordsPath();
594
+ function normalizePostgresConnectionString(connectionString) {
595
+ if (!connectionString) return connectionString;
371
596
  try {
372
- const raw = await fs.readFile(recordsPath, "utf8");
373
- const all = JSON.parse(raw);
374
- if (sourceId) {
375
- return all[sourceId] || null;
376
- }
377
- return all;
597
+ const url = new URL(connectionString);
598
+ url.searchParams.delete("sslmode");
599
+ return url.toString();
378
600
  } catch {
379
- return sourceId ? null : {};
601
+ return connectionString;
380
602
  }
381
603
  }
382
604
 
383
- async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
605
+ async function ensureWorkspaceConfigTable(client) {
606
+ await client.query(`
607
+ create table if not exists ${WORKSPACE_CONFIG_TABLE} (
608
+ owner_id text not null,
609
+ workspace_id text not null,
610
+ owner_email text,
611
+ hosted_base_url text,
612
+ config jsonb not null,
613
+ created_at timestamptz not null default now(),
614
+ updated_at timestamptz not null default now(),
615
+ primary key (owner_id, workspace_id)
616
+ )
617
+ `);
618
+ }
619
+
620
+ async function readDatabaseWorkspaceConfigRaw() {
621
+ const owner = await readWorkspaceOwner();
622
+ const seed = JSON.parse(await fs.readFile(resolveWorkspaceConfigPath(), "utf8"));
623
+ const workspaceId = process.env.GROWTHUB_WORKSPACE_CONFIG_ID || seed.id || "default";
624
+ const client = await connectPostgres();
625
+ try {
626
+ await ensureWorkspaceConfigTable(client);
627
+ const result = await client.query(
628
+ `select config from ${WORKSPACE_CONFIG_TABLE} where owner_id = $1 and workspace_id = $2 limit 1`,
629
+ [owner.ownerId, workspaceId]
630
+ );
631
+ if (result.rows[0]?.config) {
632
+ return cloneJson(result.rows[0].config);
633
+ }
634
+ await client.query(
635
+ `insert into ${WORKSPACE_CONFIG_TABLE} (owner_id, workspace_id, owner_email, hosted_base_url, config)
636
+ values ($1, $2, $3, $4, $5::jsonb)
637
+ on conflict (owner_id, workspace_id) do nothing`,
638
+ [owner.ownerId, workspaceId, owner.ownerEmail, owner.hostedBaseUrl, JSON.stringify(seed)]
639
+ );
640
+ return cloneJson(seed);
641
+ } finally {
642
+ await client.end();
643
+ }
644
+ }
645
+
646
+ async function readDatabaseWorkspaceConfig() {
647
+ const config = normalizeConfigForPersistence(await readDatabaseWorkspaceConfigRaw());
648
+ if (config.dataModel) config.dataModel = healDataModelObjects(config.dataModel);
649
+ return config;
650
+ }
651
+
652
+ async function writeDatabaseWorkspaceConfig(config) {
653
+ const owner = await readWorkspaceOwner();
654
+ const workspaceId = process.env.GROWTHUB_WORKSPACE_CONFIG_ID || config.id || "default";
655
+ const client = await connectPostgres();
656
+ try {
657
+ await ensureWorkspaceConfigTable(client);
658
+ await client.query(
659
+ `insert into ${WORKSPACE_CONFIG_TABLE} (owner_id, workspace_id, owner_email, hosted_base_url, config)
660
+ values ($1, $2, $3, $4, $5::jsonb)
661
+ on conflict (owner_id, workspace_id)
662
+ do update set
663
+ owner_email = excluded.owner_email,
664
+ hosted_base_url = excluded.hosted_base_url,
665
+ config = excluded.config,
666
+ updated_at = now()`,
667
+ [owner.ownerId, workspaceId, owner.ownerEmail, owner.hostedBaseUrl, JSON.stringify(config)]
668
+ );
669
+ } finally {
670
+ await client.end();
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Read normalized live rows for a refresh `sourceId` / object id from
676
+ * `dataModel.objects[]` (same document as workspace config — Postgres JSONB or
677
+ * growthub.config.json). No sidecar files or auxiliary tables.
678
+ */
679
+ async function readLiveDataModelBundle(sourceId) {
680
+ const config = await readWorkspaceConfig();
681
+ const objects = config.dataModel?.objects || [];
682
+ if (sourceId) {
683
+ const sid = sourceId.trim();
684
+ const o = objects.find((obj) => obj.id === sid || obj.sourceId === sid);
685
+ if (!o || o.binding?.sourceStorage !== "workspace-source-records") return null;
686
+ return bundleFromLiveDataModelObject(o);
687
+ }
688
+ const all = {};
689
+ for (const o of objects) {
690
+ if (o.binding?.sourceStorage !== "workspace-source-records") continue;
691
+ const key = typeof o.sourceId === "string" && o.sourceId.trim() ? o.sourceId.trim() : o.id;
692
+ all[key] = bundleFromLiveDataModelObject(o);
693
+ }
694
+ return all;
695
+ }
696
+
697
+ async function writeLiveDataModelRows(sourceId, records, metadata = {}) {
384
698
  const persistence = describePersistenceMode();
385
- if (persistence.mode !== PERSISTENCE_ADAPTERS.FILESYSTEM || !persistence.canSave) {
699
+ if (![PERSISTENCE_ADAPTERS.FILESYSTEM, PERSISTENCE_ADAPTERS.DATABASE].includes(persistence.mode) || !persistence.canSave) {
386
700
  const error = new Error(persistence.reason);
387
701
  error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
388
702
  error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
@@ -390,38 +704,266 @@ async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
390
704
  }
391
705
  if (typeof sourceId !== "string" || !sourceId.trim()) {
392
706
  const error = new Error("sourceId must be a non-empty string");
393
- error.code = "INVALID_SOURCE_RECORDS_WRITE";
707
+ error.code = "INVALID_LIVE_DATA_MODEL_ROWS";
394
708
  throw error;
395
709
  }
396
710
  if (!Array.isArray(records)) {
397
711
  const error = new Error("records must be an array");
398
- error.code = "INVALID_SOURCE_RECORDS_WRITE";
712
+ error.code = "INVALID_LIVE_DATA_MODEL_ROWS";
399
713
  throw error;
400
714
  }
401
- const recordsPath = resolveSourceRecordsPath();
402
- const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
403
- if (path.dirname(recordsPath) !== expectedDir) {
404
- const error = new Error(`refused to write outside workspace cwd: ${recordsPath}`);
405
- error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
715
+
716
+ const sid = sourceId.trim();
717
+ const fetchedAt = metadata.fetchedAt || new Date().toISOString();
718
+ const integrationId = metadata.integrationId ?? null;
719
+
720
+ const rawBasis = persistence.mode === PERSISTENCE_ADAPTERS.DATABASE
721
+ ? await readDatabaseWorkspaceConfigRaw()
722
+ : null;
723
+ const basis = normalizeConfigForPersistence(rawBasis || await readWorkspaceConfig());
724
+ if (!Array.isArray(basis.dataModel?.objects)) {
725
+ const error = new Error("workspace config has no dataModel.objects");
726
+ error.code = "LIVE_DATA_MODEL_NO_OBJECTS";
406
727
  throw error;
407
728
  }
408
- let all = {};
409
- try {
410
- const raw = await fs.readFile(recordsPath, "utf8");
411
- all = JSON.parse(raw);
412
- } catch {
413
- all = {};
729
+
730
+ let found = false;
731
+ const objects = basis.dataModel.objects.map((o) => {
732
+ if (o.id !== sid && o.sourceId !== sid) return o;
733
+ if (o.binding?.sourceStorage !== "workspace-source-records") return o;
734
+ found = true;
735
+ const nextBinding = {
736
+ ...o.binding,
737
+ lastFetchedAt: fetchedAt,
738
+ recordCount: records.length
739
+ };
740
+ if ("rowSource" in nextBinding) delete nextBinding.rowSource;
741
+ return {
742
+ ...o,
743
+ rows: records,
744
+ binding: nextBinding
745
+ };
746
+ });
747
+
748
+ if (!found) {
749
+ const error = new Error(`no live-backed data model object for sourceId: ${sid}`);
750
+ error.code = "LIVE_DATA_MODEL_OBJECT_NOT_FOUND";
751
+ throw error;
752
+ }
753
+
754
+ let next = normalizeConfigForPersistence({
755
+ ...basis,
756
+ dataModel: { ...basis.dataModel, objects }
757
+ });
758
+ next = syncWidgetsToLiveObjectRows(next, sid, records, fetchedAt);
759
+ if (next.dataModel) next.dataModel = healDataModelObjects(next.dataModel);
760
+
761
+ validateWorkspaceConfig({
762
+ dashboards: next.dashboards,
763
+ widgetTypes: next.widgetTypes,
764
+ canvas: next.canvas,
765
+ dataModel: next.dataModel
766
+ });
767
+
768
+ if (persistence.mode === PERSISTENCE_ADAPTERS.DATABASE) {
769
+ await writeDatabaseWorkspaceConfig(withAuxiliarySourceRecords(next, rawBasis));
770
+ } else {
771
+ const configPath = resolveWorkspaceConfigPath();
772
+ const expectedDir = path.resolve(/*turbopackIgnore: true*/ process.cwd());
773
+ if (path.dirname(configPath) !== expectedDir) {
774
+ const error = new Error(`refused to write outside workspace cwd: ${configPath}`);
775
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
776
+ throw error;
777
+ }
778
+ await fs.writeFile(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
779
+ }
780
+
781
+ return { records, integrationId, fetchedAt, recordCount: records.length };
782
+ }
783
+
784
+ /** Kit artifact parity: sidecar keyed by stable `sourceId` (live integrations + sandbox run-history keys). */
785
+ const SOURCE_RECORDS_FILENAME = "growthub.source-records.json";
786
+
787
+ function resolveSourceRecordsSidecarPath() {
788
+ return path.resolve(/* turbopackIgnore: true */ process.cwd(), SOURCE_RECORDS_FILENAME);
789
+ }
790
+
791
+ /**
792
+ * Canonical read contract for keyed source records (`refresh-sources`,
793
+ * sandbox run history keyed as `sandbox:<objectId>:<slug>`, reference pickers).
794
+ *
795
+ * Lookup order per key:
796
+ * 1. Live-backed `workspace-source-records` **rows inside** `dataModel.objects[]`
797
+ * (Postgres JSONB or growthub.config.json).
798
+ * 2. **Supabase/hosted Postgres** — `auxiliarySourceRecords` keyed map on the workspace
799
+ * document persisted with config JSON (same Postgres row).
800
+ * 3. Filesystem **`growthub.source-records.json`** — same keyed shape as exported kits.
801
+ */
802
+ async function readWorkspaceSourceRecords(sourceId) {
803
+ if (!sourceId) {
804
+ const liveAllRaw = await readLiveDataModelBundle();
805
+ const liveAll =
806
+ typeof liveAllRaw === "object" &&
807
+ liveAllRaw !== null &&
808
+ !Array.isArray(liveAllRaw)
809
+ ? liveAllRaw
810
+ : {};
811
+ const config = shouldUseDatabasePersistence()
812
+ ? await readDatabaseWorkspaceConfigRaw()
813
+ : await readWorkspaceConfig();
814
+ const auxPlain =
815
+ config.auxiliarySourceRecords &&
816
+ typeof config.auxiliarySourceRecords === "object" &&
817
+ !Array.isArray(config.auxiliarySourceRecords)
818
+ ? config.auxiliarySourceRecords
819
+ : {};
820
+ let merged = { ...auxPlain, ...liveAll };
821
+
822
+ if (!shouldUseDatabasePersistence()) {
823
+ try {
824
+ const raw = await fs.readFile(resolveSourceRecordsSidecarPath(), "utf8");
825
+ const all = JSON.parse(raw);
826
+ if (typeof all === "object" && all !== null && !Array.isArray(all)) merged = { ...all, ...merged };
827
+ } catch {
828
+ /* no sidecar */
829
+ }
830
+ }
831
+ return merged;
414
832
  }
415
- all[sourceId.trim()] = {
833
+
834
+ const sid = sourceId.trim();
835
+ const liveOne = await readLiveDataModelBundle(sid);
836
+ if (liveOne && typeof liveOne === "object") return liveOne;
837
+
838
+ const cfg = shouldUseDatabasePersistence()
839
+ ? await readDatabaseWorkspaceConfigRaw()
840
+ : await readWorkspaceConfig();
841
+ const auxHit =
842
+ cfg.auxiliarySourceRecords &&
843
+ typeof cfg.auxiliarySourceRecords === "object" &&
844
+ !Array.isArray(cfg.auxiliarySourceRecords)
845
+ ? cfg.auxiliarySourceRecords[sid]
846
+ : null;
847
+ if (auxHit && typeof auxHit === "object" && Array.isArray(auxHit.records)) return auxHit;
848
+
849
+ if (!shouldUseDatabasePersistence()) {
850
+ try {
851
+ const raw = await fs.readFile(resolveSourceRecordsSidecarPath(), "utf8");
852
+ const all = JSON.parse(raw);
853
+ return all[sid] || null;
854
+ } catch {
855
+ return null;
856
+ }
857
+ }
858
+
859
+ return null;
860
+ }
861
+
862
+ /**
863
+ * Persist keyed records: live integrations update governed object rows when
864
+ * `binding.sourceStorage === "workspace-source-records"` matches; sandbox and
865
+ * other keyed stores use Postgres-embedded auxiliary map or filesystem sidecar.
866
+ */
867
+ async function writeWorkspaceSourceRecords(sourceId, records, metadata = {}) {
868
+ const persistence = describePersistenceMode();
869
+
870
+ const sid = typeof sourceId === "string" ? sourceId.trim() : "";
871
+ if (!sid) {
872
+ const error = new Error("sourceId must be a non-empty string");
873
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
874
+ throw error;
875
+ }
876
+ if (!Array.isArray(records)) {
877
+ const error = new Error("records must be an array");
878
+ error.code = "INVALID_SOURCE_RECORDS_WRITE";
879
+ throw error;
880
+ }
881
+
882
+ const fetchedAt = metadata.fetchedAt || new Date().toISOString();
883
+ const integrationId = metadata.integrationId ?? null;
884
+ const entry = {
416
885
  records,
417
- integrationId: metadata.integrationId || null,
418
- fetchedAt: metadata.fetchedAt || new Date().toISOString(),
886
+ integrationId,
887
+ fetchedAt,
419
888
  recordCount: records.length
420
889
  };
421
- await fs.writeFile(recordsPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
422
- return all[sourceId.trim()];
423
- }
424
890
 
891
+ const basis = shouldUseDatabasePersistence()
892
+ ? await readDatabaseWorkspaceConfigRaw()
893
+ : await readWorkspaceConfig();
894
+ const hasLiveBackedObject =
895
+ Array.isArray(basis.dataModel?.objects) &&
896
+ basis.dataModel.objects.some(
897
+ (o) =>
898
+ o &&
899
+ o.binding?.sourceStorage === "workspace-source-records" &&
900
+ (o.id === sid || o.sourceId === sid)
901
+ );
902
+
903
+ if (hasLiveBackedObject) {
904
+ return writeLiveDataModelRows(sid, records, { integrationId, fetchedAt });
905
+ }
906
+
907
+ if (!persistence.canSave) {
908
+ const error = new Error(persistence.reason);
909
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
910
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
911
+ throw error;
912
+ }
913
+
914
+ if (persistence.mode === PERSISTENCE_ADAPTERS.DATABASE) {
915
+ const aux = cloneJson(
916
+ basis.auxiliarySourceRecords &&
917
+ typeof basis.auxiliarySourceRecords === "object" &&
918
+ !Array.isArray(basis.auxiliarySourceRecords)
919
+ ? basis.auxiliarySourceRecords
920
+ : {}
921
+ );
922
+ aux[sid] = entry;
923
+ const next = normalizeConfigForPersistence({
924
+ ...basis,
925
+ auxiliarySourceRecords: aux
926
+ });
927
+ validateWorkspaceConfig({
928
+ dashboards: next.dashboards,
929
+ widgetTypes: next.widgetTypes,
930
+ canvas: next.canvas,
931
+ dataModel: next.dataModel
932
+ });
933
+ await writeDatabaseWorkspaceConfig({
934
+ ...next,
935
+ auxiliarySourceRecords: aux
936
+ });
937
+ return entry;
938
+ }
939
+
940
+ if (persistence.mode === PERSISTENCE_ADAPTERS.FILESYSTEM) {
941
+ const recordsPath = resolveSourceRecordsSidecarPath();
942
+ const expectedDir = path.resolve(/* turbopackIgnore: true */ process.cwd());
943
+ if (path.dirname(recordsPath) !== expectedDir) {
944
+ const error = new Error(`refused to write outside workspace cwd: ${recordsPath}`);
945
+ error.code = "WORKSPACE_PERSISTENCE_PATH_REFUSED";
946
+ throw error;
947
+ }
948
+
949
+ let all = {};
950
+ try {
951
+ const raw = await fs.readFile(recordsPath, "utf8");
952
+ all = JSON.parse(raw);
953
+ } catch {
954
+ all = {};
955
+ }
956
+
957
+ all[sid] = entry;
958
+ await fs.writeFile(recordsPath, `${JSON.stringify(all, null, 2)}\n`, "utf8");
959
+ return entry;
960
+ }
961
+
962
+ const error = new Error(persistence.reason || "cannot persist source records");
963
+ error.code = "WORKSPACE_PERSISTENCE_READ_ONLY";
964
+ error.guidance = persistence.guidance || READ_ONLY_GUIDANCE;
965
+ throw error;
966
+ }
425
967
  export {
426
968
  GRID_COLUMNS,
427
969
  GRID_ROWS,
@@ -432,10 +974,12 @@ export {
432
974
  describePersistenceMode,
433
975
  readWorkspaceConfig,
434
976
  readWorkspaceSourceRecords,
977
+ readLiveDataModelBundle,
435
978
  resolveWorkspaceConfigPath,
436
979
  validateWorkspaceConfig,
437
980
  writeWorkspaceConfig,
438
981
  writeWorkspaceApiWebhookSettings,
439
982
  writeWorkspaceIdentitySettings,
983
+ writeLiveDataModelRows,
440
984
  writeWorkspaceSourceRecords
441
985
  };