latticesql 3.4.3 → 3.4.5

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
@@ -231,10 +231,12 @@ function readManifest(outputDir) {
231
231
  function writeManifest(outputDir, manifest) {
232
232
  atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
233
233
  }
234
+ var TEMPLATE_VERSION;
234
235
  var init_manifest = __esm({
235
236
  "src/lifecycle/manifest.ts"() {
236
237
  "use strict";
237
238
  init_writer();
239
+ TEMPLATE_VERSION = 1;
238
240
  }
239
241
  });
240
242
 
@@ -268,6 +270,126 @@ var init_adapter = __esm({
268
270
  }
269
271
  });
270
272
 
273
+ // src/lifecycle/render-cursor.ts
274
+ function markToString(v2) {
275
+ if (v2 == null) return null;
276
+ if (v2 instanceof Date) return v2.toISOString();
277
+ if (typeof v2 === "string") return v2;
278
+ if (typeof v2 === "number" || typeof v2 === "bigint" || typeof v2 === "boolean") return String(v2);
279
+ return null;
280
+ }
281
+ function padNumericMark(v2) {
282
+ const s2 = markToString(v2);
283
+ if (s2 == null) return null;
284
+ if (/^\d+$/.test(s2)) return s2.padStart(20, "0");
285
+ return s2;
286
+ }
287
+ async function changelogExists(adapter) {
288
+ if (adapter.dialect === "postgres") {
289
+ const row2 = await getAsyncOrSync(
290
+ adapter,
291
+ `SELECT to_regclass('__lattice_changelog') AS reg`
292
+ );
293
+ return !!row2 && row2.reg != null;
294
+ }
295
+ const row = await getAsyncOrSync(
296
+ adapter,
297
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='__lattice_changelog'`
298
+ );
299
+ return !!row;
300
+ }
301
+ async function changelogMark(adapter) {
302
+ try {
303
+ if (!await changelogExists(adapter)) return null;
304
+ const col = adapter.dialect === "postgres" ? "seq" : "rowid";
305
+ const row = await getAsyncOrSync(
306
+ adapter,
307
+ `SELECT MAX(${col}) AS m FROM __lattice_changelog`
308
+ );
309
+ return padNumericMark(row?.m);
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+ async function sharingMarks(adapter) {
315
+ if (adapter.dialect !== "postgres") return { grants: null, owners: null };
316
+ try {
317
+ const reg = await getAsyncOrSync(
318
+ adapter,
319
+ `SELECT to_regclass('__lattice_changes') AS reg`
320
+ );
321
+ const hasFeed = !!reg && reg.reg != null;
322
+ if (hasFeed) {
323
+ const row = await getAsyncOrSync(
324
+ adapter,
325
+ `SELECT COUNT(*) AS n, MAX(seq) AS m FROM lattice_changes_since(0, 1000)`
326
+ );
327
+ const digest = digestOf(row?.n, row?.m);
328
+ return { grants: digest, owners: digest };
329
+ }
330
+ } catch {
331
+ }
332
+ let owners = null;
333
+ let grants = null;
334
+ try {
335
+ const o3 = await getAsyncOrSync(
336
+ adapter,
337
+ `SELECT COUNT(*) AS n, MAX(updated_at) AS m FROM __lattice_owners`
338
+ );
339
+ owners = digestOf(o3?.n, o3?.m);
340
+ } catch {
341
+ owners = null;
342
+ }
343
+ try {
344
+ const g6 = await getAsyncOrSync(
345
+ adapter,
346
+ `SELECT COUNT(*) AS n, MAX(granted_at) AS m FROM __lattice_row_grants`
347
+ );
348
+ grants = digestOf(g6?.n, g6?.m);
349
+ } catch {
350
+ grants = null;
351
+ }
352
+ return { grants, owners };
353
+ }
354
+ function digestOf(count, max) {
355
+ const n3 = padNumericMark(count);
356
+ if (n3 == null) return null;
357
+ const m4 = markToString(max) ?? "";
358
+ return `${n3}#${m4}`;
359
+ }
360
+ async function computeRenderCursor(adapter) {
361
+ try {
362
+ const [changelog, sharing] = await Promise.all([changelogMark(adapter), sharingMarks(adapter)]);
363
+ return { changelog, grants: sharing.grants, owners: sharing.owners };
364
+ } catch {
365
+ return { ...EMPTY_CURSOR };
366
+ }
367
+ }
368
+ function cursorIsFresh(recorded, live, templateVersion = TEMPLATE_VERSION) {
369
+ if (recorded == null) return false;
370
+ if (recorded.templateVersion !== templateVersion) return false;
371
+ const rc = recorded.cursor;
372
+ if (rc == null) return false;
373
+ if (!fieldFresh(rc.changelog, live.changelog, (r6, l4) => l4 <= r6)) return false;
374
+ if (!fieldFresh(rc.grants, live.grants, (r6, l4) => l4 === r6)) return false;
375
+ if (!fieldFresh(rc.owners, live.owners, (r6, l4) => l4 === r6)) return false;
376
+ return true;
377
+ }
378
+ function fieldFresh(recorded, live, ok) {
379
+ if (recorded == null && live == null) return true;
380
+ if (recorded == null || live == null) return false;
381
+ return ok(recorded, live);
382
+ }
383
+ var EMPTY_CURSOR;
384
+ var init_render_cursor = __esm({
385
+ "src/lifecycle/render-cursor.ts"() {
386
+ "use strict";
387
+ init_adapter();
388
+ init_manifest();
389
+ EMPTY_CURSOR = { changelog: null, grants: null, owners: null };
390
+ }
391
+ });
392
+
271
393
  // src/db/sqlite.ts
272
394
  import Database from "better-sqlite3";
273
395
  var SQLiteAdapter;
@@ -2310,7 +2432,18 @@ var init_concurrency = __esm({
2310
2432
  // src/render/engine.ts
2311
2433
  import { join as join4, basename, isAbsolute, resolve, sep } from "path";
2312
2434
  import { mkdirSync as mkdirSync2, existsSync as existsSync4, copyFileSync as copyFileSync2 } from "fs";
2313
- var YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
2435
+ function entityContentChanged(fresh, prior) {
2436
+ const freshKeys = Object.keys(fresh);
2437
+ const priorKeys = Object.keys(prior);
2438
+ if (freshKeys.length !== priorKeys.length) return true;
2439
+ for (const k6 of freshKeys) {
2440
+ const p3 = prior[k6];
2441
+ if (p3 == null) return true;
2442
+ if (p3.hash === "" || p3.hash !== fresh[k6]?.hash) return true;
2443
+ }
2444
+ return false;
2445
+ }
2446
+ var DeferredTableProgress, YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
2314
2447
  var init_engine = __esm({
2315
2448
  "src/render/engine.ts"() {
2316
2449
  "use strict";
@@ -2320,9 +2453,44 @@ var init_engine = __esm({
2320
2453
  init_entity_query();
2321
2454
  init_entity_templates();
2322
2455
  init_manifest();
2456
+ init_render_cursor();
2323
2457
  init_cleanup();
2324
2458
  init_progress();
2325
2459
  init_concurrency();
2460
+ DeferredTableProgress = class {
2461
+ constructor(throttle) {
2462
+ this.throttle = throttle;
2463
+ }
2464
+ changed = false;
2465
+ pendingStart = null;
2466
+ /** Buffer the `table-start` event; emitted only if/when the table changes. */
2467
+ start(event) {
2468
+ if (this.changed) {
2469
+ this.throttle.force(event);
2470
+ return;
2471
+ }
2472
+ this.pendingStart = event;
2473
+ }
2474
+ /** Mark that an entity's content changed — flush the held `table-start` once. */
2475
+ markChanged() {
2476
+ if (this.changed) return;
2477
+ this.changed = true;
2478
+ if (this.pendingStart) {
2479
+ this.throttle.force(this.pendingStart);
2480
+ this.pendingStart = null;
2481
+ }
2482
+ }
2483
+ /** Coalesced per-entity progress — dropped entirely until the table changed. */
2484
+ tick(event) {
2485
+ if (!this.changed) return;
2486
+ this.throttle.tick(event);
2487
+ }
2488
+ /** Lifecycle event (`table-done`) — emitted only if the table changed. */
2489
+ force(event) {
2490
+ if (!this.changed) return;
2491
+ this.throttle.force(event);
2492
+ }
2493
+ };
2326
2494
  YIELD_EVERY_ENTITIES = 200;
2327
2495
  RENDER_TABLE_CONCURRENCY = 4;
2328
2496
  NOOP_RENDER = () => "";
@@ -2439,20 +2607,23 @@ var init_engine = __esm({
2439
2607
  }
2440
2608
  const content = def.tokenBudget ? applyTokenBudget(rows, def.render, def.tokenBudget, def.prioritizeBy) : def.render(rows);
2441
2609
  const filePath = join4(outputDir, def.outputFile);
2442
- if (atomicWrite(filePath, content)) {
2610
+ const wrote = atomicWrite(filePath, content);
2611
+ if (wrote) {
2443
2612
  filesWritten.push(filePath);
2444
2613
  } else {
2445
2614
  counters.skipped++;
2446
2615
  }
2447
- throttle.force({
2448
- kind: "table-done",
2449
- table: name,
2450
- entitiesRendered: rows.length,
2451
- entitiesTotal: rows.length,
2452
- tableIndex: 0,
2453
- tableCount: 0,
2454
- pct: 100
2455
- });
2616
+ if (wrote) {
2617
+ throttle.force({
2618
+ kind: "table-done",
2619
+ table: name,
2620
+ entitiesRendered: rows.length,
2621
+ entitiesTotal: rows.length,
2622
+ tableIndex: 0,
2623
+ tableCount: 0,
2624
+ pct: 100
2625
+ });
2626
+ }
2456
2627
  }
2457
2628
  for (const [name, def] of this._schema.getMultis()) {
2458
2629
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
@@ -2466,32 +2637,38 @@ var init_engine = __esm({
2466
2637
  tables[t8] = await this._schema.queryTable(this._adapter, t8, this._readRel);
2467
2638
  }
2468
2639
  }
2640
+ let wroteAny = false;
2469
2641
  for (const key of keys) {
2470
2642
  const content = def.render(key, tables);
2471
2643
  const filePath = join4(outputDir, def.outputFile(key));
2472
2644
  if (atomicWrite(filePath, content)) {
2473
2645
  filesWritten.push(filePath);
2646
+ wroteAny = true;
2474
2647
  } else {
2475
2648
  counters.skipped++;
2476
2649
  }
2477
2650
  }
2478
- throttle.force({
2479
- kind: "table-done",
2480
- table: name,
2481
- entitiesRendered: keys.length,
2482
- entitiesTotal: keys.length,
2483
- tableIndex: 0,
2484
- tableCount: 0,
2485
- pct: 100
2486
- });
2651
+ if (wroteAny) {
2652
+ throttle.force({
2653
+ kind: "table-done",
2654
+ table: name,
2655
+ entitiesRendered: keys.length,
2656
+ entitiesTotal: keys.length,
2657
+ tableIndex: 0,
2658
+ tableCount: 0,
2659
+ pct: 100
2660
+ });
2661
+ }
2487
2662
  }
2663
+ const priorManifest = readManifest(outputDir);
2488
2664
  const entityContextManifest = await this._renderEntityContexts(
2489
2665
  outputDir,
2490
2666
  filesWritten,
2491
2667
  counters,
2492
2668
  throttle,
2493
2669
  signal,
2494
- opts.changedTables
2670
+ opts.changedTables,
2671
+ priorManifest
2495
2672
  );
2496
2673
  if (entityContextManifest === null) {
2497
2674
  return this._abortedResult(filesWritten, counters, start);
@@ -2502,10 +2679,13 @@ var init_engine = __esm({
2502
2679
  const prev = readManifest(outputDir);
2503
2680
  entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
2504
2681
  }
2682
+ const cursor = await computeRenderCursor(this._adapter);
2505
2683
  writeManifest(outputDir, {
2506
2684
  version: 2,
2507
2685
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2508
- entityContexts
2686
+ entityContexts,
2687
+ templateVersion: TEMPLATE_VERSION,
2688
+ cursor
2509
2689
  });
2510
2690
  }
2511
2691
  const result = {
@@ -2571,7 +2751,7 @@ var init_engine = __esm({
2571
2751
  * partial tree). Progress is reported through `throttle`; abort is observed
2572
2752
  * via `signal`.
2573
2753
  */
2574
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
2754
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables, priorManifest) {
2575
2755
  const protectedTables = /* @__PURE__ */ new Set();
2576
2756
  for (const [t8, d6] of this._schema.getEntityContexts()) {
2577
2757
  if (d6.protected) protectedTables.add(t8);
@@ -2590,8 +2770,10 @@ var init_engine = __esm({
2590
2770
  const baseRows = await this._schema.queryTable(this._adapter, table, this._readRel);
2591
2771
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
2592
2772
  const directoryRoot = def.directoryRoot ?? table;
2773
+ const deferred = new DeferredTableProgress(throttle);
2774
+ const priorEntities = priorManifest?.entityContexts[table]?.entities ?? {};
2593
2775
  const entitiesTotal = allRows.length;
2594
- throttle.force({
2776
+ deferred.start({
2595
2777
  kind: "table-start",
2596
2778
  table,
2597
2779
  entitiesRendered: 0,
@@ -2600,6 +2782,7 @@ var init_engine = __esm({
2600
2782
  tableCount,
2601
2783
  pct: 0
2602
2784
  });
2785
+ if (Object.keys(priorEntities).length !== entitiesTotal) deferred.markChanged();
2603
2786
  const manifestEntry = {
2604
2787
  directoryRoot,
2605
2788
  ...def.index ? { indexFile: def.index.outputFile } : {},
@@ -2715,8 +2898,10 @@ var init_engine = __esm({
2715
2898
  }
2716
2899
  }
2717
2900
  manifestEntry.entities[slug] = entityFileHashes;
2901
+ const priorHashes = normalizeEntityFiles(priorEntities[slug] ?? {});
2902
+ if (entityContentChanged(entityFileHashes, priorHashes)) deferred.markChanged();
2718
2903
  const entitiesRendered = i6 + 1;
2719
- throttle.tick({
2904
+ deferred.tick({
2720
2905
  kind: "table-progress",
2721
2906
  table,
2722
2907
  entitiesRendered,
@@ -2726,7 +2911,7 @@ var init_engine = __esm({
2726
2911
  pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
2727
2912
  });
2728
2913
  }
2729
- throttle.force({
2914
+ deferred.force({
2730
2915
  kind: "table-done",
2731
2916
  table,
2732
2917
  entitiesRendered: entitiesTotal,
@@ -4166,6 +4351,22 @@ function deleteAssistantCredential(kind) {
4166
4351
  void _removed;
4167
4352
  saveAssistantCredentials(rest);
4168
4353
  }
4354
+ function isAssistantCredentialCleared(kind) {
4355
+ return loadAssistantCredentials()[CLEARED_SENTINEL_PREFIX + kind] === "1";
4356
+ }
4357
+ function setAssistantCredentialCleared(kind) {
4358
+ const creds = loadAssistantCredentials();
4359
+ creds[CLEARED_SENTINEL_PREFIX + kind] = "1";
4360
+ saveAssistantCredentials(creds);
4361
+ }
4362
+ function clearAssistantCredentialCleared(kind) {
4363
+ const creds = loadAssistantCredentials();
4364
+ const sentinel = CLEARED_SENTINEL_PREFIX + kind;
4365
+ if (!(sentinel in creds)) return;
4366
+ const { [sentinel]: _removed, ...rest } = creds;
4367
+ void _removed;
4368
+ saveAssistantCredentials(rest);
4369
+ }
4169
4370
  function ensureKeysDir() {
4170
4371
  const dir = join9(ensureConfigDir(), KEYS_SUBDIR);
4171
4372
  if (!existsSync9(dir)) {
@@ -4210,7 +4411,7 @@ function deleteToken(label) {
4210
4411
  const path2 = join9(ensureKeysDir(), label + TOKEN_EXT);
4211
4412
  if (existsSync9(path2)) unlinkSync3(path2);
4212
4413
  }
4213
- var MASTER_KEY_FILENAME, IDENTITY_FILENAME, EMPTY_IDENTITY, PREFERENCES_FILENAME, DEFAULT_PREFERENCES, DB_CREDENTIALS_FILENAME, CRED_LOCK_FILENAME, LOCK_STALE_MS, LOCK_TIMEOUT_MS, lockDepthInProcess, S3_CONFIG_FILENAME, ASSISTANT_CREDENTIALS_FILENAME, KEYS_SUBDIR, TOKEN_EXT;
4414
+ var MASTER_KEY_FILENAME, IDENTITY_FILENAME, EMPTY_IDENTITY, PREFERENCES_FILENAME, DEFAULT_PREFERENCES, DB_CREDENTIALS_FILENAME, CRED_LOCK_FILENAME, LOCK_STALE_MS, LOCK_TIMEOUT_MS, lockDepthInProcess, S3_CONFIG_FILENAME, ASSISTANT_CREDENTIALS_FILENAME, CLEARED_SENTINEL_PREFIX, KEYS_SUBDIR, TOKEN_EXT;
4214
4415
  var init_user_config = __esm({
4215
4416
  "src/framework/user-config.ts"() {
4216
4417
  "use strict";
@@ -4233,6 +4434,7 @@ var init_user_config = __esm({
4233
4434
  lockDepthInProcess = 0;
4234
4435
  S3_CONFIG_FILENAME = "s3-config.enc";
4235
4436
  ASSISTANT_CREDENTIALS_FILENAME = "assistant-credentials.enc";
4437
+ CLEARED_SENTINEL_PREFIX = "__cleared__:";
4236
4438
  KEYS_SUBDIR = "keys";
4237
4439
  TOKEN_EXT = ".token";
4238
4440
  }
@@ -4329,14 +4531,6 @@ function resolveDbPath(raw, configDir2) {
4329
4531
  }
4330
4532
  return resolve2(configDir2, raw);
4331
4533
  }
4332
- function warnDeprecatedRef(entity, field, target) {
4333
- const key = `${entity}.${field}`;
4334
- if (warnedDeprecatedRefs.has(key)) return;
4335
- warnedDeprecatedRefs.add(key);
4336
- console.warn(
4337
- `Lattice: one-to-many \`ref:\` on "${entity}.${field}" \u2192 "${target}" is deprecated in favor of many-to-many junction tables and will be removed in 2.0.`
4338
- );
4339
- }
4340
4534
  function entityToTableDef(entityName, entity) {
4341
4535
  const rawFields = entity.fields;
4342
4536
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -4363,7 +4557,6 @@ function entityToTableDef(entityName, entity) {
4363
4557
  table: field.ref,
4364
4558
  foreignKey: fieldName
4365
4559
  };
4366
- warnDeprecatedRef(entityName, fieldName, field.ref);
4367
4560
  }
4368
4561
  }
4369
4562
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -4520,12 +4713,10 @@ function parseEntityContexts(entityContexts) {
4520
4713
  }
4521
4714
  return result;
4522
4715
  }
4523
- var warnedDeprecatedRefs;
4524
4716
  var init_parser = __esm({
4525
4717
  "src/config/parser.ts"() {
4526
4718
  "use strict";
4527
4719
  init_user_config();
4528
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
4529
4720
  }
4530
4721
  });
4531
4722
 
@@ -5244,6 +5435,7 @@ var init_lattice = __esm({
5244
5435
  init_shred();
5245
5436
  init_encryption();
5246
5437
  init_manifest();
5438
+ init_render_cursor();
5247
5439
  init_adapter();
5248
5440
  init_sqlite();
5249
5441
  init_postgres();
@@ -5314,6 +5506,14 @@ var init_lattice = __esm({
5314
5506
  _changelogTables = /* @__PURE__ */ new Set();
5315
5507
  /** Current task context string for relevance filtering. */
5316
5508
  _taskContext = "";
5509
+ /**
5510
+ * True when this connection opened against an already-provisioned cloud as a
5511
+ * SCOPED MEMBER (no role-management privilege → no CREATE/ALTER on the schema).
5512
+ * Set during init() by the same probe that decides introspect-only. Drives
5513
+ * {@link addColumn} to route DDL through the owner-side `lattice_member_add_column`
5514
+ * SECURITY DEFINER helper instead of issuing a raw ALTER the member can't run.
5515
+ */
5516
+ _cloudMemberOpen = false;
5317
5517
  _auditHandlers = [];
5318
5518
  _renderHandlers = [];
5319
5519
  _writebackHandlers = [];
@@ -5560,7 +5760,7 @@ var init_lattice = __esm({
5560
5760
  /** Async tail of init(). See {@link init} for the sync-validation phase. */
5561
5761
  async _initAsync(options) {
5562
5762
  let introspectOnly = options.introspectOnly === true;
5563
- if (!introspectOnly && this.getDialect() === "postgres") {
5763
+ if (this.getDialect() === "postgres") {
5564
5764
  try {
5565
5765
  const [marker, role] = await Promise.all([
5566
5766
  getAsyncOrSync(this._adapter, `SELECT to_regclass('__lattice_owners') AS reg`),
@@ -5571,7 +5771,9 @@ var init_lattice = __esm({
5571
5771
  ]);
5572
5772
  const provisioned = !!marker && marker.reg != null;
5573
5773
  const canCreateRoles = !!role && role.rolcreaterole === true;
5574
- introspectOnly = provisioned && !canCreateRoles;
5774
+ const memberOpen = provisioned && !canCreateRoles;
5775
+ introspectOnly = introspectOnly || memberOpen;
5776
+ this._cloudMemberOpen = memberOpen;
5575
5777
  } catch {
5576
5778
  }
5577
5779
  }
@@ -5659,6 +5861,26 @@ var init_lattice = __esm({
5659
5861
  getDialect() {
5660
5862
  return this._adapter.dialect;
5661
5863
  }
5864
+ /**
5865
+ * True when a table opts into the observation/changelog substrate
5866
+ * (`def.changelog`). Callers that want to bypass the high-level {@link delete}
5867
+ * with a transaction-scoped raw delete use this to know whether the table also
5868
+ * needs the changelog / write-hook / embedding side effects that only
5869
+ * `delete()` performs — so they can keep the high-level path for such tables.
5870
+ */
5871
+ isChangelogTracked(table) {
5872
+ return this._changelogTables.has(table);
5873
+ }
5874
+ /**
5875
+ * True when this connection opened as a scoped cloud MEMBER (see
5876
+ * {@link _cloudMemberOpen}). Callers use it to route DDL-bearing work through
5877
+ * the owner-side SECURITY DEFINER helpers rather than issuing DDL the member's
5878
+ * role can't run (e.g. {@link addColumn} regenerates the masking view inside
5879
+ * `lattice_member_add_column`, so the caller must not also try to regenerate it).
5880
+ */
5881
+ isCloudMemberOpen() {
5882
+ return this._cloudMemberOpen;
5883
+ }
5662
5884
  /**
5663
5885
  * Return the normalised primary-key column list for a registered
5664
5886
  * table. Falls back to `['id']` for tables registered via raw DDL
@@ -5735,7 +5957,15 @@ var init_lattice = __esm({
5735
5957
  assertSafeIdentifier(column, "column");
5736
5958
  const existing = await introspectColumnsAsyncOrSync(this._adapter, table);
5737
5959
  if (!existing.includes(column)) {
5738
- await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5960
+ if (this._cloudMemberOpen) {
5961
+ await runAsyncOrSync(this._adapter, `SELECT lattice_member_add_column(?, ?, ?)`, [
5962
+ table,
5963
+ column,
5964
+ typeSpec
5965
+ ]);
5966
+ } else {
5967
+ await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5968
+ }
5739
5969
  }
5740
5970
  const cols = await introspectColumnsAsyncOrSync(this._adapter, table);
5741
5971
  this._columnCache.set(table, new Set(cols));
@@ -6667,12 +6897,39 @@ var init_lattice = __esm({
6667
6897
  async renderInBackground(outputDir, opts = {}) {
6668
6898
  const notInit = this._notInitError();
6669
6899
  if (notInit) return notInit;
6900
+ if (opts.gateOnOpen && !opts.changedTables) {
6901
+ const start = Date.now();
6902
+ const recorded = readManifest(outputDir);
6903
+ if (recorded != null) {
6904
+ const live = await computeRenderCursor(this._adapter);
6905
+ if (cursorIsFresh(recorded, live)) {
6906
+ opts.onProgress?.({
6907
+ kind: "done",
6908
+ table: null,
6909
+ entitiesRendered: 0,
6910
+ entitiesTotal: 0,
6911
+ tableIndex: 0,
6912
+ tableCount: 0,
6913
+ pct: 100,
6914
+ durationMs: Date.now() - start
6915
+ });
6916
+ const skipped = {
6917
+ filesWritten: [],
6918
+ filesSkipped: 0,
6919
+ durationMs: Date.now() - start
6920
+ };
6921
+ for (const h6 of this._renderHandlers) h6(skipped);
6922
+ return skipped;
6923
+ }
6924
+ }
6925
+ }
6670
6926
  if (!opts.changedTables) {
6671
6927
  this._pendingRenderAll = false;
6672
6928
  this._pendingRenderTables = /* @__PURE__ */ new Set();
6673
6929
  this._autoRenderPending = false;
6674
6930
  }
6675
- return this._renderGuarded(outputDir, opts);
6931
+ const { gateOnOpen: _gateOnOpen, ...engineOpts } = opts;
6932
+ return this._renderGuarded(outputDir, engineOpts);
6676
6933
  }
6677
6934
  /**
6678
6935
  * Install a per-viewer read-relation resolver for ALL renders (initial,
@@ -47863,6 +48120,111 @@ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47863
48120
  AND g."pk" = ANY(p_pks)
47864
48121
  AND o."owner_role" = session_user;
47865
48122
  $fn$;
48123
+
48124
+ -- Add a column to a user table AS THE OWNER, on behalf of a scoped member. A
48125
+ -- member's role has no CREATE/ALTER on the schema (the bootstrap REVOKEs CREATE
48126
+ -- from PUBLIC), so a member's GUI "add a field" write (createRow/updateRow with a
48127
+ -- field the table lacks) cannot run ALTER TABLE itself. This SECURITY DEFINER
48128
+ -- helper performs that ALTER \u2014 and the masking-view regen \u2014 with the owner's
48129
+ -- rights, so member-added columns behave identically to owner-added ones.
48130
+ --
48131
+ -- Injection-safe + minimal: p_table must be an existing BASE table in the current
48132
+ -- schema (rejected otherwise); p_type is whitelisted against the exact set the
48133
+ -- library's addColumn emits for an auto-added column (TEXT / INTEGER / REAL, plus
48134
+ -- BOOLEAN) \u2014 never interpolated raw; both identifiers go through %I (quote_ident).
48135
+ -- Member-callable (granted EXECUTE to the member group), but it can only widen the
48136
+ -- schema, never read or alter another member's data.
48137
+ CREATE OR REPLACE FUNCTION lattice_member_add_column(p_table text, p_column text, p_type text)
48138
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
48139
+ DECLARE
48140
+ v_type text;
48141
+ v_view text := p_table || '_v';
48142
+ v_has_view boolean;
48143
+ v_pk_expr text;
48144
+ v_select text;
48145
+ BEGIN
48146
+ -- Never alter internal bookkeeping tables (names start with "_"). The GUI only
48147
+ -- ever calls this for a user entity table; rejecting the rest is defense-in-depth
48148
+ -- against a member invoking the function directly against ownership/audit/policy
48149
+ -- tables.
48150
+ IF left(p_table, 1) = '_' THEN
48151
+ RAISE EXCEPTION 'lattice: cannot add a column to internal table "%"', p_table;
48152
+ END IF;
48153
+
48154
+ -- p_table must be a real base table in THIS schema (search_path is pinned to the
48155
+ -- cloud schema by pinDefinerSearchPath, so to_regclass resolves there).
48156
+ IF NOT EXISTS (
48157
+ SELECT 1 FROM pg_class c
48158
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48159
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND c.relkind = 'r'
48160
+ ) THEN
48161
+ RAISE EXCEPTION 'lattice: no such table "%"', p_table;
48162
+ END IF;
48163
+
48164
+ -- Whitelist the column type. These are exactly the specs addColumn's
48165
+ -- inferColumnType produces (TEXT / INTEGER / REAL); BOOLEAN is allowed too.
48166
+ -- Anything else is rejected \u2014 the type is spliced as %s (NOT %I), so it must be
48167
+ -- a known-safe literal and never caller-controlled SQL.
48168
+ v_type := upper(btrim(p_type));
48169
+ IF v_type NOT IN ('TEXT', 'INTEGER', 'REAL', 'BOOLEAN') THEN
48170
+ RAISE EXCEPTION 'lattice: unsupported column type "%"', p_type;
48171
+ END IF;
48172
+
48173
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS %I %s', p_table, p_column, v_type);
48174
+
48175
+ -- If the table is cell-masked (a "<table>_v" view exists, because some column has
48176
+ -- an audience), the view selects an explicit column list \u2014 so a new column is
48177
+ -- invisible to members until the view is regenerated. Rebuild it the same way the
48178
+ -- owner path (audienceViewSql / regenerateAudienceViewFromDb) does: pass every
48179
+ -- column through except those with an 'owner' audience in __lattice_column_policy
48180
+ -- (CASE WHEN lattice_is_owner(...) THEN col END), re-apply row visibility with
48181
+ -- WHERE lattice_row_visible(table, pk), and keep the member SELECT grant on the
48182
+ -- view. Unmasked tables need no regen \u2014 the member group's table-level base grant
48183
+ -- already covers the new column.
48184
+ SELECT EXISTS (
48185
+ SELECT 1 FROM pg_class c
48186
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48187
+ WHERE n.nspname = current_schema() AND c.relname = v_view AND c.relkind = 'v'
48188
+ ) INTO v_has_view;
48189
+
48190
+ IF v_has_view THEN
48191
+ -- Canonical pk expression: CAST("col" AS TEXT) joined by TAB (chr(9)) \u2014 the
48192
+ -- same serialization the RLS policies + audienceViewSql use.
48193
+ SELECT string_agg(format('CAST(%I AS TEXT)', a.attname), ' || chr(9) || '
48194
+ ORDER BY array_position(i.indkey, a.attnum))
48195
+ INTO v_pk_expr
48196
+ FROM pg_index i
48197
+ JOIN pg_class c ON c.oid = i.indrelid
48198
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48199
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
48200
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND i.indisprimary;
48201
+ IF v_pk_expr IS NULL THEN
48202
+ RAISE EXCEPTION 'lattice: cannot regenerate mask view for "%": no primary key', p_table;
48203
+ END IF;
48204
+
48205
+ -- Build the masked SELECT list in column order, applying the per-column policy.
48206
+ SELECT string_agg(
48207
+ CASE
48208
+ WHEN cp."audience" = 'owner'
48209
+ THEN format('CASE WHEN lattice_is_owner(%L, %s) THEN %I END AS %I',
48210
+ p_table, v_pk_expr, cols.column_name, cols.column_name)
48211
+ ELSE format('%I', cols.column_name)
48212
+ END,
48213
+ ', ' ORDER BY cols.ordinal_position)
48214
+ INTO v_select
48215
+ FROM information_schema.columns cols
48216
+ LEFT JOIN "__lattice_column_policy" cp
48217
+ ON cp."table_name" = p_table AND cp."column_name" = cols.column_name
48218
+ AND cp."audience" NOT IN ('', 'everyone', 'row-audience')
48219
+ WHERE cols.table_schema = current_schema() AND cols.table_name = p_table;
48220
+
48221
+ EXECUTE format(
48222
+ 'CREATE OR REPLACE VIEW %I AS SELECT %s FROM %I WHERE lattice_row_visible(%L, %s)',
48223
+ v_view, v_select, p_table, p_table, v_pk_expr);
48224
+ EXECUTE format('GRANT SELECT ON %I TO ${MEMBER_GROUP}', v_view);
48225
+ END IF;
48226
+ END $fn$;
48227
+ GRANT EXECUTE ON FUNCTION lattice_member_add_column(text, text, text) TO ${MEMBER_GROUP};
47866
48228
  `;
47867
48229
  }
47868
48230
  });
@@ -47973,6 +48335,11 @@ async function revokeRow(db, table, pk, grantee) {
47973
48335
  assertPg(db);
47974
48336
  await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_row(?, ?, ?)`, [table, pk, grantee]);
47975
48337
  }
48338
+ async function batchRowGrants(db, table, pk, grant, revoke) {
48339
+ assertPg(db);
48340
+ for (const grantee of grant) await grantRow(db, table, pk, grantee);
48341
+ for (const grantee of revoke) await revokeRow(db, table, pk, grantee);
48342
+ }
47976
48343
  async function revokeMemberRole(db, role) {
47977
48344
  assertPg(db);
47978
48345
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
@@ -49075,18 +49442,9 @@ function sessionUndoneFilters(undone, sessionId) {
49075
49442
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
49076
49443
  return filters;
49077
49444
  }
49078
- async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
49079
- const undone = await db.query("_lattice_gui_audit", {
49080
- filters: sessionUndoneFilters(1, sessionId)
49081
- });
49082
- for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
49083
- await db.insert("_lattice_gui_audit", {
49445
+ function buildAuditRow(table, rowId, op, before, after, sessionId, editTs) {
49446
+ return {
49084
49447
  id: crypto.randomUUID(),
49085
- // Set ts explicitly (don't rely on the column DEFAULT — it uses the
49086
- // SQLite-only `strftime(...)`, which doesn't yield a parseable ISO string
49087
- // on Postgres, so cloud history rendered "Invalid Date"). #4.6 — honor the
49088
- // originating client's validated edit time when present (an offline edit
49089
- // replayed later records when it was MADE, not when it synced), else now().
49090
49448
  ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
49091
49449
  table_name: table,
49092
49450
  row_id: rowId,
@@ -49095,7 +49453,9 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49095
49453
  after_json: after ? JSON.stringify(after) : null,
49096
49454
  undone: 0,
49097
49455
  session_id: sessionId ?? null
49098
- });
49456
+ };
49457
+ }
49458
+ function publishMutationFeed(feed, table, rowId, op, before, after, source) {
49099
49459
  const labelRow = op === "delete" ? before : after;
49100
49460
  feed.publish({
49101
49461
  table,
@@ -49105,17 +49465,28 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49105
49465
  summary: feedSummary(op, table, labelRow)
49106
49466
  });
49107
49467
  }
49108
- function isSchemaOp(operation2) {
49109
- return operation2.startsWith(SCHEMA_OP_PREFIX);
49110
- }
49111
- async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
49468
+ async function purgeRedoStack(db, sessionId) {
49112
49469
  const undone = await db.query("_lattice_gui_audit", {
49113
49470
  filters: sessionUndoneFilters(1, sessionId)
49114
49471
  });
49115
49472
  for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
49473
+ }
49474
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
49475
+ await purgeRedoStack(db, sessionId);
49476
+ await db.insert(
49477
+ "_lattice_gui_audit",
49478
+ buildAuditRow(table, rowId, op, before, after, sessionId, editTs)
49479
+ );
49480
+ publishMutationFeed(feed, table, rowId, op, before, after, source);
49481
+ }
49482
+ function isSchemaOp(operation2) {
49483
+ return operation2.startsWith(SCHEMA_OP_PREFIX);
49484
+ }
49485
+ async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
49486
+ await purgeRedoStack(db, sessionId);
49116
49487
  await db.insert("_lattice_gui_audit", {
49117
49488
  id: crypto.randomUUID(),
49118
- // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
49489
+ // Explicit ISO ts — see buildAuditRow (the SQLite-only strftime DEFAULT
49119
49490
  // rendered "Invalid Date" on the Postgres/cloud path).
49120
49491
  ts: (/* @__PURE__ */ new Date()).toISOString(),
49121
49492
  table_name: table,
@@ -49150,7 +49521,7 @@ async function ensureColumns(db, table, values) {
49150
49521
  const added = Object.keys(values).filter((k6) => !(k6 in existing));
49151
49522
  if (added.length === 0) return [];
49152
49523
  for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
49153
- if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49524
+ if (!db.isCloudMemberOpen() && db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49154
49525
  const cols = db.getRegisteredColumns(table);
49155
49526
  const pk = db.getPrimaryKey(table);
49156
49527
  if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
@@ -49272,7 +49643,14 @@ async function deleteRow(ctx, table, id, hard) {
49272
49643
  ctx.clientTs
49273
49644
  );
49274
49645
  } else {
49275
- await ctx.db.delete(table, id);
49646
+ await hardDelete(ctx, table, id, before);
49647
+ }
49648
+ }
49649
+ async function hardDelete(ctx, table, id, before) {
49650
+ const withClient = ctx.db.adapter.withClient?.bind(ctx.db.adapter);
49651
+ const pkCols = ctx.db.getPrimaryKey(table);
49652
+ const pkCol = pkCols.length === 1 ? pkCols[0] : void 0;
49653
+ if (!withClient || ctx.db.isChangelogTracked(table) || pkCol === void 0) {
49276
49654
  await appendAudit(
49277
49655
  ctx.db,
49278
49656
  ctx.feed,
@@ -49285,10 +49663,30 @@ async function deleteRow(ctx, table, id, hard) {
49285
49663
  ctx.sessionId,
49286
49664
  ctx.clientTs
49287
49665
  );
49666
+ await ctx.db.delete(table, id);
49667
+ return;
49288
49668
  }
49669
+ const auditRow = buildAuditRow(table, id, "delete", before, null, ctx.sessionId, ctx.clientTs);
49670
+ await purgeRedoStack(ctx.db, ctx.sessionId);
49671
+ const auditCols = AUDIT_COLUMNS.map((c6) => `"${c6}"`).join(", ");
49672
+ const auditPlaceholders = AUDIT_COLUMNS.map(() => "?").join(", ");
49673
+ const auditValues = AUDIT_COLUMNS.map((c6) => auditRow[c6]);
49674
+ const pkColQuoted = pkCol.replace(/"/g, '""');
49675
+ await withClient(async (tx) => {
49676
+ await tx.run(
49677
+ `INSERT INTO "_lattice_gui_audit" (${auditCols}) VALUES (${auditPlaceholders})`,
49678
+ auditValues
49679
+ );
49680
+ await tx.run(`DELETE FROM "${table.replace(/"/g, '""')}" WHERE "${pkColQuoted}" = ?`, [id]);
49681
+ });
49682
+ publishMutationFeed(ctx.feed, table, id, "delete", before, null, ctx.source);
49289
49683
  }
49290
- async function linkRows(ctx, table, body) {
49291
- await ctx.db.link(table, body);
49684
+ async function linkRows(ctx, table, body, forceVisibility) {
49685
+ if (forceVisibility !== void 0) {
49686
+ await ctx.db.insertForcingVisibility(table, body, forceVisibility);
49687
+ } else {
49688
+ await ctx.db.link(table, body);
49689
+ }
49292
49690
  await appendAudit(ctx.db, ctx.feed, table, null, "link", null, body, ctx.source, ctx.sessionId);
49293
49691
  }
49294
49692
  async function unlinkRows(ctx, table, body) {
@@ -49426,12 +49824,23 @@ async function revertEntry(ctx, id) {
49426
49824
  });
49427
49825
  return { ok: true, entry };
49428
49826
  }
49429
- var SCHEMA_OP_PREFIX;
49827
+ var AUDIT_COLUMNS, SCHEMA_OP_PREFIX;
49430
49828
  var init_mutations = __esm({
49431
49829
  "src/gui/mutations.ts"() {
49432
49830
  "use strict";
49433
49831
  init_cloud_connect();
49434
49832
  init_audience();
49833
+ AUDIT_COLUMNS = [
49834
+ "id",
49835
+ "ts",
49836
+ "table_name",
49837
+ "row_id",
49838
+ "operation",
49839
+ "before_json",
49840
+ "after_json",
49841
+ "undone",
49842
+ "session_id"
49843
+ ];
49435
49844
  SCHEMA_OP_PREFIX = "schema.";
49436
49845
  }
49437
49846
  });
@@ -49718,6 +50127,10 @@ async function readMachineCredential(db, kind) {
49718
50127
  }
49719
50128
  return null;
49720
50129
  }
50130
+ async function resolveAnthropicKey(db) {
50131
+ if (isAssistantCredentialCleared(CREDENTIALS.anthropic.kind)) return null;
50132
+ return await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50133
+ }
49721
50134
  function getAggressiveness() {
49722
50135
  const n3 = readPreferences().aggressiveness;
49723
50136
  if (!Number.isFinite(n3)) return DEFAULT_AGGRESSIVENESS;
@@ -49748,6 +50161,7 @@ async function getVoiceCredential(db) {
49748
50161
  return null;
49749
50162
  }
49750
50163
  async function hasCredential(db, name, envVar) {
50164
+ if (isAssistantCredentialCleared(CREDENTIALS[name].kind)) return false;
49751
50165
  return Boolean(await readMachineCredential(db, CREDENTIALS[name].kind)) || Boolean(process.env[envVar]);
49752
50166
  }
49753
50167
  async function resolveClaudeAuth(db) {
@@ -49770,7 +50184,7 @@ async function resolveClaudeAuth(db) {
49770
50184
  } catch {
49771
50185
  }
49772
50186
  }
49773
- const apiKey = await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50187
+ const apiKey = await resolveAnthropicKey(db);
49774
50188
  return apiKey ? { apiKey } : null;
49775
50189
  }
49776
50190
  async function hasClaudeAuth(db) {
@@ -49867,6 +50281,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49867
50281
  }
49868
50282
  const cred = CREDENTIALS[name];
49869
50283
  setAssistantCredential(cred.kind, key);
50284
+ clearAssistantCredentialCleared(cred.kind);
49870
50285
  if (db) {
49871
50286
  for (const row of await liveSecretsOfKind(db, cred.kind)) {
49872
50287
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -49883,6 +50298,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49883
50298
  return true;
49884
50299
  }
49885
50300
  deleteAssistantCredential(CREDENTIALS[name].kind);
50301
+ setAssistantCredentialCleared(CREDENTIALS[name].kind);
49886
50302
  if (db) {
49887
50303
  for (const row of await liveSecretsOfKind(db, CREDENTIALS[name].kind)) {
49888
50304
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -52072,7 +52488,7 @@ function buildSchema(db) {
52072
52488
  }
52073
52489
  return out;
52074
52490
  }
52075
- async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false) {
52491
+ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false, privateMode = false) {
52076
52492
  if (!text.trim()) return [];
52077
52493
  const auth = await resolveClaudeAuth(db);
52078
52494
  if (!auth) {
@@ -52094,6 +52510,7 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52094
52510
  });
52095
52511
  return [];
52096
52512
  }
52513
+ const forceVis = privateMode ? "private" : void 0;
52097
52514
  const temperature = aggressivenessToTemperature(aggressiveness);
52098
52515
  let description = "";
52099
52516
  try {
@@ -52136,11 +52553,16 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52136
52553
  }
52137
52554
  if (jx) {
52138
52555
  try {
52139
- await linkRows(mctx, jx.junction, {
52140
- id: crypto.randomUUID(),
52141
- [jx.fileFk]: fileId,
52142
- [jx.otherFk]: m4.id
52143
- });
52556
+ await linkRows(
52557
+ mctx,
52558
+ jx.junction,
52559
+ {
52560
+ id: crypto.randomUUID(),
52561
+ [jx.fileFk]: fileId,
52562
+ [jx.otherFk]: m4.id
52563
+ },
52564
+ forceVis
52565
+ );
52144
52566
  linkedCount++;
52145
52567
  if (created) {
52146
52568
  mctx.feed.publish({
@@ -52199,16 +52621,21 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52199
52621
  if ("name" in cols && row.name == null) row.name = obj2.label;
52200
52622
  if ("title" in cols && row.title == null) row.title = obj2.label;
52201
52623
  try {
52202
- const { id: rowId } = await createRow(mctx, entity, row);
52624
+ const { id: rowId } = await createRow(mctx, entity, row, forceVis);
52203
52625
  createdCount++;
52204
52626
  const ent = entity;
52205
52627
  const jx = junctions.find((j6) => j6.otherTable === ent) ?? (createJunction ? await createJunction(ent) : null);
52206
52628
  if (jx) {
52207
- await linkRows(mctx, jx.junction, {
52208
- id: crypto.randomUUID(),
52209
- [jx.fileFk]: fileId,
52210
- [jx.otherFk]: rowId
52211
- });
52629
+ await linkRows(
52630
+ mctx,
52631
+ jx.junction,
52632
+ {
52633
+ id: crypto.randomUUID(),
52634
+ [jx.fileFk]: fileId,
52635
+ [jx.otherFk]: rowId
52636
+ },
52637
+ forceVis
52638
+ );
52212
52639
  }
52213
52640
  } catch (e6) {
52214
52641
  console.warn(`[ingest] create ${entity} from document failed:`, e6.message);
@@ -52222,12 +52649,17 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52222
52649
  try {
52223
52650
  const title = name.replace(/\.[^./\\]+$/, "").trim() || "Note";
52224
52651
  const body = description.length > 0 ? description : text.slice(0, 2e3);
52225
- const { id: noteId } = await createRow(mctx, "notes", {
52226
- id: crypto.randomUUID(),
52227
- title,
52228
- body,
52229
- source_file_id: fileId
52230
- });
52652
+ const { id: noteId } = await createRow(
52653
+ mctx,
52654
+ "notes",
52655
+ {
52656
+ id: crypto.randomUUID(),
52657
+ title,
52658
+ body,
52659
+ source_file_id: fileId
52660
+ },
52661
+ forceVis
52662
+ );
52231
52663
  mctx.feed.publish({
52232
52664
  table: "notes",
52233
52665
  op: "insert",
@@ -52341,7 +52773,8 @@ async function ingestUrlAsFile(ctx, rawUrl, opts = {}) {
52341
52773
  ctx.enrich.createJunction,
52342
52774
  ctx.enrich.aggressiveness,
52343
52775
  ctx.enrich.createEntity,
52344
- true
52776
+ true,
52777
+ ctx.privateMode === true
52345
52778
  );
52346
52779
  }
52347
52780
  return {
@@ -53220,13 +53653,22 @@ function loadSdk() {
53220
53653
  throw new Error("Could not resolve the Anthropic constructor from '@anthropic-ai/sdk'");
53221
53654
  return ctor;
53222
53655
  }
53223
- function createAnthropicClient(auth) {
53224
- const Anthropic = loadSdk();
53656
+ function buildAnthropicConfig(auth) {
53225
53657
  const config = {};
53226
- if (auth.authToken) config.authToken = auth.authToken;
53227
- else if (auth.apiKey) config.apiKey = auth.apiKey;
53658
+ if (auth.authToken) {
53659
+ config.authToken = auth.authToken;
53660
+ config.apiKey = null;
53661
+ } else if (auth.apiKey) {
53662
+ config.apiKey = auth.apiKey;
53663
+ } else {
53664
+ config.apiKey = null;
53665
+ }
53228
53666
  if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
53229
- const sdk = new Anthropic(config);
53667
+ return config;
53668
+ }
53669
+ function createAnthropicClient(auth) {
53670
+ const Anthropic = loadSdk();
53671
+ const sdk = new Anthropic(buildAnthropicConfig(auth));
53230
53672
  return {
53231
53673
  async runTurn(params) {
53232
53674
  const stream = sdk.messages.stream({
@@ -54488,8 +54930,14 @@ var MEMBER_READABLE_BOOKKEEPING = [
54488
54930
  },
54489
54931
  {
54490
54932
  name: "_lattice_gui_audit",
54491
- privs: "SELECT, INSERT",
54492
- why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
54933
+ // UPDATE + DELETE are needed by undo/redo/revert (flips an entry's `undone`)
54934
+ // and the redo-stack purge on a new mutation (deletes the session's undone
54935
+ // entries). Safe because enableGuiAuditRls installs per-op UPDATE and DELETE
54936
+ // policies whose USING is `row_id IS NULL OR lattice_row_visible(table_name,
54937
+ // row_id)` — so a member can only update/delete audit rows for entities it can
54938
+ // already see (or schema-level entries that carry no row data).
54939
+ privs: "SELECT, INSERT, UPDATE, DELETE",
54940
+ why: "GUI undo/redo/revert + redo-stack purge + version history; RLS (enableGuiAuditRls) scopes every op to entries whose underlying row the member can see"
54493
54941
  },
54494
54942
  {
54495
54943
  name: "__lattice_user_identity",
@@ -54890,6 +55338,19 @@ async function normalizeImage(path2, maxBytes) {
54890
55338
  function renderJpeg(sharp, path2, quality) {
54891
55339
  return sharp(path2).rotate().resize({ width: MAX_DIM, height: MAX_DIM, fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
54892
55340
  }
55341
+ function buildVisionAnthropicConfig(auth) {
55342
+ const config = {};
55343
+ if (auth.authToken) {
55344
+ config.authToken = auth.authToken;
55345
+ config.apiKey = null;
55346
+ } else if (auth.apiKey) {
55347
+ config.apiKey = auth.apiKey;
55348
+ } else {
55349
+ config.apiKey = null;
55350
+ }
55351
+ if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
55352
+ return config;
55353
+ }
54893
55354
  function defaultSender(auth) {
54894
55355
  return async (input) => {
54895
55356
  const importMetaUrl = import.meta.url;
@@ -54897,11 +55358,7 @@ function defaultSender(auth) {
54897
55358
  const sdk = req("@anthropic-ai/sdk");
54898
55359
  const Anthropic = sdk.Anthropic ?? sdk.default;
54899
55360
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
54900
- const config = {};
54901
- if (auth.authToken) config.authToken = auth.authToken;
54902
- else if (auth.apiKey) config.apiKey = auth.apiKey;
54903
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
54904
- const client = new Anthropic(config);
55361
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
54905
55362
  const res = await client.messages.create({
54906
55363
  model: input.model,
54907
55364
  max_tokens: 1024,
@@ -54928,11 +55385,7 @@ function defaultPdfSender(auth) {
54928
55385
  const sdk = req("@anthropic-ai/sdk");
54929
55386
  const Anthropic = sdk.Anthropic ?? sdk.default;
54930
55387
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
54931
- const config = {};
54932
- if (auth.authToken) config.authToken = auth.authToken;
54933
- else if (auth.apiKey) config.apiKey = auth.apiKey;
54934
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
54935
- const client = new Anthropic(config);
55388
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
54936
55389
  const res = await client.messages.create({
54937
55390
  model: input.model,
54938
55391
  max_tokens: 4096,
@@ -55333,6 +55786,8 @@ var css = `
55333
55786
  .app-version:empty { display: none; }
55334
55787
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
55335
55788
  .app-update[hidden] { display: none; }
55789
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
55790
+ #app-update-link[hidden] { display: none; }
55336
55791
  /* Unseen-change count next to a sidebar entity. */
55337
55792
  .nav-badge {
55338
55793
  display: inline-block; min-width: 16px; text-align: center;
@@ -55880,6 +56335,8 @@ var css = `
55880
56335
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
55881
56336
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
55882
56337
  .grants-panel .grants-row input { accent-color: var(--accent); }
56338
+ .grants-panel .grants-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
56339
+ .grants-panel .grants-dirty { font-size: 12px; }
55883
56340
 
55884
56341
  /* Inline create-row at the bottom of every table */
55885
56342
  tr.create-row td { background: var(--surface-2); }
@@ -56809,6 +57266,12 @@ var appJs = `
56809
57266
  // drag handle once the app has booted.
56810
57267
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
56811
57268
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
57269
+ // The version chip + manual-upgrade link live in the static shell (present
57270
+ // from first paint, in both the normal and virgin-state boots), so wire the
57271
+ // click handler and run the first availability check here \u2014 independent of
57272
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
57273
+ wireUpdateLink();
57274
+ checkUpdateAvailable();
56812
57275
  // Failsafe: never leave the overlay up forever if a fetch hangs without
56813
57276
  // rejecting, or a future early-return (e.g. the virgin-state screen)
56814
57277
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -57293,6 +57756,26 @@ var appJs = `
57293
57756
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
57294
57757
  setTimeout(function () { location.reload(); }, 600);
57295
57758
  }
57759
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
57760
+ // the version chip only when the server reports a newer, installable version.
57761
+ // The auto-updater installs in the background on its own cadence; this lets
57762
+ // the user force it now. Best-effort; the link stays hidden on any failure.
57763
+ function checkUpdateAvailable() {
57764
+ var el = document.getElementById('app-update-link');
57765
+ if (!el) return;
57766
+ fetch('/api/update/status')
57767
+ .then(function (r) { return r.ok ? r.json() : null; })
57768
+ .then(function (s) {
57769
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
57770
+ el.textContent = 'Update available \u2014 Upgrade';
57771
+ el.title = 'Install v' + s.latest + ' and restart';
57772
+ el.hidden = false;
57773
+ } else {
57774
+ el.hidden = true;
57775
+ }
57776
+ })
57777
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
57778
+ }
57296
57779
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
57297
57780
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
57298
57781
  function checkServerVersion() {
@@ -57306,6 +57789,31 @@ var appJs = `
57306
57789
  else hideUpdatePill();
57307
57790
  })
57308
57791
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
57792
+ // Refresh the manual-upgrade link alongside the reconnect version check.
57793
+ checkUpdateAvailable();
57794
+ }
57795
+ // Wire the manual-upgrade link's click: kick off the install (the server
57796
+ // installs the latest and restarts onto it) and surface the progress. On
57797
+ // success we do nothing else \u2014 the update-applied event + the reconnect
57798
+ // version check land the page on the new version (no manual reload). A
57799
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
57800
+ function wireUpdateLink() {
57801
+ var el = document.getElementById('app-update-link');
57802
+ if (!el) return;
57803
+ el.addEventListener('click', function (e) {
57804
+ e.preventDefault();
57805
+ el.hidden = true;
57806
+ showUpdatePill('Updating\u2026');
57807
+ fetch('/api/update/apply', { method: 'POST' })
57808
+ .then(function (r) { return r.json(); })
57809
+ .then(function (d) {
57810
+ if (d && d.ok === false) {
57811
+ hideUpdatePill();
57812
+ showToast(d.error || 'Update unavailable', {});
57813
+ }
57814
+ })
57815
+ .catch(function () { /* server may already be restarting */ });
57816
+ });
57309
57817
  }
57310
57818
  function dispatchStreamMessage(type, data) {
57311
57819
  if (type === 'realtime-state') {
@@ -58346,6 +58854,15 @@ var appJs = `
58346
58854
  // Per-table view state: 'live' (default) or 'trash' (soft-deleted rows).
58347
58855
  var tableViewMode = {};
58348
58856
 
58857
+ // The (table, pk) of the per-row "Manage access" grants panel that is
58858
+ // currently open, or null when none is. A soft re-render (a concurrent edit
58859
+ // by another client fires pg_notify \u2192 realtime refresh \u2192 renderRoute({soft})
58860
+ // \u2192 renderDetail/renderFsItem repaint) would otherwise re-create the detail
58861
+ // view with the panel collapsed, dropping a staged multi-select mid-edit.
58862
+ // wireRowSharing reads this after each repaint and re-opens + re-populates the
58863
+ // panel WITHOUT any network call, so the staged selection survives.
58864
+ var openGrantsPanel = null;
58865
+
58349
58866
  function renderTable(content, tableName) {
58350
58867
  var myGen = renderGen;
58351
58868
  clearUnseen(tableName);
@@ -58824,70 +59341,151 @@ var appJs = `
58824
59341
  }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
58825
59342
  });
58826
59343
  });
58827
- var detailVisManage = content.querySelector('#detail-vis-manage');
58828
- if (detailVisManage) detailVisManage.addEventListener('click', function () {
59344
+ var access = row._access || {};
59345
+
59346
+ // Render the staged member checklist + a single "Save sharing" / "Cancel"
59347
+ // into the panel. Checkbox toggles mutate ONLY the local desired map \u2014
59348
+ // NO network call per toggle (the old design auto-saved live, one POST per
59349
+ // checkbox, and each grant's pg_notify collapsed the panel). A single batch
59350
+ // request fires on Save. members is the already-fetched list; desired
59351
+ // seeds from the row's current grantees (or a caller-supplied staged map
59352
+ // when re-opening after a soft re-render).
59353
+ function populateGrantsPanel(panel, members, desired) {
59354
+ // Snapshot the CURRENT (committed) grantees so Save can diff desired-vs-
59355
+ // current into adds/removes. effectiveVisibility decides whether we're
59356
+ // actually switching INTO specific-people mode (custom-0 reads as private).
59357
+ var current = {};
59358
+ (access.grantees || []).forEach(function (g) { current[g] = true; });
59359
+ if (members.length === 0) {
59360
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
59361
+ panel.hidden = false;
59362
+ return;
59363
+ }
59364
+ function dirtyCount() {
59365
+ var n = 0;
59366
+ members.forEach(function (m) {
59367
+ if (!!desired[m.role] !== !!current[m.role]) n++;
59368
+ });
59369
+ return n;
59370
+ }
59371
+ function render() {
59372
+ var changed = dirtyCount();
59373
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' +
59374
+ members.map(function (m) {
59375
+ var label = m.name || m.email || m.role;
59376
+ return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
59377
+ (desired[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
59378
+ }).join('') +
59379
+ '<div class="grants-actions">' +
59380
+ '<button class="btn primary" id="grants-save"' + (changed ? '' : ' disabled') + '>Save sharing</button>' +
59381
+ '<button class="btn" id="grants-cancel">Cancel</button>' +
59382
+ '<span class="grants-dirty muted">' + (changed ? (changed === 1 ? '1 change' : changed + ' changes') : 'No changes') + '</span>' +
59383
+ '</div>';
59384
+ panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
59385
+ cb.addEventListener('change', function () {
59386
+ var role = cb.getAttribute('data-grant-role');
59387
+ if (cb.checked) desired[role] = true; else delete desired[role];
59388
+ render(); // re-render to refresh the dirty indicator + Save state
59389
+ });
59390
+ });
59391
+ var cancelBtn = panel.querySelector('#grants-cancel');
59392
+ if (cancelBtn) cancelBtn.addEventListener('click', function () { closeGrantsPanel(panel); });
59393
+ var saveBtn = panel.querySelector('#grants-save');
59394
+ if (saveBtn) saveBtn.addEventListener('click', function () {
59395
+ var toAdd = [];
59396
+ var toRemove = [];
59397
+ members.forEach(function (m) {
59398
+ var want = !!desired[m.role];
59399
+ var have = !!current[m.role];
59400
+ if (want && !have) toAdd.push(m.role);
59401
+ if (!want && have) toRemove.push(m.role);
59402
+ });
59403
+ if (toAdd.length === 0 && toRemove.length === 0) { closeGrantsPanel(panel); return; }
59404
+ // Confirm the mode change ONCE, here \u2014 only when actually switching
59405
+ // INTO specific-people mode (effective vis isn't already custom AND we
59406
+ // are adding at least one grantee). Never per checkbox.
59407
+ if (effectiveVisibility(access) !== 'custom' && toAdd.length > 0) {
59408
+ if (!confirm('Sharing this with specific people switches it off "everyone"/"private". The chosen people will be able to see it. Continue?')) return;
59409
+ }
59410
+ withBusy(saveBtn, function () {
59411
+ return fetchJson('/api/cloud/row-grants', {
59412
+ method: 'POST',
59413
+ headers: { 'content-type': 'application/json' },
59414
+ body: JSON.stringify({ table: tableName, pk: id, grant: toAdd, revoke: toRemove }),
59415
+ }).then(function () {
59416
+ // Mirror the committed state locally so the re-render's indicator
59417
+ // is correct. The first grant flips the row to custom server-side;
59418
+ // revoking the last leaves custom-0, which effectiveVisibility
59419
+ // renders as private.
59420
+ var list = [];
59421
+ members.forEach(function (m) { if (desired[m.role]) list.push(m.role); });
59422
+ access.grantees = list;
59423
+ if (list.length > 0) access.visibility = 'custom';
59424
+ openGrantsPanel = null; // a successful save closes the staging session
59425
+ invalidate(tableName);
59426
+ showToast('Sharing updated', {});
59427
+ reRender();
59428
+ }).catch(function (e) {
59429
+ // Surface loudly + leave the staged selection intact so the user
59430
+ // can retry; no silent partial-success.
59431
+ showToast('Sharing update failed: ' + e.message, {});
59432
+ });
59433
+ });
59434
+ });
59435
+ panel.hidden = false;
59436
+ }
59437
+ render();
59438
+ }
59439
+
59440
+ function closeGrantsPanel(panel) {
59441
+ if (panel) panel.hidden = true;
59442
+ openGrantsPanel = null;
59443
+ }
59444
+
59445
+ // Open (or toggle shut) the manage-access panel. Fetches the member list,
59446
+ // then stages from the row's current grantees. Opening must NOT pre-flip
59447
+ // the row to 'custom' \u2014 that left a never-shared row stuck at "custom (0)".
59448
+ function openManagePanel(triggerBtn) {
58829
59449
  var panel = content.querySelector('#grants-panel');
58830
59450
  if (!panel) return;
58831
- if (!panel.hidden) { panel.hidden = true; return; }
58832
- var access = row._access || {};
58833
- // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
58834
- // row the user never actually shared stuck at "custom (0)". The first
58835
- // grant flips it to custom server-side (lattice_grant_row); revoking the
58836
- // last leaves it custom-with-0-grantees, which now reads as private. So
58837
- // just load the member checklist.
58838
- var ensure = Promise.resolve();
58839
- withBusy(detailVisManage, function () {
58840
- return ensure.then(function () {
58841
- return fetchJson('/api/cloud/members');
58842
- }).then(function (d) {
59451
+ if (!panel.hidden) { closeGrantsPanel(panel); return; }
59452
+ withBusy(triggerBtn, function () {
59453
+ return fetchJson('/api/cloud/members').then(function (d) {
58843
59454
  // The grant target is a member ROLE: lattice_grant_row keys on the
58844
59455
  // role, and _access.grantees holds role names. List every member
58845
59456
  // except the owner (you don't grant the owner their own row).
58846
59457
  var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
58847
- var granted = {};
58848
- (access.grantees || []).forEach(function (g) { granted[g] = true; });
58849
- if (members.length === 0) {
58850
- panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
58851
- } else {
58852
- panel.innerHTML = '<div class="grants-title">Who can see this</div>' + members.map(function (m) {
58853
- var label = m.name || m.email || m.role;
58854
- return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
58855
- (granted[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
58856
- }).join('');
58857
- }
58858
- panel.hidden = false;
58859
- panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
58860
- cb.addEventListener('change', function () {
58861
- var role = cb.getAttribute('data-grant-role');
58862
- cb.disabled = true;
58863
- fetchJson('/api/cloud/row-grant', {
58864
- method: 'POST',
58865
- headers: { 'content-type': 'application/json' },
58866
- body: JSON.stringify({ table: tableName, pk: id, grantee: role, revoke: !cb.checked }),
58867
- }).then(function () {
58868
- var list = access.grantees || (access.grantees = []);
58869
- var at = list.indexOf(role);
58870
- if (cb.checked && at === -1) list.push(role);
58871
- if (!cb.checked && at !== -1) list.splice(at, 1);
58872
- // The first grant flips the row to custom server-side; mirror
58873
- // that locally so the indicator updates. Revoking the last leaves
58874
- // visibility 'custom' but effectiveVisibility renders custom-0 as
58875
- // private, so the label flips back to "Private to you".
58876
- if (list.length > 0) access.visibility = 'custom';
58877
- var infoEl = content.querySelector('#detail-vis-info');
58878
- if (infoEl) infoEl.textContent = visInfoLabel(access);
58879
- invalidate(tableName);
58880
- }).catch(function (e) {
58881
- cb.checked = !cb.checked; // revert the failed change
58882
- showToast('Access update failed: ' + e.message, {});
58883
- }).then(function () { cb.disabled = false; });
58884
- });
58885
- });
58886
- var infoEl = content.querySelector('#detail-vis-info');
58887
- if (infoEl) infoEl.textContent = visInfoLabel(access);
59458
+ var desired = {};
59459
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59460
+ openGrantsPanel = { table: tableName, pk: id };
59461
+ populateGrantsPanel(panel, members, desired);
58888
59462
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
58889
59463
  });
59464
+ }
59465
+
59466
+ var detailVisManage = content.querySelector('#detail-vis-manage');
59467
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
59468
+ openManagePanel(detailVisManage);
58890
59469
  });
59470
+
59471
+ // Preserve an open panel across a soft re-render: if the tracked panel
59472
+ // matches the row this view just repainted, re-open it and re-populate the
59473
+ // checklist from the freshly-fetched row._access WITHOUT any network call,
59474
+ // so a concurrent edit by another client doesn't lose a staged selection.
59475
+ if (openGrantsPanel && openGrantsPanel.table === tableName && openGrantsPanel.pk === id) {
59476
+ var rpanel = content.querySelector('#grants-panel');
59477
+ if (rpanel) {
59478
+ fetchJson('/api/cloud/members').then(function (d) {
59479
+ // Only re-populate if THIS panel is still the tracked-open one (a
59480
+ // newer navigation/save may have cleared it while members loaded).
59481
+ if (!openGrantsPanel || openGrantsPanel.table !== tableName || openGrantsPanel.pk !== id) return;
59482
+ var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
59483
+ var desired = {};
59484
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59485
+ populateGrantsPanel(rpanel, members, desired);
59486
+ }).catch(function () { /* best-effort restore; a click reopens it */ });
59487
+ }
59488
+ }
58891
59489
  }
58892
59490
  function renderDetail(content, tableName, id) {
58893
59491
  var myGen = renderGen;
@@ -63671,13 +64269,21 @@ var appJs = `
63671
64269
  }
63672
64270
  function uploadFile(file) {
63673
64271
  var done = pendingIngestItem(file.name || 'file');
64272
+ // Carry the composer's "Private mode" intent so an upload made while the
64273
+ // box is checked is stamped private at insert, instead of inheriting the
64274
+ // files-table default (which can be shared-to-everyone on a cloud). Read
64275
+ // the checkbox defensively \u2014 it may not be rendered. On a local workspace
64276
+ // the box is checked+disabled, so this is '1' there too; forced visibility
64277
+ // is a harmless no-op on the single-user SQLite path.
64278
+ var pv = document.getElementById('chat-private');
64279
+ var priv = pv && pv.checked ? '1' : '0';
63674
64280
  return fetch('/api/ingest/upload', {
63675
64281
  method: 'POST',
63676
64282
  // Percent-encode the filename: HTTP header values must be ISO-8859-1,
63677
64283
  // so a Unicode filename (emoji, smart quote, accent, em-dash) would
63678
64284
  // otherwise make fetch() throw "String contains non ISO-8859-1 code
63679
64285
  // point". The server decodeURIComponent()s it back.
63680
- headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file') },
64286
+ headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file'), 'x-lattice-private': priv },
63681
64287
  body: file,
63682
64288
  })
63683
64289
  .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); })
@@ -64025,6 +64631,7 @@ var guiAppHtml = `<!doctype html>
64025
64631
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
64026
64632
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
64027
64633
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
64634
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
64028
64635
  <button id="settings-gear" title="Settings" aria-label="Open settings">
64029
64636
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64030
64637
  <circle cx="12" cy="12" r="3"/>
@@ -64513,7 +65120,7 @@ async function checkForUpdate(pkgName, currentVersion, opts = {}) {
64513
65120
  // src/update-context.ts
64514
65121
  init_user_config();
64515
65122
  import { execFileSync as execFileSync2 } from "child_process";
64516
- import { existsSync as existsSync19, lstatSync as lstatSync2, readFileSync as readFileSync15 } from "fs";
65123
+ import { existsSync as existsSync19, lstatSync as lstatSync2, readFileSync as readFileSync15, realpathSync } from "fs";
64517
65124
  import { dirname as dirname7, join as join24, sep as sep6 } from "path";
64518
65125
  var SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
64519
65126
  function isValidVersion(v2) {
@@ -64543,10 +65150,19 @@ function isUnderGlobalPrefix(packageRoot, execPath) {
64543
65150
  }
64544
65151
  function detectInstallContext(opts = {}) {
64545
65152
  const pkgName = opts.pkgName ?? "latticesql";
64546
- const cwd = opts.cwd ?? process.cwd();
64547
65153
  const env2 = opts.env ?? process.env;
64548
65154
  const execPath = opts.execPath ?? process.execPath;
64549
- const modulePath = opts.modulePath ?? process.argv[1] ?? cwd;
65155
+ const rawCwd = opts.cwd ?? process.cwd();
65156
+ const rawModulePath = opts.modulePath ?? process.argv[1] ?? rawCwd;
65157
+ const resolveReal = (p3) => {
65158
+ try {
65159
+ return realpathSync(p3);
65160
+ } catch {
65161
+ return p3;
65162
+ }
65163
+ };
65164
+ const modulePath = resolveReal(rawModulePath);
65165
+ const cwd = resolveReal(rawCwd);
64550
65166
  const packageRoot = findPackageRoot(dirname7(modulePath), pkgName);
64551
65167
  if (packageRoot && existsSync19(join24(packageRoot, ".git"))) {
64552
65168
  return {
@@ -66332,6 +66948,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66332
66948
  });
66333
66949
  return true;
66334
66950
  }
66951
+ if (pathname === "/api/cloud/row-grants" && method === "POST") {
66952
+ await tryHandler(res, async () => {
66953
+ const body = await readJson(req);
66954
+ const table = typeof body.table === "string" ? body.table : "";
66955
+ const pk = typeof body.pk === "string" ? body.pk : "";
66956
+ const strList = (v2) => Array.isArray(v2) ? v2.filter((x2) => typeof x2 === "string") : [];
66957
+ const grant = strList(body.grant);
66958
+ const revoke = strList(body.revoke);
66959
+ if (!table || !pk) {
66960
+ sendJson(res, { error: "table and pk are required" }, 400);
66961
+ return;
66962
+ }
66963
+ if (ctx.db.getDialect() !== "postgres") {
66964
+ sendJson(res, { error: "Per-row sharing requires a cloud (Postgres) database" }, 400);
66965
+ return;
66966
+ }
66967
+ await batchRowGrants(ctx.db, table, pk, grant, revoke);
66968
+ sendJson(res, { ok: true, table, pk, granted: grant, revoked: revoke });
66969
+ });
66970
+ return true;
66971
+ }
66335
66972
  if (pathname === "/api/cloud/s3-config" && method === "GET") {
66336
66973
  await tryHandler(res, () => {
66337
66974
  const label = activeWorkspaceLabel(ctx.configPath);
@@ -67128,7 +67765,7 @@ function enrichContext(ctx) {
67128
67765
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {}
67129
67766
  };
67130
67767
  }
67131
- async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67768
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res, privateMode) {
67132
67769
  try {
67133
67770
  return await enrichWithLlm(
67134
67771
  mctx,
@@ -67140,7 +67777,9 @@ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67140
67777
  ctx.entityDescriptions,
67141
67778
  ctx.createJunction,
67142
67779
  ctx.aggressiveness,
67143
- ctx.createEntity
67780
+ ctx.createEntity,
67781
+ false,
67782
+ privateMode
67144
67783
  );
67145
67784
  } catch (e6) {
67146
67785
  const err = e6;
@@ -67219,7 +67858,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67219
67858
  source: "ingest",
67220
67859
  onColumnsAdded: columnDescriptionHook(ctx.db)
67221
67860
  };
67861
+ const headerPrivate = req.headers["x-lattice-private"] === "1";
67222
67862
  if (ctx.pathname === "/api/ingest/upload") {
67863
+ const forcePrivate2 = headerPrivate;
67223
67864
  const rawName = typeof req.headers["x-filename"] === "string" && req.headers["x-filename"] || "";
67224
67865
  let name2 = "upload";
67225
67866
  if (rawName) {
@@ -67317,10 +67958,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67317
67958
  ...blob ? { blob_path: blob.blob_path } : {}
67318
67959
  } : blob ? { ref_kind: "blob", blob_path: blob.blob_path } : {}
67319
67960
  };
67320
- const { id: id2 } = await createRow(mctx, "files", {
67321
- ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
67322
- ...uploadRow
67323
- });
67961
+ const { id: id2 } = await createRow(
67962
+ mctx,
67963
+ "files",
67964
+ {
67965
+ ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
67966
+ ...uploadRow
67967
+ },
67968
+ forcePrivate2 ? "private" : void 0
67969
+ );
67324
67970
  try {
67325
67971
  const dedupCtx = {
67326
67972
  db: ctx.db,
@@ -67346,7 +67992,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67346
67992
  }
67347
67993
  let suggestedLinks = [];
67348
67994
  if (!result.skip) {
67349
- const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
67995
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res, forcePrivate2);
67350
67996
  if (links === null) return true;
67351
67997
  suggestedLinks = links;
67352
67998
  }
@@ -67373,6 +68019,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67373
68019
  sendJson4(res, { error: e6.message }, 400);
67374
68020
  return true;
67375
68021
  }
68022
+ const forcePrivate = headerPrivate || body.private === true;
67376
68023
  if (ctx.pathname === "/api/ingest/text") {
67377
68024
  const rawText = typeof body.text === "string" ? body.text : "";
67378
68025
  if (!rawText.trim()) {
@@ -67383,7 +68030,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67383
68030
  if (sourceUrl) {
67384
68031
  try {
67385
68032
  const result = await ingestUrlAsFile(
67386
- { db: ctx.db, mctx, enrich: enrichContext(ctx) },
68033
+ { db: ctx.db, mctx, enrich: enrichContext(ctx), privateMode: forcePrivate },
67387
68034
  sourceUrl
67388
68035
  );
67389
68036
  sendJson4(
@@ -67412,11 +68059,25 @@ async function dispatchIngestRoute(req, res, ctx) {
67412
68059
  description: describe(content, mime2, title),
67413
68060
  extraction_status: "extracted"
67414
68061
  };
67415
- const { id: id2 } = await createRow(mctx, "files", {
67416
- ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
67417
- ...textRow
67418
- });
67419
- const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
68062
+ const { id: id2 } = await createRow(
68063
+ mctx,
68064
+ "files",
68065
+ {
68066
+ ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
68067
+ ...textRow
68068
+ },
68069
+ forcePrivate ? "private" : void 0
68070
+ );
68071
+ const suggestedLinks = await enrichOrFail(
68072
+ mctx,
68073
+ ctx.db,
68074
+ id2,
68075
+ content,
68076
+ title,
68077
+ ctx,
68078
+ res,
68079
+ forcePrivate
68080
+ );
67420
68081
  if (suggestedLinks === null) return true;
67421
68082
  sendJson4(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
67422
68083
  return true;
@@ -67455,10 +68116,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67455
68116
  size_bytes: size,
67456
68117
  extraction_status: "pending"
67457
68118
  };
67458
- const { id } = await createRow(mctx, "files", {
67459
- ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
67460
- ...localRow
67461
- });
68119
+ const { id } = await createRow(
68120
+ mctx,
68121
+ "files",
68122
+ {
68123
+ ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
68124
+ ...localRow
68125
+ },
68126
+ forcePrivate ? "private" : void 0
68127
+ );
67462
68128
  try {
67463
68129
  const result = await extractSource(ctx.db, abs, mime, name);
67464
68130
  await updateRow(mctx, "files", id, {
@@ -67476,7 +68142,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67476
68142
  ctx.entityDescriptions,
67477
68143
  ctx.createJunction,
67478
68144
  ctx.aggressiveness,
67479
- ctx.createEntity
68145
+ ctx.createEntity,
68146
+ false,
68147
+ forcePrivate
67480
68148
  );
67481
68149
  sendJson4(
67482
68150
  res,
@@ -68163,7 +68831,7 @@ function startBackgroundRender(active) {
68163
68831
  }
68164
68832
  bus.publish(e6);
68165
68833
  };
68166
- void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
68834
+ void db.renderInBackground(active.outputDir, { signal, onProgress, gateOnOpen: true }).then(
68167
68835
  () => {
68168
68836
  },
68169
68837
  (err) => {
@@ -68505,6 +69173,28 @@ async function startGuiServer(options) {
68505
69173
  setActive(next, created.id);
68506
69174
  return created.id;
68507
69175
  };
69176
+ const cleanupWorkspaceFiles = (root6, ws) => {
69177
+ if (!ws.configPath && ws.kind === "local") {
69178
+ rmSync(workspaceDir(root6, ws.dir), { recursive: true, force: true });
69179
+ } else if (ws.kind === "cloud") {
69180
+ if (ws.configPath && existsSync24(ws.configPath)) {
69181
+ rmSync(ws.configPath, { force: true });
69182
+ }
69183
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69184
+ const label = labelMatch?.[1];
69185
+ if (label) {
69186
+ const stillUsed = listWorkspaces(root6).some(
69187
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69188
+ );
69189
+ if (!stillUsed) {
69190
+ try {
69191
+ deleteDbCredential(label);
69192
+ } catch {
69193
+ }
69194
+ }
69195
+ }
69196
+ }
69197
+ };
68508
69198
  const handleVirginRoute = async (req, res, pathname, method) => {
68509
69199
  if (method === "GET" && pathname === "/") {
68510
69200
  sendText(
@@ -68556,6 +69246,35 @@ async function startGuiServer(options) {
68556
69246
  }
68557
69247
  return true;
68558
69248
  }
69249
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
69250
+ if (!latticeRoot) {
69251
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
69252
+ return true;
69253
+ }
69254
+ const body = await readJson(req);
69255
+ if (typeof body.id !== "string") {
69256
+ sendJson(res, { error: "id must be a string" }, 400);
69257
+ return true;
69258
+ }
69259
+ const ws = getWorkspace(latticeRoot, body.id);
69260
+ if (!ws) {
69261
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
69262
+ return true;
69263
+ }
69264
+ removeWorkspace(latticeRoot, ws.id);
69265
+ try {
69266
+ cleanupWorkspaceFiles(latticeRoot, ws);
69267
+ } catch (e6) {
69268
+ sendJson(
69269
+ res,
69270
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
69271
+ 500
69272
+ );
69273
+ return true;
69274
+ }
69275
+ sendJson(res, { ok: true, switchedTo: null });
69276
+ return true;
69277
+ }
68559
69278
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
68560
69279
  await redeemInvite(createCloudWorkspace, req, res);
68561
69280
  return true;
@@ -68590,6 +69309,18 @@ async function startGuiServer(options) {
68590
69309
  );
68591
69310
  return;
68592
69311
  }
69312
+ if (method === "POST" && pathname === "/api/update/apply") {
69313
+ if (updateService) {
69314
+ void updateService.checkNow(true);
69315
+ sendJson(res, { ok: true, status: updateService.status() });
69316
+ } else {
69317
+ sendJson(res, {
69318
+ ok: false,
69319
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
69320
+ });
69321
+ }
69322
+ return;
69323
+ }
68593
69324
  if (!activeRef) {
68594
69325
  if (await handleVirginRoute(req, res, pathname, method)) return;
68595
69326
  sendJson(res, { error: "No active workspace" }, 409);
@@ -69683,26 +70414,7 @@ async function startGuiServer(options) {
69683
70414
  }
69684
70415
  removeWorkspace(latticeRoot, ws.id);
69685
70416
  try {
69686
- if (!ws.configPath && ws.kind === "local") {
69687
- rmSync(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
69688
- } else if (ws.kind === "cloud") {
69689
- if (ws.configPath && existsSync24(ws.configPath)) {
69690
- rmSync(ws.configPath, { force: true });
69691
- }
69692
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69693
- const label = labelMatch?.[1];
69694
- if (label) {
69695
- const stillUsed = listWorkspaces(latticeRoot).some(
69696
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69697
- );
69698
- if (!stillUsed) {
69699
- try {
69700
- deleteDbCredential(label);
69701
- } catch {
69702
- }
69703
- }
69704
- }
69705
- }
70417
+ cleanupWorkspaceFiles(latticeRoot, ws);
69706
70418
  } catch (e6) {
69707
70419
  sendJson(
69708
70420
  res,
@@ -70168,7 +70880,9 @@ ${e6.stack ?? ""}`
70168
70880
  }
70169
70881
  }
70170
70882
  };
70171
- if (options.selfUpdate && guiVersion) {
70883
+ if (options.updateServiceFactory) {
70884
+ updateService = options.updateServiceFactory(broadcast);
70885
+ } else if (options.selfUpdate && guiVersion) {
70172
70886
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
70173
70887
  }
70174
70888
  const handleEventStream = (ws) => {