latticesql 2.2.0 → 2.2.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/dist/cli.js CHANGED
@@ -4935,9 +4935,7 @@ var Lattice = class _Lattice {
4935
4935
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
4936
4936
  values
4937
4937
  );
4938
- const pkCol = pkCols[0] ?? "id";
4939
- const rawPk = rowWithPk[pkCol];
4940
- const pkValue = rawPk != null ? String(rawPk) : "";
4938
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4941
4939
  await this._appendChangelog(table, pkValue, "insert", rowWithPk, null);
4942
4940
  this._sanitizer.emitAudit(table, "insert", pkValue);
4943
4941
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
@@ -4980,9 +4978,7 @@ var Lattice = class _Lattice {
4980
4978
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
4981
4979
  values
4982
4980
  );
4983
- const pkCol = pkCols[0] ?? "id";
4984
- const rawPk = rowWithPk[pkCol];
4985
- const pkValue = rawPk != null ? String(rawPk) : "";
4981
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4986
4982
  this._sanitizer.emitAudit(table, "update", pkValue);
4987
4983
  this._scheduleAutoRender();
4988
4984
  return pkValue;
@@ -5028,7 +5024,7 @@ var Lattice = class _Lattice {
5028
5024
  }
5029
5025
  const values = [...Object.values(encrypted), ...pkParams];
5030
5026
  await runAsyncOrSync(this._adapter, `UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
5031
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5027
+ const auditId = this._serializePkLookup(table, id);
5032
5028
  await this._appendChangelog(table, auditId, "update", sanitized, previousValues);
5033
5029
  this._sanitizer.emitAudit(table, "update", auditId);
5034
5030
  await this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
@@ -5063,7 +5059,7 @@ var Lattice = class _Lattice {
5063
5059
  previousRow = await getAsyncOrSync(this._adapter, `SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null;
5064
5060
  }
5065
5061
  await runAsyncOrSync(this._adapter, `DELETE FROM "${table}" WHERE ${clause}`, params);
