latticesql 3.4.1 → 3.4.3

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
@@ -8131,6 +8131,33 @@ async function ownPolyfillsByGroup(db) {
8131
8131
  }
8132
8132
  }
8133
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
+ }
8134
8161
  async function installCloudRls(db) {
8135
8162
  if (!isPg(db)) return;
8136
8163
  const schema = await cloudSchema(db);
@@ -8296,6 +8323,18 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
8296
8323
  -- bootstrap is now run directly + idempotently, not version-gated).
8297
8324
  ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
8298
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
+
8299
8338
  -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
8300
8339
  -- the cloud with their minted credential, the join path calls this to CLAIM the
8301
8340
  -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
@@ -10173,7 +10212,7 @@ var init_registry = __esm({
10173
10212
  });
10174
10213
 
10175
10214
  // src/gui/ai/lattice-docs.ts
10176
- 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";
10177
10216
  import { dirname as dirname8, join as join16 } from "path";
10178
10217
  import { fileURLToPath as fileURLToPath2 } from "url";
10179
10218
  function findDocsDir() {
@@ -10233,7 +10272,7 @@ function allSections() {
10233
10272
  }
10234
10273
  for (const f6 of files) {
10235
10274
  try {
10236
- out.push(...sectionsOf(f6, readFileSync13(join16(dir, f6), "utf8")));
10275
+ out.push(...sectionsOf(f6, readFileSync14(join16(dir, f6), "utf8")));
10237
10276
  } catch {
10238
10277
  }
10239
10278
  }
@@ -54484,6 +54523,25 @@ var css = `
54484
54523
  animation: feedSpin 0.7s linear infinite; vertical-align: middle;
54485
54524
  }
54486
54525
  @keyframes feedSpin { to { transform: rotate(360deg); } }
54526
+ /* Batch-upload progress bar \u2014 pinned to the top of the feed while a
54527
+ multi-file drop drains through the bounded-concurrency queue. */
54528
+ .ingest-progress {
54529
+ position: sticky; top: 0; z-index: 3;
54530
+ display: flex; flex-direction: column; gap: 6px;
54531
+ padding: 8px 10px; border-radius: 8px;
54532
+ background: var(--surface); border: 1px solid rgba(190, 242, 100, 0.22);
54533
+ box-shadow: var(--shadow-1), var(--glow-accent-soft);
54534
+ }
54535
+ .ingest-progress-label { font-size: 12px; font-weight: 500; color: var(--text); }
54536
+ .ingest-progress-track {
54537
+ height: 6px; border-radius: 999px; overflow: hidden; background: var(--border-strong);
54538
+ }
54539
+ .ingest-progress-fill {
54540
+ height: 100%; width: 0%; border-radius: 999px;
54541
+ background: linear-gradient(90deg, var(--accent-deep), var(--accent));
54542
+ box-shadow: 0 0 8px rgba(190, 242, 100, 0.5);
54543
+ transition: width 0.3s ease;
54544
+ }
54487
54545
  .assistant-rail {
54488
54546
  position: relative;
54489
54547
  background:
@@ -61820,6 +61878,63 @@ var appJs = `
61820
61878
  // Browsers can't expose the local path, so we POST the bytes; the
61821
61879
  // server extracts + summarizes, then discards them (path stays null).
61822
61880
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
61881
+ // Cap how many uploads are in flight at once. A browser allows only ~6
61882
+ // HTTP/1.1 connections per host, so a bulk drop of N files would fire N
61883
+ // upload POSTs in parallel and saturate that budget \u2014 every other data
61884
+ // request (entities, rows, navigation) then queues for minutes behind the
61885
+ // multi-minute ingests and the GUI looks frozen. Holding uploads to a few
61886
+ // at a time leaves connections free for the rest of the app (and eases the
61887
+ // AI rate limit each ingest hits server-side). The realtime/feed streams are
61888
+ // already off this budget \u2014 they share one WebSocket \u2014 so this is the last
61889
+ // place a big batch could starve the connection pool.
61890
+ var INGEST_MAX_CONCURRENCY = 3;
61891
+ // Run a batch of upload thunks with at most \`limit\` in flight, calling
61892
+ // onProgress(done, total) as each settles. One failure never stalls the
61893
+ // batch \u2014 uploadFile surfaces its own error and resolves.
61894
+ function runIngestBatch(thunks, limit, onProgress) {
61895
+ return new Promise(function (resolve) {
61896
+ var total = thunks.length, idx = 0, done = 0;
61897
+ function startNext() {
61898
+ if (idx >= total) return;
61899
+ var thunk = thunks[idx++];
61900
+ Promise.resolve().then(thunk).catch(function () { /* already surfaced */ }).then(function () {
61901
+ done++;
61902
+ if (onProgress) onProgress(done, total);
61903
+ if (done === total) resolve(); else startNext();
61904
+ });
61905
+ }
61906
+ for (var i = 0; i < Math.min(limit, total); i++) startNext();
61907
+ });
61908
+ }
61909
+ // A batch-upload progress bar pinned to the top of the rail feed
61910
+ // ("Analyzing N of M\u2026"). The per-file "Analyzing <name>\u2026" cards still
61911
+ // appear, but only INGEST_MAX_CONCURRENCY at a time; this gives the
61912
+ // whole-batch view that the individual cards can't. Returns
61913
+ // { update(done, total), done() }.
61914
+ function ingestProgress(total) {
61915
+ var feedEl = document.getElementById('rail-feed');
61916
+ if (!feedEl) return { update: function () {}, done: function () {} };
61917
+ railEmptyGone();
61918
+ var wrap = document.createElement('div');
61919
+ wrap.className = 'ingest-progress';
61920
+ wrap.innerHTML =
61921
+ '<div class="ingest-progress-label">Analyzing 0 of ' + total + '\u2026</div>' +
61922
+ '<div class="ingest-progress-track"><div class="ingest-progress-fill"></div></div>';
61923
+ feedEl.insertBefore(wrap, feedEl.firstChild);
61924
+ var label = wrap.querySelector('.ingest-progress-label');
61925
+ var fill = wrap.querySelector('.ingest-progress-fill');
61926
+ return {
61927
+ update: function (n, t) {
61928
+ if (label) label.textContent = 'Analyzing ' + n + ' of ' + t + '\u2026';
61929
+ if (fill) fill.style.width = Math.round((n / t) * 100) + '%';
61930
+ },
61931
+ done: function () {
61932
+ if (fill) fill.style.width = '100%';
61933
+ if (label) label.textContent = 'Analyzed ' + total + ' file' + (total === 1 ? '' : 's');
61934
+ setTimeout(function () { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); }, 2500);
61935
+ },
61936
+ };
61937
+ }
61823
61938
  // Append a transient "Analyzing <file>\u2026" row to the feed so the user sees
61824
61939
  // the ingest is processing in the background; returns a disposer. The real
61825
61940
  // create/link feed events stream in over SSE as the server materializes them.
