latticesql 3.4.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -919,43 +919,57 @@ var init_postgres = __esm({
919
919
  },
920
920
  {
921
921
  warn: "could not register json_extract polyfill:",
922
- sql: `CREATE OR REPLACE FUNCTION json_extract(doc text, path text)
923
- RETURNS text
924
- LANGUAGE sql
925
- IMMUTABLE
926
- AS $fn$
927
- SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
928
- $fn$;`
922
+ // Create ONLY if absent. `CREATE OR REPLACE` on an existing function requires
923
+ // ownership, but on a cloud the function is owned by whichever single role
924
+ // created it first — so every OTHER member's per-connect replace raised "must
925
+ // be owner of function" and (sharing the render transaction) aborted it,
926
+ // yielding an empty render. The IF-absent guard makes a present function a
927
+ // clean no-op for everyone, regardless of who owns it.
928
+ sql: `DO $do$ BEGIN
929
+ IF to_regprocedure('json_extract(text, text)') IS NULL THEN
930
+ CREATE FUNCTION json_extract(doc text, path text)
931
+ RETURNS text
932
+ LANGUAGE sql
933
+ IMMUTABLE
934
+ AS $fn$
935
+ SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
936
+ $fn$;
937
+ END IF;
938
+ END $do$;`
929
939
  },
930
940
  {
931
941
  warn: "could not register strftime polyfill:",
932
- sql: `CREATE OR REPLACE FUNCTION strftime(format text, modifier text)
933
- RETURNS text
934
- LANGUAGE plpgsql
935
- IMMUTABLE
936
- AS $fn$
937
- DECLARE ts timestamptz;
938
- BEGIN
939
- IF modifier = 'now' THEN
940
- ts := now();
941
- ELSE
942
- ts := modifier::timestamptz;
943
- END IF;
944
- RETURN to_char(
945
- ts AT TIME ZONE 'UTC',
946
- replace(replace(replace(replace(replace(replace(replace(replace(
947
- format,
948
- '%Y', 'YYYY'),
949
- '%m', 'MM'),
950
- '%d', 'DD'),
951
- '%H', 'HH24'),
952
- '%M', 'MI'),
953
- '%S', 'SS'),
954
- '%f', 'MS'),
955
- 'T', '"T"')
956
- );
957
- END;
958
- $fn$;`
942
+ sql: `DO $do$ BEGIN
943
+ IF to_regprocedure('strftime(text, text)') IS NULL THEN
944
+ CREATE FUNCTION strftime(format text, modifier text)
945
+ RETURNS text
946
+ LANGUAGE plpgsql
947
+ IMMUTABLE
948
+ AS $fn$
949
+ DECLARE ts timestamptz;
950
+ BEGIN
951
+ IF modifier = 'now' THEN
952
+ ts := now();
953
+ ELSE
954
+ ts := modifier::timestamptz;
955
+ END IF;
956
+ RETURN to_char(
957
+ ts AT TIME ZONE 'UTC',
958
+ replace(replace(replace(replace(replace(replace(replace(replace(
959
+ format,
960
+ '%Y', 'YYYY'),
961
+ '%m', 'MM'),
962
+ '%d', 'DD'),
963
+ '%H', 'HH24'),
964
+ '%M', 'MI'),
965
+ '%S', 'SS'),
966
+ '%f', 'MS'),
967
+ 'T', '"T"')
968
+ );
969
+ END;
970
+ $fn$;
971
+ END IF;
972
+ END $do$;`
959
973
  }
960
974
  ];
961
975
  }
