latticesql 1.16.0 → 1.16.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.
package/README.md CHANGED
@@ -2300,7 +2300,15 @@ lattice teams join \
2300
2300
 
2301
2301
  The cloud rejects redemption if the caller's claimed email doesn't match the invitation's `invitee_email` (case-insensitive). Sharing an invite token in a public channel is therefore safe — only the addressee can redeem it.
2302
2302
 
2303
- **Other subcommands** (`lattice teams help` for the full list): `list`, `members`, `leave`, `destroy`, `share`, `unshare`, `shared`, `sync`, `link`, `unlink`, `pull`, `push`, `status`.
2303
+ **Other subcommands** (`lattice teams help` for the full list): `list`, `members`, `leave`, `destroy`, `share`, `unshare`, `shared`, `sync`, `link`, `unlink`, `pull`, `push`, `status`, `dlq`.
2304
+
2305
+ **Dead-letter queue (v1.15+).** A pulled change envelope that fails to apply (e.g. it arrived before the row/table it depends on), and any non-owner-overwrite divergence notice, lands in `__lattice_team_dlq`. Inspect and recover it instead of losing it behind the pull cursor:
2306
+
2307
+ ```bash
2308
+ lattice teams dlq list --team <name> # show entries (op, target, error)
2309
+ lattice teams dlq retry --team <name> [--id <id>] # replay; a late dependency now applies cleanly
2310
+ lattice teams dlq purge --team <name> [--id <id>] # discard without applying
2311
+ ```
2304
2312
 
2305
2313
  **Per-table ownership + opt-in sharing (v1.14+).** Team members share one physical Postgres, so visibility is enforced at the app layer via a `__lattice_object_owners` table: each table records its creator, and a user sees only the tables they own plus tables explicitly shared to the team. The native `files`/`secrets` objects are owned by the database creator and private by default. Sharing is an explicit, owner-only action (not a side effect of creating a table). The filter gates API access, not just the display.
2306
2314
 
package/dist/cli.js CHANGED
@@ -435,6 +435,15 @@ function resolveDbPath(raw, configDir2) {
435
435
  }
436
436
  return resolve(configDir2, raw);
437
437
  }
438
+ var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
439
+ function warnDeprecatedRef(entity, field, target) {
440
+ const key = `${entity}.${field}`;
441
+ if (warnedDeprecatedRefs.has(key)) return;
442
+ warnedDeprecatedRefs.add(key);
443
+ console.warn(
444
+ `Lattice: one-to-many \`ref:\` on "${entity}.${field}" \u2192 "${target}" is deprecated in favor of many-to-many junction tables and will be removed in 2.0.`
445
+ );
446
+ }
438
447
  function entityToTableDef(entityName, entity) {
439
448
  const rawFields = entity.fields;
440
449
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -457,6 +466,7 @@ function entityToTableDef(entityName, entity) {
457
466
  table: field.ref,
458
467
  foreignKey: fieldName
459
468
  };
469
+ warnDeprecatedRef(entityName, fieldName, field.ref);
460
470
  }
461
471
  }
462
472
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -6238,7 +6248,6 @@ var css = `
6238
6248
  --danger: #ef4444;
6239
6249
  --danger-deep: #dc2626;
6240
6250
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
6241
- --sidebar-width: 380px;
6242
6251
  --nav-width: 220px;
6243
6252
  }
6244
6253
  * { box-sizing: border-box; }
@@ -6459,30 +6468,11 @@ var css = `
6459
6468
  minimum keeps the track at content-width and the whole page scrolls
6460
6469
  horizontally. */
6461
6470
  .layout {
6462
- display: grid; grid-template-columns: var(--nav-width) minmax(0, 1fr) var(--sidebar-width);
6471
+ display: grid; grid-template-columns: var(--nav-width) minmax(0, 1fr);
6463
6472
  height: calc(100vh - 56px);
6464
6473
  }
6465
- .rail-handle { display: none; }
6466
6474
  @media (max-width: 720px) {
6467
- /* The activity rail becomes a bottom drawer: tap the handle to expand
6468
- the feed to ~62svh. */
6469
- .layout { grid-template-columns: var(--nav-width) minmax(0, 1fr); }
6470
- .assistant-rail {
6471
- position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
6472
- border-left: none; border-top: 1px solid var(--border);
6473
- max-height: 62svh; box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
6474
- }
6475
- .rail-resize { display: none; }
6476
- .rail-handle {
6477
- display: block; flex: 0 0 auto; height: 22px; cursor: pointer; position: relative;
6478
- }
6479
- .rail-handle::after {
6480
- content: ''; position: absolute; top: 9px; left: 50%; transform: translateX(-50%);
6481
- width: 40px; height: 4px; border-radius: 2px; background: var(--border-strong);
6482
- }
6483
- .assistant-rail:not(.expanded) { max-height: none; }
6484
- .assistant-rail:not(.expanded) .rail-feed { display: none; }
6485
- main#content { padding-bottom: 96px; }
6475
+ main#content { padding-bottom: 24px; }
6486
6476
  }
