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.js CHANGED
@@ -41443,37 +41443,63 @@ var THROTTLE_WINDOW_MS = 200;
41443
41443
  var ProgressThrottle = class {
41444
41444
  cb;
41445
41445
  windowMs;
41446
- lastEmit = 0;
41446
+ /**
41447
+ * Last passthrough time, keyed per table (`event.table`, or `''` for the
41448
+ * table-less `done`/`error` lifecycle events). Per-table — not a single shared
41449
+ * clock — so when tables render CONCURRENTLY each one keeps its own ~5/sec
41450
+ * budget: a fast table can't consume the window and starve a slow table's
41451
+ * progress. `force` (table-start) resets only that table's budget.
41452
+ */
41453
+ lastEmit = /* @__PURE__ */ new Map();
41447
41454
  constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
41448
41455
  this.cb = cb;
41449
41456
  this.windowMs = windowMs;
41450
41457
  }
41451
41458
  /**
41452
- * Emit a `table-progress` event, but only if the window since the last
41453
- * passthrough has elapsed. Dropped events are simply not delivered — the next
41454
- * one that survives carries the latest running count.
41459
+ * Emit a `table-progress` event, but only if the window since this table's
41460
+ * last passthrough has elapsed. Dropped events are simply not delivered — the
41461
+ * next one that survives carries the latest running count.
41455
41462
  */
41456
41463
  tick(event) {
41457
41464
  if (!this.cb) return;
41465
+ const key = event.table ?? "";
41458
41466
  const now = Date.now();
41459
- if (now - this.lastEmit < this.windowMs) return;
41460
- this.lastEmit = now;
41467
+ if (now - (this.lastEmit.get(key) ?? 0) < this.windowMs) return;
41468
+ this.lastEmit.set(key, now);
41461
41469
  this.cb(event);
41462
41470
  }
41463
41471
  /**
41464
- * Emit a lifecycle event immediately and reset the throttle window. Use for
41465
- * `table-start`, `table-done`, `done`, and `error` — none of which should
41466
- * ever be dropped. Resetting on `table-start` gives each table a clean budget.
41472
+ * Emit a lifecycle event immediately and reset this table's throttle window.
41473
+ * Use for `table-start`, `table-done`, `done`, and `error` — none of which
41474
+ * should ever be dropped. Resetting on `table-start` gives each table a clean
41475
+ * budget.
41467
41476
  */
41468
41477
  force(event) {
41469
41478
  if (!this.cb) return;
41470
- this.lastEmit = Date.now();
41479
+ this.lastEmit.set(event.table ?? "", Date.now());
41471
41480
  this.cb(event);
41472
41481
  }
41473
41482
  };
41474
41483
 
41484
+ // src/concurrency.ts
41485
+ async function mapWithConcurrency(items, limit, fn) {
41486
+ const results = new Array(items.length);
41487
+ let next = 0;
41488
+ const workerCount = Math.max(1, Math.min(limit, items.length));
41489
+ const workers = Array.from({ length: workerCount }, async () => {
41490
+ for (; ; ) {
41491
+ const i6 = next++;
41492
+ if (i6 >= items.length) break;
41493
+ results[i6] = await fn(items[i6], i6);
41494
+ }
41495
+ });
41496
+ await Promise.all(workers);
41497
+ return results;
41498
+ }
41499
+
41475
41500
  // src/render/engine.ts
41476
41501
  var YIELD_EVERY_ENTITIES = 200;
41502
+ var RENDER_TABLE_CONCURRENCY = 4;
41477
41503
  var NOOP_RENDER = () => "";
