@daeda/mcp-pro 0.1.11 → 0.1.12

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 (2) hide show
  1. package/dist/index.js +1721 -890
  2. package/package.json +3 -1
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { Effect as Effect91, Layer as Layer6, pipe as pipe77 } from "effect";
6
+ import { Effect as Effect93, Layer as Layer6, pipe as pipe78 } from "effect";
7
7
 
8
8
  // src/plugins/crm-data.ts
9
9
  import { Effect as Effect3, pipe as pipe3 } from "effect";
@@ -78,6 +78,7 @@ var SyncError = class extends Data.TaggedError("SyncError") {
78
78
 
79
79
  // src/pure/csv-parser.ts
80
80
  import { unzipSync, gunzipSync } from "fflate";
81
+ import Papa from "papaparse";
81
82
  var ZIP_MAGIC = [80, 75, 3, 4];
82
83
  var GZIP_MAGIC = [31, 139];
83
84
  var hasMagicBytes = (bytes, magic) => bytes.length >= magic.length && magic.every((b, i) => bytes[i] === b);
@@ -106,74 +107,6 @@ var decompressBytes = (bytes, contentType = "", contentEncoding = "", url = "")
106
107
  }
107
108
  return new TextDecoder().decode(bytes);
108
109
  };
109
- var splitCsvRows = (csv) => {
110
- const rows = [];
111
- let current = "";
112
- let inQuotes = false;
113
- for (let i = 0; i < csv.length; i++) {
114
- const char = csv[i];
115
- if (inQuotes) {
116
- if (char === '"') {
117
- if (i + 1 < csv.length && csv[i + 1] === '"') {
118
- current += '""';
119
- i++;
120
- } else {
121
- inQuotes = false;
122
- current += char;
123
- }
124
- } else {
125
- current += char;
126
- }
127
- } else {
128
- if (char === '"') {
129
- inQuotes = true;
130
- current += char;
131
- } else if (char === "\r") {
132
- if (i + 1 < csv.length && csv[i + 1] === "\n") i++;
133
- if (current.trim().length > 0) rows.push(current);
134
- current = "";
135
- } else if (char === "\n") {
136
- if (current.trim().length > 0) rows.push(current);
137
- current = "";
138
- } else {
139
- current += char;
140
- }
141
- }
142
- }
143
- if (current.trim().length > 0) rows.push(current);
144
- return rows;
145
- };
146
- var parseCSVLine = (line) => {
147
- const fields44 = [];
148
- let current = "";
149
- let inQuotes = false;
150
- for (let i = 0; i < line.length; i++) {
151
- const char = line[i];
152
- if (inQuotes) {
153
- if (char === '"') {
154
- if (i + 1 < line.length && line[i + 1] === '"') {
155
- current += '"';
156
- i++;
157
- } else {
158
- inQuotes = false;
159
- }
160
- } else {
161
- current += char;
162
- }
163
- } else {
164
- if (char === '"') {
165
- inQuotes = true;
166
- } else if (char === ",") {
167
- fields44.push(current);
168
- current = "";
169
- } else {
170
- current += char;
171
- }
172
- }
173
- }
174
- fields44.push(current);
175
- return fields44;
176
- };
177
110
  var EXPORT_NAME_TO_OBJECT_TYPE = {
178
111
  CONTACT: "contacts",
179
112
  COMPANY: "companies",
@@ -195,58 +128,43 @@ var normalizeAssociationLabel = (value) => {
195
128
  if (value === void 0) return null;
196
129
  return value.trim().length > 0 ? value : null;
197
130
  };
198
- var parseSupabaseAssociationCsv = (lines, headers, from, to) => {
199
- const fromIdIndex = headers.findIndex((h) => h === "fromObjectId");
200
- const toIdIndex = headers.findIndex((h) => h === "toObjectId");
201
- const typeIndex = headers.findIndex((h) => h === "associationType");
202
- const labelIndex = headers.findIndex((h) => h === "label");
203
- if (fromIdIndex === -1 || toIdIndex === -1) {
204
- throw new Error(`Missing fromObjectId/toObjectId columns in headers: ${headers.join(", ")}`);
205
- }
206
- const rows = [];
207
- for (let i = 1; i < lines.length; i++) {
208
- const values = parseCSVLine(lines[i]);
209
- const fromId = values[fromIdIndex] ?? "";
210
- const toId = values[toIdIndex] ?? "";
211
- if (!fromId || !toId) continue;
212
- rows.push({
213
- fromObjectType: from,
214
- fromId,
215
- toObjectType: to,
216
- toId,
217
- associationType: typeIndex !== -1 ? values[typeIndex] ?? "" : "",
218
- label: labelIndex !== -1 ? normalizeAssociationLabel(values[labelIndex]) : null
219
- });
131
+ var isAssociationHeader = (h) => h.startsWith("associations.ids.") || h.startsWith("associations.labels.") || h.startsWith("associations.");
132
+ var parseRow = (csvContent) => Papa.parse(csvContent, {
133
+ header: true,
134
+ skipEmptyLines: true,
135
+ dynamicTyping: false
136
+ });
137
+ var findAssociationMappings = (headers) => {
138
+ const mappings = [];
139
+ for (const h of headers) {
140
+ if (!h.startsWith("associations.ids.")) continue;
141
+ const exportName = h.replace("associations.ids.", "");
142
+ const toObjectType = resolveObjectTypeName(exportName);
143
+ const labelsHeader = headers.find((lh) => lh === `associations.labels.${exportName}`) ?? headers.find((lh) => lh === `associations.${exportName}`) ?? null;
144
+ mappings.push({ toObjectType, idsHeader: h, labelsHeader });
220
145
  }
221
- return rows;
146
+ return mappings;
222
147
  };
223
- var parseHubSpotAssociationCsv = (lines, headers, from, to) => {
224
- const idIndex = headers.findIndex(
225
- (h) => h === "hs_object_id" || h === "Record ID"
226
- );
227
- if (idIndex === -1) {
228
- throw new Error(`No id column found in headers: ${headers.join(", ")}`);
229
- }
230
- const assocIdsIndex = headers.findIndex((h) => h.startsWith("associations.ids."));
231
- if (assocIdsIndex === -1) {
232
- throw new Error(`No associations.ids column found in headers: ${headers.join(", ")}`);
148
+ var findIdField = (headers) => {
149
+ for (const candidate of ["hs_object_id", "Record ID", "id", "ID"]) {
150
+ if (headers.includes(candidate)) return candidate;
233
151
  }
234
- const labelHeader = headers[assocIdsIndex].replace("associations.ids.", "associations.labels.");
235
- const labelIndex = headers.findIndex((h) => h === labelHeader);
152
+ const lowerMatch = headers.find((h) => h.toLowerCase() === "id");
153
+ if (lowerMatch) return lowerMatch;
154
+ throw new Error(`No id column found in headers: ${headers.join(", ")}`);
155
+ };
156
+ var extractAssociations = (row, fromId, objectType, mappings) => {
236
157
  const rows = [];
237
- for (let i = 1; i < lines.length; i++) {
238
- const values = parseCSVLine(lines[i]);
239
- const fromId = values[idIndex] ?? "";
240
- if (!fromId) continue;
241
- const idsRaw = values[assocIdsIndex] ?? "";
158
+ for (const mapping of mappings) {
159
+ const idsRaw = row[mapping.idsHeader] ?? "";
242
160
  if (!idsRaw) continue;
243
161
  const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
244
- const labels = labelIndex !== -1 ? (values[labelIndex] ?? "").split(";").map((s) => s.trim()) : [];
162
+ const labels = mapping.labelsHeader ? (row[mapping.labelsHeader] ?? "").split(";").map((s) => s.trim()) : [];
245
163
  for (let j = 0; j < toIds.length; j++) {
246
164
  rows.push({
247
- fromObjectType: from,
165
+ fromObjectType: objectType,
248
166
  fromId,
249
- toObjectType: to,
167
+ toObjectType: mapping.toObjectType,
250
168
  toId: toIds[j],
251
169
  associationType: "",
252
170
  label: normalizeAssociationLabel(labels[j])
@@ -255,78 +173,85 @@ var parseHubSpotAssociationCsv = (lines, headers, from, to) => {
255
173
  }
256
174
  return rows;
257
175
  };
258
- var findAssociationColumns = (headers) => {
259
- const columns = [];
260
- for (let i = 0; i < headers.length; i++) {
261
- const h = headers[i];
262
- if (!h.startsWith("associations.ids.")) continue;
263
- const exportName = h.replace("associations.ids.", "");
264
- const toObjectType = resolveObjectTypeName(exportName);
265
- const labelHeader = `associations.labels.${exportName}`;
266
- const labelsIndex = headers.findIndex((lh) => lh === labelHeader);
267
- columns.push({ toObjectType, idsIndex: i, labelsIndex });
268
- }
269
- return columns;
270
- };
271
- var isAssociationHeader = (h) => h.startsWith("associations.ids.") || h.startsWith("associations.labels.");
272
176
  var parseMergedExportCsv = (csvContent, objectType) => {
273
- const lines = splitCsvRows(csvContent);
274
- if (lines.length === 0) return { objects: [], associations: [] };
275
- const headers = parseCSVLine(lines[0]);
276
- const idIndex = headers.findIndex(
277
- (h) => h.toLowerCase() === "id" || h === "Record ID" || h === "hs_object_id"
278
- );
279
- if (idIndex === -1) {
280
- throw new Error(`No id column found in headers: ${headers.join(", ")}`);
281
- }
282
- const assocColumns = findAssociationColumns(headers);
283
- const assocColumnIndices = /* @__PURE__ */ new Set();
284
- for (const col of assocColumns) {
285
- assocColumnIndices.add(col.idsIndex);
286
- if (col.labelsIndex !== -1) assocColumnIndices.add(col.labelsIndex);
177
+ const result = parseRow(csvContent);
178
+ if (result.data.length === 0) return { objects: [], associations: [] };
179
+ const headers = result.meta.fields ?? [];
180
+ const idField = findIdField(headers);
181
+ const assocMappings = findAssociationMappings(headers);
182
+ const assocHeaders = /* @__PURE__ */ new Set();
183
+ for (const m of assocMappings) {
184
+ assocHeaders.add(m.idsHeader);
185
+ if (m.labelsHeader) assocHeaders.add(m.labelsHeader);
287
186
  }
187
+ const propertyHeaders = headers.filter(
188
+ (h) => h !== idField && !assocHeaders.has(h) && !isAssociationHeader(h)
189
+ );
288
190
  const objects = [];
289
191
  const associations = [];
290
- for (let i = 1; i < lines.length; i++) {
291
- const values = parseCSVLine(lines[i]);
292
- const id = values[idIndex] ?? "";
192
+ for (const row of result.data) {
193
+ const id = row[idField] ?? "";
293
194
  if (!id) continue;
294
195
  const properties = {};
295
- for (let j = 0; j < headers.length; j++) {
296
- if (j === idIndex) continue;
297
- if (assocColumnIndices.has(j)) continue;
298
- if (isAssociationHeader(headers[j])) continue;
299
- const key = headers[j];
300
- const value = values[j];
196
+ for (const key of propertyHeaders) {
197
+ const value = row[key];
301
198
  properties[key] = value !== void 0 && value !== "" ? value : null;
302
199
  }
303
200
  objects.push({ id, properties });
304
- for (const col of assocColumns) {
305
- const idsRaw = values[col.idsIndex] ?? "";
306
- if (!idsRaw) continue;
307
- const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
308
- const labels = col.labelsIndex !== -1 ? (values[col.labelsIndex] ?? "").split(";").map((s) => s.trim()) : [];
309
- for (let j = 0; j < toIds.length; j++) {
310
- associations.push({
311
- fromObjectType: objectType,
312
- fromId: id,
313
- toObjectType: col.toObjectType,
314
- toId: toIds[j],
315
- associationType: "",
316
- label: normalizeAssociationLabel(labels[j])
317
- });
318
- }
319
- }
201
+ associations.push(...extractAssociations(row, id, objectType, assocMappings));
320
202
  }
321
203
  return { objects, associations };
322
204
  };
323
205
  var parseAssociationRows = (csvContent, objectType) => {
324
206
  const { from, to } = parseAssociationType(objectType);
325
- const lines = splitCsvRows(csvContent);
326
- if (lines.length === 0) return [];
327
- const headers = parseCSVLine(lines[0]);
328
- const isSupabaseFormat = headers.some((h) => h === "fromObjectId");
329
- return isSupabaseFormat ? parseSupabaseAssociationCsv(lines, headers, from, to) : parseHubSpotAssociationCsv(lines, headers, from, to);
207
+ const result = parseRow(csvContent);
208
+ if (result.data.length === 0) return [];
209
+ const headers = result.meta.fields ?? [];
210
+ const isSupabaseFormat = headers.includes("fromObjectId");
211
+ if (isSupabaseFormat) {
212
+ const rows2 = [];
213
+ for (const row of result.data) {
214
+ const fromId = row["fromObjectId"] ?? "";
215
+ const toId = row["toObjectId"] ?? "";
216
+ if (!fromId || !toId) continue;
217
+ rows2.push({
218
+ fromObjectType: from,
219
+ fromId,
220
+ toObjectType: to,
221
+ toId,
222
+ associationType: row["associationType"] ?? "",
223
+ label: normalizeAssociationLabel(row["label"])
224
+ });
225
+ }
226
+ return rows2;
227
+ }
228
+ const idField = findIdField(headers);
229
+ const assocIdsHeader = headers.find((h) => h.startsWith("associations.ids."));
230
+ if (!assocIdsHeader) {
231
+ throw new Error(`No associations.ids column found in headers: ${headers.join(", ")}`);
232
+ }
233
+ const exportName = assocIdsHeader.replace("associations.ids.", "");
234
+ const labelHeader = headers.find((h) => h === `associations.labels.${exportName}`) ?? headers.find((h) => h === `associations.${exportName}`) ?? null;
235
+ const rows = [];
236
+ for (const row of result.data) {
237
+ const fromId = row[idField] ?? "";
238
+ if (!fromId) continue;
239
+ const idsRaw = row[assocIdsHeader] ?? "";
240
+ if (!idsRaw) continue;
241
+ const toIds = idsRaw.split(";").map((s) => s.trim()).filter(Boolean);
242
+ const labels = labelHeader ? (row[labelHeader] ?? "").split(";").map((s) => s.trim()) : [];
243
+ for (let j = 0; j < toIds.length; j++) {
244
+ rows.push({
245
+ fromObjectType: from,
246
+ fromId,
247
+ toObjectType: to,
248
+ toId: toIds[j],
249
+ associationType: "",
250
+ label: normalizeAssociationLabel(labels[j])
251
+ });
252
+ }
253
+ }
254
+ return rows;
330
255
  };
331
256
 
332
257
  // src/effects/csv-parser.ts
@@ -386,15 +311,16 @@ import path3 from "path";
386
311
  // src/paths.ts
387
312
  import os2 from "os";
388
313
  import path2 from "path";
389
- var DATA_ROOT = path2.join(os2.homedir(), ".daeda-mcp");
390
- var PORTALS_DIR = path2.join(DATA_ROOT, "portals");
391
- var portalDir = (portalId) => path2.join(PORTALS_DIR, String(portalId));
314
+ var _dataRoot = path2.join(os2.homedir(), ".daeda-mcp");
315
+ var DATA_ROOT = () => _dataRoot;
316
+ var PORTALS_DIR = () => path2.join(_dataRoot, "portals");
317
+ var portalDir = (portalId) => path2.join(PORTALS_DIR(), String(portalId));
392
318
  var dbPath = (portalId) => path2.join(portalDir(portalId), "hubspot.duckdb");
393
- var stateFilePath = () => path2.join(DATA_ROOT, "client_state.json");
319
+ var stateFilePath = () => path2.join(_dataRoot, "client_state.json");
394
320
  var portalStatePath = (portalId) => path2.join(portalDir(portalId), "portal_state.json");
395
321
  var errorsDirPath = (portalId) => path2.join(portalDir(portalId), "errors");
396
- var CHARTS_DIR = path2.join(DATA_ROOT, "charts");
397
- var chartFilePath = (filename) => path2.join(CHARTS_DIR, filename);
322
+ var CHARTS_DIR = () => path2.join(_dataRoot, "charts");
323
+ var chartFilePath = (filename) => path2.join(CHARTS_DIR(), filename);
398
324
 
399
325
  // src/effects/save-error-csv.ts
400
326
  var logStderr = (message) => Effect2.sync(() => console.error(message));
@@ -1647,7 +1573,7 @@ var ConfigLive = Layer.succeed(
1647
1573
  ),
1648
1574
  save: (state) => Effect19.try({
1649
1575
  try: () => {
1650
- fs3.mkdirSync(DATA_ROOT, { recursive: true });
1576
+ fs3.mkdirSync(DATA_ROOT(), { recursive: true });
1651
1577
  fs3.writeFileSync(stateFilePath(), JSON.stringify(state, null, 2), "utf-8");
1652
1578
  },
1653
1579
  catch: (error) => new ConfigWriteError({ path: stateFilePath(), cause: error })
@@ -2332,103 +2258,113 @@ var PortalStateLive = Layer3.effect(
2332
2258
  );
2333
2259
 
2334
2260
  // src/layers/SyncLayerLive.ts
2335
- import { Effect as Effect24, Layer as Layer4, Ref as Ref3, pipe as pipe18 } from "effect";
2261
+ import { Effect as Effect24, Layer as Layer4, pipe as pipe18 } from "effect";
2336
2262
 
2337
2263
  // src/effects/run-full-sync.ts
2338
2264
  import { Effect as Effect22, Ref as Ref2, pipe as pipe16 } from "effect";
2339
2265
  var logStderr5 = (message) => Effect22.sync(() => console.error(message));
2340
- var runFullSync = (ws, db, portalState, portalId, artifacts, progressRef, objectTypesToSync) => pipe16(
2341
- Effect22.Do,
2342
- Effect22.let(
2343
- "objectTypesToSyncSet",
2344
- () => objectTypesToSync ? new Set(objectTypesToSync) : void 0
2345
- ),
2346
- Effect22.let(
2347
- "toSync",
2348
- ({ objectTypesToSyncSet }) => filterUnsyncedArtifacts(artifacts, objectTypesToSyncSet)
2349
- ),
2350
- Effect22.tap(
2351
- ({ toSync }) => Ref2.set(progressRef, {
2352
- totalArtifacts: toSync.length,
2353
- completedArtifacts: 0,
2354
- currentArtifact: null,
2355
- status: "syncing"
2356
- })
2357
- ),
2358
- Effect22.tap(() => db.initializeDb(portalId)),
2359
- Effect22.let("artifactCtx", () => ({
2360
- db,
2361
- ws,
2362
- portalState,
2363
- portalId
2364
- })),
2365
- Effect22.tap(
2366
- ({ toSync, artifactCtx }) => Effect22.forEach(
2367
- toSync,
2368
- (artifact, idx) => pipe16(
2369
- Ref2.update(progressRef, (p) => ({
2370
- ...p,
2371
- currentArtifact: artifact.object_type
2372
- })),
2373
- Effect22.tap(
2374
- () => logStderr5(
2375
- `[sync] (${idx + 1}/${toSync.length}) Processing: ${artifact.object_type}`
2376
- )
2377
- ),
2378
- Effect22.flatMap(() => {
2379
- const plugin = findArtifactPlugin(artifact.object_type);
2380
- if (!plugin) {
2381
- return logStderr5(`[sync] No artifact plugin found for ${artifact.object_type}, skipping`);
2382
- }
2383
- return plugin.processArtifact(artifactCtx, artifact);
2384
- }),
2385
- Effect22.tap(
2386
- () => Ref2.update(progressRef, (p) => ({
2387
- ...p,
2388
- completedArtifacts: p.completedArtifacts + 1
2389
- }))
2266
+ var runFullSync = (ws, db, portalState, portalId, artifacts, objectTypesToSync) => pipe16(
2267
+ Ref2.make({
2268
+ totalArtifacts: 0,
2269
+ completedArtifacts: 0,
2270
+ currentArtifact: null,
2271
+ status: "idle"
2272
+ }),
2273
+ Effect22.flatMap(
2274
+ (progressRef) => pipe16(
2275
+ Effect22.Do,
2276
+ Effect22.let(
2277
+ "objectTypesToSyncSet",
2278
+ () => objectTypesToSync ? new Set(objectTypesToSync) : void 0
2279
+ ),
2280
+ Effect22.let(
2281
+ "toSync",
2282
+ ({ objectTypesToSyncSet }) => filterUnsyncedArtifacts(artifacts, objectTypesToSyncSet)
2283
+ ),
2284
+ Effect22.tap(
2285
+ ({ toSync }) => Ref2.set(progressRef, {
2286
+ totalArtifacts: toSync.length,
2287
+ completedArtifacts: 0,
2288
+ currentArtifact: null,
2289
+ status: "syncing"
2290
+ })
2291
+ ),
2292
+ Effect22.tap(() => db.initializeDb(portalId)),
2293
+ Effect22.let("artifactCtx", () => ({
2294
+ db,
2295
+ ws,
2296
+ portalState,
2297
+ portalId
2298
+ })),
2299
+ Effect22.tap(
2300
+ ({ toSync, artifactCtx }) => Effect22.forEach(
2301
+ toSync,
2302
+ (artifact, idx) => pipe16(
2303
+ Ref2.update(progressRef, (p) => ({
2304
+ ...p,
2305
+ currentArtifact: artifact.object_type
2306
+ })),
2307
+ Effect22.tap(
2308
+ () => logStderr5(
2309
+ `[sync] (${idx + 1}/${toSync.length}) Processing: ${artifact.object_type}`
2310
+ )
2311
+ ),
2312
+ Effect22.flatMap(() => {
2313
+ const plugin = findArtifactPlugin(artifact.object_type);
2314
+ if (!plugin) {
2315
+ return logStderr5(`[sync] No artifact plugin found for ${artifact.object_type}, skipping`);
2316
+ }
2317
+ return plugin.processArtifact(artifactCtx, artifact);
2318
+ }),
2319
+ Effect22.tap(
2320
+ () => Ref2.update(progressRef, (p) => ({
2321
+ ...p,
2322
+ completedArtifacts: p.completedArtifacts + 1
2323
+ }))
2324
+ )
2325
+ ),
2326
+ { concurrency: 1 }
2390
2327
  )
2391
2328
  ),
2392
- { concurrency: 1 }
2329
+ Effect22.tap(
2330
+ () => db.setMetadata("last_synced", (/* @__PURE__ */ new Date()).toISOString())
2331
+ ),
2332
+ Effect22.tap(({ toSync }) => {
2333
+ const objectTypes = filterObjectTypes(toSync);
2334
+ return Effect22.forEach(
2335
+ objectTypes,
2336
+ (ot) => db.setMetadata(`last_synced:${ot}`, (/* @__PURE__ */ new Date()).toISOString()),
2337
+ { concurrency: "unbounded" }
2338
+ );
2339
+ }),
2340
+ Effect22.tap(
2341
+ () => db.setMetadata("portal_id", String(portalId))
2342
+ ),
2343
+ Effect22.tap(
2344
+ () => portalState.touchSyncedAt(portalId).pipe(Effect22.catchAll(() => Effect22.void))
2345
+ ),
2346
+ Effect22.tap(
2347
+ () => Ref2.update(progressRef, (p) => ({
2348
+ ...p,
2349
+ currentArtifact: null,
2350
+ status: "complete"
2351
+ }))
2352
+ ),
2353
+ Effect22.flatMap(() => Ref2.get(progressRef)),
2354
+ Effect22.tapError(
2355
+ (err) => Ref2.update(progressRef, (p) => ({
2356
+ ...p,
2357
+ status: "error",
2358
+ error: String(err)
2359
+ }))
2360
+ ),
2361
+ Effect22.mapError(
2362
+ (err) => new SyncError({
2363
+ message: `Full sync failed for portal ${portalId}`,
2364
+ cause: err
2365
+ })
2366
+ )
2393
2367
  )
2394
- ),
2395
- Effect22.tap(
2396
- () => db.setMetadata("last_synced", (/* @__PURE__ */ new Date()).toISOString())
2397
- ),
2398
- Effect22.tap(({ toSync }) => {
2399
- const objectTypes = filterObjectTypes(toSync);
2400
- return Effect22.forEach(
2401
- objectTypes,
2402
- (ot) => db.setMetadata(`last_synced:${ot}`, (/* @__PURE__ */ new Date()).toISOString()),
2403
- { concurrency: "unbounded" }
2404
- );
2405
- }),
2406
- Effect22.tap(
2407
- () => db.setMetadata("portal_id", String(portalId))
2408
- ),
2409
- Effect22.tap(
2410
- () => portalState.touchSyncedAt(portalId).pipe(Effect22.catchAll(() => Effect22.void))
2411
- ),
2412
- Effect22.tap(
2413
- () => Ref2.update(progressRef, (p) => ({
2414
- ...p,
2415
- currentArtifact: null,
2416
- status: "complete"
2417
- }))
2418
- ),
2419
- Effect22.flatMap(() => Ref2.get(progressRef)),
2420
- Effect22.tapError(
2421
- (err) => Ref2.update(progressRef, (p) => ({
2422
- ...p,
2423
- status: "error",
2424
- error: String(err)
2425
- }))
2426
- ),
2427
- Effect22.mapError(
2428
- (err) => new SyncError({
2429
- message: `Full sync failed for portal ${portalId}`,
2430
- cause: err
2431
- })
2432
2368
  )
2433
2369
  );
2434
2370
 
@@ -2591,14 +2527,8 @@ var SyncLive = Layer4.effect(
2591
2527
  const ws = yield* WebSocketService;
2592
2528
  const db = yield* DatabaseService;
2593
2529
  const portalState = yield* PortalStateService;
2594
- const progressRef = yield* Ref3.make({
2595
- totalArtifacts: 0,
2596
- completedArtifacts: 0,
2597
- currentArtifact: null,
2598
- status: "idle"
2599
- });
2600
2530
  return {
2601
- syncArtifacts: (portalId, artifacts, objectTypesToSync) => runFullSync(ws, db, portalState, portalId, artifacts, progressRef, objectTypesToSync),
2531
+ syncArtifacts: (portalId, artifacts, objectTypesToSync) => runFullSync(ws, db, portalState, portalId, artifacts, objectTypesToSync),
2602
2532
  syncMessagePlugins: (portalId) => pipe18(
2603
2533
  syncAllMessagePlugins(ws, db, portalState, portalId),
2604
2534
  Effect24.mapError(
@@ -2607,66 +2537,52 @@ var SyncLive = Layer4.effect(
2607
2537
  cause: err
2608
2538
  })
2609
2539
  )
2610
- ),
2611
- getProgress: () => Ref3.get(progressRef)
2540
+ )
2612
2541
  };
2613
2542
  })
2614
2543
  );
2615
2544
 
2616
2545
  // src/layers/WebSocketLayerLive.ts
2617
- import { Effect as Effect36, Layer as Layer5, Match, Ref as Ref15, Schedule, Duration, pipe as pipe30 } from "effect";
2546
+ import { Effect as Effect36, Layer as Layer5, Match, Ref as Ref14, Schedule, Duration, pipe as pipe30 } from "effect";
2618
2547
 
2619
2548
  // src/handlers/handle-register-response.ts
2620
- import { Effect as Effect25, Ref as Ref4, pipe as pipe19 } from "effect";
2549
+ import { Effect as Effect25, Ref as Ref3, pipe as pipe19 } from "effect";
2621
2550
  var handleRegisterResponse = (payload, ctx) => !payload.success || !payload.target_portals ? Effect25.sync(
2622
2551
  () => console.error(`Registration failed: ${payload.error ?? "unknown"}`)
2623
2552
  ) : pipe19(
2624
- Ref4.set(ctx.portalsRef, payload.target_portals),
2553
+ Ref3.set(ctx.portalsRef, payload.target_portals),
2625
2554
  Effect25.flatMap(
2626
- () => payload.encryption_key ? Ref4.set(ctx.encryptionKeyRef, payload.encryption_key) : Effect25.void
2555
+ () => payload.encryption_key ? Ref3.set(ctx.encryptionKeyRef, payload.encryption_key) : Effect25.void
2627
2556
  ),
2628
- Effect25.flatMap(() => Ref4.get(ctx.handlersRef)),
2557
+ Effect25.flatMap(() => Ref3.get(ctx.handlersRef)),
2629
2558
  Effect25.flatMap(
2630
2559
  (handlers) => Effect25.sync(() => {
2631
2560
  const portals = payload.target_portals;
2632
- const selectedPortalId = handlers?.autoSelectSinglePortal(portals) ?? null;
2633
- if (selectedPortalId !== null) {
2634
- Effect25.runFork(
2635
- pipe19(
2636
- Ref4.set(ctx.currentSyncPortalRef, selectedPortalId),
2637
- Effect25.flatMap(() => ctx.requireWs),
2638
- Effect25.flatMap((ws) => ctx.sendJson(ws, "sync:full", { target_portal: selectedPortalId })),
2639
- Effect25.catchAll(() => Effect25.void)
2640
- )
2641
- );
2642
- } else {
2643
- handlers?.onRegisterSuccess(portals);
2644
- }
2561
+ handlers?.autoSelectSinglePortal(portals);
2562
+ handlers?.onRegisterSuccess(portals);
2645
2563
  })
2646
2564
  )
2647
2565
  );
2648
2566
 
2649
2567
  // src/handlers/handle-sync-full-response.ts
2650
- import { Effect as Effect26, Ref as Ref5, pipe as pipe20 } from "effect";
2568
+ import { Effect as Effect26, Ref as Ref4, pipe as pipe20 } from "effect";
2651
2569
  var handleSyncFullResponse = (payload, ctx) => !payload.success ? Effect26.sync(() => console.error(`Sync failed: ${payload.error ?? "unknown"}`)) : pipe20(
2652
2570
  Effect26.sync(() => console.error(`[sync:full] Received ${payload.artifacts.length} artifacts`)),
2653
2571
  Effect26.flatMap(() => {
2654
2572
  const artifacts = payload.artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
2655
2573
  return pipe20(
2656
- Ref5.get(ctx.currentSyncPortalRef),
2657
- Effect26.flatMap((portalId) => Ref5.get(ctx.handlersRef).pipe(
2658
- Effect26.flatMap(
2659
- (handlers) => portalId !== null && handlers?.onSyncArtifacts ? Effect26.sync(() => handlers.onSyncArtifacts(portalId, artifacts)) : Effect26.void
2660
- )
2661
- ))
2574
+ Ref4.get(ctx.handlersRef),
2575
+ Effect26.flatMap(
2576
+ (handlers) => handlers?.onSyncArtifacts ? Effect26.sync(() => handlers.onSyncArtifacts(payload.target_portal, artifacts)) : Effect26.void
2577
+ )
2662
2578
  );
2663
2579
  })
2664
2580
  );
2665
2581
 
2666
2582
  // src/handlers/handle-sync-artifact-response.ts
2667
- import { Effect as Effect27, Ref as Ref6, pipe as pipe21 } from "effect";
2583
+ import { Effect as Effect27, Ref as Ref5, pipe as pipe21 } from "effect";
2668
2584
  var handleSyncArtifactResponse = (payload, ctx) => pipe21(
2669
- Ref6.getAndSet(ctx.pendingArtifactRef, null),
2585
+ Ref5.getAndSet(ctx.pendingArtifactRef, null),
2670
2586
  Effect27.flatMap(
2671
2587
  (pending) => pending !== null ? Effect27.sync(() => pending.resolve(payload)) : Effect27.void
2672
2588
  ),
@@ -2678,21 +2594,21 @@ var handleSyncArtifactResponse = (payload, ctx) => pipe21(
2678
2594
  );
2679
2595
 
2680
2596
  // src/handlers/handle-new-artifacts.ts
2681
- import { Effect as Effect28, Ref as Ref7, pipe as pipe22 } from "effect";
2597
+ import { Effect as Effect28, Ref as Ref6, pipe as pipe22 } from "effect";
2682
2598
  var handleNewArtifacts = (payload, ctx) => pipe22(
2683
2599
  Effect28.sync(
2684
2600
  () => console.error(
2685
2601
  `[sync:new-artifacts] Received ${payload.artifacts.length} new artifact(s) for portal ${payload.target_portal}: ${payload.artifacts.map((a) => a.object_type).join(", ")}`
2686
2602
  )
2687
2603
  ),
2688
- Effect28.flatMap(() => Ref7.get(ctx.handlersRef)),
2604
+ Effect28.flatMap(() => Ref6.get(ctx.handlersRef)),
2689
2605
  Effect28.flatMap(
2690
2606
  (handlers) => handlers?.onNewArtifacts ? Effect28.sync(() => handlers.onNewArtifacts(payload.target_portal, payload.artifacts)) : Effect28.void
2691
2607
  )
2692
2608
  );
2693
2609
 
2694
2610
  // src/handlers/handle-diff-batch.ts
2695
- import { Effect as Effect29, Ref as Ref8, pipe as pipe23 } from "effect";
2611
+ import { Effect as Effect29, Ref as Ref7, pipe as pipe23 } from "effect";
2696
2612
  var handleDiffBatch = (payload, ctx) => !payload.success ? Effect29.sync(
2697
2613
  () => console.error(`Diff batch failed: ${payload.error ?? "unknown"}`)
2698
2614
  ) : pipe23(
@@ -2701,46 +2617,42 @@ var handleDiffBatch = (payload, ctx) => !payload.success ? Effect29.sync(
2701
2617
  `[sync:diff:batch] ${payload.object_type}: ${payload.records.length} records, ${payload.associations.length} associations (server_time: ${payload.server_time})${payload.truncated ? " (TRUNCATED)" : ""}`
2702
2618
  )
2703
2619
  ),
2704
- Effect29.flatMap(() => Ref8.get(ctx.currentSyncPortalRef)),
2705
- Effect29.flatMap((portalId) => Ref8.get(ctx.handlersRef).pipe(
2706
- Effect29.flatMap(
2707
- (handlers) => portalId !== null && handlers?.onDiffBatch ? Effect29.sync(() => handlers.onDiffBatch(portalId, payload)) : Effect29.void
2708
- )
2709
- ))
2620
+ Effect29.flatMap(() => Ref7.get(ctx.handlersRef)),
2621
+ Effect29.flatMap(
2622
+ (handlers) => handlers?.onDiffBatch ? Effect29.sync(() => handlers.onDiffBatch(payload.target_portal, payload)) : Effect29.void
2623
+ )
2710
2624
  );
2711
2625
 
2712
2626
  // src/handlers/handle-diff-complete.ts
2713
- import { Effect as Effect30, Ref as Ref9, pipe as pipe24 } from "effect";
2627
+ import { Effect as Effect30, Ref as Ref8, pipe as pipe24 } from "effect";
2714
2628
  var handleDiffComplete = (payload, ctx) => pipe24(
2715
2629
  Effect30.sync(
2716
2630
  () => console.error(
2717
2631
  `[sync:diff:complete] ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations`
2718
2632
  )
2719
2633
  ),
2720
- Effect30.flatMap(() => Ref9.get(ctx.currentSyncPortalRef)),
2721
- Effect30.flatMap((portalId) => Ref9.get(ctx.handlersRef).pipe(
2722
- Effect30.flatMap(
2723
- (handlers) => portalId !== null && handlers?.onDiffComplete ? Effect30.sync(() => handlers.onDiffComplete(portalId, payload)) : Effect30.void
2724
- )
2725
- ))
2634
+ Effect30.flatMap(() => Ref8.get(ctx.handlersRef)),
2635
+ Effect30.flatMap(
2636
+ (handlers) => handlers?.onDiffComplete ? Effect30.sync(() => handlers.onDiffComplete(payload.target_portal, payload)) : Effect30.void
2637
+ )
2726
2638
  );
2727
2639
 
2728
2640
  // src/handlers/handle-subscribe-ack.ts
2729
- import { Effect as Effect31, Ref as Ref10, pipe as pipe25 } from "effect";
2641
+ import { Effect as Effect31, Ref as Ref9, pipe as pipe25 } from "effect";
2730
2642
  var handleSubscribeAck = (payload, ctx) => pipe25(
2731
2643
  Effect31.sync(
2732
2644
  () => console.error(
2733
2645
  `[sync:subscribe:ack] Portal ${payload.target_portal}: ${payload.success ? "subscribed" : payload.error}`
2734
2646
  )
2735
2647
  ),
2736
- Effect31.flatMap(() => Ref10.get(ctx.handlersRef)),
2648
+ Effect31.flatMap(() => Ref9.get(ctx.handlersRef)),
2737
2649
  Effect31.flatMap(
2738
2650
  (handlers) => handlers?.onSubscribeAck ? Effect31.sync(() => handlers.onSubscribeAck(payload)) : Effect31.void
2739
2651
  )
2740
2652
  );
2741
2653
 
2742
2654
  // src/handlers/handle-sync-update.ts
2743
- import { Effect as Effect32, Ref as Ref11, pipe as pipe26 } from "effect";
2655
+ import { Effect as Effect32, Ref as Ref10, pipe as pipe26 } from "effect";
2744
2656
  var handleSyncUpdate = (payload, ctx) => !payload.success ? Effect32.sync(
2745
2657
  () => console.error(`Sync update failed: ${payload.error ?? "unknown"}`)
2746
2658
  ) : pipe26(
@@ -2749,36 +2661,34 @@ var handleSyncUpdate = (payload, ctx) => !payload.success ? Effect32.sync(
2749
2661
  `[sync:update] ${payload.object_type}: ${payload.records.length} records, ${payload.associations.length} associations (server_time: ${payload.server_time})`
2750
2662
  )
2751
2663
  ),
2752
- Effect32.flatMap(() => Ref11.get(ctx.currentSyncPortalRef)),
2753
- Effect32.flatMap((portalId) => Ref11.get(ctx.handlersRef).pipe(
2754
- Effect32.flatMap(
2755
- (handlers) => portalId !== null && handlers?.onDiffBatch ? Effect32.sync(() => handlers.onDiffBatch(portalId, payload)) : Effect32.void
2756
- )
2757
- ))
2664
+ Effect32.flatMap(() => Ref10.get(ctx.handlersRef)),
2665
+ Effect32.flatMap(
2666
+ (handlers) => handlers?.onDiffBatch ? Effect32.sync(() => handlers.onDiffBatch(payload.target_portal, payload)) : Effect32.void
2667
+ )
2758
2668
  );
2759
2669
 
2760
2670
  // src/handlers/handle-plan-create-response.ts
2761
- import { Effect as Effect33, Ref as Ref12, pipe as pipe27 } from "effect";
2671
+ import { Effect as Effect33, Ref as Ref11, pipe as pipe27 } from "effect";
2762
2672
  var handlePlanCreateResponse = (payload, ctx) => pipe27(
2763
- Ref12.getAndSet(ctx.pendingPlanCreateRef, null),
2673
+ Ref11.getAndSet(ctx.pendingPlanCreateRef, null),
2764
2674
  Effect33.flatMap(
2765
2675
  (pending) => pending ? Effect33.sync(() => pending.resolve(payload)) : Effect33.sync(() => console.error("[plan:create] No pending request for response"))
2766
2676
  )
2767
2677
  );
2768
2678
 
2769
2679
  // src/handlers/handle-plan-list-response.ts
2770
- import { Effect as Effect34, Ref as Ref13, pipe as pipe28 } from "effect";
2680
+ import { Effect as Effect34, Ref as Ref12, pipe as pipe28 } from "effect";
2771
2681
  var handlePlanListResponse = (payload, ctx) => pipe28(
2772
- Ref13.getAndSet(ctx.pendingPlanListRef, null),
2682
+ Ref12.getAndSet(ctx.pendingPlanListRef, null),
2773
2683
  Effect34.flatMap(
2774
2684
  (pending) => pending ? Effect34.sync(() => pending.resolve(payload)) : Effect34.sync(() => console.error("[plan:list] No pending request for response"))
2775
2685
  )
2776
2686
  );
2777
2687
 
2778
2688
  // src/handlers/handle-plugin-data-response.ts
2779
- import { Effect as Effect35, Ref as Ref14, pipe as pipe29 } from "effect";
2689
+ import { Effect as Effect35, Ref as Ref13, pipe as pipe29 } from "effect";
2780
2690
  var handlePluginDataResponse = (payload, ctx) => pipe29(
2781
- Ref14.modify(ctx.pendingPluginRequestsRef, (pendingMap) => {
2691
+ Ref13.modify(ctx.pendingPluginRequestsRef, (pendingMap) => {
2782
2692
  const next = new Map(pendingMap);
2783
2693
  const pending = next.get(payload.request_id);
2784
2694
  next.delete(payload.request_id);
@@ -2812,20 +2722,18 @@ var WS_URL = process.env.websocket_url ?? "wss://mcp-ws.daeda.tech";
2812
2722
  var WebSocketLive = Layer5.effect(
2813
2723
  WebSocketService,
2814
2724
  Effect36.gen(function* () {
2815
- const stateRef = yield* Ref15.make("disconnected");
2816
- const portalsRef = yield* Ref15.make([]);
2817
- const wsRef = yield* Ref15.make(null);
2818
- const syncedArtifactsRef = yield* Ref15.make(null);
2819
- const isReconnectingRef = yield* Ref15.make(false);
2820
- const currentSyncPortalRef = yield* Ref15.make(null);
2821
- const pendingArtifactRef = yield* Ref15.make(null);
2822
- const pendingPluginRequestsRef = yield* Ref15.make(/* @__PURE__ */ new Map());
2823
- const pendingPlanCreateRef = yield* Ref15.make(null);
2824
- const pendingPlanListRef = yield* Ref15.make(null);
2825
- const handlersRef = yield* Ref15.make(null);
2826
- const encryptionKeyRef = yield* Ref15.make(null);
2725
+ const stateRef = yield* Ref14.make("disconnected");
2726
+ const portalsRef = yield* Ref14.make([]);
2727
+ const wsRef = yield* Ref14.make(null);
2728
+ const isReconnectingRef = yield* Ref14.make(false);
2729
+ const pendingArtifactRef = yield* Ref14.make(null);
2730
+ const pendingPluginRequestsRef = yield* Ref14.make(/* @__PURE__ */ new Map());
2731
+ const pendingPlanCreateRef = yield* Ref14.make(null);
2732
+ const pendingPlanListRef = yield* Ref14.make(null);
2733
+ const handlersRef = yield* Ref14.make(null);
2734
+ const encryptionKeyRef = yield* Ref14.make(null);
2827
2735
  const requireWs = pipe30(
2828
- Ref15.get(wsRef),
2736
+ Ref14.get(wsRef),
2829
2737
  Effect36.flatMap(
2830
2738
  (ws) => ws === null ? Effect36.fail(new WebSocketError({ url: WS_URL, cause: new Error("WebSocket not connected") })) : Effect36.succeed(ws)
2831
2739
  )
@@ -2833,8 +2741,6 @@ var WebSocketLive = Layer5.effect(
2833
2741
  const sendJson = (ws, type, payload) => Effect36.sync(() => ws.send(JSON.stringify({ type, payload })));
2834
2742
  const ctx = {
2835
2743
  portalsRef,
2836
- syncedArtifactsRef,
2837
- currentSyncPortalRef,
2838
2744
  pendingArtifactRef,
2839
2745
  pendingPluginRequestsRef,
2840
2746
  pendingPlanCreateRef,
@@ -2875,13 +2781,13 @@ var WebSocketLive = Layer5.effect(
2875
2781
  });
2876
2782
  const wireCloseHandler = (ws) => Effect36.sync(() => {
2877
2783
  ws.onclose = () => {
2878
- const pending = Effect36.runSync(Ref15.getAndSet(pendingArtifactRef, null));
2784
+ const pending = Effect36.runSync(Ref14.getAndSet(pendingArtifactRef, null));
2879
2785
  if (pending) {
2880
2786
  pending.reject(new WebSocketError({ url: WS_URL, cause: new Error("WebSocket closed") }));
2881
2787
  }
2882
2788
  pipe30(
2883
- Ref15.set(stateRef, "disconnected"),
2884
- Effect36.flatMap(() => Ref15.set(wsRef, null)),
2789
+ Ref14.set(stateRef, "disconnected"),
2790
+ Effect36.flatMap(() => Ref14.set(wsRef, null)),
2885
2791
  Effect36.flatMap(() => scheduleReconnect()),
2886
2792
  Effect36.runFork
2887
2793
  );
@@ -2889,7 +2795,7 @@ var WebSocketLive = Layer5.effect(
2889
2795
  });
2890
2796
  const connectInternal = () => pipe30(
2891
2797
  Effect36.sync(() => new WebSocket(WS_URL)),
2892
- Effect36.tap((ws) => Ref15.set(wsRef, ws)),
2798
+ Effect36.tap((ws) => Ref14.set(wsRef, ws)),
2893
2799
  Effect36.tap((ws) => wireMessageHandler(ws)),
2894
2800
  Effect36.tap((ws) => wireCloseHandler(ws)),
2895
2801
  Effect36.flatMap(
@@ -2897,8 +2803,8 @@ var WebSocketLive = Layer5.effect(
2897
2803
  ws.onopen = () => {
2898
2804
  console.error(`[ws] Connected to ${WS_URL}, sending register`);
2899
2805
  pipe30(
2900
- Ref15.set(stateRef, "connected"),
2901
- Effect36.flatMap(() => Ref15.set(isReconnectingRef, false)),
2806
+ Ref14.set(stateRef, "connected"),
2807
+ Effect36.flatMap(() => Ref14.set(isReconnectingRef, false)),
2902
2808
  Effect36.flatMap(() => sendJson(ws, "register", { license_key: LICENSE_KEY })),
2903
2809
  Effect36.runFork
2904
2810
  );
@@ -2911,7 +2817,7 @@ var WebSocketLive = Layer5.effect(
2911
2817
  )
2912
2818
  );
2913
2819
  const scheduleReconnect = () => pipe30(
2914
- Ref15.getAndSet(isReconnectingRef, true),
2820
+ Ref14.getAndSet(isReconnectingRef, true),
2915
2821
  Effect36.flatMap(
2916
2822
  (wasAlready) => wasAlready ? Effect36.void : pipe30(
2917
2823
  Effect36.sync(() => console.error(`[ws] Scheduling reconnect to ${WS_URL}`)),
@@ -2926,21 +2832,19 @@ var WebSocketLive = Layer5.effect(
2926
2832
  )
2927
2833
  );
2928
2834
  const connect = () => pipe30(
2929
- Ref15.getAndSet(isReconnectingRef, true),
2835
+ Ref14.getAndSet(isReconnectingRef, true),
2930
2836
  Effect36.flatMap(
2931
2837
  (wasAlready) => wasAlready ? Effect36.void : pipe30(connectInternal(), Effect36.retry(Schedule.fixed(Duration.seconds(3))))
2932
2838
  )
2933
2839
  );
2934
2840
  const send = (data) => pipe30(requireWs, Effect36.flatMap((ws) => Effect36.sync(() => ws.send(JSON.stringify(data)))));
2935
2841
  const sendSyncFull = (portalId) => pipe30(
2936
- Ref15.set(currentSyncPortalRef, portalId),
2937
- Effect36.flatMap(() => requireWs),
2842
+ requireWs,
2938
2843
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:full] Sending request for portal ${portalId}`))),
2939
2844
  Effect36.flatMap((ws) => sendJson(ws, "sync:full", { target_portal: portalId }))
2940
2845
  );
2941
2846
  const sendSyncDiff = (portalId, lastSyncTimes) => pipe30(
2942
- Ref15.set(currentSyncPortalRef, portalId),
2943
- Effect36.flatMap(() => requireWs),
2847
+ requireWs,
2944
2848
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:diff] Sending request for portal ${portalId}`))),
2945
2849
  Effect36.flatMap((ws) => sendJson(ws, "sync:diff", { target_portal: portalId, last_sync_times: lastSyncTimes }))
2946
2850
  );
@@ -2949,17 +2853,21 @@ var WebSocketLive = Layer5.effect(
2949
2853
  Effect36.tap(() => Effect36.sync(() => console.error(`[sync:subscribe] Sending request for portal ${portalId}`))),
2950
2854
  Effect36.flatMap((ws) => sendJson(ws, "sync:subscribe", { target_portal: portalId }))
2951
2855
  );
2856
+ const sendSyncUnsubscribe = (portalId) => pipe30(
2857
+ requireWs,
2858
+ Effect36.tap(() => Effect36.sync(() => console.error(`[sync:unsubscribe] Sending request for portal ${portalId}`))),
2859
+ Effect36.flatMap((ws) => sendJson(ws, "sync:unsubscribe", { target_portal: portalId }))
2860
+ );
2952
2861
  return {
2953
2862
  connect,
2954
2863
  send,
2955
2864
  sendSyncFull,
2956
2865
  sendSyncDiff,
2957
2866
  sendSyncSubscribe,
2958
- getState: () => Ref15.get(stateRef),
2959
- getPortals: () => Ref15.get(portalsRef),
2960
- setPortals: (portals) => Ref15.set(portalsRef, portals),
2961
- getSyncedArtifacts: () => Ref15.get(syncedArtifactsRef),
2962
- setSyncedArtifacts: (syncedArtifacts) => Ref15.set(syncedArtifactsRef, syncedArtifacts),
2867
+ sendSyncUnsubscribe,
2868
+ getState: () => Ref14.get(stateRef),
2869
+ getPortals: () => Ref14.get(portalsRef),
2870
+ setPortals: (portals) => Ref14.set(portalsRef, portals),
2963
2871
  requestArtifactUrl: (artifactId) => pipe30(
2964
2872
  requireWs,
2965
2873
  Effect36.flatMap(
@@ -2968,7 +2876,7 @@ var WebSocketLive = Layer5.effect(
2968
2876
  resolve: (payload) => resume(Effect36.succeed(payload)),
2969
2877
  reject: (error) => resume(Effect36.fail(error))
2970
2878
  };
2971
- Effect36.runSync(Ref15.set(pendingArtifactRef, pending));
2879
+ Effect36.runSync(Ref14.set(pendingArtifactRef, pending));
2972
2880
  ws.send(JSON.stringify({ type: "sync:artifact", payload: { artifact_id: artifactId } }));
2973
2881
  })
2974
2882
  ),
@@ -2987,10 +2895,10 @@ var WebSocketLive = Layer5.effect(
2987
2895
  resolve: (payload) => resume(Effect36.succeed(payload)),
2988
2896
  reject: (error) => resume(Effect36.fail(error))
2989
2897
  };
2990
- const pendingMap = Effect36.runSync(Ref15.get(pendingPluginRequestsRef));
2898
+ const pendingMap = Effect36.runSync(Ref14.get(pendingPluginRequestsRef));
2991
2899
  const next = new Map(pendingMap);
2992
2900
  next.set(requestId, pending);
2993
- Effect36.runSync(Ref15.set(pendingPluginRequestsRef, next));
2901
+ Effect36.runSync(Ref14.set(pendingPluginRequestsRef, next));
2994
2902
  ws.send(JSON.stringify({
2995
2903
  type: "sync:plugin-data",
2996
2904
  payload: {
@@ -3008,7 +2916,7 @@ var WebSocketLive = Layer5.effect(
3008
2916
  })
3009
2917
  }),
3010
2918
  Effect36.tapError(
3011
- () => Ref15.update(pendingPluginRequestsRef, (pendingMap) => {
2919
+ () => Ref14.update(pendingPluginRequestsRef, (pendingMap) => {
3012
2920
  const next = new Map(pendingMap);
3013
2921
  next.delete(requestId);
3014
2922
  return next;
@@ -3025,7 +2933,7 @@ var WebSocketLive = Layer5.effect(
3025
2933
  resolve: (payload) => resume(Effect36.succeed(payload)),
3026
2934
  reject: (error) => resume(Effect36.fail(error))
3027
2935
  };
3028
- Effect36.runSync(Ref15.set(pendingPlanCreateRef, pending));
2936
+ Effect36.runSync(Ref14.set(pendingPlanCreateRef, pending));
3029
2937
  ws.send(JSON.stringify({ type: "plan:create", payload: { target_portal: portalId, ...plan.dry_run ? { dry_run: true } : {}, plan } }));
3030
2938
  })
3031
2939
  ),
@@ -3042,7 +2950,7 @@ var WebSocketLive = Layer5.effect(
3042
2950
  resolve: (payload) => resume(Effect36.succeed(payload)),
3043
2951
  reject: (error) => resume(Effect36.fail(error))
3044
2952
  };
3045
- Effect36.runSync(Ref15.set(pendingPlanListRef, pending));
2953
+ Effect36.runSync(Ref14.set(pendingPlanListRef, pending));
3046
2954
  ws.send(JSON.stringify({ type: "plan:list", payload: { target_portal: portalId, ...status && { status } } }));
3047
2955
  })
3048
2956
  ),
@@ -3051,89 +2959,33 @@ var WebSocketLive = Layer5.effect(
3051
2959
  onTimeout: () => new WebSocketError({ url: WS_URL, cause: new Error("Plan list request timed out") })
3052
2960
  })
3053
2961
  ),
3054
- registerHandlers: (handlers) => Ref15.set(handlersRef, handlers),
3055
- getEncryptionKey: () => Ref15.get(encryptionKeyRef)
2962
+ registerHandlers: (handlers) => Ref14.set(handlersRef, handlers),
2963
+ getEncryptionKey: () => Ref14.get(encryptionKeyRef)
3056
2964
  };
3057
2965
  })
3058
2966
  );
3059
2967
 
3060
2968
  // src/tools/portal_selection.ts
3061
2969
  import { z as z12 } from "zod";
3062
- import { Effect as Effect38, pipe as pipe32 } from "effect";
3063
-
3064
- // src/effects/read-connection.ts
3065
2970
  import { Effect as Effect37, pipe as pipe31 } from "effect";
3066
- import { DuckDBInstance as DuckDBInstance2 } from "@duckdb/node-api";
3067
- import fs6 from "fs";
3068
- var handles = /* @__PURE__ */ new Map();
3069
- var getReadConnection = (portalId, encryptionKey) => pipe31(
3070
- Effect37.sync(() => handles.get(portalId)),
3071
- Effect37.flatMap(
3072
- (existing) => existing ? Effect37.succeed(existing.conn) : pipe31(
3073
- Effect37.sync(() => dbPath(portalId)),
3074
- Effect37.filterOrFail(
3075
- (dbFile) => fs6.existsSync(dbFile),
3076
- () => new DatabaseError({
3077
- message: `Database not found for portal ${portalId}. Has it been synced?`
3078
- })
3079
- ),
3080
- Effect37.flatMap(
3081
- (dbFile) => Effect37.tryPromise({
3082
- try: async () => {
3083
- const instance = await DuckDBInstance2.create(":memory:");
3084
- const conn = await instance.connect();
3085
- const escapedPath = dbFile.replace(/'/g, "''");
3086
- if (encryptionKey) {
3087
- const escapedKey = encryptionKey.replace(/'/g, "''");
3088
- await conn.run(`ATTACH '${escapedPath}' AS portal_db (ENCRYPTION_KEY '${escapedKey}', READ_ONLY)`);
3089
- } else {
3090
- await conn.run(`ATTACH '${escapedPath}' AS portal_db (READ_ONLY)`);
3091
- }
3092
- await conn.run(`USE portal_db`);
3093
- handles.set(portalId, { conn, instance });
3094
- return conn;
3095
- },
3096
- catch: (e) => new DatabaseError({
3097
- message: `Failed to open read connection for portal ${portalId}`,
3098
- cause: e
3099
- })
3100
- })
3101
- )
3102
- )
3103
- )
3104
- );
3105
- var closeAllReadConnections = () => Effect37.sync(() => {
3106
- for (const [portalId] of handles) {
3107
- const handle = handles.get(portalId);
3108
- if (!handle) continue;
3109
- try {
3110
- handle.conn.closeSync();
3111
- handle.instance.closeSync();
3112
- } catch {
3113
- }
3114
- handles.delete(portalId);
3115
- }
3116
- });
3117
-
3118
- // src/tools/portal_selection.ts
3119
2971
  function getSelectedPortalId(config) {
3120
- return Effect38.runSync(
3121
- config.load().pipe(Effect38.map((state) => state.selectedPortalId))
2972
+ return Effect37.runSync(
2973
+ config.load().pipe(Effect37.map((state) => state.selectedPortalId))
3122
2974
  );
3123
2975
  }
3124
2976
  function setSelectedPortalId(config, portalId) {
3125
- Effect38.runSync(config.save({ selectedPortalId: portalId }));
2977
+ Effect37.runSync(config.save({ selectedPortalId: portalId }));
3126
2978
  }
3127
2979
  function registerPortalSelection(server2, deps) {
3128
- const { ws, config } = deps;
2980
+ const { ws, config, handoffSelectedPortal } = deps;
3129
2981
  server2.registerPrompt(
3130
2982
  "select-portal",
3131
2983
  {
3132
2984
  description: "Show available HubSpot portals and select one to work with"
3133
2985
  },
3134
2986
  async () => {
3135
- const targetPortals = Effect38.runSync(ws.getPortals());
3136
- const selectedPortalId = getSelectedPortalId(config);
2987
+ const targetPortals = Effect37.runSync(ws.getPortals());
2988
+ const selectedPortalId2 = getSelectedPortalId(config);
3137
2989
  if (targetPortals.length === 0) {
3138
2990
  return {
3139
2991
  messages: [{
@@ -3142,7 +2994,7 @@ function registerPortalSelection(server2, deps) {
3142
2994
  }]
3143
2995
  };
3144
2996
  }
3145
- const currentSelection = selectedPortalId ? targetPortals.find((p) => p.target_portal === selectedPortalId) : null;
2997
+ const currentSelection = selectedPortalId2 ? targetPortals.find((p) => p.target_portal === selectedPortalId2) : null;
3146
2998
  const portalList = targetPortals.map((p) => `- **${p.target_name ?? "Unnamed"}** (ID: ${p.target_portal})`).join("\n");
3147
2999
  const statusText = currentSelection ? `Currently selected: **${currentSelection.target_name ?? "Unnamed"}** (${currentSelection.target_portal})` : "No portal currently selected.";
3148
3000
  return {
@@ -3165,13 +3017,15 @@ Which portal would you like to use? Use the \`set_portal\` tool to select one.`
3165
3017
  server2.registerTool(
3166
3018
  "set_portal",
3167
3019
  {
3168
- description: "Select which HubSpot portal (account) to work with. This must be set before running any queries or creating plans.",
3020
+ description: `Select which HubSpot portal (account) to work with. Sets the default portal used by tools when portalIds is not explicitly provided. This also triggers a full artifact sync and real-time subscription for the selected portal.
3021
+
3022
+ Other tools accept an optional portalIds parameter to target specific portals regardless of this selection. All registered portals are kept warm with background data syncing.`,
3169
3023
  inputSchema: {
3170
3024
  portalId: z12.number().describe("The portal ID to select")
3171
3025
  }
3172
3026
  },
3173
3027
  async ({ portalId }) => {
3174
- const targetPortals = Effect38.runSync(ws.getPortals());
3028
+ const targetPortals = Effect37.runSync(ws.getPortals());
3175
3029
  const portal = targetPortals.find((p) => p.target_portal === portalId);
3176
3030
  if (!portal) {
3177
3031
  const available = targetPortals.map((p) => p.target_portal).join(", ");
@@ -3183,9 +3037,12 @@ Which portal would you like to use? Use the \`set_portal\` tool to select one.`
3183
3037
  isError: true
3184
3038
  };
3185
3039
  }
3186
- Effect38.runSync(closeAllReadConnections());
3187
- setSelectedPortalId(config, portalId);
3188
- Effect38.runFork(pipe32(ws.sendSyncFull(portalId), Effect38.catchAll(() => Effect38.void)));
3040
+ if (handoffSelectedPortal) {
3041
+ handoffSelectedPortal(portalId);
3042
+ } else {
3043
+ setSelectedPortalId(config, portalId);
3044
+ Effect37.runFork(pipe31(ws.sendSyncFull(portalId), Effect37.catchAll(() => Effect37.void)));
3045
+ }
3189
3046
  return {
3190
3047
  content: [{
3191
3048
  type: "text",
@@ -3208,8 +3065,139 @@ function autoSelectSinglePortal(config, portals) {
3208
3065
  // src/tools/query.ts
3209
3066
  import { z as z13 } from "zod";
3210
3067
  import { Effect as Effect39 } from "effect";
3068
+
3069
+ // src/effects/read-connection.ts
3070
+ import { Effect as Effect38, pipe as pipe32 } from "effect";
3071
+ import { DuckDBInstance as DuckDBInstance2 } from "@duckdb/node-api";
3072
+ import fs6 from "fs";
3073
+ var handles = /* @__PURE__ */ new Map();
3074
+ var closeHandle = (portalId) => {
3075
+ const handle = handles.get(portalId);
3076
+ if (!handle) return;
3077
+ try {
3078
+ handle.conn.closeSync();
3079
+ handle.instance.closeSync();
3080
+ } catch {
3081
+ }
3082
+ handles.delete(portalId);
3083
+ };
3084
+ var getFreshnessToken = (portalId, dbFile) => Effect38.try({
3085
+ try: () => {
3086
+ const stat = fs6.statSync(dbFile);
3087
+ return `${stat.mtimeMs}:${stat.size}`;
3088
+ },
3089
+ catch: (e) => new DatabaseError({
3090
+ message: `Failed to read database freshness for portal ${portalId}`,
3091
+ cause: e
3092
+ })
3093
+ });
3094
+ var openReadHandle = (portalId, dbFile, encryptionKey, freshnessToken) => Effect38.tryPromise({
3095
+ try: async () => {
3096
+ const instance = await DuckDBInstance2.create(":memory:");
3097
+ const conn = await instance.connect();
3098
+ const escapedPath = dbFile.replace(/'/g, "''");
3099
+ if (encryptionKey) {
3100
+ const escapedKey = encryptionKey.replace(/'/g, "''");
3101
+ await conn.run(`ATTACH '${escapedPath}' AS portal_db (ENCRYPTION_KEY '${escapedKey}', READ_ONLY)`);
3102
+ } else {
3103
+ await conn.run(`ATTACH '${escapedPath}' AS portal_db (READ_ONLY)`);
3104
+ }
3105
+ await conn.run("USE portal_db");
3106
+ handles.set(portalId, { conn, instance, freshnessToken });
3107
+ return conn;
3108
+ },
3109
+ catch: (e) => new DatabaseError({
3110
+ message: `Failed to open read connection for portal ${portalId}`,
3111
+ cause: e
3112
+ })
3113
+ });
3114
+ var getReadConnection = (portalId, encryptionKey) => pipe32(
3115
+ Effect38.Do,
3116
+ Effect38.let("dbFile", () => dbPath(portalId)),
3117
+ Effect38.filterOrFail(
3118
+ ({ dbFile }) => fs6.existsSync(dbFile),
3119
+ () => new DatabaseError({
3120
+ message: `Database not found for portal ${portalId}. Has it been synced?`
3121
+ })
3122
+ ),
3123
+ Effect38.bind("freshnessToken", ({ dbFile }) => getFreshnessToken(portalId, dbFile)),
3124
+ Effect38.tap(
3125
+ ({ freshnessToken }) => Effect38.sync(() => {
3126
+ const existing = handles.get(portalId);
3127
+ if (!existing || existing.freshnessToken === freshnessToken) return;
3128
+ closeHandle(portalId);
3129
+ })
3130
+ ),
3131
+ Effect38.flatMap(
3132
+ ({ dbFile, freshnessToken }) => pipe32(
3133
+ Effect38.sync(() => handles.get(portalId)),
3134
+ Effect38.flatMap(
3135
+ (existing) => existing ? Effect38.succeed(existing.conn) : openReadHandle(portalId, dbFile, encryptionKey, freshnessToken)
3136
+ )
3137
+ )
3138
+ )
3139
+ );
3140
+
3141
+ // src/pure/resolve-portal-ids.ts
3142
+ var dedupePortalIds = (portalIds) => {
3143
+ const seen = /* @__PURE__ */ new Set();
3144
+ const deduped = [];
3145
+ for (const portalId of portalIds) {
3146
+ if (seen.has(portalId)) continue;
3147
+ seen.add(portalId);
3148
+ deduped.push(portalId);
3149
+ }
3150
+ return deduped;
3151
+ };
3152
+ var resolvePortalIds = (explicitPortalIds, selectedPortalId2, portals) => {
3153
+ if (explicitPortalIds !== void 0) {
3154
+ if (explicitPortalIds.length === 0) {
3155
+ return { error: "portalIds must include at least one portal ID." };
3156
+ }
3157
+ const deduped = dedupePortalIds(explicitPortalIds);
3158
+ const availableIds = new Set(portals.map((portal) => portal.target_portal));
3159
+ const invalidPortalIds = deduped.filter((portalId) => !availableIds.has(portalId));
3160
+ if (invalidPortalIds.length > 0) {
3161
+ return {
3162
+ error: `Portal ID(s) not available: ${invalidPortalIds.join(", ")}`
3163
+ };
3164
+ }
3165
+ return {
3166
+ portalIds: deduped,
3167
+ explicit: true
3168
+ };
3169
+ }
3170
+ if (selectedPortalId2 === null) {
3171
+ return {
3172
+ error: "No portal selected. Use the set_portal tool first or pass portalIds."
3173
+ };
3174
+ }
3175
+ return {
3176
+ portalIds: [selectedPortalId2],
3177
+ explicit: false
3178
+ };
3179
+ };
3180
+
3181
+ // src/tools/query.ts
3211
3182
  var MAX_ROWS = 200;
3212
3183
  var hasLimitClause = (sql) => /\bLIMIT\s+\d+/i.test(sql);
3184
+ var runQueryForPortal = async (portalId, trimmedSql, finalSql, getEncryptionKey) => {
3185
+ const conn = await Effect39.runPromise(getReadConnection(portalId, getEncryptionKey()));
3186
+ const start = performance.now();
3187
+ const reader = await conn.runAndReadAll(finalSql);
3188
+ const rows = reader.getRowObjects();
3189
+ const elapsed = (performance.now() - start).toFixed(1);
3190
+ return {
3191
+ rows,
3192
+ rowCount: rows.length,
3193
+ executionTimeMs: elapsed,
3194
+ ...rows.length >= MAX_ROWS && !hasLimitClause(trimmedSql) ? {
3195
+ truncated: true,
3196
+ note: `Results capped at ${MAX_ROWS} rows. Add your own LIMIT clause for different limits.`
3197
+ } : {}
3198
+ };
3199
+ };
3200
+ var stringifyResult = (value) => JSON.stringify(value, (_, inner) => typeof inner === "bigint" ? Number(inner) : inner, 2);
3213
3201
  function registerQueryTool(server2, deps) {
3214
3202
  const schemaDocs = [
3215
3203
  '- The "sync_metadata" table has: key TEXT, value TEXT',
@@ -3230,6 +3218,10 @@ Use this to answer questions about HubSpot data such as:
3230
3218
 
3231
3219
  Only SELECT statements are allowed. Results are capped at ${MAX_ROWS} rows.
3232
3220
 
3221
+ Optional: pass portalIds to run the same query across one or more specific portals. When portalIds is provided, the result is always a JSON object keyed by portal ID, where each value is the query result or an error.
3222
+
3223
+ Data freshness: each portal's data is automatically checked for freshness before querying. If new artifacts have been synced since the last query, they are ingested first. Results reflect the latest synced state.
3224
+
3233
3225
  IMPORTANT: Always use the 'status' tool with section "schema" first to discover available object types and property names before writing queries. Do not guess property names.
3234
3226
 
3235
3227
  The database schema:
@@ -3240,14 +3232,15 @@ Example queries:
3240
3232
  - SELECT from_object_type, to_object_type, COUNT(*) as cnt FROM associations GROUP BY 1, 2 ORDER BY cnt DESC
3241
3233
  - SELECT id, json_extract_string(properties, '$.dealname') as name, json_extract_string(properties, '$.amount') as amount FROM deals WHERE json_extract_string(properties, '$.dealstage') = 'closedwon' LIMIT 20`,
3242
3234
  inputSchema: {
3243
- sql: z13.string().describe("A SELECT SQL query to run against the DuckDB database")
3235
+ sql: z13.string().describe("A SELECT SQL query to run against the DuckDB database"),
3236
+ portalIds: z13.array(z13.number()).optional().describe("Optional explicit portal IDs to query. If provided, results are keyed by portal ID.")
3244
3237
  }
3245
3238
  },
3246
- async ({ sql }) => {
3247
- const portalId = deps.getSelectedPortalId();
3248
- if (portalId === null) {
3239
+ async ({ sql, portalIds }) => {
3240
+ const portalResolution = resolvePortalIds(portalIds, deps.getSelectedPortalId(), deps.getPortals());
3241
+ if ("error" in portalResolution) {
3249
3242
  return {
3250
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
3243
+ content: [{ type: "text", text: portalResolution.error }],
3251
3244
  isError: true
3252
3245
  };
3253
3246
  }
@@ -3259,28 +3252,48 @@ Example queries:
3259
3252
  };
3260
3253
  }
3261
3254
  const finalSql = hasLimitClause(trimmed) ? trimmed : `${trimmed} LIMIT ${MAX_ROWS}`;
3262
- try {
3263
- const conn = await Effect39.runPromise(getReadConnection(portalId, deps.getEncryptionKey()));
3264
- const start = performance.now();
3265
- const reader = await conn.runAndReadAll(finalSql);
3266
- const rows = reader.getRowObjects();
3267
- const elapsed = (performance.now() - start).toFixed(1);
3268
- const result = {
3269
- rows,
3270
- rowCount: rows.length,
3271
- executionTimeMs: elapsed,
3272
- ...rows.length >= MAX_ROWS && !hasLimitClause(trimmed) ? { truncated: true, note: `Results capped at ${MAX_ROWS} rows. Add your own LIMIT clause for different limits.` } : {}
3273
- };
3274
- return {
3275
- content: [{ type: "text", text: JSON.stringify(result, (_, v) => typeof v === "bigint" ? Number(v) : v, 2) }]
3276
- };
3277
- } catch (e) {
3278
- const message = e instanceof Error ? e.message : String(e);
3279
- return {
3280
- content: [{ type: "text", text: `Query error: ${message}` }],
3281
- isError: true
3282
- };
3255
+ if (!portalResolution.explicit) {
3256
+ const [portalId] = portalResolution.portalIds;
3257
+ try {
3258
+ await deps.ensureFresh(portalId);
3259
+ const result = await runQueryForPortal(
3260
+ portalId,
3261
+ trimmed,
3262
+ finalSql,
3263
+ deps.getEncryptionKey
3264
+ );
3265
+ return {
3266
+ content: [{ type: "text", text: stringifyResult(result) }]
3267
+ };
3268
+ } catch (e) {
3269
+ const message = e instanceof Error ? e.message : String(e);
3270
+ return {
3271
+ content: [{ type: "text", text: `Query error: ${message}` }],
3272
+ isError: true
3273
+ };
3274
+ }
3275
+ }
3276
+ const keyedResults = {};
3277
+ let hadAnyErrors = false;
3278
+ for (const portalId of portalResolution.portalIds) {
3279
+ try {
3280
+ await deps.ensureFresh(portalId);
3281
+ keyedResults[String(portalId)] = await runQueryForPortal(
3282
+ portalId,
3283
+ trimmed,
3284
+ finalSql,
3285
+ deps.getEncryptionKey
3286
+ );
3287
+ } catch (e) {
3288
+ hadAnyErrors = true;
3289
+ const message = e instanceof Error ? e.message : String(e);
3290
+ keyedResults[String(portalId)] = { error: message };
3291
+ }
3283
3292
  }
3293
+ return {
3294
+ content: [{ type: "text", text: stringifyResult(keyedResults) }],
3295
+ ...hadAnyErrors ? { isError: true } : {}
3296
+ };
3284
3297
  }
3285
3298
  );
3286
3299
  }
@@ -3327,8 +3340,8 @@ function mapQueryResultsToChartData(rows, columns) {
3327
3340
  }));
3328
3341
  return { labels, datasets };
3329
3342
  }
3330
- function generateChartHtml(options) {
3331
- const { chartType, data, title, sql } = options;
3343
+ function generateChartHtml(options2) {
3344
+ const { chartType, data, title, sql } = options2;
3332
3345
  const datasets = data.datasets.map((ds, i) => ({
3333
3346
  label: ds.label,
3334
3347
  data: ds.data,
@@ -3490,7 +3503,64 @@ function openInBrowser(target) {
3490
3503
  // src/tools/chart.ts
3491
3504
  var MAX_ROWS2 = 200;
3492
3505
  var hasLimitClause2 = (sql) => /\bLIMIT\s+\d+/i.test(sql);
3506
+ var runQueryForPortal2 = async (portalId, sql, getEncryptionKey) => {
3507
+ const conn = await Effect40.runPromise(getReadConnection(portalId, getEncryptionKey()));
3508
+ const start = performance.now();
3509
+ const reader = await conn.runAndReadAll(sql);
3510
+ const rows = reader.getRowObjects();
3511
+ const elapsed = (performance.now() - start).toFixed(1);
3512
+ const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
3513
+ return {
3514
+ portalId,
3515
+ rows,
3516
+ columns,
3517
+ executionTimeMs: elapsed
3518
+ };
3519
+ };
3520
+ var buildMergedPortalChartData = (portalRows) => {
3521
+ const allLabels = [];
3522
+ const seenLabels = /* @__PURE__ */ new Set();
3523
+ for (const entry of portalRows) {
3524
+ const labelColumn = entry.columns[0];
3525
+ for (const row of entry.rows) {
3526
+ const label = String(row[labelColumn] ?? "");
3527
+ if (seenLabels.has(label)) continue;
3528
+ seenLabels.add(label);
3529
+ allLabels.push(label);
3530
+ }
3531
+ }
3532
+ const datasets = [];
3533
+ for (const entry of portalRows) {
3534
+ const labelColumn = entry.columns[0];
3535
+ const dataColumns = entry.columns.slice(1);
3536
+ for (const dataColumn of dataColumns) {
3537
+ const valuesByLabel = /* @__PURE__ */ new Map();
3538
+ for (const row of entry.rows) {
3539
+ const label = String(row[labelColumn] ?? "");
3540
+ const value = Number(row[dataColumn]);
3541
+ valuesByLabel.set(label, Number.isFinite(value) ? value : 0);
3542
+ }
3543
+ datasets.push({
3544
+ label: `Portal ${entry.portalId} - ${dataColumn}`,
3545
+ data: allLabels.map((label) => valuesByLabel.get(label) ?? 0)
3546
+ });
3547
+ }
3548
+ }
3549
+ return {
3550
+ labels: allLabels,
3551
+ datasets
3552
+ };
3553
+ };
3554
+ var writeChartFile = (chartType, html) => {
3555
+ mkdirSync4(CHARTS_DIR(), { recursive: true });
3556
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3557
+ const filename = `${timestamp}-${chartType}.html`;
3558
+ const filePath = chartFilePath(filename);
3559
+ writeFileSync4(filePath, html, "utf-8");
3560
+ return filePath;
3561
+ };
3493
3562
  function registerChartTool(server2, deps) {
3563
+ const openChartFile = deps.openChartFile ?? openInBrowser;
3494
3564
  server2.registerTool(
3495
3565
  "chart",
3496
3566
  {
@@ -3504,6 +3574,10 @@ Use this when the user wants to visualize HubSpot data, such as:
3504
3574
 
3505
3575
  Only SELECT statements are allowed. Results are capped at ${MAX_ROWS2} rows.
3506
3576
 
3577
+ Optional: pass portalIds to run the same chart query across one or more specific portals. When portalIds is provided, results include a JSON object keyed by portal ID with per-portal summaries, and chart datasets are labeled by portal.
3578
+
3579
+ Data freshness: each portal's data is automatically checked for freshness before charting. Results reflect the latest synced state.
3580
+
3507
3581
  The query results map to the chart as follows:
3508
3582
  - First column \u2192 X-axis labels (categories)
3509
3583
  - Remaining columns \u2192 one dataset per column (Y-axis values, must be numeric)
@@ -3519,14 +3593,15 @@ SELECT json_extract_string(properties, '$.dealstage') as stage, COUNT(*) as coun
3519
3593
  inputSchema: {
3520
3594
  sql: z14.string().describe("A SELECT SQL query. First column = labels, remaining columns = numeric datasets."),
3521
3595
  chart_type: z14.enum(["bar", "line"]).describe("The chart type to render."),
3522
- title: z14.string().optional().describe("Chart title. Auto-generated from chart type if omitted.")
3596
+ title: z14.string().optional().describe("Chart title. Auto-generated from chart type if omitted."),
3597
+ portalIds: z14.array(z14.number()).optional().describe("Optional explicit portal IDs to chart. If provided, output includes keyed portal results.")
3523
3598
  }
3524
3599
  },
3525
- async ({ sql, chart_type, title }) => {
3526
- const portalId = deps.getSelectedPortalId();
3527
- if (portalId === null) {
3600
+ async ({ sql, chart_type, title, portalIds }) => {
3601
+ const portalResolution = resolvePortalIds(portalIds, deps.getSelectedPortalId(), deps.getPortals());
3602
+ if ("error" in portalResolution) {
3528
3603
  return {
3529
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
3604
+ content: [{ type: "text", text: portalResolution.error }],
3530
3605
  isError: true
3531
3606
  };
3532
3607
  }
@@ -3538,55 +3613,120 @@ SELECT json_extract_string(properties, '$.dealstage') as stage, COUNT(*) as coun
3538
3613
  };
3539
3614
  }
3540
3615
  const finalSql = hasLimitClause2(trimmed) ? trimmed : `${trimmed} LIMIT ${MAX_ROWS2}`;
3541
- try {
3542
- const conn = await Effect40.runPromise(getReadConnection(portalId, deps.getEncryptionKey()));
3543
- const start = performance.now();
3544
- const reader = await conn.runAndReadAll(finalSql);
3545
- const rows = reader.getRowObjects();
3546
- const elapsed = (performance.now() - start).toFixed(1);
3547
- if (rows.length === 0) {
3548
- return {
3549
- content: [{ type: "text", text: "Query returned no rows \u2014 nothing to chart." }],
3550
- isError: true
3616
+ if (!portalResolution.explicit) {
3617
+ const [portalId] = portalResolution.portalIds;
3618
+ try {
3619
+ await deps.ensureFresh(portalId);
3620
+ const result = await runQueryForPortal2(portalId, finalSql, deps.getEncryptionKey);
3621
+ if (result.rows.length === 0) {
3622
+ return {
3623
+ content: [{ type: "text", text: "Query returned no rows - nothing to chart." }],
3624
+ isError: true
3625
+ };
3626
+ }
3627
+ if (result.columns.length < 2) {
3628
+ return {
3629
+ content: [{
3630
+ type: "text",
3631
+ text: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis). Your query returned only 1 column."
3632
+ }],
3633
+ isError: true
3634
+ };
3635
+ }
3636
+ const chartData = mapQueryResultsToChartData(result.rows, result.columns);
3637
+ const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart - ${result.columns.slice(1).join(", ")} by ${result.columns[0]}`;
3638
+ const html = generateChartHtml({
3639
+ chartType: chart_type,
3640
+ data: chartData,
3641
+ title: chartTitle,
3642
+ sql: trimmed
3643
+ });
3644
+ const filePath = writeChartFile(chart_type, html);
3645
+ openChartFile(filePath);
3646
+ const dataColumns = result.columns.slice(1);
3647
+ const summary = [
3648
+ `Chart generated and opened in browser.`,
3649
+ ``,
3650
+ `Type: ${chart_type}`,
3651
+ `Title: ${chartTitle}`,
3652
+ `Labels (x-axis): ${result.columns[0]} (${result.rows.length} values)`,
3653
+ `Datasets: ${dataColumns.join(", ")}`,
3654
+ `Query time: ${result.executionTimeMs}ms`,
3655
+ `File: ${filePath}`
3656
+ ].join("\n");
3657
+ return {
3658
+ content: [{ type: "text", text: summary }]
3551
3659
  };
3552
- }
3553
- const columns = Object.keys(rows[0]);
3554
- if (columns.length < 2) {
3660
+ } catch (e) {
3661
+ const message = e instanceof Error ? e.message : String(e);
3555
3662
  return {
3556
- content: [{
3557
- type: "text",
3558
- text: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis). Your query returned only 1 column."
3559
- }],
3663
+ content: [{ type: "text", text: `Chart error: ${message}` }],
3560
3664
  isError: true
3561
3665
  };
3562
3666
  }
3563
- const chartData = mapQueryResultsToChartData(rows, columns);
3564
- const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart \u2014 ${columns.slice(1).join(", ")} by ${columns[0]}`;
3667
+ }
3668
+ const keyedPortalResults = {};
3669
+ const successfulPortals = [];
3670
+ let hadErrors = false;
3671
+ for (const portalId of portalResolution.portalIds) {
3672
+ try {
3673
+ await deps.ensureFresh(portalId);
3674
+ const result = await runQueryForPortal2(portalId, finalSql, deps.getEncryptionKey);
3675
+ if (result.rows.length === 0) {
3676
+ hadErrors = true;
3677
+ keyedPortalResults[String(portalId)] = { error: "Query returned no rows - nothing to chart." };
3678
+ continue;
3679
+ }
3680
+ if (result.columns.length < 2) {
3681
+ hadErrors = true;
3682
+ keyedPortalResults[String(portalId)] = {
3683
+ error: "Chart requires at least 2 columns: one for labels (x-axis) and one for values (y-axis)."
3684
+ };
3685
+ continue;
3686
+ }
3687
+ successfulPortals.push(result);
3688
+ keyedPortalResults[String(portalId)] = {
3689
+ rowCount: result.rows.length,
3690
+ executionTimeMs: result.executionTimeMs,
3691
+ labelColumn: result.columns[0],
3692
+ datasetColumns: result.columns.slice(1)
3693
+ };
3694
+ } catch (e) {
3695
+ hadErrors = true;
3696
+ const message = e instanceof Error ? e.message : String(e);
3697
+ keyedPortalResults[String(portalId)] = { error: message };
3698
+ }
3699
+ }
3700
+ if (successfulPortals.length === 0) {
3701
+ return {
3702
+ content: [{ type: "text", text: JSON.stringify(keyedPortalResults, null, 2) }],
3703
+ isError: true
3704
+ };
3705
+ }
3706
+ try {
3707
+ const chartData = buildMergedPortalChartData(successfulPortals);
3708
+ const chartTitle = title ?? `${chart_type === "bar" ? "Bar" : "Line"} Chart - Portal Comparison`;
3565
3709
  const html = generateChartHtml({
3566
3710
  chartType: chart_type,
3567
3711
  data: chartData,
3568
3712
  title: chartTitle,
3569
3713
  sql: trimmed
3570
3714
  });
3571
- mkdirSync4(CHARTS_DIR, { recursive: true });
3572
- const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3573
- const filename = `${timestamp}-${chart_type}.html`;
3574
- const filePath = chartFilePath(filename);
3575
- writeFileSync4(filePath, html, "utf-8");
3576
- openInBrowser(filePath);
3577
- const dataColumns = columns.slice(1);
3578
- const summary = [
3579
- `Chart generated and opened in browser.`,
3580
- ``,
3581
- `Type: ${chart_type}`,
3582
- `Title: ${chartTitle}`,
3583
- `Labels (x-axis): ${columns[0]} (${rows.length} values)`,
3584
- `Datasets: ${dataColumns.join(", ")}`,
3585
- `Query time: ${elapsed}ms`,
3586
- `File: ${filePath}`
3587
- ].join("\n");
3715
+ const filePath = writeChartFile(chart_type, html);
3716
+ openChartFile(filePath);
3717
+ const response = {
3718
+ chart: {
3719
+ type: chart_type,
3720
+ title: chartTitle,
3721
+ labels: chartData.labels.length,
3722
+ datasets: chartData.datasets.length,
3723
+ file: filePath
3724
+ },
3725
+ portals: keyedPortalResults
3726
+ };
3588
3727
  return {
3589
- content: [{ type: "text", text: summary }]
3728
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
3729
+ ...hadErrors ? { isError: true } : {}
3590
3730
  };
3591
3731
  } catch (e) {
3592
3732
  const message = e instanceof Error ? e.message : String(e);
@@ -3616,25 +3756,21 @@ function getDbFileSize(portalId) {
3616
3756
  return fs7.statSync(db).size;
3617
3757
  }
3618
3758
  async function buildConnectionSection(deps) {
3619
- const selectedPortalId = deps.getSelectedPortalId();
3759
+ const selectedPortalId2 = deps.getSelectedPortalId();
3620
3760
  const connectionState = deps.getConnectionState();
3621
3761
  const portals = deps.getPortals();
3622
3762
  return {
3623
3763
  connectionState,
3624
- selectedPortalId,
3764
+ selectedPortalId: selectedPortalId2,
3625
3765
  portals: portals.map((p) => ({
3626
3766
  portalId: p.target_portal,
3627
3767
  name: p.target_name,
3628
- selected: p.target_portal === selectedPortalId
3768
+ selected: p.target_portal === selectedPortalId2
3629
3769
  })),
3630
3770
  totalPortals: portals.length
3631
3771
  };
3632
3772
  }
3633
- async function buildSchemaSection(getSelectedPortalId2, getEncryptionKey) {
3634
- const portalId = getSelectedPortalId2();
3635
- if (portalId === null) {
3636
- return { error: "No portal selected. Use the set_portal tool first." };
3637
- }
3773
+ async function buildSchemaSection(portalId, getEncryptionKey) {
3638
3774
  const conn = await Effect41.runPromise(getReadConnection(portalId, getEncryptionKey()));
3639
3775
  const allPluginTableNames = new Set(getAllTableNames());
3640
3776
  const sectionResults = await Promise.all(
@@ -3666,25 +3802,66 @@ function registerStatusTool(server2, deps) {
3666
3802
 
3667
3803
  IMPORTANT: Always call this with section "schema" before writing any queries. It reveals which CRM object types are synced (contacts, companies, deals, tickets, custom objects, etc.), their available property names, record counts, and association types. This prevents query errors from incorrect property or object names.
3668
3804
 
3805
+ Optional: pass portalIds to fetch schema status for specific portals. When portalIds is provided, schema output is a JSON object keyed by portal ID.
3806
+
3807
+ Data readiness: all registered portals are synced in the background. Schema status reflects which object types have completed syncing and are queryable. If a portal is still syncing, some object types may not yet appear.
3808
+
3669
3809
  Use "connection" to check which portals are available and which is selected.
3670
3810
  Use "schema" to discover object types, property keys, record counts, and associations before writing queries.
3671
3811
  Use "all" to get everything at once.`,
3672
3812
  inputSchema: {
3673
3813
  section: SECTION_SCHEMA.describe(
3674
3814
  'Which information to retrieve: "connection" for portal list and state, "schema" for database structure, "all" for both.'
3675
- )
3815
+ ),
3816
+ portalIds: z15.array(z15.number()).optional().describe("Optional explicit portal IDs for schema status. If provided, schema output is keyed by portal ID.")
3676
3817
  }
3677
- }, async ({ section }) => {
3818
+ }, async ({ section, portalIds }) => {
3678
3819
  try {
3679
3820
  let result = {};
3821
+ let explicitSchemaHadErrors = false;
3680
3822
  if (section === "connection" || section === "all") {
3681
3823
  result.connection = await buildConnectionSection(deps);
3682
3824
  }
3683
3825
  if (section === "schema" || section === "all") {
3684
- result.schema = await buildSchemaSection(deps.getSelectedPortalId, deps.getEncryptionKey);
3826
+ const portalResolution = resolvePortalIds(
3827
+ portalIds,
3828
+ deps.getSelectedPortalId(),
3829
+ deps.getPortals()
3830
+ );
3831
+ if ("error" in portalResolution) {
3832
+ if (portalIds === void 0) {
3833
+ result.schema = { error: portalResolution.error };
3834
+ } else {
3835
+ return {
3836
+ content: [{ type: "text", text: portalResolution.error }],
3837
+ isError: true
3838
+ };
3839
+ }
3840
+ } else if (portalResolution.explicit) {
3841
+ const schemaByPortal = {};
3842
+ for (const portalId of portalResolution.portalIds) {
3843
+ try {
3844
+ await deps.ensureFresh(portalId);
3845
+ schemaByPortal[String(portalId)] = await buildSchemaSection(
3846
+ portalId,
3847
+ deps.getEncryptionKey
3848
+ );
3849
+ } catch (e) {
3850
+ explicitSchemaHadErrors = true;
3851
+ const message = e instanceof Error ? e.message : String(e);
3852
+ schemaByPortal[String(portalId)] = { error: message };
3853
+ }
3854
+ }
3855
+ result.schema = schemaByPortal;
3856
+ } else {
3857
+ const [portalId] = portalResolution.portalIds;
3858
+ await deps.ensureFresh(portalId);
3859
+ result.schema = await buildSchemaSection(portalId, deps.getEncryptionKey);
3860
+ }
3685
3861
  }
3686
3862
  return {
3687
- content: [{ type: "text", text: JSON.stringify(result) }]
3863
+ content: [{ type: "text", text: JSON.stringify(result) }],
3864
+ ...explicitSchemaHadErrors ? { isError: true } : {}
3688
3865
  };
3689
3866
  } catch (e) {
3690
3867
  const message = e instanceof Error ? e.message : String(e);
@@ -3700,34 +3877,39 @@ Use "all" to get everything at once.`,
3700
3877
  import { z as z61 } from "zod";
3701
3878
 
3702
3879
  // src/tools/draft_state.ts
3703
- var currentDraft = null;
3704
- var getDraft = () => currentDraft;
3705
- var hasDraft = () => currentDraft !== null;
3706
- var setDraft = (plan) => {
3707
- currentDraft = plan;
3708
- };
3709
- var addOperationsToDraft = (ops) => {
3710
- if (!currentDraft) throw new Error("No draft plan exists");
3711
- currentDraft.operations.push(...ops);
3712
- return currentDraft.operations.length;
3713
- };
3714
- var replaceOperationInDraft = (index, op) => {
3715
- if (!currentDraft) throw new Error("No draft plan exists");
3716
- if (index < 0 || index >= currentDraft.operations.length) {
3717
- throw new Error(`Operation index ${index} out of bounds (0-${currentDraft.operations.length - 1})`);
3880
+ var drafts = /* @__PURE__ */ new Map();
3881
+ var requireDraft = (portalId) => {
3882
+ const draft = drafts.get(portalId);
3883
+ if (!draft) throw new Error("No draft plan exists");
3884
+ return draft;
3885
+ };
3886
+ var getDraft = (portalId) => drafts.get(portalId) ?? null;
3887
+ var hasDraft = (portalId) => drafts.has(portalId);
3888
+ var setDraft = (portalId, plan) => {
3889
+ drafts.set(portalId, plan);
3890
+ };
3891
+ var addOperationsToDraft = (portalId, ops) => {
3892
+ const draft = requireDraft(portalId);
3893
+ draft.operations.push(...ops);
3894
+ return draft.operations.length;
3895
+ };
3896
+ var replaceOperationInDraft = (portalId, index, op) => {
3897
+ const draft = requireDraft(portalId);
3898
+ if (index < 0 || index >= draft.operations.length) {
3899
+ throw new Error(`Operation index ${index} out of bounds (0-${draft.operations.length - 1})`);
3718
3900
  }
3719
- currentDraft.operations[index] = op;
3901
+ draft.operations[index] = op;
3720
3902
  };
3721
- var removeOperationFromDraft = (index) => {
3722
- if (!currentDraft) throw new Error("No draft plan exists");
3723
- if (index < 0 || index >= currentDraft.operations.length) {
3724
- throw new Error(`Operation index ${index} out of bounds (0-${currentDraft.operations.length - 1})`);
3903
+ var removeOperationFromDraft = (portalId, index) => {
3904
+ const draft = requireDraft(portalId);
3905
+ if (index < 0 || index >= draft.operations.length) {
3906
+ throw new Error(`Operation index ${index} out of bounds (0-${draft.operations.length - 1})`);
3725
3907
  }
3726
- currentDraft.operations.splice(index, 1);
3727
- return currentDraft.operations.length;
3908
+ draft.operations.splice(index, 1);
3909
+ return draft.operations.length;
3728
3910
  };
3729
- var clearDraft = () => {
3730
- currentDraft = null;
3911
+ var clearDraft = (portalId) => {
3912
+ drafts.delete(portalId);
3731
3913
  };
3732
3914
 
3733
3915
  // src/pure/operation-conversion.ts
@@ -3907,12 +4089,12 @@ var updatePropertyMeta = {
3907
4089
  }
3908
4090
  },
3909
4091
  toNested: (description, fields44) => {
3910
- const { object_type, property_name, new_label, new_description, new_group_name, options, ...rest } = fields44;
4092
+ const { object_type, property_name, new_label, new_description, new_group_name, options: options2, ...rest } = fields44;
3911
4093
  const updates = {};
3912
4094
  if (new_label !== void 0) updates.label = new_label;
3913
4095
  if (new_description !== void 0) updates.description = new_description;
3914
4096
  if (new_group_name !== void 0) updates.group_name = new_group_name;
3915
- if (options !== void 0) updates.options = options;
4097
+ if (options2 !== void 0) updates.options = options2;
3916
4098
  return { type: "update_property", object_type, property_name, description, updates, ...rest };
3917
4099
  },
3918
4100
  requiredScopes: (op) => [getWriteScope(op.object_type)],
@@ -6358,7 +6540,7 @@ var buildRuleLines = (rules, optionLabelMap) => rules.map((rule) => {
6358
6540
  const labels = rule.visible_options.map((v) => optionLabelMap.get(v) ?? v).join(", ");
6359
6541
  return ` - When "${rule.controlling_property}" = "${rule.controlling_value}": Show "${labels}"`;
6360
6542
  }).join("\n");
6361
- var buildOptionLabelMap = (options) => new Map(options.map((o) => [o.value, o.label]));
6543
+ var buildOptionLabelMap = (options2) => new Map(options2.map((o) => [o.value, o.label]));
6362
6544
  var getUniqueControllingProperties = (rules) => [...new Set(rules.map((r) => r.controlling_property))];
6363
6545
  var buildConditionalOptionsTask = (property, objectType, rules) => {
6364
6546
  const subject = `Configure conditional options for "${property.label}" on ${formatObjectType(objectType)}`;
@@ -10765,12 +10947,18 @@ var summariseFields = (op) => {
10765
10947
  return `${k}: ${String(v)}`;
10766
10948
  }).join(", ");
10767
10949
  };
10768
- var formatPlanView = () => {
10769
- const draft = getDraft();
10950
+ var formatPlanView = (portalId) => {
10951
+ const draft = getDraft(portalId);
10952
+ if (!draft) {
10953
+ return [
10954
+ `Portal ${portalId}: no draft plan exists.`,
10955
+ "Provide title and description to start a new plan."
10956
+ ].join("\n");
10957
+ }
10770
10958
  const lines = [
10771
- `Draft Plan: "${draft.title}"`,
10959
+ `Portal ${portalId} Draft Plan: "${draft.title}"`,
10772
10960
  `Description: ${draft.description}`,
10773
- ``
10961
+ ""
10774
10962
  ];
10775
10963
  if (draft.operations.length === 0) {
10776
10964
  lines.push("Operations: (none)");
@@ -10788,9 +10976,36 @@ var formatPlanView = () => {
10788
10976
  }
10789
10977
  return lines.join("\n");
10790
10978
  };
10791
- function registerBuildPlanTool(server2) {
10979
+ var serialiseDraft = (portalId) => {
10980
+ const draft = getDraft(portalId);
10981
+ if (!draft) return null;
10982
+ return {
10983
+ title: draft.title,
10984
+ description: draft.description,
10985
+ operationCount: draft.operations.length,
10986
+ operations: draft.operations.map((operation, index) => {
10987
+ const op = operation;
10988
+ return {
10989
+ index: index + 1,
10990
+ type: String(op.type ?? "unknown"),
10991
+ description: String(op.description ?? "")
10992
+ };
10993
+ })
10994
+ };
10995
+ };
10996
+ var formatDraftResponse = (portalIds, explicit) => {
10997
+ if (!explicit) {
10998
+ return formatPlanView(portalIds[0]);
10999
+ }
11000
+ const keyed = {};
11001
+ for (const portalId of portalIds) {
11002
+ keyed[String(portalId)] = serialiseDraft(portalId);
11003
+ }
11004
+ return JSON.stringify(keyed, null, 2);
11005
+ };
11006
+ function registerBuildPlanTool(server2, deps) {
10792
11007
  server2.registerTool("build_plan", {
10793
- description: `Use this tool whenever the user wants to make changes to their HubSpot CRM configuration or data. This is the ONLY way to modify HubSpot \u2014 there is no direct API access. All CRM write operations must go through a plan.
11008
+ description: `Use this tool whenever the user wants to make changes to their HubSpot CRM configuration or data. This is the ONLY way to modify HubSpot - there is no direct API access. All CRM write operations must go through a plan.
10794
11009
 
10795
11010
  Supports ${OPERATION_TYPE_COUNT} operation types across these categories:
10796
11011
  - Properties & groups: create, update, or delete custom properties and property groups
@@ -10803,20 +11018,25 @@ Supports ${OPERATION_TYPE_COUNT} operation types across these categories:
10803
11018
 
10804
11019
  CREATING A PLAN:
10805
11020
  Provide title + description (+ optional operations) to start a new draft.
10806
- If a draft already exists, pass force: true to discard it and start fresh.
11021
+ If a targeted draft already exists, pass force: true to discard it and start fresh.
11022
+
11023
+ PORTAL TARGETING:
11024
+ Optionally pass portalIds to target one or more specific portals.
11025
+ If omitted, the selected portal is used.
11026
+ When portalIds is explicit, the response is a JSON object keyed by portal ID.
10807
11027
 
10808
11028
  ADDING OPERATIONS:
10809
- Provide operations to append them to the existing draft.
11029
+ Provide operations to append them to existing targeted drafts.
10810
11030
  Each operation needs: type, description, and fields.
10811
- Use describe_operations first to discover available types and their required fields.
11031
+ Use describe_operations first to discover available types and required fields.
10812
11032
 
10813
11033
  EDITING OPERATIONS:
10814
11034
  Provide edits to replace or remove operations by their 1-based index.
10815
- Edits are applied atomically \u2014 if any replacement fails validation, no changes are made.
11035
+ Edits require exactly one targeted portal.
11036
+ Edits are applied atomically - if any replacement fails validation, no changes are made.
10816
11037
 
10817
11038
  VIEWING THE PLAN:
10818
- Call with no parameters (or just title/description) to see the current draft state.
10819
- The current plan view is always returned in the response.
11039
+ Call with no operations/edits to see current draft state for the targeted portal(s).
10820
11040
 
10821
11041
  IMPORTANT: After building the plan, present it to the user and ask for confirmation before calling submit_plan.
10822
11042
 
@@ -10827,43 +11047,77 @@ WORKFLOW:
10827
11047
  4. submit_plan (dry_run: true) - Validate without saving (optional)
10828
11048
  5. submit_plan - Submit after user confirms`,
10829
11049
  inputSchema: {
10830
- title: z61.string().optional().describe("Short, descriptive title (required when creating a new plan)"),
10831
- description: z61.string().optional().describe("Detailed explanation of what the plan does and why (required when creating a new plan)"),
10832
- operations: z61.array(FlatOperationSchema).optional().describe("Operations to add to the plan"),
11050
+ title: z61.string().optional().describe("Short, descriptive title (required when creating a new draft)"),
11051
+ description: z61.string().optional().describe("Detailed explanation of what the plan does and why (required when creating a new draft)"),
11052
+ operations: z61.array(FlatOperationSchema).optional().describe("Operations to add to targeted draft(s)"),
10833
11053
  edits: z61.array(EditSchema).optional().describe("Edits to apply: replace or remove operations by 1-based index"),
10834
- force: z61.boolean().optional().describe("If true, discard any existing draft and start fresh")
11054
+ force: z61.boolean().optional().describe("If true, discard targeted draft(s) and start fresh"),
11055
+ portalIds: z61.array(z61.number()).optional().describe("Optional explicit portal IDs to target. If provided, responses are keyed by portal ID.")
11056
+ }
11057
+ }, async ({ title, description, operations, edits, force, portalIds }) => {
11058
+ const portalResolution = resolvePortalIds(
11059
+ portalIds,
11060
+ deps.getSelectedPortalId(),
11061
+ deps.getPortals()
11062
+ );
11063
+ if ("error" in portalResolution) {
11064
+ return {
11065
+ content: [{ type: "text", text: portalResolution.error }],
11066
+ isError: true
11067
+ };
11068
+ }
11069
+ const targetPortalIds = portalResolution.portalIds;
11070
+ const explicit = portalResolution.explicit;
11071
+ if (edits && edits.length > 0 && targetPortalIds.length !== 1) {
11072
+ return {
11073
+ content: [{ type: "text", text: "When using edits, target exactly one portal. Provide a single portal ID in portalIds." }],
11074
+ isError: true
11075
+ };
10835
11076
  }
10836
- }, async ({ title, description, operations, edits, force }) => {
10837
- if (!hasDraft()) {
11077
+ const missingPortalIds = targetPortalIds.filter((portalId) => !hasDraft(portalId));
11078
+ if (force) {
10838
11079
  if (!title || !description) {
10839
11080
  return {
10840
- content: [{ type: "text", text: "No draft plan exists. Provide title and description to start a new plan." }],
11081
+ content: [{ type: "text", text: "When using force: true, provide title and description for the new draft." }],
10841
11082
  isError: true
10842
11083
  };
10843
11084
  }
10844
- setDraft({ title, description, operations: [] });
10845
- } else if (force) {
10846
- if (!title || !description) {
11085
+ for (const portalId of targetPortalIds) {
11086
+ clearDraft(portalId);
11087
+ setDraft(portalId, { title, description, operations: [] });
11088
+ }
11089
+ } else {
11090
+ if (missingPortalIds.length > 0 && (!title || !description)) {
11091
+ const plural = missingPortalIds.length > 1 ? "portals" : "portal";
10847
11092
  return {
10848
- content: [{ type: "text", text: "When using force: true, provide title and description for the new plan." }],
11093
+ content: [{
11094
+ type: "text",
11095
+ text: `No draft plan exists for ${plural}: ${missingPortalIds.join(", ")}. Provide title and description to start a new draft.`
11096
+ }],
10849
11097
  isError: true
10850
11098
  };
10851
11099
  }
10852
- clearDraft();
10853
- setDraft({ title, description, operations: [] });
10854
- } else {
10855
- if (title || description) {
10856
- const draft = getDraft();
10857
- if (title) draft.title = title;
10858
- if (description) draft.description = description;
11100
+ for (const portalId of targetPortalIds) {
11101
+ if (!hasDraft(portalId)) {
11102
+ setDraft(portalId, { title, description, operations: [] });
11103
+ continue;
11104
+ }
11105
+ if (title || description) {
11106
+ const draft = getDraft(portalId);
11107
+ if (title) draft.title = title;
11108
+ if (description) draft.description = description;
11109
+ }
10859
11110
  }
10860
11111
  }
10861
11112
  if (edits && edits.length > 0) {
10862
- const draft = getDraft();
11113
+ const portalId = targetPortalIds[0];
11114
+ const draft = getDraft(portalId);
10863
11115
  const opCount = draft.operations.length;
10864
11116
  if (opCount === 0) {
10865
11117
  return {
10866
- content: [{ type: "text", text: "Draft has no operations to edit. Provide operations to add them first.\n\n" + formatPlanView() }],
11118
+ content: [{ type: "text", text: `Draft has no operations to edit. Provide operations to add them first.
11119
+
11120
+ ${formatPlanView(portalId)}` }],
10867
11121
  isError: true
10868
11122
  };
10869
11123
  }
@@ -10872,7 +11126,7 @@ WORKFLOW:
10872
11126
  return {
10873
11127
  content: [{ type: "text", text: `Index ${edit.index} is out of range. Draft has ${opCount} operation(s) (indices 1-${opCount}).
10874
11128
 
10875
- ${formatPlanView()}` }],
11129
+ ${formatPlanView(portalId)}` }],
10876
11130
  isError: true
10877
11131
  };
10878
11132
  }
@@ -10880,18 +11134,17 @@ ${formatPlanView()}` }],
10880
11134
  return {
10881
11135
  content: [{ type: "text", text: `Edit at index ${edit.index}: "replace" action requires an operation.
10882
11136
 
10883
- ${formatPlanView()}` }],
11137
+ ${formatPlanView(portalId)}` }],
10884
11138
  isError: true
10885
11139
  };
10886
11140
  }
10887
11141
  }
10888
- const replaces = edits.filter((e) => e.action === "replace");
10889
- const removes = edits.filter((e) => e.action === "remove");
11142
+ const replaces = edits.filter((edit) => edit.action === "replace");
11143
+ const removes = edits.filter((edit) => edit.action === "remove");
10890
11144
  const nestedReplacements = [];
10891
11145
  const errors = [];
10892
11146
  for (const edit of replaces) {
10893
- const flat = edit.operation;
10894
- const nested = nestOperation(flat);
11147
+ const nested = nestOperation(edit.operation);
10895
11148
  if (typeof nested === "string") {
10896
11149
  errors.push(`Index ${edit.index}: ${nested}`);
10897
11150
  continue;
@@ -10904,7 +11157,6 @@ ${formatPlanView()}` }],
10904
11157
  nestedReplacements.push({ zeroIndex: edit.index - 1, nested });
10905
11158
  }
10906
11159
  if (errors.length > 0) {
10907
- const hint = "Use describe_operations to check field requirements.";
10908
11160
  return {
10909
11161
  content: [{
10910
11162
  type: "text",
@@ -10912,44 +11164,58 @@ ${formatPlanView()}` }],
10912
11164
 
10913
11165
  ${errors.join("\n\n")}
10914
11166
 
10915
- ${hint}
11167
+ Use describe_operations to check field requirements.
10916
11168
 
10917
- ${formatPlanView()}`
11169
+ ${formatPlanView(portalId)}`
10918
11170
  }],
10919
11171
  isError: true
10920
11172
  };
10921
11173
  }
10922
11174
  for (const { zeroIndex, nested } of nestedReplacements) {
10923
- replaceOperationInDraft(zeroIndex, nested);
11175
+ replaceOperationInDraft(portalId, zeroIndex, nested);
10924
11176
  }
10925
- const removeIndices = removes.map((e) => e.index - 1).sort((a, b) => b - a);
10926
- for (const idx of removeIndices) {
10927
- removeOperationFromDraft(idx);
11177
+ const removeIndices = removes.map((edit) => edit.index - 1).sort((a, b) => b - a);
11178
+ for (const index of removeIndices) {
11179
+ removeOperationFromDraft(portalId, index);
10928
11180
  }
10929
11181
  }
10930
11182
  if (operations && operations.length > 0) {
10931
- const draft = getDraft();
10932
- const startIndex = draft.operations.length + 1;
10933
- const { nested, errors } = convertAndValidateOperations(operations, startIndex);
10934
- if (errors.length > 0) {
10935
- const hint = "Use describe_operations to see the required fields for each operation type.";
11183
+ const validatedByPortal = /* @__PURE__ */ new Map();
11184
+ const portalErrors = [];
11185
+ for (const portalId of targetPortalIds) {
11186
+ const draft = getDraft(portalId);
11187
+ const startIndex = draft.operations.length + 1;
11188
+ const { nested, errors } = convertAndValidateOperations(operations, startIndex);
11189
+ if (errors.length > 0) {
11190
+ portalErrors.push(`Portal ${portalId}:
11191
+ ${errors.join("\n")}`);
11192
+ continue;
11193
+ }
11194
+ validatedByPortal.set(portalId, nested);
11195
+ }
11196
+ if (portalErrors.length > 0) {
10936
11197
  return {
10937
11198
  content: [{
10938
11199
  type: "text",
10939
- text: `${errors.length} operation(s) failed validation:
10940
-
10941
- ${errors.join("\n\n")}
11200
+ text: `${portalErrors.length} portal(s) failed operation validation:
10942
11201
 
10943
- ${hint}
11202
+ ${portalErrors.join("\n\n")}
10944
11203
 
10945
- ${formatPlanView()}`
11204
+ Use describe_operations to see the required fields for each operation type.`
10946
11205
  }],
10947
11206
  isError: true
10948
11207
  };
10949
11208
  }
10950
- addOperationsToDraft(nested);
11209
+ for (const portalId of targetPortalIds) {
11210
+ addOperationsToDraft(portalId, validatedByPortal.get(portalId) ?? []);
11211
+ }
10951
11212
  }
10952
- return { content: [{ type: "text", text: formatPlanView() }] };
11213
+ return {
11214
+ content: [{
11215
+ type: "text",
11216
+ text: formatDraftResponse(targetPortalIds, explicit)
11217
+ }]
11218
+ };
10953
11219
  });
10954
11220
  }
10955
11221
 
@@ -10997,16 +11263,16 @@ import { Effect as Effect89 } from "effect";
10997
11263
  var formatFixHint = (issue, summary) => {
10998
11264
  const opNum = issue.operation_index + 1;
10999
11265
  const opInfo = summary?.operations[issue.operation_index];
11000
- const label = opInfo ? `${opInfo.type} "${opInfo.description}"` : `operation`;
11266
+ const label = opInfo ? `${opInfo.type} "${opInfo.description}"` : "operation";
11001
11267
  const severity = issue.severity === "error" ? "ERROR" : "WARNING";
11002
- return ` ${severity} [Op ${opNum}] (${label}): ${issue.code} \u2014 ${issue.message}`;
11268
+ return ` ${severity} [Op ${opNum}] (${label}): ${issue.code} - ${issue.message}`;
11003
11269
  };
11004
11270
  var formatValidationIssues = (response) => {
11005
11271
  const v = response.validation;
11006
11272
  const lines = [];
11007
11273
  if (response.summary) {
11008
11274
  lines.push(`Summary: ${response.summary.operation_count} operation(s)`);
11009
- lines.push(``);
11275
+ lines.push("");
11010
11276
  }
11011
11277
  const errors = v.issues.filter((i) => i.severity === "error");
11012
11278
  const warnings = v.issues.filter((i) => i.severity === "warning");
@@ -11015,165 +11281,272 @@ var formatValidationIssues = (response) => {
11015
11281
  for (const issue of errors) {
11016
11282
  lines.push(formatFixHint(issue, response.summary));
11017
11283
  }
11018
- lines.push(``);
11284
+ lines.push("");
11019
11285
  }
11020
11286
  if (warnings.length > 0) {
11021
11287
  lines.push(`Warnings (${warnings.length}):`);
11022
11288
  for (const issue of warnings) {
11023
11289
  lines.push(formatFixHint(issue, response.summary));
11024
11290
  }
11025
- lines.push(``);
11291
+ lines.push("");
11026
11292
  }
11027
11293
  if (!v.scope_check?.valid) {
11028
11294
  lines.push(`Scope issue: Missing scopes: ${v.scope_check.missingScopes.join(", ")}`);
11029
- lines.push(`The user may need to re-authorize the portal with write permissions.`);
11030
- lines.push(``);
11295
+ lines.push("The user may need to re-authorize the portal with write permissions.");
11296
+ lines.push("");
11031
11297
  }
11032
11298
  return lines;
11033
11299
  };
11300
+ var submitPortalDraft = async (portalId, dryRun, deps) => {
11301
+ const draft = getDraft(portalId);
11302
+ if (!draft) {
11303
+ return {
11304
+ kind: "error",
11305
+ message: "No draft plan exists. Start one with build_plan first."
11306
+ };
11307
+ }
11308
+ if (draft.operations.length === 0) {
11309
+ return {
11310
+ kind: "error",
11311
+ message: "Draft plan has no operations. Use build_plan to add operations before submitting."
11312
+ };
11313
+ }
11314
+ try {
11315
+ const response = await Effect89.runPromise(
11316
+ deps.ws.sendPlanCreate(portalId, {
11317
+ title: draft.title,
11318
+ description: draft.description,
11319
+ operations: draft.operations,
11320
+ dry_run: dryRun
11321
+ })
11322
+ );
11323
+ if (!response.success && !response.validation) {
11324
+ return {
11325
+ kind: "error",
11326
+ message: `Failed to submit plan: ${response.error ?? "Unknown error"}`
11327
+ };
11328
+ }
11329
+ if (!response.success && response.validation) {
11330
+ return {
11331
+ kind: "validation_failed",
11332
+ response
11333
+ };
11334
+ }
11335
+ if (response.dry_run) {
11336
+ return {
11337
+ kind: "dry_run_success",
11338
+ response
11339
+ };
11340
+ }
11341
+ clearDraft(portalId);
11342
+ return {
11343
+ kind: "submit_success",
11344
+ response
11345
+ };
11346
+ } catch (e) {
11347
+ const message = e instanceof Error ? e.message : String(e);
11348
+ return {
11349
+ kind: "error",
11350
+ message: `Error submitting plan: ${message}`
11351
+ };
11352
+ }
11353
+ };
11354
+ var toKeyedResult = (result) => {
11355
+ switch (result.kind) {
11356
+ case "error":
11357
+ return { success: false, error: result.message };
11358
+ case "validation_failed":
11359
+ return {
11360
+ success: false,
11361
+ error: "Plan validation failed",
11362
+ summary: result.response.summary,
11363
+ validation: result.response.validation,
11364
+ scope_warning: result.response.scope_warning
11365
+ };
11366
+ case "dry_run_success":
11367
+ return {
11368
+ success: true,
11369
+ dry_run: true,
11370
+ summary: result.response.summary,
11371
+ validation: result.response.validation,
11372
+ scope_warning: result.response.scope_warning
11373
+ };
11374
+ case "submit_success":
11375
+ return {
11376
+ success: true,
11377
+ dry_run: false,
11378
+ plan_id: result.response.plan_id,
11379
+ summary: result.response.summary,
11380
+ validation: result.response.validation,
11381
+ scope_warning: result.response.scope_warning
11382
+ };
11383
+ }
11384
+ };
11385
+ var formatSinglePortalResult = (result) => {
11386
+ if (result.kind === "error") {
11387
+ return {
11388
+ text: result.message,
11389
+ isError: true
11390
+ };
11391
+ }
11392
+ if (result.kind === "validation_failed") {
11393
+ const response2 = result.response;
11394
+ const lines2 = [
11395
+ "Plan validation failed - the plan was NOT saved. Fix the issues below and resubmit.",
11396
+ "",
11397
+ ...formatValidationIssues(response2),
11398
+ "Use build_plan with edits to fix issues, then submit_plan again."
11399
+ ];
11400
+ return { text: lines2.join("\n") };
11401
+ }
11402
+ if (result.kind === "dry_run_success") {
11403
+ const response2 = result.response;
11404
+ const summary2 = response2.summary;
11405
+ const opLines2 = summary2.operations.map(
11406
+ (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11407
+ );
11408
+ const lines2 = [
11409
+ "Validation passed - plan is ready to submit.",
11410
+ "",
11411
+ "Summary:",
11412
+ ` Operations: ${summary2.operation_count}`,
11413
+ ` Total records affected: ${summary2.total_record_count}`,
11414
+ "",
11415
+ "Operations:",
11416
+ ...opLines2
11417
+ ];
11418
+ if (response2.validation?.issues && response2.validation.issues.length > 0) {
11419
+ lines2.push("", ...formatValidationIssues(response2));
11420
+ }
11421
+ if (response2.scope_warning) {
11422
+ lines2.push("", `WARNING: ${response2.scope_warning}`);
11423
+ }
11424
+ lines2.push("", "Draft is preserved. Call submit_plan() (without dry_run) to save the plan.");
11425
+ return { text: lines2.join("\n") };
11426
+ }
11427
+ const response = result.response;
11428
+ const summary = response.summary;
11429
+ const opLines = summary.operations.map(
11430
+ (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11431
+ );
11432
+ const lines = [
11433
+ "Plan submitted successfully.",
11434
+ "",
11435
+ `Plan ID: ${response.plan_id}`,
11436
+ "Status: DRAFT (awaiting review in HubSpot UI)",
11437
+ "",
11438
+ "Summary:",
11439
+ ` Operations: ${summary.operation_count}`,
11440
+ ` Total records affected: ${summary.total_record_count}`,
11441
+ "",
11442
+ "Operations:",
11443
+ ...opLines
11444
+ ];
11445
+ if (response.validation) {
11446
+ const validation = response.validation;
11447
+ if (validation.issues && validation.issues.length > 0) {
11448
+ lines.push("", "Validation warnings:");
11449
+ for (const issue of validation.issues) {
11450
+ lines.push(` - [Op ${issue.operation_index + 1}] ${issue.code}: ${issue.message}`);
11451
+ }
11452
+ }
11453
+ if (!validation.scope_check?.valid) {
11454
+ lines.push("", `Scope warning: Missing scopes: ${validation.scope_check.missingScopes.join(", ")}`);
11455
+ }
11456
+ }
11457
+ if (response.scope_warning) {
11458
+ lines.push("", `WARNING: ${response.scope_warning}`);
11459
+ }
11460
+ lines.push("", "The user can review and execute this plan in their HubSpot settings page.");
11461
+ return { text: lines.join("\n") };
11462
+ };
11034
11463
  function registerSubmitPlanTool(server2, deps) {
11035
11464
  server2.registerTool("submit_plan", {
11036
- description: `Validate and/or submit the current draft write plan.
11465
+ description: `Validate and/or submit draft write plans.
11466
+
11467
+ IMPORTANT: Always present the plan to the user and get explicit confirmation BEFORE calling this tool. Never submit a plan without user approval.
11037
11468
 
11038
- IMPORTANT: Always present the plan to the user and get their explicit confirmation BEFORE calling this tool. Never submit a plan without user approval.
11469
+ PORTAL TARGETING:
11470
+ Optionally pass portalIds to submit one or more specific portal drafts.
11471
+ If omitted, the selected portal is used.
11472
+ When portalIds is explicit, results are a JSON object keyed by portal ID.
11039
11473
 
11040
11474
  MODES:
11041
- - dry_run: true \u2014 Validate only. Runs full server-side validation (scopes, HubSpot checks) without saving. The draft is preserved so you can fix issues with build_plan.
11042
- - dry_run: false (default) \u2014 Validate AND save. If valid, saves the plan as a DRAFT for user review in HubSpot. The draft is cleared on success.
11475
+ - dry_run: true - Validate only. Runs full server-side validation (scopes, HubSpot checks) without saving. Drafts are preserved.
11476
+ - dry_run: false (default) - Validate AND save. Successful portals are saved as DRAFT and their drafts are cleared.
11043
11477
 
11044
11478
  RECOMMENDED WORKFLOW:
11045
11479
  1. Build the plan with build_plan
11046
11480
  2. Present the plan to the user and ask for confirmation
11047
11481
  3. Call submit_plan(dry_run: true) to validate
11048
11482
  4. Fix any issues with build_plan(edits: [...])
11049
- 5. Call submit_plan() to save after user confirms
11050
-
11051
- After a successful submission, the user can review and execute the plan in their HubSpot settings page.`,
11483
+ 5. Call submit_plan() to save after user confirms`,
11052
11484
  inputSchema: {
11053
- dry_run: z63.boolean().optional().describe("If true, validate only without saving. Defaults to false.")
11054
- }
11055
- }, async ({ dry_run }) => {
11056
- const draft = getDraft();
11057
- if (!draft) {
11058
- return {
11059
- content: [{ type: "text", text: "No draft plan exists. Start one with build_plan first." }],
11060
- isError: true
11061
- };
11062
- }
11063
- if (draft.operations.length === 0) {
11064
- return {
11065
- content: [{ type: "text", text: "Draft plan has no operations. Use build_plan to add operations before submitting." }],
11066
- isError: true
11067
- };
11068
- }
11069
- const portalId = deps.getSelectedPortalId();
11070
- if (portalId === null) {
11485
+ dry_run: z63.boolean().optional().describe("If true, validate only without saving. Defaults to false."),
11486
+ portalIds: z63.array(z63.number()).optional().describe("Optional explicit portal IDs to submit. If provided, results are keyed by portal ID.")
11487
+ }
11488
+ }, async ({ dry_run, portalIds }) => {
11489
+ const portalResolution = resolvePortalIds(
11490
+ portalIds,
11491
+ deps.getSelectedPortalId(),
11492
+ deps.getPortals()
11493
+ );
11494
+ if ("error" in portalResolution) {
11071
11495
  return {
11072
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
11496
+ content: [{ type: "text", text: portalResolution.error }],
11073
11497
  isError: true
11074
11498
  };
11075
11499
  }
11076
- try {
11077
- const response = await Effect89.runPromise(
11078
- deps.ws.sendPlanCreate(portalId, {
11079
- title: draft.title,
11080
- description: draft.description,
11081
- operations: draft.operations,
11082
- dry_run: dry_run ?? false
11083
- })
11084
- );
11085
- if (!response.success && !response.validation) {
11086
- return {
11087
- content: [{ type: "text", text: `Failed to submit plan: ${response.error ?? "Unknown error"}` }],
11088
- isError: true
11089
- };
11090
- }
11091
- if (!response.success && response.validation) {
11092
- const lines2 = [
11093
- `Plan validation failed \u2014 the plan was NOT saved. Fix the issues below and resubmit.`,
11094
- ``,
11095
- ...formatValidationIssues(response),
11096
- `Use build_plan with edits to fix issues, then submit_plan again.`
11097
- ];
11098
- return { content: [{ type: "text", text: lines2.join("\n") }] };
11099
- }
11100
- if (response.dry_run) {
11101
- const summary2 = response.summary;
11102
- const opLines2 = summary2.operations.map(
11103
- (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11104
- );
11105
- const lines2 = [
11106
- `Validation passed \u2014 plan is ready to submit.`,
11107
- ``,
11108
- `Summary:`,
11109
- ` Operations: ${summary2.operation_count}`,
11110
- ` Total records affected: ${summary2.total_record_count}`,
11111
- ``,
11112
- `Operations:`,
11113
- ...opLines2
11114
- ];
11115
- if (response.validation?.issues && response.validation.issues.length > 0) {
11116
- lines2.push(``, ...formatValidationIssues(response));
11117
- }
11118
- if (response.scope_warning) {
11119
- lines2.push(``, `WARNING: ${response.scope_warning}`);
11500
+ const targetPortalIds = portalResolution.portalIds;
11501
+ const dryRun = dry_run ?? false;
11502
+ if (portalResolution.explicit) {
11503
+ const keyed = {};
11504
+ let hadAnyErrors = false;
11505
+ for (const portalId2 of targetPortalIds) {
11506
+ const result2 = await submitPortalDraft(portalId2, dryRun, deps);
11507
+ const keyedResult = toKeyedResult(result2);
11508
+ keyed[String(portalId2)] = keyedResult;
11509
+ if (!keyedResult.success) {
11510
+ hadAnyErrors = true;
11120
11511
  }
11121
- lines2.push(``, `Draft is preserved. Call submit_plan() (without dry_run) to save the plan.`);
11122
- return { content: [{ type: "text", text: lines2.join("\n") }] };
11123
11512
  }
11124
- clearDraft();
11125
- const summary = response.summary;
11126
- const opLines = summary.operations.map(
11127
- (op, i) => ` ${i + 1}. [${op.type}] ${op.description} (${op.record_count} records)`
11128
- );
11129
- const lines = [
11130
- `Plan submitted successfully.`,
11131
- ``,
11132
- `Plan ID: ${response.plan_id}`,
11133
- `Status: DRAFT (awaiting review in HubSpot UI)`,
11134
- ``,
11135
- `Summary:`,
11136
- ` Operations: ${summary.operation_count}`,
11137
- ` Total records affected: ${summary.total_record_count}`,
11138
- ``,
11139
- `Operations:`,
11140
- ...opLines
11141
- ];
11142
- if (response.validation) {
11143
- const v = response.validation;
11144
- if (v.issues && v.issues.length > 0) {
11145
- lines.push(``, `Validation warnings:`);
11146
- for (const issue of v.issues) {
11147
- lines.push(` - [Op ${issue.operation_index + 1}] ${issue.code}: ${issue.message}`);
11148
- }
11149
- }
11150
- if (!v.scope_check?.valid) {
11151
- lines.push(``, `Scope warning: Missing scopes: ${v.scope_check.missingScopes.join(", ")}`);
11152
- }
11153
- }
11154
- if (response.scope_warning) {
11155
- lines.push(``, `WARNING: ${response.scope_warning}`);
11156
- }
11157
- lines.push(``, `The user can review and execute this plan in their HubSpot settings page.`);
11158
- return { content: [{ type: "text", text: lines.join("\n") }] };
11159
- } catch (e) {
11160
- const message = e instanceof Error ? e.message : String(e);
11161
11513
  return {
11162
- content: [{ type: "text", text: `Error submitting plan: ${message}` }],
11163
- isError: true
11514
+ content: [{ type: "text", text: JSON.stringify(keyed, null, 2) }],
11515
+ ...hadAnyErrors ? { isError: true } : {}
11164
11516
  };
11165
11517
  }
11518
+ const portalId = targetPortalIds[0];
11519
+ const result = await submitPortalDraft(portalId, dryRun, deps);
11520
+ const formatted = formatSinglePortalResult(result);
11521
+ return {
11522
+ content: [{ type: "text", text: formatted.text }],
11523
+ ...formatted.isError ? { isError: true } : {}
11524
+ };
11166
11525
  });
11167
11526
  }
11168
11527
 
11169
11528
  // src/tools/get_plans.ts
11170
11529
  import { z as z64 } from "zod";
11171
11530
  import { Effect as Effect90 } from "effect";
11531
+ var serialiseDraft2 = (portalId) => {
11532
+ const draft = getDraft(portalId);
11533
+ if (!draft) return null;
11534
+ return {
11535
+ title: draft.title,
11536
+ description: draft.description,
11537
+ operationCount: draft.operations.length
11538
+ };
11539
+ };
11172
11540
  function registerGetPlansTool(server2, deps) {
11173
11541
  server2.registerTool("get_plans", {
11174
- description: `List write plans for the selected HubSpot portal. Shows plans created by AI along with their current execution status.
11542
+ description: `List write plans for one or more HubSpot portals, including current local draft state.
11175
11543
 
11176
- Use this to check on previously submitted plans, see if they've been reviewed, or find failed plans that need attention.
11544
+ Use this to check submitted plans and view draft readiness before calling submit_plan.
11545
+
11546
+ PORTAL TARGETING:
11547
+ Optionally pass portalIds to fetch plans for specific portals.
11548
+ If omitted, the selected portal is used.
11549
+ When portalIds is explicit, results are a JSON object keyed by portal ID.
11177
11550
 
11178
11551
  Statuses:
11179
11552
  - DRAFT: Awaiting user review in HubSpot UI
@@ -11182,16 +11555,59 @@ Statuses:
11182
11555
  - FAILED: Execution failed
11183
11556
  - REJECTED: User rejected the plan`,
11184
11557
  inputSchema: {
11185
- status: z64.enum(["DRAFT", "REJECTED", "EXECUTING", "EXECUTED", "FAILED"]).optional().describe("Filter by plan status. Omit to show all plans.")
11186
- }
11187
- }, async ({ status }) => {
11188
- const portalId = deps.getSelectedPortalId();
11189
- if (portalId === null) {
11558
+ status: z64.enum(["DRAFT", "REJECTED", "EXECUTING", "EXECUTED", "FAILED"]).optional().describe("Filter by plan status. Omit to show all plans."),
11559
+ portalIds: z64.array(z64.number()).optional().describe("Optional explicit portal IDs. If provided, results are keyed by portal ID.")
11560
+ }
11561
+ }, async ({ status, portalIds }) => {
11562
+ const portalResolution = resolvePortalIds(
11563
+ portalIds,
11564
+ deps.getSelectedPortalId(),
11565
+ deps.getPortals()
11566
+ );
11567
+ if ("error" in portalResolution) {
11190
11568
  return {
11191
- content: [{ type: "text", text: "No portal selected. Use the set_portal tool first." }],
11569
+ content: [{ type: "text", text: portalResolution.error }],
11192
11570
  isError: true
11193
11571
  };
11194
11572
  }
11573
+ const targetPortalIds = portalResolution.portalIds;
11574
+ if (portalResolution.explicit) {
11575
+ const keyed = {};
11576
+ let hadAnyErrors = false;
11577
+ for (const portalId2 of targetPortalIds) {
11578
+ const draft2 = serialiseDraft2(portalId2);
11579
+ try {
11580
+ const response = await Effect90.runPromise(
11581
+ deps.ws.sendPlanList(portalId2, status)
11582
+ );
11583
+ if (!response.success) {
11584
+ hadAnyErrors = true;
11585
+ keyed[String(portalId2)] = {
11586
+ draft: draft2,
11587
+ error: `Failed to list plans: ${response.error}`
11588
+ };
11589
+ continue;
11590
+ }
11591
+ keyed[String(portalId2)] = {
11592
+ draft: draft2,
11593
+ plans: response.plans ?? []
11594
+ };
11595
+ } catch (e) {
11596
+ hadAnyErrors = true;
11597
+ const message = e instanceof Error ? e.message : String(e);
11598
+ keyed[String(portalId2)] = {
11599
+ draft: draft2,
11600
+ error: `Error listing plans: ${message}`
11601
+ };
11602
+ }
11603
+ }
11604
+ return {
11605
+ content: [{ type: "text", text: JSON.stringify(keyed, null, 2) }],
11606
+ ...hadAnyErrors ? { isError: true } : {}
11607
+ };
11608
+ }
11609
+ const portalId = targetPortalIds[0];
11610
+ const draft = serialiseDraft2(portalId);
11195
11611
  try {
11196
11612
  const response = await Effect90.runPromise(
11197
11613
  deps.ws.sendPlanList(portalId, status)
@@ -11203,19 +11619,22 @@ Statuses:
11203
11619
  };
11204
11620
  }
11205
11621
  const plans = response.plans ?? [];
11622
+ const draftLine = draft ? `Current draft: "${draft.title}" (${draft.operationCount} operation(s))` : "Current draft: none";
11206
11623
  if (plans.length === 0) {
11207
11624
  const filterMsg = status ? ` with status ${status}` : "";
11208
11625
  return {
11209
- content: [{ type: "text", text: `No plans found${filterMsg}.` }]
11626
+ content: [{ type: "text", text: `${draftLine}
11627
+ No plans found${filterMsg}.` }]
11210
11628
  };
11211
11629
  }
11212
- const lines = plans.map((p) => {
11213
- const executed = p.executed_at ? ` | Executed: ${p.executed_at}` : "";
11214
- return `- [${p.status}] ${p.title} (${p.operation_count} ops, ${p.total_record_count} records) | Created: ${p.created_at}${executed} | ID: ${p.id}`;
11630
+ const lines = plans.map((plan) => {
11631
+ const executed = plan.executed_at ? ` | Executed: ${plan.executed_at}` : "";
11632
+ return `- [${plan.status}] ${plan.title} (${plan.operation_count} ops, ${plan.total_record_count} records) | Created: ${plan.created_at}${executed} | ID: ${plan.id}`;
11215
11633
  });
11216
11634
  const result = [
11217
11635
  `Plans for portal ${portalId}:`,
11218
- ``,
11636
+ draftLine,
11637
+ "",
11219
11638
  ...lines
11220
11639
  ].join("\n");
11221
11640
  return { content: [{ type: "text", text: result }] };
@@ -11229,182 +11648,566 @@ Statuses:
11229
11648
  });
11230
11649
  }
11231
11650
 
11651
+ // src/pure/sync-lifecycle.ts
11652
+ var dedupePortalIds2 = (portalIds) => {
11653
+ const seen = /* @__PURE__ */ new Set();
11654
+ const deduped = [];
11655
+ for (const portalId of portalIds) {
11656
+ if (seen.has(portalId)) continue;
11657
+ seen.add(portalId);
11658
+ deduped.push(portalId);
11659
+ }
11660
+ return deduped;
11661
+ };
11662
+ var orderPortalWarmupIds = (portals, selectedPortalId2) => {
11663
+ const portalIds = dedupePortalIds2(portals.map((portal) => portal.target_portal));
11664
+ if (selectedPortalId2 === null || !portalIds.includes(selectedPortalId2)) {
11665
+ return portalIds;
11666
+ }
11667
+ return [selectedPortalId2, ...portalIds.filter((portalId) => portalId !== selectedPortalId2)];
11668
+ };
11669
+ var resolveDiffFlow = (portalId, selectedPortalId2) => selectedPortalId2 === portalId ? "diff_then_subscribe" : "diff";
11670
+ var shouldSubscribeAfterDiffComplete = (portalId, selectedPortalId2, success) => success && selectedPortalId2 === portalId;
11671
+
11672
+ // src/effects/artifact-queue.ts
11673
+ import { Effect as Effect91 } from "effect";
11674
+ var DEFAULT_OPTIONS = {
11675
+ maxRetries: 2,
11676
+ baseBackoffMs: 500
11677
+ };
11678
+ var DEFAULT_PROCESSOR = (portalId, artifact) => Effect91.sync(() => {
11679
+ console.error(
11680
+ `[artifact-queue] No processor configured for portal ${portalId} artifact ${artifact.object_type}`
11681
+ );
11682
+ return false;
11683
+ });
11684
+ var selectedPortalId = null;
11685
+ var inFlightJob = null;
11686
+ var workerRunning = false;
11687
+ var workerGeneration = 0;
11688
+ var options = { ...DEFAULT_OPTIONS };
11689
+ var processArtifact2 = DEFAULT_PROCESSOR;
11690
+ var pendingJobs = [];
11691
+ var activeKeys = /* @__PURE__ */ new Set();
11692
+ var drainWaiters = /* @__PURE__ */ new Map();
11693
+ var makeJobKey = (portalId, artifactId) => `${portalId}:${artifactId}`;
11694
+ var sleep = (ms) => new Promise((resolve) => {
11695
+ setTimeout(resolve, ms);
11696
+ });
11697
+ var isPortalDrained = (portalId) => inFlightJob?.portalId !== portalId && !pendingJobs.some((job) => job.portalId === portalId);
11698
+ var resolvePortalWaiters = (portalId, drained) => {
11699
+ const waiters = drainWaiters.get(portalId);
11700
+ if (!waiters) return;
11701
+ for (const waiter of waiters) {
11702
+ if (waiter.timeoutId !== null) {
11703
+ clearTimeout(waiter.timeoutId);
11704
+ }
11705
+ waiter.resolve(drained);
11706
+ }
11707
+ drainWaiters.delete(portalId);
11708
+ };
11709
+ var maybeResolvePortalDrain = (portalId) => {
11710
+ if (isPortalDrained(portalId)) {
11711
+ resolvePortalWaiters(portalId, true);
11712
+ }
11713
+ };
11714
+ var dequeueNextJob = () => {
11715
+ if (pendingJobs.length === 0) return null;
11716
+ if (selectedPortalId === null) {
11717
+ return pendingJobs.shift() ?? null;
11718
+ }
11719
+ const selectedIdx = pendingJobs.findIndex((job) => job.portalId === selectedPortalId);
11720
+ if (selectedIdx === -1) {
11721
+ return pendingJobs.shift() ?? null;
11722
+ }
11723
+ return pendingJobs.splice(selectedIdx, 1)[0] ?? null;
11724
+ };
11725
+ var processJobWithRetry = async (job, generation) => {
11726
+ const maxAttempts = options.maxRetries + 1;
11727
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
11728
+ if (generation !== workerGeneration) return;
11729
+ const succeeded = await Effect91.runPromise(
11730
+ processArtifact2(job.portalId, job.artifact).pipe(
11731
+ Effect91.catchAll(
11732
+ (error) => Effect91.sync(() => {
11733
+ console.error(
11734
+ `[artifact-queue] Ingest failed for portal ${job.portalId} artifact ${job.artifact.object_type}:`,
11735
+ error
11736
+ );
11737
+ return false;
11738
+ })
11739
+ )
11740
+ )
11741
+ );
11742
+ if (succeeded) return;
11743
+ if (attempt >= maxAttempts) {
11744
+ console.error(
11745
+ `[artifact-queue] Exhausted retries for portal ${job.portalId} artifact ${job.artifact.object_type}`
11746
+ );
11747
+ return;
11748
+ }
11749
+ const backoffMs = options.baseBackoffMs * 2 ** (attempt - 1);
11750
+ console.error(
11751
+ `[artifact-queue] Retry ${attempt}/${options.maxRetries} for portal ${job.portalId} artifact ${job.artifact.object_type} in ${backoffMs}ms`
11752
+ );
11753
+ await sleep(backoffMs);
11754
+ }
11755
+ };
11756
+ var runWorker = async (generation) => {
11757
+ while (generation === workerGeneration) {
11758
+ const job = dequeueNextJob();
11759
+ if (!job) {
11760
+ if (generation === workerGeneration) {
11761
+ workerRunning = false;
11762
+ }
11763
+ return;
11764
+ }
11765
+ inFlightJob = job;
11766
+ try {
11767
+ await processJobWithRetry(job, generation);
11768
+ } catch (error) {
11769
+ console.error("[artifact-queue] Unexpected worker failure:", error);
11770
+ } finally {
11771
+ if (generation !== workerGeneration) return;
11772
+ inFlightJob = null;
11773
+ activeKeys.delete(job.key);
11774
+ maybeResolvePortalDrain(job.portalId);
11775
+ }
11776
+ }
11777
+ };
11778
+ var startWorkerIfNeeded = () => {
11779
+ if (workerRunning || pendingJobs.length === 0) return;
11780
+ workerRunning = true;
11781
+ const generation = workerGeneration;
11782
+ void runWorker(generation);
11783
+ };
11784
+ var configureArtifactQueue = (processor, nextOptions) => {
11785
+ processArtifact2 = processor;
11786
+ options = {
11787
+ ...DEFAULT_OPTIONS,
11788
+ ...nextOptions ?? {}
11789
+ };
11790
+ startWorkerIfNeeded();
11791
+ };
11792
+ var enqueueArtifacts = (portalId, artifacts) => {
11793
+ let enqueued = 0;
11794
+ for (const artifact of artifacts) {
11795
+ const key = makeJobKey(portalId, artifact.id);
11796
+ if (activeKeys.has(key)) continue;
11797
+ activeKeys.add(key);
11798
+ pendingJobs.push({ portalId, artifact, key });
11799
+ enqueued += 1;
11800
+ }
11801
+ if (enqueued > 0) {
11802
+ startWorkerIfNeeded();
11803
+ }
11804
+ };
11805
+ var waitForPortalDrain = (portalId, timeoutMs) => {
11806
+ if (!hasPendingForPortal(portalId)) {
11807
+ return Promise.resolve(true);
11808
+ }
11809
+ if (timeoutMs <= 0) {
11810
+ return Promise.resolve(false);
11811
+ }
11812
+ return new Promise((resolve) => {
11813
+ const waiter = {
11814
+ resolve,
11815
+ timeoutId: null
11816
+ };
11817
+ const timeoutId = setTimeout(() => {
11818
+ const waiters2 = drainWaiters.get(portalId);
11819
+ if (waiters2) {
11820
+ waiters2.delete(waiter);
11821
+ if (waiters2.size === 0) {
11822
+ drainWaiters.delete(portalId);
11823
+ }
11824
+ }
11825
+ waiter.timeoutId = null;
11826
+ resolve(false);
11827
+ }, timeoutMs);
11828
+ waiter.timeoutId = timeoutId;
11829
+ const waiters = drainWaiters.get(portalId) ?? /* @__PURE__ */ new Set();
11830
+ waiters.add(waiter);
11831
+ drainWaiters.set(portalId, waiters);
11832
+ maybeResolvePortalDrain(portalId);
11833
+ });
11834
+ };
11835
+ var hasPendingForPortal = (portalId) => inFlightJob?.portalId === portalId || pendingJobs.some((job) => job.portalId === portalId);
11836
+ var setSelectedPortal = (portalId) => {
11837
+ selectedPortalId = portalId;
11838
+ };
11839
+
11840
+ // src/effects/ensure-fresh.ts
11841
+ import fs8 from "fs";
11842
+ import { Effect as Effect92, pipe as pipe77 } from "effect";
11843
+ var DEFAULT_MAX_AGE_MS = 3e5;
11844
+ var DEFAULT_QUEUE_DRAIN_TIMEOUT_MS = 12e4;
11845
+ var DEFAULT_DIFF_TIMEOUT_MS = 3e4;
11846
+ var DIFF_POLL_INTERVAL_MS = 100;
11847
+ var LAST_DIFF_CHECKED_AT_KEY = "last_diff_checked_at";
11848
+ var createEnsureFresh = (deps, options2) => {
11849
+ const maxAgeMs = options2?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
11850
+ const queueDrainTimeoutMs = options2?.queueDrainTimeoutMs ?? DEFAULT_QUEUE_DRAIN_TIMEOUT_MS;
11851
+ const diffTimeoutMs = options2?.diffTimeoutMs ?? DEFAULT_DIFF_TIMEOUT_MS;
11852
+ return async (portalId) => {
11853
+ const selectedPortalId2 = deps.getSelectedPortalId();
11854
+ if (selectedPortalId2 === portalId) {
11855
+ return;
11856
+ }
11857
+ const drained = await deps.waitForPortalDrain(portalId, queueDrainTimeoutMs);
11858
+ if (!drained) {
11859
+ throw new Error(`Timed out waiting for portal ${portalId} artifact queue drain.`);
11860
+ }
11861
+ if (!deps.hasLocalDatabase(portalId)) {
11862
+ throw new Error(
11863
+ `Portal ${portalId} has no synced data yet. Background sync in progress - retry shortly.`
11864
+ );
11865
+ }
11866
+ const checkedAt = await deps.getFreshnessCheckedAt(portalId);
11867
+ const stale = checkedAt === null || Date.now() - checkedAt.getTime() > maxAgeMs;
11868
+ if (!stale) {
11869
+ return;
11870
+ }
11871
+ const objectSyncTimes = await deps.getObjectSyncTimes(portalId);
11872
+ if (Object.keys(objectSyncTimes).length === 0) {
11873
+ await deps.setFreshnessCheckedAt(portalId, /* @__PURE__ */ new Date());
11874
+ return;
11875
+ }
11876
+ const diffRequestedAt = Date.now();
11877
+ await deps.sendSyncDiff(portalId, objectSyncTimes);
11878
+ const completed = await deps.waitForDiffCompletion(portalId, diffRequestedAt, diffTimeoutMs);
11879
+ if (!completed) {
11880
+ throw new Error(`Timed out waiting for sync diff completion for portal ${portalId}.`);
11881
+ }
11882
+ await deps.setFreshnessCheckedAt(portalId, /* @__PURE__ */ new Date());
11883
+ };
11884
+ };
11885
+ var parseDate = (value) => {
11886
+ if (!value) return null;
11887
+ const parsed = Date.parse(value);
11888
+ return Number.isNaN(parsed) ? null : new Date(parsed);
11889
+ };
11890
+ var sleep2 = (ms) => new Promise((resolve) => {
11891
+ setTimeout(resolve, ms);
11892
+ });
11893
+ var runDatabaseForPortal = (portalId, getEncryptionKey, run) => Effect92.runPromise(
11894
+ pipe77(
11895
+ DatabaseService,
11896
+ Effect92.flatMap((db) => run(db)),
11897
+ Effect92.provide(makeDatabaseLive(portalId, getEncryptionKey()))
11898
+ )
11899
+ );
11900
+ var waitForPortalSyncedAtAfter = (portalState, portalId, sinceMs, timeoutMs) => (async () => {
11901
+ const startedAt = Date.now();
11902
+ while (Date.now() - startedAt <= timeoutMs) {
11903
+ const syncedAtMs = await Effect92.runPromise(
11904
+ pipe77(
11905
+ portalState.get(portalId),
11906
+ Effect92.map((state) => state.syncedAt.getTime()),
11907
+ Effect92.catchAll(() => Effect92.succeed(0))
11908
+ )
11909
+ );
11910
+ if (syncedAtMs > sinceMs) {
11911
+ return true;
11912
+ }
11913
+ await sleep2(DIFF_POLL_INTERVAL_MS);
11914
+ }
11915
+ return false;
11916
+ })();
11917
+ var makeEnsureFresh = (deps, options2) => createEnsureFresh(
11918
+ {
11919
+ getSelectedPortalId: deps.getSelectedPortalId,
11920
+ waitForPortalDrain,
11921
+ hasLocalDatabase: (portalId) => fs8.existsSync(dbPath(portalId)),
11922
+ getFreshnessCheckedAt: (portalId) => runDatabaseForPortal(
11923
+ portalId,
11924
+ deps.getEncryptionKey,
11925
+ (db) => pipe77(
11926
+ db.getMetadata(LAST_DIFF_CHECKED_AT_KEY),
11927
+ Effect92.map((value) => parseDate(value))
11928
+ )
11929
+ ),
11930
+ getObjectSyncTimes: (portalId) => runDatabaseForPortal(portalId, deps.getEncryptionKey, (db) => db.getObjectSyncTimes()),
11931
+ sendSyncDiff: (portalId, lastSyncTimes) => Effect92.runPromise(
11932
+ deps.ws.sendSyncDiff(portalId, lastSyncTimes).pipe(
11933
+ Effect92.mapError(
11934
+ (error) => error instanceof Error ? error : new Error(`Failed to request sync diff for portal ${portalId}`)
11935
+ )
11936
+ )
11937
+ ).then(() => void 0),
11938
+ waitForDiffCompletion: (portalId, sinceMs, timeoutMs) => waitForPortalSyncedAtAfter(deps.portalState, portalId, sinceMs, timeoutMs),
11939
+ setFreshnessCheckedAt: (portalId, checkedAt) => runDatabaseForPortal(
11940
+ portalId,
11941
+ deps.getEncryptionKey,
11942
+ (db) => db.setMetadata(LAST_DIFF_CHECKED_AT_KEY, checkedAt.toISOString())
11943
+ ).then(() => void 0)
11944
+ },
11945
+ options2
11946
+ );
11947
+
11232
11948
  // src/index.ts
11233
11949
  var server = new McpServer({
11234
11950
  name: "mcp-pro-client",
11235
11951
  version: "1.0.0"
11236
11952
  });
11237
- var mainProgram = Effect91.gen(function* () {
11953
+ var QUEUE_DRAIN_TIMEOUT_MS = 12e4;
11954
+ var mainProgram = Effect93.gen(function* () {
11238
11955
  const ws = yield* WebSocketService;
11239
11956
  const config = yield* ConfigService;
11240
11957
  const portalState = yield* PortalStateService;
11241
11958
  const getPortalId = () => getSelectedPortalId(config);
11242
- const getEncryptionKey = () => Effect91.runSync(ws.getEncryptionKey());
11243
- registerPortalSelection(server, { ws, config });
11244
- registerQueryTool(server, { getSelectedPortalId: getPortalId, getEncryptionKey });
11245
- registerChartTool(server, { getSelectedPortalId: getPortalId, getEncryptionKey });
11959
+ const getPortals = () => Effect93.runSync(ws.getPortals());
11960
+ const getEncryptionKey = () => Effect93.runSync(ws.getEncryptionKey());
11961
+ const ensureFresh = makeEnsureFresh({
11962
+ getSelectedPortalId: getPortalId,
11963
+ getEncryptionKey,
11964
+ ws,
11965
+ portalState
11966
+ });
11967
+ const handoffSelectedPortal = (nextPortalId) => {
11968
+ const previousPortalId = getSelectedPortalId(config);
11969
+ if (previousPortalId !== null && previousPortalId !== nextPortalId) {
11970
+ Effect93.runSync(
11971
+ pipe78(
11972
+ ws.sendSyncUnsubscribe(previousPortalId),
11973
+ Effect93.catchAll(() => Effect93.void)
11974
+ )
11975
+ );
11976
+ }
11977
+ setSelectedPortal(nextPortalId);
11978
+ Effect93.runSync(config.save({ selectedPortalId: nextPortalId }));
11979
+ Effect93.runFork(pipe78(ws.sendSyncFull(nextPortalId), Effect93.catchAll(() => Effect93.void)));
11980
+ };
11981
+ registerPortalSelection(server, { ws, config, handoffSelectedPortal });
11982
+ registerQueryTool(server, {
11983
+ getSelectedPortalId: getPortalId,
11984
+ getPortals,
11985
+ ensureFresh,
11986
+ getEncryptionKey
11987
+ });
11988
+ registerChartTool(server, {
11989
+ getSelectedPortalId: getPortalId,
11990
+ getPortals,
11991
+ ensureFresh,
11992
+ getEncryptionKey
11993
+ });
11246
11994
  registerStatusTool(server, {
11247
- getConnectionState: () => Effect91.runSync(ws.getState()),
11248
- getPortals: () => Effect91.runSync(ws.getPortals()),
11995
+ getConnectionState: () => Effect93.runSync(ws.getState()),
11996
+ getPortals,
11249
11997
  getSelectedPortalId: getPortalId,
11998
+ ensureFresh,
11250
11999
  getEncryptionKey
11251
12000
  });
11252
- registerBuildPlanTool(server);
12001
+ registerBuildPlanTool(server, {
12002
+ getSelectedPortalId: getPortalId,
12003
+ getPortals
12004
+ });
11253
12005
  registerDescribeOperationsTool(server);
11254
- registerSubmitPlanTool(server, { getSelectedPortalId: getPortalId, ws });
11255
- registerGetPlansTool(server, { getSelectedPortalId: getPortalId, ws });
12006
+ registerSubmitPlanTool(server, {
12007
+ getSelectedPortalId: getPortalId,
12008
+ getPortals,
12009
+ ws
12010
+ });
12011
+ registerGetPlansTool(server, {
12012
+ getSelectedPortalId: getPortalId,
12013
+ getPortals,
12014
+ ws
12015
+ });
11256
12016
  const state = yield* config.load();
11257
12017
  yield* config.save(state);
12018
+ setSelectedPortal(state.selectedPortalId);
11258
12019
  if (state.selectedPortalId !== null) {
11259
- const cached = yield* portalState.load(state.selectedPortalId).pipe(Effect91.catchAll(() => Effect91.succeed(null)));
12020
+ const cached = yield* portalState.load(state.selectedPortalId).pipe(Effect93.catchAll(() => Effect93.succeed(null)));
11260
12021
  if (cached !== null) {
11261
- yield* ws.setSyncedArtifacts(cached);
11262
- yield* Effect91.sync(
12022
+ yield* Effect93.sync(
11263
12023
  () => console.error(`[startup] Loaded ${cached.artifacts.length} cached artifacts for portal ${state.selectedPortalId}`)
11264
12024
  );
11265
12025
  }
11266
12026
  }
12027
+ const pendingPortalFollowUps = /* @__PURE__ */ new Set();
12028
+ const runDiffFlow = (portalId) => {
12029
+ const selectedPortalId2 = getSelectedPortalId(config);
12030
+ const flow = resolveDiffFlow(portalId, selectedPortalId2);
12031
+ if (flow === "diff_then_subscribe") {
12032
+ triggerDiffThenSubscribe(portalId);
12033
+ return;
12034
+ }
12035
+ triggerDiff(portalId);
12036
+ };
12037
+ const schedulePortalFollowUpAfterDrain = (portalId, source) => {
12038
+ if (pendingPortalFollowUps.has(portalId)) {
12039
+ console.error(`[live-sync] Follow-up already scheduled for portal ${portalId}, coalescing ${source}`);
12040
+ return;
12041
+ }
12042
+ pendingPortalFollowUps.add(portalId);
12043
+ Effect93.runFork(
12044
+ pipe78(
12045
+ Effect93.promise(() => waitForPortalDrain(portalId, QUEUE_DRAIN_TIMEOUT_MS)),
12046
+ Effect93.flatMap(
12047
+ (drained) => drained ? Effect93.sync(() => {
12048
+ console.error(`[live-sync] Queue drained for portal ${portalId} after ${source} \u2014 triggering diff flow`);
12049
+ runDiffFlow(portalId);
12050
+ }) : Effect93.sync(
12051
+ () => console.error(`[live-sync] Timed out waiting for portal ${portalId} queue drain after ${source}`)
12052
+ )
12053
+ ),
12054
+ Effect93.ensuring(
12055
+ Effect93.sync(() => {
12056
+ pendingPortalFollowUps.delete(portalId);
12057
+ })
12058
+ ),
12059
+ Effect93.catchAll(
12060
+ (error) => Effect93.sync(() => {
12061
+ console.error(`[live-sync] Failed post-queue follow-up for portal ${portalId}:`, error);
12062
+ })
12063
+ )
12064
+ )
12065
+ );
12066
+ };
12067
+ const subscribeIfStillSelected = (portalId) => {
12068
+ const selectedPortalId2 = getSelectedPortalId(config);
12069
+ if (selectedPortalId2 !== portalId) {
12070
+ console.error(
12071
+ `[live-sync] Skipping subscribe for portal ${portalId} because selected portal is ${selectedPortalId2 ?? "none"}`
12072
+ );
12073
+ return;
12074
+ }
12075
+ Effect93.runFork(pipe78(ws.sendSyncSubscribe(portalId), Effect93.catchAll(() => Effect93.void)));
12076
+ };
12077
+ const triggerDiff = (portalId) => {
12078
+ const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
12079
+ pipe78(
12080
+ DatabaseService,
12081
+ Effect93.flatMap((db) => db.getObjectSyncTimes()),
12082
+ Effect93.provide(dbLayer),
12083
+ Effect93.catchAll(() => Effect93.succeed({})),
12084
+ Effect93.flatMap((syncTimes) => {
12085
+ const count = Object.keys(syncTimes).length;
12086
+ if (count === 0) {
12087
+ return Effect93.sync(() => {
12088
+ console.error(`[live-sync] No watermarks found in DuckDB for portal ${portalId}, skipping diff`);
12089
+ });
12090
+ }
12091
+ return pipe78(
12092
+ Effect93.sync(() => {
12093
+ console.error(`[live-sync] Triggering catch-up diff for portal ${portalId} with ${count} object types: ${JSON.stringify(syncTimes)}`);
12094
+ }),
12095
+ Effect93.flatMap(() => pipe78(ws.sendSyncDiff(portalId, syncTimes), Effect93.catchAll(() => Effect93.void)))
12096
+ );
12097
+ }),
12098
+ Effect93.runFork
12099
+ );
12100
+ };
11267
12101
  const triggerDiffThenSubscribe = (portalId) => {
11268
12102
  const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11269
- pipe77(
12103
+ pipe78(
11270
12104
  DatabaseService,
11271
- Effect91.flatMap((db) => db.getObjectSyncTimes()),
11272
- Effect91.provide(dbLayer),
11273
- Effect91.catchAll(() => Effect91.succeed({})),
11274
- Effect91.tap(
11275
- (syncTimes) => Effect91.sync(() => {
11276
- const count = Object.keys(syncTimes).length;
11277
- if (count > 0) {
12105
+ Effect93.flatMap((db) => db.getObjectSyncTimes()),
12106
+ Effect93.provide(dbLayer),
12107
+ Effect93.catchAll(() => Effect93.succeed({})),
12108
+ Effect93.flatMap((syncTimes) => {
12109
+ const count = Object.keys(syncTimes).length;
12110
+ if (count === 0) {
12111
+ return Effect93.sync(() => {
12112
+ console.error(`[live-sync] No watermarks found in DuckDB for portal ${portalId}, subscribing directly`);
12113
+ subscribeIfStillSelected(portalId);
12114
+ });
12115
+ }
12116
+ return pipe78(
12117
+ Effect93.sync(() => {
11278
12118
  console.error(`[live-sync] Triggering catch-up diff for portal ${portalId} with ${count} object types: ${JSON.stringify(syncTimes)}`);
11279
- Effect91.runFork(pipe77(ws.sendSyncDiff(portalId, syncTimes), Effect91.catchAll(() => Effect91.void)));
11280
- } else {
11281
- console.error(`[live-sync] No watermarks found in DuckDB, subscribing directly for portal ${portalId}`);
11282
- Effect91.runFork(pipe77(ws.sendSyncSubscribe(portalId), Effect91.catchAll(() => Effect91.void)));
11283
- }
11284
- })
11285
- ),
11286
- Effect91.runFork
12119
+ }),
12120
+ Effect93.flatMap(() => pipe78(ws.sendSyncDiff(portalId, syncTimes), Effect93.catchAll(() => Effect93.void)))
12121
+ );
12122
+ }),
12123
+ Effect93.runFork
11287
12124
  );
11288
12125
  };
12126
+ const makeSyncLayer = (portalId) => {
12127
+ const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
12128
+ const wsLayer = Layer6.succeed(WebSocketService, ws);
12129
+ const configLayer = Layer6.succeed(ConfigService, config);
12130
+ const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
12131
+ return SyncLive.pipe(
12132
+ Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
12133
+ );
12134
+ };
12135
+ configureArtifactQueue(
12136
+ (portalId, artifact) => pipe78(
12137
+ SyncService,
12138
+ Effect93.flatMap((sync) => sync.syncArtifacts(portalId, [artifact], [artifact.object_type])),
12139
+ Effect93.flatMap(() => portalState.get(portalId)),
12140
+ Effect93.map((portal) => {
12141
+ const status = portal.artifacts.find((a) => a.object_type === artifact.object_type)?.status;
12142
+ if (status !== "SYNCED") {
12143
+ console.error(
12144
+ `[artifact-queue] Artifact ${artifact.object_type} for portal ${portalId} finished with status ${status ?? "MISSING"}`
12145
+ );
12146
+ }
12147
+ return status === "SYNCED";
12148
+ }),
12149
+ Effect93.catchAll(
12150
+ (err) => Effect93.sync(() => {
12151
+ console.error(
12152
+ `[artifact-queue] Failed ingest for portal ${portalId} artifact ${artifact.object_type}:`,
12153
+ err
12154
+ );
12155
+ return false;
12156
+ })
12157
+ ),
12158
+ Effect93.provide(makeSyncLayer(portalId))
12159
+ )
12160
+ );
11289
12161
  yield* ws.registerHandlers({
11290
12162
  onSyncArtifacts: (portalId, artifacts) => {
11291
12163
  const incomingWithStatus = artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
11292
- const updated = pipe77(
12164
+ const updated = pipe78(
11293
12165
  portalState.setArtifacts(portalId, incomingWithStatus),
11294
- Effect91.tap((s) => ws.setSyncedArtifacts(s)),
11295
- Effect91.tap(
11296
- (s) => Effect91.sync(
12166
+ Effect93.tap(
12167
+ (s) => Effect93.sync(
11297
12168
  () => console.error(`[sync:full] Saved ${s.artifacts.length} artifacts for portal ${portalId}`)
11298
12169
  )
11299
12170
  ),
11300
- Effect91.catchAll(() => Effect91.succeed(null)),
11301
- Effect91.runSync
12171
+ Effect93.catchAll(() => Effect93.succeed(null)),
12172
+ Effect93.runSync
11302
12173
  );
11303
12174
  if (!updated) return;
11304
12175
  const objectTypesToSync = incomingWithStatus.map((a) => a.object_type);
11305
12176
  const toSync = filterUnsyncedArtifacts(updated.artifacts, new Set(objectTypesToSync));
11306
12177
  if (toSync.length === 0) {
11307
- console.error(`[live-sync] All ${artifacts.length} artifacts already SYNCED for portal ${portalId} \u2014 syncing message plugins then catch-up diff`);
11308
- const dbLayer2 = makeDatabaseLive(portalId, getEncryptionKey());
11309
- const wsLayer2 = Layer6.succeed(WebSocketService, ws);
11310
- const configLayer2 = Layer6.succeed(ConfigService, config);
11311
- const portalStateLayer2 = Layer6.succeed(PortalStateService, portalState);
11312
- const syncLayer2 = SyncLive.pipe(
11313
- Layer6.provide(Layer6.mergeAll(wsLayer2, dbLayer2, configLayer2, portalStateLayer2))
12178
+ console.error(
12179
+ `[artifact-queue] All ${artifacts.length} artifacts already SYNCED for portal ${portalId} \u2014 no enqueue needed`
11314
12180
  );
11315
- pipe77(
11316
- SyncService,
11317
- Effect91.flatMap((sync) => sync.syncMessagePlugins(portalId)),
11318
- Effect91.tap(
11319
- () => Effect91.sync(() => {
11320
- console.error(`[live-sync] Message plugins synced for portal ${portalId} \u2014 starting catch-up diff`);
11321
- triggerDiffThenSubscribe(portalId);
11322
- })
11323
- ),
11324
- Effect91.catchAll(
11325
- (err) => Effect91.sync(() => {
11326
- console.error(`[live-sync] Message plugin sync failed:`, err);
11327
- triggerDiffThenSubscribe(portalId);
11328
- })
11329
- ),
11330
- Effect91.provide(syncLayer2),
11331
- Effect91.runFork
12181
+ } else {
12182
+ console.error(
12183
+ `[artifact-queue] Enqueueing ${toSync.length}/${artifacts.length} artifacts for portal ${portalId}`
11332
12184
  );
11333
- return;
12185
+ enqueueArtifacts(portalId, toSync);
11334
12186
  }
11335
- console.error(`[sync] ${toSync.length}/${artifacts.length} artifacts need syncing`);
11336
- const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11337
- const wsLayer = Layer6.succeed(WebSocketService, ws);
11338
- const configLayer = Layer6.succeed(ConfigService, config);
11339
- const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
11340
- const syncLayer = SyncLive.pipe(
11341
- Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
11342
- );
11343
- pipe77(
11344
- SyncService,
11345
- Effect91.flatMap(
11346
- (sync) => Effect91.all([
11347
- sync.syncArtifacts(portalId, updated.artifacts, objectTypesToSync).pipe(
11348
- Effect91.tap(
11349
- (progress) => Effect91.sync(() => {
11350
- console.error(`[sync] Complete: ${progress.completedArtifacts}/${progress.totalArtifacts} artifacts synced`);
11351
- console.error(`[live-sync] Artifact sync done for portal ${portalId} \u2014 starting catch-up diff`);
11352
- triggerDiffThenSubscribe(portalId);
11353
- })
11354
- ),
11355
- Effect91.catchAll(
11356
- (err) => Effect91.sync(() => console.error(`[sync] Sync failed:`, err))
11357
- )
11358
- ),
11359
- sync.syncMessagePlugins(portalId).pipe(
11360
- Effect91.tap(
11361
- () => Effect91.sync(() => console.error(`[sync] Message plugins synced independently for portal ${portalId}`))
11362
- ),
11363
- Effect91.catchAll(
11364
- (err) => Effect91.sync(() => console.error(`[sync] Message plugin sync failed:`, err))
11365
- )
11366
- )
11367
- ], { concurrency: 2 })
11368
- ),
11369
- Effect91.catchAll(() => Effect91.void),
11370
- Effect91.provide(syncLayer),
11371
- Effect91.runFork
11372
- );
12187
+ schedulePortalFollowUpAfterDrain(portalId, "sync:full");
11373
12188
  },
11374
12189
  onNewArtifacts: (portalId, artifacts) => {
11375
12190
  console.error(`[sync:new-artifacts] Discovered ${artifacts.length} new artifact(s) for portal ${portalId}: ${artifacts.map((a) => a.object_type).join(", ")}`);
11376
12191
  const newWithStatus = artifacts.map((a) => ({ ...a, status: "NOT_STARTED" }));
11377
12192
  const objectTypesToSync = newWithStatus.map((a) => a.object_type);
11378
- const merged = pipe77(
12193
+ const merged = pipe78(
11379
12194
  portalState.mergeArtifacts(portalId, newWithStatus),
11380
- Effect91.tap((m) => ws.setSyncedArtifacts(m)),
11381
- Effect91.catchAll(() => Effect91.succeed(null)),
11382
- Effect91.runSync
12195
+ Effect93.catchAll(() => Effect93.succeed(null)),
12196
+ Effect93.runSync
11383
12197
  );
11384
12198
  if (!merged) return;
11385
- const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11386
- const wsLayer = Layer6.succeed(WebSocketService, ws);
11387
- const configLayer = Layer6.succeed(ConfigService, config);
11388
- const portalStateLayer = Layer6.succeed(PortalStateService, portalState);
11389
- const syncLayer = SyncLive.pipe(
11390
- Layer6.provide(Layer6.mergeAll(wsLayer, dbLayer, configLayer, portalStateLayer))
11391
- );
11392
- pipe77(
11393
- SyncService,
11394
- Effect91.flatMap((sync) => sync.syncArtifacts(portalId, merged.artifacts, objectTypesToSync)),
11395
- Effect91.tap(
11396
- (progress) => Effect91.sync(() => {
11397
- console.error(`[sync:new-artifacts] Download complete: ${progress.completedArtifacts}/${progress.totalArtifacts} new artifacts synced`);
11398
- console.error(`[sync:new-artifacts] Triggering diff for new types on portal ${portalId}`);
11399
- triggerDiffThenSubscribe(portalId);
11400
- })
11401
- ),
11402
- Effect91.catchAll(
11403
- (err) => Effect91.sync(() => console.error(`[sync:new-artifacts] Sync failed:`, err))
11404
- ),
11405
- Effect91.provide(syncLayer),
11406
- Effect91.runFork
11407
- );
12199
+ const toSync = filterUnsyncedArtifacts(merged.artifacts, new Set(objectTypesToSync));
12200
+ if (toSync.length === 0) {
12201
+ console.error(
12202
+ `[artifact-queue] All ${artifacts.length} new artifacts already SYNCED for portal ${portalId} \u2014 no enqueue needed`
12203
+ );
12204
+ } else {
12205
+ console.error(
12206
+ `[artifact-queue] Enqueueing ${toSync.length}/${artifacts.length} new artifact(s) for portal ${portalId}`
12207
+ );
12208
+ enqueueArtifacts(portalId, toSync);
12209
+ }
12210
+ schedulePortalFollowUpAfterDrain(portalId, "sync:new-artifacts");
11408
12211
  },
11409
12212
  onDiffBatch: (portalId, batch) => {
11410
12213
  const plugin = findArtifactPlugin(batch.object_type);
@@ -11413,54 +12216,82 @@ var mainProgram = Effect91.gen(function* () {
11413
12216
  return;
11414
12217
  }
11415
12218
  const dbLayer = makeDatabaseLive(portalId, getEncryptionKey());
11416
- pipe77(
12219
+ pipe78(
11417
12220
  DatabaseService,
11418
- Effect91.flatMap((db) => {
12221
+ Effect93.flatMap((db) => {
11419
12222
  const ctx = { db, ws, portalState, portalId };
11420
12223
  return plugin.processDiffBatch(ctx, batch);
11421
12224
  }),
11422
- Effect91.catchAll(
11423
- (err) => Effect91.sync(() => console.error(`[diff-sync] Error processing batch:`, err))
12225
+ Effect93.catchAll(
12226
+ (err) => Effect93.sync(() => console.error(`[diff-sync] Error processing batch:`, err))
11424
12227
  ),
11425
- Effect91.provide(dbLayer),
11426
- Effect91.runFork
12228
+ Effect93.provide(dbLayer),
12229
+ Effect93.runFork
11427
12230
  );
11428
12231
  },
11429
12232
  onDiffComplete: (portalId, payload) => {
11430
- console.error(`[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 subscribing for real-time updates`);
11431
- Effect91.runFork(pipe77(ws.sendSyncSubscribe(portalId), Effect91.catchAll(() => Effect91.void)));
12233
+ if (!payload.success) {
12234
+ console.error(`[live-sync] Catch-up diff failed for portal ${portalId}: ${payload.error ?? "unknown"}`);
12235
+ return;
12236
+ }
12237
+ Effect93.runFork(pipe78(portalState.touchSyncedAt(portalId), Effect93.catchAll(() => Effect93.void)));
12238
+ const selectedPortalId2 = getSelectedPortalId(config);
12239
+ const shouldSubscribe = shouldSubscribeAfterDiffComplete(portalId, selectedPortalId2, payload.success);
12240
+ if (!shouldSubscribe) {
12241
+ console.error(
12242
+ `[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 not subscribing (selected portal: ${selectedPortalId2 ?? "none"})`
12243
+ );
12244
+ return;
12245
+ }
12246
+ console.error(
12247
+ `[live-sync] Catch-up diff complete for portal ${portalId} \u2014 ${payload.object_types_processed.length} types, ${payload.total_records} records, ${payload.total_associations} associations \u2014 subscribing for real-time updates`
12248
+ );
12249
+ Effect93.runFork(pipe78(ws.sendSyncSubscribe(portalId), Effect93.catchAll(() => Effect93.void)));
11432
12250
  },
11433
12251
  onSubscribeAck: (_payload) => {
11434
12252
  },
11435
- autoSelectSinglePortal: (portals) => autoSelectSinglePortal(config, portals),
12253
+ autoSelectSinglePortal: (portals) => {
12254
+ const autoSelected = autoSelectSinglePortal(config, portals);
12255
+ if (autoSelected !== null) {
12256
+ setSelectedPortal(autoSelected);
12257
+ }
12258
+ return autoSelected;
12259
+ },
11436
12260
  onRegisterSuccess: (portals) => {
11437
- if (state.selectedPortalId === null) return;
11438
- const portalId = state.selectedPortalId;
11439
- const match = portals.find((p) => p.target_portal === portalId);
11440
- if (!match) {
11441
- console.error(`[live-sync] Previously selected portal ${portalId} not in portal list, skipping diff`);
11442
- return;
12261
+ const selectedPortalId2 = getSelectedPortalId(config);
12262
+ setSelectedPortal(selectedPortalId2);
12263
+ const warmupPortalIds = orderPortalWarmupIds(portals, selectedPortalId2);
12264
+ if (selectedPortalId2 !== null && !warmupPortalIds.includes(selectedPortalId2)) {
12265
+ console.error(`[live-sync] Previously selected portal ${selectedPortalId2} not in portal list; warming available portals only`);
12266
+ }
12267
+ console.error(`[live-sync] Registration complete \u2014 warming ${warmupPortalIds.length} portal(s): ${warmupPortalIds.join(", ")}`);
12268
+ for (const portalId of warmupPortalIds) {
12269
+ Effect93.runFork(pipe78(ws.sendSyncFull(portalId), Effect93.catchAll(() => Effect93.void)));
11443
12270
  }
11444
- console.error(`[live-sync] Portal ${portalId} reconnected \u2014 sending sync:full to refresh artifacts`);
11445
- Effect91.runFork(pipe77(ws.sendSyncFull(portalId), Effect91.catchAll(() => Effect91.void)));
11446
12271
  },
11447
12272
  onSyncDiffNeeded: (portalId) => {
11448
- console.error(`[live-sync] onSyncDiffNeeded called for portal ${portalId}`);
11449
- triggerDiffThenSubscribe(portalId);
12273
+ const selectedPortalId2 = getSelectedPortalId(config);
12274
+ const flow = resolveDiffFlow(portalId, selectedPortalId2);
12275
+ console.error(`[live-sync] onSyncDiffNeeded called for portal ${portalId} \u2014 running ${flow}`);
12276
+ if (flow === "diff_then_subscribe") {
12277
+ triggerDiffThenSubscribe(portalId);
12278
+ return;
12279
+ }
12280
+ triggerDiff(portalId);
11450
12281
  }
11451
12282
  });
11452
12283
  yield* ws.connect().pipe(
11453
- Effect91.catchAll((e) => Effect91.sync(() => console.error("WebSocket error:", e)))
12284
+ Effect93.catchAll((e) => Effect93.sync(() => console.error("WebSocket error:", e)))
11454
12285
  );
11455
12286
  const transport = new StdioServerTransport();
11456
- yield* Effect91.promise(() => server.connect(transport));
11457
- yield* Effect91.sync(() => console.error("MCP Pro Client server started"));
11458
- yield* Effect91.never;
12287
+ yield* Effect93.promise(() => server.connect(transport));
12288
+ yield* Effect93.sync(() => console.error("MCP Pro Client server started"));
12289
+ yield* Effect93.never;
11459
12290
  });
11460
- pipe77(
12291
+ pipe78(
11461
12292
  mainProgram,
11462
- Effect91.provide(Layer6.mergeAll(WebSocketLive, ConfigLive, PortalStateLive)),
11463
- Effect91.runPromise
12293
+ Effect93.provide(Layer6.mergeAll(WebSocketLive, ConfigLive, PortalStateLive)),
12294
+ Effect93.runPromise
11464
12295
  ).catch((error) => {
11465
12296
  console.error("Fatal error:", error);
11466
12297
  process.exit(1);