@@ -61879,7 +61994,14 @@ var appJs = `
61879
61994
  });
61880
61995
  return;
61881
61996
  }
61882
- for (var i = 0; i < files.length; i++) uploadFile(files[i]);
61997
+ // Multi-file: drain through the bounded-concurrency queue (so a big drop
61998
+ // can't saturate the connection budget) with a batch progress bar.
61999
+ var bar = ingestProgress(files.length);
62000
+ var thunks = [];
62001
+ for (var i = 0; i < files.length; i++) {
62002
+ (function (f) { thunks.push(function () { return uploadFile(f); }); })(files[i]);
62003
+ }
62004
+ runIngestBatch(thunks, INGEST_MAX_CONCURRENCY, bar.update).then(bar.done);
61883
62005
  }
61884
62006
  // Mobile: tapping the handle expands/collapses the bottom drawer.
61885
62007
  function initRailDrawer() {
@@ -62767,6 +62889,87 @@ async function discoverCloudTables(db) {
62767
62889
  // src/gui/server.ts
62768
62890
  init_rls();
62769
62891
 
62892
+ // src/cloud/shared-schema.ts
62893
+ init_lattice();
62894
+ init_adapter();
62895
+
62896
+ // src/gui/config-io.ts
62897
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync6 } from "fs";
62898
+ import { parseDocument as parseDocument2 } from "yaml";
62899
+ async function execSql(db, sql) {
62900
+ const adapter = db._adapter;
62901
+ if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
62902
+ await adapter.runAsync(sql);
62903
+ }
62904
+ function loadConfigDoc(configPath) {
62905
+ return parseDocument2(readFileSync13(configPath, "utf8"));
62906
+ }
62907
+ function saveConfigDoc(configPath, doc) {
62908
+ writeFileSync6(configPath, doc.toString(), "utf8");
62909
+ }
62910
+
62911
+ // src/cloud/shared-schema.ts
62912
+ async function publishSharedSchema(db, configPath) {
62913
+ if (db.getDialect() !== "postgres") return;
62914
+ const cfg = loadConfigDoc(configPath).toJSON();
62915
+ const entities = cfg.entities ?? {};
62916
+ if (Object.keys(entities).length === 0) return;
62917
+ await runAsyncOrSync(
62918
+ db.adapter,
62919
+ `INSERT INTO "__lattice_shared_schema" ("id","entities_json","contexts_json","updated_at")
62920
+ VALUES ('singleton', $1, $2, $3)
62921
+ ON CONFLICT ("id") DO UPDATE SET
62922
+ "entities_json" = EXCLUDED."entities_json",
62923
+ "contexts_json" = EXCLUDED."contexts_json",
62924
+ "updated_at" = EXCLUDED."updated_at"`,
62925
+ [
62926
+ JSON.stringify(entities),
62927
+ JSON.stringify(cfg.entityContexts ?? null),
62928
+ (/* @__PURE__ */ new Date()).toISOString()
62929
+ ]
62930
+ );
62931
+ }
62932
+ async function hydrateMemberConfigFromCloud(configPath, dbUrl, encryptionKey) {
62933
+ if (!isPostgresUrl(dbUrl)) return false;
62934
+ const existing = loadConfigDoc(configPath).toJSON();
62935
+ if (Object.keys(existing.entities ?? {}).length > 0) return false;
62936
+ try {
62937
+ const peek = new Lattice({ config: configPath }, { encryptionKey });
62938
+ try {
62939
+ await peek.init({ introspectOnly: true });
62940
+ const reg = await getAsyncOrSync(
62941
+ peek.adapter,
62942
+ "SELECT to_regclass('__lattice_shared_schema') AS reg"
62943
+ );
62944
+ if (reg?.reg == null) return false;
62945
+ const row = await getAsyncOrSync(
62946
+ peek.adapter,
62947
+ 'SELECT "entities_json","contexts_json" FROM "__lattice_shared_schema" WHERE "id" = $1',
62948
+ ["singleton"]
62949
+ );
62950
+ if (row?.entities_json == null) return false;
62951
+ const entities = JSON.parse(row.entities_json);
62952
+ if (Object.keys(entities).length === 0) return false;
62953
+ const doc = loadConfigDoc(configPath);
62954
+ doc.setIn(["entities"], entities);
62955
+ if (row.contexts_json != null) {
62956
+ const ctx = JSON.parse(row.contexts_json);
62957
+ if (ctx) doc.setIn(["entityContexts"], ctx);
62958
+ }
62959
+ saveConfigDoc(configPath, doc);
62960
+ return true;
62961
+ } finally {
62962
+ peek.close();
62963
+ }
62964
+ } catch (e6) {
62965
+ console.warn(
62966
+ "[hydrateMemberConfigFromCloud] could not hydrate member schema:",
62967
+ e6.message
62968
+ );
62969
+ return false;
62970
+ }
62971
+ }
62972
+
62770
62973
  // src/cloud/settings.ts
62771
62974
  init_adapter();
62772
62975
  init_rls();
@@ -62870,7 +63073,7 @@ var MEMBER_READABLE_BOOKKEEPING = [
62870
63073
  {
62871
63074
  name: "_lattice_gui_audit",
62872
63075
  privs: "SELECT, INSERT",
62873
- why: "the member's own GUI undo/redo log (session-scoped)"
63076
+ why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
62874
63077
  },
62875
63078
  {
62876
63079
  name: "__lattice_user_identity",
@@ -62881,6 +63084,11 @@ var MEMBER_READABLE_BOOKKEEPING = [
62881
63084
  name: "__lattice_changelog",
62882
63085
  privs: "SELECT, INSERT",
62883
63086
  why: "per-viewer-RLS-filtered change history for observe()/history (the policy filters reads, so the base grant is safe)"
63087
+ },
63088
+ {
63089
+ name: "__lattice_shared_schema",
63090
+ privs: "SELECT",
63091
+ why: "owner-published entity/render layout (entities + entityContexts) a joined member hydrates its config from so render produces the full context tree"
62884
63092
  }
62885
63093
  ];
62886
63094
  var MEMBER_EXECUTE_FUNCTIONS = [
@@ -63037,6 +63245,7 @@ async function secureCloud(db) {
63037
63245
  await db.ensureObservationSubstrate();
63038
63246
  await enableChangelogRls(db);
63039
63247
  await enableChatPrivacyRls(db);
63248
+ await enableGuiAuditRls(db);
63040
63249
  await convergeLegacyColumnAudience(db);
63041
63250
  const registered = db.getRegisteredTableNames();
63042
63251
  for (const table of registered) {
@@ -63133,21 +63342,6 @@ var FeedBus = class {
63133
63342
  init_fts();
63134
63343
  init_mutations();
63135
63344
 
63136
- // src/gui/config-io.ts
63137
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync6 } from "fs";
63138
- import { parseDocument as parseDocument2 } from "yaml";
63139
- async function execSql(db, sql) {
63140
- const adapter = db._adapter;
63141
- if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
63142
- await adapter.runAsync(sql);
63143
- }
63144
- function loadConfigDoc(configPath) {
63145
- return parseDocument2(readFileSync14(configPath, "utf8"));
63146
- }
63147
- function saveConfigDoc(configPath, doc) {
63148
- writeFileSync6(configPath, doc.toString(), "utf8");
63149
- }
63150
-
63151
63345
  // src/gui/schema-ops.ts
63152
63346
  init_parser();
63153
63347
  init_canonical_context();
@@ -64683,6 +64877,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
64683
64877
  const result = await migrateLatticeData(ctx.db, target);
64684
64878
  await target.rebuildFtsIndexes();
64685
64879
  await secureCloud(target);
64880
+ await publishSharedSchema(target, ctx.configPath);
64686
64881
  target.close();
64687
64882
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
64688
64883
  const backupPath = archiveLocalSqlite(sourceDbPath);
@@ -66541,10 +66736,13 @@ function resolveOutputDirForConfig(configPath) {
66541
66736
  async function openConfig(configPath, outputDir, autoRender = false, realtimeWatchdogMs) {
66542
66737
  healRawDbUrl(configPath);
66543
66738
  const parsed = parseConfigFile(configPath);
66739
+ const encryptionKey = getOrCreateMasterKey();
66544
66740
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
66545
66741
  mkdirSync11(dirname14(parsed.dbPath), { recursive: true });
66546
66742
  }
66547
- const encryptionKey = getOrCreateMasterKey();
66743
+ if (/^postgres(ql)?:\/\//i.test(parsed.dbPath)) {
66744
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
66745
+ }
66548
66746
  const db = new Lattice({ config: configPath }, { encryptionKey });
66549
66747
  registerNativeEntities(db);
66550
66748
  db.define("_lattice_gui_meta", {
@@ -66701,16 +66899,27 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
66701
66899
  await db.ensureObservationSubstrate();
66702
66900
  await enableChangelogRls(db);
66703
66901
  await enableChatPrivacyRls(db);
66902
+ await enableGuiAuditRls(db);
66704
66903
  const access = await reconcileCloudMemberAccess(db);
66705
66904
  convergeWarnings = access.skipped;
66706
66905
  for (const s2 of convergeWarnings) {
66707
66906
  console.warn(`[openConfig] cloud converge could not manage "${s2.table}": ${s2.reason}`);
66708
66907
  }
66908
+ await publishSharedSchema(db, configPath);
66709
66909
  }
66710
66910
  } catch (e6) {
66711
66911
  console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
66712
66912
  }
66713
66913
  }
66914
+ if (memberOpen) {
66915
+ const userTables = db.getRegisteredTableNames().filter((t8) => !t8.startsWith("_") && !discoveredJunctions.has(t8));
66916
+ if (userTables.length === 0) {
66917
+ convergeWarnings.push({
66918
+ table: "(schema)",
66919
+ 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."
66920
+ });
66921
+ }
66922
+ }
66714
66923
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
66715
66924
  for (const name of db.getRegisteredTableNames()) {
66716
66925
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -69298,7 +69507,7 @@ function printHelp() {
69298
69507
  );
69299
69508
  }
69300
69509
  function getVersion() {
69301
- if (true) return "3.4.1";
69510
+ if (true) return "3.4.3";
69302
69511
  try {
69303
69512
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
69304
69513
  const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8"));
@@ -69366,14 +69575,17 @@ function runGenerate(args) {
69366
69575
  }
69367
69576
  async function runRender(args) {
69368
69577
  const outputDir = resolve11(args.output);
69578
+ const configPath = resolve11(args.config);
69369
69579
  let parsed;
69370
69580
  try {
69371
- parsed = parseConfigFile(resolve11(args.config));
69581
+ parsed = parseConfigFile(configPath);
69372
69582
  } catch (e6) {
69373
69583
  console.error(`Error: ${e6.message}`);
69374
69584
  process.exit(1);
69375
69585
  }
69376
- const db = new Lattice({ config: resolve11(args.config) });
69586
+ const encryptionKey = getOrCreateMasterKey();
69587
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
69588
+ const db = new Lattice({ config: configPath }, { encryptionKey });
69377
69589
  try {
69378
69590
  await db.init();
69379
69591
  const start = Date.now();
@@ -69389,7 +69601,6 @@ async function runRender(args) {
69389
69601
  } finally {
69390
69602
  db.close();
69391
69603
  }
69392
- void parsed;
69393
69604
  }
69394
69605
  async function runReconcile(args, isDryRun) {
69395
69606
  const outputDir = resolve11(args.output);
package/dist/index.cjs CHANGED
@@ -47378,6 +47378,33 @@ async function ownPolyfillsByGroup(db) {
47378
47378
  }
47379
47379
  }
47380
47380
  }
47381
+ async function enableGuiAuditRls(db) {
47382
+ if (!isPg(db)) return;
47383
+ const reg = await getAsyncOrSync(db.adapter, `SELECT to_regclass($1) AS reg`, [
47384
+ "_lattice_gui_audit"
47385
+ ]);
47386
+ if (reg?.reg == null) return;
47387
+ await runCloudBootstrapSql(
47388
+ db,
47389
+ `
47390
+ ALTER TABLE "_lattice_gui_audit" ENABLE ROW LEVEL SECURITY;
47391
+ ALTER TABLE "_lattice_gui_audit" FORCE ROW LEVEL SECURITY;
47392
+ DROP POLICY IF EXISTS "lattice_gui_audit_owner" ON "_lattice_gui_audit";
47393
+ DROP POLICY IF EXISTS "lattice_gui_audit_sel" ON "_lattice_gui_audit";
47394
+ CREATE POLICY "lattice_gui_audit_sel" ON "_lattice_gui_audit" FOR SELECT
47395
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47396
+ DROP POLICY IF EXISTS "lattice_gui_audit_ins" ON "_lattice_gui_audit";
47397
+ CREATE POLICY "lattice_gui_audit_ins" ON "_lattice_gui_audit" FOR INSERT
47398
+ WITH CHECK ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47399
+ DROP POLICY IF EXISTS "lattice_gui_audit_upd" ON "_lattice_gui_audit";
47400
+ CREATE POLICY "lattice_gui_audit_upd" ON "_lattice_gui_audit" FOR UPDATE
47401
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47402
+ DROP POLICY IF EXISTS "lattice_gui_audit_del" ON "_lattice_gui_audit";
47403
+ CREATE POLICY "lattice_gui_audit_del" ON "_lattice_gui_audit" FOR DELETE
47404
+ USING ("row_id" IS NULL OR lattice_row_visible("table_name", "row_id"));
47405
+ `
47406
+ );
47407
+ }
47381
47408
  async function installCloudRls(db) {
47382
47409
  if (!isPg(db)) return;
47383
47410
  const schema = await cloudSchema(db);
@@ -47543,6 +47570,18 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
47543
47570
  -- bootstrap is now run directly + idempotently, not version-gated).
47544
47571
  ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
47545
47572
 
47573
+ -- Owner-published entity/render LAYOUT (the entities + entityContexts config
47574
+ -- blocks), so a joined member \u2014 whose generated config has entities: {} \u2014 can
47575
+ -- hydrate the full render layout and produce a complete context tree. This holds
47576
+ -- schema CONFIG, not row data, so it is safe to share with members (granted
47577
+ -- SELECT). A shared singleton, like __lattice_user_identity: no per-row RLS.
47578
+ CREATE TABLE IF NOT EXISTS "__lattice_shared_schema" (
47579
+ "id" TEXT PRIMARY KEY DEFAULT 'singleton',
47580
+ "entities_json" TEXT,
47581
+ "contexts_json" TEXT,
47582
+ "updated_at" TEXT
47583
+ );
47584
+
47546
47585
  -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
47547
47586
  -- the cloud with their minted credential, the join path calls this to CLAIM the
47548
47587
  -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
@@ -50471,7 +50510,7 @@ function findDocsDir() {
50471
50510
  }
50472
50511
  for (let i6 = 0; i6 < 8; i6++) {
50473
50512
  const candidate = (0, import_node_path31.join)(dir, "docs");
50474
- if ((0, import_node_fs28.existsSync)((0, import_node_path31.join)(candidate, "cloud.md"))) {
50513
+ if ((0, import_node_fs29.existsSync)((0, import_node_path31.join)(candidate, "cloud.md"))) {
50475
50514
  _docsDir = candidate;
50476
50515
  return _docsDir;
50477
50516
  }
@@ -50512,13 +50551,13 @@ function allSections() {
50512
50551
  const out = [];
50513
50552
  let files = [];
50514
50553
  try {
50515
- files = (0, import_node_fs28.readdirSync)(dir).filter((f6) => f6.endsWith(".md"));
50554
+ files = (0, import_node_fs29.readdirSync)(dir).filter((f6) => f6.endsWith(".md"));
50516
50555
  } catch {
50517
50556
  files = [];
50518
50557
  }
50519
50558
  for (const f6 of files) {
50520
50559
  try {
50521
- out.push(...sectionsOf(f6, (0, import_node_fs28.readFileSync)((0, import_node_path31.join)(dir, f6), "utf8")));
50560
+ out.push(...sectionsOf(f6, (0, import_node_fs29.readFileSync)((0, import_node_path31.join)(dir, f6), "utf8")));
50522
50561
  } catch {
50523
50562
  }
50524
50563
  }
@@ -50560,11 +50599,11 @@ function searchLatticeDocs(query, limit = 4) {
50560
50599
  }))
50561
50600
  };
50562
50601
  }
50563
- var import_node_fs28, import_node_path31, import_node_url2, import_meta5, _docsDir, MAX_SECTION_CHARS, _cache;
50602
+ var import_node_fs29, import_node_path31, import_node_url2, import_meta5, _docsDir, MAX_SECTION_CHARS, _cache;
50564
50603
  var init_lattice_docs = __esm({
50565
50604
  "src/gui/ai/lattice-docs.ts"() {
50566
50605
  "use strict";
50567
- import_node_fs28 = require("fs");
50606
+ import_node_fs29 = require("fs");
50568
50607
  import_node_path31 = require("path");
50569
50608
  import_node_url2 = require("url");
50570
50609
  import_meta5 = {};
@@ -54632,7 +54671,7 @@ var MEMBER_READABLE_BOOKKEEPING = [
54632
54671
  {
54633
54672
  name: "_lattice_gui_audit",
54634
54673
  privs: "SELECT, INSERT",
54635
- why: "the member's own GUI undo/redo log (session-scoped)"
54674
+ why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
54636
54675
  },
54637
54676
  {
54638
54677
  name: "__lattice_user_identity",
@@ -54643,6 +54682,11 @@ var MEMBER_READABLE_BOOKKEEPING = [
54643
54682
  name: "__lattice_changelog",
54644
54683
  privs: "SELECT, INSERT",
54645
54684
  why: "per-viewer-RLS-filtered change history for observe()/history (the policy filters reads, so the base grant is safe)"
54685
+ },
54686
+ {
54687
+ name: "__lattice_shared_schema",
54688
+ privs: "SELECT",
54689
+ why: "owner-published entity/render layout (entities + entityContexts) a joined member hydrates its config from so render produces the full context tree"
54646
54690
  }
54647
54691
  ];
54648
54692
  var MEMBER_EXECUTE_FUNCTIONS = [
@@ -54799,6 +54843,7 @@ async function secureCloud(db) {
54799
54843
  await db.ensureObservationSubstrate();
54800
54844
  await enableChangelogRls(db);
54801
54845
  await enableChatPrivacyRls(db);
54846
+ await enableGuiAuditRls(db);
54802
54847
  await convergeLegacyColumnAudience(db);
54803
54848
  const registered = db.getRegisteredTableNames();
54804
54849
  for (const table of registered) {
@@ -56354,6 +56399,25 @@ var css = `
56354
56399
  animation: feedSpin 0.7s linear infinite; vertical-align: middle;
