latticesql 3.4.0 → 3.4.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/index.js CHANGED
@@ -908,43 +908,57 @@ var init_postgres = __esm({
908
908
  },
909
909
  {
910
910
  warn: "could not register json_extract polyfill:",
911
- sql: `CREATE OR REPLACE FUNCTION json_extract(doc text, path text)
912
- RETURNS text
913
- LANGUAGE sql
914
- IMMUTABLE
915
- AS $fn$
916
- SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
917
- $fn$;`
911
+ // Create ONLY if absent. `CREATE OR REPLACE` on an existing function requires
912
+ // ownership, but on a cloud the function is owned by whichever single role
913
+ // created it first — so every OTHER member's per-connect replace raised "must
914
+ // be owner of function" and (sharing the render transaction) aborted it,
915
+ // yielding an empty render. The IF-absent guard makes a present function a
916
+ // clean no-op for everyone, regardless of who owns it.
917
+ sql: `DO $do$ BEGIN
918
+ IF to_regprocedure('json_extract(text, text)') IS NULL THEN
919
+ CREATE FUNCTION json_extract(doc text, path text)
920
+ RETURNS text
921
+ LANGUAGE sql
922
+ IMMUTABLE
923
+ AS $fn$
924
+ SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
925
+ $fn$;
926
+ END IF;
927
+ END $do$;`
918
928
  },
919
929
  {
920
930
  warn: "could not register strftime polyfill:",
921
- sql: `CREATE OR REPLACE FUNCTION strftime(format text, modifier text)
922
- RETURNS text
923
- LANGUAGE plpgsql
924
- IMMUTABLE
925
- AS $fn$
926
- DECLARE ts timestamptz;
927
- BEGIN
928
- IF modifier = 'now' THEN
929
- ts := now();
930
- ELSE
931
- ts := modifier::timestamptz;
932
- END IF;
933
- RETURN to_char(
934
- ts AT TIME ZONE 'UTC',
935
- replace(replace(replace(replace(replace(replace(replace(replace(
936
- format,
937
- '%Y', 'YYYY'),
938
- '%m', 'MM'),
939
- '%d', 'DD'),
940
- '%H', 'HH24'),
941
- '%M', 'MI'),
942
- '%S', 'SS'),
943
- '%f', 'MS'),
944
- 'T', '"T"')
945
- );
946
- END;
947
- $fn$;`
931
+ sql: `DO $do$ BEGIN
932
+ IF to_regprocedure('strftime(text, text)') IS NULL THEN
933
+ CREATE FUNCTION strftime(format text, modifier text)
934
+ RETURNS text
935
+ LANGUAGE plpgsql
936
+ IMMUTABLE
937
+ AS $fn$
938
+ DECLARE ts timestamptz;
939
+ BEGIN
940
+ IF modifier = 'now' THEN
941
+ ts := now();
942
+ ELSE
943
+ ts := modifier::timestamptz;
944
+ END IF;
945
+ RETURN to_char(
946
+ ts AT TIME ZONE 'UTC',
947
+ replace(replace(replace(replace(replace(replace(replace(replace(
948
+ format,
949
+ '%Y', 'YYYY'),
950
+ '%m', 'MM'),
951
+ '%d', 'DD'),
952
+ '%H', 'HH24'),
953
+ '%M', 'MI'),
954
+ '%S', 'SS'),
955
+ '%f', 'MS'),
956
+ 'T', '"T"')
957
+ );
958
+ END;
959
+ $fn$;
960
+ END IF;
961
+ END $do$;`
948
962
  }
949
963
  ];
950
964
  }
