latticesql 1.16.0 → 1.16.1

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 {
@@ -7412,9 +7357,6 @@ var appJs = `
7412
7357
  refreshHistoryState();
7413
7358
  renderRoute();
7414
7359
  startRealtime();
7415
- initRailResize();
7416
- initRailDrawer();
7417
- startFeed();
7418
7360
  initSearch();
7419
7361
  initLastEdited();
7420
7362
  initOffline();
@@ -7770,11 +7712,12 @@ var appJs = `
7770
7712
  }
7771
7713
 
7772
7714
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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.
7715
+ // Shared activity helpers \u2014 the operation-icon map and relative-time
7716
+ // formatter, used by Version History and the dashboard activity list. The
7717
+ // standalone Activity rail was removed in 1.16.1 (redundant with Version
7718
+ // History); multiplayer realtime convergence runs on the separate realtime
7719
+ // channel (startRealtime), not on this.
7776
7720
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
7721
  var FEED_ICONS = {
7779
7722
  insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
7780
7723
  link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
@@ -7790,51 +7733,6 @@ var appJs = `
7790
7733
  return new Date(iso).toLocaleDateString();
7791
7734
  } catch (_) { return ''; }
7792
7735
  }
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
7736
 
7839
7737
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\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
7738
  // Full-text search \u2014 GET /api/search, grouped dropdown, click to open.
@@ -7916,52 +7814,6 @@ var appJs = `
7916
7814
  });
7917
7815
  }
7918
7816
 
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
7817
  /** Reload column meta after a secret-flag change. */
7966
7818
  function refreshColumnMeta() {
7967
7819
  return fetchJson('/api/gui-meta/columns').then(function (d) {
@@ -8112,10 +7964,10 @@ var appJs = `
8112
7964
  else renderRoute();
8113
7965
  loadedTables = {};
8114
7966
  startRealtime();
8115
- startFeed();
8116
7967
  });
8117
7968
  }
8118
7969
 
7970
+ var wsOutsideClickBound = false;
8119
7971
  function renderWsSwitcher(data) {
8120
7972
  var wrap = document.getElementById('ws-switcher');
8121
7973
  var btn = document.getElementById('ws-button');
@@ -8173,8 +8025,13 @@ var appJs = `
8173
8025
  });
8174
8026
  });
8175
8027
  });