6487
6477
  nav.sidebar {
6488
6478
  background: var(--surface); border-right: 1px solid var(--border);
@@ -6506,51 +6496,6 @@ var css = `
6506
6496
 
6507
6497
  main#content { padding: 24px; overflow: auto; }
6508
6498
 
6509
- /* \u2500\u2500 Assistant rail (activity feed) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
6510
- .assistant-rail {
6511
- position: relative;
6512
- background: var(--surface);
6513
- border-left: 1px solid var(--border);
6514
- display: flex; flex-direction: column;
6515
- min-width: 0; overflow: hidden;
6516
- }
6517
- .rail-resize {
6518
- position: absolute; left: 0; top: 0; bottom: 0; width: 5px;
6519
- cursor: col-resize; background: transparent; z-index: 5;
6520
- transition: background-color 120ms;
6521
- }
6522
- .rail-resize:hover, .rail-resize.dragging { background: var(--accent-soft); }
6523
- .rail-header {
6524
- flex: 0 0 auto; padding: 12px 14px; border-bottom: 1px solid var(--border);
6525
- display: flex; align-items: center; gap: 8px;
6526
- }
6527
- .rail-title {
6528
- font-size: 11px; font-weight: 600; color: var(--text-muted);
6529
- text-transform: uppercase; letter-spacing: 0.06em; flex: 0 0 auto;
6530
- }
6531
- .rail-feed {
6532
- flex: 1 1 auto; overflow-y: auto; padding: 10px 12px;
6533
- display: flex; flex-direction: column; gap: 8px;
6534
- }
6535
- .rail-empty { color: var(--text-muted); font-size: 12.5px; text-align: center; padding: 18px 8px; }
6536
- .feed-item {
6537
- display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 8px;
6538
- align-items: baseline; padding: 7px 9px; border-radius: 8px;
6539
- background: var(--surface-2); border: 1px solid var(--border);
6540
- animation: feedIn 0.18s ease-out;
6541
- }
6542
- @keyframes feedIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
6543
- .feed-icon { text-align: center; font-size: 13px; }
6544
- .feed-body { min-width: 0; }
6545
- .feed-summary { font-size: 13px; color: var(--text); word-break: break-word; }
6546
- .feed-meta { margin-top: 2px; display: flex; align-items: center; gap: 6px; }
6547
- .feed-source {
6548
- font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
6549
- padding: 1px 6px; border-radius: 999px;
6550
- background: var(--accent-soft); color: var(--accent);
6551
- }
6552
- .feed-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
6553
-
6554
6499
  /* \u2500\u2500 File preview (files detail page) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
6555
6500
  .file-preview { margin: 4px 0 16px; }
6556
6501
  .file-preview .file-desc {
@@ -6599,13 +6544,6 @@ var css = `
6599
6544
  .stat-n { font-size: 24px; font-weight: 700; color: var(--text); }
6600
6545
  .stat-tile.warn .stat-n { color: var(--warn); }
6601
6546
  .stat-l { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
6602
- .dash-recent { margin-top: 26px; max-width: 1100px; }
6603
- .dash-recent-head { font-size: 13px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 10px; }
6604
- .dash-recent ul { list-style: none; margin: 0; padding: 0; }
6605
- .dash-act { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
6606
- .dash-act-ic { width: 18px; text-align: center; }
6607
- .dash-act-txt { flex: 1; color: var(--text); }
6608
- .dash-act-time { font-size: 12px; color: var(--text-muted); }
6609
6547
 
6610
6548
  /* \u2500\u2500 Table view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
6611
6549
  .view-header {
@@ -7117,6 +7055,8 @@ var css = `
7117
7055
  transition: transform 0.05s ease, border-color 0.15s ease, box-shadow 0.15s ease;
7118
7056
  }
7119
7057
  .fs-tile:hover { border-color: var(--accent); transform: translateY(-1px); }
7058
+ .fs-tile-create { border-style: dashed; background: transparent; }
7059
+ .fs-tile-create .fs-tile-icon { color: var(--accent); }
7120
7060
  .fs-tile-icon { font-size: 40px; line-height: 1; }
7121
7061
  .fs-tile-label {
7122
7062
  font-size: 13px; font-weight: 500; color: var(--text);
@@ -7412,9 +7352,6 @@ var appJs = `
7412
7352
  refreshHistoryState();
7413
7353
  renderRoute();
7414
7354
  startRealtime();
7415
- initRailResize();
7416
- initRailDrawer();
7417
- startFeed();
7418
7355
  initSearch();
7419
7356
  initLastEdited();
7420
7357
  initOffline();
@@ -7770,11 +7707,12 @@ var appJs = `
7770
7707
  }
7771
7708
 
7772
7709
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7773
- // Activity feed \u2014 SSE from /api/feed/stream. Renders every audited
7774
- // mutation as a bubble in the assistant rail. Unlike the realtime
7775
- // channel (Postgres-only), this works for SQLite databases too.
7710
+ // Shared activity helpers \u2014 the operation-icon map and relative-time
7711
+ // formatter, used by Version History and the dashboard activity list. The
7712
+ // standalone Activity rail was removed in 1.16.1 (redundant with Version
7713
+ // History); multiplayer realtime convergence runs on the separate realtime
7714
+ // channel (startRealtime), not on this.
7776
7715
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7777
- var feedSource = null;
7778
7716
  var FEED_ICONS = {
7779
7717
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
7780
7718
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
@@ -7790,51 +7728,6 @@ var appJs = `
7790
7728
  return new Date(iso).toLocaleDateString();
7791
7729
  } catch (_) { return ''; }
7792
7730
  }
7793
- function renderFeedItem(ev) {
7794
- var feedEl = document.getElementById('rail-feed');
7795
- if (!feedEl) return;
7796
- var empty = document.getElementById('rail-empty');
7797
- if (empty) empty.remove();
7798
- var item = document.createElement('div');
7799
- item.className = 'feed-item';
7800
- var icon = document.createElement('div');
7801
- icon.className = 'feed-icon';
7802
- icon.textContent = FEED_ICONS[ev.op] || '\u2022';
7803
- var body = document.createElement('div');
7804
- body.className = 'feed-body';
7805
- var summary = document.createElement('div');
7806
- summary.className = 'feed-summary';
7807
- summary.textContent = ev.summary || (String(ev.op || '') + ' ' + String(ev.table || ''));
7808
- var meta = document.createElement('div');
7809
- meta.className = 'feed-meta';
7810
- var src = document.createElement('span');
7811
- src.className = 'feed-source';
7812
- src.textContent = ev.source === 'gui' ? 'you' : String(ev.source || '');
7813
- meta.appendChild(src);
7814
- body.appendChild(summary);
7815
- body.appendChild(meta);
7816
- var time = document.createElement('div');
7817
- time.className = 'feed-time';
7818
- time.textContent = relTime(ev.ts);
7819
- item.appendChild(icon);
7820
- item.appendChild(body);
7821
- item.appendChild(time);
7822
- // Most-recent on top: prepend new items and keep the view scrolled up.
7823
- feedEl.insertBefore(item, feedEl.firstChild);
7824
- feedEl.scrollTop = 0;
7825
- }
7826
- function startFeed() {
7827
- if (feedSource) {
7828
- try { feedSource.close(); } catch (_) { /* ignore */ }
7829
- feedSource = null;
7830
- }
7831
- if (typeof EventSource === 'undefined') return;
7832
- feedSource = new EventSource('/api/feed/stream');
7833
- feedSource.addEventListener('feed', function (ev) {
7834
- try { renderFeedItem(JSON.parse(ev.data)); } catch (_) { /* ignore malformed */ }
7835
- });
7836
- // EventSource auto-reconnects on error; no extra handling needed.
7837
- }
7838
7731
 
7839
7732
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7840
7733
  // Full-text search \u2014 GET /api/search, grouped dropdown, click to open.
@@ -7916,52 +7809,6 @@ var appJs = `
7916
7809
  });
7917
7810
  }
7918
7811
 
7919
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7920
- // Activity rail resize \u2014 drag the left edge, clamp, persist.
7921
- // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
7922
- var RAIL_MIN = 320, RAIL_MAX = 640, RAIL_KEY = 'lattice-rail-width';
7923
- function applyRailWidth(px) {
7924
- var w = Math.min(RAIL_MAX, Math.max(RAIL_MIN, Math.round(px)));
7925
- document.documentElement.style.setProperty('--sidebar-width', w + 'px');
7926
- return w;
7927
- }
7928
- function initRailResize() {
7929
- var saved = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
7930
- if (!isNaN(saved)) applyRailWidth(saved);
7931
- var handle = document.getElementById('rail-resize');
7932
- if (!handle) return;
7933
- handle.addEventListener('pointerdown', function (e) {
7934
- e.preventDefault();
7935
- var startX = e.clientX;
7936
- var rail = document.getElementById('assistant-rail');
7937
- var startW = rail ? rail.getBoundingClientRect().width : 380;
7938
- handle.classList.add('dragging');
7939
- function move(ev) {
7940
- // Rail sits on the right; dragging left (smaller clientX) widens it.
7941
- applyRailWidth(startW - (ev.clientX - startX));
7942
- }
7943
- function up() {
7944
- handle.classList.remove('dragging');
7945
- window.removeEventListener('pointermove', move);
7946
- window.removeEventListener('pointerup', up);
7947
- var cur = parseInt(
7948
- getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'),
7949
- 10,
7950
- );
7951
- if (!isNaN(cur)) window.localStorage.setItem(RAIL_KEY, String(cur));
7952
- }
7953
- window.addEventListener('pointermove', move);
7954
- window.addEventListener('pointerup', up);
7955
- });
7956
- }
7957
-
7958
- // Mobile: tapping the handle expands/collapses the bottom drawer.
7959
- function initRailDrawer() {
7960
- var handle = document.getElementById('rail-handle');
7961
- var rail = document.getElementById('assistant-rail');
7962
- if (handle && rail) handle.addEventListener('click', function () { rail.classList.toggle('expanded'); });
7963
- }
7964
-
7965
7812
  /** Reload column meta after a secret-flag change. */
7966
7813
  function refreshColumnMeta() {
7967
7814
  return fetchJson('/api/gui-meta/columns').then(function (d) {
@@ -8112,10 +7959,10 @@ var appJs = `
8112
7959
  else renderRoute();
8113
7960
  loadedTables = {};
8114
7961
  startRealtime();
8115
- startFeed();
8116
7962
  });
8117
7963
  }
8118
7964
 
7965
+ var wsOutsideClickBound = false;
8119
7966
  function renderWsSwitcher(data) {
8120
7967
  var wrap = document.getElementById('ws-switcher');
8121
7968
  var btn = document.getElementById('ws-button');
@@ -8173,8 +8020,13 @@ var appJs = `
8173
8020
  });
8174
8021
  });
8175
8022
  });
8176
- document.getElementById('ws-create-btn').addEventListener('click', function () {
8177
- showCreateWorkspaceInput(menu);
8023
+ document.getElementById('ws-create-btn').addEventListener('click', function (e) {
8024
+ // Stop propagation: showCreateWorkspaceInput replaces .db-create's
8025
+ // innerHTML, detaching THIS button. Without this, the click then
8026
+ // bubbles to the document outside-click closer, whose
8027
+ // menu.contains(e.target) is now false (target detached) \u2192 it would
8028
+ // close the menu, so the create input never appears.
8029
+ e.stopPropagation(); showCreateWorkspaceInput(menu);
8178
8030
  });
8179
8031
  }
8180
8032
 
@@ -8183,12 +8035,20 @@ var appJs = `
8183
8035
  if (menu.hidden) buildMenu();
8184
8036
  menu.hidden = !menu.hidden;
8185
8037
  };