41478
41504
  var RenderEngine = class {
41479
41505
  _schema;
@@ -41655,163 +41681,175 @@ var RenderEngine = class {
41655
41681
  * via `signal`.
41656
41682
  */
41657
41683
  async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
41658
- const manifestData = {};
41659
41684
  const protectedTables = /* @__PURE__ */ new Set();
41660
41685
  for (const [t8, d6] of this._schema.getEntityContexts()) {
41661
41686
  if (d6.protected) protectedTables.add(t8);
41662
41687
  }
41663
41688
  const entityTables = [...this._schema.getEntityContexts()];
41664
41689
  const tableCount = entityTables.length;
41665
- for (let tableIndex = 0; tableIndex < tableCount; tableIndex++) {
41666
- if (signal?.aborted) return null;
41667
- const [table, def] = entityTables[tableIndex];
41668
- const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
41669
- const allRows = await this._schema.queryTable(this._adapter, table);
41670
- const directoryRoot = def.directoryRoot ?? table;
41671
- const entitiesTotal = allRows.length;
41672
- throttle.force({
41673
- kind: "table-start",
41674
- table,
41675
- entitiesRendered: 0,
41676
- entitiesTotal,
41677
- tableIndex,
41678
- tableCount,
41679
- pct: 0
41680
- });
41681
- const manifestEntry = {
41682
- directoryRoot,
41683
- ...def.index ? { indexFile: def.index.outputFile } : {},
41684
- declaredFiles: Object.keys(def.files),
41685
- protectedFiles: def.protectedFiles ?? [],
41686
- entities: {}
41687
- };
41688
- if (def.index) {
41689
- const indexPath = join4(outputDir, def.index.outputFile);
41690
- if (atomicWrite(indexPath, def.index.render(allRows))) {
41691
- filesWritten.push(indexPath);
41692
- } else {
41693
- counters.skipped++;
41694
- }
41695
- }
41696
- for (let i6 = 0; i6 < allRows.length; i6++) {
41697
- const entityRow = allRows[i6];
41690
+ if (signal?.aborted) return null;
41691
+ const renderedEntries = await mapWithConcurrency(
41692
+ entityTables,
41693
+ RENDER_TABLE_CONCURRENCY,
41694
+ async ([table, def], tableIndex) => {
41698
41695
  if (signal?.aborted) return null;
41699
- if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
41700
- await new Promise((r6) => setImmediate(r6));
41701
- }
41702
- const rawSlug = def.slug(entityRow);
41703
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
41704
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
41705
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
41706
- }
41707
- const entityDir = def.directory ? join4(outputDir, def.directory(entityRow)) : join4(outputDir, directoryRoot, slug);
41708
- const resolvedDir = resolve(entityDir);
41709
- const resolvedBase = resolve(outputDir);
41710
- if (!resolvedDir.startsWith(resolvedBase + sep) && resolvedDir !== resolvedBase) {
41711
- throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
41712
- }
41713
- mkdirSync2(entityDir, { recursive: true });
41714
- if (def.attachFileColumn) {
41715
- const filePath = entityRow[def.attachFileColumn];
41716
- if (filePath && typeof filePath === "string" && filePath.length > 0) {
41717
- if (def.attachFileMode === "reference") {
41718
- const refPath = join4(entityDir, `${basename(filePath)}.ref.md`);
41719
- try {
41720
- atomicWrite(refPath, `# Reference
41696
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
41697
+ const allRows = await this._schema.queryTable(this._adapter, table);
41698
+ const directoryRoot = def.directoryRoot ?? table;
41699
+ const entitiesTotal = allRows.length;
41700
+ throttle.force({
41701
+ kind: "table-start",
41702
+ table,
41703
+ entitiesRendered: 0,
41704
+ entitiesTotal,
41705
+ tableIndex,
41706
+ tableCount,
41707
+ pct: 0
41708
+ });
41709
+ const manifestEntry = {
41710
+ directoryRoot,
41711
+ ...def.index ? { indexFile: def.index.outputFile } : {},
41712
+ declaredFiles: Object.keys(def.files),
41713
+ protectedFiles: def.protectedFiles ?? [],
41714
+ entities: {}
41715
+ };
41716
+ if (def.index) {
41717
+ const indexPath = join4(outputDir, def.index.outputFile);
41718
+ if (atomicWrite(indexPath, def.index.render(allRows))) {
41719
+ filesWritten.push(indexPath);
41720
+ } else {
41721
+ counters.skipped++;
41722
+ }
41723
+ }
41724
+ for (let i6 = 0; i6 < allRows.length; i6++) {
41725
+ const entityRow = allRows[i6];
41726
+ if (signal?.aborted) return null;
41727
+ if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
41728
+ await new Promise((r6) => setImmediate(r6));
41729
+ }
41730
+ const rawSlug = def.slug(entityRow);
41731
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
41732
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
41733
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
41734
+ }
41735
+ const entityDir = def.directory ? join4(outputDir, def.directory(entityRow)) : join4(outputDir, directoryRoot, slug);
41736
+ const resolvedDir = resolve(entityDir);
41737
+ const resolvedBase = resolve(outputDir);
41738
+ if (!resolvedDir.startsWith(resolvedBase + sep) && resolvedDir !== resolvedBase) {
41739
+ throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
41740
+ }
41741
+ mkdirSync2(entityDir, { recursive: true });
41742
+ if (def.attachFileColumn) {
41743
+ const filePath = entityRow[def.attachFileColumn];
41744
+ if (filePath && typeof filePath === "string" && filePath.length > 0) {
41745
+ if (def.attachFileMode === "reference") {
41746
+ const refPath = join4(entityDir, `${basename(filePath)}.ref.md`);
41747
+ try {
41748
+ atomicWrite(refPath, `# Reference
41721
41749
 
41722
41750
  - **location:** ${filePath}
41723
41751
  `);
41724
- filesWritten.push(refPath);
41725
- } catch {
41726
- }
41727
- } else {
41728
- const absPath = isAbsolute(filePath) ? filePath : resolve(outputDir, filePath);
41729
- if (existsSync4(absPath)) {
41730
- const destPath = join4(entityDir, basename(absPath));
41731
- if (!existsSync4(destPath)) {
41732
- try {
41733
- copyFileSync2(absPath, destPath);
41734
- filesWritten.push(destPath);
41735
- } catch {
41752
+ filesWritten.push(refPath);
41753
+ } catch {
41754
+ }
41755
+ } else {
41756
+ const absPath = isAbsolute(filePath) ? filePath : resolve(outputDir, filePath);
41757
+ if (existsSync4(absPath)) {
41758
+ const destPath = join4(entityDir, basename(absPath));
41759
+ if (!existsSync4(destPath)) {
41760
+ try {
41761
+ copyFileSync2(absPath, destPath);
41762
+ filesWritten.push(destPath);
41763
+ } catch {
41764
+ }
41736
41765
  }
41737
41766
  }
41738
41767
  }
41739
41768
  }
41740
41769
  }
41741
- }
41742
- const renderedFiles = /* @__PURE__ */ new Map();
41743
- const entityFileHashes = {};
41744
- const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
41745
- for (const [filename, spec] of Object.entries(def.files)) {
41746
- if (signal?.aborted) return null;
41747
- const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
41748
- const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
41749
- const rows = await resolveEntitySource(
41750
- source,
41751
- entityRow,
41752
- entityPk,
41753
- this._adapter,
41754
- protection
41755
- );
41756
- if (spec.omitIfEmpty && rows.length === 0) continue;
41757
- const renderFn = compileEntityRender(spec.render);
41758
- const content = truncateContent(renderFn(rows), spec.budget);
41759
- renderedFiles.set(filename, content);
41760
- entityFileHashes[filename] = { hash: contentHash(content) };
41761
- const filePath = join4(entityDir, filename);
41762
- if (atomicWrite(filePath, content)) {
41763
- filesWritten.push(filePath);
41764
- } else {
41765
- counters.skipped++;
41766
- }
41767
- }
41768
- const fileKeys = Object.keys(def.files);
41769
- const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
41770
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
41771
- { outputFile: fileKeys[0] }
41772
- ) : void 0);
41773
- if (effectiveCombined && renderedFiles.size > 0) {
41774
- const excluded = new Set(effectiveCombined.exclude ?? []);
41775
- const parts = [];
41776
- for (const filename of Object.keys(def.files)) {
41777
- if (!excluded.has(filename) && renderedFiles.has(filename)) {
41778
- parts.push(renderedFiles.get(filename) ?? "");
41779
- }
41780
- }
41781
- if (parts.length > 0) {
41782
- const combinedContent = parts.join("\n\n---\n\n");
41783
- const combinedPath = join4(entityDir, effectiveCombined.outputFile);
41784
- if (atomicWrite(combinedPath, combinedContent)) {
41785
- filesWritten.push(combinedPath);
41770
+ const renderedFiles = /* @__PURE__ */ new Map();
41771
+ const entityFileHashes = {};
41772
+ const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
41773
+ for (const [filename, spec] of Object.entries(def.files)) {
41774
+ if (signal?.aborted) return null;
41775
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
41776
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
41777
+ const rows = await resolveEntitySource(
41778
+ source,
41779
+ entityRow,
41780
+ entityPk,
41781
+ this._adapter,
41782
+ protection
41783
+ );
41784
+ if (spec.omitIfEmpty && rows.length === 0) continue;
41785
+ const renderFn = compileEntityRender(spec.render);
41786
+ const content = truncateContent(renderFn(rows), spec.budget);
41787
+ renderedFiles.set(filename, content);
41788
+ entityFileHashes[filename] = { hash: contentHash(content) };
41789
+ const filePath = join4(entityDir, filename);
41790
+ if (atomicWrite(filePath, content)) {
41791
+ filesWritten.push(filePath);
41786
41792
  } else {
41787
41793
  counters.skipped++;
41788
41794
  }
41789
- renderedFiles.set(effectiveCombined.outputFile, combinedContent);
41790
- entityFileHashes[effectiveCombined.outputFile] = { hash: contentHash(combinedContent) };
41791
41795
  }
41796
+ const fileKeys = Object.keys(def.files);
41797
+ const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
41798
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
41799
+ { outputFile: fileKeys[0] }
41800
+ ) : void 0);
41801
+ if (effectiveCombined && renderedFiles.size > 0) {
41802
+ const excluded = new Set(effectiveCombined.exclude ?? []);
41803
+ const parts = [];
41804
+ for (const filename of Object.keys(def.files)) {
41805
+ if (!excluded.has(filename) && renderedFiles.has(filename)) {
41806
+ parts.push(renderedFiles.get(filename) ?? "");
41807
+ }
41808
+ }
41809
+ if (parts.length > 0) {
41810
+ const combinedContent = parts.join("\n\n---\n\n");
41811
+ const combinedPath = join4(entityDir, effectiveCombined.outputFile);
41812
+ if (atomicWrite(combinedPath, combinedContent)) {
41813
+ filesWritten.push(combinedPath);
41814
+ } else {
41815
+ counters.skipped++;
41816
+ }
41817
+ renderedFiles.set(effectiveCombined.outputFile, combinedContent);
41818
+ entityFileHashes[effectiveCombined.outputFile] = {
41819
+ hash: contentHash(combinedContent)
41820
+ };
41821
+ }
41822
+ }
41823
+ manifestEntry.entities[slug] = entityFileHashes;
41824
+ const entitiesRendered = i6 + 1;
41825
+ throttle.tick({
41826
+ kind: "table-progress",
41827
+ table,
41828
+ entitiesRendered,
41829
+ entitiesTotal,
41830
+ tableIndex,
41831
+ tableCount,
41832
+ pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
41833
+ });
41792
41834
  }
41793
- manifestEntry.entities[slug] = entityFileHashes;
41794
- const entitiesRendered = i6 + 1;
41795
- throttle.tick({
41796
- kind: "table-progress",
41835
+ throttle.force({
41836
+ kind: "table-done",
41797
41837
  table,
41798
- entitiesRendered,
41838
+ entitiesRendered: entitiesTotal,
41799
41839
  entitiesTotal,
41800
41840
  tableIndex,
41801
41841
  tableCount,
41802
- pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
41842
+ pct: 100
41803
41843
  });
41844
+ return manifestEntry;
41804
41845
  }
41805
- manifestData[table] = manifestEntry;
41806
- throttle.force({
41807
- kind: "table-done",
41808
- table,
41809
- entitiesRendered: entitiesTotal,
41810
- entitiesTotal,
41811
- tableIndex,
41812
- tableCount,
41813
- pct: 100
41814
- });
41846
+ );
41847
+ if (signal?.aborted) return null;
41848
+ const manifestData = {};
41849
+ for (let i6 = 0; i6 < renderedEntries.length; i6++) {
41850
+ const entry = renderedEntries[i6];
41851
+ if (entry == null) return null;
41852
+ manifestData[entityTables[i6][0]] = entry;
41815
41853
  }
41816
41854
  return manifestData;
41817
41855
  }
@@ -43071,14 +43109,27 @@ function buildParsedConfig(raw, sourceName, configDir2) {
43071
43109
  const entityContexts = parseEntityContexts(config.entityContexts);
43072
43110
  return name !== void 0 ? { dbPath, name, tables, entityContexts } : { dbPath, tables, entityContexts };
43073
43111
  }
43112
+ function isDbRefShaped(raw) {
43113
+ return /^\s*\$\{LATTICE_DB:/.test(raw);
43114
+ }
43115
+ function parseDbRef(raw) {
43116
+ const m4 = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
43117
+ return m4 ? { label: m4[1] ?? "" } : null;
43118
+ }
43074
43119
  function resolveDbPath(raw, configDir2) {
43075
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
43076
- if (labelMatch) {
43077
- const label = labelMatch[1] ?? "";
43078
- const url = getDbCredential(label);
43120
+ if (isDbRefShaped(raw)) {
43121
+ const ref = parseDbRef(raw);
43122
+ if (!ref) {
43123
+ throw new Error(
43124
+ `Lattice: malformed \${LATTICE_DB:\u2026} reference ${JSON.stringify(
43125
+ raw.trim()
43126
+ )} \u2014 the label may contain only [A-Za-z0-9._-] (no spaces). This usually means a workspace was created with an unsanitized name.`
43127
+ );
43128
+ }
43129
+ const url = getDbCredential(ref.label);
43079
43130
  if (!url) {
43080
43131
  throw new Error(
43081
- `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}.`
43132
+ `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}.`
43082
43133
  );
43083
43134
  }
43084
43135
  return url;
@@ -43086,6 +43137,13 @@ function resolveDbPath(raw, configDir2) {
43086
43137
  if (/^postgres(ql)?:\/\//i.test(raw) || raw.startsWith("file:") || raw === ":memory:") {
43087
43138
  return raw;
43088
43139
  }
43140
+ if (raw.includes("${")) {
43141
+ throw new Error(
43142
+ `Lattice: refusing to treat ${JSON.stringify(
43143
+ raw.trim()
43144
+ )} as a database path \u2014 it looks like a malformed variable reference, not a file path.`
43145
+ );
43146
+ }
43089
43147
  return resolve2(configDir2, raw);
43090
43148
  }
43091
43149
  var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
@@ -47139,7 +47197,7 @@ function archiveLocalSqlite(dbPath) {
47139
47197
  async function cloudRlsInstalled(probe) {
47140
47198
  const row = await getAsyncOrSync(
47141
47199
  probe.adapter,
47142
- `SELECT to_regclass('public.__lattice_owners') AS reg`
47200
+ `SELECT to_regclass('__lattice_owners') AS reg`
47143
47201
  );
47144
47202
  return !!row && row.reg != null;
47145
47203
  }
@@ -47196,6 +47254,19 @@ function isPostgresUrl(url) {
47196
47254
  }
47197
47255
 
47198
47256
  // src/cloud/rls.ts
47257
+ async function runCloudBootstrapSql(db, sql) {
47258
+ const adapter = db.adapter;
47259
+ if (adapter.withClient) {
47260
+ await adapter.withClient(async (tx) => {
47261
+ await tx.run("SELECT pg_advisory_xact_lock($1::bigint)", [
47262
+ LATTICE_MIGRATION_LOCK_ID.toString()
47263
+ ]);
47264
+ await tx.run(sql);
47265
+ });
47266
+ } else {
47267
+ await runAsyncOrSync(adapter, sql);
47268
+ }
47269
+ }
47199
47270
  function isPg(db) {
47200
47271
  return db.getDialect() === "postgres";
47201
47272
  }
@@ -47329,12 +47400,42 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
47329
47400
  "id" text PRIMARY KEY,
47330
47401
  "role" text NOT NULL,
47331
47402
  "email_hash" text NOT NULL,
47403
+ "email" text,
47332
47404
  "created_by" text NOT NULL DEFAULT session_user,
47333
47405
  "created_at" timestamptz NOT NULL DEFAULT now(),
47334
47406
  "expires_at" timestamptz NOT NULL,
47335
47407
  "redeemed_at" timestamptz,
47336
47408
  "revoked_at" timestamptz
47337
47409
  );
47410
+ -- Plaintext invitee email (owner-only table; members have no grant) so the
47411
+ -- owner's Members list can show who each member is. Added via ALTER so clouds
47412
+ -- created before this column converge to it on the owner's next open (the
47413
+ -- bootstrap is now run directly + idempotently, not version-gated).
47414
+ ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
47415
+
47416
+ -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
47417
+ -- the cloud with their minted credential, the join path calls this to CLAIM the
47418
+ -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
47419
+ -- an invite for the CALLING role (session_user) is still pending: not already
47420
+ -- redeemed (one-time-use), not revoked, and not expired. A replayed redeem of a
47421
+ -- leaked token, a revoked invite, or an expired one returns false, so the caller
47422
+ -- rejects the join. Members have no direct grant on the owner-only
47423
+ -- __lattice_member_invites table \u2014 this SECURITY DEFINER function is the only
47424
+ -- path, and it can claim ONLY the caller's own invite (keyed on session_user,
47425
+ -- never a caller-supplied parameter, so one member can't burn another's invite).
47426
+ CREATE OR REPLACE FUNCTION lattice_claim_invite()
47427
+ RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $fn$
47428
+ DECLARE v_ok boolean;
47429
+ BEGIN
47430
+ UPDATE "__lattice_member_invites"
47431
+ SET "redeemed_at" = now()
47432
+ WHERE "role" = session_user
47433
+ AND "redeemed_at" IS NULL
47434
+ AND "revoked_at" IS NULL
47435
+ AND "expires_at" > now()
47436
+ RETURNING true INTO v_ok;
47437
+ RETURN COALESCE(v_ok, false);
47438
+ END $fn$;
47338
47439
 
47339
47440
  -- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
47340
47441
  -- keyed on session_user (the member's login role). A row with no ownership record
@@ -47617,6 +47718,62 @@ END $fn$;
47617
47718
  DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
47618
47719
  CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
47619
47720
  FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
47721
+
47722
+ -- #4.4 \u2014 seq-based catch-up after a realtime gap. NOTIFY is fire-and-forget, so a
47723
+ -- broker that drops its LISTEN (network blip, laptop sleep) misses every change
47724
+ -- during the gap. The broker tracks the highest seq it delivered and, on
47725
+ -- reconnect, replays what it missed via this function. Members have NO direct
47726
+ -- grant on __lattice_changes (reading it raw would leak every change on the
47727
+ -- cloud), so this SECURITY DEFINER function is the only path and it returns ONLY
47728
+ -- the rows the CALLING role can see: keyed on session_user via lattice_row_visible
47729
+ -- (same gate as live fan-out, #4.3). Deletes are excluded \u2014 the ownership record
47730
+ -- is gone post-delete so visibility can't be verified, and replaying them would
47731
+ -- leak deleted-row pks (the client reconciles deletes on its reconnect refetch).
47732
+ -- Bounded (LIMIT clamped \u2264 1000) so a long gap can't stream the whole table (Rule:
47733
+ -- bounded reads on a hot path).
47734
+ CREATE OR REPLACE FUNCTION lattice_changes_since(p_seq bigint, p_limit int)
47735
+ RETURNS TABLE(seq bigint, table_name text, pk text, op text, owner_role text, created_at timestamptz)
47736
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47737
+ SELECT c."seq", c."table_name", c."pk", c."op", c."owner_role", c."created_at"
47738
+ FROM "__lattice_changes" c
47739
+ WHERE c."seq" > p_seq
47740
+ AND c."op" = 'upsert'
47741
+ AND lattice_row_visible(c."table_name", c."pk")
47742
+ ORDER BY c."seq" ASC
47743
+ LIMIT GREATEST(0, LEAST(COALESCE(p_limit, 500), 1000));
47744
+ $fn$;
47745
+
47746
+ -- #2.1 \u2014 per-row access summary for the connecting role. The GUI attaches this as
47747
+ -- each row's _access so the sharing affordance renders, but __lattice_owners is
47748
+ -- owner-only bookkeeping (members have no grant), so a member reading it directly
47749
+ -- got "permission denied". This SECURITY DEFINER function returns visibility +
47750
+ -- whether the CALLER owns the row, ONLY for the rows the caller can actually see
47751
+ -- (lattice_row_visible, keyed on session_user) \u2014 so a member learns nothing about
47752
+ -- rows hidden from it. Member-callable; the owner gets the same view of its rows.
47753
+ CREATE OR REPLACE FUNCTION lattice_rows_access(p_table text, p_pks text[])
47754
+ RETURNS TABLE(pk text, visibility text, owned boolean)
47755
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47756
+ SELECT o."pk", o."visibility", (o."owner_role" = session_user) AS owned
47757
+ FROM "__lattice_owners" o
47758
+ WHERE o."table_name" = p_table
47759
+ AND o."pk" = ANY(p_pks)
47760
+ AND lattice_row_visible(o."table_name", o."pk");
47761
+ $fn$;
47762
+
47763
+ -- #2.1 \u2014 grantees of a CALLER-OWNED custom-shared row (who you shared YOUR row
47764
+ -- with). Only the row owner sees this (the WHERE pins owner_role = session_user),
47765
+ -- so a member can't enumerate another owner's grants. __lattice_row_grants is
47766
+ -- member-ungranted, so this SECURITY DEFINER function is the member-safe path.
47767
+ CREATE OR REPLACE FUNCTION lattice_row_grantees(p_table text, p_pks text[])
47768
+ RETURNS TABLE(pk text, grantee_role text)
47769
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47770
+ SELECT g."pk", g."grantee_role"
47771
+ FROM "__lattice_row_grants" g
47772
+ JOIN "__lattice_owners" o ON o."table_name" = g."table_name" AND o."pk" = g."pk"
47773
+ WHERE g."table_name" = p_table
47774
+ AND g."pk" = ANY(p_pks)
47775
+ AND o."owner_role" = session_user;
47776
+ $fn$;
47620
47777
  `;
47621
47778
  function tableRlsSql(table, pkCols) {
47622
47779
  const q3 = `"${table.replace(/"/g, '""')}"`;
@@ -47684,28 +47841,14 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
47684
47841
  async function installCloudRls(db) {
47685
47842
  if (!isPg(db)) return;
47686
47843
  const schema = await cloudSchema(db);
47687
- const migration = {
47688
- // v3 added the audience helpers; v4 the role model; v5 the per-card override
47689
- // model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell);
47690
- // v6 added per-table policy (__lattice_table_policy: default_row_visibility +
47691
- // never_share, enforced in the insert trigger + share/grant guards), the
47692
- // canonical column-audience store (__lattice_column_policy), lattice_is_owner,
47693
- // and the owner-only setters; v7 pins search_path on every SECURITY DEFINER
47694
- // helper (closes the pg_temp-shadow RLS bypass) + revokes schema CREATE from
47695
- // PUBLIC. The bootstrap is fully idempotent.
47696
- version: "internal:cloud-rls:bootstrap:v7",
47697
- sql: pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema)
47698
- };
47699
- await db.migrate([migration]);
47844
+ const sql = pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema);
47845
+ await runCloudBootstrapSql(db, sql);
47700
47846
  }
47701
47847
  async function enableChangelogRls(db) {
47702
47848
  if (!isPg(db)) return;
47703
- const migration = {
47704
- // v2: ground-truth/audit entries are owner-only (was lattice_row_visible),
47705
- // closing the masked-column-via-history leak. Bump re-installs the policy on
47706
- // existing clouds.
47707
- version: "internal:cloud-rls:changelog:v2",
47708
- sql: `
47849
+ await runCloudBootstrapSql(
47850
+ db,
47851
+ `
47709
47852
  ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
47710
47853
  ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
47711
47854
  GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
@@ -47715,6 +47858,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
47715
47858
  CASE
47716
47859
  WHEN "change_kind" = 'derived' THEN
47717
47860
  "source_ref" IS NOT NULL
47861
+ AND jsonb_array_length("source_ref"::jsonb) > 0
47718
47862
  AND NOT EXISTS (
47719
47863
  SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
47720
47864
  WHERE NOT lattice_source_visible(src.sid)
@@ -47725,8 +47869,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
47725
47869
  DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
47726
47870
  CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
47727
47871
  `
47728
- };
47729
- await db.migrate([migration]);
47872
+ );
47730
47873
  }
47731
47874
  async function enableRlsForTable(db, table, pkCols) {
47732
47875
  if (!isPg(db)) return;
@@ -47780,7 +47923,12 @@ async function provisionMemberRole(db, role, password) {
47780
47923
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
47781
47924
  CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
47782
47925
  ELSE
47783
- ALTER ROLE "${role}" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
47926
+ -- Re-invite of an EXISTING role: set ONLY what changed (login + password).
47927
+ -- Restating NOSUPERUSER/superuser-class attrs trips Supabase supautils
47928
+ -- ("only superuser may alter the SUPERUSER attribute", 42501) since the
47929
+ -- owner 'postgres' isn't a true superuser. The role was already created
47930
+ -- NOSUPERUSER NOCREATEDB NOCREATEROLE, so there is nothing to restate.
47931
+ ALTER ROLE "${role}" WITH LOGIN PASSWORD '${password}';
47784
47932
  END IF;
47785
47933
  END $LATTICE$`
47786
47934
  );
@@ -47819,9 +47967,28 @@ async function revokeCell(db, table, pk, column, grantee) {
47819
47967
  async function revokeMemberRole(db, role) {
47820
47968
  assertPg(db);
47821
47969
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
47822
- await runAsyncOrSync(db.adapter, `DROP OWNED BY "${role}"`).catch(() => void 0);
47970
+ const exists = await getAsyncOrSync(
47971
+ db.adapter,
47972
+ `SELECT 1 AS x FROM pg_roles WHERE rolname = ?`,
47973
+ [role]
47974
+ );
47975
+ if (!exists) return;
47976
+ for (const stmt of [`REASSIGN OWNED BY "${role}" TO CURRENT_USER`, `DROP OWNED BY "${role}"`]) {
47977
+ try {
47978
+ await runAsyncOrSync(db.adapter, stmt);
47979
+ } catch (e6) {
47980
+ if (!isInsufficientPrivilege(e6)) throw e6;
47981
+ console.warn(
47982
+ `[cloud] "${stmt.split(" ").slice(0, 2).join(" ")} \u2026" skipped (insufficient privilege; a scoped member owns no objects): ${e6.message}`
47983
+ );
47984
+ }
47985
+ }
47823
47986
  await runAsyncOrSync(db.adapter, `DROP ROLE IF EXISTS "${role}"`);
47824
47987
  }
47988
+ function isInsufficientPrivilege(e6) {
47989
+ const err = e6 ?? {};
47990
+ return err.code === "42501" || /permission denied/i.test(err.message ?? "");
47991
+ }
47825
47992
 
47826
47993
  // src/cloud/discover.ts
47827
47994
  async function discoverCloudTables(db) {
@@ -48065,6 +48232,7 @@ var FoldCache = class {
48065
48232
  };
48066
48233
 
48067
48234
  // src/cloud/settings.ts
48235
+ import { createHash as createHash8, randomBytes as randomBytes6 } from "crypto";
48068
48236
  var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
48069
48237
  var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
48070
48238
  -- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
@@ -48104,13 +48272,7 @@ END $fn$;
48104
48272
  async function installCloudSettings(db) {
48105
48273
  if (db.getDialect() !== "postgres") return;
48106
48274
  const schema = await cloudSchema(db);
48107
- const migration = {
48108
- // v2 pins search_path on the two SECURITY DEFINER helpers (closes the
48109
- // pg_temp-shadow class of bypass on the settings getter/setter).
48110
- version: "internal:cloud-settings:v2",
48111
- sql: pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema)
48112
- };
48113
- await db.migrate([migration]);
48275
+ await runCloudBootstrapSql(db, pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema));
48114
48276
  }
48115
48277
  async function getCloudSetting(db, key) {
48116
48278
  if (db.getDialect() !== "postgres") return null;
@@ -48129,6 +48291,18 @@ async function setCloudSetting(db, key, value) {
48129
48291
  }
48130
48292
 
48131
48293
  // src/cloud/setup.ts
48294
+ async function secureNewCloudTable(db, table, pk) {
48295
+ if (db.getDialect() !== "postgres") return;
48296
+ if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) return;
48297
+ if (pk.length === 0) return;
48298
+ await backfillOwnership(db, table, pk);
48299
+ await enableRlsForTable(db, table, pk);
48300
+ const cols = db.getRegisteredColumns(table);
48301
+ if (cols) {
48302
+ await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
48303
+ await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
48304
+ }
48305
+ }
48132
48306
  async function secureCloud(db) {
48133
48307
  if (db.getDialect() !== "postgres") return;
48134
48308
  await installCloudRls(db);
@@ -48137,16 +48311,7 @@ async function secureCloud(db) {
48137
48311
  await enableChangelogRls(db);
48138
48312
  const registered = db.getRegisteredTableNames();
48139
48313
  for (const table of registered) {
48140
- if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
48141
- const pk = db.getPrimaryKey(table);
48142
- if (pk.length === 0) continue;
48143
- await backfillOwnership(db, table, pk);
48144
- await enableRlsForTable(db, table, pk);
48145
- const cols = db.getRegisteredColumns(table);
48146
- if (cols) {
48147
- await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
48148
- await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
48149
- }
48314
+ await secureNewCloudTable(db, table, db.getPrimaryKey(table));
48150
48315
  }
48151
48316
  if (registered.includes("secrets")) {
48152
48317
  await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);