latticesql 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -41629,37 +41629,63 @@ var THROTTLE_WINDOW_MS = 200;
41629
41629
  var ProgressThrottle = class {
41630
41630
  cb;
41631
41631
  windowMs;
41632
- lastEmit = 0;
41632
+ /**
41633
+ * Last passthrough time, keyed per table (`event.table`, or `''` for the
41634
+ * table-less `done`/`error` lifecycle events). Per-table — not a single shared
41635
+ * clock — so when tables render CONCURRENTLY each one keeps its own ~5/sec
41636
+ * budget: a fast table can't consume the window and starve a slow table's
41637
+ * progress. `force` (table-start) resets only that table's budget.
41638
+ */
41639
+ lastEmit = /* @__PURE__ */ new Map();
41633
41640
  constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
41634
41641
  this.cb = cb;
41635
41642
  this.windowMs = windowMs;
41636
41643
  }
41637
41644
  /**
41638
- * Emit a `table-progress` event, but only if the window since the last
41639
- * passthrough has elapsed. Dropped events are simply not delivered — the next
41640
- * one that survives carries the latest running count.
41645
+ * Emit a `table-progress` event, but only if the window since this table's
41646
+ * last passthrough has elapsed. Dropped events are simply not delivered — the
41647
+ * next one that survives carries the latest running count.
41641
41648
  */
41642
41649
  tick(event) {
41643
41650
  if (!this.cb) return;
41651
+ const key = event.table ?? "";
41644
41652
  const now = Date.now();
41645
- if (now - this.lastEmit < this.windowMs) return;
41646
- this.lastEmit = now;
41653
+ if (now - (this.lastEmit.get(key) ?? 0) < this.windowMs) return;
41654
+ this.lastEmit.set(key, now);
41647
41655
  this.cb(event);
41648
41656
  }
41649
41657
  /**
41650
- * Emit a lifecycle event immediately and reset the throttle window. Use for
41651
- * `table-start`, `table-done`, `done`, and `error` — none of which should
41652
- * ever be dropped. Resetting on `table-start` gives each table a clean budget.
41658
+ * Emit a lifecycle event immediately and reset this table's throttle window.
41659
+ * Use for `table-start`, `table-done`, `done`, and `error` — none of which
41660
+ * should ever be dropped. Resetting on `table-start` gives each table a clean
41661
+ * budget.
41653
41662
  */
41654
41663
  force(event) {
41655
41664
  if (!this.cb) return;
41656
- this.lastEmit = Date.now();
41665
+ this.lastEmit.set(event.table ?? "", Date.now());
41657
41666
  this.cb(event);
41658
41667
  }
41659
41668
  };
41660
41669
 
41670
+ // src/concurrency.ts
41671
+ async function mapWithConcurrency(items, limit, fn) {
41672
+ const results = new Array(items.length);
41673
+ let next = 0;
41674
+ const workerCount = Math.max(1, Math.min(limit, items.length));
41675
+ const workers = Array.from({ length: workerCount }, async () => {
41676
+ for (; ; ) {
41677
+ const i6 = next++;
41678
+ if (i6 >= items.length) break;
41679
+ results[i6] = await fn(items[i6], i6);
41680
+ }
41681
+ });
41682
+ await Promise.all(workers);
41683
+ return results;
41684
+ }
41685
+
41661
41686
  // src/render/engine.ts
41662
41687
  var YIELD_EVERY_ENTITIES = 200;
41688
+ var RENDER_TABLE_CONCURRENCY = 4;
41663
41689
  var NOOP_RENDER = () => "";