8186
- document.addEventListener('click', function (e) {
8187
- if (menu.hidden) return;
8188
- if (!menu.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
8189
- menu.hidden = true;
8190
- }
8191
- });
8038
+ // Attach the outside-click closer ONCE \u2014 renderWsSwitcher runs on every
8039
+ // reload, so adding it each time leaked a listener per render. Re-fetch
8040
+ // the elements by id inside the handler so it never holds a stale closure.
8041
+ if (!wsOutsideClickBound) {
8042
+ wsOutsideClickBound = true;
8043
+ document.addEventListener('click', function (e) {
8044
+ var m = document.getElementById('ws-menu');
8045
+ var b = document.getElementById('ws-button');
8046
+ if (!m || m.hidden) return;
8047
+ if (!m.contains(e.target) && e.target !== b && (!b || !b.contains(e.target))) {
8048
+ m.hidden = true;
8049
+ }
8050
+ });
8051
+ }
8192
8052
  }
8193
8053
 
8194
8054
  // Inline "new workspace" name entry, shown inside the Workspaces menu.
@@ -8433,7 +8293,6 @@ var appJs = `
8433
8293
  entities: tables.map(function (t) {
8434
8294
  return { name: t.name, rowCount: t.rowCount, lastUpdatedAt: null, stale: false };
8435
8295
  }),
8436
- recent: [],
8437
8296
  };
8438
8297
  }
8439
8298
  function drawDashboard(content, d) {
@@ -8471,21 +8330,7 @@ var appJs = `
8471
8330
  fresh +
8472
8331
  '</a>';
8473
8332
  }).join('');
8474
- var recent = '';
8475
- if (d.recent && d.recent.length) {
8476
- var items = d.recent.map(function (r) {
8477
- var ic = FEED_ICONS[r.op] || '\u2022';
8478
- var label = displayFor(r.table).label;
8479
- return '<li class="dash-act">' +
8480
- '<span class="dash-act-ic">' + ic + '</span>' +
8481
- '<span class="dash-act-txt">' + escapeHtml(String(r.op)) + ' \xB7 ' + escapeHtml(label) + '</span>' +
8482
- '<span class="dash-act-time">' + relTime(r.ts) + '</span>' +
8483
- '</li>';
8484
- }).join('');
8485
- recent = '<div class="dash-recent"><div class="dash-recent-head">Recent activity</div>' +
8486
- '<ul>' + items + '</ul></div>';
8487
- }
8488
- content.innerHTML = stats + '<div class="dashboard">' + cards + '</div>' + recent;
8333
+ content.innerHTML = stats + '<div class="dashboard">' + cards + '</div>';
8489
8334
  }
8490
8335
  function renderDashboard(content) {
8491
8336
  // Workspace overview: counts + freshness + recent activity from
@@ -9365,7 +9210,16 @@ var appJs = `
9365
9210
  return rowsP.then(function (rows) {
9366
9211
  var d = displayFor(table);
9367
9212
  var base = fsHref(segs);
9368
- var tiles = rows.length
9213
+ // "New" tile (top-level collections only) \u2014 a folder box with a + that
9214
+ // opens a create form. Related-row folders aren't a place to mint a
9215
+ // brand-new object, so the tile is top-level only.
9216
+ var createTile = topLevel
9217
+ ? '<a class="fs-tile fs-tile-create" href="#" data-fs-create="1" title="Create a new ' + escapeHtml(d.label) + '">' +
9218
+ '<div class="fs-tile-icon">\u2795</div>' +
9219
+ '<div class="fs-tile-label">New ' + escapeHtml(d.label) + '</div>' +
9220
+ '</a>'
9221
+ : '';
9222
+ var rowTiles = rows.length
9369
9223
  ? rows.map(function (r) {
9370
9224
  var icon = (table === 'files') ? fileEmoji(r) : '\u{1F4C1}';
9371
9225
  return '<a class="fs-tile" href="' + base + '/' + encodeURIComponent(r.id) + '">' +
@@ -9373,7 +9227,7 @@ var appJs = `
9373
9227
  '<div class="fs-tile-label">' + escapeHtml(fsDisplayName(r)) + '</div>' +
9374
9228
  '</a>';
9375
9229
  }).join('')
9376
- : '<div class="fs-empty">Nothing here yet.</div>';
9230
+ : (topLevel ? '' : '<div class="fs-empty">Nothing here yet.</div>');
9377
9231
  content.innerHTML =
9378
9232
  fsBreadcrumb(segs, crumbs) +
9379
9233
  '<div class="view-header">' +