5066
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5062
+ const auditId = this._serializePkLookup(table, id);
5067
5063
  await this._appendChangelog(
5068
5064
  table,
5069
5065
  auditId,
@@ -5501,19 +5497,24 @@ var Lattice = class _Lattice {
5501
5497
  this._assertIdent(table);
5502
5498
  if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5503
5499
  const cols = this._ensureColumnCache(table);
5504
- const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
5500
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(table));
5505
5501
  let softDelete = "";
5506
5502
  if (cols.has("deleted_at") && opts.deleted !== "any") {
5507
5503
  softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5508
5504
  }
5509
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5510
- let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${rowAclVisibleExists("?", "?", pkExpr)}`;
5511
- const params = [opts.teamId, table, opts.userId, opts.userId];
5512
- if (opts.noAclVisible) {
5513
- sql += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5514
- params.push(opts.teamId, table);
5505
+ const params = [];
5506
+ let visClause;
5507
+ if (pkExpr) {
5508
+ visClause = rowAclVisibleExists("?", "?", pkExpr);
5509
+ params.push(opts.teamId, table, opts.userId, opts.userId);
5510
+ if (opts.noAclVisible) {
5511
+ visClause += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5512
+ params.push(opts.teamId, table);
5513
+ }
5514
+ } else {
5515
+ visClause = opts.noAclVisible ? "1=1" : "1=0";
5515
5516
  }
5516
- sql += `)`;
5517
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${visClause})`;
5517
5518
  if (opts.orderBy && cols.has(opts.orderBy)) {
5518
5519
  const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5519
5520
  sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
@@ -5559,14 +5560,18 @@ var Lattice = class _Lattice {
5559
5560
  for (const [i, spec] of bounded.entries()) {
5560
5561
  this._assertIdent(spec.table);
5561
5562
  const cols = this._ensureColumnCache(spec.table);
5562
- const pkCol = this._schema.getPrimaryKey(spec.table)[0] ?? "id";
5563
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
5563
5564
  const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5564
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5565
- let predicate = rowAclVisibleExists("?", "?", pkExpr);
5566
- params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5567
- if (spec.noAclVisible) {
5568
- predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5569
- params.push(opts.teamId, spec.table);
5565
+ let predicate;
5566
+ if (pkExpr) {
5567
+ predicate = rowAclVisibleExists("?", "?", pkExpr);
5568
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5569
+ if (spec.noAclVisible) {
5570
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5571
+ params.push(opts.teamId, spec.table);
5572
+ }
5573
+ } else {
5574
+ predicate = spec.noAclVisible ? "1=1" : "1=0";
5570
5575
  }
5571
5576
  selects.push(
5572
5577
  `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
@@ -5822,6 +5827,62 @@ var Lattice = class _Lattice {
5822
5827
  const params = pkCols.map((col) => id[col]);
5823
5828
  return { clause: clauses.join(" AND "), params };
5824
5829
  }
5830
+ // ── Composite-key serialization for the row-level-permission layer ───────
5831
+ // The row ACL (`__lattice_row_acl`/`__lattice_row_grants`) and the
5832
+ // change-log key each row by a single TEXT `pk`. For a table whose primary
5833
+ // key spans several columns (e.g. a junction table `(project_id,
5834
+ // meeting_id)` with no `id`), that key must encode EVERY pk column, and the
5835
+ // write side (what we store) must match the read side (the SQL that
5836
+ // reconstructs it from row columns). These three helpers are the single
5837
+ // source of truth for both. A single-column key serializes to the bare
5838
+ // value (so all pre-2.2.1 single-`id` ACL data stays valid).
5839
+ static _PK_SEP = " ";
5840
+ /**
5841
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
5842
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
5843
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
5844
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
5845
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
5846
+ */
5847
+ _resolvedPkCols(table) {
5848
+ const cols = this._ensureColumnCache(table);
5849
+ return this._schema.getPrimaryKey(table).filter((c) => cols.has(c));
5850
+ }
5851
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
5852
+ _serializeRowPk(table, row) {
5853
+ const pkCols = this._resolvedPkCols(table);
5854
+ const cols = pkCols.length > 0 ? pkCols : ["id"];
5855
+ return cols.map((c) => {
5856
+ const v = row[c];
5857
+ return v != null ? String(v) : "";
5858
+ }).join(_Lattice._PK_SEP);
5859
+ }
5860
+ /**
5861
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
5862
+ * row addressed by lookup keys its change-log entry identically to the way
5863
+ * {@link _serializeRowPk} keyed it at insert time.
5864
+ */
5865
+ _serializePkLookup(table, id) {
5866
+ if (typeof id === "string") return id;
5867
+ const pkCols = this._resolvedPkCols(table);
5868
+ if (pkCols.length === 0) return JSON.stringify(id);
5869
+ return pkCols.map((c) => {
5870
+ const v = id[c];
5871
+ return v != null ? String(v) : "";
5872
+ }).join(_Lattice._PK_SEP);
5873
+ }
5874
+ /**
5875
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
5876
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
5877
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
5878
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
5879
+ * {@link _PK_SEP}.
5880
+ */
5881
+ _pkSqlExpr(pkCols) {
5882
+ if (pkCols.length === 0) return null;
5883
+ const sep5 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5884
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep5} || `);
5885
+ }
5825
5886
  /**
5826
5887
  * Convert Filter objects into SQL clause strings and bound params.
5827
5888
  * An `in` filter with an empty array is silently ignored (produces no clause).
@@ -6359,6 +6420,13 @@ var NATIVE_ENTITY_DEFS = {
6359
6420
  columns: {
6360
6421
  id: "TEXT PRIMARY KEY",
6361
6422
  title: "TEXT",
6423
+ // Cloud user id of the member who started the thread (the operator's
6424
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6425
+ // cloud the chat routes only ever return threads whose owner matches the
6426
+ // requesting member. NULL on local single-user databases (no team
6427
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6428
+ // operator's own (visible only when there is no team context).
6429
+ owner_user_id: "TEXT",
6362
6430
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6363
6431
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6364
6432
  deleted_at: "TEXT"
@@ -6373,6 +6441,10 @@ var NATIVE_ENTITY_DEFS = {
6373
6441
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6374
6442
  // to match the generic, dialect-agnostic native-entity style.
6375
6443
  thread_id: "TEXT",
6444
+ // Cloud user id of the member the message belongs to — mirrors the
6445
+ // owning thread's owner_user_id so a message read can be filtered
6446
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6447
+ owner_user_id: "TEXT",
6376
6448
  // user | assistant | tool | feed | system
6377
6449
  role: "TEXT NOT NULL DEFAULT 'user'",
6378
6450
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -7512,20 +7584,6 @@ var css = `
7512
7584
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
7513
7585
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
7514
7586
  .grants-panel .grants-row input { accent-color: var(--accent); }
7515
- /* Deprecation banner: shown when the workspace holds a grandfathered
7516
- direct database cloud connection (no row-level security). Amber so
7517
- it reads as a warning, not an error. */
7518
- .deprecation-banner {
7519
- display: flex; align-items: center; gap: 12px;
7520
- padding: 8px 16px; font-size: 13px;
7521
- background: rgba(234, 179, 8, 0.12); color: var(--text);
7522
- border-bottom: 1px solid rgba(234, 179, 8, 0.45);
7523
- }
7524
- .deprecation-banner button {
7525
- margin-left: auto; background: transparent; border: none; cursor: pointer;
7526
- color: var(--text-muted); font-size: 13px; padding: 2px 6px; border-radius: 4px;
7527
- }
7528
- .deprecation-banner button:hover { background: rgba(234, 179, 8, 0.18); }
7529
7587
 
7530
7588
  /* Inline create-row at the bottom of every table */
7531
7589
  tr.create-row td { background: var(--surface-2); }
@@ -9016,27 +9074,6 @@ var appJs = `
9016
9074
 
9017
9075
  window.addEventListener('hashchange', renderRoute);
9018
9076
 
9019
- // Deprecation banner: a grandfathered direct database cloud connection
9020
- // bypasses the hosted server's row security entirely \u2014 say so up front.
9021
- // Dismiss hides it for this browser session only.
9022
- function initDeprecationBanner() {
9023
- if (sessionStorage.getItem('lattice-direct-banner-dismissed')) return;
9024
- fetchJson('/api/dbconfig').then(function (d) {
9025
- if (!d || !d.directCloud) return;
9026
- var banner = document.getElementById('deprecation-banner');
9027
- var text = document.getElementById('deprecation-banner-text');
9028
- if (!banner || !text) return;
9029
- text.textContent = "Direct database cloud connections are deprecated and don't support row-level security. Migrate to a hosted workspace.";
9030
- banner.hidden = false;
9031
- var dismiss = document.getElementById('deprecation-banner-dismiss');
9032
- if (dismiss) dismiss.addEventListener('click', function () {
9033
- banner.hidden = true;
9034
- sessionStorage.setItem('lattice-direct-banner-dismissed', '1');
9035
- });
9036
- }).catch(function () { /* dbconfig unavailable (e.g. team-cloud server mode) \u2014 no banner */ });
9037
- }
9038
- initDeprecationBanner();
9039
-
9040
9077
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
9041
9078
  // Sidebar
9042
9079
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -11437,9 +11474,11 @@ var appJs = `
11437
11474
  headers: { 'content-type': 'application/json' },
11438
11475
  body: JSON.stringify({ share: !isShared }),
11439
11476
  }).then(function () {
11440
- // The server updated team visibility in place (no DB re-open),
11441
- // so a light in-place refresh reflects it without a full reload.
11442
- return dmRefreshPanel(tableName, false);
11477
+ // Rebuild the graph (not just the panel) so the node's share-status
11478
+ // colour (gnode-shared/gnode-private) recolours immediately from the
11479
+ // refreshed entities \u2014 otherwise the swatch stayed stale until a
11480
+ // manual reload. The editor re-shows for the same table.
11481
+ return dmRefreshPanel(tableName, true);
11443
11482
  }).then(function () {
11444
11483
  showToast(isShared ? 'Unshared "' + tableName + '" from workspace' : 'Shared "' + tableName + '" with workspace', {});
11445
11484
  }).catch(function (e) { showToast('Share update failed: ' + e.message, {}); });
@@ -14159,10 +14198,6 @@ var guiAppHtml = `<!doctype html>
14159
14198
  </svg>
14160
14199
  </button>
14161
14200
  </header>
14162
- <div class="deprecation-banner" id="deprecation-banner" hidden>
14163
- <span id="deprecation-banner-text"></span>
14164
- <button id="deprecation-banner-dismiss" title="Dismiss for this session" aria-label="Dismiss">\u2715</button>
14165
- </div>
14166
14201
  <div class="layout">
14167
14202
  <nav class="sidebar">
14168
14203
  <label class="sidebar-advanced toggle" title="Advanced mode \u2014 row/table editor instead of the file workspace">
@@ -15334,19 +15369,12 @@ async function destroyTeamDirect(db) {
15334
15369
  }
15335
15370
  await db.delete("__lattice_team_identity", "singleton");
15336
15371
  }
15337
- var directDeprecationWarned = false;
15338
15372
  async function openCloud(cloudUrl) {
15339
15373
  if (!isPostgresUrl(cloudUrl)) {
15340
15374
  throw new Error(
15341
15375
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
15342
15376
  );
15343
15377
  }
15344
- if (!directDeprecationWarned) {
15345
- directDeprecationWarned = true;
15346
- console.warn(
15347
- "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
15348
- );
15349
- }
15350
15378
  const db = new Lattice(cloudUrl);
15351
15379
  await db.init();
15352
15380
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
@@ -15511,7 +15539,7 @@ async function resolveUserIdByEmail(db, email) {
15511
15539
  function isVisibleInTeam(tableName, ctx) {
15512
15540
  if (ctx.shared.has(tableName)) return true;
15513
15541
  const owner = ctx.owners.get(tableName);
15514
- if (owner === void 0) return true;
15542
+ if (owner === void 0) return ctx.isCreator;
15515
15543
  return owner === ctx.myUserId;
15516
15544
  }
15517
15545
  async function listTeamUsers(db) {
@@ -19331,10 +19359,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
19331
19359
  // without a local `__lattice_team_connections` row (which doesn't
19332
19360
  // exist when the team cloud itself is the active database).
19333
19361
  teamId: ctx.teamMembership?.teamId ?? null,
19334
- myUserId: ctx.teamMembership?.myUserId ?? null,
19335
- // Deprecated direct postgres:// team connection present → the SPA
19336
- // shows the migrate-to-hosted deprecation banner.
19337
- directCloud: ctx.directCloud
19362
+ myUserId: ctx.teamMembership?.myUserId ?? null
19338
19363
  });
19339
19364
  });
19340
19365
  return true;
@@ -20793,8 +20818,12 @@ async function buildSchemaContext(d) {
20793
20818
  }
20794
20819
  return lines.join("\n");
20795
20820
  }
20796
- function buildSystemPrompt(schema) {
20797
- return `${BASE_SYSTEM_PROMPT}
20821
+ function buildSystemPrompt(schema, operatorName) {
20822
+ const who = operatorName && operatorName.trim().length > 0 ? `
20823
+
20824
+ # Who you are assisting
20825
+ You are assisting ${operatorName.trim()}. When the user says "me" / "my", they mean ${operatorName.trim()}; never ask the user for their own name.` : "";
20826
+ return `${BASE_SYSTEM_PROMPT}${who}
20798
20827
 
20799
20828
  # Current database
20800
20829
  ${schema}`;
@@ -20809,7 +20838,7 @@ async function* runChat(opts) {
20809
20838
  ...opts.history ?? [],
20810
20839
  { role: "user", content: opts.userMessage }
20811
20840
  ];
20812
- const system = buildSystemPrompt(await buildSchemaContext(opts.dispatch));
20841
+ const system = buildSystemPrompt(await buildSchemaContext(opts.dispatch), opts.operatorName);
20813
20842
  let loop = 0;
20814
20843
  try {
20815
20844
  for (; loop < MAX_TOOL_LOOPS; loop++) {
@@ -21168,15 +21197,19 @@ function collapseSameRole(msgs) {
21168
21197
  }
21169
21198
  return out;
21170
21199
  }
21171
- async function rehydrateHistory(db, threadId, clientHistory) {
21200
+ async function rehydrateHistory(db, threadId, clientHistory, ownerUserId) {
21172
21201
  if (!threadId || !rehydrateEnabled()) return clientHistory;
21173
21202
  let rows;
21174
21203
  try {
21204
+ const filters = [
21205
+ { col: "thread_id", op: "eq", val: threadId },
21206
+ { col: "deleted_at", op: "isNull" }
21207
+ ];
21208
+ if (ownerUserId != null) {
21209
+ filters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
21210
+ }
21175
21211
  rows = await db.query("chat_messages", {
21176
- filters: [
21177
- { col: "thread_id", op: "eq", val: threadId },
21178
- { col: "deleted_at", op: "isNull" }
21179
- ],
21212
+ filters,
21180
21213
  limit: 1e3
21181
21214
  });
21182
21215
  } catch {
@@ -21248,13 +21281,18 @@ async function rehydrateHistory(db, threadId, clientHistory) {
21248
21281
  }
21249
21282
  return merged;
21250
21283
  }
21251
- async function ensureThread(db, threadId, title) {
21284
+ async function ensureThread(db, threadId, title, ownerUserId) {
21252
21285
  if (threadId) {
21253
21286
  const existing = await db.get("chat_threads", threadId);
21254
- if (existing && !existing.deleted_at) return threadId;
21287
+ const ownsIt = ownerUserId == null || (existing?.owner_user_id ?? null) === ownerUserId;
21288
+ if (existing && !existing.deleted_at && ownsIt) return threadId;
21255
21289
  }
21256
21290
  const id = crypto.randomUUID();
21257
- await db.insert("chat_threads", { id, title: title.slice(0, 60) || "Chat" });
21291
+ await db.insert("chat_threads", {
21292
+ id,
21293
+ title: title.slice(0, 60) || "Chat",
21294
+ owner_user_id: ownerUserId
21295
+ });
21258
21296
  return id;
21259
21297
  }
21260
21298
  var REHYDRATE_MAX_TURNS = 6;
@@ -21262,20 +21300,28 @@ var REHYDRATE_MAX_BYTES = 24e3;
21262
21300
  function rehydrateEnabled() {
21263
21301
  return process.env.LATTICE_CHAT_REHYDRATE !== "false";
21264
21302
  }
21265
- async function persistMessage(db, threadId, role, text, turns, startedAt) {
21303
+ async function persistMessage(db, threadId, role, text, ownerUserId, turns, startedAt) {
21266
21304
  const payload = turns && turns.length > 0 ? { text, turns } : { text };
21267
21305
  if (startedAt) payload.startedAt = startedAt;
21268
21306
  await db.insert("chat_messages", {
21269
21307
  id: crypto.randomUUID(),
21270
21308
  thread_id: threadId,
21309
+ // Mirror the owning member onto each message so a message read can be
21310
+ // filtered independently of the thread join. NULL on local DBs.
21311
+ owner_user_id: ownerUserId,
21271
21312
  role,
21272
21313
  content_json: JSON.stringify(payload),
21273
21314
  source: role === "user" ? "gui" : "ai"
21274
21315
  });
21275
21316
  }
21276
21317
  async function dispatchChatRoute(req, res, ctx) {
21318
+ const owner = ctx.team ? ctx.team.myUserId : null;
21277
21319
  if (ctx.method === "GET" && ctx.pathname === "/api/chat/threads") {
21278
- const rows = await ctx.db.query("chat_threads", { limit: 100 });
21320
+ const filters = [
21321
+ { col: "deleted_at", op: "isNull" }
21322
+ ];
21323
+ if (owner != null) filters.push({ col: "owner_user_id", op: "eq", val: owner });
21324
+ const rows = await ctx.db.query("chat_threads", { filters, limit: 100 });
21279
21325
  const threads = rows.filter((r) => !r.deleted_at).map((r) => ({
21280
21326
  id: asStr(r.id),
21281
21327
  title: asStr(r.title, "Chat"),
@@ -21287,7 +21333,19 @@ async function dispatchChatRoute(req, res, ctx) {
21287
21333
  const msgMatch = /^\/api\/chat\/threads\/([^/]+)\/messages$/.exec(ctx.pathname);
21288
21334
  if (ctx.method === "GET" && msgMatch) {
21289
21335
  const threadId2 = decodeURIComponent(msgMatch[1] ?? "");
21290
- const rows = await ctx.db.query("chat_messages", { limit: 1e3 });
21336
+ if (owner != null) {
21337
+ const thread = await ctx.db.get("chat_threads", threadId2);
21338
+ if (!thread || thread.deleted_at || (thread.owner_user_id ?? null) !== owner) {
21339
+ sendJson4(res, { messages: [] });
21340
+ return true;
21341
+ }
21342
+ }
21343
+ const msgFilters = [{ col: "thread_id", op: "eq", val: threadId2 }];
21344
+ if (owner != null) msgFilters.push({ col: "owner_user_id", op: "eq", val: owner });
21345
+ const rows = await ctx.db.query("chat_messages", {
21346
+ filters: msgFilters,
21347
+ limit: 1e3
21348
+ });
21291
21349
  const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
21292
21350
  let text = "";
21293
21351
  let turns2;
@@ -21341,11 +21399,11 @@ async function dispatchChatRoute(req, res, ctx) {
21341
21399
  return true;
21342
21400
  }
21343
21401
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
21344
- const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history));
21402
+ const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), owner);
21345
21403
  let threadId = "";
21346
21404
  try {
21347
- threadId = await ensureThread(ctx.db, requestedThread, message);
21348
- await persistMessage(ctx.db, threadId, "user", message);
21405
+ threadId = await ensureThread(ctx.db, requestedThread, message, owner);
21406
+ await persistMessage(ctx.db, threadId, "user", message, owner);
21349
21407
  } catch (e) {
21350
21408
  console.warn("[chat] persist user message failed:", e.message);
21351
21409
  }
@@ -21391,6 +21449,9 @@ async function dispatchChatRoute(req, res, ctx) {
21391
21449
  history,
21392
21450
  userMessage: message,
21393
21451
  temperature,
21452
+ // Give the assistant the operator's name so it addresses them and
21453
+ // resolves "me"/"my" without asking for a name it already has.
21454
+ operatorName: readIdentity().display_name,
21394
21455
  // Capture each executed tool call (capped) for cross-turn replay memory.
21395
21456
  onToolRecord: (rec) => {
21396
21457
  turns[turns.length - 1]?.toolCalls.push(rec);
@@ -21432,7 +21493,15 @@ async function dispatchChatRoute(req, res, ctx) {
21432
21493
  ...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
21433
21494
  })).filter((t) => t.text.length > 0 || t.tools.length > 0 || (t.events?.length ?? 0) > 0);
21434
21495
  try {
21435
- await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
21496
+ await persistMessage(
21497
+ ctx.db,
21498
+ threadId,
21499
+ "assistant",
21500
+ assistantText,
21501
+ owner,
21502
+ cleanTurns,
21503
+ turnStartedAt
21504
+ );
21436
21505
  } catch (e) {
21437
21506
  console.warn("[chat] persist assistant message failed:", e.message);
21438
21507
  }
@@ -21964,6 +22033,10 @@ async function attachBlob(srcPath, latticeRoot) {
21964
22033
  }
21965
22034
 
21966
22035
  // src/gui/ingest-routes.ts
22036
+ function fileSlug(name, id) {
22037
+ const base = slugify(name.replace(/\.[^./\\]+$/, "")) || "file";
22038
+ return `${base}-${id.slice(0, 8)}`;
22039
+ }
21967
22040
  var MIME_BY_EXT = {
21968
22041
  ".pdf": "application/pdf",
21969
22042
  ".png": "image/png",
@@ -22331,8 +22404,10 @@ async function dispatchIngestRoute(req, res, ctx) {
22331
22404
  } finally {
22332
22405
  await rm(tmp, { force: true }).catch(() => void 0);
22333
22406
  }
22407
+ const fileId = crypto.randomUUID();
22334
22408
  const { id: id2 } = await createRow(mctx, "files", {
22335
- id: crypto.randomUUID(),
22409
+ id: fileId,
22410
+ slug: fileSlug(name2, fileId),
22336
22411
  original_name: name2,
22337
22412
  mime: mime2,
22338
22413
  size_bytes: buf.length,
@@ -22383,8 +22458,10 @@ async function dispatchIngestRoute(req, res, ctx) {
22383
22458
  console.warn("[ingest] url crawl failed:", e.message);
22384
22459
  }
22385
22460
  }
22461
+ const textFileId = crypto.randomUUID();
22386
22462
  const { id: id2 } = await createRow(mctx, "files", {
22387
- id: crypto.randomUUID(),
22463
+ id: textFileId,
22464
+ slug: fileSlug(title, textFileId),
22388
22465
  original_name: title,
22389
22466
  mime: mime2,
22390
22467
  size_bytes: Buffer.byteLength(content, "utf8"),
@@ -22418,8 +22495,10 @@ async function dispatchIngestRoute(req, res, ctx) {
22418
22495
  }
22419
22496
  const name = basename10(abs);
22420
22497
  const mime = mimeFor(name);
22498
+ const localFileId = crypto.randomUUID();
22421
22499
  const { id } = await createRow(mctx, "files", {
22422
- id: crypto.randomUUID(),
22500
+ id: localFileId,
22501
+ slug: fileSlug(name, localFileId),
22423
22502
  path: abs,
22424
22503
  original_name: name,
22425
22504
  mime,
@@ -22956,13 +23035,6 @@ async function openConfig(configPath, outputDir, autoRender = false) {
22956
23035
  if (!isVisibleInTeam(name, teamContext)) validTables.delete(name);
22957
23036
  }
22958
23037
  }
22959
- let directTeamConnection = false;
22960
- try {
22961
- const conns = await teamsClient.listConnections();
22962
- directTeamConnection = conns.some((c) => isPostgresUrl(c.cloud_url));
22963
- } catch (e) {
22964
- console.warn("[openConfig] could not check for direct team connections:", e.message);
22965
- }
22966
23038
  let realtime = null;
22967
23039
  if (db.getDialect() === "postgres") {
22968
23040
  try {
@@ -23003,7 +23075,6 @@ async function openConfig(configPath, outputDir, autoRender = false) {
23003
23075
  teamsClient,
23004
23076
  validTables,
23005
23077
  teamContext,
23006
- directTeamConnection,
23007
23078
  junctionTables,
23008
23079
  entityContextByTable,
23009
23080
  manifest,
@@ -25099,7 +25170,6 @@ data: ${JSON.stringify(data)}
25099
25170
  teamId: active.teamContext.teamId,
25100
25171
  myUserId: active.teamContext.myUserId
25101
25172
  } : null,
25102
- directCloud: active.directTeamConnection,
25103
25173
  swap: async () => {
25104
25174
  const next = await openConfig(active.configPath, active.outputDir, autoRender);
25105
25175
  await disposeActive(active);
package/dist/index.cjs CHANGED
@@ -4935,9 +4935,7 @@ var Lattice = class _Lattice {
4935
4935
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
4936
4936
  values
4937
4937
  );
4938
- const pkCol = pkCols[0] ?? "id";
4939
- const rawPk = rowWithPk[pkCol];
4940
- const pkValue = rawPk != null ? String(rawPk) : "";
4938
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4941
4939
  await this._appendChangelog(table, pkValue, "insert", rowWithPk, null);
4942
4940
  this._sanitizer.emitAudit(table, "insert", pkValue);
4943
4941
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
@@ -4980,9 +4978,7 @@ var Lattice = class _Lattice {
4980
4978
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
4981
4979
  values
4982
4980
  );
4983
- const pkCol = pkCols[0] ?? "id";
4984
- const rawPk = rowWithPk[pkCol];
4985
- const pkValue = rawPk != null ? String(rawPk) : "";
4981
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4986
4982
  this._sanitizer.emitAudit(table, "update", pkValue);
4987
4983
  this._scheduleAutoRender();
4988
4984
  return pkValue;
@@ -5028,7 +5024,7 @@ var Lattice = class _Lattice {
5028
5024
  }
5029
5025
  const values = [...Object.values(encrypted), ...pkParams];
5030
5026
  await runAsyncOrSync(this._adapter, `UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
5031
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5027
+ const auditId = this._serializePkLookup(table, id);
5032
5028
  await this._appendChangelog(table, auditId, "update", sanitized, previousValues);
5033
5029
  this._sanitizer.emitAudit(table, "update", auditId);
5034
5030
  await this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
@@ -5063,7 +5059,7 @@ var Lattice = class _Lattice {
5063
5059
  previousRow = await getAsyncOrSync(this._adapter, `SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null;
5064
5060
  }
5065
5061
  await runAsyncOrSync(this._adapter, `DELETE FROM "${table}" WHERE ${clause}`, params);
5066
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
5062
+ const auditId = this._serializePkLookup(table, id);
5067
5063
  await this._appendChangelog(
5068
5064
  table,
5069
5065
  auditId,
@@ -5501,19 +5497,24 @@ var Lattice = class _Lattice {
5501
5497
  this._assertIdent(table);
5502
5498
  if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5503
5499
  const cols = this._ensureColumnCache(table);
5504
- const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
5500
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(table));
5505
5501
  let softDelete = "";
5506
5502
  if (cols.has("deleted_at") && opts.deleted !== "any") {
5507
5503
  softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5508
5504
  }
5509
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5510
- let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${rowAclVisibleExists("?", "?", pkExpr)}`;
5511
- const params = [opts.teamId, table, opts.userId, opts.userId];
5512
- if (opts.noAclVisible) {
5513
- sql += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5514
- params.push(opts.teamId, table);
5505
+ const params = [];
5506
+ let visClause;
5507
+ if (pkExpr) {
5508
+ visClause = rowAclVisibleExists("?", "?", pkExpr);
5509
+ params.push(opts.teamId, table, opts.userId, opts.userId);
5510
+ if (opts.noAclVisible) {
5511
+ visClause += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5512
+ params.push(opts.teamId, table);
5513
+ }
5514
+ } else {
5515
+ visClause = opts.noAclVisible ? "1=1" : "1=0";
5515
5516
  }
5516
- sql += `)`;
5517
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${visClause})`;
5517
5518
  if (opts.orderBy && cols.has(opts.orderBy)) {
5518
5519
  const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5519
5520
  sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
@@ -5559,14 +5560,18 @@ var Lattice = class _Lattice {
5559
5560
  for (const [i, spec] of bounded.entries()) {
5560
5561
  this._assertIdent(spec.table);
5561
5562
  const cols = this._ensureColumnCache(spec.table);
5562
- const pkCol = this._schema.getPrimaryKey(spec.table)[0] ?? "id";
5563
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
5563
5564
  const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5564
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5565
- let predicate = rowAclVisibleExists("?", "?", pkExpr);
5566
- params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5567
- if (spec.noAclVisible) {
5568
- predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5569
- params.push(opts.teamId, spec.table);
5565
+ let predicate;
5566
+ if (pkExpr) {
5567
+ predicate = rowAclVisibleExists("?", "?", pkExpr);
5568
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5569
+ if (spec.noAclVisible) {
5570
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5571
+ params.push(opts.teamId, spec.table);
5572
+ }
5573
+ } else {
5574
+ predicate = spec.noAclVisible ? "1=1" : "1=0";
5570
5575
  }
5571
5576
  selects.push(
5572
5577
  `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
@@ -5822,6 +5827,62 @@ var Lattice = class _Lattice {
5822
5827
  const params = pkCols.map((col) => id[col]);
5823
5828
  return { clause: clauses.join(" AND "), params };
5824
5829
  }
5830
+ // ── Composite-key serialization for the row-level-permission layer ───────
5831
+ // The row ACL (`__lattice_row_acl`/`__lattice_row_grants`) and the
5832
+ // change-log key each row by a single TEXT `pk`. For a table whose primary
5833
+ // key spans several columns (e.g. a junction table `(project_id,
5834
+ // meeting_id)` with no `id`), that key must encode EVERY pk column, and the
5835
+ // write side (what we store) must match the read side (the SQL that
5836
+ // reconstructs it from row columns). These three helpers are the single
5837
+ // source of truth for both. A single-column key serializes to the bare
5838
+ // value (so all pre-2.2.1 single-`id` ACL data stays valid).
5839
+ static _PK_SEP = " ";
5840
+ /**
5841
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
5842
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
5843
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
5844
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
5845
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
5846
+ */
5847
+ _resolvedPkCols(table) {
5848
+ const cols = this._ensureColumnCache(table);
5849
+ return this._schema.getPrimaryKey(table).filter((c) => cols.has(c));
5850
+ }
5851
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
5852
+ _serializeRowPk(table, row) {
5853
+ const pkCols = this._resolvedPkCols(table);
5854
+ const cols = pkCols.length > 0 ? pkCols : ["id"];
5855
+ return cols.map((c) => {
5856
+ const v = row[c];
5857
+ return v != null ? String(v) : "";
5858
+ }).join(_Lattice._PK_SEP);
5859
+ }
5860
+ /**
5861
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
5862
+ * row addressed by lookup keys its change-log entry identically to the way
5863
+ * {@link _serializeRowPk} keyed it at insert time.
5864
+ */
5865
+ _serializePkLookup(table, id) {
5866
+ if (typeof id === "string") return id;
5867
+ const pkCols = this._resolvedPkCols(table);
5868
+ if (pkCols.length === 0) return JSON.stringify(id);
5869
+ return pkCols.map((c) => {
5870
+ const v = id[c];
5871
+ return v != null ? String(v) : "";
5872
+ }).join(_Lattice._PK_SEP);
5873
+ }
5874
+ /**
5875
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
5876
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
5877
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
5878
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
5879
+ * {@link _PK_SEP}.
5880
+ */
5881
+ _pkSqlExpr(pkCols) {
5882
+ if (pkCols.length === 0) return null;
5883
+ const sep2 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5884
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep2} || `);
5885
+ }
5825
5886
  /**
5826
5887
  * Convert Filter objects into SQL clause strings and bound params.
5827
5888
  * An `in` filter with an empty array is silently ignored (produces no clause).
@@ -6762,6 +6823,13 @@ var NATIVE_ENTITY_DEFS = {
6762
6823
  columns: {
6763
6824
  id: "TEXT PRIMARY KEY",
6764
6825
  title: "TEXT",
6826
+ // Cloud user id of the member who started the thread (the operator's
6827
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6828
+ // cloud the chat routes only ever return threads whose owner matches the
6829
+ // requesting member. NULL on local single-user databases (no team
6830
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6831
+ // operator's own (visible only when there is no team context).
6832
+ owner_user_id: "TEXT",
6765
6833
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6766
6834
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6767
6835
  deleted_at: "TEXT"
@@ -6776,6 +6844,10 @@ var NATIVE_ENTITY_DEFS = {
6776
6844
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6777
6845
  // to match the generic, dialect-agnostic native-entity style.
6778
6846
  thread_id: "TEXT",
6847
+ // Cloud user id of the member the message belongs to — mirrors the
6848
+ // owning thread's owner_user_id so a message read can be filtered
6849
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6850
+ owner_user_id: "TEXT",
6779
6851
  // user | assistant | tool | feed | system
6780
6852
  role: "TEXT NOT NULL DEFAULT 'user'",
6781
6853
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -8024,19 +8096,12 @@ async function destroyTeamDirect(db) {
8024
8096
  }
8025
8097
  await db.delete("__lattice_team_identity", "singleton");
8026
8098
  }
8027
- var directDeprecationWarned = false;
8028
8099
  async function openCloud(cloudUrl) {
8029
8100
  if (!isPostgresUrl(cloudUrl)) {
8030
8101
  throw new Error(
8031
8102
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
8032
8103
  );
8033
8104
  }
8034
- if (!directDeprecationWarned) {
8035
- directDeprecationWarned = true;
8036
- console.warn(
8037
- "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
8038
- );
8039
- }
8040
8105
  const db = new Lattice(cloudUrl);
8041
8106
  await db.init();
8042
8107
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
package/dist/index.d.cts CHANGED
@@ -2111,6 +2111,31 @@ declare class Lattice {
2111
2111
  * - `Record` → matches every PK column; all must be present in the object.
2112
2112
  */
2113
2113
  private _pkWhere;
2114
+ private static readonly _PK_SEP;
2115
+ /**
2116
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
2117
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
2118
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
2119
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
2120
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
2121
+ */
2122
+ private _resolvedPkCols;
2123
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
2124
+ private _serializeRowPk;
2125
+ /**
2126
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
2127
+ * row addressed by lookup keys its change-log entry identically to the way
2128
+ * {@link _serializeRowPk} keyed it at insert time.
2129
+ */
2130
+ private _serializePkLookup;
2131
+ /**
2132
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
2133
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
2134
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
2135
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
2136
+ * {@link _PK_SEP}.
2137
+ */
2138
+ private _pkSqlExpr;
2114
2139
  /**
2115
2140
  * Convert Filter objects into SQL clause strings and bound params.
2116
2141
  * An `in` filter with an empty array is silently ignored (produces no clause).
package/dist/index.d.ts CHANGED
@@ -2111,6 +2111,31 @@ declare class Lattice {
2111
2111
  * - `Record` → matches every PK column; all must be present in the object.
2112
2112
  */
2113
2113
  private _pkWhere;
2114
+ private static readonly _PK_SEP;
2115
+ /**
2116
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
2117
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
2118
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
2119
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
2120
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
2121
+ */
2122
+ private _resolvedPkCols;
2123
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
2124
+ private _serializeRowPk;
2125
+ /**
2126
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
2127
+ * row addressed by lookup keys its change-log entry identically to the way
2128
+ * {@link _serializeRowPk} keyed it at insert time.
2129
+ */
2130
+ private _serializePkLookup;
2131
+ /**
2132
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
2133
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
2134
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
2135
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
2136
+ * {@link _PK_SEP}.
2137
+ */
2138
+ private _pkSqlExpr;
2114
2139
  /**
2115
2140
  * Convert Filter objects into SQL clause strings and bound params.
2116
2141
  * An `in` filter with an empty array is silently ignored (produces no clause).
package/dist/index.js CHANGED
@@ -4801,9 +4801,7 @@ var Lattice = class _Lattice {
4801
4801
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders})`,
4802
4802
  values
4803
4803
  );
4804
- const pkCol = pkCols[0] ?? "id";
4805
- const rawPk = rowWithPk[pkCol];
4806
- const pkValue = rawPk != null ? String(rawPk) : "";
4804
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4807
4805
  await this._appendChangelog(table, pkValue, "insert", rowWithPk, null);
4808
4806
  this._sanitizer.emitAudit(table, "insert", pkValue);
4809
4807
  await this._fireWriteHooks(table, "insert", rowWithPk, pkValue);
@@ -4846,9 +4844,7 @@ var Lattice = class _Lattice {
4846
4844
  `INSERT INTO "${table}" (${cols}) VALUES (${placeholders}) ON CONFLICT(${conflictCols}) DO UPDATE SET ${updateCols}`,
4847
4845
  values
4848
4846
  );
4849
- const pkCol = pkCols[0] ?? "id";
4850
- const rawPk = rowWithPk[pkCol];
4851
- const pkValue = rawPk != null ? String(rawPk) : "";
4847
+ const pkValue = this._serializeRowPk(table, rowWithPk);
4852
4848
  this._sanitizer.emitAudit(table, "update", pkValue);
4853
4849
  this._scheduleAutoRender();
4854
4850
  return pkValue;
@@ -4894,7 +4890,7 @@ var Lattice = class _Lattice {
4894
4890
  }
4895
4891
  const values = [...Object.values(encrypted), ...pkParams];
4896
4892
  await runAsyncOrSync(this._adapter, `UPDATE "${table}" SET ${setCols} WHERE ${clause}`, values);
4897
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
4893
+ const auditId = this._serializePkLookup(table, id);
4898
4894
  await this._appendChangelog(table, auditId, "update", sanitized, previousValues);
4899
4895
  this._sanitizer.emitAudit(table, "update", auditId);
4900
4896
  await this._fireWriteHooks(table, "update", sanitized, auditId, Object.keys(sanitized));
@@ -4929,7 +4925,7 @@ var Lattice = class _Lattice {
4929
4925
  previousRow = await getAsyncOrSync(this._adapter, `SELECT * FROM "${table}" WHERE ${clause}`, params) ?? null;
4930
4926
  }
4931
4927
  await runAsyncOrSync(this._adapter, `DELETE FROM "${table}" WHERE ${clause}`, params);
4932
- const auditId = typeof id === "string" ? id : JSON.stringify(id);
4928
+ const auditId = this._serializePkLookup(table, id);
4933
4929
  await this._appendChangelog(
4934
4930
  table,
4935
4931
  auditId,
@@ -5367,19 +5363,24 @@ var Lattice = class _Lattice {
5367
5363
  this._assertIdent(table);
5368
5364
  if (opts.orderBy) this._assertIdent(table, opts.orderBy);
5369
5365
  const cols = this._ensureColumnCache(table);
5370
- const pkCol = this._schema.getPrimaryKey(table)[0] ?? "id";
5366
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(table));
5371
5367
  let softDelete = "";
5372
5368
  if (cols.has("deleted_at") && opts.deleted !== "any") {
5373
5369
  softDelete = opts.deleted === "only" ? `t."deleted_at" IS NOT NULL AND ` : `t."deleted_at" IS NULL AND `;
5374
5370
  }
5375
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5376
- let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${rowAclVisibleExists("?", "?", pkExpr)}`;
5377
- const params = [opts.teamId, table, opts.userId, opts.userId];
5378
- if (opts.noAclVisible) {
5379
- sql += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5380
- params.push(opts.teamId, table);
5371
+ const params = [];
5372
+ let visClause;
5373
+ if (pkExpr) {
5374
+ visClause = rowAclVisibleExists("?", "?", pkExpr);
5375
+ params.push(opts.teamId, table, opts.userId, opts.userId);
5376
+ if (opts.noAclVisible) {
5377
+ visClause += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5378
+ params.push(opts.teamId, table);
5379
+ }
5380
+ } else {
5381
+ visClause = opts.noAclVisible ? "1=1" : "1=0";
5381
5382
  }
5382
- sql += `)`;
5383
+ let sql = `SELECT t.* FROM "${table}" t WHERE ${softDelete}(${visClause})`;
5383
5384
  if (opts.orderBy && cols.has(opts.orderBy)) {
5384
5385
  const dir = opts.orderDir === "desc" ? "DESC" : "ASC";
5385
5386
  sql += ` ORDER BY t."${opts.orderBy}" ${dir}`;
@@ -5425,14 +5426,18 @@ var Lattice = class _Lattice {
5425
5426
  for (const [i, spec] of bounded.entries()) {
5426
5427
  this._assertIdent(spec.table);
5427
5428
  const cols = this._ensureColumnCache(spec.table);
5428
- const pkCol = this._schema.getPrimaryKey(spec.table)[0] ?? "id";
5429
+ const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
5429
5430
  const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
5430
- const pkExpr = `CAST(t."${pkCol}" AS TEXT)`;
5431
- let predicate = rowAclVisibleExists("?", "?", pkExpr);
5432
- params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5433
- if (spec.noAclVisible) {
5434
- predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5435
- params.push(opts.teamId, spec.table);
5431
+ let predicate;
5432
+ if (pkExpr) {
5433
+ predicate = rowAclVisibleExists("?", "?", pkExpr);
5434
+ params.push(opts.teamId, spec.table, opts.userId, opts.userId);
5435
+ if (spec.noAclVisible) {
5436
+ predicate += ` OR ${rowAclAbsent("?", "?", pkExpr)}`;
5437
+ params.push(opts.teamId, spec.table);
5438
+ }
5439
+ } else {
5440
+ predicate = spec.noAclVisible ? "1=1" : "1=0";
5436
5441
  }
5437
5442
  selects.push(
5438
5443
  `(SELECT COUNT(*) FROM "${spec.table}" t WHERE ${softDelete}(${predicate})) AS c${String(i)}`
@@ -5688,6 +5693,62 @@ var Lattice = class _Lattice {
5688
5693
  const params = pkCols.map((col) => id[col]);
5689
5694
  return { clause: clauses.join(" AND "), params };
5690
5695
  }
5696
+ // ── Composite-key serialization for the row-level-permission layer ───────
5697
+ // The row ACL (`__lattice_row_acl`/`__lattice_row_grants`) and the
5698
+ // change-log key each row by a single TEXT `pk`. For a table whose primary
5699
+ // key spans several columns (e.g. a junction table `(project_id,
5700
+ // meeting_id)` with no `id`), that key must encode EVERY pk column, and the
5701
+ // write side (what we store) must match the read side (the SQL that
5702
+ // reconstructs it from row columns). These three helpers are the single
5703
+ // source of truth for both. A single-column key serializes to the bare
5704
+ // value (so all pre-2.2.1 single-`id` ACL data stays valid).
5705
+ static _PK_SEP = " ";
5706
+ /**
5707
+ * The primary-key columns of `table` that PHYSICALLY exist, in declared
5708
+ * order. Empty when the table has no Lattice-addressable key — e.g. a table
5709
+ * reached via raw SQL whose PK metadata defaulted to `['id']` but that has
5710
+ * no `id` column. Callers treat an empty result as "unkeyable" (no per-row
5711
+ * ACL is possible, so the row-perm SQL must not reference a pk column).
5712
+ */
5713
+ _resolvedPkCols(table) {
5714
+ const cols = this._ensureColumnCache(table);
5715
+ return this._schema.getPrimaryKey(table).filter((c) => cols.has(c));
5716
+ }
5717
+ /** Canonical ACL / change-log `pk` string for a row. Matches {@link _pkSqlExpr}. */
5718
+ _serializeRowPk(table, row) {
5719
+ const pkCols = this._resolvedPkCols(table);
5720
+ const cols = pkCols.length > 0 ? pkCols : ["id"];
5721
+ return cols.map((c) => {
5722
+ const v = row[c];
5723
+ return v != null ? String(v) : "";
5724
+ }).join(_Lattice._PK_SEP);
5725
+ }
5726
+ /**
5727
+ * Canonical `pk` string for a {@link PkLookup} used by update/delete, so a
5728
+ * row addressed by lookup keys its change-log entry identically to the way
5729
+ * {@link _serializeRowPk} keyed it at insert time.
5730
+ */
5731
+ _serializePkLookup(table, id) {
5732
+ if (typeof id === "string") return id;
5733
+ const pkCols = this._resolvedPkCols(table);
5734
+ if (pkCols.length === 0) return JSON.stringify(id);
5735
+ return pkCols.map((c) => {
5736
+ const v = id[c];
5737
+ return v != null ? String(v) : "";
5738
+ }).join(_Lattice._PK_SEP);
5739
+ }
5740
+ /**
5741
+ * SQL expression reconstructing {@link _serializeRowPk} from a row aliased
5742
+ * `t`. Returns null when the table is unkeyable (no pk columns present) — the
5743
+ * caller must then avoid referencing a pk column at all. Dialect-aware tab
5744
+ * separator: SQLite `char(9)`, Postgres `chr(9)` (both = U+0009), matching
5745
+ * {@link _PK_SEP}.
5746
+ */
5747
+ _pkSqlExpr(pkCols) {
5748
+ if (pkCols.length === 0) return null;
5749
+ const sep2 = this.getDialect() === "postgres" ? "chr(9)" : "char(9)";
5750
+ return pkCols.map((c) => `CAST(t."${c}" AS TEXT)`).join(` || ${sep2} || `);
5751
+ }
5691
5752
  /**
5692
5753
  * Convert Filter objects into SQL clause strings and bound params.
5693
5754
  * An `in` filter with an empty array is silently ignored (produces no clause).
@@ -6628,6 +6689,13 @@ var NATIVE_ENTITY_DEFS = {
6628
6689
  columns: {
6629
6690
  id: "TEXT PRIMARY KEY",
6630
6691
  title: "TEXT",
6692
+ // Cloud user id of the member who started the thread (the operator's
6693
+ // `teamContext.myUserId`). A chat is PRIVATE to its author — on a team
6694
+ // cloud the chat routes only ever return threads whose owner matches the
6695
+ // requesting member. NULL on local single-user databases (no team
6696
+ // context) and on pre-2.2.1 threads, which the routes treat as the local
6697
+ // operator's own (visible only when there is no team context).
6698
+ owner_user_id: "TEXT",
6631
6699
  created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6632
6700
  updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
6633
6701
  deleted_at: "TEXT"
@@ -6642,6 +6710,10 @@ var NATIVE_ENTITY_DEFS = {
6642
6710
  // Soft reference to chat_threads.id. Kept as a plain column (no FK)
6643
6711
  // to match the generic, dialect-agnostic native-entity style.
6644
6712
  thread_id: "TEXT",
6713
+ // Cloud user id of the member the message belongs to — mirrors the
6714
+ // owning thread's owner_user_id so a message read can be filtered
6715
+ // independently of the thread join. NULL on local DBs / pre-2.2.1 rows.
6716
+ owner_user_id: "TEXT",
6645
6717
  // user | assistant | tool | feed | system
6646
6718
  role: "TEXT NOT NULL DEFAULT 'user'",
6647
6719
  // JSON payload: text, tool_use / tool_result blocks, attachments, or
@@ -7890,19 +7962,12 @@ async function destroyTeamDirect(db) {
7890
7962
  }
7891
7963
  await db.delete("__lattice_team_identity", "singleton");
7892
7964
  }
7893
- var directDeprecationWarned = false;
7894
7965
  async function openCloud(cloudUrl) {
7895
7966
  if (!isPostgresUrl(cloudUrl)) {
7896
7967
  throw new Error(
7897
7968
  `direct-ops: cloudUrl must be a postgres:// URL (got ${cloudUrl.slice(0, 12)}\u2026)`
7898
7969
  );
7899
7970
  }
7900
- if (!directDeprecationWarned) {
7901
- directDeprecationWarned = true;
7902
- console.warn(
7903
- "[teams] Direct postgres:// team-cloud connection is deprecated and does NOT enforce 2.2 row-level security. Migrate to a hosted Lattice Teams server."
7904
- );
7905
- }
7906
7971
  const db = new Lattice(cloudUrl);
7907
7972
  await db.init();
7908
7973
  for (const [table, def] of Object.entries(CLOUD_INTERNAL_TABLE_DEFS)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "2.2.0",
3
+ "version": "2.2.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",