latticesql 3.3.1 → 3.3.2

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 +337 -268
  2. package/dist/index.cjs +336 -268
  3. package/dist/index.js +336 -268
  4. package/package.json +3 -1
package/dist/index.cjs CHANGED
@@ -48545,6 +48545,10 @@ function isJunctionTable(table) {
48545
48545
  const fkCols = new Set(belongsTo.map((r6) => r6.foreignKey));
48546
48546
  return table.columns.every((c6) => fkCols.has(c6) || JUNCTION_ALLOWED_NONFK.has(c6));
48547
48547
  }
48548
+ function isJunctionByColumns(columns) {
48549
+ const payload = columns.filter((c6) => !JUNCTION_ALLOWED_NONFK.has(c6));
48550
+ return payload.length === 2 && payload.every((c6) => c6.endsWith("_id"));
48551
+ }
48548
48552
  function fileJunctions(configPath, outputDir) {
48549
48553
  const out = [];
48550
48554
  for (const t8 of getGuiEntities(configPath, outputDir).tables) {
@@ -51884,6 +51888,16 @@ var init_ingest_url = __esm({
51884
51888
  });
51885
51889
 
51886
51890
  // src/gui/ai/dispatch.ts
51891
+ function visibilityDenialReason(opts) {
51892
+ if (opts.kind === "table") {
51893
+ return opts.canManageTableDefault ? null : "Only the workspace owner can change a table's default sharing.";
51894
+ }
51895
+ if (!opts.rowAccess) return "That record was not found, or is not visible to you.";
51896
+ if (!opts.rowAccess.ownedByMe) {
51897
+ return "You do not own this record, so you cannot change its sharing \u2014 only its owner can.";
51898
+ }
51899
+ return null;
51900
+ }
51887
51901
  async function secretColumnsFor(db, table) {
51888
51902
  try {
51889
51903
  const rows = await db.query("_lattice_gui_column_meta", {
@@ -52109,6 +52123,14 @@ async function executeFunction(ctx, name, args) {
52109
52123
  };
52110
52124
  }
52111
52125
  const id = typeof args.id === "string" && args.id ? args.id : void 0;
52126
+ const denial = id ? visibilityDenialReason({
52127
+ kind: "row",
52128
+ rowAccess: (await rowAccessSummaries(ctx.db, table, [id])).get(id)
52129
+ }) : visibilityDenialReason({
52130
+ kind: "table",
52131
+ canManageTableDefault: await canManageRoles(ctx.db)
52132
+ });
52133
+ if (denial) return { ok: false, error: denial };
52112
52134
  try {
52113
52135
  if (id) {
52114
52136
  await setRowVisibility(ctx.db, table, id, visibility);
@@ -52272,6 +52294,7 @@ var init_dispatch = __esm({
52272
52294
  init_column_descriptions();
52273
52295
  init_members();
52274
52296
  init_table_policy();
52297
+ init_cloud_connect();
52275
52298
  init_dedup_service();
52276
52299
  DISPATCHABLE = /* @__PURE__ */ new Set([
52277
52300
  "list_entities",
@@ -54318,6 +54341,7 @@ init_summarize();
54318
54341
  // src/gui/server.ts
54319
54342
  var import_node_http3 = require("http");
54320
54343
  var import_node_child_process4 = require("child_process");
54344
+ var import_ws = require("ws");
54321
54345
  var import_node_fs31 = require("fs");
54322
54346
  var import_node_path35 = require("path");
54323
54347
  var import_yaml5 = require("yaml");
@@ -56184,6 +56208,31 @@ var appJs = `
56184
56208
  // Boot analytics with the resolved consent (no network contact when off),
56185
56209
  // then record the session open. advanced_mode is a boolean \u2014 safe to send.
56186
56210
  if (window.LatticeGA) window.LatticeGA.init(state.analyticsEffective);
56211
+ // Deduplicate unique users in GA: set the GA user_id to a SHA-256 hash of
56212
+ // the operator's email. Anonymized \u2014 the plaintext is hashed in-browser and
56213
+ // never sent (analytics.ts only accepts a hex digest). Without a user_id,
56214
+ // GA counts each session/device as a new user (active-users \u2248 events).
56215
+ // Best-effort + only when analytics consent is on.
56216
+ if (window.LatticeGA && state.analyticsEffective && window.crypto && window.crypto.subtle) {
56217
+ fetchJson('/api/userconfig/identity')
56218
+ .then(function (id) {
56219
+ var email = id && id.email ? String(id.email).trim().toLowerCase() : '';
56220
+ if (!email) return undefined;
56221
+ return window.crypto.subtle
56222
+ .digest('SHA-256', new TextEncoder().encode(email))
56223
+ .then(function (buf) {
56224
+ var hex = Array.prototype.map
56225
+ .call(new Uint8Array(buf), function (b) {
56226
+ return ('0' + b.toString(16)).slice(-2);
56227
+ })
56228
+ .join('');
56229
+ window.LatticeGA.setUser(hex);
56230
+ });
56231
+ })
56232
+ .catch(function () {
56233
+ /* best-effort \u2014 GA still functions without a user_id */
56234
+ });
56235
+ }
56187
56236
  gaTrack('app_open', { advanced_mode: advancedMode() });
56188
56237
  document.body.classList.toggle('advanced-mode', advancedMode());
56189
56238
  wireSettingsDrawer();
@@ -56195,15 +56244,13 @@ var appJs = `
56195
56244
  wireHistoryControls();
56196
56245
  refreshHistoryState();
56197
56246
  renderRoute();
56198
- startRealtime();
56199
- startRenderProgress();
56247
+ startEventStream();
56200
56248
  initSearch();
56201
56249
  initLastEdited();
56202
56250
  initOffline();
56203
56251
  initRailResize();
56204
56252
  initRailDrawer();
56205
56253
  initRailDragDrop();
56206
- startFeed();
56207
56254
  renderComposer();
56208
56255
  initThreadControls();
56209
56256
  checkNativeSetup();
@@ -56219,12 +56266,14 @@ var appJs = `
56219
56266
  }
56220
56267
 
56221
56268
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56222
- // Realtime \u2014 Server-Sent Events from /api/realtime/stream.
56223
- // One EventSource per session; on 'change' events we mark the
56224
- // current view dirty and refetch via afterMutation() (debounced
56225
- // to coalesce bursts). On 'state' events we drive the topbar pill.
56269
+ // Realtime / feed / render-progress all arrive over ONE multiplexed
56270
+ // WebSocket (/api/stream) \u2014 see startEventStream() below. A single
56271
+ // connection per tab (instead of three SSE streams) keeps the browser's
56272
+ // tiny per-host HTTP connection budget free for data requests, so clicking
56273
+ // objects and switching workspaces stay responsive no matter how many tabs
56274
+ // are open. 'change' events mark the current view dirty and refetch via
56275
+ // afterMutation() (debounced); 'state' events drive the topbar pill.
56226
56276
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56227
- var realtimeSource = null;
56228
56277
  var realtimePending = null;
56229
56278
  // Team-cloud collaboration state. usersById resolves "last edited by"
56230
56279
  // names; lastEditedByPk maps "<table>|<pk>" \u2192 { userId, at } from realtime
@@ -56562,42 +56611,87 @@ var appJs = `
56562
56611
  afterMutation().catch(function () { /* swallow */ });
56563
56612
  }, 200);
56564
56613
  }
56565
- function startRealtime() {
56566
- if (realtimeSource) {
56567
- try { realtimeSource.close(); } catch (_) { /* ignore */ }
56568
- realtimeSource = null;
56569
- }
56570
- if (typeof EventSource === 'undefined') return;
56571
- realtimeSource = new EventSource('/api/realtime/stream');
56572
- realtimeSource.addEventListener('state', function (ev) {
56573
- try {
56574
- var data = JSON.parse(ev.data);
56575
- setStatusPill(data.mode || 'local', data.state || 'local');
56576
- } catch (_) { /* ignore malformed */ }
56577
- });
56578
- realtimeSource.addEventListener('change', function (ev) {
56579
- var p = null;
56580
- try { p = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56581
- if (p) onRealtimeChange(p);
56614
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56615
+ // Multiplexed event stream \u2014 ONE WebSocket carries realtime state/change,
56616
+ // the activity feed, and background-render progress (previously three
56617
+ // separate SSE streams). Holding one connection per tab instead of three
56618
+ // keeps the browser's per-host HTTP budget free for data requests, so the
56619
+ // GUI never freezes when several tabs are open. Each server message is
56620
+ // { type, data }; we demux to the same handlers the SSE listeners used.
56621
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56622
+ var eventStream = null; // the active WebSocket (or null)
56623
+ var eventStreamReconnect = null; // pending reconnect timer
56624
+ var eventStreamBackoff = 1000; // reconnect delay, grows to a cap
56625
+ var eventStreamClosed = false; // true \u21D2 closed on purpose (switch/teardown), don't reconnect
56626
+ function dispatchStreamMessage(type, data) {
56627
+ if (type === 'realtime-state') {
56628
+ setStatusPill((data && data.mode) || 'local', (data && data.state) || 'local');
56629
+ } else if (type === 'realtime-change') {
56630
+ if (data) onRealtimeChange(data);
56582
56631
  scheduleRealtimeRefresh();
56583
- });
56584
- realtimeSource.onerror = function () {
56585
- // EventSource auto-reconnects; surface the disconnect on the pill
56586
- // until the server's 'state' event reports recovery.
56632
+ } else if (type === 'feed') {
56633
+ try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
56634
+ if (data && (data.table || data.op === 'schema')) scheduleRealtimeRefresh();
56635
+ } else if (type === 'render-snapshot') {
56636
+ if (data) applyRenderSnapshot(data);
56637
+ } else if (type === 'render-progress') {
56638
+ if (data) onRenderEvent(data);
56639
+ }
56640
+ }
56641
+ function scheduleEventStreamReconnect() {
56642
+ if (eventStreamClosed || eventStreamReconnect) return;
56643
+ var delay = eventStreamBackoff;
56644
+ eventStreamBackoff = Math.min(eventStreamBackoff * 2, 15000);
56645
+ eventStreamReconnect = setTimeout(function () {
56646
+ eventStreamReconnect = null;
56647
+ startEventStream();
56648
+ }, delay);
56649
+ }
56650
+ function stopEventStream() {
56651
+ eventStreamClosed = true;
56652
+ if (eventStreamReconnect) { clearTimeout(eventStreamReconnect); eventStreamReconnect = null; }
56653
+ if (eventStream) {
56654
+ // Drop the onclose handler first so an intentional close doesn't trip the
56655
+ // reconnect/disconnect path.
56656
+ try { eventStream.onclose = null; eventStream.close(); } catch (_) { /* ignore */ }
56657
+ eventStream = null;
56658
+ }
56659
+ }
56660
+ function startEventStream() {
56661
+ stopEventStream();
56662
+ eventStreamClosed = false;
56663
+ if (typeof WebSocket === 'undefined') return;
56664
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
56665
+ var ws;
56666
+ try { ws = new WebSocket(proto + '//' + location.host + '/api/stream'); }
56667
+ catch (_) { scheduleEventStreamReconnect(); return; }
56668
+ eventStream = ws;
56669
+ ws.onopen = function () { eventStreamBackoff = 1000; };
56670
+ ws.onmessage = function (ev) {
56671
+ var msg = null;
56672
+ try { msg = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
56673
+ if (msg && msg.type) dispatchStreamMessage(msg.type, msg.data);
56674
+ };
56675
+ ws.onerror = function () { /* surfaced via onclose \u2192 reconnect */ };
56676
+ ws.onclose = function () {
56677
+ if (eventStream === ws) eventStream = null;
56678
+ if (eventStreamClosed) return;
56679
+ // Unexpected drop: show the disconnect on the pill and auto-reconnect with
56680
+ // backoff (the server replays state + render snapshot on reconnect).
56587
56681
  setStatusPill('cloud', 'disconnected');
56682
+ scheduleEventStreamReconnect();
56588
56683
  };
56589
56684
  }
56590
56685
 
56591
56686
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56592
- // Background-render progress \u2014 Server-Sent Events from
56593
- // /api/render/progress. A workspace opens/switches instantly and renders its
56594
- // context tree in the background; this paints a per-table % overlay on the
56595
- // dashboard cards (bottom-edge bar + \u27F3 pill) and dims the row count until
56596
- // each table completes. Row COUNTS come only from /api/entities \u2014 the render
56597
- // stream drives only the transient overlay and one reconciling refetch on
56598
- // completion. Mirrors the realtime EventSource pattern above.
56687
+ // Background-render progress \u2014 render events arrive over the multiplexed
56688
+ // /api/stream WebSocket (render-snapshot + render-progress). A workspace
56689
+ // opens/switches instantly and renders its context tree in the background;
56690
+ // this paints a per-table % overlay on the dashboard cards (bottom-edge bar +
56691
+ // \u27F3 pill) and dims the row count until each table completes. Row COUNTS come
56692
+ // only from /api/entities \u2014 the render events drive only the transient overlay
56693
+ // and one reconciling refetch on completion.
56599
56694
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56600
- var renderSource = null;
56601
56695
  // { [table]: { pct, rendered, total, done, error } } \u2014 the live render state,
56602
56696
  // re-applied to cards after every dashboard rebuild (drawDashboard wipes the
56603
56697
  // DOM overlays but not this map).
@@ -56710,37 +56804,17 @@ var appJs = `
56710
56804
  }
56711
56805
  }
56712
56806
  }
56713
- function startRenderProgress() {
56714
- if (renderSource) {
56715
- try { renderSource.close(); } catch (_) { /* ignore */ }
56716
- renderSource = null;
56717
- }
56718
- if (typeof EventSource === 'undefined') return;
56719
- // On (re)connect, fetch the single-shot snapshot so a tab that connects
56720
- // mid- or post-render paints correctly even before the next event. The SSE
56721
- // endpoint ALSO replays a 'snapshot' event on connect, so both paths agree.
56722
- fetchJson('/api/render/status').then(applyRenderSnapshot).catch(function () { /* ignore */ });
56723
- renderSource = new EventSource('/api/render/progress');
56724
- renderSource.addEventListener('snapshot', function (ev) {
56725
- var snap = null;
56726
- try { snap = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56727
- if (snap) applyRenderSnapshot(snap);
56728
- });
56729
- renderSource.addEventListener('progress', function (ev) {
56730
- var e = null;
56731
- try { e = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56732
- if (e) onRenderEvent(e);
56733
- });
56734
- // EventSource auto-reconnects on error; the status refetch on the next
56735
- // open repaints from the authoritative snapshot.
56736
- }
56807
+ // Render snapshot + progress are applied from the multiplexed /api/stream
56808
+ // WebSocket: the server replays a render-snapshot on connect (so a tab that
56809
+ // connects mid- or post-render paints correctly) and streams render-progress
56810
+ // events thereafter. See dispatchStreamMessage / startEventStream.
56737
56811
 
56738
56812
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56739
56813
  // Shared activity helpers \u2014 the operation-icon map and relative-time
56740
56814
  // formatter, used by Version History and the dashboard activity list. The
56741
56815
  // standalone Activity rail was removed in 1.16.1 (redundant with Version
56742
- // History); multiplayer realtime convergence runs on the separate realtime
56743
- // channel (startRealtime), not on this.
56816
+ // History); multiplayer realtime convergence runs on the realtime-change
56817
+ // messages of the multiplexed event stream (startEventStream), not on this.
56744
56818
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56745
56819
  var FEED_ICONS = {
56746
56820
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
@@ -56978,12 +57052,11 @@ var appJs = `
56978
57052
  if (location.hash !== '#/') location.hash = '#/';
56979
57053
  else renderRoute();
56980
57054
  loadedTables = {};
56981
- startRealtime();
56982
- // A switch swaps the server-side render bus to the new workspace; drop the
56983
- // old workspace's overlay state and re-subscribe so the new render streams
56984
- // onto this workspace's cards.
57055
+ // A switch swaps the server-side buses to the new workspace; drop the old
57056
+ // workspace's render overlay state and reconnect the multiplexed event
57057
+ // stream so realtime/feed/render all rebind to this workspace.
56985
57058
  renderProgress = {};
56986
- startRenderProgress();
57059
+ startEventStream();
56987
57060
  });
56988
57061
  }
56989
57062
 
@@ -57150,7 +57223,6 @@ var appJs = `
57150
57223
  // to THIS workspace, and reload its thread list (+ latest convo).
57151
57224
  newChat();
57152
57225
  clearActivityFeed();
57153
- startFeed();
57154
57226
  refreshThreadList(true);
57155
57227
  showToast('Switched workspace', {});
57156
57228
  }).catch(function (err) { menu.hidden = true; endWsSwitching(true); showToast('Switch failed: ' + err.message, {}); });
@@ -57611,7 +57683,7 @@ var appJs = `
57611
57683
  function rowVisMarkup(tbl, r) {
57612
57684
  var a = r._access;
57613
57685
  if (!a) return '';
57614
- var vis = a.visibility;
57686
+ var vis = effectiveVisibility(a);
57615
57687
  var glyph = vis === 'custom' ? '\u25CE' : '\u25C9';
57616
57688
  if (!a.ownedByMe) {
57617
57689
  var seen = vis === 'custom' ? 'Shared with you' : 'Visible to everyone';
@@ -57961,7 +58033,7 @@ var appJs = `
57961
58033
  function detailVisLineEl(row) {
57962
58034
  var a = row._access;
57963
58035
  if (!a) return '';
57964
- var vis = a.visibility;
58036
+ var vis = effectiveVisibility(a);
57965
58037
  var labelMap = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
57966
58038
  // Clear visual indicator: a lock when private, an eye when shared (everyone
57967
58039
  // or specific people), with a hover tooltip. The shared helper keeps the
@@ -57972,8 +58044,7 @@ var appJs = `
57972
58044
  return '<div class="detail-vis muted" style="display:flex;align-items:center;gap:6px;margin:6px 0;font-size:13px">' +
57973
58045
  visIcon + '<span>' + escapeHtml(seen) + '</span></div>';
57974
58046
  }
57975
- var info = labelMap[vis] || '';
57976
- if (vis === 'custom' && a.grantees) info += ' (' + a.grantees.length + ')';
58047
+ var info = visInfoLabel(a);
57977
58048
  var buttons;
57978
58049
  if (vis === 'custom') {
57979
58050
  // Leaving custom stops the grant list from applying \u2014 the toggle
@@ -58028,9 +58099,12 @@ var appJs = `
58028
58099
  if (!panel) return;
58029
58100
  if (!panel.hidden) { panel.hidden = true; return; }
58030
58101
  var access = row._access || {};
58031
- var ensure = access.visibility === 'custom'
58032
- ? Promise.resolve()
58033
- : postVisibility('custom').then(function () { access.visibility = 'custom'; });
58102
+ // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
58103
+ // row the user never actually shared stuck at "custom (0)". The first
58104
+ // grant flips it to custom server-side (lattice_grant_row); revoking the
58105
+ // last leaves it custom-with-0-grantees, which now reads as private. So
58106
+ // just load the member checklist.
58107
+ var ensure = Promise.resolve();
58034
58108
  withBusy(detailVisManage, function () {
58035
58109
  return ensure.then(function () {
58036
58110
  return fetchJson('/api/cloud/members');
@@ -58064,8 +58138,13 @@ var appJs = `
58064
58138
  var at = list.indexOf(role);
58065
58139
  if (cb.checked && at === -1) list.push(role);
58066
58140
  if (!cb.checked && at !== -1) list.splice(at, 1);
58141
+ // The first grant flips the row to custom server-side; mirror
58142
+ // that locally so the indicator updates. Revoking the last leaves
58143
+ // visibility 'custom' but effectiveVisibility renders custom-0 as
58144
+ // private, so the label flips back to "Private to you".
58145
+ if (list.length > 0) access.visibility = 'custom';
58067
58146
  var infoEl = content.querySelector('#detail-vis-info');
58068
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + list.length + ')';
58147
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
58069
58148
  invalidate(tableName);
58070
58149
  }).catch(function (e) {
58071
58150
  cb.checked = !cb.checked; // revert the failed change
@@ -58073,10 +58152,8 @@ var appJs = `
58073
58152
  }).then(function () { cb.disabled = false; });
58074
58153
  });
58075
58154
  });
58076
- if (access.visibility === 'custom') {
58077
- var infoEl = content.querySelector('#detail-vis-info');
58078
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + (access.grantees || []).length + ')';
58079
- }
58155
+ var infoEl = content.querySelector('#detail-vis-info');
58156
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
58080
58157
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
58081
58158
  });
58082
58159
  });
@@ -60187,6 +60264,29 @@ var appJs = `
60187
60264
  var EYE_SVG =
60188
60265
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
60189
60266
  '<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>';
60267
+ // A custom row with zero grantees is effectively PRIVATE (RLS shows it only to
60268
+ // the owner), so it must read as private, not "Shared with specific people
60269
+ // (0)". Only collapse for the OWNER's own view, where the grantees list is
60270
+ // authoritative \u2014 a member viewing a row shared WITH them gets custom with no
60271
+ // grantees list and must still read as "Shared with you".
60272
+ function effectiveVisibility(access) {
60273
+ if (!access || !access.visibility) return 'private';
60274
+ if (access.visibility === 'custom' && access.ownedByMe &&
60275
+ (!access.grantees || access.grantees.length === 0)) {
60276
+ return 'private';
60277
+ }
60278
+ return access.visibility;
60279
+ }
60280
+ // Owner-facing status label for a row's sharing, honoring effectiveVisibility
60281
+ // (so custom-0 reads as "Private to you") and appending the grantee count only
60282
+ // for a genuine specific-people share.
60283
+ function visInfoLabel(access) {
60284
+ var v = effectiveVisibility(access);
60285
+ var map = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
60286
+ var s = map[v] || '';
60287
+ if (v === 'custom' && access && access.grantees) s += ' (' + access.grantees.length + ')';
60288
+ return s;
60289
+ }
60190
60290
  // Shared per-row lock/eye indicator, reused on the entity-detail header and the
60191
60291
  // fs card tiles so the meaning is consistent. The access arg is the server-
60192
60292
  // attached row._access (visibility + ownedByMe); returns empty when absent (a
@@ -60194,7 +60294,7 @@ var appJs = `
60194
60294
  // A hover tooltip spells out what the lock/eye means (state + ownership aware).
60195
60295
  function visIndicator(access, extraClass) {
60196
60296
  if (!access || !access.visibility) return '';
60197
- var vis = access.visibility;
60297
+ var vis = effectiveVisibility(access);
60198
60298
  var tip = vis === 'private'
60199
60299
  ? 'Private \u2014 only you can see this'
60200
60300
  : vis === 'custom'
@@ -61983,7 +62083,6 @@ var appJs = `
61983
62083
 
61984
62084
 
61985
62085
  // ============ AI assistant rail (2.0) ============
61986
- var feedSource = null;
61987
62086
  var FEED_ICONS = {
61988
62087
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
61989
62088
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
@@ -62236,32 +62335,13 @@ var appJs = `
62236
62335
  else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
62237
62336
  }
62238
62337
  }
62239
- function startFeed() {
62240
- if (feedSource) {
62241
- try { feedSource.close(); } catch (_) { /* ignore */ }
62242
- feedSource = null;
62243
- }
62244
- if (typeof EventSource === 'undefined') return;
62245
- feedSource = new EventSource('/api/feed/stream');
62246
- feedSource.addEventListener('feed', function (ev) {
62247
- var data;
62248
- try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
62249
- try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
62250
- // Refresh on ANY data mutation, not just schema/new-table events. The
62251
- // local feed bus delivers every insert/update/delete/link even when
62252
- // there's no realtime broker (SQLite/local), so this is what makes the
62253
- // home dashboard counts AND the open entity view live-update without a
62254
- // manual reload (previously only schema ops or brand-new tables did).
62255
- // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
62256
- // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
62257
- // it shares that debounce with the realtime 'change' handler (no double
62258
- // fetch). /api/entities batches its row counts into one query, not N.
62259
- if (data && (data.table || data.op === 'schema')) {
62260
- scheduleRealtimeRefresh();
62261
- }
62262
- });
62263
- // EventSource auto-reconnects on error; no extra handling needed.
62264
- }
62338
+ // Feed events arrive over the multiplexed /api/stream WebSocket and are
62339
+ // handled in dispatchStreamMessage('feed', \u2026): renderFeedItem() paints the
62340
+ // card, then scheduleRealtimeRefresh() refetches on ANY data mutation (the
62341
+ // local feed bus delivers every insert/update/delete/link even with no
62342
+ // realtime broker, so this is what live-updates the dashboard counts and the
62343
+ // open entity view without a manual reload). The 200ms debounce coalesces a
62344
+ // burst into a single refetch and is shared with the realtime 'change' path.
62265
62345
 
62266
62346
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
62267
62347
  // Assistant rail resize \u2014 drag the left edge, clamp, persist.
@@ -62957,6 +63037,16 @@ var analyticsJs = `
62957
63037
  page_title: t,
62958
63038
  });
62959
63039
  },
63040
+ // Set the GA user_id to an OPAQUE per-user hash so active users are
63041
+ // deduplicated across sessions/devices (without it, unique users \u2248 events).
63042
+ // Accepts ONLY a 64-char hex digest \u2014 a raw email or any other PII is
63043
+ // rejected (never sent), preserving the privacy contract above. The caller
63044
+ // hashes the email; this module never sees the plaintext.
63045
+ setUser: function (idHash) {
63046
+ if (!consent || !loaded) return;
63047
+ if (typeof idHash !== 'string' || !/^[a-f0-9]{64}$/.test(idHash)) return;
63048
+ gtag('config', MEASUREMENT_ID, { user_id: idHash });
63049
+ },
62960
63050
  };
62961
63051
  })();
62962
63052
  `;
@@ -66366,19 +66456,36 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66366
66456
  }
66367
66457
  let memberOpen = false;
66368
66458
  const maskedReadViews = /* @__PURE__ */ new Map();
66459
+ const discoveredJunctions = /* @__PURE__ */ new Set();
66369
66460
  if (db.getDialect() === "postgres") {
66370
66461
  const peek = new Lattice({ config: configPath }, { encryptionKey });
66371
66462
  try {
66372
66463
  await peek.init({ introspectOnly: true });
66373
- if (await cloudRlsInstalled(peek)) {
66374
- memberOpen = !await canManageRoles(peek);
66464
+ const [rlsInstalled, canManage] = await Promise.all([
66465
+ cloudRlsInstalled(peek),
66466
+ canManageRoles(peek)
66467
+ ]);
66468
+ if (rlsInstalled) {
66469
+ memberOpen = !canManage;
66375
66470
  if (memberOpen) {
66376
66471
  const declared = new Set(db.getRegisteredTableNames());
66377
- const discovered = await discoverCloudTables(peek);
66472
+ const [discovered, viewsRaw] = await Promise.all([
66473
+ discoverCloudTables(peek),
66474
+ allAsyncOrSync(
66475
+ peek.adapter,
66476
+ `SELECT table_name AS name FROM information_schema.views
66477
+ WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
66478
+ )
66479
+ ]);
66480
+ const views = viewsRaw;
66378
66481
  const knownTables = /* @__PURE__ */ new Set([...declared, ...discovered.map((t8) => t8.name)]);
66379
66482
  for (const t8 of discovered) {
66380
66483
  if (declared.has(t8.name)) continue;
66381
66484
  if (t8.columns.length === 0) continue;
66485
+ if (isJunctionByColumns(t8.columns)) {
66486
+ discoveredJunctions.add(t8.name);
66487
+ continue;
66488
+ }
66382
66489
  db.define(t8.name, {
66383
66490
  columns: Object.fromEntries(t8.columns.map((c6) => [c6, "TEXT"])),
66384
66491
  ...t8.pk.length > 0 ? { primaryKey: t8.pk.length === 1 ? t8.pk[0] : t8.pk } : {},
@@ -66386,11 +66493,6 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66386
66493
  outputFile: `${t8.name}/.lattice/${t8.name}.md`
66387
66494
  });
66388
66495
  }
66389
- const views = await allAsyncOrSync(
66390
- peek.adapter,
66391
- `SELECT table_name AS name FROM information_schema.views
66392
- WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
66393
- );
66394
66496
  for (const { name } of views) {
66395
66497
  const base = name.slice(0, -2);
66396
66498
  if (knownTables.has(base)) maskedReadViews.set(base, name);
@@ -66427,9 +66529,12 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66427
66529
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
66428
66530
  validTables.add(name);
66429
66531
  }
66430
- const junctionTables = new Set(
66431
- getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name)
66432
- );
66532
+ const junctionTables = /* @__PURE__ */ new Set([
66533
+ ...getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name),
66534
+ // Member-discovered junctions (classified from the physical shape above);
66535
+ // empty for an owner/local open.
66536
+ ...discoveredJunctions
66537
+ ]);
66433
66538
  const entityContextByTable = db.entityContexts();
66434
66539
  const manifest = readManifest(outputDir);
66435
66540
  const softDeletable = /* @__PURE__ */ new Set();
@@ -66630,6 +66735,9 @@ async function changeVisibleToActiveRole(db, payload) {
66630
66735
  function isDeleteOp(op) {
66631
66736
  return op === "delete" || op === "DELETE";
66632
66737
  }
66738
+ function isFeedHiddenTable(t8) {
66739
+ return t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
66740
+ }
66633
66741
  function readRelationFor(active, table) {
66634
66742
  return active.maskedReadViews.get(table) ?? table;
66635
66743
  }
@@ -67011,158 +67119,10 @@ async function startGuiServer(options) {
67011
67119
  sendJson(res, { mode, state: active.realtime?.state() ?? "local", connected });
67012
67120
  return;
67013
67121
  }
67014
- if (method === "GET" && pathname === "/api/realtime/stream") {
67015
- res.writeHead(200, {
67016
- "content-type": "text/event-stream; charset=utf-8",
67017
- "cache-control": "no-store, no-transform",
67018
- connection: "keep-alive",
67019
- "x-accel-buffering": "no"
67020
- });
67021
- const broker = active.realtime;
67022
- const initialMode = broker ? "cloud" : "local";
67023
- const writeEvent = (event, data) => {
67024
- try {
67025
- res.write(`event: ${event}
67026
- data: ${JSON.stringify(data)}
67027
-
67028
- `);
67029
- } catch {
67030
- }
67031
- };
67032
- writeEvent("state", {
67033
- mode: initialMode,
67034
- state: broker?.state() ?? "local"
67035
- });
67036
- const keepalive = setInterval(() => {
67037
- try {
67038
- res.write(`: keepalive
67039
-
67040
- `);
67041
- } catch {
67042
- }
67043
- }, 25e3);
67044
- const offState = broker?.subscribeState((state2) => {
67045
- writeEvent("state", { mode: "cloud", state: state2 });
67046
- });
67047
- const offPayload = broker?.subscribePayload((payload) => {
67048
- if (activeRef !== active) return;
67049
- void changeVisibleToActiveRole(active.db, payload).then((visible) => {
67050
- if (!visible) return;
67051
- const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
67052
- writeEvent("change", out);
67053
- });
67054
- });
67055
- const cleanup = () => {
67056
- clearInterval(keepalive);
67057
- if (offState) offState();
67058
- if (offPayload) offPayload();
67059
- };
67060
- req.on("close", cleanup);
67061
- req.on("error", cleanup);
67062
- return;
67063
- }
67064
- if (method === "GET" && pathname === "/api/feed/stream") {
67065
- res.writeHead(200, {
67066
- "content-type": "text/event-stream; charset=utf-8",
67067
- "cache-control": "no-store, no-transform",
67068
- connection: "keep-alive",
67069
- "x-accel-buffering": "no"
67070
- });
67071
- const writeFeed = (data) => {
67072
- try {
67073
- res.write(`event: feed
67074
- data: ${JSON.stringify(data)}
67075
-
67076
- `);
67077
- } catch {
67078
- }
67079
- };
67080
- const keepalive = setInterval(() => {
67081
- try {
67082
- res.write(`: keepalive
67083
-
67084
- `);
67085
- } catch {
67086
- }
67087
- }, 25e3);
67088
- const recentSelf = /* @__PURE__ */ new Map();
67089
- const isFeedHiddenTable = (t8) => t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
67090
- const offFeed = active.feed.subscribe((e6) => {
67091
- if (e6.table && isFeedHiddenTable(e6.table)) return;
67092
- recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
67093
- writeFeed(e6);
67094
- });
67095
- const offBroker = active.realtime?.subscribePayload((p3) => {
67096
- const op = feedOpForChange(p3.op);
67097
- if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
67098
- const tableName = p3.table_name;
67099
- const key = `${tableName}:${p3.pk ?? ""}:${op}`;
67100
- const seen = recentSelf.get(key);
67101
- if (seen && Date.now() - seen < 5e3) return;
67102
- if (activeRef !== active) return;
67103
- void changeVisibleToActiveRole(active.db, p3).then((visible) => {
67104
- if (!visible) return;
67105
- writeFeed({
67106
- seq: p3.seq,
67107
- table: tableName,
67108
- op,
67109
- rowId: p3.pk,
67110
- source: "cli",
67111
- actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
67112
- ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
67113
- summary: `${op} on ${tableName} (another client)`
67114
- });
67115
- });
67116
- });
67117
- const cleanup = () => {
67118
- clearInterval(keepalive);
67119
- offFeed();
67120
- if (offBroker) offBroker();
67121
- };
67122
- req.on("close", cleanup);
67123
- req.on("error", cleanup);
67124
- return;
67125
- }
67126
67122
  if (method === "GET" && pathname === "/api/render/status") {
67127
67123
  sendJson(res, active.renderState);
67128
67124
  return;
67129
67125
  }
67130
- if (method === "GET" && pathname === "/api/render/progress") {
67131
- res.writeHead(200, {
67132
- "content-type": "text/event-stream; charset=utf-8",
67133
- "cache-control": "no-store, no-transform",
67134
- connection: "keep-alive",
67135
- "x-accel-buffering": "no"
67136
- });
67137
- const writeEvent = (event, data) => {
67138
- try {
67139
- res.write(`event: ${event}
67140
- data: ${JSON.stringify(data)}
67141
-
67142
- `);
67143
- } catch {
67144
- }
67145
- };
67146
- writeEvent("snapshot", active.renderState);
67147
- const keepalive = setInterval(() => {
67148
- try {
67149
- res.write(`: keepalive
67150
-
67151
- `);
67152
- } catch {
67153
- }
67154
- }, 25e3);
67155
- const offProgress = active.renderProgress.subscribe((e6) => {
67156
- writeEvent("progress", e6);
67157
- });
67158
- const cleanup = () => {
67159
- clearInterval(keepalive);
67160
- offProgress();
67161
- };
67162
- req.on("close", cleanup);
67163
- req.on("error", cleanup);
67164
- return;
67165
- }
67166
67126
  if (method === "GET" && pathname === "/api/project") {
67167
67127
  sendJson(res, getGuiProject(active.configPath, active.outputDir));
67168
67128
  return;
@@ -68680,6 +68640,107 @@ ${e6.stack ?? ""}`
68680
68640
  }
68681
68641
  })();
68682
68642
  });