@@ -9381,13 +9235,111 @@ var appJs = `
9381
9235
  '<h1>' + escapeHtml(d.label) + '</h1>' +
9382
9236
  '<span class="count">' + rows.length + ' item' + (rows.length === 1 ? '' : 's') + '</span>' +
9383
9237
  '</div>' +
9384
- '<div class="fs-grid">' + tiles + '</div>';
9238
+ '<div class="fs-grid">' + createTile + rowTiles + '</div>';
9239
+ var ctile = content.querySelector('[data-fs-create]');
9240
+ if (ctile) ctile.addEventListener('click', function (e) {
9241
+ e.preventDefault();
9242
+ openFsCreateModal(content, table, segs);
9243
+ });
9385
9244
  });
9386
9245
  }).catch(function (err) {
9387
9246
  content.innerHTML = '<div class="placeholder"><h2>Failed</h2>' + escapeHtml(err.message) + '</div>';
9388
9247
  });
9389
9248
  }
9390
9249
 
9250
+ // Create a new object from the simple view \u2014 a form styled like the item
9251
+ // page with blank fields + a Save button, plus a select-menu + "+" for each
9252
+ // many-to-many link. Reuses fieldFor() (intrinsic + belongsTo) and the
9253
+ // existing row-create + junction-row endpoints (no new backend).
9254
+ function openFsCreateModal(content, table, segs) {
9255
+ var t = tableByName(table);
9256
+ if (!t) return;
9257
+ var bt = belongsToColumns(t);
9258
+ var juncs = junctionsFor(table);
9259
+ // Preload FK + junction-remote target rows so the <select> menus populate.
9260
+ var needed = bt.map(function (b) { return b.rel.table; })
9261
+ .concat(juncs.map(function (j) { return j.remoteRel.table; }));
9262
+ Promise.all(needed.map(loadAllRows)).then(function () {
9263
+ var fieldsHtml = '';
9264
+ intrinsicColumns(t).forEach(function (c) {
9265
+ fieldsHtml += '<div class="fs-field"><div class="fs-field-label">' + escapeHtml(titleCase(c)) + '</div>' +
9266
+ '<div class="fs-field-val">' + fieldFor(c, '', t) + '</div></div>';
9267
+ });
9268
+ bt.forEach(function (b) {
9269
+ fieldsHtml += '<div class="fs-field"><div class="fs-field-label">' + escapeHtml(titleCase(b.relName)) + '</div>' +
9270
+ '<div class="fs-field-val">' + fieldFor(b.rel.foreignKey, '', t) + '</div></div>';
9271
+ });
9272
+ juncs.forEach(function (j) {
9273
+ var remoteRows = loadedTables[j.remoteRel.table] || [];
9274
+ var opts = '<option value="">(none)</option>' + remoteRows.map(function (r) {
9275
+ return '<option value="' + escapeHtml(r.id) + '">' + escapeHtml(displayNameFor(r)) + '</option>';
9276
+ }).join('');
9277
+ fieldsHtml += '<div class="fs-field"><div class="fs-field-label">' + escapeHtml(titleCase(j.remoteRel.table)) + ' (links)</div>' +
9278
+ '<div class="fs-field-val">' +
9279
+ '<div class="fs-link-stage" data-junction="' + escapeHtml(j.junction) + '" data-local-fk="' + escapeHtml(j.localFk) + '" data-remote-fk="' + escapeHtml(j.remoteRel.foreignKey) + '">' +
9280
+ '<select class="fs-link-select">' + opts + '</select>' +
9281
+ '</div>' +
9282
+ '<button type="button" class="btn fs-link-add">+ Add another</button>' +
9283
+ '</div></div>';
9284
+ });
9285
+ showModal('New ' + displayFor(table).label, '<div class="fs-doc fs-create-form">' + fieldsHtml + '</div>', {
9286
+ primaryLabel: 'Save',
9287
+ onBody: function (backdrop) {
9288
+ backdrop.querySelectorAll('.fs-link-add').forEach(function (addBtn) {
9289
+ addBtn.addEventListener('click', function () {
9290
+ var stage = addBtn.previousElementSibling; // the .fs-link-stage
9291
+ var firstSel = stage && stage.querySelector('.fs-link-select');
9292
+ if (!firstSel) return;
9293
+ var clone = firstSel.cloneNode(true);
9294
+ clone.value = '';
9295
+ stage.appendChild(clone);
9296
+ });
9297
+ });
9298
+ },
9299
+ onSubmit: function (scope) {
9300
+ // Intrinsic + belongsTo values (the [name] inputs/selects).
9301
+ var values = {};
9302
+ scope.querySelectorAll('.fs-create-form [name]').forEach(function (el) {
9303
+ var v = el.value;
9304
+ if (v !== '' && v != null) values[el.getAttribute('name')] = v;
9305
+ });
9306
+ // Staged many-to-many links \u2014 one junction row per chosen target.
9307
+ var links = [];
9308
+ scope.querySelectorAll('.fs-link-stage').forEach(function (stage) {
9309
+ var junction = stage.getAttribute('data-junction');
9310
+ var localFk = stage.getAttribute('data-local-fk');
9311
+ var remoteFk = stage.getAttribute('data-remote-fk');
9312
+ stage.querySelectorAll('.fs-link-select').forEach(function (sel) {
9313
+ if (sel.value) links.push({ junction: junction, localFk: localFk, remoteFk: remoteFk, remoteId: sel.value });
9314
+ });
9315
+ });
9316
+ return rowWrite('POST', '/api/tables/' + encodeURIComponent(table) + '/rows', values).then(function (res) {
9317
+ var newId = res && (res.id || (res.row && res.row.id));
9318
+ var chain = Promise.resolve();
9319
+ links.forEach(function (lk) {
9320
+ chain = chain.then(function () {
9321
+ // Use the junction's /link endpoint (INSERT OR IGNORE on the
9322
+ // two FK columns) \u2014 works for junctions with no own pk and is
9323
+ // idempotent, unlike a raw row insert.
9324
+ var jrow = {};
9325
+ jrow[lk.localFk] = newId;
9326
+ jrow[lk.remoteFk] = lk.remoteId;
9327
+ return rowWrite('POST', '/api/tables/' + encodeURIComponent(lk.junction) + '/link', jrow);
9328
+ });
9329
+ });
9330
+ return chain;
9331
+ }).then(function () {
9332
+ invalidate(table);
9333
+ return refreshEntities();
9334
+ }).then(function () {
9335
+ showToast('Created', {});
9336
+ renderFsCollection(content, segs);
9337
+ });
9338
+ },
9339
+ });
9340
+ }).catch(function (err) { showToast('Create failed: ' + err.message, {}); });
9341
+ }
9342
+
9391
9343
  // Item view \u2014 one row as a document (click-to-edit) + its relationship folders.
9392
9344
  function renderFsItem(content, segs) {
9393
9345
  fsWalk(segs).then(function (crumbs) {
@@ -9732,8 +9684,11 @@ var appJs = `
9732
9684
  if (!s) return '';
9733
9685
  try {
9734
9686
  var d = new Date(s);
9687
+ // Never render the literal "Invalid Date" \u2014 new Date() returns an
9688
+ // Invalid Date (not a throw) for an unparseable value.
9689
+ if (isNaN(d.getTime())) return '(no timestamp)';
9735
9690
  return d.toLocaleString();
9736
- } catch (_e) { return s; }
9691
+ } catch (_e) { return '(no timestamp)'; }
9737
9692
  }
9738
9693
 
9739
9694
  /** Side-by-side-ish text diff. Shows changed columns only for updates. */
