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.js CHANGED
@@ -48535,6 +48535,10 @@ function isJunctionTable(table) {
48535
48535
  const fkCols = new Set(belongsTo.map((r6) => r6.foreignKey));
48536
48536
  return table.columns.every((c6) => fkCols.has(c6) || JUNCTION_ALLOWED_NONFK.has(c6));
48537
48537
  }
48538
+ function isJunctionByColumns(columns) {
48539
+ const payload = columns.filter((c6) => !JUNCTION_ALLOWED_NONFK.has(c6));
48540
+ return payload.length === 2 && payload.every((c6) => c6.endsWith("_id"));
48541
+ }
48538
48542
  function fileJunctions(configPath, outputDir) {
48539
48543
  const out = [];
48540
48544
  for (const t8 of getGuiEntities(configPath, outputDir).tables) {
@@ -51871,6 +51875,16 @@ var init_ingest_url = __esm({
51871
51875
  });
51872
51876
 
51873
51877
  // src/gui/ai/dispatch.ts
51878
+ function visibilityDenialReason(opts) {
51879
+ if (opts.kind === "table") {
51880
+ return opts.canManageTableDefault ? null : "Only the workspace owner can change a table's default sharing.";
51881
+ }
51882
+ if (!opts.rowAccess) return "That record was not found, or is not visible to you.";
51883
+ if (!opts.rowAccess.ownedByMe) {
51884
+ return "You do not own this record, so you cannot change its sharing \u2014 only its owner can.";
51885
+ }
51886
+ return null;
51887
+ }
51874
51888
  async function secretColumnsFor(db, table) {
51875
51889
  try {
51876
51890
  const rows = await db.query("_lattice_gui_column_meta", {
@@ -52096,6 +52110,14 @@ async function executeFunction(ctx, name, args) {
52096
52110
  };
52097
52111
  }
52098
52112
  const id = typeof args.id === "string" && args.id ? args.id : void 0;
52113
+ const denial = id ? visibilityDenialReason({
52114
+ kind: "row",
52115
+ rowAccess: (await rowAccessSummaries(ctx.db, table, [id])).get(id)
52116
+ }) : visibilityDenialReason({
52117
+ kind: "table",
52118
+ canManageTableDefault: await canManageRoles(ctx.db)
52119
+ });
52120
+ if (denial) return { ok: false, error: denial };
52099
52121
  try {
52100
52122
  if (id) {
52101
52123
  await setRowVisibility(ctx.db, table, id, visibility);
@@ -52259,6 +52281,7 @@ var init_dispatch = __esm({
52259
52281
  init_column_descriptions();
52260
52282
  init_members();
52261
52283
  init_table_policy();
52284
+ init_cloud_connect();
52262
52285
  init_dedup_service();
52263
52286
  DISPATCHABLE = /* @__PURE__ */ new Set([
52264
52287
  "list_entities",
@@ -54130,6 +54153,7 @@ init_summarize();
54130
54153
  // src/gui/server.ts
54131
54154
  import { createServer } from "http";
54132
54155
  import { spawn as spawn2 } from "child_process";
54156
+ import { WebSocketServer, WebSocket } from "ws";
54133
54157
  import {
54134
54158
  existsSync as existsSync21,
54135
54159
  mkdirSync as mkdirSync9,
@@ -56004,6 +56028,31 @@ var appJs = `
56004
56028
  // Boot analytics with the resolved consent (no network contact when off),
56005
56029
  // then record the session open. advanced_mode is a boolean \u2014 safe to send.
56006
56030
  if (window.LatticeGA) window.LatticeGA.init(state.analyticsEffective);
56031
+ // Deduplicate unique users in GA: set the GA user_id to a SHA-256 hash of
56032
+ // the operator's email. Anonymized \u2014 the plaintext is hashed in-browser and
56033
+ // never sent (analytics.ts only accepts a hex digest). Without a user_id,
56034
+ // GA counts each session/device as a new user (active-users \u2248 events).
56035
+ // Best-effort + only when analytics consent is on.
56036
+ if (window.LatticeGA && state.analyticsEffective && window.crypto && window.crypto.subtle) {
56037
+ fetchJson('/api/userconfig/identity')
56038
+ .then(function (id) {
56039
+ var email = id && id.email ? String(id.email).trim().toLowerCase() : '';
56040
+ if (!email) return undefined;
56041
+ return window.crypto.subtle
56042
+ .digest('SHA-256', new TextEncoder().encode(email))
56043
+ .then(function (buf) {
56044
+ var hex = Array.prototype.map
56045
+ .call(new Uint8Array(buf), function (b) {
56046
+ return ('0' + b.toString(16)).slice(-2);
56047
+ })
56048
+ .join('');
56049
+ window.LatticeGA.setUser(hex);
56050
+ });
56051
+ })
56052
+ .catch(function () {
56053
+ /* best-effort \u2014 GA still functions without a user_id */
56054
+ });
56055
+ }
56007
56056
  gaTrack('app_open', { advanced_mode: advancedMode() });
56008
56057
  document.body.classList.toggle('advanced-mode', advancedMode());
56009
56058
  wireSettingsDrawer();
@@ -56015,15 +56064,13 @@ var appJs = `
56015
56064
  wireHistoryControls();
56016
56065
  refreshHistoryState();
56017
56066
  renderRoute();
56018
- startRealtime();
56019
- startRenderProgress();
56067
+ startEventStream();
56020
56068
  initSearch();
56021
56069
  initLastEdited();
56022
56070
  initOffline();
56023
56071
  initRailResize();
56024
56072
  initRailDrawer();
56025
56073
  initRailDragDrop();
56026
- startFeed();
56027
56074
  renderComposer();
56028
56075
  initThreadControls();
56029
56076
  checkNativeSetup();
@@ -56039,12 +56086,14 @@ var appJs = `
56039
56086
  }
56040
56087
 
56041
56088
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56042
- // Realtime \u2014 Server-Sent Events from /api/realtime/stream.
56043
- // One EventSource per session; on 'change' events we mark the
56044
- // current view dirty and refetch via afterMutation() (debounced
56045
- // to coalesce bursts). On 'state' events we drive the topbar pill.
56089
+ // Realtime / feed / render-progress all arrive over ONE multiplexed
56090
+ // WebSocket (/api/stream) \u2014 see startEventStream() below. A single
56091
+ // connection per tab (instead of three SSE streams) keeps the browser's
56092
+ // tiny per-host HTTP connection budget free for data requests, so clicking
56093
+ // objects and switching workspaces stay responsive no matter how many tabs
56094
+ // are open. 'change' events mark the current view dirty and refetch via
56095
+ // afterMutation() (debounced); 'state' events drive the topbar pill.
56046
56096
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56047
- var realtimeSource = null;
56048
56097
  var realtimePending = null;
56049
56098
  // Team-cloud collaboration state. usersById resolves "last edited by"
56050
56099
  // names; lastEditedByPk maps "<table>|<pk>" \u2192 { userId, at } from realtime
@@ -56382,42 +56431,87 @@ var appJs = `
56382
56431
  afterMutation().catch(function () { /* swallow */ });
56383
56432
  }, 200);
56384
56433
  }
56385
- function startRealtime() {
56386
- if (realtimeSource) {
56387
- try { realtimeSource.close(); } catch (_) { /* ignore */ }
56388
- realtimeSource = null;
56389
- }
56390
- if (typeof EventSource === 'undefined') return;
56391
- realtimeSource = new EventSource('/api/realtime/stream');
56392
- realtimeSource.addEventListener('state', function (ev) {
56393
- try {
56394
- var data = JSON.parse(ev.data);
56395
- setStatusPill(data.mode || 'local', data.state || 'local');
56396
- } catch (_) { /* ignore malformed */ }
56397
- });
56398
- realtimeSource.addEventListener('change', function (ev) {
56399
- var p = null;
56400
- try { p = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56401
- if (p) onRealtimeChange(p);
56434
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56435
+ // Multiplexed event stream \u2014 ONE WebSocket carries realtime state/change,
56436
+ // the activity feed, and background-render progress (previously three
56437
+ // separate SSE streams). Holding one connection per tab instead of three
56438
+ // keeps the browser's per-host HTTP budget free for data requests, so the
56439
+ // GUI never freezes when several tabs are open. Each server message is
56440
+ // { type, data }; we demux to the same handlers the SSE listeners used.
56441
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56442
+ var eventStream = null; // the active WebSocket (or null)
56443
+ var eventStreamReconnect = null; // pending reconnect timer
56444
+ var eventStreamBackoff = 1000; // reconnect delay, grows to a cap
56445
+ var eventStreamClosed = false; // true \u21D2 closed on purpose (switch/teardown), don't reconnect
56446
+ function dispatchStreamMessage(type, data) {
56447
+ if (type === 'realtime-state') {
56448
+ setStatusPill((data && data.mode) || 'local', (data && data.state) || 'local');
56449
+ } else if (type === 'realtime-change') {
56450
+ if (data) onRealtimeChange(data);
56402
56451
  scheduleRealtimeRefresh();
56403
- });
56404
- realtimeSource.onerror = function () {
56405
- // EventSource auto-reconnects; surface the disconnect on the pill
56406
- // until the server's 'state' event reports recovery.
56452
+ } else if (type === 'feed') {
56453
+ try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
56454
+ if (data && (data.table || data.op === 'schema')) scheduleRealtimeRefresh();
56455
+ } else if (type === 'render-snapshot') {
56456
+ if (data) applyRenderSnapshot(data);
56457
+ } else if (type === 'render-progress') {
56458
+ if (data) onRenderEvent(data);
56459
+ }
56460
+ }
56461
+ function scheduleEventStreamReconnect() {
56462
+ if (eventStreamClosed || eventStreamReconnect) return;
56463
+ var delay = eventStreamBackoff;
56464
+ eventStreamBackoff = Math.min(eventStreamBackoff * 2, 15000);
56465
+ eventStreamReconnect = setTimeout(function () {
56466
+ eventStreamReconnect = null;
56467
+ startEventStream();
56468
+ }, delay);
56469
+ }
56470
+ function stopEventStream() {
56471
+ eventStreamClosed = true;
56472
+ if (eventStreamReconnect) { clearTimeout(eventStreamReconnect); eventStreamReconnect = null; }
56473
+ if (eventStream) {
56474
+ // Drop the onclose handler first so an intentional close doesn't trip the
56475
+ // reconnect/disconnect path.
56476
+ try { eventStream.onclose = null; eventStream.close(); } catch (_) { /* ignore */ }
56477
+ eventStream = null;
56478
+ }
56479
+ }
56480
+ function startEventStream() {
56481
+ stopEventStream();
56482
+ eventStreamClosed = false;
56483
+ if (typeof WebSocket === 'undefined') return;
56484
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
56485
+ var ws;
56486
+ try { ws = new WebSocket(proto + '//' + location.host + '/api/stream'); }
56487
+ catch (_) { scheduleEventStreamReconnect(); return; }
56488
+ eventStream = ws;
56489
+ ws.onopen = function () { eventStreamBackoff = 1000; };
56490
+ ws.onmessage = function (ev) {
56491
+ var msg = null;
56492
+ try { msg = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
56493
+ if (msg && msg.type) dispatchStreamMessage(msg.type, msg.data);
56494
+ };
56495
+ ws.onerror = function () { /* surfaced via onclose \u2192 reconnect */ };
56496
+ ws.onclose = function () {
56497
+ if (eventStream === ws) eventStream = null;
56498
+ if (eventStreamClosed) return;
56499
+ // Unexpected drop: show the disconnect on the pill and auto-reconnect with
56500
+ // backoff (the server replays state + render snapshot on reconnect).
56407
56501
  setStatusPill('cloud', 'disconnected');
56502
+ scheduleEventStreamReconnect();
56408
56503
  };
56409
56504
  }
56410
56505
 
56411
56506
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56412
- // Background-render progress \u2014 Server-Sent Events from
56413
- // /api/render/progress. A workspace opens/switches instantly and renders its
56414
- // context tree in the background; this paints a per-table % overlay on the
56415
- // dashboard cards (bottom-edge bar + \u27F3 pill) and dims the row count until
56416
- // each table completes. Row COUNTS come only from /api/entities \u2014 the render
56417
- // stream drives only the transient overlay and one reconciling refetch on
56418
- // completion. Mirrors the realtime EventSource pattern above.
56507
+ // Background-render progress \u2014 render events arrive over the multiplexed
56508
+ // /api/stream WebSocket (render-snapshot + render-progress). A workspace
56509
+ // opens/switches instantly and renders its context tree in the background;
56510
+ // this paints a per-table % overlay on the dashboard cards (bottom-edge bar +
56511
+ // \u27F3 pill) and dims the row count until each table completes. Row COUNTS come
56512
+ // only from /api/entities \u2014 the render events drive only the transient overlay
56513
+ // and one reconciling refetch on completion.
56419
56514
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56420
- var renderSource = null;
56421
56515
  // { [table]: { pct, rendered, total, done, error } } \u2014 the live render state,
56422
56516
  // re-applied to cards after every dashboard rebuild (drawDashboard wipes the
56423
56517
  // DOM overlays but not this map).
@@ -56530,37 +56624,17 @@ var appJs = `
56530
56624
  }
56531
56625
  }
56532
56626
  }
56533
- function startRenderProgress() {
56534
- if (renderSource) {
56535
- try { renderSource.close(); } catch (_) { /* ignore */ }
56536
- renderSource = null;
56537
- }
56538
- if (typeof EventSource === 'undefined') return;
56539
- // On (re)connect, fetch the single-shot snapshot so a tab that connects
56540
- // mid- or post-render paints correctly even before the next event. The SSE
56541
- // endpoint ALSO replays a 'snapshot' event on connect, so both paths agree.
56542
- fetchJson('/api/render/status').then(applyRenderSnapshot).catch(function () { /* ignore */ });
56543
- renderSource = new EventSource('/api/render/progress');
56544
- renderSource.addEventListener('snapshot', function (ev) {
56545
- var snap = null;
56546
- try { snap = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56547
- if (snap) applyRenderSnapshot(snap);
56548
- });
56549
- renderSource.addEventListener('progress', function (ev) {
56550
- var e = null;
56551
- try { e = JSON.parse(ev.data); } catch (_) { /* ignore malformed */ }
56552
- if (e) onRenderEvent(e);
56553
- });
56554
- // EventSource auto-reconnects on error; the status refetch on the next
56555
- // open repaints from the authoritative snapshot.
56556
- }
56627
+ // Render snapshot + progress are applied from the multiplexed /api/stream
56628
+ // WebSocket: the server replays a render-snapshot on connect (so a tab that
56629
+ // connects mid- or post-render paints correctly) and streams render-progress
56630
+ // events thereafter. See dispatchStreamMessage / startEventStream.
56557
56631
 
56558
56632
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56559
56633
  // Shared activity helpers \u2014 the operation-icon map and relative-time
56560
56634
  // formatter, used by Version History and the dashboard activity list. The
56561
56635
  // standalone Activity rail was removed in 1.16.1 (redundant with Version
56562
- // History); multiplayer realtime convergence runs on the separate realtime
56563
- // channel (startRealtime), not on this.
56636
+ // History); multiplayer realtime convergence runs on the realtime-change
56637
+ // messages of the multiplexed event stream (startEventStream), not on this.
56564
56638
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
56565
56639
  var FEED_ICONS = {
56566
56640
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
@@ -56798,12 +56872,11 @@ var appJs = `
56798
56872
  if (location.hash !== '#/') location.hash = '#/';
56799
56873
  else renderRoute();
56800
56874
  loadedTables = {};
56801
- startRealtime();
56802
- // A switch swaps the server-side render bus to the new workspace; drop the
56803
- // old workspace's overlay state and re-subscribe so the new render streams
56804
- // onto this workspace's cards.
56875
+ // A switch swaps the server-side buses to the new workspace; drop the old
56876
+ // workspace's render overlay state and reconnect the multiplexed event
56877
+ // stream so realtime/feed/render all rebind to this workspace.
56805
56878
  renderProgress = {};
56806
- startRenderProgress();
56879
+ startEventStream();
56807
56880
  });
56808
56881
  }
56809
56882
 
@@ -56970,7 +57043,6 @@ var appJs = `
56970
57043
  // to THIS workspace, and reload its thread list (+ latest convo).
56971
57044
  newChat();
56972
57045
  clearActivityFeed();
56973
- startFeed();
56974
57046
  refreshThreadList(true);
56975
57047
  showToast('Switched workspace', {});
56976
57048
  }).catch(function (err) { menu.hidden = true; endWsSwitching(true); showToast('Switch failed: ' + err.message, {}); });
@@ -57431,7 +57503,7 @@ var appJs = `
57431
57503
  function rowVisMarkup(tbl, r) {
57432
57504
  var a = r._access;
57433
57505
  if (!a) return '';
57434
- var vis = a.visibility;
57506
+ var vis = effectiveVisibility(a);
57435
57507
  var glyph = vis === 'custom' ? '\u25CE' : '\u25C9';
57436
57508
  if (!a.ownedByMe) {
57437
57509
  var seen = vis === 'custom' ? 'Shared with you' : 'Visible to everyone';
@@ -57781,7 +57853,7 @@ var appJs = `
57781
57853
  function detailVisLineEl(row) {
57782
57854
  var a = row._access;
57783
57855
  if (!a) return '';
57784
- var vis = a.visibility;
57856
+ var vis = effectiveVisibility(a);
57785
57857
  var labelMap = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
57786
57858
  // Clear visual indicator: a lock when private, an eye when shared (everyone
57787
57859
  // or specific people), with a hover tooltip. The shared helper keeps the
@@ -57792,8 +57864,7 @@ var appJs = `
57792
57864
  return '<div class="detail-vis muted" style="display:flex;align-items:center;gap:6px;margin:6px 0;font-size:13px">' +
57793
57865
  visIcon + '<span>' + escapeHtml(seen) + '</span></div>';
57794
57866
  }
57795
- var info = labelMap[vis] || '';
57796
- if (vis === 'custom' && a.grantees) info += ' (' + a.grantees.length + ')';
57867
+ var info = visInfoLabel(a);
57797
57868
  var buttons;
57798
57869
  if (vis === 'custom') {
57799
57870
  // Leaving custom stops the grant list from applying \u2014 the toggle
@@ -57848,9 +57919,12 @@ var appJs = `
57848
57919
  if (!panel) return;
57849
57920
  if (!panel.hidden) { panel.hidden = true; return; }
57850
57921
  var access = row._access || {};
57851
- var ensure = access.visibility === 'custom'
57852
- ? Promise.resolve()
57853
- : postVisibility('custom').then(function () { access.visibility = 'custom'; });
57922
+ // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
57923
+ // row the user never actually shared stuck at "custom (0)". The first
57924
+ // grant flips it to custom server-side (lattice_grant_row); revoking the
57925
+ // last leaves it custom-with-0-grantees, which now reads as private. So
57926
+ // just load the member checklist.
57927
+ var ensure = Promise.resolve();
57854
57928
  withBusy(detailVisManage, function () {
57855
57929
  return ensure.then(function () {
57856
57930
  return fetchJson('/api/cloud/members');
@@ -57884,8 +57958,13 @@ var appJs = `
57884
57958
  var at = list.indexOf(role);
57885
57959
  if (cb.checked && at === -1) list.push(role);
57886
57960
  if (!cb.checked && at !== -1) list.splice(at, 1);
57961
+ // The first grant flips the row to custom server-side; mirror
57962
+ // that locally so the indicator updates. Revoking the last leaves
57963
+ // visibility 'custom' but effectiveVisibility renders custom-0 as
57964
+ // private, so the label flips back to "Private to you".
57965
+ if (list.length > 0) access.visibility = 'custom';
57887
57966
  var infoEl = content.querySelector('#detail-vis-info');
57888
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + list.length + ')';
57967
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
57889
57968
  invalidate(tableName);
57890
57969
  }).catch(function (e) {
57891
57970
  cb.checked = !cb.checked; // revert the failed change
@@ -57893,10 +57972,8 @@ var appJs = `
57893
57972
  }).then(function () { cb.disabled = false; });
57894
57973
  });
57895
57974
  });
57896
- if (access.visibility === 'custom') {
57897
- var infoEl = content.querySelector('#detail-vis-info');
57898
- if (infoEl) infoEl.textContent = 'Shared with specific people (' + (access.grantees || []).length + ')';
57899
- }
57975
+ var infoEl = content.querySelector('#detail-vis-info');
57976
+ if (infoEl) infoEl.textContent = visInfoLabel(access);
57900
57977
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
57901
57978
  });
57902
57979
  });
@@ -60007,6 +60084,29 @@ var appJs = `
60007
60084
  var EYE_SVG =
60008
60085
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
60009
60086
  '<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>';
60087
+ // A custom row with zero grantees is effectively PRIVATE (RLS shows it only to
60088
+ // the owner), so it must read as private, not "Shared with specific people
60089
+ // (0)". Only collapse for the OWNER's own view, where the grantees list is
60090
+ // authoritative \u2014 a member viewing a row shared WITH them gets custom with no
60091
+ // grantees list and must still read as "Shared with you".
60092
+ function effectiveVisibility(access) {
60093
+ if (!access || !access.visibility) return 'private';
60094
+ if (access.visibility === 'custom' && access.ownedByMe &&
60095
+ (!access.grantees || access.grantees.length === 0)) {
60096
+ return 'private';
60097
+ }
60098
+ return access.visibility;
60099
+ }
60100
+ // Owner-facing status label for a row's sharing, honoring effectiveVisibility
60101
+ // (so custom-0 reads as "Private to you") and appending the grantee count only
60102
+ // for a genuine specific-people share.
60103
+ function visInfoLabel(access) {
60104
+ var v = effectiveVisibility(access);
60105
+ var map = { everyone: 'Visible to everyone', private: 'Private to you', custom: 'Shared with specific people' };
60106
+ var s = map[v] || '';
60107
+ if (v === 'custom' && access && access.grantees) s += ' (' + access.grantees.length + ')';
60108
+ return s;
60109
+ }
60010
60110
  // Shared per-row lock/eye indicator, reused on the entity-detail header and the
60011
60111
  // fs card tiles so the meaning is consistent. The access arg is the server-
60012
60112
  // attached row._access (visibility + ownedByMe); returns empty when absent (a
@@ -60014,7 +60114,7 @@ var appJs = `
60014
60114
  // A hover tooltip spells out what the lock/eye means (state + ownership aware).
60015
60115
  function visIndicator(access, extraClass) {
60016
60116
  if (!access || !access.visibility) return '';
60017
- var vis = access.visibility;
60117
+ var vis = effectiveVisibility(access);
60018
60118
  var tip = vis === 'private'
60019
60119
  ? 'Private \u2014 only you can see this'
60020
60120
  : vis === 'custom'
@@ -61803,7 +61903,6 @@ var appJs = `
61803
61903
 
61804
61904
 
61805
61905
  // ============ AI assistant rail (2.0) ============
61806
- var feedSource = null;
61807
61906
  var FEED_ICONS = {
61808
61907
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
61809
61908
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
@@ -62056,32 +62155,13 @@ var appJs = `
62056
62155
  else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
62057
62156
  }
62058
62157
  }
62059
- function startFeed() {
62060
- if (feedSource) {
62061
- try { feedSource.close(); } catch (_) { /* ignore */ }
62062
- feedSource = null;
62063
- }
62064
- if (typeof EventSource === 'undefined') return;
62065
- feedSource = new EventSource('/api/feed/stream');
62066
- feedSource.addEventListener('feed', function (ev) {
62067
- var data;
62068
- try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
62069
- try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
62070
- // Refresh on ANY data mutation, not just schema/new-table events. The
62071
- // local feed bus delivers every insert/update/delete/link even when
62072
- // there's no realtime broker (SQLite/local), so this is what makes the
62073
- // home dashboard counts AND the open entity view live-update without a
62074
- // manual reload (previously only schema ops or brand-new tables did).
62075
- // scheduleRealtimeRefresh is debounced (200ms) so a burst from one
62076
- // ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
62077
- // it shares that debounce with the realtime 'change' handler (no double
62078
- // fetch). /api/entities batches its row counts into one query, not N.
62079
- if (data && (data.table || data.op === 'schema')) {
62080
- scheduleRealtimeRefresh();
62081
- }
62082
- });
62083
- // EventSource auto-reconnects on error; no extra handling needed.
62084
- }
62158
+ // Feed events arrive over the multiplexed /api/stream WebSocket and are
62159
+ // handled in dispatchStreamMessage('feed', \u2026): renderFeedItem() paints the
62160
+ // card, then scheduleRealtimeRefresh() refetches on ANY data mutation (the
62161
+ // local feed bus delivers every insert/update/delete/link even with no
62162
+ // realtime broker, so this is what live-updates the dashboard counts and the
62163
+ // open entity view without a manual reload). The 200ms debounce coalesces a
62164
+ // burst into a single refetch and is shared with the realtime 'change' path.
62085
62165
 
62086
62166
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
62087
62167
  // Assistant rail resize \u2014 drag the left edge, clamp, persist.
@@ -62777,6 +62857,16 @@ var analyticsJs = `
62777
62857
  page_title: t,
62778
62858
  });
62779
62859
  },
62860
+ // Set the GA user_id to an OPAQUE per-user hash so active users are
62861
+ // deduplicated across sessions/devices (without it, unique users \u2248 events).
62862
+ // Accepts ONLY a 64-char hex digest \u2014 a raw email or any other PII is
62863
+ // rejected (never sent), preserving the privacy contract above. The caller
62864
+ // hashes the email; this module never sees the plaintext.
62865
+ setUser: function (idHash) {
62866
+ if (!consent || !loaded) return;
62867
+ if (typeof idHash !== 'string' || !/^[a-f0-9]{64}$/.test(idHash)) return;
62868
+ gtag('config', MEASUREMENT_ID, { user_id: idHash });
62869
+ },
62780
62870
  };
62781
62871
  })();
62782
62872
  `;
@@ -66185,19 +66275,36 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66185
66275
  }
66186
66276
  let memberOpen = false;
66187
66277
  const maskedReadViews = /* @__PURE__ */ new Map();
66278
+ const discoveredJunctions = /* @__PURE__ */ new Set();
66188
66279
  if (db.getDialect() === "postgres") {
66189
66280
  const peek = new Lattice({ config: configPath }, { encryptionKey });
66190
66281
  try {
66191
66282
  await peek.init({ introspectOnly: true });
66192
- if (await cloudRlsInstalled(peek)) {
66193
- memberOpen = !await canManageRoles(peek);
66283
+ const [rlsInstalled, canManage] = await Promise.all([
66284
+ cloudRlsInstalled(peek),
66285
+ canManageRoles(peek)
66286
+ ]);
66287
+ if (rlsInstalled) {
66288
+ memberOpen = !canManage;
66194
66289
  if (memberOpen) {
66195
66290
  const declared = new Set(db.getRegisteredTableNames());
66196
- const discovered = await discoverCloudTables(peek);
66291
+ const [discovered, viewsRaw] = await Promise.all([
66292
+ discoverCloudTables(peek),
66293
+ allAsyncOrSync(
66294
+ peek.adapter,
66295
+ `SELECT table_name AS name FROM information_schema.views
66296
+ WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
66297
+ )
66298
+ ]);
66299
+ const views = viewsRaw;
66197
66300
  const knownTables = /* @__PURE__ */ new Set([...declared, ...discovered.map((t8) => t8.name)]);
66198
66301
  for (const t8 of discovered) {
66199
66302
  if (declared.has(t8.name)) continue;
66200
66303
  if (t8.columns.length === 0) continue;
66304
+ if (isJunctionByColumns(t8.columns)) {
66305
+ discoveredJunctions.add(t8.name);
66306
+ continue;
66307
+ }
66201
66308
  db.define(t8.name, {
66202
66309
  columns: Object.fromEntries(t8.columns.map((c6) => [c6, "TEXT"])),
66203
66310
  ...t8.pk.length > 0 ? { primaryKey: t8.pk.length === 1 ? t8.pk[0] : t8.pk } : {},
@@ -66205,11 +66312,6 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66205
66312
  outputFile: `${t8.name}/.lattice/${t8.name}.md`
66206
66313
  });
66207
66314
  }
66208
- const views = await allAsyncOrSync(
66209
- peek.adapter,
66210
- `SELECT table_name AS name FROM information_schema.views
66211
- WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
66212
- );
66213
66315
  for (const { name } of views) {
66214
66316
  const base = name.slice(0, -2);
66215
66317
  if (knownTables.has(base)) maskedReadViews.set(base, name);
@@ -66246,9 +66348,12 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66246
66348
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
66247
66349
  validTables.add(name);
66248
66350
  }
66249
- const junctionTables = new Set(
66250
- getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name)
66251
- );
66351
+ const junctionTables = /* @__PURE__ */ new Set([
66352
+ ...getGuiEntities(configPath, outputDir).tables.filter(isJunctionTable).map((t8) => t8.name),
66353
+ // Member-discovered junctions (classified from the physical shape above);
66354
+ // empty for an owner/local open.
66355
+ ...discoveredJunctions
66356
+ ]);
66252
66357
  const entityContextByTable = db.entityContexts();
66253
66358
  const manifest = readManifest(outputDir);
66254
66359
  const softDeletable = /* @__PURE__ */ new Set();
@@ -66449,6 +66554,9 @@ async function changeVisibleToActiveRole(db, payload) {
66449
66554
  function isDeleteOp(op) {
66450
66555
  return op === "delete" || op === "DELETE";
66451
66556
  }
66557
+ function isFeedHiddenTable(t8) {
66558
+ return t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
66559
+ }
66452
66560
  function readRelationFor(active, table) {
66453
66561
  return active.maskedReadViews.get(table) ?? table;
66454
66562
  }
@@ -66830,158 +66938,10 @@ async function startGuiServer(options) {
66830
66938
  sendJson(res, { mode, state: active.realtime?.state() ?? "local", connected });
66831
66939
  return;
66832
66940
  }
66833
- if (method === "GET" && pathname === "/api/realtime/stream") {
66834
- res.writeHead(200, {
66835
- "content-type": "text/event-stream; charset=utf-8",
66836
- "cache-control": "no-store, no-transform",
66837
- connection: "keep-alive",
66838
- "x-accel-buffering": "no"
66839
- });
66840
- const broker = active.realtime;
66841
- const initialMode = broker ? "cloud" : "local";
66842
- const writeEvent = (event, data) => {
66843
- try {
66844
- res.write(`event: ${event}
66845
- data: ${JSON.stringify(data)}
66846
-
66847
- `);
66848
- } catch {
66849
- }
66850
- };
66851
- writeEvent("state", {
66852
- mode: initialMode,
66853
- state: broker?.state() ?? "local"
66854
- });
66855
- const keepalive = setInterval(() => {
66856
- try {
66857
- res.write(`: keepalive
66858
-
66859
- `);
66860
- } catch {
66861
- }
66862
- }, 25e3);
66863
- const offState = broker?.subscribeState((state2) => {
66864
- writeEvent("state", { mode: "cloud", state: state2 });
66865
- });
66866
- const offPayload = broker?.subscribePayload((payload) => {
66867
- if (activeRef !== active) return;
66868
- void changeVisibleToActiveRole(active.db, payload).then((visible) => {
66869
- if (!visible) return;
66870
- const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
66871
- writeEvent("change", out);
66872
- });
66873
- });
66874
- const cleanup = () => {
66875
- clearInterval(keepalive);
66876
- if (offState) offState();
66877
- if (offPayload) offPayload();
66878
- };
66879
- req.on("close", cleanup);
66880
- req.on("error", cleanup);
66881
- return;
66882
- }
66883
- if (method === "GET" && pathname === "/api/feed/stream") {
66884
- res.writeHead(200, {
66885
- "content-type": "text/event-stream; charset=utf-8",
66886
- "cache-control": "no-store, no-transform",
66887
- connection: "keep-alive",
66888
- "x-accel-buffering": "no"
66889
- });
66890
- const writeFeed = (data) => {
66891
- try {
66892
- res.write(`event: feed
66893
- data: ${JSON.stringify(data)}
66894
-
66895
- `);
66896
- } catch {
66897
- }
66898
- };
66899
- const keepalive = setInterval(() => {
66900
- try {
66901
- res.write(`: keepalive
66902
-
66903
- `);
66904
- } catch {
66905
- }
66906
- }, 25e3);
66907
- const recentSelf = /* @__PURE__ */ new Map();
66908
- const isFeedHiddenTable = (t8) => t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
66909
- const offFeed = active.feed.subscribe((e6) => {
66910
- if (e6.table && isFeedHiddenTable(e6.table)) return;
66911
- recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
66912
- writeFeed(e6);
66913
- });
66914
- const offBroker = active.realtime?.subscribePayload((p3) => {
66915
- const op = feedOpForChange(p3.op);
66916
- if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
66917
- const tableName = p3.table_name;
66918
- const key = `${tableName}:${p3.pk ?? ""}:${op}`;
66919
- const seen = recentSelf.get(key);
66920
- if (seen && Date.now() - seen < 5e3) return;
66921
- if (activeRef !== active) return;
66922
- void changeVisibleToActiveRole(active.db, p3).then((visible) => {
66923
- if (!visible) return;
66924
- writeFeed({
66925
- seq: p3.seq,
66926
- table: tableName,
66927
- op,
66928
- rowId: p3.pk,
66929
- source: "cli",
66930
- actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
66931
- ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
66932
- summary: `${op} on ${tableName} (another client)`
66933
- });
66934
- });
66935
- });
66936
- const cleanup = () => {
66937
- clearInterval(keepalive);
66938
- offFeed();
66939
- if (offBroker) offBroker();
66940
- };
66941
- req.on("close", cleanup);
66942
- req.on("error", cleanup);
66943
- return;
66944
- }
66945
66941
  if (method === "GET" && pathname === "/api/render/status") {
66946
66942
  sendJson(res, active.renderState);
66947
66943
  return;
66948
66944
  }
66949
- if (method === "GET" && pathname === "/api/render/progress") {
66950
- res.writeHead(200, {
66951
- "content-type": "text/event-stream; charset=utf-8",
66952
- "cache-control": "no-store, no-transform",
66953
- connection: "keep-alive",
66954
- "x-accel-buffering": "no"
66955
- });
66956
- const writeEvent = (event, data) => {
66957
- try {
66958
- res.write(`event: ${event}
66959
- data: ${JSON.stringify(data)}
66960
-
66961
- `);
66962
- } catch {
66963
- }
66964
- };
66965
- writeEvent("snapshot", active.renderState);
66966
- const keepalive = setInterval(() => {
66967
- try {
66968
- res.write(`: keepalive
66969
-
66970
- `);
66971
- } catch {
66972
- }
66973
- }, 25e3);
66974
- const offProgress = active.renderProgress.subscribe((e6) => {
66975
- writeEvent("progress", e6);
66976
- });
66977
- const cleanup = () => {
66978
- clearInterval(keepalive);
66979
- offProgress();
66980
- };
66981
- req.on("close", cleanup);
66982
- req.on("error", cleanup);
66983
- return;
66984
- }
66985
66945
  if (method === "GET" && pathname === "/api/project") {
66986
66946
  sendJson(res, getGuiProject(active.configPath, active.outputDir));
66987
66947
  return;
@@ -68499,6 +68459,107 @@ ${e6.stack ?? ""}`
68499
68459
  }
68500
68460
  })();
68501
68461
  });
