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/cli.js CHANGED
@@ -1637,43 +1637,57 @@ var init_postgres = __esm({
1637
1637
  },
1638
1638
  {
1639
1639
  warn: "could not register json_extract polyfill:",
1640
- sql: `CREATE OR REPLACE FUNCTION json_extract(doc text, path text)
1641
- RETURNS text
1642
- LANGUAGE sql
1643
- IMMUTABLE
1644
- AS $fn$
1645
- SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
1646
- $fn$;`
1640
+ // Create ONLY if absent. `CREATE OR REPLACE` on an existing function requires
1641
+ // ownership, but on a cloud the function is owned by whichever single role
1642
+ // created it first — so every OTHER member's per-connect replace raised "must
1643
+ // be owner of function" and (sharing the render transaction) aborted it,
1644
+ // yielding an empty render. The IF-absent guard makes a present function a
1645
+ // clean no-op for everyone, regardless of who owns it.
1646
+ sql: `DO $do$ BEGIN
1647
+ IF to_regprocedure('json_extract(text, text)') IS NULL THEN
1648
+ CREATE FUNCTION json_extract(doc text, path text)
1649
+ RETURNS text
1650
+ LANGUAGE sql
1651
+ IMMUTABLE
1652
+ AS $fn$
1653
+ SELECT doc::jsonb #>> string_to_array(regexp_replace(path, '^\\$\\.?', ''), '.')
1654
+ $fn$;
1655
+ END IF;
1656
+ END $do$;`
1647
1657
  },
1648
1658
  {
1649
1659
  warn: "could not register strftime polyfill:",
1650
- sql: `CREATE OR REPLACE FUNCTION strftime(format text, modifier text)
1651
- RETURNS text
1652
- LANGUAGE plpgsql
1653
- IMMUTABLE
1654
- AS $fn$
1655
- DECLARE ts timestamptz;
1656
- BEGIN
1657
- IF modifier = 'now' THEN
1658
- ts := now();
1659
- ELSE
1660
- ts := modifier::timestamptz;
1661
- END IF;
1662
- RETURN to_char(
1663
- ts AT TIME ZONE 'UTC',
1664
- replace(replace(replace(replace(replace(replace(replace(replace(
1665
- format,
1666
- '%Y', 'YYYY'),
1667
- '%m', 'MM'),
1668
- '%d', 'DD'),
1669
- '%H', 'HH24'),
1670
- '%M', 'MI'),
1671
- '%S', 'SS'),
1672
- '%f', 'MS'),
1673
- 'T', '"T"')
1674
- );
1675
- END;
1676
- $fn$;`
1660
+ sql: `DO $do$ BEGIN
1661
+ IF to_regprocedure('strftime(text, text)') IS NULL THEN
1662
+ CREATE FUNCTION strftime(format text, modifier text)
1663
+ RETURNS text
1664
+ LANGUAGE plpgsql
1665
+ IMMUTABLE
1666
+ AS $fn$
1667
+ DECLARE ts timestamptz;
1668
+ BEGIN
1669
+ IF modifier = 'now' THEN
1670
+ ts := now();
1671
+ ELSE
1672
+ ts := modifier::timestamptz;
1673
+ END IF;
1674
+ RETURN to_char(
1675
+ ts AT TIME ZONE 'UTC',
1676
+ replace(replace(replace(replace(replace(replace(replace(replace(
1677
+ format,
1678
+ '%Y', 'YYYY'),
1679
+ '%m', 'MM'),
1680
+ '%d', 'DD'),
1681
+ '%H', 'HH24'),
1682
+ '%M', 'MI'),
1683
+ '%S', 'SS'),
1684
+ '%f', 'MS'),
1685
+ 'T', '"T"')
1686
+ );
1687
+ END;
1688
+ $fn$;
1689
+ END IF;
1690
+ END $do$;`
1677
1691
  }
1678
1692
  ];
1679
1693
  }