68643
+ const wss = new import_ws.WebSocketServer({ noServer: true });
68644
+ const handleEventStream = (ws) => {
68645
+ const bound = activeRef;
68646
+ const send = (type, data) => {
68647
+ if (ws.readyState !== import_ws.WebSocket.OPEN) return;
68648
+ try {
68649
+ ws.send(JSON.stringify({ type, data }));
68650
+ } catch {
68651
+ }
68652
+ };
68653
+ const broker = bound?.realtime ?? null;
68654
+ send("realtime-state", { mode: broker ? "cloud" : "local", state: broker?.state() ?? "local" });
68655
+ if (bound) send("render-snapshot", bound.renderState);
68656
+ const offs = [];
68657
+ if (bound) {
68658
+ if (broker) {
68659
+ offs.push(
68660
+ broker.subscribeState((state2) => {
68661
+ send("realtime-state", { mode: "cloud", state: state2 });
68662
+ })
68663
+ );
68664
+ offs.push(
68665
+ broker.subscribePayload((payload) => {
68666
+ if (activeRef !== bound) return;
68667
+ void changeVisibleToActiveRole(bound.db, payload).then((visible) => {
68668
+ if (!visible) return;
68669
+ const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
68670
+ send("realtime-change", out);
68671
+ });
68672
+ })
68673
+ );
68674
+ }
68675
+ const recentSelf = /* @__PURE__ */ new Map();
68676
+ offs.push(
68677
+ bound.feed.subscribe((e6) => {
68678
+ if (e6.table && isFeedHiddenTable(e6.table)) return;
68679
+ recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
68680
+ send("feed", e6);
68681
+ })
68682
+ );
68683
+ if (broker) {
68684
+ offs.push(
68685
+ broker.subscribePayload((p3) => {
68686
+ const op = feedOpForChange(p3.op);
68687
+ if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
68688
+ const tableName = p3.table_name;
68689
+ const key = `${tableName}:${p3.pk ?? ""}:${op}`;
68690
+ const seen = recentSelf.get(key);
68691
+ if (seen && Date.now() - seen < 5e3) return;
68692
+ if (activeRef !== bound) return;
68693
+ void changeVisibleToActiveRole(bound.db, p3).then((visible) => {
68694
+ if (!visible) return;
68695
+ send("feed", {
68696
+ seq: p3.seq,
68697
+ table: tableName,
68698
+ op,
68699
+ rowId: p3.pk,
68700
+ source: "cli",
68701
+ actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
68702
+ ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
68703
+ summary: `${op} on ${tableName} (another client)`
68704
+ });
68705
+ });
68706
+ })
68707
+ );
68708
+ }
68709
+ offs.push(
68710
+ bound.renderProgress.subscribe((e6) => {
68711
+ send("render-progress", e6);
68712
+ })
68713
+ );
68714
+ }
68715
+ const keepalive = setInterval(() => {
68716
+ if (ws.readyState !== import_ws.WebSocket.OPEN) return;
68717
+ try {
68718
+ ws.ping();
68719
+ } catch {
68720
+ }
68721
+ }, 25e3);
68722
+ const cleanup = () => {
68723
+ clearInterval(keepalive);
68724
+ for (const off of offs) {
68725
+ try {
68726
+ off();
68727
+ } catch {
68728
+ }
68729
+ }
68730
+ };
68731
+ ws.on("close", cleanup);
68732
+ ws.on("error", cleanup);
68733
+ };
68734
+ server.on("upgrade", (req, socket, head) => {
68735
+ const { pathname } = new URL(req.url ?? "/", `http://${host}`);
68736
+ if (pathname !== "/api/stream") {
68737
+ socket.destroy();
68738
+ return;
68739
+ }
68740
+ wss.handleUpgrade(req, socket, head, (ws) => {
68741
+ handleEventStream(ws);
68742
+ });
68743
+ });
68683
68744
  const port = await listenWithPortFallback(server, startPort, host);
68684
68745
  if (activeRef) startBackgroundRender(activeRef);
68685
68746
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
@@ -68690,6 +68751,13 @@ ${e6.stack ?? ""}`
68690
68751
  port,
68691
68752
  url,
68692
68753
  close: () => new Promise((resolveClose, reject) => {
68754
+ for (const client of wss.clients) {
68755
+ try {
68756
+ client.terminate();
68757
+ } catch {
68758
+ }
68759
+ }
68760
+ wss.close();
68693
68761
  server.close((err) => {
68694
68762
  if (err) {
68695
68763
  reject(err);