41664
41690
  var RenderEngine = class {
41665
41691
  _schema;
@@ -41841,163 +41867,175 @@ var RenderEngine = class {
41841
41867
  * via `signal`.
41842
41868
  */
41843
41869
  async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
41844
- const manifestData = {};
41845
41870
  const protectedTables = /* @__PURE__ */ new Set();
41846
41871
  for (const [t8, d6] of this._schema.getEntityContexts()) {
41847
41872
  if (d6.protected) protectedTables.add(t8);
41848
41873
  }
41849
41874
  const entityTables = [...this._schema.getEntityContexts()];
41850
41875
  const tableCount = entityTables.length;
41851
- for (let tableIndex = 0; tableIndex < tableCount; tableIndex++) {
41852
- if (signal?.aborted) return null;
41853
- const [table, def] = entityTables[tableIndex];
41854
- const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
41855
- const allRows = await this._schema.queryTable(this._adapter, table);
41856
- const directoryRoot = def.directoryRoot ?? table;
41857
- const entitiesTotal = allRows.length;
41858
- throttle.force({
41859
- kind: "table-start",
41860
- table,
41861
- entitiesRendered: 0,
41862
- entitiesTotal,
41863
- tableIndex,
41864
- tableCount,
41865
- pct: 0
41866
- });
41867
- const manifestEntry = {
41868
- directoryRoot,
41869
- ...def.index ? { indexFile: def.index.outputFile } : {},
41870
- declaredFiles: Object.keys(def.files),
41871
- protectedFiles: def.protectedFiles ?? [],
41872
- entities: {}
41873
- };
41874
- if (def.index) {
41875
- const indexPath = (0, import_node_path5.join)(outputDir, def.index.outputFile);
41876
- if (atomicWrite(indexPath, def.index.render(allRows))) {
41877
- filesWritten.push(indexPath);
41878
- } else {
41879
- counters.skipped++;
41880
- }
41881
- }
41882
- for (let i6 = 0; i6 < allRows.length; i6++) {
41883
- const entityRow = allRows[i6];
41876
+ if (signal?.aborted) return null;
41877
+ const renderedEntries = await mapWithConcurrency(
41878
+ entityTables,
41879
+ RENDER_TABLE_CONCURRENCY,
41880
+ async ([table, def], tableIndex) => {
41884
41881
  if (signal?.aborted) return null;
41885
- if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
41886
- await new Promise((r6) => setImmediate(r6));
41887
- }
41888
- const rawSlug = def.slug(entityRow);
41889
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
41890
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
41891
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
41892
- }
41893
- const entityDir = def.directory ? (0, import_node_path5.join)(outputDir, def.directory(entityRow)) : (0, import_node_path5.join)(outputDir, directoryRoot, slug);
41894
- const resolvedDir = (0, import_node_path5.resolve)(entityDir);
41895
- const resolvedBase = (0, import_node_path5.resolve)(outputDir);
41896
- if (!resolvedDir.startsWith(resolvedBase + import_node_path5.sep) && resolvedDir !== resolvedBase) {
41897
- throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
41898
- }
41899
- (0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
41900
- if (def.attachFileColumn) {
41901
- const filePath = entityRow[def.attachFileColumn];
41902
- if (filePath && typeof filePath === "string" && filePath.length > 0) {
41903
- if (def.attachFileMode === "reference") {
41904
- const refPath = (0, import_node_path5.join)(entityDir, `${(0, import_node_path5.basename)(filePath)}.ref.md`);
41905
- try {
41906
- atomicWrite(refPath, `# Reference
41882
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
41883
+ const allRows = await this._schema.queryTable(this._adapter, table);
41884
+ const directoryRoot = def.directoryRoot ?? table;
41885
+ const entitiesTotal = allRows.length;
41886
+ throttle.force({
41887
+ kind: "table-start",
41888
+ table,
41889
+ entitiesRendered: 0,
41890
+ entitiesTotal,
41891
+ tableIndex,
41892
+ tableCount,
41893
+ pct: 0
41894
+ });
41895
+ const manifestEntry = {
41896
+ directoryRoot,
41897
+ ...def.index ? { indexFile: def.index.outputFile } : {},
41898
+ declaredFiles: Object.keys(def.files),
41899
+ protectedFiles: def.protectedFiles ?? [],
41900
+ entities: {}
41901
+ };
41902
+ if (def.index) {
41903
+ const indexPath = (0, import_node_path5.join)(outputDir, def.index.outputFile);
41904
+ if (atomicWrite(indexPath, def.index.render(allRows))) {
41905
+ filesWritten.push(indexPath);
41906
+ } else {
41907
+ counters.skipped++;
41908
+ }
41909
+ }
41910
+ for (let i6 = 0; i6 < allRows.length; i6++) {
41911
+ const entityRow = allRows[i6];
41912
+ if (signal?.aborted) return null;
41913
+ if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
41914
+ await new Promise((r6) => setImmediate(r6));
41915
+ }
41916
+ const rawSlug = def.slug(entityRow);
41917
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
41918
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
41919
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
41920
+ }
41921
+ const entityDir = def.directory ? (0, import_node_path5.join)(outputDir, def.directory(entityRow)) : (0, import_node_path5.join)(outputDir, directoryRoot, slug);
41922
+ const resolvedDir = (0, import_node_path5.resolve)(entityDir);
41923
+ const resolvedBase = (0, import_node_path5.resolve)(outputDir);
41924
+ if (!resolvedDir.startsWith(resolvedBase + import_node_path5.sep) && resolvedDir !== resolvedBase) {
41925
+ throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
41926
+ }
41927
+ (0, import_node_fs4.mkdirSync)(entityDir, { recursive: true });
41928
+ if (def.attachFileColumn) {
41929
+ const filePath = entityRow[def.attachFileColumn];
41930
+ if (filePath && typeof filePath === "string" && filePath.length > 0) {
41931
+ if (def.attachFileMode === "reference") {
41932
+ const refPath = (0, import_node_path5.join)(entityDir, `${(0, import_node_path5.basename)(filePath)}.ref.md`);
41933
+ try {
41934
+ atomicWrite(refPath, `# Reference
41907
41935
 
41908
41936
  - **location:** ${filePath}
41909
41937
  `);
41910
- filesWritten.push(refPath);
41911
- } catch {
41912
- }
41913
- } else {
41914
- const absPath = (0, import_node_path5.isAbsolute)(filePath) ? filePath : (0, import_node_path5.resolve)(outputDir, filePath);
41915
- if ((0, import_node_fs4.existsSync)(absPath)) {
41916
- const destPath = (0, import_node_path5.join)(entityDir, (0, import_node_path5.basename)(absPath));
41917
- if (!(0, import_node_fs4.existsSync)(destPath)) {
41918
- try {
41919
- (0, import_node_fs4.copyFileSync)(absPath, destPath);
41920
- filesWritten.push(destPath);
41921
- } catch {
41938
+ filesWritten.push(refPath);
41939
+ } catch {
41940
+ }
41941
+ } else {
41942
+ const absPath = (0, import_node_path5.isAbsolute)(filePath) ? filePath : (0, import_node_path5.resolve)(outputDir, filePath);
41943
+ if ((0, import_node_fs4.existsSync)(absPath)) {
41944
+ const destPath = (0, import_node_path5.join)(entityDir, (0, import_node_path5.basename)(absPath));
41945
+ if (!(0, import_node_fs4.existsSync)(destPath)) {
41946
+ try {
41947
+ (0, import_node_fs4.copyFileSync)(absPath, destPath);
41948
+ filesWritten.push(destPath);
41949
+ } catch {
41950
+ }
41922
41951
  }
41923
41952
  }
41924
41953
  }
41925
41954
  }
41926
41955
  }
41927
- }
41928
- const renderedFiles = /* @__PURE__ */ new Map();
41929
- const entityFileHashes = {};
41930
- const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
41931
- for (const [filename, spec] of Object.entries(def.files)) {
41932
- if (signal?.aborted) return null;
41933
- const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
41934
- const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
41935
- const rows = await resolveEntitySource(
41936
- source,
41937
- entityRow,
41938
- entityPk,
41939
- this._adapter,
41940
- protection
41941
- );
41942
- if (spec.omitIfEmpty && rows.length === 0) continue;
41943
- const renderFn = compileEntityRender(spec.render);
41944
- const content = truncateContent(renderFn(rows), spec.budget);
41945
- renderedFiles.set(filename, content);
41946
- entityFileHashes[filename] = { hash: contentHash(content) };
41947
- const filePath = (0, import_node_path5.join)(entityDir, filename);
41948
- if (atomicWrite(filePath, content)) {
41949
- filesWritten.push(filePath);
41950
- } else {
41951
- counters.skipped++;
41952
- }
41953
- }
41954
- const fileKeys = Object.keys(def.files);
41955
- const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
41956
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
41957
- { outputFile: fileKeys[0] }
41958
- ) : void 0);
41959
- if (effectiveCombined && renderedFiles.size > 0) {
41960
- const excluded = new Set(effectiveCombined.exclude ?? []);
41961
- const parts = [];
41962
- for (const filename of Object.keys(def.files)) {
41963
- if (!excluded.has(filename) && renderedFiles.has(filename)) {
41964
- parts.push(renderedFiles.get(filename) ?? "");
41965
- }
41966
- }
41967
- if (parts.length > 0) {
41968
- const combinedContent = parts.join("\n\n---\n\n");
41969
- const combinedPath = (0, import_node_path5.join)(entityDir, effectiveCombined.outputFile);
41970
- if (atomicWrite(combinedPath, combinedContent)) {
41971
- filesWritten.push(combinedPath);
41956
+ const renderedFiles = /* @__PURE__ */ new Map();
41957
+ const entityFileHashes = {};
41958
+ const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
41959
+ for (const [filename, spec] of Object.entries(def.files)) {
41960
+ if (signal?.aborted) return null;
41961
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
41962
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
41963
+ const rows = await resolveEntitySource(
41964
+ source,
41965
+ entityRow,
41966
+ entityPk,
41967
+ this._adapter,
41968
+ protection
41969
+ );
41970
+ if (spec.omitIfEmpty && rows.length === 0) continue;
41971
+ const renderFn = compileEntityRender(spec.render);
41972
+ const content = truncateContent(renderFn(rows), spec.budget);
41973
+ renderedFiles.set(filename, content);
41974
+ entityFileHashes[filename] = { hash: contentHash(content) };
41975
+ const filePath = (0, import_node_path5.join)(entityDir, filename);
41976
+ if (atomicWrite(filePath, content)) {
41977
+ filesWritten.push(filePath);
41972
41978
  } else {
41973
41979
  counters.skipped++;
41974
41980
  }
41975
- renderedFiles.set(effectiveCombined.outputFile, combinedContent);
41976
- entityFileHashes[effectiveCombined.outputFile] = { hash: contentHash(combinedContent) };
41977
41981
  }
41982
+ const fileKeys = Object.keys(def.files);
41983
+ const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
41984
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
41985
+ { outputFile: fileKeys[0] }
41986
+ ) : void 0);
41987
+ if (effectiveCombined && renderedFiles.size > 0) {
41988
+ const excluded = new Set(effectiveCombined.exclude ?? []);
41989
+ const parts = [];
41990
+ for (const filename of Object.keys(def.files)) {
41991
+ if (!excluded.has(filename) && renderedFiles.has(filename)) {
41992
+ parts.push(renderedFiles.get(filename) ?? "");
41993
+ }
41994
+ }
41995
+ if (parts.length > 0) {
41996
+ const combinedContent = parts.join("\n\n---\n\n");
41997
+ const combinedPath = (0, import_node_path5.join)(entityDir, effectiveCombined.outputFile);
41998
+ if (atomicWrite(combinedPath, combinedContent)) {
41999
+ filesWritten.push(combinedPath);
42000
+ } else {
42001
+ counters.skipped++;
42002
+ }
42003
+ renderedFiles.set(effectiveCombined.outputFile, combinedContent);
42004
+ entityFileHashes[effectiveCombined.outputFile] = {
42005
+ hash: contentHash(combinedContent)
42006
+ };
42007
+ }
42008
+ }
42009
+ manifestEntry.entities[slug] = entityFileHashes;
42010
+ const entitiesRendered = i6 + 1;
42011
+ throttle.tick({
42012
+ kind: "table-progress",
42013
+ table,
42014
+ entitiesRendered,
42015
+ entitiesTotal,
42016
+ tableIndex,
42017
+ tableCount,
42018
+ pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
42019
+ });
41978
42020
  }
41979
- manifestEntry.entities[slug] = entityFileHashes;
41980
- const entitiesRendered = i6 + 1;
41981
- throttle.tick({
41982
- kind: "table-progress",
42021
+ throttle.force({
42022
+ kind: "table-done",
41983
42023
  table,
41984
- entitiesRendered,
42024
+ entitiesRendered: entitiesTotal,
41985
42025
  entitiesTotal,
41986
42026
  tableIndex,
41987
42027
  tableCount,
41988
- pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
42028
+ pct: 100
41989
42029
  });
42030
+ return manifestEntry;
41990
42031
  }
41991
- manifestData[table] = manifestEntry;
41992
- throttle.force({
41993
- kind: "table-done",
41994
- table,
41995
- entitiesRendered: entitiesTotal,
41996
- entitiesTotal,
41997
- tableIndex,
41998
- tableCount,
41999
- pct: 100
42000
- });
42032
+ );
42033
+ if (signal?.aborted) return null;
42034
+ const manifestData = {};
42035
+ for (let i6 = 0; i6 < renderedEntries.length; i6++) {
42036
+ const entry = renderedEntries[i6];
42037
+ if (entry == null) return null;
42038
+ manifestData[entityTables[i6][0]] = entry;
42001
42039
  }
42002
42040
  return manifestData;
42003
42041
  }
@@ -43249,14 +43287,27 @@ function buildParsedConfig(raw, sourceName, configDir2) {
43249
43287
  const entityContexts = parseEntityContexts(config.entityContexts);
43250
43288
  return name !== void 0 ? { dbPath, name, tables, entityContexts } : { dbPath, tables, entityContexts };
43251
43289
  }
43290
+ function isDbRefShaped(raw) {
43291
+ return /^\s*\$\{LATTICE_DB:/.test(raw);
43292
+ }
43293
+ function parseDbRef(raw) {
43294
+ const m4 = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
43295
+ return m4 ? { label: m4[1] ?? "" } : null;
43296
+ }
43252
43297
  function resolveDbPath(raw, configDir2) {
43253
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
43254
- if (labelMatch) {
43255
- const label = labelMatch[1] ?? "";
43256
- const url = getDbCredential(label);
43298
+ if (isDbRefShaped(raw)) {
43299
+ const ref = parseDbRef(raw);
43300
+ if (!ref) {
43301
+ throw new Error(
43302
+ `Lattice: malformed \${LATTICE_DB:\u2026} reference ${JSON.stringify(
43303
+ raw.trim()
43304
+ )} \u2014 the label may contain only [A-Za-z0-9._-] (no spaces). This usually means a workspace was created with an unsanitized name.`
43305
+ );
43306
+ }
43307
+ const url = getDbCredential(ref.label);
43257
43308
  if (!url) {
43258
43309
  throw new Error(
43259
- `Lattice: config references \${LATTICE_DB:${label}} but no credential is saved for "${label}". Save one via the GUI's Database panel or set LATTICE_DB_${label}.`
43310
+ `Lattice: config references \${LATTICE_DB:${ref.label}} but no credential is saved for "${ref.label}". Save one via the GUI's Database panel or set LATTICE_DB_${ref.label}.`
43260
43311
  );
43261
43312
  }
43262
43313
  return url;
@@ -43264,6 +43315,13 @@ function resolveDbPath(raw, configDir2) {
43264
43315
  if (/^postgres(ql)?:\/\//i.test(raw) || raw.startsWith("file:") || raw === ":memory:") {
43265
43316
  return raw;
43266
43317
  }
43318
+ if (raw.includes("${")) {
43319
+ throw new Error(
43320
+ `Lattice: refusing to treat ${JSON.stringify(
43321
+ raw.trim()
43322
+ )} as a database path \u2014 it looks like a malformed variable reference, not a file path.`
43323
+ );
43324
+ }
43267
43325
  return (0, import_node_path11.resolve)(configDir2, raw);
43268
43326
  }
43269
43327
  var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
@@ -47317,7 +47375,7 @@ function archiveLocalSqlite(dbPath) {
47317
47375
  async function cloudRlsInstalled(probe) {
47318
47376
  const row = await getAsyncOrSync(
47319
47377
  probe.adapter,
47320
- `SELECT to_regclass('public.__lattice_owners') AS reg`
47378
+ `SELECT to_regclass('__lattice_owners') AS reg`
47321
47379
  );
47322
47380
  return !!row && row.reg != null;
47323
47381
  }
@@ -47374,6 +47432,19 @@ function isPostgresUrl(url) {
47374
47432
  }
47375
47433
 
47376
47434
  // src/cloud/rls.ts
47435
+ async function runCloudBootstrapSql(db, sql) {
47436
+ const adapter = db.adapter;
47437
+ if (adapter.withClient) {
47438
+ await adapter.withClient(async (tx) => {
47439
+ await tx.run("SELECT pg_advisory_xact_lock($1::bigint)", [
47440
+ LATTICE_MIGRATION_LOCK_ID.toString()
47441
+ ]);
47442
+ await tx.run(sql);
47443
+ });
47444
+ } else {
47445
+ await runAsyncOrSync(adapter, sql);
47446
+ }
47447
+ }
47377
47448
  function isPg(db) {
47378
47449
  return db.getDialect() === "postgres";
47379
47450
  }
@@ -47507,12 +47578,42 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
47507
47578
  "id" text PRIMARY KEY,
47508
47579
  "role" text NOT NULL,
47509
47580
  "email_hash" text NOT NULL,
47581
+ "email" text,
47510
47582
  "created_by" text NOT NULL DEFAULT session_user,
47511
47583
  "created_at" timestamptz NOT NULL DEFAULT now(),
47512
47584
  "expires_at" timestamptz NOT NULL,
47513
47585
  "redeemed_at" timestamptz,
47514
47586
  "revoked_at" timestamptz
47515
47587
  );
47588
+ -- Plaintext invitee email (owner-only table; members have no grant) so the
47589
+ -- owner's Members list can show who each member is. Added via ALTER so clouds
47590
+ -- created before this column converge to it on the owner's next open (the
47591
+ -- bootstrap is now run directly + idempotently, not version-gated).
47592
+ ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
47593
+
47594
+ -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
47595
+ -- the cloud with their minted credential, the join path calls this to CLAIM the
47596
+ -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
47597
+ -- an invite for the CALLING role (session_user) is still pending: not already
47598
+ -- redeemed (one-time-use), not revoked, and not expired. A replayed redeem of a
47599
+ -- leaked token, a revoked invite, or an expired one returns false, so the caller
47600
+ -- rejects the join. Members have no direct grant on the owner-only
47601
+ -- __lattice_member_invites table \u2014 this SECURITY DEFINER function is the only
47602
+ -- path, and it can claim ONLY the caller's own invite (keyed on session_user,
47603
+ -- never a caller-supplied parameter, so one member can't burn another's invite).
47604
+ CREATE OR REPLACE FUNCTION lattice_claim_invite()
47605
+ RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $fn$
47606
+ DECLARE v_ok boolean;
47607
+ BEGIN
47608
+ UPDATE "__lattice_member_invites"
47609
+ SET "redeemed_at" = now()
47610
+ WHERE "role" = session_user
47611
+ AND "redeemed_at" IS NULL
47612
+ AND "revoked_at" IS NULL
47613
+ AND "expires_at" > now()
47614
+ RETURNING true INTO v_ok;
47615
+ RETURN COALESCE(v_ok, false);
47616
+ END $fn$;
47516
47617
 
47517
47618
  -- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
47518
47619
  -- keyed on session_user (the member's login role). A row with no ownership record
@@ -47795,6 +47896,62 @@ END $fn$;
47795
47896
  DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
47796
47897
  CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
47797
47898
  FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
47899
+
47900
+ -- #4.4 \u2014 seq-based catch-up after a realtime gap. NOTIFY is fire-and-forget, so a
47901
+ -- broker that drops its LISTEN (network blip, laptop sleep) misses every change
47902
+ -- during the gap. The broker tracks the highest seq it delivered and, on
47903
+ -- reconnect, replays what it missed via this function. Members have NO direct
47904
+ -- grant on __lattice_changes (reading it raw would leak every change on the
47905
+ -- cloud), so this SECURITY DEFINER function is the only path and it returns ONLY
47906
+ -- the rows the CALLING role can see: keyed on session_user via lattice_row_visible
47907
+ -- (same gate as live fan-out, #4.3). Deletes are excluded \u2014 the ownership record
47908
+ -- is gone post-delete so visibility can't be verified, and replaying them would
47909
+ -- leak deleted-row pks (the client reconciles deletes on its reconnect refetch).
47910
+ -- Bounded (LIMIT clamped \u2264 1000) so a long gap can't stream the whole table (Rule:
47911
+ -- bounded reads on a hot path).
47912
+ CREATE OR REPLACE FUNCTION lattice_changes_since(p_seq bigint, p_limit int)
47913
+ RETURNS TABLE(seq bigint, table_name text, pk text, op text, owner_role text, created_at timestamptz)
47914
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47915
+ SELECT c."seq", c."table_name", c."pk", c."op", c."owner_role", c."created_at"
47916
+ FROM "__lattice_changes" c
47917
+ WHERE c."seq" > p_seq
47918
+ AND c."op" = 'upsert'
47919
+ AND lattice_row_visible(c."table_name", c."pk")
47920
+ ORDER BY c."seq" ASC
47921
+ LIMIT GREATEST(0, LEAST(COALESCE(p_limit, 500), 1000));
47922
+ $fn$;
47923
+
47924
+ -- #2.1 \u2014 per-row access summary for the connecting role. The GUI attaches this as
47925
+ -- each row's _access so the sharing affordance renders, but __lattice_owners is
47926
+ -- owner-only bookkeeping (members have no grant), so a member reading it directly
47927
+ -- got "permission denied". This SECURITY DEFINER function returns visibility +
47928
+ -- whether the CALLER owns the row, ONLY for the rows the caller can actually see
47929
+ -- (lattice_row_visible, keyed on session_user) \u2014 so a member learns nothing about
47930
+ -- rows hidden from it. Member-callable; the owner gets the same view of its rows.
47931
+ CREATE OR REPLACE FUNCTION lattice_rows_access(p_table text, p_pks text[])
47932
+ RETURNS TABLE(pk text, visibility text, owned boolean)
47933
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47934
+ SELECT o."pk", o."visibility", (o."owner_role" = session_user) AS owned
47935
+ FROM "__lattice_owners" o
47936
+ WHERE o."table_name" = p_table
47937
+ AND o."pk" = ANY(p_pks)
47938
+ AND lattice_row_visible(o."table_name", o."pk");
47939
+ $fn$;
47940
+
47941
+ -- #2.1 \u2014 grantees of a CALLER-OWNED custom-shared row (who you shared YOUR row
47942
+ -- with). Only the row owner sees this (the WHERE pins owner_role = session_user),
47943
+ -- so a member can't enumerate another owner's grants. __lattice_row_grants is
47944
+ -- member-ungranted, so this SECURITY DEFINER function is the member-safe path.
47945
+ CREATE OR REPLACE FUNCTION lattice_row_grantees(p_table text, p_pks text[])
47946
+ RETURNS TABLE(pk text, grantee_role text)
47947
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47948
+ SELECT g."pk", g."grantee_role"
47949
+ FROM "__lattice_row_grants" g
47950
+ JOIN "__lattice_owners" o ON o."table_name" = g."table_name" AND o."pk" = g."pk"
47951
+ WHERE g."table_name" = p_table
47952
+ AND g."pk" = ANY(p_pks)
47953
+ AND o."owner_role" = session_user;
47954
+ $fn$;
47798
47955
  `;
47799
47956
  function tableRlsSql(table, pkCols) {
47800
47957
  const q3 = `"${table.replace(/"/g, '""')}"`;
@@ -47862,28 +48019,14 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
47862
48019
  async function installCloudRls(db) {
47863
48020
  if (!isPg(db)) return;
47864
48021
  const schema = await cloudSchema(db);
47865
- const migration = {
47866
- // v3 added the audience helpers; v4 the role model; v5 the per-card override
47867
- // model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell);
47868
- // v6 added per-table policy (__lattice_table_policy: default_row_visibility +
47869
- // never_share, enforced in the insert trigger + share/grant guards), the
47870
- // canonical column-audience store (__lattice_column_policy), lattice_is_owner,
47871
- // and the owner-only setters; v7 pins search_path on every SECURITY DEFINER
47872
- // helper (closes the pg_temp-shadow RLS bypass) + revokes schema CREATE from
47873
- // PUBLIC. The bootstrap is fully idempotent.
47874
- version: "internal:cloud-rls:bootstrap:v7",
47875
- sql: pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema)
47876
- };
47877
- await db.migrate([migration]);
48022
+ const sql = pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema);
48023
+ await runCloudBootstrapSql(db, sql);
47878
48024
  }
47879
48025
  async function enableChangelogRls(db) {
47880
48026
  if (!isPg(db)) return;
47881
- const migration = {
47882
- // v2: ground-truth/audit entries are owner-only (was lattice_row_visible),
47883
- // closing the masked-column-via-history leak. Bump re-installs the policy on
47884
- // existing clouds.
47885
- version: "internal:cloud-rls:changelog:v2",
47886
- sql: `
48027
+ await runCloudBootstrapSql(
48028
+ db,
48029
+ `
47887
48030
  ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
47888
48031
  ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
47889
48032
  GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
@@ -47893,6 +48036,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
47893
48036
  CASE
47894
48037
  WHEN "change_kind" = 'derived' THEN
47895
48038
  "source_ref" IS NOT NULL
48039
+ AND jsonb_array_length("source_ref"::jsonb) > 0
47896
48040
  AND NOT EXISTS (
47897
48041
  SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
47898
48042
  WHERE NOT lattice_source_visible(src.sid)
@@ -47903,8 +48047,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
47903
48047
  DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
47904
48048
  CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
47905
48049
  `
47906
- };
47907
- await db.migrate([migration]);
48050
+ );
47908
48051
  }
47909
48052
  async function enableRlsForTable(db, table, pkCols) {
47910
48053
  if (!isPg(db)) return;
@@ -47958,7 +48101,12 @@ async function provisionMemberRole(db, role, password) {
47958
48101
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
47959
48102
  CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
47960
48103
  ELSE
47961
- ALTER ROLE "${role}" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
48104
+ -- Re-invite of an EXISTING role: set ONLY what changed (login + password).
48105
+ -- Restating NOSUPERUSER/superuser-class attrs trips Supabase supautils
48106
+ -- ("only superuser may alter the SUPERUSER attribute", 42501) since the
48107
+ -- owner 'postgres' isn't a true superuser. The role was already created
48108
+ -- NOSUPERUSER NOCREATEDB NOCREATEROLE, so there is nothing to restate.
48109
+ ALTER ROLE "${role}" WITH LOGIN PASSWORD '${password}';
47962
48110
  END IF;
47963
48111
  END $LATTICE$`
47964
48112
  );
@@ -47997,9 +48145,28 @@ async function revokeCell(db, table, pk, column, grantee) {
47997
48145
  async function revokeMemberRole(db, role) {
47998
48146
  assertPg(db);
47999
48147
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
48000
- await runAsyncOrSync(db.adapter, `DROP OWNED BY "${role}"`).catch(() => void 0);
48148
+ const exists = await getAsyncOrSync(
48149
+ db.adapter,
48150
+ `SELECT 1 AS x FROM pg_roles WHERE rolname = ?`,
48151
+ [role]
48152
+ );
48153
+ if (!exists) return;
48154
+ for (const stmt of [`REASSIGN OWNED BY "${role}" TO CURRENT_USER`, `DROP OWNED BY "${role}"`]) {
48155
+ try {
48156
+ await runAsyncOrSync(db.adapter, stmt);
48157
+ } catch (e6) {
48158
+ if (!isInsufficientPrivilege(e6)) throw e6;
48159
+ console.warn(
48160
+ `[cloud] "${stmt.split(" ").slice(0, 2).join(" ")} \u2026" skipped (insufficient privilege; a scoped member owns no objects): ${e6.message}`
48161
+ );
48162
+ }
48163
+ }
48001
48164
  await runAsyncOrSync(db.adapter, `DROP ROLE IF EXISTS "${role}"`);
48002
48165
  }
48166
+ function isInsufficientPrivilege(e6) {
48167
+ const err = e6 ?? {};
48168
+ return err.code === "42501" || /permission denied/i.test(err.message ?? "");
48169
+ }
48003
48170
 
48004
48171
  // src/cloud/discover.ts
48005
48172
  async function discoverCloudTables(db) {
@@ -48243,6 +48410,7 @@ var FoldCache = class {
48243
48410
  };
48244
48411
 
48245
48412
  // src/cloud/settings.ts
48413
+ var import_node_crypto14 = require("crypto");
48246
48414
  var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
48247
48415
  var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
48248
48416
  -- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
@@ -48282,13 +48450,7 @@ END $fn$;
48282
48450
  async function installCloudSettings(db) {
48283
48451
  if (db.getDialect() !== "postgres") return;
48284
48452
  const schema = await cloudSchema(db);
48285
- const migration = {
48286
- // v2 pins search_path on the two SECURITY DEFINER helpers (closes the
48287
- // pg_temp-shadow class of bypass on the settings getter/setter).
48288
- version: "internal:cloud-settings:v2",
48289
- sql: pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema)
48290
- };
48291
- await db.migrate([migration]);
48453
+ await runCloudBootstrapSql(db, pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema));
48292
48454
  }
48293
48455
  async function getCloudSetting(db, key) {
48294
48456
  if (db.getDialect() !== "postgres") return null;
@@ -48307,6 +48469,18 @@ async function setCloudSetting(db, key, value) {
48307
48469
  }
48308
48470
 
48309
48471
  // src/cloud/setup.ts
48472
+ async function secureNewCloudTable(db, table, pk) {
48473
+ if (db.getDialect() !== "postgres") return;
48474
+ if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) return;
48475
+ if (pk.length === 0) return;
48476
+ await backfillOwnership(db, table, pk);
48477
+ await enableRlsForTable(db, table, pk);
48478
+ const cols = db.getRegisteredColumns(table);
48479
+ if (cols) {
48480
+ await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
48481
+ await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
48482
+ }
48483
+ }
48310
48484
  async function secureCloud(db) {
48311
48485
  if (db.getDialect() !== "postgres") return;
48312
48486
  await installCloudRls(db);
@@ -48315,16 +48489,7 @@ async function secureCloud(db) {
48315
48489
  await enableChangelogRls(db);
48316
48490
  const registered = db.getRegisteredTableNames();
48317
48491
  for (const table of registered) {
48318
- if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
48319
- const pk = db.getPrimaryKey(table);
48320
- if (pk.length === 0) continue;
48321
- await backfillOwnership(db, table, pk);
48322
- await enableRlsForTable(db, table, pk);
48323
- const cols = db.getRegisteredColumns(table);
48324
- if (cols) {
48325
- await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
48326
- await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
48327
- }
48492
+ await secureNewCloudTable(db, table, db.getPrimaryKey(table));
48328
48493
  }
48329
48494
  if (registered.includes("secrets")) {
48330
48495
  await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);