@@ -2352,6 +2366,34 @@ var init_engine = __esm({
2352
2366
  setRenderFold(fn) {
2353
2367
  this._foldRows = fn;
2354
2368
  }
2369
+ /**
2370
+ * Incremental scope: is this entity-context table affected by a change to one
2371
+ * of `changed`? Affected when the table itself changed (its own rows / `self`
2372
+ * source / index) OR any of its files SOURCES from a changed table (a cross-
2373
+ * table dependent — e.g. an AGENT.md that lists the agent's tasks must re-render
2374
+ * when `tasks` changes). A `custom` source runs an arbitrary query, so we can't
2375
+ * prove independence — treat it as always-affected (conservative, never stale).
2376
+ */
2377
+ _entityAffected(table, def, changed) {
2378
+ if (changed.has(table)) return true;
2379
+ for (const spec of Object.values(def.files)) {
2380
+ if (this._sourceTouches(spec.source, changed)) return true;
2381
+ }
2382
+ return false;
2383
+ }
2384
+ _sourceTouches(source, changed) {
2385
+ if (source == null || typeof source !== "object") return false;
2386
+ const s2 = source;
2387
+ if (s2.type === "custom") return true;
2388
+ if (typeof s2.table === "string" && changed.has(s2.table)) return true;
2389
+ if (typeof s2.junctionTable === "string" && changed.has(s2.junctionTable)) return true;
2390
+ if (s2.sources != null && typeof s2.sources === "object") {
2391
+ for (const sub of Object.values(s2.sources)) {
2392
+ if (this._sourceTouches(sub, changed)) return true;
2393
+ }
2394
+ }
2395
+ return false;
2396
+ }
2355
2397
  async render(outputDir, opts = {}) {
2356
2398
  const start = Date.now();
2357
2399
  const filesWritten = [];
@@ -2361,6 +2403,7 @@ var init_engine = __esm({
2361
2403
  for (const [name, def] of this._schema.getTables()) {
2362
2404
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
2363
2405
  if (this._skipEmpty && def.render === NOOP_RENDER) continue;
2406
+ if (opts.changedTables && !opts.changedTables.has(name)) continue;
2364
2407
  let rows = await this._schema.queryTable(this._adapter, name, this._readRel);
2365
2408
  if (def.relevanceFilter) {
2366
2409
  const ctx = this._getTaskContext();
@@ -2413,6 +2456,9 @@ var init_engine = __esm({
2413
2456
  }
2414
2457
  for (const [name, def] of this._schema.getMultis()) {
2415
2458
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
2459
+ if (opts.changedTables && def.tables && !def.tables.some((t8) => opts.changedTables?.has(t8))) {
2460
+ continue;
2461
+ }
2416
2462
  const keys = await def.keys();
2417
2463
  const tables = {};
2418
2464
  if (def.tables) {
@@ -2444,16 +2490,22 @@ var init_engine = __esm({
2444
2490
  filesWritten,
2445
2491
  counters,
2446
2492
  throttle,
2447
- signal
2493
+ signal,
2494
+ opts.changedTables
2448
2495
  );
2449
2496
  if (entityContextManifest === null) {
2450
2497
  return this._abortedResult(filesWritten, counters, start);
2451
2498
  }
2452
2499
  if (this._schema.getEntityContexts().size > 0) {
2500
+ let entityContexts = entityContextManifest;
2501
+ if (opts.changedTables) {
2502
+ const prev = readManifest(outputDir);
2503
+ entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
2504
+ }
2453
2505
  writeManifest(outputDir, {
2454
2506
  version: 2,
2455
2507
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2456
- entityContexts: entityContextManifest
2508
+ entityContexts
2457
2509
  });
2458
2510
  }
2459
2511
  const result = {
@@ -2519,12 +2571,14 @@ var init_engine = __esm({
2519
2571
  * partial tree). Progress is reported through `throttle`; abort is observed
2520
2572
  * via `signal`.
2521
2573
  */
2522
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
2574
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
2523
2575
  const protectedTables = /* @__PURE__ */ new Set();
2524
2576
  for (const [t8, d6] of this._schema.getEntityContexts()) {
2525
2577
  if (d6.protected) protectedTables.add(t8);
2526
2578
  }
2527
- const entityTables = [...this._schema.getEntityContexts()];
2579
+ const entityTables = [...this._schema.getEntityContexts()].filter(
2580
+ ([table, def]) => !changedTables || this._entityAffected(table, def, changedTables)
2581
+ );
2528
2582
  const tableCount = entityTables.length;
2529
2583
  if (signal?.aborted) return null;
2530
2584
  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.
@@ -47275,6 +47360,44 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
47275
47360
  FOR EACH ROW EXECUTE FUNCTION "${trg}"();
47276
47361
  `;
47277
47362
  }
47363
+ async function ownPolyfillsByGroup(db) {
47364
+ if (!isPg(db)) return;
47365
+ for (const sig of ["json_extract(text, text)", "strftime(text, text)"]) {
47366
+ try {
47367
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regprocedure($1) AS reg`, [sig]);
47368
+ if (reg?.reg == null) continue;
47369
+ await runAsyncOrSync(db.adapter, `ALTER FUNCTION ${sig} OWNER TO "${MEMBER_GROUP}"`);
47370
+ } catch {
47371
+ }
47372
+ }
47373
+ }
47374
+ async function enableGuiAuditRls(db) {
47375
+ if (!isPg(db)) return;
47376
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [
47377
+ "_lattice_gui_audit"
47378
+ ]);
47379
+ if (reg?.reg == null) return;
47380
+ await runCloudBootstrapSql(
47381
+ db,
47382
+ `
47383
+ ALTER TABLE "_lattice_gui_audit" ENABLE ROW LEVEL SECURITY;
47384
+ ALTER TABLE "_lattice_gui_audit" FORCE ROW LEVEL SECURITY;
47385
+ DROP POLICY IF EXISTS "lattice_gui_audit_owner" ON "_lattice_gui_audit";
47386
+ DROP POLICY IF EXISTS "lattice_gui_audit_sel" ON "_lattice_gui_audit";
47387
+ CREATE POLICY "lattice_gui_audit_sel" ON "_lattice_gui_audit" FOR SELECT
47388
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47389
+ DROP POLICY IF EXISTS "lattice_gui_audit_ins" ON "_lattice_gui_audit";
47390
+ CREATE POLICY "lattice_gui_audit_ins" ON "_lattice_gui_audit" FOR INSERT
47391
+ WITH CHECK ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47392
+ DROP POLICY IF EXISTS "lattice_gui_audit_upd" ON "_lattice_gui_audit";
47393
+ CREATE POLICY "lattice_gui_audit_upd" ON "_lattice_gui_audit" FOR UPDATE
47394
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47395
+ DROP POLICY IF EXISTS "lattice_gui_audit_del" ON "_lattice_gui_audit";
47396
+ CREATE POLICY "lattice_gui_audit_del" ON "_lattice_gui_audit" FOR DELETE
47397
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47398
+ `
47399
+ );
47400
+ }
47278
47401
  async function installCloudRls(db) {
47279
47402
  if (!isPg(db)) return;
47280
47403
  const schema = await cloudSchema(db);
@@ -47308,6 +47431,24 @@ CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH C
47308
47431
  `
47309
47432
  );
47310
47433
  }
47434
+ async function enableChatPrivacyRls(db) {
47435
+ if (!isPg(db)) return;
47436
+ for (const t8 of ["chat_threads", "chat_messages"]) {
47437
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [t8]);
47438
+ if (reg?.reg == null) continue;
47439
+ const q3 = `"${t8}"`;
47440
+ await runCloudBootstrapSql(
47441
+ db,
47442
+ `
47443
+ ALTER TABLE ${q3} ENABLE ROW LEVEL SECURITY;
47444
+ ALTER TABLE ${q3} FORCE ROW LEVEL SECURITY;
47445
+ DROP POLICY IF EXISTS "lattice_chat_owner" ON ${q3};
47446
+ CREATE POLICY "lattice_chat_owner" ON ${q3} AS RESTRICTIVE FOR SELECT
47447
+ USING ("owner_user_id" IS NOT NULL AND "owner_user_id" = session_user);
47448
+ `
47449
+ );
47450
+ }
47451
+ }
47311
47452
  async function enableRlsForTable(db, table, pkCols) {
47312
47453
  if (!isPg(db)) return;
47313
47454
  const schema = await cloudSchema(db);
@@ -47422,6 +47563,18 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
47422
47563
  -- bootstrap is now run directly + idempotently, not version-gated).
47423
47564
  ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
47424
47565
 
47566
+ -- Owner-published entity/render LAYOUT (the entities + entityContexts config
47567
+ -- blocks), so a joined member \u2014 whose generated config has entities: {} \u2014 can
47568
+ -- hydrate the full render layout and produce a complete context tree. This holds
47569
+ -- schema CONFIG, not row data, so it is safe to share with members (granted
47570
+ -- SELECT). A shared singleton, like __lattice_user_identity: no per-row RLS.
47571
+ CREATE TABLE IF NOT EXISTS "__lattice_shared_schema" (
47572
+ "id" TEXT PRIMARY KEY DEFAULT 'singleton',
47573
+ "entities_json" TEXT,
47574
+ "contexts_json" TEXT,
47575
+ "updated_at" TEXT
47576
+ );
47577
+
47425
47578
  -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
47426
47579
  -- the cloud with their minted credential, the join path calls this to CLAIM the
47427
47580
  -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
@@ -50338,7 +50491,7 @@ var init_registry = __esm({
50338
50491
  });
50339
50492
 
50340
50493
  // src/gui/ai/lattice-docs.ts
50341
- import { readFileSync as readFileSync16, readdirSync as readdirSync5, existsSync as existsSync20 } from "fs";
50494
+ import { readFileSync as readFileSync17, readdirSync as readdirSync5, existsSync as existsSync20 } from "fs";
50342
50495
  import { dirname as dirname8, join as join25 } from "path";
50343
50496
  import { fileURLToPath as fileURLToPath2 } from "url";
50344
50497
  function findDocsDir() {
@@ -50398,7 +50551,7 @@ function allSections() {
50398
50551
  }
50399
50552
  for (const f6 of files) {
50400
50553
  try {
50401
- out.push(...sectionsOf(f6, readFileSync16(join25(dir, f6), "utf8")));
50554
+ out.push(...sectionsOf(f6, readFileSync17(join25(dir, f6), "utf8")));
50402
50555
  } catch {
50403
50556
  }
50404
50557
  }
@@ -54336,7 +54489,7 @@ var MEMBER_READABLE_BOOKKEEPING = [
54336
54489
  {
54337
54490
  name: "_lattice_gui_audit",
54338
54491
  privs: "SELECT, INSERT",
54339
- why: "the member's own GUI undo/redo log (session-scoped)"
54492
+ why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
54340
54493
  },
54341
54494
  {
54342
54495
  name: "__lattice_user_identity",
@@ -54347,6 +54500,11 @@ var MEMBER_READABLE_BOOKKEEPING = [
54347
54500
  name: "__lattice_changelog",
54348
54501
  privs: "SELECT, INSERT",
54349
54502
  why: "per-viewer-RLS-filtered change history for observe()/history (the policy filters reads, so the base grant is safe)"
54503
+ },
54504
+ {
54505
+ name: "__lattice_shared_schema",
54506
+ privs: "SELECT",
54507
+ why: "owner-published entity/render layout (entities + entityContexts) a joined member hydrates its config from so render produces the full context tree"
54350
54508
  }
54351
54509
  ];
54352
54510
  var MEMBER_EXECUTE_FUNCTIONS = [
@@ -54498,9 +54656,12 @@ async function secureCloud(db) {
54498
54656
  if (db.getDialect() !== "postgres") return;
54499
54657
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
54500
54658
  await installCloudRls(db);
54659
+ await ownPolyfillsByGroup(db);
54501
54660
  await installCloudSettings(db);
54502
54661
  await db.ensureObservationSubstrate();
54503
54662
  await enableChangelogRls(db);
54663
+ await enableChatPrivacyRls(db);
54664
+ await enableGuiAuditRls(db);
54504
54665
  await convergeLegacyColumnAudience(db);
54505
54666
  const registered = db.getRegisteredTableNames();
54506
54667
  for (const table of registered) {
@@ -57553,12 +57714,17 @@ var appJs = `
57553
57714
  fetchJson('/api/gui-meta/columns').catch(function () { return {}; }),
57554
57715
  fetchJson('/api/system-tables').catch(function () { return { tables: [] }; }),
57555
57716
  fetchJson('/api/workspaces').catch(function () { return null; }),
57717
+ fetchJson('/api/dbconfig').catch(function () { return {}; }),
57556
57718
  ]).then(function (results) {
57557
57719
  state.entities = results[0];
57558
57720
  state.iconOverrides = results[1] || {};
57559
57721
  state.columnMeta = results[2] || {};
57560
57722
  state.systemTables = (results[3] && results[3].tables) || [];
57561
57723
  renderWsSwitcher(results[4]);
57724
+ // Re-point the header logo at the NEW workspace's mark \u2014 the switch path
57725
+ // must refresh branding the way boot does (the etag cache-busts the
57726
+ // <img>), else the previous workspace's logo stays until a hard refresh.
57727
+ applyWorkspaceLogo((results[5] || {}).logoEtag);
57562
57728
  renderSidebar();
57563
57729
  // renderWsSwitcher set cloudMode from the new workspace's kind; re-render
57564
57730
  // the composer so the Private-mode toggle reflects local vs cloud (it is
@@ -64455,6 +64621,87 @@ init_postgres();
64455
64621
  init_cloud_connect();
64456
64622
  init_rls();
64457
64623
 
64624
+ // src/cloud/shared-schema.ts
64625
+ init_lattice();
64626
+ init_adapter();
64627
+
64628
+ // src/gui/config-io.ts
64629
+ import { readFileSync as readFileSync16, writeFileSync as writeFileSync5 } from "fs";
64630
+ import { parseDocument as parseDocument2 } from "yaml";
64631
+ async function execSql(db, sql) {
64632
+ const adapter = db._adapter;
64633
+ if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64634
+ await adapter.runAsync(sql);
64635
+ }
64636
+ function loadConfigDoc(configPath) {
64637
+ return parseDocument2(readFileSync16(configPath, "utf8"));
64638
+ }
64639
+ function saveConfigDoc(configPath, doc) {
64640
+ writeFileSync5(configPath, doc.toString(), "utf8");
64641
+ }
64642
+
64643
+ // src/cloud/shared-schema.ts
64644
+ async function publishSharedSchema(db, configPath) {
64645
+ if (db.getDialect() !== "postgres") return;
64646
+ const cfg = loadConfigDoc(configPath).toJSON();
64647
+ const entities = cfg.entities ?? {};
64648
+ if (Object.keys(entities).length === 0) return;
64649
+ await runAsyncOrSync(
64650
+ db.adapter,
64651
+ `INSERT INTO "__lattice_shared_schema" ("id","entities_json","contexts_json","updated_at")
64652
+ VALUES ('singleton', $1, $2, $3)
64653
+ ON CONFLICT ("id") DO UPDATE SET
64654
+ "entities_json" = EXCLUDED."entities_json",
64655
+ "contexts_json" = EXCLUDED."contexts_json",
64656
+ "updated_at" = EXCLUDED."updated_at"`,
64657
+ [
64658
+ JSON.stringify(entities),
64659
+ JSON.stringify(cfg.entityContexts ?? null),
64660
+ (/* @__PURE__ */ new Date()).toISOString()
64661
+ ]
64662
+ );
64663
+ }
64664
+ async function hydrateMemberConfigFromCloud(configPath, dbUrl, encryptionKey) {
64665
+ if (!isPostgresUrl(dbUrl)) return false;
64666
+ const existing = loadConfigDoc(configPath).toJSON();
64667
+ if (Object.keys(existing.entities ?? {}).length > 0) return false;
64668
+ try {
64669
+ const peek = new Lattice({ config: configPath }, { encryptionKey });
64670
+ try {
64671
+ await peek.init({ introspectOnly: true });
64672
+ const reg = await getAsyncOrSync(
64673
+ peek.adapter,
64674
+ "SELECT to_regclass('__lattice_shared_schema') AS reg"
64675
+ );
64676
+ if (reg?.reg == null) return false;
64677
+ const row = await getAsyncOrSync(
64678
+ peek.adapter,
64679
+ 'SELECT "entities_json","contexts_json" FROM "__lattice_shared_schema" WHERE "id" = $1',
64680
+ ["singleton"]
64681
+ );
64682
+ if (row?.entities_json == null) return false;
64683
+ const entities = JSON.parse(row.entities_json);
64684
+ if (Object.keys(entities).length === 0) return false;
64685
+ const doc = loadConfigDoc(configPath);
64686
+ doc.setIn(["entities"], entities);
64687
+ if (row.contexts_json != null) {
64688
+ const ctx = JSON.parse(row.contexts_json);
64689
+ if (ctx) doc.setIn(["entityContexts"], ctx);
64690
+ }
64691
+ saveConfigDoc(configPath, doc);
64692
+ return true;
64693
+ } finally {
64694
+ peek.close();
64695
+ }
64696
+ } catch (e6) {
64697
+ console.warn(
64698
+ "[hydrateMemberConfigFromCloud] could not hydrate member schema:",
64699
+ e6.message
64700
+ );
64701
+ return false;
64702
+ }
64703
+ }
64704
+
64458
64705
  // src/gui/meta-gen.ts
64459
64706
  init_assistant_routes();
64460
64707
  init_chat();
@@ -64543,21 +64790,6 @@ var FeedBus = class {
64543
64790
  init_fts();
64544
64791
  init_mutations();
64545
64792
 
64546
- // src/gui/config-io.ts
64547
- import { readFileSync as readFileSync17, writeFileSync as writeFileSync5 } from "fs";
64548
- import { parseDocument as parseDocument2 } from "yaml";
64549
- async function execSql(db, sql) {
64550
- const adapter = db._adapter;
64551
- if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64552
- await adapter.runAsync(sql);
64553
- }
64554
- function loadConfigDoc(configPath) {
64555
- return parseDocument2(readFileSync17(configPath, "utf8"));
64556
- }
64557
- function saveConfigDoc(configPath, doc) {
64558
- writeFileSync5(configPath, doc.toString(), "utf8");
64559
- }
64560
-
64561
64793
  // src/gui/schema-ops.ts
64562
64794
  init_parser();
64563
64795
  init_canonical_context();
@@ -65746,6 +65978,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
65746
65978
  const result = await migrateLatticeData(ctx.db, target);
65747
65979
  await target.rebuildFtsIndexes();
65748
65980
  await secureCloud(target);
65981
+ await publishSharedSchema(target, ctx.configPath);
65749
65982
  target.close();
65750
65983
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
65751
65984
  const backupPath = archiveLocalSqlite(sourceDbPath);
@@ -66208,6 +66441,7 @@ function activeCloudCoords(configPath) {
66208
66441
  init_assistant_routes();
66209
66442
 
66210
66443
  // src/gui/chat-routes.ts
66444
+ init_adapter();
66211
66445
  init_assistant_routes();
66212
66446
  init_chat();
66213
66447
  init_user_config();
@@ -66233,6 +66467,15 @@ function sendJson3(res, body, status = 200) {
66233
66467
  function asStr(v2, fallback = "") {
66234
66468
  return typeof v2 === "string" ? v2 : fallback;
66235
66469
  }
66470
+ function isCloudChat(db) {
66471
+ return db.getDialect() === "postgres";
66472
+ }
66473
+ async function resolveChatOwnerId(db) {
66474
+ if (!isCloudChat(db)) return null;
66475
+ const row = await getAsyncOrSync(db.adapter, "SELECT session_user AS u");
66476
+ const u2 = row?.u;
66477
+ return typeof u2 === "string" && u2.length > 0 ? u2 : null;
66478
+ }
66236
66479
  function readJson3(req) {
66237
66480
  return new Promise((resolve12, reject) => {
66238
66481
  let raw = "";
@@ -66425,12 +66668,20 @@ async function persistMessage(db, threadId, role, text, ownerUserId, turns, star
66425
66668
  });
66426
66669
  }
66427
66670
  async function dispatchChatRoute(req, res, ctx) {
66671
+ const ownerUserId = await resolveChatOwnerId(ctx.db);
66672
+ const cloud = isCloudChat(ctx.db);
66673
+ const ownedByMe = (r6) => !cloud || r6.owner_user_id != null && r6.owner_user_id === ownerUserId;
66428
66674
  if (ctx.method === "GET" && ctx.pathname === "/api/chat/threads") {
66675
+ if (cloud && ownerUserId == null) {
66676
+ sendJson3(res, { threads: [] });
66677
+ return true;
66678
+ }
66429
66679
  const filters = [
66430
66680
  { col: "deleted_at", op: "isNull" }
66431
66681
  ];
66682
+ if (ownerUserId != null) filters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
66432
66683
  const rows = await ctx.db.query("chat_threads", { filters, limit: 100 });
66433
- const threads = rows.filter((r6) => !r6.deleted_at).map((r6) => ({
66684
+ const threads = rows.filter((r6) => !r6.deleted_at && ownedByMe(r6)).map((r6) => ({
66434
66685
  id: asStr(r6.id),
66435
66686
  title: asStr(r6.title, "Chat"),
66436
66687
  created_at: asStr(r6.created_at)
@@ -66441,12 +66692,19 @@ async function dispatchChatRoute(req, res, ctx) {
66441
66692
  const msgMatch = /^\/api\/chat\/threads\/([^/]+)\/messages$/.exec(ctx.pathname);
66442
66693
  if (ctx.method === "GET" && msgMatch) {
66443
66694
  const threadId2 = decodeURIComponent(msgMatch[1] ?? "");
66444
- const msgFilters = [{ col: "thread_id", op: "eq", val: threadId2 }];
66695
+ if (cloud && ownerUserId == null) {
66696
+ sendJson3(res, { messages: [] });
66697
+ return true;
66698
+ }
66699
+ const msgFilters = [
66700
+ { col: "thread_id", op: "eq", val: threadId2 }
66701
+ ];
66702
+ if (ownerUserId != null) msgFilters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
66445
66703
  const rows = await ctx.db.query("chat_messages", {
66446
66704
  filters: msgFilters,
66447
66705
  limit: 1e3
66448
66706
  });
66449
- const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at).map((r6) => {
66707
+ const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at && ownedByMe(r6)).map((r6) => {
66450
66708
  let text = "";
66451
66709
  let turns2;
66452
66710
  let startedAt;
@@ -66499,12 +66757,21 @@ async function dispatchChatRoute(req, res, ctx) {
66499
66757
  return true;
66500
66758
  }
66501
66759
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
66760
+ if (cloud && ownerUserId == null) {
66761
+ sendJson3(res, { error: "Could not resolve your cloud identity; chat is disabled." }, 500);
66762
+ return true;
66763
+ }
66502
66764
  const activeContext = parseActiveContext(body.activeContext, ctx.validTables);
66503
- const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), null);
66765
+ const history = await rehydrateHistory(
66766
+ ctx.db,
66767
+ requestedThread,
66768
+ mapHistory(body.history),
66769
+ ownerUserId
66770
+ );
66504
66771
  let threadId = "";
66505
66772
  try {
66506
- threadId = await ensureThread(ctx.db, requestedThread, message, null);
66507
- await persistMessage(ctx.db, threadId, "user", message, null);
66773
+ threadId = await ensureThread(ctx.db, requestedThread, message, ownerUserId);
66774
+ await persistMessage(ctx.db, threadId, "user", message, ownerUserId);
66508
66775
  } catch (e6) {
66509
66776
  console.warn("[chat] persist user message failed:", e6.message);
66510
66777
  }
@@ -66576,7 +66843,7 @@ async function dispatchChatRoute(req, res, ctx) {
66576
66843
  threadId,
66577
66844
  "assistant",
66578
66845
  assistantText,
66579
- null,
66846
+ ownerUserId,
66580
66847
  cleanTurns,
66581
66848
  turnStartedAt,
66582
66849
  assistantMsgId
@@ -67420,10 +67687,13 @@ function resolveOutputDirForConfig(configPath) {
67420
67687
  async function openConfig(configPath, outputDir, autoRender = false, realtimeWatchdogMs) {
67421
67688
  healRawDbUrl(configPath);
67422
67689
  const parsed = parseConfigFile(configPath);
67690
+ const encryptionKey = getOrCreateMasterKey();
67423
67691
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
67424
67692
  mkdirSync10(dirname13(parsed.dbPath), { recursive: true });
67425
67693
  }
67426
- const encryptionKey = getOrCreateMasterKey();
67694
+ if (/^postgres(ql)?:\/\//i.test(parsed.dbPath)) {
67695
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
67696
+ }
67427
67697
  const db = new Lattice({ config: configPath }, { encryptionKey });
67428
67698
  registerNativeEntities(db);
67429
67699
  db.define("_lattice_gui_meta", {
@@ -67575,19 +67845,32 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
67575
67845
  if (await cloudRlsInstalled(db) && await canManageRoles(db)) {
67576
67846
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
67577
67847
  await installCloudRls(db);
67848
+ await ownPolyfillsByGroup(db);
67578
67849
  await installCloudSettings(db);
67579
67850
  await db.ensureObservationSubstrate();
67580
67851
  await enableChangelogRls(db);
67852
+ await enableChatPrivacyRls(db);
67853
+ await enableGuiAuditRls(db);
67581
67854
  const access = await reconcileCloudMemberAccess(db);
67582
67855
  convergeWarnings = access.skipped;
67583
67856
  for (const s2 of convergeWarnings) {
67584
67857
  console.warn(`[openConfig] cloud converge could not manage "${s2.table}": ${s2.reason}`);
67585
67858
  }
67859
+ await publishSharedSchema(db, configPath);
67586
67860
  }
67587
67861
  } catch (e6) {
67588
67862
  console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
67589
67863
  }
67590
67864
  }
67865
+ if (memberOpen) {
67866
+ const userTables = db.getRegisteredTableNames().filter((t8) => !t8.startsWith("_") && !discoveredJunctions.has(t8));
67867
+ if (userTables.length === 0) {
67868
+ convergeWarnings.push({
67869
+ table: "(schema)",
67870
+ reason: "No entity layout is configured for this cloud workspace yet \u2014 ask the cloud owner to open the workspace once so it publishes the schema, then reopen. Until then, render produces no context files."
67871
+ });
67872
+ }
67873
+ }
67591
67874
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
67592
67875
  for (const name of db.getRegisteredTableNames()) {
67593
67876
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -67739,11 +68022,22 @@ function startBackgroundRender(active) {
67739
68022
  active.eagerRenderWired = true;
67740
68023
  let lastFire = 0;
67741
68024
  let trailing;
68025
+ const pendingTables = /* @__PURE__ */ new Set();
68026
+ let pendingFull = false;
67742
68027
  const fire = () => {
67743
68028
  lastFire = Date.now();
67744
- active.db.requestRender();
68029
+ if (pendingFull || pendingTables.size === 0) {
68030
+ pendingFull = false;
68031
+ pendingTables.clear();
68032
+ active.db.requestRender();
68033
+ return;
68034
+ }
68035
+ for (const t8 of pendingTables) active.db.requestRender(t8);
68036
+ pendingTables.clear();
67745
68037
  };
67746
- active.realtime.subscribePayload(() => {
68038
+ active.realtime.subscribePayload((payload) => {
68039
+ if (payload.table_name) pendingTables.add(payload.table_name);
68040
+ else pendingFull = true;
67747
68041
  const since = Date.now() - lastFire;
67748
68042
  if (since >= EAGER_RERENDER_MIN_INTERVAL_MS) {
67749
68043
  fire();