@@ -10288,7 +10243,9 @@ var appJs = `
10288
10243
  var linkRows = dmLinks.map(function (lk, i) {
10289
10244
  return '<div class="dm-link-row">' +
10290
10245
  '<span class="dm-link-name">' + escapeHtml(displayFor(lk.other).label) + '</span>' +
10291
- '<span class="dm-link-arrow">\u2194 many-to-many</span>' +
10246
+ '<span class="dm-link-arrow' + (lk.kind === 'fk' ? ' legacy' : '') + '" ' +
10247
+ (lk.kind === 'fk' ? 'title="Legacy one-to-many link. New links are many-to-many; this is kept for back-compat and will be migrated in 2.0."' : '') +
10248
+ '>' + (lk.kind === 'fk' ? '\u2192 one-to-many (legacy)' : '\u2194 many-to-many') + '</span>' +
10292
10249
  '<button class="btn danger dm-link-destroy" data-link="' + i +
10293
10250
  '" title="Delete this link \u2014 removes it from both tables">Delete link</button>' +
10294
10251
  '</div>';
@@ -11230,12 +11187,12 @@ var appJs = `
11230
11187
  fetchJson('/api/userconfig/databases').then(function (cat) {
11231
11188
  var localRows = (cat.local || []).map(function (d) {
11232
11189
  var stateBadge = '<span style="font-family:JetBrains Mono,monospace;font-size:10px;color:var(--text-muted)">' + escapeHtml((d.state || 'local').toUpperCase()) + '</span>';
11233
- return '<tr>' +
11190
+ return '<tr' + (d.active ? '' : ' class="db-row" data-switch-path="' + escapeHtml(d.configPath) + '"') + '>' +
11234
11191
  '<td>' + escapeHtml(d.label) + (d.active ? ' <span class="role-tag">active</span>' : '') + '</td>' +
11235
11192
  '<td>SQLite</td>' +
11236
11193
  '<td>' + stateBadge + '</td>' +
11237
11194
  '<td><code>' + escapeHtml(d.dbFile) + '</code></td>' +
11238
- '<td>' + (d.active ? '\u2014' : '<button class="btn" data-switch="' + escapeHtml(d.configPath) + '">Switch</button>') + '</td>' +
11195
+ '<td>\u2014</td>' +
11239
11196
  '</tr>';
11240
11197
  }).join('');
11241
11198
  var cloudRows = (cat.cloud || []).map(function (d) {
@@ -11253,9 +11210,9 @@ var appJs = `
11253
11210
  '<tbody>' + (localRows + cloudRows || '<tr><td colspan="5" style="padding:8px;color:var(--text-muted)">No databases configured.</td></tr>') + '</tbody>' +
11254
11211
  '</table>' +
11255
11212
  '</div>';
11256
- host.querySelectorAll('[data-switch]').forEach(function (btn) {
11257
- btn.addEventListener('click', function () {
11258
- var configPath = btn.getAttribute('data-switch');
11213
+ host.querySelectorAll('tr.db-row[data-switch-path]').forEach(function (row) {
11214
+ row.addEventListener('click', function () {
11215
+ var configPath = row.getAttribute('data-switch-path');
11259
11216
  fetch('/api/databases/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: configPath }) })
11260
11217
  .then(function (r) { return r.json(); })
11261
11218
  .then(function () { renderUserConfig(document.getElementById('content')); });
@@ -11356,11 +11313,74 @@ var appJs = `
11356
11313
 
11357
11314
  function renderDatabaseDangerZone(host) {
11358
11315
  if (!host) return;
11359
- fetchJson('/api/databases').then(function (data) {
11316
+ Promise.all([
11317
+ fetchJson('/api/databases'),
11318
+ fetchJson('/api/dbconfig').catch(function () { return {}; }),
11319
+ ]).then(function (results) {
11320
+ var data = results[0];
11321
+ var cfg = results[1] || {};
11360
11322
  var current = (data && data.current) || {};
11361
11323
  var label = current.label || current.dbFile || '';
11362
11324
  var path = current.path || '';
11363
11325
  if (!path) { host.innerHTML = ''; return; }
11326
+
11327
+ // After tearing down / leaving the active DB, switch to another the
11328
+ // operator still has and navigate off the (now-gone) page.
11329
+ var switchAway = function () {
11330
+ var cur = (data && data.current && data.current.path) || null;
11331
+ var target = ((data && data.configs) || []).filter(function (c) { return c.path !== cur; })[0];
11332
+ var p = target
11333
+ ? fetchJson('/api/databases/switch', {
11334
+ method: 'POST', headers: { 'content-type': 'application/json' },
11335
+ body: JSON.stringify({ path: target.path }),
11336
+ }).then(function () { return reloadEverything(); })
11337
+ : reloadEverything();
11338
+ return p.then(function () { location.hash = '#/'; renderRoute(); });
11339
+ };
11340
+
11341
+ if (cfg.state === 'team-cloud-creator') {
11342
+ // Owner: disconnect the database from the cloud \u2014 kicks all members.
11343
+ host.innerHTML =
11344
+ '<div class="danger-zone">' +
11345
+ '<h3>Danger zone</h3>' +
11346
+ '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
11347
+ 'Disconnect this database from the cloud. This removes the team and <strong>kicks all members</strong>. This cannot be undone.' +
11348
+ '</p>' +
11349
+ '<button class="btn destructive" id="db-disconnect-btn">Disconnect from cloud</button>' +
11350
+ '</div>';
11351
+ host.querySelector('#db-disconnect-btn').addEventListener('click', function () {
11352
+ if (!confirm('Disconnect "' + (cfg.teamName || label || 'this database') + '" from the cloud? This kicks all members and cannot be undone.')) return;
11353
+ var dbtn = host.querySelector('#db-disconnect-btn');
11354
+ withBusy(dbtn, function () {
11355
+ return fetchJson('/api/teams-gui/teams/' + cfg.teamId, { method: 'DELETE' })
11356
+ .then(function () { showToast('Disconnected from cloud', {}); return switchAway(); })
11357
+ .catch(function (e) { alert('Disconnect failed: ' + e.message); });
11358
+ });
11359
+ });
11360
+ return;
11361
+ }
11362
+ if (cfg.state === 'team-cloud-member') {
11363
+ // Member: leave the team. The cloud DB keeps running for others.
11364
+ host.innerHTML =
11365
+ '<div class="danger-zone">' +
11366
+ '<h3>Danger zone</h3>' +
11367
+ '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
11368
+ 'Leave this team. The cloud database keeps running for everyone else; you simply stop being a member.' +
11369
+ '</p>' +
11370
+ '<button class="btn destructive" id="db-leave-btn">Leave team</button>' +
11371
+ '</div>';
11372
+ host.querySelector('#db-leave-btn').addEventListener('click', function () {
11373
+ if (!confirm('Leave "' + (cfg.teamName || label || 'this team') + '"?')) return;
11374
+ var lbtn = host.querySelector('#db-leave-btn');
11375
+ withBusy(lbtn, function () {
11376
+ return fetchJson('/api/teams-gui/teams/' + cfg.teamId + '/members/' + encodeURIComponent(cfg.myUserId), { method: 'DELETE' })
11377
+ .then(function () { showToast('Left the team', {}); return switchAway(); })
11378
+ .catch(function (e) { alert('Leave failed: ' + e.message); });
11379
+ });
11380
+ });
11381
+ return;
11382
+ }
11383
+ // Local / non-team cloud database: delete it.
11364
11384
  host.innerHTML =
11365
11385
  '<div class="danger-zone">' +
11366
11386
  '<h3>Danger zone</h3>' +
@@ -11461,12 +11481,11 @@ var appJs = `
11461
11481
  : '\u2014';
11462
11482
  var rowLabel = c.label || c.name;
11463
11483
  var del = '<button class="btn danger" data-delete-path="' + escapeHtml(c.path) + '" data-delete-label="' + escapeHtml(rowLabel) + '">Delete</button>';
11464
- var actions = (c.active ? '' : '<button class="btn" data-switch="' + escapeHtml(c.path) + '">Switch</button> ') + del;
11465
- return '<tr>' +
11484
+ return '<tr' + (c.active ? '' : ' class="ws-row" data-switch-path="' + escapeHtml(c.path) + '"') + '>' +
11466
11485
  '<td>' + escapeHtml(rowLabel) + (c.active ? ' <span class="role-tag">active</span>' : '') + '</td>' +
11467
11486
  '<td>' + kind + '</td>' +
11468
11487
  '<td><code>' + escapeHtml(c.dbFile || '') + '</code></td>' +
11469
- '<td>' + actions + '</td>' +
11488
+ '<td>' + del + '</td>' +
11470
11489
  '</tr>';
11471
11490
  }).join('');
11472
11491
  host.innerHTML =
@@ -11480,9 +11499,9 @@ var appJs = `
11480
11499
  '<tbody>' + (rows || '<tr><td colspan="4" style="padding:8px;color:var(--text-muted)">No workspaces configured.</td></tr>') + '</tbody>' +
11481
11500
  '</table>' +
11482
11501
  '</div>';
11483
- host.querySelectorAll('[data-switch]').forEach(function (btn) {
11484
- btn.addEventListener('click', function () {
11485
- var configPath = btn.getAttribute('data-switch');
11502
+ host.querySelectorAll('tr.ws-row[data-switch-path]').forEach(function (row) {
11503
+ row.addEventListener('click', function () {
11504
+ var configPath = row.getAttribute('data-switch-path');
11486
11505
  fetch('/api/databases/switch', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path: configPath }) })
11487
11506
  .then(function (r) { return r.json(); })
11488
11507
  .then(function () { return reloadEverything(); })
@@ -11490,7 +11509,8 @@ var appJs = `
11490
11509
  });
11491
11510
  });
11492
11511
  host.querySelectorAll('[data-delete-path]').forEach(function (btn) {
11493
- btn.addEventListener('click', function () {
11512
+ btn.addEventListener('click', function (e) {
11513
+ e.stopPropagation(); // don't trigger the row's switch handler
11494
11514
  confirmDeleteDatabase(
11495
11515
  btn.getAttribute('data-delete-path'),
11496
11516
  btn.getAttribute('data-delete-label'),
@@ -11569,11 +11589,11 @@ var appJs = `
11569
11589
  return (
11570
11590
  '<p style="margin:0 0 12px;color:var(--text-muted);font-size:13px">' +
11571
11591
  'SQLite DB: <code>' + escapeHtml(info.dbFile || '(unknown)') + '</code>. ' +
11572
- 'Move forward by either pushing this data to a new cloud Postgres or connecting to an existing one.' +
11592
+ 'Push this workspace to a cloud Postgres to collaborate. ' +
11593
+ '(To join an existing cloud, create a new workspace and choose \u201Cjoin via cloud invite\u201D.)' +
11573
11594
  '</p>' +
11574
11595
  '<div class="team-actions">' +
11575
11596
  '<button class="btn primary" data-act="open-migrate">Migrate to cloud \u2192</button>' +
11576
- '<button class="btn" data-act="open-connect-existing">Connect to existing cloud \u2192</button>' +
11577
11597
  '</div>'
11578
11598
  );
11579
11599
  }
@@ -11594,10 +11614,10 @@ var appJs = `
11594
11614
  (isCreator ? ' \xB7 <span style="color:var(--accent)">you are the creator</span>' : ' \xB7 <span style="color:var(--text-muted)">member</span>') +
11595
11615
  '</div>' +
11596
11616
  '<div class="team-actions" style="margin-top:10px">' +
11597
- (isCreator ? '<button class="btn primary" data-act="open-invite">Generate invite token</button>' : '') +
11617
+ (isCreator ? '<button class="btn primary" data-act="open-invite">Invite member</button>' : '') +
11598
11618
  '</div>' +
11599
- // Leave (member) / Destroy (creator) now live on your own row in
11600
- // the members list below \u2014 no separate top-level button.
11619
+ // Exit actions (Disconnect for the owner / Leave for a member) live
11620
+ // in the Danger Zone below \u2014 not on a member row.
11601
11621
  '<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
11602
11622
  );
11603
11623
  }
@@ -11642,11 +11662,6 @@ var appJs = `
11642
11662
  showMigrateToCloudModal(rerender);
11643
11663
  });
11644
11664
 
11645
- var connectExBtn = host.querySelector('[data-act="open-connect-existing"]');
11646
- if (connectExBtn) connectExBtn.addEventListener('click', function () {
11647
- showConnectExistingModal(rerender);
11648
- });
11649
-
11650
11665
  var upgradeBtn = host.querySelector('[data-act="open-upgrade"]');
11651
11666
  if (upgradeBtn) upgradeBtn.addEventListener('click', function () {
11652
11667
  showUpgradeToTeamModal(rerender);
@@ -11661,27 +11676,10 @@ var appJs = `
11661
11676
  var myUserId = info.myUserId;
11662
11677
  var isCreator = !!info.isCreator;
11663
11678
 
11664
- // After leaving/destroying, the left DB is torn down on the backend
11665
- // (config + credential removed). Switch to another database the
11666
- // operator still has and navigate off the (now-gone) DB page.
11667
- var switchAway = function () {
11668
- return fetchJson('/api/databases').then(function (data) {
11669
- var current = (data && data.current && data.current.path) || null;
11670
- var configs = (data && data.configs) || [];
11671
- var target = configs.filter(function (c) { return c.path !== current; })[0];
11672
- if (!target) return reloadEverything();
11673
- return fetchJson('/api/databases/switch', {
11674
- method: 'POST',
11675
- headers: { 'content-type': 'application/json' },
11676
- body: JSON.stringify({ path: target.path }),
11677
- }).then(function () { return reloadEverything(); });
11678
- }).then(function () { location.hash = '#/'; renderRoute(); });
11679
- };
11680
-
11681
11679
  var inviteBtn = host.querySelector('[data-act="open-invite"]');
11682
11680
  if (inviteBtn) inviteBtn.addEventListener('click', function () {
11683
11681
  if (!teamId) { alert('No team is active.'); return; }
11684
- showInviteByEmailModal(teamId);
11682
+ showInviteByEmailModal(teamId, info);
11685
11683
  });
11686
11684
 
11687
11685
  // Inline member list for the active team cloud. Marks "you"; your
@@ -11705,26 +11703,6 @@ var appJs = `
11705
11703
  });
11706
11704
  });
11707
11705
  });
11708
- // Leave (member) / Destroy team (creator) \u2014 your own row.
11709
- var selfBtn = membersHost.querySelector('[data-act="leave-self"]');
11710
- if (selfBtn) selfBtn.addEventListener('click', function () {
11711
-
11712
- if (isCreator) {
11713
- if (!confirm('Destroy team "' + (info.teamName || 'this team') + '"? This soft-deletes it on the cloud for everyone.')) return;
11714
- withBusy(selfBtn, function () {
11715
- return fetchJson('/api/teams-gui/teams/' + teamId, { method: 'DELETE' })
11716
- .then(function () { showToast('Team destroyed', {}); return switchAway(); })
11717
- .catch(function (e) { setMsg('Destroy failed: ' + e.message, false); });
11718
- });
11719
- } else {
11720
- if (!confirm('Leave team "' + (info.teamName || 'this team') + '"?')) return;
11721
- withBusy(selfBtn, function () {
11722
- return fetchJson('/api/teams-gui/teams/' + teamId + '/members/' + encodeURIComponent(myUserId), { method: 'DELETE' })
11723
- .then(function () { showToast('Left the team', {}); return switchAway(); })
11724
- .catch(function (e) { setMsg('Leave failed: ' + e.message, false); });
11725
- });
11726
- }
11727
- });
11728
11706
  }).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
11729
11707
  }
11730
11708
 
@@ -12070,13 +12048,11 @@ var appJs = `
12070
12048
  var rows = members.map(function (m) {
12071
12049
  var label = m.name || m.email || '(unknown)';
12072
12050
  var isSelf = m.user_id === myUserId;
12073
- // Your own row: Leave (member) or Destroy team (creator). Other
12074
- // rows: Kick, but only the creator may remove other members.
12051
+ // Other rows: Kick, but only the creator may remove other members.
12052
+ // Your own exit (Disconnect for the owner / Leave for a member) lives
12053
+ // in the Danger Zone, not on a member row.
12075
12054
  var btn = '';
12076
- if (isSelf) {
12077
- btn = '<button class="btn danger-btn" data-act="leave-self">' +
12078
- (isCreator ? 'Destroy team' : 'Leave') + '</button>';
12079
- } else if (isCreator) {
12055
+ if (!isSelf && isCreator) {
12080
12056
  btn = '<button class="btn danger-btn" data-act="kick">Kick</button>';
12081
12057
  }
12082
12058
  return '<div class="member-row" data-user-id="' + escapeHtml(m.user_id) + '">' +
@@ -12091,7 +12067,7 @@ var appJs = `
12091
12067
  return '<div class="members-list"><h4>Members</h4>' + rows + '</div>';
12092
12068
  }
12093
12069
 
12094
- function showInviteByEmailModal(teamId) {
12070
+ function showInviteByEmailModal(teamId, info) {
12095
12071
  var bodyHtml =
12096
12072
  '<div class="field"><label>Invitee email</label>' +
12097
12073
  '<input name="invitee_email" type="email" placeholder="bob@example.com" /></div>' +
@@ -12107,17 +12083,30 @@ var appJs = `
12107
12083
  method: 'POST',
12108
12084
  headers: { 'content-type': 'application/json' },
12109
12085
  body: JSON.stringify({ invitee_email: data.invitee_email }),
12110
- }).then(function (inv) { showInviteTokenModal(inv); });
12086
+ }).then(function (inv) { showInviteTokenModal(inv, info); });
12111
12087
  },
12112
12088
  });
12113
12089
  }
12114
12090
 
12115
- function showInviteTokenModal(inv) {
12091
+ function showInviteTokenModal(inv, info) {
12092
+ info = info || {};
12093
+ // The invitee needs the cloud connection string AND the token. Show the
12094
+ // URL with the password MASKED \u2014 redeem never needs the owner's password
12095
+ // (the invitee authenticates with their own credentials).
12096
+ var connStr = info.host
12097
+ ? 'postgres://' + (info.user || 'user') + ':****@' + info.host + ':' + (info.port || 5432) + '/' + (info.dbname || '')
12098
+ : '';
12099
+ var connBlock = connStr
12100
+ ? '<h4 style="margin:14px 0 4px">Cloud connection</h4>' +
12101
+ '<div class="copy-token" id="copy-conn">' + escapeHtml(connStr) + '</div>' +
12102
+ '<p style="font-size:12px;color:var(--text-muted);margin:4px 0 0">Share this URL with the invitee (password masked). Click to copy.</p>'
12103
+ : '';
12116
12104
  var bodyHtml =
12117
12105
  '<p style="margin-top:0">Share this token with the invitee (one-time use). It expires at <code>' +
12118
12106
  escapeHtml(inv.expires_at || '(no expiry)') + '</code>.</p>' +
12119
12107
  '<div class="copy-token" id="copy-token">' + escapeHtml(inv.raw_token) + '</div>' +
12120
- '<p style="font-size:12px;color:var(--text-muted);margin-bottom:0">Click the token to copy.</p>';
12108
+ '<p style="font-size:12px;color:var(--text-muted);margin-bottom:0">Click the token to copy.</p>' +
12109
+ connBlock;
12121
12110
  var handle = showModal('Invitation token', bodyHtml, { primaryLabel: 'Done', onSubmit: function () {} });
12122
12111
  var tokenEl = document.getElementById('copy-token');
12123
12112
  if (tokenEl) {
@@ -12128,6 +12117,15 @@ var appJs = `
12128
12117
  });
12129
12118
  });
12130
12119
  }