@@ -3077,6 +3091,34 @@ var init_engine = __esm({
3077
3091
  setRenderFold(fn) {
3078
3092
  this._foldRows = fn;
3079
3093
  }
3094
+ /**
3095
+ * Incremental scope: is this entity-context table affected by a change to one
3096
+ * of `changed`? Affected when the table itself changed (its own rows / `self`
3097
+ * source / index) OR any of its files SOURCES from a changed table (a cross-
3098
+ * table dependent — e.g. an AGENT.md that lists the agent's tasks must re-render
3099
+ * when `tasks` changes). A `custom` source runs an arbitrary query, so we can't
3100
+ * prove independence — treat it as always-affected (conservative, never stale).
3101
+ */
3102
+ _entityAffected(table, def, changed) {
3103
+ if (changed.has(table)) return true;
3104
+ for (const spec of Object.values(def.files)) {
3105
+ if (this._sourceTouches(spec.source, changed)) return true;
3106
+ }
3107
+ return false;
3108
+ }
3109
+ _sourceTouches(source, changed) {
3110
+ if (source == null || typeof source !== "object") return false;
3111
+ const s2 = source;
3112
+ if (s2.type === "custom") return true;
3113
+ if (typeof s2.table === "string" && changed.has(s2.table)) return true;
3114
+ if (typeof s2.junctionTable === "string" && changed.has(s2.junctionTable)) return true;
3115
+ if (s2.sources != null && typeof s2.sources === "object") {
3116
+ for (const sub of Object.values(s2.sources)) {
3117
+ if (this._sourceTouches(sub, changed)) return true;
3118
+ }
3119
+ }
3120
+ return false;
3121
+ }
3080
3122
  async render(outputDir, opts = {}) {
3081
3123
  const start = Date.now();
3082
3124
  const filesWritten = [];
@@ -3086,6 +3128,7 @@ var init_engine = __esm({
3086
3128
  for (const [name, def] of this._schema.getTables()) {
3087
3129
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
3088
3130
  if (this._skipEmpty && def.render === NOOP_RENDER) continue;
3131
+ if (opts.changedTables && !opts.changedTables.has(name)) continue;
3089
3132
  let rows = await this._schema.queryTable(this._adapter, name, this._readRel);
3090
3133
  if (def.relevanceFilter) {
3091
3134
  const ctx = this._getTaskContext();
@@ -3138,6 +3181,9 @@ var init_engine = __esm({
3138
3181
  }
3139
3182
  for (const [name, def] of this._schema.getMultis()) {
3140
3183
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
3184
+ if (opts.changedTables && def.tables && !def.tables.some((t8) => opts.changedTables?.has(t8))) {
3185
+ continue;
3186
+ }
3141
3187
  const keys = await def.keys();
3142
3188
  const tables = {};
3143
3189
  if (def.tables) {
@@ -3169,16 +3215,22 @@ var init_engine = __esm({
3169
3215
  filesWritten,
3170
3216
  counters,
3171
3217
  throttle,
3172
- signal
3218
+ signal,
3219
+ opts.changedTables
3173
3220
  );
3174
3221
  if (entityContextManifest === null) {
3175
3222
  return this._abortedResult(filesWritten, counters, start);
3176
3223
  }
3177
3224
  if (this._schema.getEntityContexts().size > 0) {
3225
+ let entityContexts = entityContextManifest;
3226
+ if (opts.changedTables) {
3227
+ const prev = readManifest(outputDir);
3228
+ entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
3229
+ }
3178
3230
  writeManifest(outputDir, {
3179
3231
  version: 2,
3180
3232
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3181
- entityContexts: entityContextManifest
3233
+ entityContexts
3182
3234
  });
3183
3235
  }
3184
3236
  const result = {
@@ -3244,12 +3296,14 @@ var init_engine = __esm({
3244
3296
  * partial tree). Progress is reported through `throttle`; abort is observed
3245
3297
  * via `signal`.
3246
3298
  */
3247
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
3299
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
3248
3300
  const protectedTables = /* @__PURE__ */ new Set();
3249
3301
  for (const [t8, d6] of this._schema.getEntityContexts()) {
3250
3302
  if (d6.protected) protectedTables.add(t8);
3251
3303
  }
3252
- const entityTables = [...this._schema.getEntityContexts()];
3304
+ const entityTables = [...this._schema.getEntityContexts()].filter(
3305
+ ([table, def]) => !changedTables || this._entityAffected(table, def, changedTables)
3306
+ );
3253
3307
  const tableCount = entityTables.length;
3254
3308
  if (signal?.aborted) return null;
3255
3309
  const renderedEntries = await mapWithConcurrency(
@@ -5077,6 +5131,16 @@ var init_lattice = __esm({
5077
5131
  _autoRenderPending = false;
5078
5132
  _autoRenderInFlight = false;
5079
5133
  _autoRenderDebounceMs = 250;
5134
+ /**
5135
+ * Incremental auto-render scope, accumulated between debounced renders. A write
5136
+ * or a remote (cloud) change records the AFFECTED table here, so the next
5137
+ * auto-render re-renders only that entity (+ its cross-table dependents) instead
5138
+ * of the whole tree. `_pendingRenderAll` forces a full render (the initial
5139
+ * render, or a change with no known table). Captured + reset when a render
5140
+ * starts, so changes during a render re-accumulate and re-trigger.
5141
+ */
5142
+ _pendingRenderTables = /* @__PURE__ */ new Set();
5143
+ _pendingRenderAll = true;
5080
5144
  /** Cache of actual table columns (from PRAGMA), populated after init(). */
5081
5145
  _columnCache = /* @__PURE__ */ new Map();
5082
5146
  /** Derived encryption key (from options.encryptionKey via scrypt). */
@@ -6172,7 +6236,7 @@ var init_lattice = __esm({
6172
6236
  `${verb} INTO "${junctionTable}" (${colNames}) VALUES (${placeholders})`,
6173
6237
  Object.values(filtered)
6174
6238
  );
6175
- this._scheduleAutoRender();
6239
+ this._scheduleAutoRender(junctionTable);
6176
6240
  }
6177
6241
  /**
6178
6242
  * Delete rows from a junction table matching all given conditions.
@@ -6188,7 +6252,7 @@ var init_lattice = __esm({
6188
6252
  `DELETE FROM "${junctionTable}" WHERE ${where}`,
6189
6253
  entries.map(([, v2]) => v2)
6190
6254
  );
6191
- this._scheduleAutoRender();
6255
+ this._scheduleAutoRender(junctionTable);
6192
6256
  }
6193
6257
  // -------------------------------------------------------------------------
6194
6258
  // Seeding DSL (v0.13+)
@@ -6444,6 +6508,11 @@ var init_lattice = __esm({
6444
6508
  async renderInBackground(outputDir, opts = {}) {
6445
6509
  const notInit = this._notInitError();
6446
6510
  if (notInit) return notInit;
6511
+ if (!opts.changedTables) {
6512
+ this._pendingRenderAll = false;
6513
+ this._pendingRenderTables = /* @__PURE__ */ new Set();
6514
+ this._autoRenderPending = false;
6515
+ }
6447
6516
  return this._renderGuarded(outputDir, opts);
6448
6517
  }
6449
6518
  /**
@@ -6473,9 +6542,12 @@ var init_lattice = __esm({
6473
6542
  * tree when a REMOTE change arrives — notably an owner re-sharing or un-sharing
6474
6543
  * a row, after which the member's per-viewer projection must be recompiled. A
6475
6544
  * no-op when auto-render isn't enabled.
6545
+ *
6546
+ * Pass the CHANGED table so only that entity (+ its cross-table dependents) is
6547
+ * re-rendered instead of the whole tree; omit it to force a full render.
6476
6548
  */
6477
- requestRender() {
6478
- this._scheduleAutoRender();
6549
+ requestRender(table) {
6550
+ this._scheduleAutoRender(table);
6479
6551
  }
6480
6552
  /**
6481
6553
  * True while a render is actively writing the context tree + manifest (auto-
@@ -6876,7 +6948,7 @@ var init_lattice = __esm({
6876
6948
  for (const h6 of this._errorHandlers) h6(err instanceof Error ? err : new Error(String(err)));
6877
6949
  }
6878
6950
  }
6879
- this._scheduleAutoRender();
6951
+ this._scheduleAutoRender(table);
6880
6952
  }
6881
6953
  /**
6882
6954
  * Turn on automatic rendering into `outputDir`. After this, every insert /
@@ -6900,10 +6972,18 @@ var init_lattice = __esm({
6900
6972
  this._autoRenderPending = false;
6901
6973
  return this;
6902
6974
  }
6903
- _scheduleAutoRender() {
6975
+ _scheduleAutoRender(table) {
6904
6976
  if (!this._autoRenderDir) return;
6977
+ if (table === void 0) this._pendingRenderAll = true;
6978
+ else this._pendingRenderTables.add(table);
6905
6979
  this._autoRenderPending = true;
6906
- if (this._autoRenderTimer) return;
6980
+ this._armAutoRenderTimer();
6981
+ }
6982
+ /** Arm the debounce timer if not already armed. Does NOT change the render
6983
+ * scope — used both by `_scheduleAutoRender` and the post-render re-arm so a
6984
+ * re-arm never escalates a pending incremental render to a full one. */
6985
+ _armAutoRenderTimer() {
6986
+ if (!this._autoRenderDir || this._autoRenderTimer) return;
6907
6987
  this._autoRenderTimer = setTimeout(() => {
6908
6988
  this._autoRenderTimer = void 0;
6909
6989
  void this._runAutoRender();
@@ -6943,10 +7023,15 @@ var init_lattice = __esm({
6943
7023
  }
6944
7024
  if (!this._autoRenderPending) return;
6945
7025
  this._autoRenderPending = false;
7026
+ const renderAll = this._pendingRenderAll;
7027
+ const changed = this._pendingRenderTables;
7028
+ this._pendingRenderAll = false;
7029
+ this._pendingRenderTables = /* @__PURE__ */ new Set();
6946
7030
  this._autoRenderInFlight = true;
6947
7031
  try {
6948
7032
  const prevManifest = readManifest(dir);
6949
- const result = await this._render.render(dir);
7033
+ const renderOpts = renderAll || changed.size === 0 ? {} : { changedTables: changed };
7034
+ const result = await this._render.render(dir, renderOpts);
6950
7035
  for (const h6 of this._renderHandlers) h6(result);
6951
7036
  const newManifest = readManifest(dir);
6952
7037
  await this._render.cleanup(dir, prevManifest, {}, newManifest);
@@ -6958,7 +7043,7 @@ var init_lattice = __esm({
6958
7043
  }
6959
7044
  }
6960
7045
  _rearmAutoRenderIfPending() {
6961
- if (this._autoRenderPending) this._scheduleAutoRender();
7046
+ if (this._autoRenderPending) this._armAutoRenderTimer();
6962
7047
  }
6963
7048
  /**
6964
7049
  * Update or remove the embedding for a row.
@@ -8035,6 +8120,44 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
8035
8120
  FOR EACH ROW EXECUTE FUNCTION "${trg}"();
8036
8121
  `;
8037
8122
  }
8123
+ async function ownPolyfillsByGroup(db) {
8124
+ if (!isPg(db)) return;
8125
+ for (const sig of ["json_extract(text, text)", "strftime(text, text)"]) {
8126
+ try {
8127
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regprocedure($1) AS reg`, [sig]);
8128
+ if (reg?.reg == null) continue;
8129
+ await runAsyncOrSync(db.adapter, `ALTER FUNCTION ${sig} OWNER TO "${MEMBER_GROUP}"`);
8130
+ } catch {
8131
+ }
8132
+ }
8133
+ }
8134
+ async function enableGuiAuditRls(db) {
8135
+ if (!isPg(db)) return;
8136
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [
8137
+ "_lattice_gui_audit"
8138
+ ]);
8139
+ if (reg?.reg == null) return;
8140
+ await runCloudBootstrapSql(
8141
+ db,
8142
+ `
8143
+ ALTER TABLE "_lattice_gui_audit" ENABLE ROW LEVEL SECURITY;
8144
+ ALTER TABLE "_lattice_gui_audit" FORCE ROW LEVEL SECURITY;
8145
+ DROP POLICY IF EXISTS "lattice_gui_audit_owner" ON "_lattice_gui_audit";
8146
+ DROP POLICY IF EXISTS "lattice_gui_audit_sel" ON "_lattice_gui_audit";
8147
+ CREATE POLICY "lattice_gui_audit_sel" ON "_lattice_gui_audit" FOR SELECT
8148
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
8149
+ DROP POLICY IF EXISTS "lattice_gui_audit_ins" ON "_lattice_gui_audit";
8150
+ CREATE POLICY "lattice_gui_audit_ins" ON "_lattice_gui_audit" FOR INSERT
8151
+ WITH CHECK ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
8152
+ DROP POLICY IF EXISTS "lattice_gui_audit_upd" ON "_lattice_gui_audit";
8153
+ CREATE POLICY "lattice_gui_audit_upd" ON "_lattice_gui_audit" FOR UPDATE
8154
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
8155
+ DROP POLICY IF EXISTS "lattice_gui_audit_del" ON "_lattice_gui_audit";
8156
+ CREATE POLICY "lattice_gui_audit_del" ON "_lattice_gui_audit" FOR DELETE
8157
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
8158
+ `
8159
+ );
8160
+ }
8038
8161
  async function installCloudRls(db) {
8039
8162
  if (!isPg(db)) return;
8040
8163
  const schema = await cloudSchema(db);
@@ -8068,6 +8191,24 @@ CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH C
8068
8191
  `
8069
8192
  );
8070
8193
  }
8194
+ async function enableChatPrivacyRls(db) {
8195
+ if (!isPg(db)) return;
8196
+ for (const t8 of ["chat_threads", "chat_messages"]) {
8197
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [t8]);
8198
+ if (reg?.reg == null) continue;
8199
+ const q3 = `"${t8}"`;
8200
+ await runCloudBootstrapSql(
8201
+ db,
8202
+ `
8203
+ ALTER TABLE ${q3} ENABLE ROW LEVEL SECURITY;
8204
+ ALTER TABLE ${q3} FORCE ROW LEVEL SECURITY;
8205
+ DROP POLICY IF EXISTS "lattice_chat_owner" ON ${q3};
8206
+ CREATE POLICY "lattice_chat_owner" ON ${q3} AS RESTRICTIVE FOR SELECT
8207
+ USING ("owner_user_id" IS NOT NULL AND "owner_user_id" = session_user);
8208
+ `
8209
+ );
8210
+ }
8211
+ }
8071
8212
  async function enableRlsForTable(db, table, pkCols) {
8072
8213
  if (!isPg(db)) return;
8073
8214
  const schema = await cloudSchema(db);
@@ -8182,6 +8323,18 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
8182
8323
  -- bootstrap is now run directly + idempotently, not version-gated).
8183
8324
  ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
8184
8325
 
8326
+ -- Owner-published entity/render LAYOUT (the entities + entityContexts config
8327
+ -- blocks), so a joined member \u2014 whose generated config has entities: {} \u2014 can
8328
+ -- hydrate the full render layout and produce a complete context tree. This holds
8329
+ -- schema CONFIG, not row data, so it is safe to share with members (granted
8330
+ -- SELECT). A shared singleton, like __lattice_user_identity: no per-row RLS.
8331
+ CREATE TABLE IF NOT EXISTS "__lattice_shared_schema" (
8332
+ "id" TEXT PRIMARY KEY DEFAULT 'singleton',
8333
+ "entities_json" TEXT,
8334
+ "contexts_json" TEXT,
8335
+ "updated_at" TEXT
8336
+ );
8337
+
8185
8338
  -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
8186
8339
  -- the cloud with their minted credential, the join path calls this to CLAIM the
8187
8340
  -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
@@ -10059,7 +10212,7 @@ var init_registry = __esm({
10059
10212
  });
10060
10213
 
10061
10214
  // src/gui/ai/lattice-docs.ts
10062
- import { readFileSync as readFileSync13, readdirSync as readdirSync5, existsSync as existsSync17 } from "fs";
10215
+ import { readFileSync as readFileSync14, readdirSync as readdirSync5, existsSync as existsSync17 } from "fs";
10063
10216
  import { dirname as dirname8, join as join16 } from "path";
10064
10217
  import { fileURLToPath as fileURLToPath2 } from "url";
10065
10218
  function findDocsDir() {
@@ -10119,7 +10272,7 @@ function allSections() {
10119
10272
  }
10120
10273
  for (const f6 of files) {
10121
10274
  try {
10122
- out.push(...sectionsOf(f6, readFileSync13(join16(dir, f6), "utf8")));
10275
+ out.push(...sectionsOf(f6, readFileSync14(join16(dir, f6), "utf8")));
10123
10276
  } catch {
10124
10277
  }
10125
10278
  }
@@ -55860,12 +56013,17 @@ var appJs = `
55860
56013
  fetchJson('/api/gui-meta/columns').catch(function () { return {}; }),
55861
56014
  fetchJson('/api/system-tables').catch(function () { return { tables: [] }; }),
55862
56015
  fetchJson('/api/workspaces').catch(function () { return null; }),
56016
+ fetchJson('/api/dbconfig').catch(function () { return {}; }),
55863
56017
  ]).then(function (results) {
55864
56018
  state.entities = results[0];
55865
56019
  state.iconOverrides = results[1] || {};
55866
56020
  state.columnMeta = results[2] || {};
55867
56021
  state.systemTables = (results[3] && results[3].tables) || [];
55868
56022
  renderWsSwitcher(results[4]);
56023
+ // Re-point the header logo at the NEW workspace's mark \u2014 the switch path
56024
+ // must refresh branding the way boot does (the etag cache-busts the
56025
+ // <img>), else the previous workspace's logo stays until a hard refresh.
56026
+ applyWorkspaceLogo((results[5] || {}).logoEtag);
55869
56027
  renderSidebar();
55870
56028
  // renderWsSwitcher set cloudMode from the new workspace's kind; re-render
55871
56029
  // the composer so the Private-mode toggle reflects local vs cloud (it is
@@ -62648,6 +62806,87 @@ async function discoverCloudTables(db) {
62648
62806
  // src/gui/server.ts
62649
62807
  init_rls();
62650
62808
 
62809
+ // src/cloud/shared-schema.ts
62810
+ init_lattice();
62811
+ init_adapter();
62812
+
62813
+ // src/gui/config-io.ts
62814
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync6 } from "fs";
62815
+ import { parseDocument as parseDocument2 } from "yaml";
62816
+ async function execSql(db, sql) {
62817
+ const adapter = db._adapter;
62818
+ if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
62819
+ await adapter.runAsync(sql);
62820
+ }
62821
+ function loadConfigDoc(configPath) {
62822
+ return parseDocument2(readFileSync13(configPath, "utf8"));
62823
+ }
62824
+ function saveConfigDoc(configPath, doc) {
62825
+ writeFileSync6(configPath, doc.toString(), "utf8");
62826
+ }
62827
+
62828
+ // src/cloud/shared-schema.ts
62829
+ async function publishSharedSchema(db, configPath) {
62830
+ if (db.getDialect() !== "postgres") return;
62831
+ const cfg = loadConfigDoc(configPath).toJSON();
62832
+ const entities = cfg.entities ?? {};
62833
+ if (Object.keys(entities).length === 0) return;
62834
+ await runAsyncOrSync(
62835
+ db.adapter,
62836
+ `INSERT INTO "__lattice_shared_schema" ("id","entities_json","contexts_json","updated_at")
62837
+ VALUES ('singleton', $1, $2, $3)
62838
+ ON CONFLICT ("id") DO UPDATE SET
62839
+ "entities_json" = EXCLUDED."entities_json",
62840
+ "contexts_json" = EXCLUDED."contexts_json",
62841
+ "updated_at" = EXCLUDED."updated_at"`,
62842
+ [
62843
+ JSON.stringify(entities),
62844
+ JSON.stringify(cfg.entityContexts ?? null),
62845
+ (/* @__PURE__ */ new Date()).toISOString()
62846
+ ]
62847
+ );
62848
+ }
62849
+ async function hydrateMemberConfigFromCloud(configPath, dbUrl, encryptionKey) {
62850
+ if (!isPostgresUrl(dbUrl)) return false;
62851
+ const existing = loadConfigDoc(configPath).toJSON();
62852
+ if (Object.keys(existing.entities ?? {}).length > 0) return false;
62853
+ try {
62854
+ const peek = new Lattice({ config: configPath }, { encryptionKey });
62855
+ try {
62856
+ await peek.init({ introspectOnly: true });
62857
+ const reg = await getAsyncOrSync(
62858
+ peek.adapter,
62859
+ "SELECT to_regclass('__lattice_shared_schema') AS reg"
62860
+ );
62861
+ if (reg?.reg == null) return false;
62862
+ const row = await getAsyncOrSync(
62863
+ peek.adapter,
62864
+ 'SELECT "entities_json","contexts_json" FROM "__lattice_shared_schema" WHERE "id" = $1',
62865
+ ["singleton"]
62866
+ );
62867
+ if (row?.entities_json == null) return false;
62868
+ const entities = JSON.parse(row.entities_json);
62869
+ if (Object.keys(entities).length === 0) return false;
62870
+ const doc = loadConfigDoc(configPath);
62871
+ doc.setIn(["entities"], entities);
62872
+ if (row.contexts_json != null) {
62873
+ const ctx = JSON.parse(row.contexts_json);
62874
+ if (ctx) doc.setIn(["entityContexts"], ctx);
62875
+ }
62876
+ saveConfigDoc(configPath, doc);
62877
+ return true;
62878
+ } finally {
62879
+ peek.close();
62880
+ }
62881
+ } catch (e6) {
62882
+ console.warn(
62883
+ "[hydrateMemberConfigFromCloud] could not hydrate member schema:",
62884
+ e6.message
62885
+ );
62886
+ return false;
62887
+ }
62888
+ }
62889
+
62651
62890
  // src/cloud/settings.ts
62652
62891
  init_adapter();
62653
62892
  init_rls();
@@ -62751,7 +62990,7 @@ var MEMBER_READABLE_BOOKKEEPING = [
62751
62990
  {
62752
62991
  name: "_lattice_gui_audit",
62753
62992
  privs: "SELECT, INSERT",
62754
- why: "the member's own GUI undo/redo log (session-scoped)"
62993
+ why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
62755
62994
  },
62756
62995
  {
62757
62996
  name: "__lattice_user_identity",
@@ -62762,6 +63001,11 @@ var MEMBER_READABLE_BOOKKEEPING = [
62762
63001
  name: "__lattice_changelog",
62763
63002
  privs: "SELECT, INSERT",
62764
63003
  why: "per-viewer-RLS-filtered change history for observe()/history (the policy filters reads, so the base grant is safe)"
63004
+ },
63005
+ {
63006
+ name: "__lattice_shared_schema",
63007
+ privs: "SELECT",
63008
+ why: "owner-published entity/render layout (entities + entityContexts) a joined member hydrates its config from so render produces the full context tree"
62765
63009
  }
62766
63010
  ];
62767
63011
  var MEMBER_EXECUTE_FUNCTIONS = [
@@ -62913,9 +63157,12 @@ async function secureCloud(db) {
62913
63157
  if (db.getDialect() !== "postgres") return;
62914
63158
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
62915
63159
  await installCloudRls(db);
63160
+ await ownPolyfillsByGroup(db);
62916
63161
  await installCloudSettings(db);
62917
63162
  await db.ensureObservationSubstrate();
62918
63163
  await enableChangelogRls(db);
63164
+ await enableChatPrivacyRls(db);
63165
+ await enableGuiAuditRls(db);
62919
63166
  await convergeLegacyColumnAudience(db);
62920
63167
  const registered = db.getRegisteredTableNames();
62921
63168
  for (const table of registered) {
@@ -63012,21 +63259,6 @@ var FeedBus = class {
63012
63259
  init_fts();
63013
63260
  init_mutations();
63014
63261
 
63015
- // src/gui/config-io.ts
63016
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
63017
- import { parseDocument as parseDocument2 } from "yaml";
63018
- async function execSql(db, sql) {
63019
- const adapter = db._adapter;
63020
- if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
63021
- await adapter.runAsync(sql);
63022
- }
63023
- function loadConfigDoc(configPath) {
63024
- return parseDocument2(readFileSync14(configPath, "utf8"));
63025
- }
63026
- function saveConfigDoc(configPath, doc) {
63027
- writeFileSync6(configPath, doc.toString(), "utf8");
63028
- }
63029
-
63030
63262
  // src/gui/schema-ops.ts
63031
63263
  init_parser();
63032
63264
  init_canonical_context();
@@ -64562,6 +64794,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
64562
64794
  const result = await migrateLatticeData(ctx.db, target);
64563
64795
  await target.rebuildFtsIndexes();
64564
64796
  await secureCloud(target);
64797
+ await publishSharedSchema(target, ctx.configPath);
64565
64798
  target.close();
64566
64799
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
64567
64800
  const backupPath = archiveLocalSqlite(sourceDbPath);
@@ -65024,6 +65257,7 @@ function activeCloudCoords(configPath) {
65024
65257
  init_assistant_routes();
65025
65258
 
65026
65259
  // src/gui/chat-routes.ts
65260
+ init_adapter();
65027
65261
  init_assistant_routes();
65028
65262
  init_chat();
65029
65263
  init_user_config();
@@ -65049,6 +65283,15 @@ function sendJson3(res, body, status = 200) {
65049
65283
  function asStr(v2, fallback = "") {
65050
65284
  return typeof v2 === "string" ? v2 : fallback;
65051
65285
  }
65286
+ function isCloudChat(db) {
65287
+ return db.getDialect() === "postgres";
65288
+ }
65289
+ async function resolveChatOwnerId(db) {
65290
+ if (!isCloudChat(db)) return null;
65291
+ const row = await getAsyncOrSync(db.adapter, "SELECT session_user AS u");
65292
+ const u2 = row?.u;
65293
+ return typeof u2 === "string" && u2.length > 0 ? u2 : null;
65294
+ }
65052
65295
  function readJson3(req) {
65053
65296
  return new Promise((resolve12, reject) => {
65054
65297
  let raw = "";
@@ -65241,12 +65484,20 @@ async function persistMessage(db, threadId, role, text, ownerUserId, turns, star
65241
65484
  });
65242
65485
  }
65243
65486
  async function dispatchChatRoute(req, res, ctx) {
65487
+ const ownerUserId = await resolveChatOwnerId(ctx.db);
65488
+ const cloud = isCloudChat(ctx.db);
65489
+ const ownedByMe = (r6) => !cloud || r6.owner_user_id != null && r6.owner_user_id === ownerUserId;
65244
65490
  if (ctx.method === "GET" && ctx.pathname === "/api/chat/threads") {
65491
+ if (cloud && ownerUserId == null) {
65492
+ sendJson3(res, { threads: [] });
65493
+ return true;
65494
+ }
65245
65495
  const filters = [
65246
65496
  { col: "deleted_at", op: "isNull" }
65247
65497
  ];
65498
+ if (ownerUserId != null) filters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
65248
65499
  const rows = await ctx.db.query("chat_threads", { filters, limit: 100 });
65249
- const threads = rows.filter((r6) => !r6.deleted_at).map((r6) => ({
65500
+ const threads = rows.filter((r6) => !r6.deleted_at && ownedByMe(r6)).map((r6) => ({
65250
65501
  id: asStr(r6.id),
65251
65502
  title: asStr(r6.title, "Chat"),
65252
65503
  created_at: asStr(r6.created_at)
@@ -65257,12 +65508,19 @@ async function dispatchChatRoute(req, res, ctx) {
65257
65508
  const msgMatch = /^\/api\/chat\/threads\/([^/]+)\/messages$/.exec(ctx.pathname);
65258
65509
  if (ctx.method === "GET" && msgMatch) {
65259
65510
  const threadId2 = decodeURIComponent(msgMatch[1] ?? "");
65260
- const msgFilters = [{ col: "thread_id", op: "eq", val: threadId2 }];
65511
+ if (cloud && ownerUserId == null) {
65512
+ sendJson3(res, { messages: [] });
65513
+ return true;
65514
+ }
65515
+ const msgFilters = [
65516
+ { col: "thread_id", op: "eq", val: threadId2 }
65517
+ ];
65518
+ if (ownerUserId != null) msgFilters.push({ col: "owner_user_id", op: "eq", val: ownerUserId });
65261
65519
  const rows = await ctx.db.query("chat_messages", {
65262
65520
  filters: msgFilters,
65263
65521
  limit: 1e3
65264
65522
  });
65265
- const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at).map((r6) => {
65523
+ const messages = rows.filter((r6) => r6.thread_id === threadId2 && !r6.deleted_at && ownedByMe(r6)).map((r6) => {
65266
65524
  let text = "";
65267
65525
  let turns2;
65268
65526
  let startedAt;
@@ -65315,12 +65573,21 @@ async function dispatchChatRoute(req, res, ctx) {
65315
65573
  return true;
65316
65574
  }
65317
65575
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
65576
+ if (cloud && ownerUserId == null) {
65577
+ sendJson3(res, { error: "Could not resolve your cloud identity; chat is disabled." }, 500);
65578
+ return true;
65579
+ }
65318
65580
  const activeContext = parseActiveContext(body.activeContext, ctx.validTables);
65319
- const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), null);
65581
+ const history = await rehydrateHistory(
65582
+ ctx.db,
65583
+ requestedThread,
65584
+ mapHistory(body.history),
65585
+ ownerUserId
65586
+ );
65320
65587
  let threadId = "";
65321
65588
  try {
65322
- threadId = await ensureThread(ctx.db, requestedThread, message, null);
65323
- await persistMessage(ctx.db, threadId, "user", message, null);
65589
+ threadId = await ensureThread(ctx.db, requestedThread, message, ownerUserId);
65590
+ await persistMessage(ctx.db, threadId, "user", message, ownerUserId);
65324
65591
  } catch (e6) {
65325
65592
  console.warn("[chat] persist user message failed:", e6.message);
65326
65593
  }
@@ -65392,7 +65659,7 @@ async function dispatchChatRoute(req, res, ctx) {
65392
65659
  threadId,
65393
65660
  "assistant",
65394
65661
  assistantText,
65395
- null,
65662
+ ownerUserId,
65396
65663
  cleanTurns,
65397
65664
  turnStartedAt,
65398
65665
  assistantMsgId
@@ -66386,10 +66653,13 @@ function resolveOutputDirForConfig(configPath) {
66386
66653
  async function openConfig(configPath, outputDir, autoRender = false, realtimeWatchdogMs) {
66387
66654
  healRawDbUrl(configPath);
66388
66655
  const parsed = parseConfigFile(configPath);
66656
+ const encryptionKey = getOrCreateMasterKey();
66389
66657
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
66390
66658
  mkdirSync11(dirname14(parsed.dbPath), { recursive: true });
66391
66659
  }
66392
- const encryptionKey = getOrCreateMasterKey();
66660
+ if (/^postgres(ql)?:\/\//i.test(parsed.dbPath)) {
66661
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
66662
+ }
66393
66663
  const db = new Lattice({ config: configPath }, { encryptionKey });
66394
66664
  registerNativeEntities(db);
66395
66665
  db.define("_lattice_gui_meta", {
@@ -66541,19 +66811,32 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66541
66811
  if (await cloudRlsInstalled(db) && await canManageRoles(db)) {
66542
66812
  await registerPostgresPolyfills((sql) => runAsyncOrSync(db.adapter, sql));
66543
66813
  await installCloudRls(db);
66814
+ await ownPolyfillsByGroup(db);
66544
66815
  await installCloudSettings(db);
66545
66816
  await db.ensureObservationSubstrate();
66546
66817
  await enableChangelogRls(db);
66818
+ await enableChatPrivacyRls(db);
66819
+ await enableGuiAuditRls(db);
66547
66820
  const access = await reconcileCloudMemberAccess(db);
66548
66821
  convergeWarnings = access.skipped;
66549
66822
  for (const s2 of convergeWarnings) {
66550
66823
  console.warn(`[openConfig] cloud converge could not manage "${s2.table}": ${s2.reason}`);
66551
66824
  }
66825
+ await publishSharedSchema(db, configPath);
66552
66826
  }
66553
66827
  } catch (e6) {
66554
66828
  console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
66555
66829
  }
66556
66830
  }
66831
+ if (memberOpen) {
66832
+ const userTables = db.getRegisteredTableNames().filter((t8) => !t8.startsWith("_") && !discoveredJunctions.has(t8));
66833
+ if (userTables.length === 0) {
66834
+ convergeWarnings.push({
66835
+ table: "(schema)",
66836
+ 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."
66837
+ });
66838
+ }
66839
+ }
66557
66840
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
66558
66841
  for (const name of db.getRegisteredTableNames()) {
66559
66842
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -66705,11 +66988,22 @@ function startBackgroundRender(active) {
66705
66988
  active.eagerRenderWired = true;
66706
66989
  let lastFire = 0;
66707
66990
  let trailing;
66991
+ const pendingTables = /* @__PURE__ */ new Set();
66992
+ let pendingFull = false;
66708
66993
  const fire = () => {
66709
66994
  lastFire = Date.now();
66710
- active.db.requestRender();
66995
+ if (pendingFull || pendingTables.size === 0) {
66996
+ pendingFull = false;
66997
+ pendingTables.clear();
66998
+ active.db.requestRender();
66999
+ return;
67000
+ }
67001
+ for (const t8 of pendingTables) active.db.requestRender(t8);
67002
+ pendingTables.clear();
66711
67003
  };
66712
- active.realtime.subscribePayload(() => {
67004
+ active.realtime.subscribePayload((payload) => {
67005
+ if (payload.table_name) pendingTables.add(payload.table_name);
67006
+ else pendingFull = true;
66713
67007
  const since = Date.now() - lastFire;
66714
67008
  if (since >= EAGER_RERENDER_MIN_INTERVAL_MS) {
66715
67009
  fire();
@@ -69130,7 +69424,7 @@ function printHelp() {
69130
69424
  );
69131
69425
  }
69132
69426
  function getVersion() {
69133
- if (true) return "3.4.0";
69427
+ if (true) return "3.4.2";
69134
69428
  try {
69135
69429
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
69136
69430
  const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8"));
@@ -69198,14 +69492,17 @@ function runGenerate(args) {
69198
69492
  }
69199
69493
  async function runRender(args) {
69200
69494
  const outputDir = resolve11(args.output);
69495
+ const configPath = resolve11(args.config);
69201
69496
  let parsed;
69202
69497
  try {
69203
- parsed = parseConfigFile(resolve11(args.config));
69498
+ parsed = parseConfigFile(configPath);
69204
69499
  } catch (e6) {
69205
69500
  console.error(`Error: ${e6.message}`);
69206
69501
  process.exit(1);
69207
69502
  }
69208
- const db = new Lattice({ config: resolve11(args.config) });
69503
+ const encryptionKey = getOrCreateMasterKey();
69504
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
69505
+ const db = new Lattice({ config: configPath }, { encryptionKey });
69209
69506
  try {
69210
69507
  await db.init();
69211
69508
  const start = Date.now();
@@ -69221,7 +69518,6 @@ async function runRender(args) {
69221
69518
  } finally {
69222
69519
  db.close();
69223
69520
  }
69224
- void parsed;
69225
69521
  }
69226
69522
  async function runReconcile(args, isDryRun) {
69227
69523
  const outputDir = resolve11(args.output);