8176
- document.getElementById('ws-create-btn').addEventListener('click', function () {
8177
- showCreateWorkspaceInput(menu);
8028
+ document.getElementById('ws-create-btn').addEventListener('click', function (e) {
8029
+ // Stop propagation: showCreateWorkspaceInput replaces .db-create's
8030
+ // innerHTML, detaching THIS button. Without this, the click then
8031
+ // bubbles to the document outside-click closer, whose
8032
+ // menu.contains(e.target) is now false (target detached) \u2192 it would
8033
+ // close the menu, so the create input never appears.
8034
+ e.stopPropagation(); showCreateWorkspaceInput(menu);
8178
8035
  });
8179
8036
  }
8180
8037
 
@@ -8183,12 +8040,20 @@ var appJs = `
8183
8040
  if (menu.hidden) buildMenu();
8184
8041
  menu.hidden = !menu.hidden;
8185
8042
  };
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
- });
8043
+ // Attach the outside-click closer ONCE \u2014 renderWsSwitcher runs on every
8044
+ // reload, so adding it each time leaked a listener per render. Re-fetch
8045
+ // the elements by id inside the handler so it never holds a stale closure.
8046
+ if (!wsOutsideClickBound) {
8047
+ wsOutsideClickBound = true;
8048
+ document.addEventListener('click', function (e) {
8049
+ var m = document.getElementById('ws-menu');
8050
+ var b = document.getElementById('ws-button');
8051
+ if (!m || m.hidden) return;
8052
+ if (!m.contains(e.target) && e.target !== b && (!b || !b.contains(e.target))) {
8053
+ m.hidden = true;
8054
+ }
8055
+ });
8056
+ }
8192
8057
  }
8193
8058
 
8194
8059
  // Inline "new workspace" name entry, shown inside the Workspaces menu.
@@ -10288,7 +10153,9 @@ var appJs = `
10288
10153
  var linkRows = dmLinks.map(function (lk, i) {
10289
10154
  return '<div class="dm-link-row">' +
10290
10155
  '<span class="dm-link-name">' + escapeHtml(displayFor(lk.other).label) + '</span>' +
10291
- '<span class="dm-link-arrow">\u2194 many-to-many</span>' +
10156
+ '<span class="dm-link-arrow' + (lk.kind === 'fk' ? ' legacy' : '') + '" ' +
10157
+ (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."' : '') +
10158
+ '>' + (lk.kind === 'fk' ? '\u2192 one-to-many (legacy)' : '\u2194 many-to-many') + '</span>' +
10292
10159
  '<button class="btn danger dm-link-destroy" data-link="' + i +
10293
10160
  '" title="Delete this link \u2014 removes it from both tables">Delete link</button>' +
10294
10161
  '</div>';
@@ -11569,11 +11436,11 @@ var appJs = `
11569
11436
  return (
11570
11437
  '<p style="margin:0 0 12px;color:var(--text-muted);font-size:13px">' +
11571
11438
  '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.' +
11439
+ 'Push this workspace to a cloud Postgres to collaborate. ' +
11440
+ '(To join an existing cloud, create a new workspace and choose \u201Cjoin via cloud invite\u201D.)' +
11573
11441
  '</p>' +
11574
11442
  '<div class="team-actions">' +
11575
11443
  '<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
11444
  '</div>'
11578
11445
  );
11579
11446
  }
@@ -11642,11 +11509,6 @@ var appJs = `
11642
11509
  showMigrateToCloudModal(rerender);
11643
11510
  });
11644
11511
 
11645
- var connectExBtn = host.querySelector('[data-act="open-connect-existing"]');
11646
- if (connectExBtn) connectExBtn.addEventListener('click', function () {
11647
- showConnectExistingModal(rerender);
11648
- });
11649
-
11650
11512
  var upgradeBtn = host.querySelector('[data-act="open-upgrade"]');
11651
11513
  if (upgradeBtn) upgradeBtn.addEventListener('click', function () {
11652
11514
  showUpgradeToTeamModal(rerender);
@@ -12218,16 +12080,6 @@ var guiAppHtml = `<!doctype html>
12218
12080
  </div>
12219
12081
  </nav>
12220
12082
  <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
12083
  </div>
12232
12084
 
12233
12085
  <div class="drawer-backdrop" id="drawer-backdrop" hidden></div>
@@ -13653,10 +13505,32 @@ async function createRow(ctx, table, values) {
13653
13505
  await emitTeamEnvelope(ctx, table, id, "upsert", row);
13654
13506
  return { id, row };
13655
13507
  }
13508
+ function storedValueMatches(stored, requested) {
13509
+ if (stored === requested) return true;
13510
+ const storedEmpty = stored === null || stored === void 0 || stored === "";
13511
+ const reqEmpty = requested === null || requested === void 0 || requested === "";
13512
+ if (storedEmpty && reqEmpty) return true;
13513
+ if (typeof requested === "boolean") return Number(stored) === Number(requested);
13514
+ if (typeof requested === "number") return Number(stored) === requested;
13515
+ return String(stored) === String(requested);
13516
+ }
13517
+ function rowsEqual(a, b) {
13518
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
13519
+ for (const k of keys) if (a[k] !== b[k]) return false;
13520
+ return true;
13521
+ }
13656
13522
  async function updateRow(ctx, table, id, values) {
13657
13523
  const before = await ctx.db.get(table, id);
13658
13524
  await ctx.db.update(table, id, values);
13659
13525
  const after = await ctx.db.get(table, id);
13526
+ if (before != null && after != null) {
13527
+ const wantedChange = Object.keys(values).some(
13528
+ (k) => !storedValueMatches(before[k], values[k])
13529
+ );
13530
+ if (wantedChange && rowsEqual(before, after)) {
13531
+ throw new Error("Row update did not persist \u2014 the data source may be read-only");
13532
+ }
13533
+ }
13660
13534
  await appendAudit(
13661
13535
  ctx.db,
13662
13536
  ctx.feed,
@@ -18194,6 +18068,14 @@ data: ${JSON.stringify(data)}
18194
18068
  sendJson(res, { error: `Unknown entity: ${oldName}` }, 400);
18195
18069
  return;
18196
18070
  }
18071
+ if (isNativeEntity(oldName)) {
18072
+ sendJson(
18073
+ res,
18074
+ { error: `"${oldName}" is a built-in entity and cannot be modified` },
18075
+ 400
18076
+ );
18077
+ return;
18078
+ }
18197
18079
  if (!operatorOwnsTable(active.teamContext, oldName)) {
18198
18080
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18199
18081
  return;
@@ -18239,6 +18121,14 @@ data: ${JSON.stringify(data)}
18239
18121
  sendJson(res, { error: `Unknown entity: ${entityName}` }, 400);
18240
18122
  return;
18241
18123
  }
18124
+ if (isNativeEntity(entityName)) {
18125
+ sendJson(
18126
+ res,
18127
+ { error: `"${entityName}" is a built-in entity and cannot be modified` },
18128
+ 400
18129
+ );
18130
+ return;
18131
+ }
18242
18132
  if (!operatorOwnsTable(active.teamContext, entityName)) {
18243
18133
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18244
18134
  return;
@@ -18266,12 +18156,22 @@ data: ${JSON.stringify(data)}
18266
18156
  sendJson(res, { error: "Use \u201CAdd link\u201D to create a relationship column" }, 400);
18267
18157
  return;
18268
18158
  }
18159
+ const doc = loadConfigDoc(active.configPath);
18160
+ const fieldsNode = doc.getIn(["entities", entityName, "fields"]);
18161
+ if (!fieldsNode || typeof fieldsNode !== "object" || typeof fieldsNode.toJSON !== "function") {
18162
+ sendJson(res, { error: `Cannot add columns to "${entityName}"` }, 400);
18163
+ return;
18164
+ }
18165
+ const existingFields = fieldsNode.toJSON();
18166
+ if (colName in existingFields) {
18167
+ sendJson(res, { error: `Column "${colName}" already exists on ${entityName}` }, 400);
18168
+ return;
18169
+ }
18269
18170
  const sqliteType = fieldToSqliteBaseType(colType);
18270
18171
  await execSql(
18271
18172
  active.db,
18272
18173
  `ALTER TABLE "${entityName}" ADD COLUMN "${colName}" ${sqliteType}`
18273
18174
  );
18274
- const doc = loadConfigDoc(active.configPath);
18275
18175
  const fieldDef = { type: colType };
18276
18176
  if (body.required === true) fieldDef.required = true;
18277
18177
  doc.setIn(["entities", entityName, "fields", colName], fieldDef);
@@ -18298,6 +18198,14 @@ data: ${JSON.stringify(data)}
18298
18198
  sendJson(res, { error: `Unknown entity: ${entityName}` }, 400);
18299
18199
  return;
18300
18200
  }
18201
+ if (isNativeEntity(entityName)) {
18202
+ sendJson(
18203
+ res,
18204
+ { error: `"${entityName}" is a built-in entity and cannot be modified` },
18205
+ 400
18206
+ );
18207
+ return;
18208
+ }
18301
18209
  if (!operatorOwnsTable(active.teamContext, entityName)) {
18302
18210
  sendJson(res, { error: "Only the table owner can edit this entity" }, 403);
18303
18211
  return;
@@ -18320,14 +18228,30 @@ data: ${JSON.stringify(data)}
18320
18228
  sendJson(res, { error: `"${newCol}" is a reserved system column` }, 400);
18321
18229
  return;
18322
18230
  }
18231
+ const doc = loadConfigDoc(active.configPath);
18232
+ const fieldsNode = doc.getIn(["entities", entityName, "fields"]);
18233
+ if (!fieldsNode || typeof fieldsNode !== "object" || typeof fieldsNode.toJSON !== "function") {
18234
+ sendJson(res, { error: `Cannot rename columns on "${entityName}"` }, 400);
18235
+ return;
18236
+ }
18237
+ const fieldsObj = fieldsNode.toJSON();
18238
+ if (!(colName in fieldsObj)) {
18239
+ sendJson(res, { error: `Unknown column "${colName}" on ${entityName}` }, 400);
18240
+ return;
18241
+ }
18242
+ if (newCol in fieldsObj) {
18243
+ sendJson(res, { error: `Column "${newCol}" already exists on ${entityName}` }, 400);
18244
+ return;
18245
+ }
18323
18246
  await execSql(
18324
18247
  active.db,
18325
18248
  `ALTER TABLE "${entityName}" RENAME COLUMN "${colName}" TO "${newCol}"`
18326
18249
  );
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);
18250
+ const renamedFields = {};
18251
+ for (const k of Object.keys(fieldsObj)) {
18252
+ renamedFields[k === colName ? newCol : k] = fieldsObj[k];
18253
+ }
18254
+ doc.setIn(["entities", entityName, "fields"], renamedFields);
18331
18255
  saveConfigDoc(active.configPath, doc);
18332
18256
  await disposeActive(active);
18333
18257
  active = await openConfig(active.configPath, active.outputDir, autoRender);
@@ -18569,10 +18493,12 @@ data: ${JSON.stringify(data)}
18569
18493
  (e) => e.table_name === filterTable || junctionMatchesFilter.has(e.table_name)
18570
18494
  );
18571
18495
  }
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 });
18496
+ const sessionRows = await active.db.query("_lattice_gui_audit", {
18497
+ filters: [{ col: "session_id", op: "eq", val: sessionId }]
18498
+ });
18499
+ const sessionLive = sessionRows.filter((r) => Number(r.undone) === 0).length;
18500
+ const sessionUndone = sessionRows.length - sessionLive;
18501
+ sendJson(res, { entries, canUndo: sessionLive > 0, canRedo: sessionUndone > 0 });
18576
18502
  return;
18577
18503
  }
18578
18504
  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.1",
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",