68462
+ const wss = new WebSocketServer({ noServer: true });
68463
+ const handleEventStream = (ws) => {
68464
+ const bound = activeRef;
68465
+ const send = (type, data) => {
68466
+ if (ws.readyState !== WebSocket.OPEN) return;
68467
+ try {
68468
+ ws.send(JSON.stringify({ type, data }));
68469
+ } catch {
68470
+ }
68471
+ };
68472
+ const broker = bound?.realtime ?? null;
68473
+ send("realtime-state", { mode: broker ? "cloud" : "local", state: broker?.state() ?? "local" });
68474
+ if (bound) send("render-snapshot", bound.renderState);
68475
+ const offs = [];
68476
+ if (bound) {
68477
+ if (broker) {
68478
+ offs.push(
68479
+ broker.subscribeState((state2) => {
68480
+ send("realtime-state", { mode: "cloud", state: state2 });
68481
+ })
68482
+ );
68483
+ offs.push(
68484
+ broker.subscribePayload((payload) => {
68485
+ if (activeRef !== bound) return;
68486
+ void changeVisibleToActiveRole(bound.db, payload).then((visible) => {
68487
+ if (!visible) return;
68488
+ const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
68489
+ send("realtime-change", out);
68490
+ });
68491
+ })
68492
+ );
68493
+ }
68494
+ const recentSelf = /* @__PURE__ */ new Map();
68495
+ offs.push(
68496
+ bound.feed.subscribe((e6) => {
68497
+ if (e6.table && isFeedHiddenTable(e6.table)) return;
68498
+ recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
68499
+ send("feed", e6);
68500
+ })
68501
+ );
68502
+ if (broker) {
68503
+ offs.push(
68504
+ broker.subscribePayload((p3) => {
68505
+ const op = feedOpForChange(p3.op);
68506
+ if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
68507
+ const tableName = p3.table_name;
68508
+ const key = `${tableName}:${p3.pk ?? ""}:${op}`;
68509
+ const seen = recentSelf.get(key);
68510
+ if (seen && Date.now() - seen < 5e3) return;
68511
+ if (activeRef !== bound) return;
68512
+ void changeVisibleToActiveRole(bound.db, p3).then((visible) => {
68513
+ if (!visible) return;
68514
+ send("feed", {
68515
+ seq: p3.seq,
68516
+ table: tableName,
68517
+ op,
68518
+ rowId: p3.pk,
68519
+ source: "cli",
68520
+ actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
68521
+ ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
68522
+ summary: `${op} on ${tableName} (another client)`
68523
+ });
68524
+ });
68525
+ })
68526
+ );
68527
+ }
68528
+ offs.push(
68529
+ bound.renderProgress.subscribe((e6) => {
68530
+ send("render-progress", e6);
68531
+ })
68532
+ );
68533
+ }
68534
+ const keepalive = setInterval(() => {
68535
+ if (ws.readyState !== WebSocket.OPEN) return;
68536
+ try {
68537
+ ws.ping();
68538
+ } catch {
68539
+ }
68540
+ }, 25e3);
68541
+ const cleanup = () => {
68542
+ clearInterval(keepalive);
68543
+ for (const off of offs) {
68544
+ try {
68545
+ off();
68546
+ } catch {
68547
+ }
68548
+ }
68549
+ };
68550
+ ws.on("close", cleanup);
68551
+ ws.on("error", cleanup);
68552
+ };
68553
+ server.on("upgrade", (req, socket, head) => {
68554
+ const { pathname } = new URL(req.url ?? "/", `http://${host}`);
68555
+ if (pathname !== "/api/stream") {
68556
+ socket.destroy();
68557
+ return;
68558
+ }
68559
+ wss.handleUpgrade(req, socket, head, (ws) => {
68560
+ handleEventStream(ws);
68561
+ });
68562
+ });
68502
68563
  const port = await listenWithPortFallback(server, startPort, host);
68503
68564
  if (activeRef) startBackgroundRender(activeRef);
68504
68565
  const displayHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
@@ -68509,6 +68570,13 @@ ${e6.stack ?? ""}`
68509
68570
  port,
68510
68571
  url,
68511
68572
  close: () => new Promise((resolveClose, reject) => {
68573
+ for (const client of wss.clients) {
68574
+ try {
68575
+ client.terminate();
68576
+ } catch {
68577
+ }
68578
+ }
68579
+ wss.close();
68512
68580
  server.close((err) => {
68513
68581
  if (err) {
68514
68582
  reject(err);