56355
56400
  }
56356
56401
  @keyframes feedSpin { to { transform: rotate(360deg); } }
56402
+ /* Batch-upload progress bar \u2014 pinned to the top of the feed while a
56403
+ multi-file drop drains through the bounded-concurrency queue. */
56404
+ .ingest-progress {
56405
+ position: sticky; top: 0; z-index: 3;
56406
+ display: flex; flex-direction: column; gap: 6px;
56407
+ padding: 8px 10px; border-radius: 8px;
56408
+ background: var(--surface); border: 1px solid rgba(190, 242, 100, 0.22);
56409
+ box-shadow: var(--shadow-1), var(--glow-accent-soft);
56410
+ }
56411
+ .ingest-progress-label { font-size: 12px; font-weight: 500; color: var(--text); }
56412
+ .ingest-progress-track {
56413
+ height: 6px; border-radius: 999px; overflow: hidden; background: var(--border-strong);
56414
+ }
56415
+ .ingest-progress-fill {
56416
+ height: 100%; width: 0%; border-radius: 999px;
56417
+ background: linear-gradient(90deg, var(--accent-deep), var(--accent));
56418
+ box-shadow: 0 0 8px rgba(190, 242, 100, 0.5);
56419
+ transition: width 0.3s ease;
56420
+ }
56357
56421
  .assistant-rail {
56358
56422
  position: relative;
56359
56423
  background:
@@ -63690,6 +63754,63 @@ var appJs = `
63690
63754
  // Browsers can't expose the local path, so we POST the bytes; the
63691
63755
  // server extracts + summarizes, then discards them (path stays null).
63692
63756
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
63757
+ // Cap how many uploads are in flight at once. A browser allows only ~6
63758
+ // HTTP/1.1 connections per host, so a bulk drop of N files would fire N
63759
+ // upload POSTs in parallel and saturate that budget \u2014 every other data
63760
+ // request (entities, rows, navigation) then queues for minutes behind the
63761
+ // multi-minute ingests and the GUI looks frozen. Holding uploads to a few
63762
+ // at a time leaves connections free for the rest of the app (and eases the
63763
+ // AI rate limit each ingest hits server-side). The realtime/feed streams are
63764
+ // already off this budget \u2014 they share one WebSocket \u2014 so this is the last
63765
+ // place a big batch could starve the connection pool.
63766
+ var INGEST_MAX_CONCURRENCY = 3;
63767
+ // Run a batch of upload thunks with at most \`limit\` in flight, calling
63768
+ // onProgress(done, total) as each settles. One failure never stalls the
63769
+ // batch \u2014 uploadFile surfaces its own error and resolves.
63770
+ function runIngestBatch(thunks, limit, onProgress) {
63771
+ return new Promise(function (resolve) {
63772
+ var total = thunks.length, idx = 0, done = 0;
63773
+ function startNext() {
63774
+ if (idx >= total) return;
63775
+ var thunk = thunks[idx++];
63776
+ Promise.resolve().then(thunk).catch(function () { /* already surfaced */ }).then(function () {
63777
+ done++;
63778
+ if (onProgress) onProgress(done, total);
63779
+ if (done === total) resolve(); else startNext();
63780
+ });
63781
+ }
63782
+ for (var i = 0; i < Math.min(limit, total); i++) startNext();
63783
+ });
63784
+ }
63785
+ // A batch-upload progress bar pinned to the top of the rail feed
63786
+ // ("Analyzing N of M\u2026"). The per-file "Analyzing <name>\u2026" cards still
63787
+ // appear, but only INGEST_MAX_CONCURRENCY at a time; this gives the
63788
+ // whole-batch view that the individual cards can't. Returns
63789
+ // { update(done, total), done() }.
63790
+ function ingestProgress(total) {
63791
+ var feedEl = document.getElementById('rail-feed');
63792
+ if (!feedEl) return { update: function () {}, done: function () {} };
63793
+ railEmptyGone();
63794
+ var wrap = document.createElement('div');
63795
+ wrap.className = 'ingest-progress';
63796
+ wrap.innerHTML =
63797
+ '<div class="ingest-progress-label">Analyzing 0 of ' + total + '\u2026</div>' +
63798
+ '<div class="ingest-progress-track"><div class="ingest-progress-fill"></div></div>';
63799
+ feedEl.insertBefore(wrap, feedEl.firstChild);
63800
+ var label = wrap.querySelector('.ingest-progress-label');
63801
+ var fill = wrap.querySelector('.ingest-progress-fill');
63802
+ return {
63803
+ update: function (n, t) {
63804
+ if (label) label.textContent = 'Analyzing ' + n + ' of ' + t + '\u2026';
63805
+ if (fill) fill.style.width = Math.round((n / t) * 100) + '%';
63806
+ },
63807
+ done: function () {
63808
+ if (fill) fill.style.width = '100%';
63809
+ if (label) label.textContent = 'Analyzed ' + total + ' file' + (total === 1 ? '' : 's');
63810
+ setTimeout(function () { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); }, 2500);
63811
+ },
63812
+ };
63813
+ }
63693
63814
  // Append a transient "Analyzing <file>\u2026" row to the feed so the user sees
63694
63815
  // the ingest is processing in the background; returns a disposer. The real
63695
63816
  // create/link feed events stream in over SSE as the server materializes them.
@@ -63749,7 +63870,14 @@ var appJs = `
63749
63870
  });