@@ -2364,6 +2378,34 @@ var init_engine = __esm({
2364
2378
  setRenderFold(fn) {
2365
2379
  this._foldRows = fn;
2366
2380
  }
2381
+ /**
2382
+ * Incremental scope: is this entity-context table affected by a change to one
2383
+ * of `changed`? Affected when the table itself changed (its own rows / `self`
2384
+ * source / index) OR any of its files SOURCES from a changed table (a cross-
2385
+ * table dependent — e.g. an AGENT.md that lists the agent's tasks must re-render
2386
+ * when `tasks` changes). A `custom` source runs an arbitrary query, so we can't
2387
+ * prove independence — treat it as always-affected (conservative, never stale).
2388
+ */
2389
+ _entityAffected(table, def, changed) {
2390
+ if (changed.has(table)) return true;
2391
+ for (const spec of Object.values(def.files)) {
2392
+ if (this._sourceTouches(spec.source, changed)) return true;
2393
+ }
2394
+ return false;
2395
+ }
2396
+ _sourceTouches(source, changed) {
2397
+ if (source == null || typeof source !== "object") return false;
2398
+ const s2 = source;
2399
+ if (s2.type === "custom") return true;
2400
+ if (typeof s2.table === "string" && changed.has(s2.table)) return true;
2401
+ if (typeof s2.junctionTable === "string" && changed.has(s2.junctionTable)) return true;
2402
+ if (s2.sources != null && typeof s2.sources === "object") {
2403
+ for (const sub of Object.values(s2.sources)) {
2404
+ if (this._sourceTouches(sub, changed)) return true;
2405
+ }
2406
+ }
2407
+ return false;
2408
+ }
2367
2409
  async render(outputDir, opts = {}) {
2368
2410
  const start = Date.now();
2369
2411
  const filesWritten = [];
@@ -2373,6 +2415,7 @@ var init_engine = __esm({
2373
2415
  for (const [name, def] of this._schema.getTables()) {
2374
2416
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
2375
2417
  if (this._skipEmpty && def.render === NOOP_RENDER) continue;
2418
+ if (opts.changedTables && !opts.changedTables.has(name)) continue;
2376
2419
  let rows = await this._schema.queryTable(this._adapter, name, this._readRel);
2377
2420
  if (def.relevanceFilter) {
2378
2421
  const ctx = this._getTaskContext();
@@ -2425,6 +2468,9 @@ var init_engine = __esm({
2425
2468
  }
2426
2469
  for (const [name, def] of this._schema.getMultis()) {
2427
2470
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
2471
+ if (opts.changedTables && def.tables && !def.tables.some((t8) => opts.changedTables?.has(t8))) {
2472
+ continue;
2473
+ }
2428
2474
  const keys = await def.keys();
2429
2475
  const tables = {};
2430
2476
  if (def.tables) {
@@ -2456,16 +2502,22 @@ var init_engine = __esm({
2456
2502
  filesWritten,
2457
2503
  counters,
2458
2504
  throttle,
2459
- signal
2505
+ signal,
2506
+ opts.changedTables
2460
2507
  );
2461
2508
  if (entityContextManifest === null) {
2462
2509
  return this._abortedResult(filesWritten, counters, start);
2463
2510
  }
2464
2511
  if (this._schema.getEntityContexts().size > 0) {
2512
+ let entityContexts = entityContextManifest;
2513
+ if (opts.changedTables) {
2514
+ const prev = readManifest(outputDir);
2515
+ entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
2516
+ }
2465
2517
  writeManifest(outputDir, {
2466
2518
  version: 2,
2467
2519
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2468
- entityContexts: entityContextManifest
2520
+ entityContexts
2469
2521
  });
2470
2522
  }
2471
2523
  const result = {
@@ -2531,12 +2583,14 @@ var init_engine = __esm({
2531
2583
  * partial tree). Progress is reported through `throttle`; abort is observed
2532
2584
  * via `signal`.
2533
2585
  */
2534
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
2586
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
2535
2587
  const protectedTables = /* @__PURE__ */ new Set();
2536
2588
  for (const [t8, d6] of this._schema.getEntityContexts()) {
2537
2589
  if (d6.protected) protectedTables.add(t8);
2538
2590
  }
2539
- const entityTables = [...this._schema.getEntityContexts()];
2591
+ const entityTables = [...this._schema.getEntityContexts()].filter(
2592
+ ([table, def]) => !changedTables || this._entityAffected(table, def, changedTables)
2593
+ );
2540
2594
  const tableCount = entityTables.length;
2541
2595
  if (signal?.aborted) return null;
2542
2596
  const renderedEntries = await mapWithConcurrency(
@@ -5236,6 +5290,16 @@ var init_lattice = __esm({
5236
5290
  _autoRenderPending = false;
5237
5291
  _autoRenderInFlight = false;
5238
5292
  _autoRenderDebounceMs = 250;
5293
+ /**
5294
+ * Incremental auto-render scope, accumulated between debounced renders. A write
5295
+ * or a remote (cloud) change records the AFFECTED table here, so the next
5296
+ * auto-render re-renders only that entity (+ its cross-table dependents) instead
5297
+ * of the whole tree. `_pendingRenderAll` forces a full render (the initial
5298
+ * render, or a change with no known table). Captured + reset when a render
5299
+ * starts, so changes during a render re-accumulate and re-trigger.
5300
+ */
5301
+ _pendingRenderTables = /* @__PURE__ */ new Set();
5302
+ _pendingRenderAll = true;
5239
5303
  /** Cache of actual table columns (from PRAGMA), populated after init(). */
5240
5304
  _columnCache = /* @__PURE__ */ new Map();
5241
5305
  /** Derived encryption key (from options.encryptionKey via scrypt). */
@@ -6331,7 +6395,7 @@ var init_lattice = __esm({
6331
6395
  `${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`,
6332
6396
  Object.values(filtered)
6333
6397
  );
6334
- this._scheduleAutoRender();
6398
+ this._scheduleAutoRender(junctionTable);
6335
6399
  }
6336
6400
  /**
6337
6401
  * Delete rows from a junction table matching all given conditions.
@@ -6347,7 +6411,7 @@ var init_lattice = __esm({
6347
6411
  `DELETE FROM "${junctionTable}" WHERE ${where}`,
6348
6412
  entries.map(([, v2]) => v2)
6349
6413
  );
6350
- this._scheduleAutoRender();
6414
+ this._scheduleAutoRender(junctionTable);
6351
6415
  }
6352
6416
  // -------------------------------------------------------------------------
6353
6417
  // Seeding DSL (v0.13+)
@@ -6603,6 +6667,11 @@ var init_lattice = __esm({
6603
6667
  async renderInBackground(outputDir, opts = {}) {
6604
6668
  const notInit = this._notInitError();
6605
6669
  if (notInit) return notInit;
6670
+ if (!opts.changedTables) {
6671
+ this._pendingRenderAll = false;
6672
+ this._pendingRenderTables = /* @__PURE__ */ new Set();
6673
+ this._autoRenderPending = false;
6674
+ }
6606
6675
  return this._renderGuarded(outputDir, opts);
6607
6676
  }
6608
6677
  /**
@@ -6632,9 +6701,12 @@ var init_lattice = __esm({
6632
6701
  * tree when a REMOTE change arrives — notably an owner re-sharing or un-sharing
6633
6702
  * a row, after which the member's per-viewer projection must be recompiled. A
6634
6703
  * no-op when auto-render isn't enabled.
6704
+ *
6705
+ * Pass the CHANGED table so only that entity (+ its cross-table dependents) is
6706
+ * re-rendered instead of the whole tree; omit it to force a full render.
6635
6707
  */
6636
- requestRender() {
6637
- this._scheduleAutoRender();
6708
+ requestRender(table) {
6709
+ this._scheduleAutoRender(table);
6638
6710
  }
6639
6711
  /**
6640
6712
  * True while a render is actively writing the context tree + manifest (auto-
@@ -7035,7 +7107,7 @@ var init_lattice = __esm({
7035
7107
  for (const h6 of this._errorHandlers) h6(err instanceof Error ? err : new Error(String(err)));
7036
7108
  }
7037
7109
  }
7038
- this._scheduleAutoRender();
7110
+ this._scheduleAutoRender(table);
7039
7111
  }
7040
7112
  /**
7041
7113
  * Turn on automatic rendering into `outputDir`. After this, every insert /
@@ -7059,10 +7131,18 @@ var init_lattice = __esm({
7059
7131
  this._autoRenderPending = false;
7060
7132
  return this;
7061
7133
  }
7062
- _scheduleAutoRender() {
7134
+ _scheduleAutoRender(table) {
7063
7135
  if (!this._autoRenderDir) return;
7136
+ if (table === void 0) this._pendingRenderAll = true;
7137
+ else this._pendingRenderTables.add(table);
7064
7138
  this._autoRenderPending = true;
7065
- if (this._autoRenderTimer) return;
7139
+ this._armAutoRenderTimer();
7140
+ }
7141
+ /** Arm the debounce timer if not already armed. Does NOT change the render
7142
+ * scope — used both by `_scheduleAutoRender` and the post-render re-arm so a
7143
+ * re-arm never escalates a pending incremental render to a full one. */
7144
+ _armAutoRenderTimer() {
7145
+ if (!this._autoRenderDir || this._autoRenderTimer) return;
7066
7146
  this._autoRenderTimer = setTimeout(() => {
7067
7147
  this._autoRenderTimer = void 0;
7068
7148
  void this._runAutoRender();
@@ -7102,10 +7182,15 @@ var init_lattice = __esm({
7102
7182
  }
7103
7183
  if (!this._autoRenderPending) return;
7104
7184
  this._autoRenderPending = false;
7185
+ const renderAll = this._pendingRenderAll;
7186
+ const changed = this._pendingRenderTables;
7187
+ this._pendingRenderAll = false;
7188
+ this._pendingRenderTables = /* @__PURE__ */ new Set();
7105
7189
  this._autoRenderInFlight = true;
7106
7190
  try {
7107
7191
  const prevManifest = readManifest(dir);
7108
- const result = await this._render.render(dir);
7192
+ const renderOpts = renderAll || changed.size === 0 ? {} : { changedTables: changed };
7193
+ const result = await this._render.render(dir, renderOpts);
7109
7194
  for (const h6 of this._renderHandlers) h6(result);
7110
7195
  const newManifest = readManifest(dir);
7111
7196
  await this._render.cleanup(dir, prevManifest, {}, newManifest);
@@ -7117,7 +7202,7 @@ var init_lattice = __esm({
7117
7202
  }
7118
7203
  }
7119
7204
  _rearmAutoRenderIfPending() {
7120
- if (this._autoRenderPending) this._scheduleAutoRender();
7205
+ if (this._autoRenderPending) this._armAutoRenderTimer();
7121
7206
  }
7122
7207
  /**
7123
7208
  * Update or remove the embedding for a row.
@@ -47282,6 +47367,17 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
47282
47367
  FOR EACH ROW EXECUTE FUNCTION "${trg}"();
47283
47368
  `;
47284
47369
  }
47370
+ async function ownPolyfillsByGroup(db) {
47371
+ if (!isPg(db)) return;
47372
+ for (const sig of ["json_extract(text, text)", "strftime(text, text)"]) {
47373
+ try {
47374
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regprocedure($1) AS reg`, [sig]);
47375
+ if (reg?.reg == null) continue;
47376
+ await runAsyncOrSync(db.adapter, `ALTER FUNCTION ${sig} OWNER TO "${MEMBER_GROUP}"`);
47377
+ } catch {
47378
+ }
47379
+ }
47380
+ }
47285
47381
  async function installCloudRls(db) {
47286
47382
  if (!isPg(db)) return;
47287
47383
  const schema = await cloudSchema(db);
@@ -47315,6 +47411,24 @@ CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH C
47315
47411
  `
47316
47412
  );
47317
47413
  }
47414
+ async function enableChatPrivacyRls(db) {
47415
+ if (!isPg(db)) return;
47416
+ for (const t8 of ["chat_threads", "chat_messages"]) {
47417
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [t8]);
47418
+ if (reg?.reg == null) continue;
47419
+ const q3 = `"${t8}"`;
47420
+ await runCloudBootstrapSql(
47421
+ db,
47422
+ `
47423
+ ALTER TABLE ${q3} ENABLE ROW LEVEL SECURITY;
47424
+ ALTER TABLE ${q3} FORCE ROW LEVEL SECURITY;
47425
+ DROP POLICY IF EXISTS "lattice_chat_owner" ON ${q3};
47426
+ CREATE POLICY "lattice_chat_owner" ON ${q3} AS RESTRICTIVE FOR SELECT
47427
+ USING ("owner_user_id" IS NOT NULL AND "owner_user_id" = session_user);
47428
+ `
47429
+ );
47430
+ }
47431
+ }
47318
47432
  async function enableRlsForTable(db, table, pkCols) {
47319
47433
  if (!isPg(db)) return;
47320
47434
  const schema = await cloudSchema(db);
@@ -54680,9 +54794,11 @@ async function secureCloud(db) {
54680
54794
  if (db.getDialect() !== "postgres") return;
54681
54795
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
54682
54796
  await installCloudRls(db);
54797
+ await ownPolyfillsByGroup(db);
54683
54798
  await installCloudSettings(db);
54684
54799
  await db.ensureObservationSubstrate();
54685
54800
  await enableChangelogRls(db);
54801
+ await enableChatPrivacyRls(db);
54686
54802
  await convergeLegacyColumnAudience(db);
54687
54803
  const registered = db.getRegisteredTableNames();
54688
54804
  for (const table of registered) {
@@ -57728,12 +57844,17 @@ var appJs = `
57728
57844
  fetchJson('/api/gui-meta/columns').catch(function () { return {}; }),
57729
57845
  fetchJson('/api/system-tables').catch(function () { return { tables: [] }; }),
57730
57846
  fetchJson('/api/workspaces').catch(function () { return null; }),
57847
+ fetchJson('/api/dbconfig').catch(function () { return {}; }),
57731
57848
  ]).then(function (results) {
57732
57849
  state.entities = results[0];
57733
57850
  state.iconOverrides = results[1] || {};
57734
57851
  state.columnMeta = results[2] || {};
57735
57852
  state.systemTables = (results[3] && results[3].tables) || [];
57736
57853
  renderWsSwitcher(results[4]);
57854
+ // Re-point the header logo at the NEW workspace's mark \u2014 the switch path
57855
+ // must refresh branding the way boot does (the etag cache-busts the
57856
+ // <img>), else the previous workspace's logo stays until a hard refresh.
57857
+ applyWorkspaceLogo((results[5] || {}).logoEtag);
57737
57858
  renderSidebar();
57738
57859
  // renderWsSwitcher set cloudMode from the new workspace's kind; re-render
57739
57860
  // the composer so the Private-mode toggle reflects local vs cloud (it is
@@ -66384,6 +66505,7 @@ function activeCloudCoords(configPath) {
66384
66505
  init_assistant_routes();
66385
66506
 
66386
66507
  // src/gui/chat-routes.ts
66508
+ init_adapter();
66387
66509
  init_assistant_routes();
66388
66510
  init_chat();
66389
66511
  init_user_config();
@@ -66409,6 +66531,15 @@ function sendJson3(res, body, status = 200) {
66409
66531
  function asStr(v2, fallback = "") {
66410
66532
  return typeof v2 === "string" ? v2 : fallback;
66411
66533
  }
66534
+ function isCloudChat(db) {
66535
+ return db.getDialect() === "postgres";
66536
+ }
66537
+ async function resolveChatOwnerId(db) {
66538
+ if (!isCloudChat(db)) return null;
66539
+ const row = await getAsyncOrSync(db.adapter, "SELECT session_user AS u");
66540
+ const u2 = row?.u;
66541
+ return typeof u2 === "string" && u2.length > 0 ? u2 : null;
66542
+ }
66412
66543
  function readJson3(req) {
66413
66544
  return new Promise((resolve12, reject) => {
66414
66545
  let raw = "";
@@ -66601,12 +66732,20 @@ async function persistMessage(db, threadId, role, text, ownerUserId, turns, star
66601
66732
  });
66602
66733
  }
66603
66734
  async function dispatchChatRoute(req, res, ctx) {
66735
+ const ownerUserId = await resolveChatOwnerId(ctx.db);
66736
+ const cloud = isCloudChat(ctx.db);
66737
+ const ownedByMe = (r6) => !cloud || r6.owner_user_id != null && r6.owner_user_id === ownerUserId;
66604
66738
  if (ctx.method === "GET" && ctx.pathname === "/api/chat/threads") {
66739
+ if (cloud && ownerUserId == null) {
66740
+ sendJson3(res, { threads: [] });
66741
+ return true;
66742
+ }
66605
66743
  const filters = [
66606
66744
  { col: "deleted_at", op: "isNull" }
66607
66745
  ];
66746
+ if (ownerUserId != null) filters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
66608
66747
  const rows = await ctx.db.query("chat_threads", { filters, limit: 100 });
66609
- const threads = rows.filter((r6) => !r6.deleted_at).map((r6) => ({
66748
+ const threads = rows.filter((r6) => !r6.deleted_at && ownedByMe(r6)).map((r6) => ({
66610
66749
  id: asStr(r6.id),
66611
66750
  title: asStr(r6.title, "Chat"),
66612
66751
  created_at: asStr(r6.created_at)
@@ -66617,12 +66756,19 @@ async function dispatchChatRoute(req, res, ctx) {
66617
66756
  const msgMatch = /^\/api\/chat\/threads\/([^/]+)\/messages$/.exec(ctx.pathname);
66618
66757
  if (ctx.method === "GET" && msgMatch) {
66619
66758
  const threadId2 = decodeURIComponent(msgMatch[1] ?? "");
66620
- const msgFilters = [{ col: "thread_id", op: "eq", val: threadId2 }];
66759
+ if (cloud && ownerUserId == null) {
66760
+ sendJson3(res, { messages: [] });
66761
+ return true;
66762
+ }
66763
+ const msgFilters = [
66764
+ { col: "thread_id", op: "eq", val: threadId2 }
66765
+ ];
66766
+ if (ownerUserId != null) msgFilters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
66621
66767
  const rows = await ctx.db.query("chat_messages", {
66622
66768
  filters: msgFilters,
66623
66769
  limit: 1e3
66624
66770
  });
66625
- const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at).map((r6) => {
66771
+ const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at && ownedByMe(r6)).map((r6) => {
66626
66772
  let text = "";
66627
66773
  let turns2;
66628
66774
  let startedAt;
@@ -66675,12 +66821,21 @@ async function dispatchChatRoute(req, res, ctx) {
66675
66821
  return true;
66676
66822
  }
66677
66823
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
66824
+ if (cloud && ownerUserId == null) {
66825
+ sendJson3(res, { error: "Could not resolve your cloud identity; chat is disabled." }, 500);
66826
+ return true;
66827
+ }
66678
66828
  const activeContext = parseActiveContext(body.activeContext, ctx.validTables);
66679
- const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), null);
66829
+ const history = await rehydrateHistory(
66830
+ ctx.db,
66831
+ requestedThread,
66832
+ mapHistory(body.history),
66833
+ ownerUserId
66834
+ );
66680
66835
  let threadId = "";
66681
66836
  try {
66682
- threadId = await ensureThread(ctx.db, requestedThread, message, null);
66683
- await persistMessage(ctx.db, threadId, "user", message, null);
66837
+ threadId = await ensureThread(ctx.db, requestedThread, message, ownerUserId);
66838
+ await persistMessage(ctx.db, threadId, "user", message, ownerUserId);
66684
66839
  } catch (e6) {
66685
66840
  console.warn("[chat] persist user message failed:", e6.message);
66686
66841
  }
@@ -66752,7 +66907,7 @@ async function dispatchChatRoute(req, res, ctx) {
66752
66907
  threadId,
66753
66908
  "assistant",
66754
66909
  assistantText,
66755
- null,
66910
+ ownerUserId,
66756
66911
  cleanTurns,
66757
66912
  turnStartedAt,
66758
66913
  assistantMsgId
@@ -67751,9 +67906,11 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
67751
67906
  if (await cloudRlsInstalled(db) && await canManageRoles(db)) {
67752
67907
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
67753
67908
  await installCloudRls(db);
67909
+ await ownPolyfillsByGroup(db);
67754
67910
  await installCloudSettings(db);
67755
67911
  await db.ensureObservationSubstrate();
67756
67912
  await enableChangelogRls(db);
67913
+ await enableChatPrivacyRls(db);
67757
67914
  const access = await reconcileCloudMemberAccess(db);
67758
67915
  convergeWarnings = access.skipped;
67759
67916
  for (const s2 of convergeWarnings) {
@@ -67915,11 +68072,22 @@ function startBackgroundRender(active) {
67915
68072
  active.eagerRenderWired = true;
67916
68073
  let lastFire = 0;
67917
68074
  let trailing;
68075
+ const pendingTables = /* @__PURE__ */ new Set();
68076
+ let pendingFull = false;
67918
68077
  const fire = () => {
67919
68078
  lastFire = Date.now();
67920
- active.db.requestRender();
68079
+ if (pendingFull || pendingTables.size === 0) {
68080
+ pendingFull = false;
68081
+ pendingTables.clear();
68082
+ active.db.requestRender();
68083
+ return;
68084
+ }
68085
+ for (const t8 of pendingTables) active.db.requestRender(t8);
68086
+ pendingTables.clear();
67921
68087
  };
67922
- active.realtime.subscribePayload(() => {
68088
+ active.realtime.subscribePayload((payload) => {
68089
+ if (payload.table_name) pendingTables.add(payload.table_name);
68090
+ else pendingFull = true;
67923
68091
  const since = Date.now() - lastFire;
67924
68092
  if (since >= EAGER_RERENDER_MIN_INTERVAL_MS) {
67925
68093
  fire();
package/dist/index.d.cts CHANGED
@@ -1816,6 +1816,16 @@ type RenderProgressCallback = (event: RenderProgress) => void;
1816
1816
  interface RenderOptions {
1817
1817
  onProgress?: RenderProgressCallback;
1818
1818
  signal?: AbortSignal;
1819
+ /**
1820
+ * Incremental render scope. When set, ONLY the entity-context tables affected
1821
+ * by a change to one of these tables are re-rendered — the changed table itself
1822
+ * plus any entity context that SOURCES from it (cross-table dependents) — and
1823
+ * every other table's manifest entry + rendered files are left untouched. Used
1824
+ * by the auto-render that fires on a single write or a single remote (cloud)
1825
+ * change, so a one-row edit re-renders one entity instead of the whole tree.
1826
+ * Omitted → a full render of everything (initial open, explicit `render()`).
1827
+ */
1828
+ changedTables?: ReadonlySet<string>;
1819
1829
  }
1820
1830
  /**
1821
1831
  * Coalesces high-frequency `table-progress` events down to ≤ ~5/sec per table,
@@ -1903,6 +1913,16 @@ declare class Lattice {
1903
1913
  private _autoRenderPending;
1904
1914
  private _autoRenderInFlight;
1905
1915
  private _autoRenderDebounceMs;
1916
+ /**
1917
+ * Incremental auto-render scope, accumulated between debounced renders. A write
1918
+ * or a remote (cloud) change records the AFFECTED table here, so the next
1919
+ * auto-render re-renders only that entity (+ its cross-table dependents) instead
1920
+ * of the whole tree. `_pendingRenderAll` forces a full render (the initial
1921
+ * render, or a change with no known table). Captured + reset when a render
1922
+ * starts, so changes during a render re-accumulate and re-trigger.
1923
+ */
1924
+ private _pendingRenderTables;
1925
+ private _pendingRenderAll;
1906
1926
  /** Cache of actual table columns (from PRAGMA), populated after init(). */
1907
1927
  private readonly _columnCache;
1908
1928
  /** Derived encryption key (from options.encryptionKey via scrypt). */
@@ -2323,8 +2343,11 @@ declare class Lattice {
2323
2343
  * tree when a REMOTE change arrives — notably an owner re-sharing or un-sharing
2324
2344
  * a row, after which the member's per-viewer projection must be recompiled. A
2325
2345
  * no-op when auto-render isn't enabled.
2346
+ *
2347
+ * Pass the CHANGED table so only that entity (+ its cross-table dependents) is
2348
+ * re-rendered instead of the whole tree; omit it to force a full render.
2326
2349
  */
2327
- requestRender(): void;
2350
+ requestRender(table?: string): void;
2328
2351
  /**
2329
2352
  * True while a render is actively writing the context tree + manifest (auto-
2330
2353
  * render OR a guarded background render). The file-loopback watcher checks this
@@ -2483,6 +2506,10 @@ declare class Lattice {
2483
2506
  /** Turn off automatic rendering and cancel any pending render. */
2484
2507
  disableAutoRender(): this;
2485
2508
  private _scheduleAutoRender;
2509
+ /** Arm the debounce timer if not already armed. Does NOT change the render
2510
+ * scope — used both by `_scheduleAutoRender` and the post-render re-arm so a
2511
+ * re-arm never escalates a pending incremental render to a full one. */
2512
+ private _armAutoRenderTimer;
2486
2513
  /**
2487
2514
  * Shared single-flight render path used by {@link renderInBackground}.
2488
2515
  *
package/dist/index.d.ts CHANGED
@@ -1816,6 +1816,16 @@ type RenderProgressCallback = (event: RenderProgress) => void;
1816
1816
  interface RenderOptions {
1817
1817
  onProgress?: RenderProgressCallback;
1818
1818
  signal?: AbortSignal;
1819
+ /**
1820
+ * Incremental render scope. When set, ONLY the entity-context tables affected
1821
+ * by a change to one of these tables are re-rendered — the changed table itself
1822
+ * plus any entity context that SOURCES from it (cross-table dependents) — and
1823
+ * every other table's manifest entry + rendered files are left untouched. Used
1824
+ * by the auto-render that fires on a single write or a single remote (cloud)
1825
+ * change, so a one-row edit re-renders one entity instead of the whole tree.
1826
+ * Omitted → a full render of everything (initial open, explicit `render()`).
1827
+ */
1828
+ changedTables?: ReadonlySet<string>;
1819
1829
  }
1820
1830
  /**
1821
1831
  * Coalesces high-frequency `table-progress` events down to ≤ ~5/sec per table,
@@ -1903,6 +1913,16 @@ declare class Lattice {
1903
1913
  private _autoRenderPending;
1904
1914
  private _autoRenderInFlight;
1905
1915
  private _autoRenderDebounceMs;
1916
+ /**
1917
+ * Incremental auto-render scope, accumulated between debounced renders. A write
1918
+ * or a remote (cloud) change records the AFFECTED table here, so the next
1919
+ * auto-render re-renders only that entity (+ its cross-table dependents) instead
1920
+ * of the whole tree. `_pendingRenderAll` forces a full render (the initial
1921
+ * render, or a change with no known table). Captured + reset when a render
1922
+ * starts, so changes during a render re-accumulate and re-trigger.
1923
+ */
1924
+ private _pendingRenderTables;
1925
+ private _pendingRenderAll;
1906
1926
  /** Cache of actual table columns (from PRAGMA), populated after init(). */
1907
1927
  private readonly _columnCache;
1908
1928
  /** Derived encryption key (from options.encryptionKey via scrypt). */
@@ -2323,8 +2343,11 @@ declare class Lattice {
2323
2343
  * tree when a REMOTE change arrives — notably an owner re-sharing or un-sharing
2324
2344
  * a row, after which the member's per-viewer projection must be recompiled. A
2325
2345
  * no-op when auto-render isn't enabled.
2346
+ *
2347
+ * Pass the CHANGED table so only that entity (+ its cross-table dependents) is
2348
+ * re-rendered instead of the whole tree; omit it to force a full render.
2326
2349
  */
2327
- requestRender(): void;
2350
+ requestRender(table?: string): void;
2328
2351
  /**
2329
2352
  * True while a render is actively writing the context tree + manifest (auto-
2330
2353
  * render OR a guarded background render). The file-loopback watcher checks this
@@ -2483,6 +2506,10 @@ declare class Lattice {
2483
2506
  /** Turn off automatic rendering and cancel any pending render. */
2484
2507
  disableAutoRender(): this;
2485
2508
  private _scheduleAutoRender;
2509
+ /** Arm the debounce timer if not already armed. Does NOT change the render
2510
+ * scope — used both by `_scheduleAutoRender` and the post-render re-arm so a
2511
+ * re-arm never escalates a pending incremental render to a full one. */
2512
+ private _armAutoRenderTimer;
2486
2513
  /**
2487
2514
  * Shared single-flight render path used by {@link renderInBackground}.
2488
2515
  *