12120
+ var connEl = document.getElementById('copy-conn');
12121
+ if (connEl) {
12122
+ connEl.addEventListener('click', function () {
12123
+ navigator.clipboard.writeText(connStr).then(function () {
12124
+ connEl.textContent = 'Copied!';
12125
+ setTimeout(function () { connEl.textContent = connStr; }, 1200);
12126
+ });
12127
+ });
12128
+ }
12131
12129
  // Suppress unused-var on handle
12132
12130
  void handle;
12133
12131
  }
@@ -12218,16 +12216,6 @@ var guiAppHtml = `<!doctype html>
12218
12216
  </div>
12219
12217
  </nav>
12220
12218
  <main id="content"></main>
12221
- <aside class="assistant-rail" id="assistant-rail">
12222
- <div class="rail-resize" id="rail-resize" role="separator" aria-orientation="vertical" title="Drag to resize"></div>
12223
- <div class="rail-handle" id="rail-handle" title="Expand / collapse"></div>
12224
- <div class="rail-header">
12225
- <span class="rail-title">Activity</span>
12226
- </div>
12227
- <div class="rail-feed" id="rail-feed">
12228
- <div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>
12229
- </div>
12230
- </aside>
12231
12219
  </div>
12232
12220
 
12233
12221
  <div class="drawer-backdrop" id="drawer-backdrop" hidden></div>
@@ -13303,17 +13291,22 @@ async function resolveTeamContext(db, teamsClient, cloudUrl, candidateTables) {
13303
13291
  const teamId = identity.team_id;
13304
13292
  const creatorUserId = identity.creator_email ? await resolveUserIdByEmail(db, identity.creator_email) : null;
13305
13293
  let myUserId = "";
13294
+ let myEmail = "";
13306
13295
  try {
13307
13296
  const me = await db.get("__lattice_user_identity", "singleton");
13308
- if (me?.email) myUserId = await resolveUserIdByEmail(db, me.email) ?? "";
13297
+ if (me?.email) {
13298
+ myEmail = me.email;
13299
+ myUserId = await resolveUserIdByEmail(db, me.email) ?? "";
13300
+ }
13309
13301
  } catch {
13310
13302
  myUserId = "";
13311
13303
  }
13304
+ let savedConn = null;
13312
13305
  if (!myUserId) {
13313
13306
  try {
13314
13307
  const conns = await teamsClient.listConnections();
13315
- const conn = conns.find((c) => c.cloud_url === cloudUrl) ?? conns.find((c) => c.team_id === teamId);
13316
- myUserId = conn?.my_user_id ?? "";
13308
+ savedConn = conns.find((c) => c.cloud_url === cloudUrl) ?? conns.find((c) => c.team_id === teamId) ?? null;
13309
+ myUserId = savedConn?.my_user_id ?? "";
13317
13310
  } catch {
13318
13311
  }
13319
13312
  }
@@ -13332,6 +13325,26 @@ async function resolveTeamContext(db, teamsClient, cloudUrl, candidateTables) {
13332
13325
  isMember = false;
13333
13326
  }
13334
13327
  }
13328
+ if (!isMember && (myEmail || savedConn)) {
13329
+ try {
13330
+ const memberRows = await db.query("__lattice_team_members", {
13331
+ filters: [
13332
+ { col: "team_id", op: "eq", val: teamId },
13333
+ { col: "deleted_at", op: "isNull" }
13334
+ ]
13335
+ });
13336
+ if (memberRows.length > 0) {
13337
+ const matchId = myEmail ? await resolveUserIdByEmail(db, myEmail) : null;
13338
+ if (matchId && memberRows.some((m) => m.user_id === matchId)) {
13339
+ myUserId = matchId;
13340
+ isMember = true;
13341
+ } else if (savedConn) {
13342
+ isMember = true;
13343
+ }
13344
+ }
13345
+ } catch {
13346
+ }
13347
+ }
13335
13348
  if (creatorUserId) {
13336
13349
  await reconcileObjectOwners(db, teamId, creatorUserId, candidateTables);
13337
13350
  }
@@ -13602,6 +13615,11 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
13602
13615
  for (const r of undone) await db.delete("_lattice_gui_audit", r.id);
13603
13616
  await db.insert("_lattice_gui_audit", {
13604
13617
  id: crypto.randomUUID(),
13618
+ // Set ts explicitly (don't rely on the column DEFAULT — it uses the
13619
+ // SQLite-only `strftime(...)`, which doesn't yield a parseable ISO string
13620
+ // on Postgres, so cloud history rendered "Invalid Date"). Mirrors the
13621
+ // explicit `client_ts` below; adapter-agnostic.
13622
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
13605
13623
  table_name: table,
13606
13624
  row_id: rowId,
13607
13625
  operation: op,
@@ -13623,6 +13641,9 @@ async function recordSchemaAudit(db, feed, table, operation, before, after, summ
13623
13641
  for (const r of undone) await db.delete("_lattice_gui_audit", r.id);
13624
13642
  await db.insert("_lattice_gui_audit", {
13625
13643
  id: crypto.randomUUID(),
13644
+ // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
13645
+ // rendered "Invalid Date" on the Postgres/cloud path).
13646
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
13626
13647
  table_name: table,
13627
13648
  row_id: null,
13628
13649
  operation,
@@ -13653,10 +13674,32 @@ async function createRow(ctx, table, values) {
13653
13674
  await emitTeamEnvelope(ctx, table, id, "upsert", row);
13654
13675
  return { id, row };
13655
13676
  }
13677
+ function storedValueMatches(stored, requested) {
13678
+ if (stored === requested) return true;
13679
+ const storedEmpty = stored === null || stored === void 0 || stored === "";
13680
+ const reqEmpty = requested === null || requested === void 0 || requested === "";
13681
+ if (storedEmpty && reqEmpty) return true;
13682
+ if (typeof requested === "boolean") return Number(stored) === Number(requested);
13683
+ if (typeof requested === "number") return Number(stored) === requested;
13684
+ return String(stored) === String(requested);
13685
+ }
13686
+ function rowsEqual(a, b) {
13687
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
13688
+ for (const k of keys) if (a[k] !== b[k]) return false;
13689
+ return true;
13690
+ }
13656
13691
  async function updateRow(ctx, table, id, values) {
13657
13692
  const before = await ctx.db.get(table, id);
13658
13693
  await ctx.db.update(table, id, values);
13659
13694
  const after = await ctx.db.get(table, id);
13695
+ if (before != null && after != null) {
13696
+ const wantedChange = Object.keys(values).some(
13697
+ (k) => !storedValueMatches(before[k], values[k])
13698
+ );
13699
+ if (wantedChange && rowsEqual(before, after)) {
13700
+ throw new Error("Row update did not persist \u2014 the data source may be read-only");
13701
+ }
13702
+ }
13660
13703
  await appendAudit(
13661
13704
  ctx.db,
13662
13705
  ctx.feed,
@@ -17132,18 +17175,11 @@ async function dashboardPayload(db, configPath, outputDir, teamContext) {
17132
17175
  if (stale) staleCount += 1;
17133
17176
  return { ...t, lastUpdatedAt, stale };
17134
17177
  });
17135
- let recent = [];
17136
- try {
17137
- const raw = await db.query("_lattice_gui_audit", { limit: 15 });
17138
- recent = raw.map(parseAudit).sort((a, b) => b.ts.localeCompare(a.ts)).slice(0, 15).map((e) => ({ table: e.table_name, op: e.operation, rowId: e.row_id, ts: e.ts }));
17139
- } catch {
17140
- }
17141
17178
  return {
17142
17179
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
17143
17180
  staleDays: DASHBOARD_STALE_DAYS,
17144
17181
  totals: { entities: entities.length, rows: totalRows, stale: staleCount },
17145
- entities,
17146
- recent
17182
+ entities
17147
17183
  };
17148
17184
  }
17149
17185
  var ROWS_PATH = /^\/api\/tables\/([^/]+)\/rows(?:\/(.+))?$/;
@@ -18194,6 +18230,14 @@ data: ${JSON.stringify(data)}
18194
18230
  sendJson(res, { error: `Unknown entity: ${oldName}` }, 400);
18195
18231
  return;
18196
18232
  }
18233
+ if (isNativeEntity(oldName)) {
18234
+ sendJson(
18235
+ res,
18236
+ { error: `"${oldName}" is a built-in entity and cannot be modified` },
18237
+ 400
18238
+ );
18239
+ return;
18240
+ }
18197
18241
  if (!operatorOwnsTable(active.teamContext, oldName)) {
18198
18242
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18199
18243
  return;
@@ -18239,6 +18283,14 @@ data: ${JSON.stringify(data)}
18239
18283
  sendJson(res, { error: `Unknown entity: ${entityName}` }, 400);
18240
18284
  return;
18241
18285
  }
18286
+ if (isNativeEntity(entityName)) {
18287
+ sendJson(
18288
+ res,
18289
+ { error: `"${entityName}" is a built-in entity and cannot be modified` },
18290
+ 400
18291
+ );
18292
+ return;
18293
+ }
18242
18294
  if (!operatorOwnsTable(active.teamContext, entityName)) {
18243
18295
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18244
18296
  return;
@@ -18266,12 +18318,22 @@ data: ${JSON.stringify(data)}
18266
18318
  sendJson(res, { error: "Use \u201CAdd link\u201D to create a relationship column" }, 400);
18267
18319
  return;
18268
18320
  }
18321
+ const doc = loadConfigDoc(active.configPath);
18322
+ const fieldsNode = doc.getIn(["entities", entityName, "fields"]);
18323
+ if (!fieldsNode || typeof fieldsNode !== "object" || typeof fieldsNode.toJSON !== "function") {
18324
+ sendJson(res, { error: `Cannot add columns to "${entityName}"` }, 400);
18325
+ return;
18326
+ }
18327
+ const existingFields = fieldsNode.toJSON();
18328
+ if (colName in existingFields) {
18329
+ sendJson(res, { error: `Column "${colName}" already exists on ${entityName}` }, 400);
18330
+ return;
18331
+ }
18269
18332
  const sqliteType = fieldToSqliteBaseType(colType);
18270
18333
  await execSql(
18271
18334
  active.db,
18272
18335
  `ALTER TABLE "${entityName}" ADD COLUMN "${colName}" ${sqliteType}`
18273
18336
  );
18274
- const doc = loadConfigDoc(active.configPath);
18275
18337
  const fieldDef = { type: colType };
18276
18338
  if (body.required === true) fieldDef.required = true;
18277
18339
  doc.setIn(["entities", entityName, "fields", colName], fieldDef);
@@ -18298,6 +18360,14 @@ data: ${JSON.stringify(data)}
18298
18360
  sendJson(res, { error: `Unknown entity: ${entityName}` }, 400);
18299
18361
  return;
18300
18362
  }
18363
+ if (isNativeEntity(entityName)) {
18364
+ sendJson(
18365
+ res,
18366
+ { error: `"${entityName}" is a built-in entity and cannot be modified` },
18367
+ 400
18368
+ );
18369
+ return;
18370
+ }
18301
18371
  if (!operatorOwnsTable(active.teamContext, entityName)) {
18302
18372
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18303
18373
  return;
@@ -18320,14 +18390,30 @@ data: ${JSON.stringify(data)}
18320
18390
  sendJson(res, { error: `"${newCol}" is a reserved system column` }, 400);
18321
18391
  return;
18322
18392
  }
18393
+ const doc = loadConfigDoc(active.configPath);
18394
+ const fieldsNode = doc.getIn(["entities", entityName, "fields"]);
18395
+ if (!fieldsNode || typeof fieldsNode !== "object" || typeof fieldsNode.toJSON !== "function") {
18396
+ sendJson(res, { error: `Cannot rename columns on "${entityName}"` }, 400);
18397
+ return;
18398
+ }
18399
+ const fieldsObj = fieldsNode.toJSON();
18400
+ if (!(colName in fieldsObj)) {
18401
+ sendJson(res, { error: `Unknown column "${colName}" on ${entityName}` }, 400);
18402
+ return;
18403
+ }
18404
+ if (newCol in fieldsObj) {
18405
+ sendJson(res, { error: `Column "${newCol}" already exists on ${entityName}` }, 400);
18406
+ return;
18407
+ }
18323
18408
  await execSql(
18324
18409
  active.db,
18325
18410
  `ALTER TABLE "${entityName}" RENAME COLUMN "${colName}" TO "${newCol}"`
18326
18411
  );
18327
- const doc = loadConfigDoc(active.configPath);
18328
- const fieldDef = doc.getIn(["entities", entityName, "fields", colName]);
18329
- doc.deleteIn(["entities", entityName, "fields", colName]);
18330
- doc.setIn(["entities", entityName, "fields", newCol], fieldDef);
18412
+ const renamedFields = {};
18413
+ for (const k of Object.keys(fieldsObj)) {
18414
+ renamedFields[k === colName ? newCol : k] = fieldsObj[k];
18415
+ }
18416
+ doc.setIn(["entities", entityName, "fields"], renamedFields);
18331
18417
  saveConfigDoc(active.configPath, doc);
18332
18418
  await disposeActive(active);
18333
18419
  active = await openConfig(active.configPath, active.outputDir, autoRender);
@@ -18569,10 +18655,12 @@ data: ${JSON.stringify(data)}
18569
18655
  (e) => e.table_name === filterTable || junctionMatchesFilter.has(e.table_name)
18570
18656
  );
18571
18657
  }
18572
- const allEntries = raw.map(parseAudit);
18573
- const liveCount = allEntries.filter((e) => e.undone === 0).length;
18574
- const undoneCount = allEntries.length - liveCount;
18575
- sendJson(res, { entries, canUndo: liveCount > 0, canRedo: undoneCount > 0 });
18658
+ const sessionRows = await active.db.query("_lattice_gui_audit", {
18659
+ filters: [{ col: "session_id", op: "eq", val: sessionId }]
18660
+ });
18661
+ const sessionLive = sessionRows.filter((r) => Number(r.undone) === 0).length;
18662
+ const sessionUndone = sessionRows.length - sessionLive;
18663
+ sendJson(res, { entries, canUndo: sessionLive > 0, canRedo: sessionUndone > 0 });
18576
18664
  return;
18577
18665
  }
18578
18666
  if (method === "POST" && pathname === "/api/history/undo") {
package/dist/index.cjs CHANGED
@@ -3560,6 +3560,15 @@ function resolveDbPath(raw, configDir2) {
3560
3560
  }
3561
3561
  return (0, import_node_path11.resolve)(configDir2, raw);
3562
3562
  }
3563
+ var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
3564
+ function warnDeprecatedRef(entity, field, target) {
3565
+ const key = `${entity}.${field}`;
3566
+ if (warnedDeprecatedRefs.has(key)) return;
3567
+ warnedDeprecatedRefs.add(key);
3568
+ console.warn(
3569
+ `Lattice: one-to-many \`ref:\` on "${entity}.${field}" \u2192 "${target}" is deprecated in favor of many-to-many junction tables and will be removed in 2.0.`
3570
+ );
3571
+ }
3563
3572
  function entityToTableDef(entityName, entity) {
3564
3573
  const rawFields = entity.fields;
3565
3574
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -3582,6 +3591,7 @@ function entityToTableDef(entityName, entity) {
3582
3591
  table: field.ref,
3583
3592
  foreignKey: fieldName
3584
3593
  };
3594
+ warnDeprecatedRef(entityName, fieldName, field.ref);
3585
3595
  }