63750
63871
  return;
63751
63872
  }
63752
- for (var i = 0; i < files.length; i++) uploadFile(files[i]);
63873
+ // Multi-file: drain through the bounded-concurrency queue (so a big drop
63874
+ // can't saturate the connection budget) with a batch progress bar.
63875
+ var bar = ingestProgress(files.length);
63876
+ var thunks = [];
63877
+ for (var i = 0; i < files.length; i++) {
63878
+ (function (f) { thunks.push(function () { return uploadFile(f); }); })(files[i]);
63879
+ }
63880
+ runIngestBatch(thunks, INGEST_MAX_CONCURRENCY, bar.update).then(bar.done);
63753
63881
  }
63754
63882
  // Mobile: tapping the handle expands/collapses the bottom drawer.
63755
63883
  function initRailDrawer() {
@@ -64752,6 +64880,87 @@ init_postgres();
64752
64880
  init_cloud_connect();
64753
64881
  init_rls();
64754
64882
 
64883
+ // src/cloud/shared-schema.ts
64884
+ init_lattice();
64885
+ init_adapter();
64886
+
64887
+ // src/gui/config-io.ts
64888
+ var import_node_fs28 = require("fs");
64889
+ var import_yaml4 = require("yaml");
64890
+ async function execSql(db, sql) {
64891
+ const adapter = db._adapter;
64892
+ if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64893
+ await adapter.runAsync(sql);
64894
+ }
64895
+ function loadConfigDoc(configPath) {
64896
+ return (0, import_yaml4.parseDocument)((0, import_node_fs28.readFileSync)(configPath, "utf8"));
64897
+ }
64898
+ function saveConfigDoc(configPath, doc) {
64899
+ (0, import_node_fs28.writeFileSync)(configPath, doc.toString(), "utf8");
64900
+ }
64901
+
64902
+ // src/cloud/shared-schema.ts
64903
+ async function publishSharedSchema(db, configPath) {
64904
+ if (db.getDialect() !== "postgres") return;
64905
+ const cfg = loadConfigDoc(configPath).toJSON();
64906
+ const entities = cfg.entities ?? {};
64907
+ if (Object.keys(entities).length === 0) return;
64908
+ await runAsyncOrSync(
64909
+ db.adapter,
64910
+ `INSERT INTO "__lattice_shared_schema" ("id","entities_json","contexts_json","updated_at")
64911
+ VALUES ('singleton', $1, $2, $3)
64912
+ ON CONFLICT ("id") DO UPDATE SET
64913
+ "entities_json" = EXCLUDED."entities_json",
64914
+ "contexts_json" = EXCLUDED."contexts_json",
64915
+ "updated_at" = EXCLUDED."updated_at"`,
64916
+ [
64917
+ JSON.stringify(entities),
64918
+ JSON.stringify(cfg.entityContexts ?? null),
64919
+ (/* @__PURE__ */ new Date()).toISOString()
64920
+ ]
64921
+ );
64922
+ }
64923
+ async function hydrateMemberConfigFromCloud(configPath, dbUrl, encryptionKey) {
64924
+ if (!isPostgresUrl(dbUrl)) return false;
64925
+ const existing = loadConfigDoc(configPath).toJSON();
64926
+ if (Object.keys(existing.entities ?? {}).length > 0) return false;
64927
+ try {
64928
+ const peek = new Lattice({ config: configPath }, { encryptionKey });
64929
+ try {
64930
+ await peek.init({ introspectOnly: true });
64931
+ const reg = await getAsyncOrSync(
64932
+ peek.adapter,
64933
+ "SELECT to_regclass('__lattice_shared_schema') AS reg"
64934
+ );
64935
+ if (reg?.reg == null) return false;
64936
+ const row = await getAsyncOrSync(
64937
+ peek.adapter,
64938
+ 'SELECT "entities_json","contexts_json" FROM "__lattice_shared_schema" WHERE "id" = $1',
64939
+ ["singleton"]
64940
+ );
64941
+ if (row?.entities_json == null) return false;
64942
+ const entities = JSON.parse(row.entities_json);
64943
+ if (Object.keys(entities).length === 0) return false;
64944
+ const doc = loadConfigDoc(configPath);
64945
+ doc.setIn(["entities"], entities);
64946
+ if (row.contexts_json != null) {
64947
+ const ctx = JSON.parse(row.contexts_json);
64948
+ if (ctx) doc.setIn(["entityContexts"], ctx);
64949
+ }
64950
+ saveConfigDoc(configPath, doc);
64951
+ return true;
64952
+ } finally {
64953
+ peek.close();
64954
+ }
64955
+ } catch (e6) {
64956
+ console.warn(
64957
+ "[hydrateMemberConfigFromCloud] could not hydrate member schema:",
64958
+ e6.message
64959
+ );
64960
+ return false;
64961
+ }
64962
+ }
64963
+
64755
64964
  // src/gui/meta-gen.ts
64756
64965
  init_assistant_routes();
64757
64966
  init_chat();
@@ -64840,21 +65049,6 @@ var FeedBus = class {
64840
65049
  init_fts();
64841
65050
  init_mutations();
64842
65051
 
64843
- // src/gui/config-io.ts
64844
- var import_node_fs29 = require("fs");
64845
- var import_yaml4 = require("yaml");
64846
- async function execSql(db, sql) {
64847
- const adapter = db._adapter;
64848
- if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64849
- await adapter.runAsync(sql);
64850
- }
64851
- function loadConfigDoc(configPath) {
64852
- return (0, import_yaml4.parseDocument)((0, import_node_fs29.readFileSync)(configPath, "utf8"));
64853
- }
64854
- function saveConfigDoc(configPath, doc) {
64855
- (0, import_node_fs29.writeFileSync)(configPath, doc.toString(), "utf8");
64856
- }
64857
-
64858
65052
  // src/gui/schema-ops.ts
64859
65053
  init_parser();
64860
65054
  init_canonical_context();
@@ -66043,6 +66237,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66043
66237
  const result = await migrateLatticeData(ctx.db, target);
66044
66238
  await target.rebuildFtsIndexes();
66045
66239
  await secureCloud(target);
66240
+ await publishSharedSchema(target, ctx.configPath);
66046
66241
  target.close();
66047
66242
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
66048
66243
  const backupPath = archiveLocalSqlite(sourceDbPath);
@@ -67751,10 +67946,13 @@ function resolveOutputDirForConfig(configPath) {
67751
67946
  async function openConfig(configPath, outputDir, autoRender = false, realtimeWatchdogMs) {
67752
67947
  healRawDbUrl(configPath);
67753
67948
  const parsed = parseConfigFile(configPath);
67949
+ const encryptionKey = getOrCreateMasterKey();
67754
67950
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
67755
67951
  (0, import_node_fs35.mkdirSync)((0, import_node_path38.dirname)(parsed.dbPath), { recursive: true });
67756
67952
  }
67757
- const encryptionKey = getOrCreateMasterKey();
67953
+ if (/^postgres(ql)?:\/\//i.test(parsed.dbPath)) {
67954
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
67955
+ }
67758
67956
  const db = new Lattice({ config: configPath }, { encryptionKey });
67759
67957
  registerNativeEntities(db);
67760
67958
  db.define("_lattice_gui_meta", {
@@ -67911,16 +68109,27 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
67911
68109
  await db.ensureObservationSubstrate();
67912
68110
  await enableChangelogRls(db);
67913
68111
  await enableChatPrivacyRls(db);
68112
+ await enableGuiAuditRls(db);
67914
68113
  const access = await reconcileCloudMemberAccess(db);
67915
68114
  convergeWarnings = access.skipped;
67916
68115
  for (const s2 of convergeWarnings) {
67917
68116
  console.warn(`[openConfig] cloud converge could not manage "${s2.table}": ${s2.reason}`);
67918
68117
  }
68118
+ await publishSharedSchema(db, configPath);
67919
68119
  }
67920
68120
  } catch (e6) {
67921
68121
  console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
67922
68122
  }
67923
68123
  }
68124
+ if (memberOpen) {
68125
+ const userTables = db.getRegisteredTableNames().filter((t8) => !t8.startsWith("_") && !discoveredJunctions.has(t8));
68126
+ if (userTables.length === 0) {
68127
+ convergeWarnings.push({
68128
+ table: "(schema)",
68129
+ 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."
68130
+ });
68131
+ }
68132
+ }
67924
68133
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
67925
68134
  for (const name of db.getRegisteredTableNames()) {
67926
68135
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
package/dist/index.js CHANGED
@@ -47371,6 +47371,33 @@ async function ownPolyfillsByGroup(db) {
47371
47371
  }
47372
47372
  }
47373
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
+ }
47374
47401
  async function installCloudRls(db) {
47375
47402
  if (!isPg(db)) return;
47376
47403
  const schema = await cloudSchema(db);
@@ -47536,6 +47563,18 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
47536
47563
  -- bootstrap is now run directly + idempotently, not version-gated).
47537
47564
  ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
47538
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
+
47539
47578
  -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
47540
47579
  -- the cloud with their minted credential, the join path calls this to CLAIM the
47541
47580
  -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
@@ -50452,7 +50491,7 @@ var init_registry = __esm({
50452
50491
  });
50453
50492
 
50454
50493
  // src/gui/ai/lattice-docs.ts
50455
- 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";
50456
50495
  import { dirname as dirname8, join as join25 } from "path";
50457
50496
  import { fileURLToPath as fileURLToPath2 } from "url";
50458
50497
  function findDocsDir() {
@@ -50512,7 +50551,7 @@ function allSections() {
50512
50551
  }
50513
50552
  for (const f6 of files) {
50514
50553
  try {
50515
- out.push(...sectionsOf(f6, readFileSync16(join25(dir, f6), "utf8")));
50554
+ out.push(...sectionsOf(f6, readFileSync17(join25(dir, f6), "utf8")));
50516
50555
  } catch {
50517
50556
  }
50518
50557
  }
@@ -54450,7 +54489,7 @@ var MEMBER_READABLE_BOOKKEEPING = [
54450
54489
  {
54451
54490
  name: "_lattice_gui_audit",
54452
54491
  privs: "SELECT, INSERT",
54453
- 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"
54454
54493
  },
54455
54494
  {
54456
54495
  name: "__lattice_user_identity",
@@ -54461,6 +54500,11 @@ var MEMBER_READABLE_BOOKKEEPING = [
54461
54500
  name: "__lattice_changelog",
54462
54501
  privs: "SELECT, INSERT",
54463
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"
54464
54508
  }
54465
54509
  ];
54466
54510
  var MEMBER_EXECUTE_FUNCTIONS = [
@@ -54617,6 +54661,7 @@ async function secureCloud(db) {
54617
54661
  await db.ensureObservationSubstrate();
54618
54662
  await enableChangelogRls(db);
54619
54663
  await enableChatPrivacyRls(db);
54664
+ await enableGuiAuditRls(db);
54620
54665
  await convergeLegacyColumnAudience(db);
54621
54666
  const registered = db.getRegisteredTableNames();
54622
54667
  for (const table of registered) {
@@ -56179,6 +56224,25 @@ var css = `
56179
56224
  animation: feedSpin 0.7s linear infinite; vertical-align: middle;
56180
56225
  }
56181
56226
  @keyframes feedSpin { to { transform: rotate(360deg); } }
56227
+ /* Batch-upload progress bar \u2014 pinned to the top of the feed while a
56228
+ multi-file drop drains through the bounded-concurrency queue. */
56229
+ .ingest-progress {
56230
+ position: sticky; top: 0; z-index: 3;
56231
+ display: flex; flex-direction: column; gap: 6px;
56232
+ padding: 8px 10px; border-radius: 8px;
56233
+ background: var(--surface); border: 1px solid rgba(190, 242, 100, 0.22);
56234
+ box-shadow: var(--shadow-1), var(--glow-accent-soft);
56235
+ }
56236
+ .ingest-progress-label { font-size: 12px; font-weight: 500; color: var(--text); }
56237
+ .ingest-progress-track {
56238
+ height: 6px; border-radius: 999px; overflow: hidden; background: var(--border-strong);
56239
+ }
56240
+ .ingest-progress-fill {
56241
+ height: 100%; width: 0%; border-radius: 999px;
56242
+ background: linear-gradient(90deg, var(--accent-deep), var(--accent));
56243
+ box-shadow: 0 0 8px rgba(190, 242, 100, 0.5);
56244
+ transition: width 0.3s ease;
56245
+ }
56182
56246
  .assistant-rail {
56183
56247
  position: relative;
56184
56248
  background:
@@ -63515,6 +63579,63 @@ var appJs = `
63515
63579
  // Browsers can't expose the local path, so we POST the bytes; the
63516
63580
  // server extracts + summarizes, then discards them (path stays null).
63517
63581
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
63582
+ // Cap how many uploads are in flight at once. A browser allows only ~6
63583
+ // HTTP/1.1 connections per host, so a bulk drop of N files would fire N
63584
+ // upload POSTs in parallel and saturate that budget \u2014 every other data
63585
+ // request (entities, rows, navigation) then queues for minutes behind the
63586
+ // multi-minute ingests and the GUI looks frozen. Holding uploads to a few
63587
+ // at a time leaves connections free for the rest of the app (and eases the
63588
+ // AI rate limit each ingest hits server-side). The realtime/feed streams are
63589
+ // already off this budget \u2014 they share one WebSocket \u2014 so this is the last
63590
+ // place a big batch could starve the connection pool.
63591
+ var INGEST_MAX_CONCURRENCY = 3;
63592
+ // Run a batch of upload thunks with at most \`limit\` in flight, calling
63593
+ // onProgress(done, total) as each settles. One failure never stalls the
63594
+ // batch \u2014 uploadFile surfaces its own error and resolves.
63595
+ function runIngestBatch(thunks, limit, onProgress) {
63596
+ return new Promise(function (resolve) {
63597
+ var total = thunks.length, idx = 0, done = 0;
63598
+ function startNext() {
63599
+ if (idx >= total) return;
63600
+ var thunk = thunks[idx++];
63601
+ Promise.resolve().then(thunk).catch(function () { /* already surfaced */ }).then(function () {
63602
+ done++;
63603
+ if (onProgress) onProgress(done, total);
63604
+ if (done === total) resolve(); else startNext();
63605
+ });
63606
+ }
63607
+ for (var i = 0; i < Math.min(limit, total); i++) startNext();
63608
+ });
63609
+ }
63610
+ // A batch-upload progress bar pinned to the top of the rail feed
63611
+ // ("Analyzing N of M\u2026"). The per-file "Analyzing <name>\u2026" cards still
63612
+ // appear, but only INGEST_MAX_CONCURRENCY at a time; this gives the
63613
+ // whole-batch view that the individual cards can't. Returns
63614
+ // { update(done, total), done() }.
63615
+ function ingestProgress(total) {
63616
+ var feedEl = document.getElementById('rail-feed');
63617
+ if (!feedEl) return { update: function () {}, done: function () {} };
63618
+ railEmptyGone();
63619
+ var wrap = document.createElement('div');
63620
+ wrap.className = 'ingest-progress';
63621
+ wrap.innerHTML =
63622
+ '<div class="ingest-progress-label">Analyzing 0 of ' + total + '\u2026</div>' +
63623
+ '<div class="ingest-progress-track"><div class="ingest-progress-fill"></div></div>';
63624
+ feedEl.insertBefore(wrap, feedEl.firstChild);
63625
+ var label = wrap.querySelector('.ingest-progress-label');
63626
+ var fill = wrap.querySelector('.ingest-progress-fill');
63627
+ return {
63628
+ update: function (n, t) {
63629
+ if (label) label.textContent = 'Analyzing ' + n + ' of ' + t + '\u2026';
63630
+ if (fill) fill.style.width = Math.round((n / t) * 100) + '%';
63631
+ },
63632
+ done: function () {
63633
+ if (fill) fill.style.width = '100%';
63634
+ if (label) label.textContent = 'Analyzed ' + total + ' file' + (total === 1 ? '' : 's');
63635
+ setTimeout(function () { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); }, 2500);
63636
+ },
63637
+ };
63638
+ }
63518
63639
  // Append a transient "Analyzing <file>\u2026" row to the feed so the user sees
63519
63640
  // the ingest is processing in the background; returns a disposer. The real
63520
63641
  // create/link feed events stream in over SSE as the server materializes them.
@@ -63574,7 +63695,14 @@ var appJs = `
63574
63695
  });
63575
63696
  return;
63576
63697
  }
63577
- for (var i = 0; i < files.length; i++) uploadFile(files[i]);
63698
+ // Multi-file: drain through the bounded-concurrency queue (so a big drop
63699
+ // can't saturate the connection budget) with a batch progress bar.
63700
+ var bar = ingestProgress(files.length);
63701
+ var thunks = [];
63702
+ for (var i = 0; i < files.length; i++) {
63703
+ (function (f) { thunks.push(function () { return uploadFile(f); }); })(files[i]);
63704
+ }
63705
+ runIngestBatch(thunks, INGEST_MAX_CONCURRENCY, bar.update).then(bar.done);
63578
63706
  }
63579
63707
  // Mobile: tapping the handle expands/collapses the bottom drawer.
63580
63708
  function initRailDrawer() {
@@ -64576,6 +64704,87 @@ init_postgres();
64576
64704
  init_cloud_connect();
64577
64705
  init_rls();
64578
64706
 
64707
+ // src/cloud/shared-schema.ts
64708
+ init_lattice();
64709
+ init_adapter();
64710
+
64711
+ // src/gui/config-io.ts
64712
+ import { readFileSync as readFileSync16, writeFileSync as writeFileSync5 } from "fs";
64713
+ import { parseDocument as parseDocument2 } from "yaml";
64714
+ async function execSql(db, sql) {
64715
+ const adapter = db._adapter;
64716
+ if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64717
+ await adapter.runAsync(sql);
64718
+ }
64719
+ function loadConfigDoc(configPath) {
64720
+ return parseDocument2(readFileSync16(configPath, "utf8"));
64721
+ }
64722
+ function saveConfigDoc(configPath, doc) {
64723
+ writeFileSync5(configPath, doc.toString(), "utf8");
64724
+ }
64725
+
64726
+ // src/cloud/shared-schema.ts
64727
+ async function publishSharedSchema(db, configPath) {
64728
+ if (db.getDialect() !== "postgres") return;
64729
+ const cfg = loadConfigDoc(configPath).toJSON();
64730
+ const entities = cfg.entities ?? {};
64731
+ if (Object.keys(entities).length === 0) return;
64732
+ await runAsyncOrSync(
64733
+ db.adapter,
64734
+ `INSERT INTO "__lattice_shared_schema" ("id","entities_json","contexts_json","updated_at")
64735
+ VALUES ('singleton', $1, $2, $3)
64736
+ ON CONFLICT ("id") DO UPDATE SET
64737
+ "entities_json" = EXCLUDED."entities_json",
64738
+ "contexts_json" = EXCLUDED."contexts_json",
64739
+ "updated_at" = EXCLUDED."updated_at"`,
64740
+ [
64741
+ JSON.stringify(entities),
64742
+ JSON.stringify(cfg.entityContexts ?? null),
64743
+ (/* @__PURE__ */ new Date()).toISOString()
64744
+ ]
64745
+ );
64746
+ }
64747
+ async function hydrateMemberConfigFromCloud(configPath, dbUrl, encryptionKey) {
64748
+ if (!isPostgresUrl(dbUrl)) return false;
64749
+ const existing = loadConfigDoc(configPath).toJSON();
64750
+ if (Object.keys(existing.entities ?? {}).length > 0) return false;
64751
+ try {
64752
+ const peek = new Lattice({ config: configPath }, { encryptionKey });
64753
+ try {
64754
+ await peek.init({ introspectOnly: true });
64755
+ const reg = await getAsyncOrSync(
64756
+ peek.adapter,
64757
+ "SELECT to_regclass('__lattice_shared_schema') AS reg"
64758
+ );
64759
+ if (reg?.reg == null) return false;
64760
+ const row = await getAsyncOrSync(
64761
+ peek.adapter,
64762
+ 'SELECT "entities_json","contexts_json" FROM "__lattice_shared_schema" WHERE "id" = $1',
64763
+ ["singleton"]
64764
+ );
64765
+ if (row?.entities_json == null) return false;
64766
+ const entities = JSON.parse(row.entities_json);
64767
+ if (Object.keys(entities).length === 0) return false;
64768
+ const doc = loadConfigDoc(configPath);
64769
+ doc.setIn(["entities"], entities);
64770
+ if (row.contexts_json != null) {
64771
+ const ctx = JSON.parse(row.contexts_json);
64772
+ if (ctx) doc.setIn(["entityContexts"], ctx);
64773
+ }
64774
+ saveConfigDoc(configPath, doc);
64775
+ return true;
64776
+ } finally {
64777
+ peek.close();
64778
+ }
64779
+ } catch (e6) {
64780
+ console.warn(
64781
+ "[hydrateMemberConfigFromCloud] could not hydrate member schema:",
64782
+ e6.message
64783
+ );
64784
+ return false;
64785
+ }
64786
+ }
64787
+
64579
64788
  // src/gui/meta-gen.ts
64580
64789
  init_assistant_routes();
64581
64790
  init_chat();
@@ -64664,21 +64873,6 @@ var FeedBus = class {
64664
64873
  init_fts();
64665
64874
  init_mutations();
64666
64875
 
64667
- // src/gui/config-io.ts
64668
- import { readFileSync as readFileSync17, writeFileSync as writeFileSync5 } from "fs";
64669
- import { parseDocument as parseDocument2 } from "yaml";
64670
- async function execSql(db, sql) {
64671
- const adapter = db._adapter;
64672
- if (!adapter.runAsync) throw new Error("Adapter does not support runAsync");
64673
- await adapter.runAsync(sql);
64674
- }
64675
- function loadConfigDoc(configPath) {
64676
- return parseDocument2(readFileSync17(configPath, "utf8"));
64677
- }
64678
- function saveConfigDoc(configPath, doc) {
64679
- writeFileSync5(configPath, doc.toString(), "utf8");
64680
- }
64681
-
64682
64876
  // src/gui/schema-ops.ts
64683
64877
  init_parser();
64684
64878
  init_canonical_context();
@@ -65867,6 +66061,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
65867
66061
  const result = await migrateLatticeData(ctx.db, target);
65868
66062
  await target.rebuildFtsIndexes();
65869
66063
  await secureCloud(target);
66064
+ await publishSharedSchema(target, ctx.configPath);
65870
66065
  target.close();
65871
66066
  const sourceDbPath = parseConfigFile(ctx.configPath).dbPath;
65872
66067
  const backupPath = archiveLocalSqlite(sourceDbPath);
@@ -67575,10 +67770,13 @@ function resolveOutputDirForConfig(configPath) {
67575
67770
  async function openConfig(configPath, outputDir, autoRender = false, realtimeWatchdogMs) {
67576
67771
  healRawDbUrl(configPath);
67577
67772
  const parsed = parseConfigFile(configPath);
67773
+ const encryptionKey = getOrCreateMasterKey();
67578
67774
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
67579
67775
  mkdirSync10(dirname13(parsed.dbPath), { recursive: true });
67580
67776
  }
67581
- const encryptionKey = getOrCreateMasterKey();
67777
+ if (/^postgres(ql)?:\/\//i.test(parsed.dbPath)) {
67778
+ await hydrateMemberConfigFromCloud(configPath, parsed.dbPath, encryptionKey);
67779
+ }
67582
67780
  const db = new Lattice({ config: configPath }, { encryptionKey });
67583
67781
  registerNativeEntities(db);
67584
67782
  db.define("_lattice_gui_meta", {
@@ -67735,16 +67933,27 @@ async function openConfig(configPath, outputDir, autoRender = false, realtimeWat
67735
67933
  await db.ensureObservationSubstrate();
67736
67934
  await enableChangelogRls(db);
67737
67935
  await enableChatPrivacyRls(db);
67936
+ await enableGuiAuditRls(db);
67738
67937
  const access = await reconcileCloudMemberAccess(db);
67739
67938
  convergeWarnings = access.skipped;
67740
67939
  for (const s2 of convergeWarnings) {
67741
67940
  console.warn(`[openConfig] cloud converge could not manage "${s2.table}": ${s2.reason}`);
67742
67941
  }
67942
+ await publishSharedSchema(db, configPath);
67743
67943
  }
67744
67944
  } catch (e6) {
67745
67945
  console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
67746
67946
  }
67747
67947
  }
67948
+ if (memberOpen) {
67949
+ const userTables = db.getRegisteredTableNames().filter((t8) => !t8.startsWith("_") && !discoveredJunctions.has(t8));
67950
+ if (userTables.length === 0) {
67951
+ convergeWarnings.push({
67952
+ table: "(schema)",
67953
+ 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."
67954
+ });
67955
+ }
67956
+ }
67748
67957
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
67749
67958
  for (const name of db.getRegisteredTableNames()) {
67750
67959
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",