latticesql 3.3.1 → 3.3.3

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 (4) hide show
  1. package/dist/cli.js +375 -276
  2. package/dist/index.cjs +374 -276
  3. package/dist/index.js +374 -276
  4. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -7417,6 +7417,10 @@ function isJunctionTable(table) {
7417
7417
  const fkCols = new Set(belongsTo.map((r6) => r6.foreignKey));
7418
7418
  return table.columns.every((c6) => fkCols.has(c6) || JUNCTION_ALLOWED_NONFK.has(c6));
7419
7419
  }
7420
+ function isJunctionByColumns(columns) {
7421
+ const payload = columns.filter((c6) => !JUNCTION_ALLOWED_NONFK.has(c6));
7422
+ return payload.length === 2 && payload.every((c6) => c6.endsWith("_id"));
7423
+ }
7420
7424
  function fileJunctions(configPath, outputDir) {
7421
7425
  const out = [];
7422
7426
  for (const t8 of getGuiEntities(configPath, outputDir).tables) {
@@ -12224,6 +12228,16 @@ var init_ingest_url = __esm({
12224
12228
  });
12225
12229
 
12226
12230
  // src/gui/ai/dispatch.ts
12231
+ function visibilityDenialReason(opts) {
12232
+ if (opts.kind === "table") {
12233
+ return opts.canManageTableDefault ? null : "Only the workspace owner can change a table's default sharing.";
12234
+ }
12235
+ if (!opts.rowAccess) return "That record was not found, or is not visible to you.";
12236
+ if (!opts.rowAccess.ownedByMe) {
12237
+ return "You do not own this record, so you cannot change its sharing \u2014 only its owner can.";
12238
+ }
12239
+ return null;
12240
+ }
12227
12241
  async function secretColumnsFor(db, table) {
12228
12242
  try {
12229
12243
  const rows = await db.query("_lattice_gui_column_meta", {
@@ -12449,6 +12463,14 @@ async function executeFunction(ctx, name, args) {
12449
12463
  };
12450
12464
  }
12451
12465
  const id = typeof args.id === "string" && args.id ? args.id : void 0;
12466
+ const denial = id ? visibilityDenialReason({
12467
+ kind: "row",
12468
+ rowAccess: (await rowAccessSummaries(ctx.db, table, [id])).get(id)
12469
+ }) : visibilityDenialReason({
12470
+ kind: "table",
12471
+ canManageTableDefault: await canManageRoles(ctx.db)
12472
+ });
12473
+ if (denial) return { ok: false, error: denial };
12452
12474
  try {
12453
12475
  if (id) {
12454
12476
  await setRowVisibility(ctx.db, table, id, visibility);
@@ -12612,6 +12634,7 @@ var init_dispatch = __esm({
12612
12634
  init_column_descriptions();
12613
12635
  init_members();
12614
12636
  init_table_policy();
12637
+ init_cloud_connect();
12615
12638
  init_dedup_service();
12616
12639
  DISPATCHABLE = /* @__PURE__ */ new Set([
12617
12640
  "list_entities",
@@ -52440,6 +52463,7 @@ async function checkForUpdate(pkgName, currentVersion) {
52440
52463
  // src/gui/server.ts
52441
52464
  import { createServer } from "http";
52442
52465
  import { spawn as spawn2 } from "child_process";
52466
+ import { WebSocketServer, WebSocket } from "ws";
52443
52467
  import {
52444
52468
  existsSync as existsSync23,
52445
52469
  mkdirSync as mkdirSync11,
@@ -54314,6 +54338,31 @@ var appJs = `
54314
54338
  // Boot analytics with the resolved consent (no network contact when off),
54315
54339
  // then record the session open. advanced_mode is a boolean \u2014 safe to send.
54316
54340
  if (window.LatticeGA) window.LatticeGA.init(state.analyticsEffective);
54341
+ // Deduplicate unique users in GA: set the GA user_id to a SHA-256 hash of
54342
+ // the operator's email. Anonymized \u2014 the plaintext is hashed in-browser and
54343
+ // never sent (analytics.ts only accepts a hex digest). Without a user_id,
54344
+ // GA counts each session/device as a new user (active-users \u2248 events).
54345
+ // Best-effort + only when analytics consent is on.
54346
+ if (window.LatticeGA && state.analyticsEffective && window.crypto && window.crypto.subtle) {
54347
+ fetchJson('/api/userconfig/identity')
54348
+ .then(function (id) {
54349
+ var email = id && id.email ? String(id.email).trim().toLowerCase() : '';
54350
+ if (!email) return undefined;
54351
+ return window.crypto.subtle
54352
+ .digest('SHA-256', new TextEncoder().encode(email))
54353
+ .then(function (buf) {
54354
+ var hex = Array.prototype.map
54355
+ .call(new Uint8Array(buf), function (b) {
54356
+ return ('0' + b.toString(16)).slice(-2);
54357
+ })
54358
+ .join('');
54359
+ window.LatticeGA.setUser(hex);
54360
+ });
54361
+ })
54362
+ .catch(function () {
54363
+ /* best-effort \u2014 GA still functions without a user_id */
54364
+ });
54365
+ }
54317
54366
  gaTrack('app_open', { advanced_mode: advancedMode() });
54318
54367
  document.body.classList.toggle('advanced-mode', advancedMode());
54319
54368
  wireSettingsDrawer();
@@ -54325,15 +54374,13 @@ var appJs = `
54325
54374
  wireHistoryControls();
54326
54375
  refreshHistoryState();
54327
54376
  renderRoute();
54328
- startRealtime();
54329
- startRenderProgress();
54377
+ startEventStream();
54330
54378
  initSearch();
54331
54379
  initLastEdited();
54332
54380
  initOffline();
54333
54381
  initRailResize();
54334
54382
  initRailDrawer();
54335
54383
  initRailDragDrop();
54336
- startFeed();
54337
54384
  renderComposer();
54338
54385
  initThreadControls();
54339
54386
  checkNativeSetup();
@@ -54349,12 +54396,14 @@ var appJs = `
54349
54396
  }
54350
54397
 
54351
54398
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54352
- // Realtime \u2014 Server-Sent Events from /api/realtime/stream.
54353
- // One EventSource per session; on 'change' events we mark the
54354
- // current view dirty and refetch via afterMutation() (debounced
54355
- // to coalesce bursts). On 'state' events we drive the topbar pill.
54399
+ // Realtime / feed / render-progress all arrive over ONE multiplexed
54400
+ // WebSocket (/api/stream) \u2014 see startEventStream() below. A single
54401
+ // connection per tab (instead of three SSE streams) keeps the browser's
54402
+ // tiny per-host HTTP connection budget free for data requests, so clicking
54403
+ // objects and switching workspaces stay responsive no matter how many tabs
54404
+ // are open. 'change' events mark the current view dirty and refetch via
54405
+ // afterMutation() (debounced); 'state' events drive the topbar pill.
54356
54406
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54357
- var realtimeSource = null;
54358
54407
  var realtimePending = null;
54359
54408
  // Team-cloud collaboration state. usersById resolves "last edited by"
54360
54409
  // names; lastEditedByPk maps "<table>|<pk>" \u2192 { userId, at } from realtime
@@ -54692,42 +54741,87 @@ var appJs = `
54692
54741
  afterMutation().catch(function () { /* swallow */ });
54693
54742
  }, 200);
54694
54743
  }
54695
- function startRealtime() {
54696
- if (realtimeSource) {
54697
- try { realtimeSource.close(); } catch (_) { /* ignore */ }
54698
- realtimeSource = null;
54699
- }
54700
- if (typeof EventSource === 'undefined') return;
54701
- realtimeSource = new EventSource('/api/realtime/stream');
54702
- realtimeSource.addEventListener('state', function (ev) {
54703
- try {
54704
- var data = JSON.parse(ev.data);
54705
- setStatusPill(data.mode || 'local', data.state || 'local');
54706
- } catch (_) { /* ignore malformed */ }
54707
- });
54708
- realtimeSource.addEventListener('change', function (ev) {
54709
- var p = null;
54710
- try { p = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
54711
- if (p) onRealtimeChange(p);
54744
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54745
+ // Multiplexed event stream \u2014 ONE WebSocket carries realtime state/change,
54746
+ // the activity feed, and background-render progress (previously three
54747
+ // separate SSE streams). Holding one connection per tab instead of three
54748
+ // keeps the browser's per-host HTTP budget free for data requests, so the
54749
+ // GUI never freezes when several tabs are open. Each server message is
54750
+ // { type, data }; we demux to the same handlers the SSE listeners used.
54751
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54752
+ var eventStream = null; // the active WebSocket (or null)
54753
+ var eventStreamReconnect = null; // pending reconnect timer
54754
+ var eventStreamBackoff = 1000; // reconnect delay, grows to a cap
54755
+ var eventStreamClosed = false; // true \u21D2 closed on purpose (switch/teardown), don't reconnect
54756
+ function dispatchStreamMessage(type, data) {
54757
+ if (type === 'realtime-state') {
54758
+ setStatusPill((data && data.mode) || 'local', (data && data.state) || 'local');
54759
+ } else if (type === 'realtime-change') {
54760
+ if (data) onRealtimeChange(data);
54712
54761
  scheduleRealtimeRefresh();
54713
- });
54714
- realtimeSource.onerror = function () {
54715
- // EventSource auto-reconnects; surface the disconnect on the pill
54716
- // until the server's 'state' event reports recovery.
54762
+ } else if (type === 'feed') {
54763
+ try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
54764
+ if (data && (data.table || data.op === 'schema')) scheduleRealtimeRefresh();
54765
+ } else if (type === 'render-snapshot') {
54766
+ if (data) applyRenderSnapshot(data);
54767
+ } else if (type === 'render-progress') {
54768
+ if (data) onRenderEvent(data);
54769
+ }
54770
+ }
54771
+ function scheduleEventStreamReconnect() {
54772
+ if (eventStreamClosed || eventStreamReconnect) return;
54773
+ var delay = eventStreamBackoff;
54774
+ eventStreamBackoff = Math.min(eventStreamBackoff * 2, 15000);
54775
+ eventStreamReconnect = setTimeout(function () {
54776
+ eventStreamReconnect = null;
54777
+ startEventStream();
54778
+ }, delay);
54779
+ }
54780
+ function stopEventStream() {
54781
+ eventStreamClosed = true;
54782
+ if (eventStreamReconnect) { clearTimeout(eventStreamReconnect); eventStreamReconnect = null; }
54783
+ if (eventStream) {
54784
+ // Drop the onclose handler first so an intentional close doesn't trip the
54785
+ // reconnect/disconnect path.
54786
+ try { eventStream.onclose = null; eventStream.close(); } catch (_) { /* ignore */ }
54787
+ eventStream = null;
54788
+ }
54789
+ }
54790
+ function startEventStream() {
54791
+ stopEventStream();
54792
+ eventStreamClosed = false;
54793
+ if (typeof WebSocket === 'undefined') return;
54794
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
54795
+ var ws;
54796
+ try { ws = new WebSocket(proto + '//' + location.host + '/api/stream'); }
54797
+ catch (_) { scheduleEventStreamReconnect(); return; }
54798
+ eventStream = ws;
54799
+ ws.onopen = function () { eventStreamBackoff = 1000; };
54800
+ ws.onmessage = function (ev) {
54801
+ var msg = null;
54802
+ try { msg = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
54803
+ if (msg && msg.type) dispatchStreamMessage(msg.type, msg.data);
54804
+ };
54805
+ ws.onerror = function () { /* surfaced via onclose \u2192 reconnect */ };
54806
+ ws.onclose = function () {
54807
+ if (eventStream === ws) eventStream = null;
54808
+ if (eventStreamClosed) return;
54809
+ // Unexpected drop: show the disconnect on the pill and auto-reconnect with
54810
+ // backoff (the server replays state + render snapshot on reconnect).
54717
54811
  setStatusPill('cloud', 'disconnected');
54812
+ scheduleEventStreamReconnect();
54718
54813
  };
54719
54814
  }
54720
54815
 
54721
54816
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54722
- // Background-render progress \u2014 Server-Sent Events from
54723
- // /api/render/progress. A workspace opens/switches instantly and renders its
54724
- // context tree in the background; this paints a per-table % overlay on the
54725
- // dashboard cards (bottom-edge bar + \u27F3 pill) and dims the row count until
54726
- // each table completes. Row COUNTS come only from /api/entities \u2014 the render
54727
- // stream drives only the transient overlay and one reconciling refetch on
54728
- // completion. Mirrors the realtime EventSource pattern above.
54817
+ // Background-render progress \u2014 render events arrive over the multiplexed
54818
+ // /api/stream WebSocket (render-snapshot + render-progress). A workspace
54819
+ // opens/switches instantly and renders its context tree in the background;
54820
+ // this paints a per-table % overlay on the dashboard cards (bottom-edge bar +
54821
+ // \u27F3 pill) and dims the row count until each table completes. Row COUNTS come
54822
+ // only from /api/entities \u2014 the render events drive only the transient overlay
54823
+ // and one reconciling refetch on completion.
54729
54824
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54730
- var renderSource = null;
54731
54825
  // { [table]: { pct, rendered, total, done, error } } \u2014 the live render state,
54732
54826
  // re-applied to cards after every dashboard rebuild (drawDashboard wipes the
54733
54827
  // DOM overlays but not this map).
@@ -54840,37 +54934,17 @@ var appJs = `
54840
54934
  }
54841
54935
  }
54842
54936
  }
54843
- function startRenderProgress() {
54844
- if (renderSource) {
54845
- try { renderSource.close(); } catch (_) { /* ignore */ }
54846
- renderSource = null;
54847
- }
54848
- if (typeof EventSource === 'undefined') return;
54849
- // On (re)connect, fetch the single-shot snapshot so a tab that connects
54850
- // mid- or post-render paints correctly even before the next event. The SSE
54851
- // endpoint ALSO replays a 'snapshot' event on connect, so both paths agree.
54852
- fetchJson('/api/render/status').then(applyRenderSnapshot).catch(function () { /* ignore */ });
54853
- renderSource = new EventSource('/api/render/progress');
54854
- renderSource.addEventListener('snapshot', function (ev) {
54855
- var snap = null;
54856
- try { snap = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
54857
- if (snap) applyRenderSnapshot(snap);
54858
- });
54859
- renderSource.addEventListener('progress', function (ev) {
54860
- var e = null;
54861
- try { e = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
54862
- if (e) onRenderEvent(e);
54863
- });
54864
- // EventSource auto-reconnects on error; the status refetch on the next
54865
- // open repaints from the authoritative snapshot.
54866
- }
54937
+ // Render snapshot + progress are applied from the multiplexed /api/stream
54938
+ // WebSocket: the server replays a render-snapshot on connect (so a tab that
54939
+ // connects mid- or post-render paints correctly) and streams render-progress
54940
+ // events thereafter. See dispatchStreamMessage / startEventStream.
54867
54941
 
54868
54942
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54869
54943
  // Shared activity helpers \u2014 the operation-icon map and relative-time
54870
54944
  // formatter, used by Version History and the dashboard activity list. The
54871
54945
  // standalone Activity rail was removed in 1.16.1 (redundant with Version
54872
- // History); multiplayer realtime convergence runs on the separate realtime
54873
- // channel (startRealtime), not on this.
54946
+ // History); multiplayer realtime convergence runs on the realtime-change
54947
+ // messages of the multiplexed event stream (startEventStream), not on this.
54874
54948
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
54875
54949
  var FEED_ICONS = {
54876
54950
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
@@ -55108,12 +55182,11 @@ var appJs = `
55108
55182
  if (location.hash !== '#/') location.hash = '#/';
55109
55183
  else renderRoute();
55110
55184
  loadedTables = {};
55111
- startRealtime();
55112
- // A switch swaps the server-side render bus to the new workspace; drop the
55113
- // old workspace's overlay state and re-subscribe so the new render streams
55114
- // onto this workspace's cards.
55185
+ // A switch swaps the server-side buses to the new workspace; drop the old
55186
+ // workspace's render overlay state and reconnect the multiplexed event
55187
+ // stream so realtime/feed/render all rebind to this workspace.
55115
55188
  renderProgress = {};
55116
- startRenderProgress();
55189
+ startEventStream();
55117
55190
  });
55118
55191
  }
55119
55192
 
@@ -55280,7 +55353,6 @@ var appJs = `
55280
55353
  // to THIS workspace, and reload its thread list (+ latest convo).
55281
55354
  newChat();
55282
55355
  clearActivityFeed();
55283
- startFeed();
55284
55356
  refreshThreadList(true);
55285
55357
  showToast('Switched workspace', {});
55286
55358
  }).catch(function (err) { menu.hidden = true; endWsSwitching(true); showToast('Switch failed: ' + err.message, {}); });
@@ -55741,7 +55813,7 @@ var appJs = `
55741
55813
  function rowVisMarkup(tbl, r) {
55742
55814
  var a = r._access;
55743
55815
  if (!a) return '';
55744
- var vis = a.visibility;
55816
+ var vis = effectiveVisibility(a);
55745
55817
  var glyph = vis === 'custom' ? '\u25CE' : '\u25C9';
55746
55818
  if (!a.ownedByMe) {
55747
55819
  var seen = vis === 'custom' ? 'Shared with you' : 'Visible to everyone';
@@ -56091,7 +56163,7 @@ var appJs = `
56091
56163
  function detailVisLineEl(row) {
56092
56164
  var a = row._access;
56093
56165
  if (!a) return '';
56094
- var vis = a.visibility;
56166
+ var vis = effectiveVisibility(a);
56095
56167
  var labelMap = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
56096
56168
  // Clear visual indicator: a lock when private, an eye when shared (everyone
56097
56169
  // or specific people), with a hover tooltip. The shared helper keeps the
@@ -56102,8 +56174,7 @@ var appJs = `
56102
56174
  return '<div class="detail-vis muted" style="display:flex;align-items:center;gap:6px;margin:6px 0;font-size:13px">' +
56103
56175
  visIcon + '<span>' + escapeHtml(seen) + '</span></div>';
56104
56176
  }
56105
- var info = labelMap[vis] || '';
56106
- if (vis === 'custom' && a.grantees) info += ' (' + a.grantees.length + ')';
56177
+ var info = visInfoLabel(a);
56107
56178
  var buttons;
56108
56179
  if (vis === 'custom') {
56109
56180
  // Leaving custom stops the grant list from applying \u2014 the toggle
@@ -56158,9 +56229,12 @@ var appJs = `
56158
56229
  if (!panel) return;
56159
56230
  if (!panel.hidden) { panel.hidden = true; return; }
56160
56231
  var access = row._access || {};
56161
- var ensure = access.visibility === 'custom'
56162
- ? Promise.resolve()
56163
- : postVisibility('custom').then(function () { access.visibility = 'custom'; });
56232
+ // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
56233
+ // row the user never actually shared stuck at "custom (0)". The first
56234
+ // grant flips it to custom server-side (lattice_grant_row); revoking the
56235
+ // last leaves it custom-with-0-grantees, which now reads as private. So
56236
+ // just load the member checklist.
56237
+ var ensure = Promise.resolve();
56164
56238
  withBusy(detailVisManage, function () {
56165
56239
  return ensure.then(function () {
56166
56240
  return fetchJson('/api/cloud/members');
@@ -56194,8 +56268,13 @@ var appJs = `
56194
56268
  var at = list.indexOf(role);
56195
56269
  if (cb.checked && at === -1) list.push(role);
56196
56270
  if (!cb.checked && at !== -1) list.splice(at, 1);
56271
+ // The first grant flips the row to custom server-side; mirror
56272
+ // that locally so the indicator updates. Revoking the last leaves
56273
+ // visibility 'custom' but effectiveVisibility renders custom-0 as
56274
+ // private, so the label flips back to "Private to you".
56275
+ if (list.length > 0) access.visibility = 'custom';
56197
56276
  var infoEl = content.querySelector('#detail-vis-info');
56198
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + list.length + ')';
56277
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
56199
56278
  invalidate(tableName);
56200
56279
  }).catch(function (e) {
56201
56280
  cb.checked = !cb.checked; // revert the failed change
@@ -56203,10 +56282,8 @@ var appJs = `
56203
56282
  }).then(function () { cb.disabled = false; });
56204
56283
  });
56205
56284
  });
56206
- if (access.visibility === 'custom') {
56207
- var infoEl = content.querySelector('#detail-vis-info');
56208
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + (access.grantees || []).length + ')';
56209
- }
56285
+ var infoEl = content.querySelector('#detail-vis-info');
56286
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
56210
56287
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
56211
56288
  });
56212
56289
  });
@@ -58317,6 +58394,29 @@ var appJs = `
58317
58394
  var EYE_SVG =
58318
58395
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
58319
58396
  '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>';
58397
+ // A custom row with zero grantees is effectively PRIVATE (RLS shows it only to
58398
+ // the owner), so it must read as private, not "Shared with specific people
58399
+ // (0)". Only collapse for the OWNER's own view, where the grantees list is
58400
+ // authoritative \u2014 a member viewing a row shared WITH them gets custom with no
58401
+ // grantees list and must still read as "Shared with you".
58402
+ function effectiveVisibility(access) {
58403
+ if (!access || !access.visibility) return 'private';
58404
+ if (access.visibility === 'custom' && access.ownedByMe &&
58405
+ (!access.grantees || access.grantees.length === 0)) {
58406
+ return 'private';
58407
+ }
58408
+ return access.visibility;
58409
+ }
58410
+ // Owner-facing status label for a row's sharing, honoring effectiveVisibility
58411
+ // (so custom-0 reads as "Private to you") and appending the grantee count only
58412
+ // for a genuine specific-people share.
58413
+ function visInfoLabel(access) {
58414
+ var v = effectiveVisibility(access);
58415
+ var map = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
58416
+ var s = map[v] || '';
58417
+ if (v === 'custom' && access && access.grantees) s += ' (' + access.grantees.length + ')';
58418
+ return s;
58419
+ }
58320
58420
  // Shared per-row lock/eye indicator, reused on the entity-detail header and the
58321
58421
  // fs card tiles so the meaning is consistent. The access arg is the server-
58322
58422
  // attached row._access (visibility + ownedByMe); returns empty when absent (a
@@ -58324,7 +58424,7 @@ var appJs = `
58324
58424
  // A hover tooltip spells out what the lock/eye means (state + ownership aware).
58325
58425
  function visIndicator(access, extraClass) {
58326
58426
  if (!access || !access.visibility) return '';
58327
- var vis = access.visibility;
58427
+ var vis = effectiveVisibility(access);
58328
58428
  var tip = vis === 'private'
58329
58429
  ? 'Private \u2014 only you can see this'
58330
58430
  : vis === 'custom'
@@ -60113,7 +60213,6 @@ var appJs = `
60113
60213
 
60114
60214
 
60115
60215
  // ============ AI assistant rail (2.0) ============
60116
- var feedSource = null;
60117
60216
  var FEED_ICONS = {
60118
60217
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
60119
60218
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
@@ -60366,32 +60465,13 @@ var appJs = `
60366
60465
  else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
60367
60466
  }
60368
60467
  }
60369
- function startFeed() {
60370
- if (feedSource) {
60371
- try { feedSource.close(); } catch (_) { /* ignore */ }
60372
- feedSource = null;
60373
- }
60374
- if (typeof EventSource === 'undefined') return;
60375
- feedSource = new EventSource('/api/feed/stream');
60376
- feedSource.addEventListener('feed', function (ev) {
60377
- var data;
60378
- try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
60379
- try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
60380
- // Refresh on ANY data mutation, not just schema/new-table events. The
60381
- // local feed bus delivers every insert/update/delete/link even when
60382
- // there's no realtime broker (SQLite/local), so this is what makes the
60383
- // home dashboard counts AND the open entity view live-update without a
60384
- // manual reload (previously only schema ops or brand-new tables did).
60385
- // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
60386
- // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
60387
- // it shares that debounce with the realtime 'change' handler (no double
60388
- // fetch). /api/entities batches its row counts into one query, not N.
60389
- if (data && (data.table || data.op === 'schema')) {
60390
- scheduleRealtimeRefresh();
60391
- }
60392
- });
60393
- // EventSource auto-reconnects on error; no extra handling needed.
60394
- }
60468
+ // Feed events arrive over the multiplexed /api/stream WebSocket and are
60469
+ // handled in dispatchStreamMessage('feed', \u2026): renderFeedItem() paints the
60470
+ // card, then scheduleRealtimeRefresh() refetches on ANY data mutation (the
60471
+ // local feed bus delivers every insert/update/delete/link even with no
60472
+ // realtime broker, so this is what live-updates the dashboard counts and the
60473
+ // open entity view without a manual reload). The 200ms debounce coalesces a
60474
+ // burst into a single refetch and is shared with the realtime 'change' path.
60395
60475
 
60396
60476
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
60397
60477
  // Assistant rail resize \u2014 drag the left edge, clamp, persist.
@@ -61087,6 +61167,16 @@ var analyticsJs = `
61087
61167
  page_title: t,
61088
61168
  });
61089
61169
  },
61170
+ // Set the GA user_id to an OPAQUE per-user hash so active users are
61171
+ // deduplicated across sessions/devices (without it, unique users \u2248 events).
61172
+ // Accepts ONLY a 64-char hex digest \u2014 a raw email or any other PII is
61173
+ // rejected (never sent), preserving the privacy contract above. The caller
61174
+ // hashes the email; this module never sees the plaintext.
61175
+ setUser: function (idHash) {
61176
+ if (!consent || !loaded) return;
61177
+ if (typeof idHash !== 'string' || !/^[a-f0-9]{64}$/.test(idHash)) return;
61178
+ gtag('config', MEASUREMENT_ID, { user_id: idHash });
61179
+ },
61090
61180
  };
61091
61181
  })();
61092
61182
  `;
@@ -61676,6 +61766,44 @@ async function reconcileCloudMemberAccess(db) {
61676
61766
  );
61677
61767
  }
61678
61768
  }
61769
+ const memberSystemGrants = [
61770
+ ["_lattice_gui_meta", "SELECT, INSERT, UPDATE"],
61771
+ ["_lattice_gui_column_meta", "SELECT, INSERT, UPDATE"],
61772
+ ["_lattice_gui_audit", "SELECT, INSERT"],
61773
+ ["__lattice_user_identity", "SELECT, INSERT, UPDATE"]
61774
+ ];
61775
+ for (const [tbl, privs] of memberSystemGrants) {
61776
+ await runAsyncOrSync(
61777
+ db.adapter,
61778
+ `DO $LATTICE$ BEGIN
61779
+ IF to_regclass('${tbl}') IS NOT NULL THEN
61780
+ EXECUTE 'GRANT ${privs} ON "${tbl}" TO ${MEMBER_GROUP}';
61781
+ END IF;
61782
+ END $LATTICE$`
61783
+ );
61784
+ }
61785
+ try {
61786
+ await runAsyncOrSync(
61787
+ db.adapter,
61788
+ `GRANT EXECUTE ON FUNCTION json_extract(text, text), strftime(text, text) TO ${MEMBER_GROUP}`
61789
+ );
61790
+ } catch (err) {
61791
+ console.warn(
61792
+ "[reconcileCloudMemberAccess] could not grant EXECUTE on polyfills (will retry next open):",
61793
+ err instanceof Error ? err.message : String(err)
61794
+ );
61795
+ }
61796
+ for (const table of registered) {
61797
+ if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
61798
+ const cols = db.getRegisteredColumns(table);
61799
+ if (cols && !("deleted_at" in cols)) {
61800
+ const q3 = `"${table.replace(/"/g, '""')}"`;
61801
+ await runAsyncOrSync(
61802
+ db.adapter,
61803
+ `ALTER TABLE ${q3} ADD COLUMN IF NOT EXISTS "deleted_at" TEXT`
61804
+ );
61805
+ }
61806
+ }
61679
61807
  }
61680
61808
  async function secureNewCloudTable(db, table, pk) {
61681
61809
  if (db.getDialect() !== "postgres") return;
@@ -61701,14 +61829,6 @@ async function secureCloud(db) {
61701
61829
  await secureNewCloudTable(db, table, db.getPrimaryKey(table));
61702
61830
  }
61703
61831
  await reconcileCloudMemberAccess(db);
61704
- await runAsyncOrSync(
61705
- db.adapter,
61706
- `DO $LATTICE$ BEGIN
61707
- IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
61708
- EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
61709
- END IF;
61710
- END $LATTICE$`
61711
- );
61712
61832
  }
61713
61833
 
61714
61834
  // src/gui/meta-gen.ts
@@ -65194,19 +65314,36 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
65194
65314
  }
65195
65315
  let memberOpen = false;
65196
65316
  const maskedReadViews = /* @__PURE__ */ new Map();
65317
+ const discoveredJunctions = /* @__PURE__ */ new Set();
65197
65318
  if (db.getDialect() === "postgres") {
65198
65319
  const peek = new Lattice({ config: configPath }, { encryptionKey });
65199
65320
  try {
65200
65321
  await peek.init({ introspectOnly: true });
65201
- if (await cloudRlsInstalled(peek)) {
65202
- memberOpen = !await canManageRoles(peek);
65322
+ const [rlsInstalled, canManage] = await Promise.all([
65323
+ cloudRlsInstalled(peek),
65324
+ canManageRoles(peek)
65325
+ ]);
65326
+ if (rlsInstalled) {
65327
+ memberOpen = !canManage;
65203
65328
  if (memberOpen) {
65204
65329
  const declared = new Set(db.getRegisteredTableNames());
65205
- const discovered = await discoverCloudTables(peek);
65330
+ const [discovered, viewsRaw] = await Promise.all([
65331
+ discoverCloudTables(peek),
65332
+ allAsyncOrSync(
65333
+ peek.adapter,
65334
+ `SELECT table_name AS name FROM information_schema.views
65335
+ WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
65336
+ )
65337
+ ]);
65338
+ const views = viewsRaw;
65206
65339
  const knownTables = /* @__PURE__ */ new Set([...declared, ...discovered.map((t8) => t8.name)]);
65207
65340
  for (const t8 of discovered) {
65208
65341
  if (declared.has(t8.name)) continue;
65209
65342
  if (t8.columns.length === 0) continue;
65343
+ if (isJunctionByColumns(t8.columns)) {
65344
+ discoveredJunctions.add(t8.name);
65345
+ continue;
65346
+ }
65210
65347
  db.define(t8.name, {
65211
65348
  columns: Object.fromEntries(t8.columns.map((c6) => [c6, "TEXT"])),
65212
65349
  ...t8.pk.length > 0 ? { primaryKey: t8.pk.length === 1 ? t8.pk[0] : t8.pk } : {},
@@ -65214,11 +65351,6 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
65214
65351
  outputFile: `${t8.name}/.lattice/${t8.name}.md`
65215
65352
  });
65216
65353
  }
65217
- const views = await allAsyncOrSync(
65218
- peek.adapter,
65219
- `SELECT table_name AS name FROM information_schema.views
65220
- WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
65221
- );
65222
65354
  for (const { name } of views) {
65223
65355
  const base = name.slice(0, -2);
65224
65356
  if (knownTables.has(base)) maskedReadViews.set(base, name);
@@ -65255,9 +65387,12 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
65255
65387
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
65256
65388
  validTables.add(name);
65257
65389
  }
65258
- const junctionTables = new Set(
65259
- getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name)
65260
- );
65390
+ const junctionTables = /* @__PURE__ */ new Set([
65391
+ ...getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name),
65392
+ // Member-discovered junctions (classified from the physical shape above);
65393
+ // empty for an owner/local open.
65394
+ ...discoveredJunctions
65395
+ ]);
65261
65396
  const entityContextByTable = db.entityContexts();
65262
65397
  const manifest = readManifest(outputDir);
65263
65398
  const softDeletable = /* @__PURE__ */ new Set();
@@ -65458,6 +65593,9 @@ async function changeVisibleToActiveRole(db, payload) {
65458
65593
  function isDeleteOp(op) {
65459
65594
  return op === "delete" || op === "DELETE";
65460
65595
  }
65596
+ function isFeedHiddenTable(t8) {
65597
+ return t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
65598
+ }
65461
65599
  function readRelationFor(active, table) {
65462
65600
  return active.maskedReadViews.get(table) ?? table;
65463
65601
  }
@@ -65839,158 +65977,10 @@ async function startGuiServer(options) {
65839
65977
  sendJson(res, { mode, state: active.realtime?.state() ?? "local", connected });
65840
65978
  return;
65841
65979
  }
65842
- if (method === "GET" && pathname === "/api/realtime/stream") {
65843
- res.writeHead(200, {
65844
- "content-type": "text/event-stream; charset=utf-8",
65845
- "cache-control": "no-store, no-transform",
65846
- connection: "keep-alive",
65847
- "x-accel-buffering": "no"
65848
- });
65849
- const broker = active.realtime;
65850
- const initialMode = broker ? "cloud" : "local";
65851
- const writeEvent = (event, data) => {
65852
- try {
65853
- res.write(`event: ${event}
65854
- data: ${JSON.stringify(data)}
65855
-
65856
- `);
65857
- } catch {
65858
- }
65859
- };
65860
- writeEvent("state", {
65861
- mode: initialMode,
65862
- state: broker?.state() ?? "local"
65863
- });
65864
- const keepalive = setInterval(() => {
65865
- try {
65866
- res.write(`: keepalive
65867
-
65868
- `);
65869
- } catch {
65870
- }
65871
- }, 25e3);
65872
- const offState = broker?.subscribeState((state2) => {
65873
- writeEvent("state", { mode: "cloud", state: state2 });
65874
- });
65875
- const offPayload = broker?.subscribePayload((payload) => {
65876
- if (activeRef !== active) return;
65877
- void changeVisibleToActiveRole(active.db, payload).then((visible) => {
65878
- if (!visible) return;
65879
- const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
65880
- writeEvent("change", out);
65881
- });
65882
- });
65883
- const cleanup = () => {
65884
- clearInterval(keepalive);
65885
- if (offState) offState();
65886
- if (offPayload) offPayload();
65887
- };
65888
- req.on("close", cleanup);
65889
- req.on("error", cleanup);
65890
- return;
65891
- }
65892
- if (method === "GET" && pathname === "/api/feed/stream") {
65893
- res.writeHead(200, {
65894
- "content-type": "text/event-stream; charset=utf-8",
65895
- "cache-control": "no-store, no-transform",
65896
- connection: "keep-alive",
65897
- "x-accel-buffering": "no"
65898
- });
65899
- const writeFeed = (data) => {
65900
- try {
65901
- res.write(`event: feed
65902
- data: ${JSON.stringify(data)}
65903
-
65904
- `);
65905
- } catch {
65906
- }
65907
- };
65908
- const keepalive = setInterval(() => {
65909
- try {
65910
- res.write(`: keepalive
65911
-
65912
- `);
65913
- } catch {
65914
- }
65915
- }, 25e3);
65916
- const recentSelf = /* @__PURE__ */ new Map();
65917
- const isFeedHiddenTable = (t8) => t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
65918
- const offFeed = active.feed.subscribe((e6) => {
65919
- if (e6.table && isFeedHiddenTable(e6.table)) return;
65920
- recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
65921
- writeFeed(e6);
65922
- });
65923
- const offBroker = active.realtime?.subscribePayload((p3) => {
65924
- const op = feedOpForChange(p3.op);
65925
- if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
65926
- const tableName = p3.table_name;
65927
- const key = `${tableName}:${p3.pk ?? ""}:${op}`;
65928
- const seen = recentSelf.get(key);
65929
- if (seen && Date.now() - seen < 5e3) return;
65930
- if (activeRef !== active) return;
65931
- void changeVisibleToActiveRole(active.db, p3).then((visible) => {
65932
- if (!visible) return;
65933
- writeFeed({
65934
- seq: p3.seq,
65935
- table: tableName,
65936
- op,
65937
- rowId: p3.pk,
65938
- source: "cli",
65939
- actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
65940
- ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
65941
- summary: `${op} on ${tableName} (another client)`
65942
- });
65943
- });
65944
- });
65945
- const cleanup = () => {
65946
- clearInterval(keepalive);
65947
- offFeed();
65948
- if (offBroker) offBroker();
65949
- };
65950
- req.on("close", cleanup);
65951
- req.on("error", cleanup);
65952
- return;
65953
- }
65954
65980
  if (method === "GET" && pathname === "/api/render/status") {
65955
65981
  sendJson(res, active.renderState);
65956
65982
  return;
65957
65983
  }
65958
- if (method === "GET" && pathname === "/api/render/progress") {
65959
- res.writeHead(200, {
65960
- "content-type": "text/event-stream; charset=utf-8",
65961
- "cache-control": "no-store, no-transform",
65962
- connection: "keep-alive",
65963
- "x-accel-buffering": "no"
65964
- });
65965
- const writeEvent = (event, data) => {
65966
- try {
65967
- res.write(`event: ${event}
65968
- data: ${JSON.stringify(data)}
65969
-
65970
- `);
65971
- } catch {
65972
- }
65973
- };
65974
- writeEvent("snapshot", active.renderState);
65975
- const keepalive = setInterval(() => {
65976
- try {
65977
- res.write(`: keepalive
65978
-
65979
- `);
65980
- } catch {
65981
- }
65982
- }, 25e3);
65983
- const offProgress = active.renderProgress.subscribe((e6) => {
65984
- writeEvent("progress", e6);
65985
- });
65986
- const cleanup = () => {
65987
- clearInterval(keepalive);
65988
- offProgress();
65989
- };
65990
- req.on("close", cleanup);
65991
- req.on("error", cleanup);
65992
- return;
65993
- }
65994
65984
  if (method === "GET" && pathname === "/api/project") {
65995
65985
  sendJson(res, getGuiProject(active.configPath, active.outputDir));
65996
65986
  return;
@@ -67508,6 +67498,107 @@ ${e6.stack ?? ""}`
67508
67498
  }
67509
67499
  })();
67510
67500
  });
67501
+ const wss = new WebSocketServer({ noServer: true });
67502
+ const handleEventStream = (ws) => {
67503
+ const bound = activeRef;
67504
+ const send = (type, data) => {
67505
+ if (ws.readyState !== WebSocket.OPEN) return;
67506
+ try {
67507
+ ws.send(JSON.stringify({ type, data }));
67508
+ } catch {
67509
+ }
67510
+ };
67511
+ const broker = bound?.realtime ?? null;
67512
+ send("realtime-state", { mode: broker ? "cloud" : "local", state: broker?.state() ?? "local" });
67513
+ if (bound) send("render-snapshot", bound.renderState);
67514
+ const offs = [];
67515
+ if (bound) {
67516
+ if (broker) {
67517
+ offs.push(
67518
+ broker.subscribeState((state2) => {
67519
+ send("realtime-state", { mode: "cloud", state: state2 });
67520
+ })
67521
+ );
67522
+ offs.push(
67523
+ broker.subscribePayload((payload) => {
67524
+ if (activeRef !== bound) return;
67525
+ void changeVisibleToActiveRole(bound.db, payload).then((visible) => {
67526
+ if (!visible) return;
67527
+ const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
67528
+ send("realtime-change", out);
67529
+ });
67530
+ })
67531
+ );
67532
+ }
67533
+ const recentSelf = /* @__PURE__ */ new Map();
67534
+ offs.push(
67535
+ bound.feed.subscribe((e6) => {
67536
+ if (e6.table && isFeedHiddenTable(e6.table)) return;
67537
+ recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
67538
+ send("feed", e6);
67539
+ })
67540
+ );
67541
+ if (broker) {
67542
+ offs.push(
67543
+ broker.subscribePayload((p3) => {
67544
+ const op = feedOpForChange(p3.op);
67545
+ if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
67546
+ const tableName = p3.table_name;
67547
+ const key = `${tableName}:${p3.pk ?? ""}:${op}`;
67548
+ const seen = recentSelf.get(key);
67549
+ if (seen && Date.now() - seen < 5e3) return;
67550
+ if (activeRef !== bound) return;
67551
+ void changeVisibleToActiveRole(bound.db, p3).then((visible) => {
67552
+ if (!visible) return;
67553
+ send("feed", {
67554
+ seq: p3.seq,
67555
+ table: tableName,
67556
+ op,
67557
+ rowId: p3.pk,
67558
+ source: "cli",
67559
+ actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
67560
+ ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
67561
+ summary: `${op} on ${tableName} (another client)`
67562
+ });
67563
+ });
67564
+ })
67565
+ );
67566
+ }
67567
+ offs.push(
67568
+ bound.renderProgress.subscribe((e6) => {
67569
+ send("render-progress", e6);
67570
+ })
67571
+ );
67572
+ }
67573
+ const keepalive = setInterval(() => {
67574
+ if (ws.readyState !== WebSocket.OPEN) return;
67575
+ try {
67576
+ ws.ping();
67577
+ } catch {
67578
+ }
67579
+ }, 25e3);
67580
+ const cleanup = () => {
67581
+ clearInterval(keepalive);
67582
+ for (const off of offs) {
67583
+ try {
67584
+ off();
67585
+ } catch {
67586
+ }
67587
+ }
67588
+ };
67589
+ ws.on("close", cleanup);
67590
+ ws.on("error", cleanup);
67591
+ };
67592
+ server.on("upgrade", (req, socket, head) => {
67593
+ const { pathname } = new URL(req.url ?? "/", `http://${host}`);
67594
+ if (pathname !== "/api/stream") {
67595
+ socket.destroy();
67596
+ return;
67597
+ }
67598
+ wss.handleUpgrade(req, socket, head, (ws) => {
67599
+ handleEventStream(ws);
67600
+ });
67601
+ });
67511
67602
  const port = await listenWithPortFallback(server, startPort, host);
67512
67603
  if (activeRef) startBackgroundRender(activeRef);
67513
67604
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
@@ -67518,6 +67609,13 @@ ${e6.stack ?? ""}`
67518
67609
  port,
67519
67610
  url,
67520
67611
  close: () => new Promise((resolveClose, reject) => {
67612
+ for (const client of wss.clients) {
67613
+ try {
67614
+ client.terminate();
67615
+ } catch {
67616
+ }
67617
+ }
67618
+ wss.close();
67521
67619
  server.close((err) => {
67522
67620
  if (err) {
67523
67621
  reject(err);
@@ -67715,6 +67813,7 @@ function printHelp() {
67715
67813
  );
67716
67814
  }
67717
67815
  function getVersion() {
67816
+ if (true) return "3.3.3";
67718
67817
  try {
67719
67818
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
67720
67819
  const pkg = JSON.parse(readFileSync18(pkgPath, "utf-8"));