3586
3596
  }
3587
3597
  const primaryKey = entity.primaryKey ?? pkFromField;
package/dist/index.js CHANGED
@@ -3436,6 +3436,15 @@ function resolveDbPath(raw, configDir2) {
3436
3436
  }
3437
3437
  return resolve2(configDir2, raw);
3438
3438
  }
3439
+ var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
3440
+ function warnDeprecatedRef(entity, field, target) {
3441
+ const key = `${entity}.${field}`;
3442
+ if (warnedDeprecatedRefs.has(key)) return;
3443
+ warnedDeprecatedRefs.add(key);
3444
+ console.warn(
3445
+ `Lattice: one-to-many \`ref:\` on "${entity}.${field}" \u2192 "${target}" is deprecated in favor of many-to-many junction tables and will be removed in 2.0.`
3446
+ );
3447
+ }
3439
3448
  function entityToTableDef(entityName, entity) {
3440
3449
  const rawFields = entity.fields;
3441
3450
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -3458,6 +3467,7 @@ function entityToTableDef(entityName, entity) {
3458
3467
  table: field.ref,
3459
3468
  foreignKey: fieldName
3460
3469
  };
3470
+ warnDeprecatedRef(entityName, fieldName, field.ref);
3461
3471
  }
3462
3472
  }
3463
3473
  const primaryKey = entity.primaryKey ?? pkFromField;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",