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 +177 -107
- package/dist/index.cjs +95 -30
- package/dist/index.d.cts +25 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +95 -30
- package/package.json +1 -1
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
5510
|
-
let
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
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
|
|
5563
|
+
const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
|
|
5563
5564
|
const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
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
|
-
//
|
|
11441
|
-
//
|
|
11442
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
5510
|
-
let
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
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
|
|
5563
|
+
const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
|
|
5563
5564
|
const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
5376
|
-
let
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
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
|
|
5429
|
+
const pkExpr = this._pkSqlExpr(this._resolvedPkCols(spec.table));
|
|
5429
5430
|
const softDelete = cols.has("deleted_at") ? `t."deleted_at" IS NULL AND ` : "";
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
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