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.cjs CHANGED
@@ -238,13 +238,14 @@ function readManifest(outputDir) {
238
238
  function writeManifest(outputDir, manifest) {
239
239
  atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
240
240
  }
241
- var import_node_path2, import_node_fs2;
241
+ var import_node_path2, import_node_fs2, TEMPLATE_VERSION;
242
242
  var init_manifest = __esm({
243
243
  "src/lifecycle/manifest.ts"() {
244
244
  "use strict";
245
245
  import_node_path2 = require("path");
246
246
  import_node_fs2 = require("fs");
247
247
  init_writer();
248
+ TEMPLATE_VERSION = 1;
248
249
  }
249
250
  });
250
251
 
@@ -278,6 +279,126 @@ var init_adapter = __esm({
278
279
  }
279
280
  });
280
281
 
282
+ // src/lifecycle/render-cursor.ts
283
+ function markToString(v2) {
284
+ if (v2 == null) return null;
285
+ if (v2 instanceof Date) return v2.toISOString();
286
+ if (typeof v2 === "string") return v2;
287
+ if (typeof v2 === "number" || typeof v2 === "bigint" || typeof v2 === "boolean") return String(v2);
288
+ return null;
289
+ }
290
+ function padNumericMark(v2) {
291
+ const s2 = markToString(v2);
292
+ if (s2 == null) return null;
293
+ if (/^\d+$/.test(s2)) return s2.padStart(20, "0");
294
+ return s2;
295
+ }
296
+ async function changelogExists(adapter) {
297
+ if (adapter.dialect === "postgres") {
298
+ const row2 = await getAsyncOrSync(
299
+ adapter,
300
+ `SELECT to_regclass('__lattice_changelog') AS reg`
301
+ );
302
+ return !!row2 && row2.reg != null;
303
+ }
304
+ const row = await getAsyncOrSync(
305
+ adapter,
306
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='__lattice_changelog'`
307
+ );
308
+ return !!row;
309
+ }
310
+ async function changelogMark(adapter) {
311
+ try {
312
+ if (!await changelogExists(adapter)) return null;
313
+ const col = adapter.dialect === "postgres" ? "seq" : "rowid";
314
+ const row = await getAsyncOrSync(
315
+ adapter,
316
+ `SELECT MAX(${col}) AS m FROM __lattice_changelog`
317
+ );
318
+ return padNumericMark(row?.m);
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+ async function sharingMarks(adapter) {
324
+ if (adapter.dialect !== "postgres") return { grants: null, owners: null };
325
+ try {
326
+ const reg = await getAsyncOrSync(
327
+ adapter,
328
+ `SELECT to_regclass('__lattice_changes') AS reg`
329
+ );
330
+ const hasFeed = !!reg && reg.reg != null;
331
+ if (hasFeed) {
332
+ const row = await getAsyncOrSync(
333
+ adapter,
334
+ `SELECT COUNT(*) AS n, MAX(seq) AS m FROM lattice_changes_since(0, 1000)`
335
+ );
336
+ const digest = digestOf(row?.n, row?.m);
337
+ return { grants: digest, owners: digest };
338
+ }
339
+ } catch {
340
+ }
341
+ let owners = null;
342
+ let grants = null;
343
+ try {
344
+ const o3 = await getAsyncOrSync(
345
+ adapter,
346
+ `SELECT COUNT(*) AS n, MAX(updated_at) AS m FROM __lattice_owners`
347
+ );
348
+ owners = digestOf(o3?.n, o3?.m);
349
+ } catch {
350
+ owners = null;
351
+ }
352
+ try {
353
+ const g6 = await getAsyncOrSync(
354
+ adapter,
355
+ `SELECT COUNT(*) AS n, MAX(granted_at) AS m FROM __lattice_row_grants`
356
+ );
357
+ grants = digestOf(g6?.n, g6?.m);
358
+ } catch {
359
+ grants = null;
360
+ }
361
+ return { grants, owners };
362
+ }
363
+ function digestOf(count, max) {
364
+ const n3 = padNumericMark(count);
365
+ if (n3 == null) return null;
366
+ const m4 = markToString(max) ?? "";
367
+ return `${n3}#${m4}`;
368
+ }
369
+ async function computeRenderCursor(adapter) {
370
+ try {
371
+ const [changelog, sharing] = await Promise.all([changelogMark(adapter), sharingMarks(adapter)]);
372
+ return { changelog, grants: sharing.grants, owners: sharing.owners };
373
+ } catch {
374
+ return { ...EMPTY_CURSOR };
375
+ }
376
+ }
377
+ function cursorIsFresh(recorded, live, templateVersion = TEMPLATE_VERSION) {
378
+ if (recorded == null) return false;
379
+ if (recorded.templateVersion !== templateVersion) return false;
380
+ const rc = recorded.cursor;
381
+ if (rc == null) return false;
382
+ if (!fieldFresh(rc.changelog, live.changelog, (r6, l4) => l4 <= r6)) return false;
383
+ if (!fieldFresh(rc.grants, live.grants, (r6, l4) => l4 === r6)) return false;
384
+ if (!fieldFresh(rc.owners, live.owners, (r6, l4) => l4 === r6)) return false;
385
+ return true;
386
+ }
387
+ function fieldFresh(recorded, live, ok) {
388
+ if (recorded == null && live == null) return true;
389
+ if (recorded == null || live == null) return false;
390
+ return ok(recorded, live);
391
+ }
392
+ var EMPTY_CURSOR;
393
+ var init_render_cursor = __esm({
394
+ "src/lifecycle/render-cursor.ts"() {
395
+ "use strict";
396
+ init_adapter();
397
+ init_manifest();
398
+ EMPTY_CURSOR = { changelog: null, grants: null, owners: null };
399
+ }
400
+ });
401
+
281
402
  // src/db/sqlite.ts
282
403
  var import_better_sqlite3, SQLiteAdapter;
283
404
  var init_sqlite = __esm({
@@ -2320,7 +2441,18 @@ var init_concurrency = __esm({
2320
2441
  });
2321
2442
 
2322
2443
  // src/render/engine.ts
2323
- var import_node_path5, import_node_fs4, YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
2444
+ function entityContentChanged(fresh, prior) {
2445
+ const freshKeys = Object.keys(fresh);
2446
+ const priorKeys = Object.keys(prior);
2447
+ if (freshKeys.length !== priorKeys.length) return true;
2448
+ for (const k6 of freshKeys) {
2449
+ const p3 = prior[k6];
2450
+ if (p3 == null) return true;
2451
+ if (p3.hash === "" || p3.hash !== fresh[k6]?.hash) return true;
2452
+ }
2453
+ return false;
2454
+ }
2455
+ var import_node_path5, import_node_fs4, DeferredTableProgress, YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
2324
2456
  var init_engine = __esm({
2325
2457
  "src/render/engine.ts"() {
2326
2458
  "use strict";
@@ -2332,9 +2464,44 @@ var init_engine = __esm({
2332
2464
  init_entity_query();
2333
2465
  init_entity_templates();
2334
2466
  init_manifest();
2467
+ init_render_cursor();
2335
2468
  init_cleanup();
2336
2469
  init_progress();
2337
2470
  init_concurrency();
2471
+ DeferredTableProgress = class {
2472
+ constructor(throttle) {
2473
+ this.throttle = throttle;
2474
+ }
2475
+ changed = false;
2476
+ pendingStart = null;
2477
+ /** Buffer the `table-start` event; emitted only if/when the table changes. */
2478
+ start(event) {
2479
+ if (this.changed) {
2480
+ this.throttle.force(event);
2481
+ return;
2482
+ }
2483
+ this.pendingStart = event;
2484
+ }
2485
+ /** Mark that an entity's content changed — flush the held `table-start` once. */
2486
+ markChanged() {
2487
+ if (this.changed) return;
2488
+ this.changed = true;
2489
+ if (this.pendingStart) {
2490
+ this.throttle.force(this.pendingStart);
2491
+ this.pendingStart = null;
2492
+ }
2493
+ }
2494
+ /** Coalesced per-entity progress — dropped entirely until the table changed. */
2495
+ tick(event) {
2496
+ if (!this.changed) return;
2497
+ this.throttle.tick(event);
2498
+ }
2499
+ /** Lifecycle event (`table-done`) — emitted only if the table changed. */
2500
+ force(event) {
2501
+ if (!this.changed) return;
2502
+ this.throttle.force(event);
2503
+ }
2504
+ };
2338
2505
  YIELD_EVERY_ENTITIES = 200;
2339
2506
  RENDER_TABLE_CONCURRENCY = 4;
2340
2507
  NOOP_RENDER = () => "";
@@ -2451,20 +2618,23 @@ var init_engine = __esm({
2451
2618
  }
2452
2619
  const content = def.tokenBudget ? applyTokenBudget(rows, def.render, def.tokenBudget, def.prioritizeBy) : def.render(rows);
2453
2620
  const filePath = (0, import_node_path5.join)(outputDir, def.outputFile);
2454
- if (atomicWrite(filePath, content)) {
2621
+ const wrote = atomicWrite(filePath, content);
2622
+ if (wrote) {
2455
2623
  filesWritten.push(filePath);
2456
2624
  } else {
2457
2625
  counters.skipped++;
2458
2626
  }
2459
- throttle.force({
2460
- kind: "table-done",
2461
- table: name,
2462
- entitiesRendered: rows.length,
2463
- entitiesTotal: rows.length,
2464
- tableIndex: 0,
2465
- tableCount: 0,
2466
- pct: 100
2467
- });
2627
+ if (wrote) {
2628
+ throttle.force({
2629
+ kind: "table-done",
2630
+ table: name,
2631
+ entitiesRendered: rows.length,
2632
+ entitiesTotal: rows.length,
2633
+ tableIndex: 0,
2634
+ tableCount: 0,
2635
+ pct: 100
2636
+ });
2637
+ }
2468
2638
  }
2469
2639
  for (const [name, def] of this._schema.getMultis()) {
2470
2640
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
@@ -2478,32 +2648,38 @@ var init_engine = __esm({
2478
2648
  tables[t8] = await this._schema.queryTable(this._adapter, t8, this._readRel);
2479
2649
  }
2480
2650
  }
2651
+ let wroteAny = false;
2481
2652
  for (const key of keys) {
2482
2653
  const content = def.render(key, tables);
2483
2654
  const filePath = (0, import_node_path5.join)(outputDir, def.outputFile(key));
2484
2655
  if (atomicWrite(filePath, content)) {
2485
2656
  filesWritten.push(filePath);
2657
+ wroteAny = true;
2486
2658
  } else {
2487
2659
  counters.skipped++;
2488
2660
  }
2489
2661
  }
2490
- throttle.force({
2491
- kind: "table-done",
2492
- table: name,
2493
- entitiesRendered: keys.length,
2494
- entitiesTotal: keys.length,
2495
- tableIndex: 0,
2496
- tableCount: 0,
2497
- pct: 100
2498
- });
2662
+ if (wroteAny) {
2663
+ throttle.force({
2664
+ kind: "table-done",
2665
+ table: name,
2666
+ entitiesRendered: keys.length,
2667
+ entitiesTotal: keys.length,
2668
+ tableIndex: 0,
2669
+ tableCount: 0,
2670
+ pct: 100
2671
+ });
2672
+ }
2499
2673
  }
2674
+ const priorManifest = readManifest(outputDir);
2500
2675
  const entityContextManifest = await this._renderEntityContexts(
2501
2676
  outputDir,
2502
2677
  filesWritten,
2503
2678
  counters,
2504
2679
  throttle,
2505
2680
  signal,
2506
- opts.changedTables
2681
+ opts.changedTables,
2682
+ priorManifest
2507
2683
  );
2508
2684
  if (entityContextManifest === null) {
2509
2685
  return this._abortedResult(filesWritten, counters, start);
@@ -2514,10 +2690,13 @@ var init_engine = __esm({
2514
2690
  const prev = readManifest(outputDir);
2515
2691
  entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
2516
2692
  }
2693
+ const cursor = await computeRenderCursor(this._adapter);
2517
2694
  writeManifest(outputDir, {
2518
2695
  version: 2,
2519
2696
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
2520
- entityContexts
2697
+ entityContexts,
2698
+ templateVersion: TEMPLATE_VERSION,
2699
+ cursor
2521
2700
  });
2522
2701
  }
2523
2702
  const result = {
@@ -2583,7 +2762,7 @@ var init_engine = __esm({
2583
2762
  * partial tree). Progress is reported through `throttle`; abort is observed
2584
2763
  * via `signal`.
2585
2764
  */
2586
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
2765
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables, priorManifest) {
2587
2766
  const protectedTables = /* @__PURE__ */ new Set();
2588
2767
  for (const [t8, d6] of this._schema.getEntityContexts()) {
2589
2768
  if (d6.protected) protectedTables.add(t8);
@@ -2602,8 +2781,10 @@ var init_engine = __esm({
2602
2781
  const baseRows = await this._schema.queryTable(this._adapter, table, this._readRel);
2603
2782
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
2604
2783
  const directoryRoot = def.directoryRoot ?? table;
2784
+ const deferred = new DeferredTableProgress(throttle);
2785
+ const priorEntities = priorManifest?.entityContexts[table]?.entities ?? {};
2605
2786
  const entitiesTotal = allRows.length;
2606
- throttle.force({
2787
+ deferred.start({
2607
2788
  kind: "table-start",
2608
2789
  table,
2609
2790
  entitiesRendered: 0,
@@ -2612,6 +2793,7 @@ var init_engine = __esm({
2612
2793
  tableCount,
2613
2794
  pct: 0
2614
2795
  });
2796
+ if (Object.keys(priorEntities).length !== entitiesTotal) deferred.markChanged();
2615
2797
  const manifestEntry = {
2616
2798
  directoryRoot,
2617
2799
  ...def.index ? { indexFile: def.index.outputFile } : {},
@@ -2727,8 +2909,10 @@ var init_engine = __esm({
2727
2909
  }
2728
2910
  }
2729
2911
  manifestEntry.entities[slug] = entityFileHashes;
2912
+ const priorHashes = normalizeEntityFiles(priorEntities[slug] ?? {});
2913
+ if (entityContentChanged(entityFileHashes, priorHashes)) deferred.markChanged();
2730
2914
  const entitiesRendered = i6 + 1;
2731
- throttle.tick({
2915
+ deferred.tick({
2732
2916
  kind: "table-progress",
2733
2917
  table,
2734
2918
  entitiesRendered,
@@ -2738,7 +2922,7 @@ var init_engine = __esm({
2738
2922
  pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
2739
2923
  });
2740
2924
  }
2741
- throttle.force({
2925
+ deferred.force({
2742
2926
  kind: "table-done",
2743
2927
  table,
2744
2928
  entitiesRendered: entitiesTotal,
@@ -4161,6 +4345,22 @@ function deleteAssistantCredential(kind) {
4161
4345
  void _removed;
4162
4346
  saveAssistantCredentials(rest);
4163
4347
  }
4348
+ function isAssistantCredentialCleared(kind) {
4349
+ return loadAssistantCredentials()[CLEARED_SENTINEL_PREFIX + kind] === "1";
4350
+ }
4351
+ function setAssistantCredentialCleared(kind) {
4352
+ const creds = loadAssistantCredentials();
4353
+ creds[CLEARED_SENTINEL_PREFIX + kind] = "1";
4354
+ saveAssistantCredentials(creds);
4355
+ }
4356
+ function clearAssistantCredentialCleared(kind) {
4357
+ const creds = loadAssistantCredentials();
4358
+ const sentinel = CLEARED_SENTINEL_PREFIX + kind;
4359
+ if (!(sentinel in creds)) return;
4360
+ const { [sentinel]: _removed, ...rest } = creds;
4361
+ void _removed;
4362
+ saveAssistantCredentials(rest);
4363
+ }
4164
4364
  function ensureKeysDir() {
4165
4365
  const dir = (0, import_node_path10.join)(ensureConfigDir(), KEYS_SUBDIR);
4166
4366
  if (!(0, import_node_fs9.existsSync)(dir)) {
@@ -4205,7 +4405,7 @@ function deleteToken(label) {
4205
4405
  const path2 = (0, import_node_path10.join)(ensureKeysDir(), label + TOKEN_EXT);
4206
4406
  if ((0, import_node_fs9.existsSync)(path2)) (0, import_node_fs9.unlinkSync)(path2);
4207
4407
  }
4208
- var import_node_crypto5, import_node_fs9, import_node_os3, import_node_path10, import_yaml2, 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;
4408
+ var import_node_crypto5, import_node_fs9, import_node_os3, import_node_path10, import_yaml2, 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;
4209
4409
  var init_user_config = __esm({
4210
4410
  "src/framework/user-config.ts"() {
4211
4411
  "use strict";
@@ -4233,6 +4433,7 @@ var init_user_config = __esm({
4233
4433
  lockDepthInProcess = 0;
4234
4434
  S3_CONFIG_FILENAME = "s3-config.enc";
4235
4435
  ASSISTANT_CREDENTIALS_FILENAME = "assistant-credentials.enc";
4436
+ CLEARED_SENTINEL_PREFIX = "__cleared__:";
4236
4437
  KEYS_SUBDIR = "keys";
4237
4438
  TOKEN_EXT = ".token";
4238
4439
  }
@@ -4326,14 +4527,6 @@ function resolveDbPath(raw, configDir2) {
4326
4527
  }
4327
4528
  return (0, import_node_path11.resolve)(configDir2, raw);
4328
4529
  }
4329
- function warnDeprecatedRef(entity, field, target) {
4330
- const key = `${entity}.${field}`;
4331
- if (warnedDeprecatedRefs.has(key)) return;
4332
- warnedDeprecatedRefs.add(key);
4333
- console.warn(
4334
- `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.`
4335
- );
4336
- }
4337
4530
  function entityToTableDef(entityName, entity) {
4338
4531
  const rawFields = entity.fields;
4339
4532
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -4360,7 +4553,6 @@ function entityToTableDef(entityName, entity) {
4360
4553
  table: field.ref,
4361
4554
  foreignKey: fieldName
4362
4555
  };
4363
- warnDeprecatedRef(entityName, fieldName, field.ref);
4364
4556
  }
4365
4557
  }
4366
4558
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -4517,7 +4709,7 @@ function parseEntityContexts(entityContexts) {
4517
4709
  }
4518
4710
  return result;
4519
4711
  }
4520
- var import_node_fs10, import_node_path11, import_yaml3, warnedDeprecatedRefs;
4712
+ var import_node_fs10, import_node_path11, import_yaml3;
4521
4713
  var init_parser = __esm({
4522
4714
  "src/config/parser.ts"() {
4523
4715
  "use strict";
@@ -4525,7 +4717,6 @@ var init_parser = __esm({
4525
4717
  import_node_path11 = require("path");
4526
4718
  import_yaml3 = require("yaml");
4527
4719
  init_user_config();
4528
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
4529
4720
  }
4530
4721
  });
4531
4722
 
@@ -5243,6 +5434,7 @@ var init_lattice = __esm({
5243
5434
  init_shred();
5244
5435
  init_encryption();
5245
5436
  init_manifest();
5437
+ init_render_cursor();
5246
5438
  import_node_fs12 = require("fs");
5247
5439
  init_adapter();
5248
5440
  init_sqlite();
@@ -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,
@@ -47870,6 +48127,111 @@ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47870
48127
  AND g."pk" = ANY(p_pks)
47871
48128
  AND o."owner_role" = session_user;
47872
48129
  $fn$;
48130
+
48131
+ -- Add a column to a user table AS THE OWNER, on behalf of a scoped member. A
48132
+ -- member's role has no CREATE/ALTER on the schema (the bootstrap REVOKEs CREATE
48133
+ -- from PUBLIC), so a member's GUI "add a field" write (createRow/updateRow with a
48134
+ -- field the table lacks) cannot run ALTER TABLE itself. This SECURITY DEFINER
48135
+ -- helper performs that ALTER \u2014 and the masking-view regen \u2014 with the owner's
48136
+ -- rights, so member-added columns behave identically to owner-added ones.
48137
+ --
48138
+ -- Injection-safe + minimal: p_table must be an existing BASE table in the current
48139
+ -- schema (rejected otherwise); p_type is whitelisted against the exact set the
48140
+ -- library's addColumn emits for an auto-added column (TEXT / INTEGER / REAL, plus
48141
+ -- BOOLEAN) \u2014 never interpolated raw; both identifiers go through %I (quote_ident).
48142
+ -- Member-callable (granted EXECUTE to the member group), but it can only widen the
48143
+ -- schema, never read or alter another member's data.
48144
+ CREATE OR REPLACE FUNCTION lattice_member_add_column(p_table text, p_column text, p_type text)
48145
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
48146
+ DECLARE
48147
+ v_type text;
48148
+ v_view text := p_table || '_v';
48149
+ v_has_view boolean;
48150
+ v_pk_expr text;
48151
+ v_select text;
48152
+ BEGIN
48153
+ -- Never alter internal bookkeeping tables (names start with "_"). The GUI only
48154
+ -- ever calls this for a user entity table; rejecting the rest is defense-in-depth
48155
+ -- against a member invoking the function directly against ownership/audit/policy
48156
+ -- tables.
48157
+ IF left(p_table, 1) = '_' THEN
48158
+ RAISE EXCEPTION 'lattice: cannot add a column to internal table "%"', p_table;
48159
+ END IF;
48160
+
48161
+ -- p_table must be a real base table in THIS schema (search_path is pinned to the
48162
+ -- cloud schema by pinDefinerSearchPath, so to_regclass resolves there).
48163
+ IF NOT EXISTS (
48164
+ SELECT 1 FROM pg_class c
48165
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48166
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND c.relkind = 'r'
48167
+ ) THEN
48168
+ RAISE EXCEPTION 'lattice: no such table "%"', p_table;
48169
+ END IF;
48170
+
48171
+ -- Whitelist the column type. These are exactly the specs addColumn's
48172
+ -- inferColumnType produces (TEXT / INTEGER / REAL); BOOLEAN is allowed too.
48173
+ -- Anything else is rejected \u2014 the type is spliced as %s (NOT %I), so it must be
48174
+ -- a known-safe literal and never caller-controlled SQL.
48175
+ v_type := upper(btrim(p_type));
48176
+ IF v_type NOT IN ('TEXT', 'INTEGER', 'REAL', 'BOOLEAN') THEN
48177
+ RAISE EXCEPTION 'lattice: unsupported column type "%"', p_type;
48178
+ END IF;
48179
+
48180
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS %I %s', p_table, p_column, v_type);
48181
+
48182
+ -- If the table is cell-masked (a "<table>_v" view exists, because some column has
48183
+ -- an audience), the view selects an explicit column list \u2014 so a new column is
48184
+ -- invisible to members until the view is regenerated. Rebuild it the same way the
48185
+ -- owner path (audienceViewSql / regenerateAudienceViewFromDb) does: pass every
48186
+ -- column through except those with an 'owner' audience in __lattice_column_policy
48187
+ -- (CASE WHEN lattice_is_owner(...) THEN col END), re-apply row visibility with
48188
+ -- WHERE lattice_row_visible(table, pk), and keep the member SELECT grant on the
48189
+ -- view. Unmasked tables need no regen \u2014 the member group's table-level base grant
48190
+ -- already covers the new column.
48191
+ SELECT EXISTS (
48192
+ SELECT 1 FROM pg_class c
48193
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48194
+ WHERE n.nspname = current_schema() AND c.relname = v_view AND c.relkind = 'v'
48195
+ ) INTO v_has_view;
48196
+
48197
+ IF v_has_view THEN
48198
+ -- Canonical pk expression: CAST("col" AS TEXT) joined by TAB (chr(9)) \u2014 the
48199
+ -- same serialization the RLS policies + audienceViewSql use.
48200
+ SELECT string_agg(format('CAST(%I AS TEXT)', a.attname), ' || chr(9) || '
48201
+ ORDER BY array_position(i.indkey, a.attnum))
48202
+ INTO v_pk_expr
48203
+ FROM pg_index i
48204
+ JOIN pg_class c ON c.oid = i.indrelid
48205
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48206
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
48207
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND i.indisprimary;
48208
+ IF v_pk_expr IS NULL THEN
48209
+ RAISE EXCEPTION 'lattice: cannot regenerate mask view for "%": no primary key', p_table;
48210
+ END IF;
48211
+
48212
+ -- Build the masked SELECT list in column order, applying the per-column policy.
48213
+ SELECT string_agg(
48214
+ CASE
48215
+ WHEN cp."audience" = 'owner'
48216
+ THEN format('CASE WHEN lattice_is_owner(%L, %s) THEN %I END AS %I',
48217
+ p_table, v_pk_expr, cols.column_name, cols.column_name)
48218
+ ELSE format('%I', cols.column_name)
48219
+ END,
48220
+ ', ' ORDER BY cols.ordinal_position)
48221
+ INTO v_select
48222
+ FROM information_schema.columns cols
48223
+ LEFT JOIN "__lattice_column_policy" cp
48224
+ ON cp."table_name" = p_table AND cp."column_name" = cols.column_name
48225
+ AND cp."audience" NOT IN ('', 'everyone', 'row-audience')
48226
+ WHERE cols.table_schema = current_schema() AND cols.table_name = p_table;
48227
+
48228
+ EXECUTE format(
48229
+ 'CREATE OR REPLACE VIEW %I AS SELECT %s FROM %I WHERE lattice_row_visible(%L, %s)',
48230
+ v_view, v_select, p_table, p_table, v_pk_expr);
48231
+ EXECUTE format('GRANT SELECT ON %I TO ${MEMBER_GROUP}', v_view);
48232
+ END IF;
48233
+ END $fn$;
48234
+ GRANT EXECUTE ON FUNCTION lattice_member_add_column(text, text, text) TO ${MEMBER_GROUP};
47873
48235
  `;
47874
48236
  }
47875
48237
  });
@@ -47979,6 +48341,11 @@ async function revokeRow(db, table, pk, grantee) {
47979
48341
  assertPg(db);
47980
48342
  await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_row(?, ?, ?)`, [table, pk, grantee]);
47981
48343
  }
48344
+ async function batchRowGrants(db, table, pk, grant, revoke) {
48345
+ assertPg(db);
48346
+ for (const grantee of grant) await grantRow(db, table, pk, grantee);
48347
+ for (const grantee of revoke) await revokeRow(db, table, pk, grantee);
48348
+ }
47982
48349
  async function revokeMemberRole(db, role) {
47983
48350
  assertPg(db);
47984
48351
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
@@ -49082,18 +49449,9 @@ function sessionUndoneFilters(undone, sessionId) {
49082
49449
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
49083
49450
  return filters;
49084
49451
  }
49085
- async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
49086
- const undone = await db.query("_lattice_gui_audit", {
49087
- filters: sessionUndoneFilters(1, sessionId)
49088
- });
49089
- for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
49090
- await db.insert("_lattice_gui_audit", {
49452
+ function buildAuditRow(table, rowId, op, before, after, sessionId, editTs) {
49453
+ return {
49091
49454
  id: crypto.randomUUID(),
49092
- // Set ts explicitly (don't rely on the column DEFAULT — it uses the
49093
- // SQLite-only `strftime(...)`, which doesn't yield a parseable ISO string
49094
- // on Postgres, so cloud history rendered "Invalid Date"). #4.6 — honor the
49095
- // originating client's validated edit time when present (an offline edit
49096
- // replayed later records when it was MADE, not when it synced), else now().
49097
49455
  ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
49098
49456
  table_name: table,
49099
49457
  row_id: rowId,
@@ -49102,7 +49460,9 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49102
49460
  after_json: after ? JSON.stringify(after) : null,
49103
49461
  undone: 0,
49104
49462
  session_id: sessionId ?? null
49105
- });
49463
+ };
49464
+ }
49465
+ function publishMutationFeed(feed, table, rowId, op, before, after, source) {
49106
49466
  const labelRow = op === "delete" ? before : after;
49107
49467
  feed.publish({
49108
49468
  table,
@@ -49112,17 +49472,28 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49112
49472
  summary: feedSummary(op, table, labelRow)
49113
49473
  });
49114
49474
  }
49115
- function isSchemaOp(operation2) {
49116
- return operation2.startsWith(SCHEMA_OP_PREFIX);
49117
- }
49118
- async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
49475
+ async function purgeRedoStack(db, sessionId) {
49119
49476
  const undone = await db.query("_lattice_gui_audit", {
49120
49477
  filters: sessionUndoneFilters(1, sessionId)
49121
49478
  });
49122
49479
  for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
49480
+ }
49481
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
49482
+ await purgeRedoStack(db, sessionId);
49483
+ await db.insert(
49484
+ "_lattice_gui_audit",
49485
+ buildAuditRow(table, rowId, op, before, after, sessionId, editTs)
49486
+ );
49487
+ publishMutationFeed(feed, table, rowId, op, before, after, source);
49488
+ }
49489
+ function isSchemaOp(operation2) {
49490
+ return operation2.startsWith(SCHEMA_OP_PREFIX);
49491
+ }
49492
+ async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
49493
+ await purgeRedoStack(db, sessionId);
49123
49494
  await db.insert("_lattice_gui_audit", {
49124
49495
  id: crypto.randomUUID(),
49125
- // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
49496
+ // Explicit ISO ts — see buildAuditRow (the SQLite-only strftime DEFAULT
49126
49497
  // rendered "Invalid Date" on the Postgres/cloud path).
49127
49498
  ts: (/* @__PURE__ */ new Date()).toISOString(),
49128
49499
  table_name: table,
@@ -49157,7 +49528,7 @@ async function ensureColumns(db, table, values) {
49157
49528
  const added = Object.keys(values).filter((k6) => !(k6 in existing));
49158
49529
  if (added.length === 0) return [];
49159
49530
  for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
49160
- if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49531
+ if (!db.isCloudMemberOpen() && db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49161
49532
  const cols = db.getRegisteredColumns(table);
49162
49533
  const pk = db.getPrimaryKey(table);
49163
49534
  if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
@@ -49279,7 +49650,14 @@ async function deleteRow(ctx, table, id, hard) {
49279
49650
  ctx.clientTs
49280
49651
  );
49281
49652
  } else {
49282
- await ctx.db.delete(table, id);
49653
+ await hardDelete(ctx, table, id, before);
49654
+ }
49655
+ }
49656
+ async function hardDelete(ctx, table, id, before) {
49657
+ const withClient = ctx.db.adapter.withClient?.bind(ctx.db.adapter);
49658
+ const pkCols = ctx.db.getPrimaryKey(table);
49659
+ const pkCol = pkCols.length === 1 ? pkCols[0] : void 0;
49660
+ if (!withClient || ctx.db.isChangelogTracked(table) || pkCol === void 0) {
49283
49661
  await appendAudit(
49284
49662
  ctx.db,
49285
49663
  ctx.feed,
@@ -49292,10 +49670,30 @@ async function deleteRow(ctx, table, id, hard) {
49292
49670
  ctx.sessionId,
49293
49671
  ctx.clientTs
49294
49672
  );
49673
+ await ctx.db.delete(table, id);
49674
+ return;
49295
49675
  }
49676
+ const auditRow = buildAuditRow(table, id, "delete", before, null, ctx.sessionId, ctx.clientTs);
49677
+ await purgeRedoStack(ctx.db, ctx.sessionId);
49678
+ const auditCols = AUDIT_COLUMNS.map((c6) => `"${c6}"`).join(", ");
49679
+ const auditPlaceholders = AUDIT_COLUMNS.map(() => "?").join(", ");
49680
+ const auditValues = AUDIT_COLUMNS.map((c6) => auditRow[c6]);
49681
+ const pkColQuoted = pkCol.replace(/"/g, '""');
49682
+ await withClient(async (tx) => {
49683
+ await tx.run(
49684
+ `INSERT INTO "_lattice_gui_audit" (${auditCols}) VALUES (${auditPlaceholders})`,
49685
+ auditValues
49686
+ );
49687
+ await tx.run(`DELETE FROM "${table.replace(/"/g, '""')}" WHERE "${pkColQuoted}" = ?`, [id]);
49688
+ });
49689
+ publishMutationFeed(ctx.feed, table, id, "delete", before, null, ctx.source);
49296
49690
  }
49297
- async function linkRows(ctx, table, body) {
49298
- await ctx.db.link(table, body);
49691
+ async function linkRows(ctx, table, body, forceVisibility) {
49692
+ if (forceVisibility !== void 0) {
49693
+ await ctx.db.insertForcingVisibility(table, body, forceVisibility);
49694
+ } else {
49695
+ await ctx.db.link(table, body);
49696
+ }
49299
49697
  await appendAudit(ctx.db, ctx.feed, table, null, "link", null, body, ctx.source, ctx.sessionId);
49300
49698
  }
49301
49699
  async function unlinkRows(ctx, table, body) {
@@ -49433,13 +49831,24 @@ async function revertEntry(ctx, id) {
49433
49831
  });
49434
49832
  return { ok: true, entry };
49435
49833
  }
49436
- var import_node_crypto15, SCHEMA_OP_PREFIX;
49834
+ var import_node_crypto15, AUDIT_COLUMNS, SCHEMA_OP_PREFIX;
49437
49835
  var init_mutations = __esm({
49438
49836
  "src/gui/mutations.ts"() {
49439
49837
  "use strict";
49440
49838
  import_node_crypto15 = require("crypto");
49441
49839
  init_cloud_connect();
49442
49840
  init_audience();
49841
+ AUDIT_COLUMNS = [
49842
+ "id",
49843
+ "ts",
49844
+ "table_name",
49845
+ "row_id",
49846
+ "operation",
49847
+ "before_json",
49848
+ "after_json",
49849
+ "undone",
49850
+ "session_id"
49851
+ ];
49443
49852
  SCHEMA_OP_PREFIX = "schema.";
49444
49853
  }
49445
49854
  });
@@ -49727,6 +50136,10 @@ async function readMachineCredential(db, kind) {
49727
50136
  }
49728
50137
  return null;
49729
50138
  }
50139
+ async function resolveAnthropicKey(db) {
50140
+ if (isAssistantCredentialCleared(CREDENTIALS.anthropic.kind)) return null;
50141
+ return await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50142
+ }
49730
50143
  function getAggressiveness() {
49731
50144
  const n3 = readPreferences().aggressiveness;
49732
50145
  if (!Number.isFinite(n3)) return DEFAULT_AGGRESSIVENESS;
@@ -49757,6 +50170,7 @@ async function getVoiceCredential(db) {
49757
50170
  return null;
49758
50171
  }
49759
50172
  async function hasCredential(db, name, envVar) {
50173
+ if (isAssistantCredentialCleared(CREDENTIALS[name].kind)) return false;
49760
50174
  return Boolean(await readMachineCredential(db, CREDENTIALS[name].kind)) || Boolean(process.env[envVar]);
49761
50175
  }
49762
50176
  async function resolveClaudeAuth(db) {
@@ -49779,7 +50193,7 @@ async function resolveClaudeAuth(db) {
49779
50193
  } catch {
49780
50194
  }
49781
50195
  }
49782
- const apiKey = await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50196
+ const apiKey = await resolveAnthropicKey(db);
49783
50197
  return apiKey ? { apiKey } : null;
49784
50198
  }
49785
50199
  async function hasClaudeAuth(db) {
@@ -49876,6 +50290,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49876
50290
  }
49877
50291
  const cred = CREDENTIALS[name];
49878
50292
  setAssistantCredential(cred.kind, key);
50293
+ clearAssistantCredentialCleared(cred.kind);
49879
50294
  if (db) {
49880
50295
  for (const row of await liveSecretsOfKind(db, cred.kind)) {
49881
50296
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -49892,6 +50307,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49892
50307
  return true;
49893
50308
  }
49894
50309
  deleteAssistantCredential(CREDENTIALS[name].kind);
50310
+ setAssistantCredentialCleared(CREDENTIALS[name].kind);
49895
50311
  if (db) {
49896
50312
  for (const row of await liveSecretsOfKind(db, CREDENTIALS[name].kind)) {
49897
50313
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -52082,7 +52498,7 @@ function buildSchema(db) {
52082
52498
  }
52083
52499
  return out;
52084
52500
  }
52085
- async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false) {
52501
+ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false, privateMode = false) {
52086
52502
  if (!text.trim()) return [];
52087
52503
  const auth = await resolveClaudeAuth(db);
52088
52504
  if (!auth) {
@@ -52104,6 +52520,7 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52104
52520
  });
52105
52521
  return [];
52106
52522
  }
52523
+ const forceVis = privateMode ? "private" : void 0;
52107
52524
  const temperature = aggressivenessToTemperature(aggressiveness);
52108
52525
  let description = "";
52109
52526
  try {
@@ -52146,11 +52563,16 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52146
52563
  }
52147
52564
  if (jx) {
52148
52565
  try {
52149
- await linkRows(mctx, jx.junction, {
52150
- id: crypto.randomUUID(),
52151
- [jx.fileFk]: fileId,
52152
- [jx.otherFk]: m4.id
52153
- });
52566
+ await linkRows(
52567
+ mctx,
52568
+ jx.junction,
52569
+ {
52570
+ id: crypto.randomUUID(),
52571
+ [jx.fileFk]: fileId,
52572
+ [jx.otherFk]: m4.id
52573
+ },
52574
+ forceVis
52575
+ );
52154
52576
  linkedCount++;
52155
52577
  if (created) {
52156
52578
  mctx.feed.publish({
@@ -52209,16 +52631,21 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52209
52631
  if ("name" in cols && row.name == null) row.name = obj2.label;
52210
52632
  if ("title" in cols && row.title == null) row.title = obj2.label;
52211
52633
  try {
52212
- const { id: rowId } = await createRow(mctx, entity, row);
52634
+ const { id: rowId } = await createRow(mctx, entity, row, forceVis);
52213
52635
  createdCount++;
52214
52636
  const ent = entity;
52215
52637
  const jx = junctions.find((j6) => j6.otherTable === ent) ?? (createJunction ? await createJunction(ent) : null);
52216
52638
  if (jx) {
52217
- await linkRows(mctx, jx.junction, {
52218
- id: crypto.randomUUID(),
52219
- [jx.fileFk]: fileId,
52220
- [jx.otherFk]: rowId
52221
- });
52639
+ await linkRows(
52640
+ mctx,
52641
+ jx.junction,
52642
+ {
52643
+ id: crypto.randomUUID(),
52644
+ [jx.fileFk]: fileId,
52645
+ [jx.otherFk]: rowId
52646
+ },
52647
+ forceVis
52648
+ );
52222
52649
  }
52223
52650
  } catch (e6) {
52224
52651
  console.warn(`[ingest] create ${entity} from document failed:`, e6.message);
@@ -52232,12 +52659,17 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52232
52659
  try {
52233
52660
  const title = name.replace(/\.[^./\\]+$/, "").trim() || "Note";
52234
52661
  const body = description.length > 0 ? description : text.slice(0, 2e3);
52235
- const { id: noteId } = await createRow(mctx, "notes", {
52236
- id: crypto.randomUUID(),
52237
- title,
52238
- body,
52239
- source_file_id: fileId
52240
- });
52662
+ const { id: noteId } = await createRow(
52663
+ mctx,
52664
+ "notes",
52665
+ {
52666
+ id: crypto.randomUUID(),
52667
+ title,
52668
+ body,
52669
+ source_file_id: fileId
52670
+ },
52671
+ forceVis
52672
+ );
52241
52673
  mctx.feed.publish({
52242
52674
  table: "notes",
52243
52675
  op: "insert",
@@ -52351,7 +52783,8 @@ async function ingestUrlAsFile(ctx, rawUrl, opts = {}) {
52351
52783
  ctx.enrich.createJunction,
52352
52784
  ctx.enrich.aggressiveness,
52353
52785
  ctx.enrich.createEntity,
52354
- true
52786
+ true,
52787
+ ctx.privateMode === true
52355
52788
  );
52356
52789
  }
52357
52790
  return {
@@ -53229,13 +53662,22 @@ function loadSdk() {
53229
53662
  throw new Error("Could not resolve the Anthropic constructor from '@anthropic-ai/sdk'");
53230
53663
  return ctor;
53231
53664
  }
53232
- function createAnthropicClient(auth) {
53233
- const Anthropic = loadSdk();
53665
+ function buildAnthropicConfig(auth) {
53234
53666
  const config = {};
53235
- if (auth.authToken) config.authToken = auth.authToken;
53236
- else if (auth.apiKey) config.apiKey = auth.apiKey;
53667
+ if (auth.authToken) {
53668
+ config.authToken = auth.authToken;
53669
+ config.apiKey = null;
53670
+ } else if (auth.apiKey) {
53671
+ config.apiKey = auth.apiKey;
53672
+ } else {
53673
+ config.apiKey = null;
53674
+ }
53237
53675
  if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
53238
- const sdk = new Anthropic(config);
53676
+ return config;
53677
+ }
53678
+ function createAnthropicClient(auth) {
53679
+ const Anthropic = loadSdk();
53680
+ const sdk = new Anthropic(buildAnthropicConfig(auth));
53239
53681
  return {
53240
53682
  async runTurn(params) {
53241
53683
  const stream = sdk.messages.stream({
@@ -54670,8 +55112,14 @@ var MEMBER_READABLE_BOOKKEEPING = [
54670
55112
  },
54671
55113
  {
54672
55114
  name: "_lattice_gui_audit",
54673
- privs: "SELECT, INSERT",
54674
- why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
55115
+ // UPDATE + DELETE are needed by undo/redo/revert (flips an entry's `undone`)
55116
+ // and the redo-stack purge on a new mutation (deletes the session's undone
55117
+ // entries). Safe because enableGuiAuditRls installs per-op UPDATE and DELETE
55118
+ // policies whose USING is `row_id IS NULL OR lattice_row_visible(table_name,
55119
+ // row_id)` — so a member can only update/delete audit rows for entities it can
55120
+ // already see (or schema-level entries that carry no row data).
55121
+ privs: "SELECT, INSERT, UPDATE, DELETE",
55122
+ why: "GUI undo/redo/revert + redo-stack purge + version history; RLS (enableGuiAuditRls) scopes every op to entries whose underlying row the member can see"
54675
55123
  },
54676
55124
  {
54677
55125
  name: "__lattice_user_identity",
@@ -55073,6 +55521,19 @@ async function normalizeImage(path2, maxBytes) {
55073
55521
  function renderJpeg(sharp, path2, quality) {
55074
55522
  return sharp(path2).rotate().resize({ width: MAX_DIM, height: MAX_DIM, fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
55075
55523
  }
55524
+ function buildVisionAnthropicConfig(auth) {
55525
+ const config = {};
55526
+ if (auth.authToken) {
55527
+ config.authToken = auth.authToken;
55528
+ config.apiKey = null;
55529
+ } else if (auth.apiKey) {
55530
+ config.apiKey = auth.apiKey;
55531
+ } else {
55532
+ config.apiKey = null;
55533
+ }
55534
+ if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
55535
+ return config;
55536
+ }
55076
55537
  function defaultSender(auth) {
55077
55538
  return async (input) => {
55078
55539
  const importMetaUrl = import_meta3.url;
@@ -55080,11 +55541,7 @@ function defaultSender(auth) {
55080
55541
  const sdk = req("@anthropic-ai/sdk");
55081
55542
  const Anthropic = sdk.Anthropic ?? sdk.default;
55082
55543
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
55083
- const config = {};
55084
- if (auth.authToken) config.authToken = auth.authToken;
55085
- else if (auth.apiKey) config.apiKey = auth.apiKey;
55086
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
55087
- const client = new Anthropic(config);
55544
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
55088
55545
  const res = await client.messages.create({
55089
55546
  model: input.model,
55090
55547
  max_tokens: 1024,
@@ -55111,11 +55568,7 @@ function defaultPdfSender(auth) {
55111
55568
  const sdk = req("@anthropic-ai/sdk");
55112
55569
  const Anthropic = sdk.Anthropic ?? sdk.default;
55113
55570
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
55114
- const config = {};
55115
- if (auth.authToken) config.authToken = auth.authToken;
55116
- else if (auth.apiKey) config.apiKey = auth.apiKey;
55117
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
55118
- const client = new Anthropic(config);
55571
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
55119
55572
  const res = await client.messages.create({
55120
55573
  model: input.model,
55121
55574
  max_tokens: 4096,
@@ -55508,6 +55961,8 @@ var css = `
55508
55961
  .app-version:empty { display: none; }
55509
55962
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
55510
55963
  .app-update[hidden] { display: none; }
55964
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
55965
+ #app-update-link[hidden] { display: none; }
55511
55966
  /* Unseen-change count next to a sidebar entity. */
55512
55967
  .nav-badge {
55513
55968
  display: inline-block; min-width: 16px; text-align: center;
@@ -56055,6 +56510,8 @@ var css = `
56055
56510
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
56056
56511
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
56057
56512
  .grants-panel .grants-row input { accent-color: var(--accent); }
56513
+ .grants-panel .grants-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
56514
+ .grants-panel .grants-dirty { font-size: 12px; }
56058
56515
 
56059
56516
  /* Inline create-row at the bottom of every table */
56060
56517
  tr.create-row td { background: var(--surface-2); }
@@ -56984,6 +57441,12 @@ var appJs = `
56984
57441
  // drag handle once the app has booted.
56985
57442
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
56986
57443
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
57444
+ // The version chip + manual-upgrade link live in the static shell (present
57445
+ // from first paint, in both the normal and virgin-state boots), so wire the
57446
+ // click handler and run the first availability check here \u2014 independent of
57447
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
57448
+ wireUpdateLink();
57449
+ checkUpdateAvailable();
56987
57450
  // Failsafe: never leave the overlay up forever if a fetch hangs without
56988
57451
  // rejecting, or a future early-return (e.g. the virgin-state screen)
56989
57452
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -57468,6 +57931,26 @@ var appJs = `
57468
57931
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
57469
57932
  setTimeout(function () { location.reload(); }, 600);
57470
57933
  }
57934
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
57935
+ // the version chip only when the server reports a newer, installable version.
57936
+ // The auto-updater installs in the background on its own cadence; this lets
57937
+ // the user force it now. Best-effort; the link stays hidden on any failure.
57938
+ function checkUpdateAvailable() {
57939
+ var el = document.getElementById('app-update-link');
57940
+ if (!el) return;
57941
+ fetch('/api/update/status')
57942
+ .then(function (r) { return r.ok ? r.json() : null; })
57943
+ .then(function (s) {
57944
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
57945
+ el.textContent = 'Update available \u2014 Upgrade';
57946
+ el.title = 'Install v' + s.latest + ' and restart';
57947
+ el.hidden = false;
57948
+ } else {
57949
+ el.hidden = true;
57950
+ }
57951
+ })
57952
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
57953
+ }
57471
57954
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
57472
57955
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
57473
57956
  function checkServerVersion() {
@@ -57481,6 +57964,31 @@ var appJs = `
57481
57964
  else hideUpdatePill();
57482
57965
  })
57483
57966
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
57967
+ // Refresh the manual-upgrade link alongside the reconnect version check.
57968
+ checkUpdateAvailable();
57969
+ }
57970
+ // Wire the manual-upgrade link's click: kick off the install (the server
57971
+ // installs the latest and restarts onto it) and surface the progress. On
57972
+ // success we do nothing else \u2014 the update-applied event + the reconnect
57973
+ // version check land the page on the new version (no manual reload). A
57974
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
57975
+ function wireUpdateLink() {
57976
+ var el = document.getElementById('app-update-link');
57977
+ if (!el) return;
57978
+ el.addEventListener('click', function (e) {
57979
+ e.preventDefault();
57980
+ el.hidden = true;
57981
+ showUpdatePill('Updating\u2026');
57982
+ fetch('/api/update/apply', { method: 'POST' })
57983
+ .then(function (r) { return r.json(); })
57984
+ .then(function (d) {
57985
+ if (d && d.ok === false) {
57986
+ hideUpdatePill();
57987
+ showToast(d.error || 'Update unavailable', {});
57988
+ }
57989
+ })
57990
+ .catch(function () { /* server may already be restarting */ });
57991
+ });
57484
57992
  }
57485
57993
  function dispatchStreamMessage(type, data) {
57486
57994
  if (type === 'realtime-state') {
@@ -58521,6 +59029,15 @@ var appJs = `
58521
59029
  // Per-table view state: 'live' (default) or 'trash' (soft-deleted rows).
58522
59030
  var tableViewMode = {};
58523
59031
 
59032
+ // The (table, pk) of the per-row "Manage access" grants panel that is
59033
+ // currently open, or null when none is. A soft re-render (a concurrent edit
59034
+ // by another client fires pg_notify \u2192 realtime refresh \u2192 renderRoute({soft})
59035
+ // \u2192 renderDetail/renderFsItem repaint) would otherwise re-create the detail
59036
+ // view with the panel collapsed, dropping a staged multi-select mid-edit.
59037
+ // wireRowSharing reads this after each repaint and re-opens + re-populates the
59038
+ // panel WITHOUT any network call, so the staged selection survives.
59039
+ var openGrantsPanel = null;
59040
+
58524
59041
  function renderTable(content, tableName) {
58525
59042
  var myGen = renderGen;
58526
59043
  clearUnseen(tableName);
@@ -58999,70 +59516,151 @@ var appJs = `
58999
59516
  }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
59000
59517
  });
59001
59518
  });
59002
- var detailVisManage = content.querySelector('#detail-vis-manage');
59003
- if (detailVisManage) detailVisManage.addEventListener('click', function () {
59519
+ var access = row._access || {};
59520
+
59521
+ // Render the staged member checklist + a single "Save sharing" / "Cancel"
59522
+ // into the panel. Checkbox toggles mutate ONLY the local desired map \u2014
59523
+ // NO network call per toggle (the old design auto-saved live, one POST per
59524
+ // checkbox, and each grant's pg_notify collapsed the panel). A single batch
59525
+ // request fires on Save. members is the already-fetched list; desired
59526
+ // seeds from the row's current grantees (or a caller-supplied staged map
59527
+ // when re-opening after a soft re-render).
59528
+ function populateGrantsPanel(panel, members, desired) {
59529
+ // Snapshot the CURRENT (committed) grantees so Save can diff desired-vs-
59530
+ // current into adds/removes. effectiveVisibility decides whether we're
59531
+ // actually switching INTO specific-people mode (custom-0 reads as private).
59532
+ var current = {};
59533
+ (access.grantees || []).forEach(function (g) { current[g] = true; });
59534
+ if (members.length === 0) {
59535
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
59536
+ panel.hidden = false;
59537
+ return;
59538
+ }
59539
+ function dirtyCount() {
59540
+ var n = 0;
59541
+ members.forEach(function (m) {
59542
+ if (!!desired[m.role] !== !!current[m.role]) n++;
59543
+ });
59544
+ return n;
59545
+ }
59546
+ function render() {
59547
+ var changed = dirtyCount();
59548
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' +
59549
+ members.map(function (m) {
59550
+ var label = m.name || m.email || m.role;
59551
+ return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
59552
+ (desired[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
59553
+ }).join('') +
59554
+ '<div class="grants-actions">' +
59555
+ '<button class="btn primary" id="grants-save"' + (changed ? '' : ' disabled') + '>Save sharing</button>' +
59556
+ '<button class="btn" id="grants-cancel">Cancel</button>' +
59557
+ '<span class="grants-dirty muted">' + (changed ? (changed === 1 ? '1 change' : changed + ' changes') : 'No changes') + '</span>' +
59558
+ '</div>';
59559
+ panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
59560
+ cb.addEventListener('change', function () {
59561
+ var role = cb.getAttribute('data-grant-role');
59562
+ if (cb.checked) desired[role] = true; else delete desired[role];
59563
+ render(); // re-render to refresh the dirty indicator + Save state
59564
+ });
59565
+ });
59566
+ var cancelBtn = panel.querySelector('#grants-cancel');
59567
+ if (cancelBtn) cancelBtn.addEventListener('click', function () { closeGrantsPanel(panel); });
59568
+ var saveBtn = panel.querySelector('#grants-save');
59569
+ if (saveBtn) saveBtn.addEventListener('click', function () {
59570
+ var toAdd = [];
59571
+ var toRemove = [];
59572
+ members.forEach(function (m) {
59573
+ var want = !!desired[m.role];
59574
+ var have = !!current[m.role];
59575
+ if (want && !have) toAdd.push(m.role);
59576
+ if (!want && have) toRemove.push(m.role);
59577
+ });
59578
+ if (toAdd.length === 0 && toRemove.length === 0) { closeGrantsPanel(panel); return; }
59579
+ // Confirm the mode change ONCE, here \u2014 only when actually switching
59580
+ // INTO specific-people mode (effective vis isn't already custom AND we
59581
+ // are adding at least one grantee). Never per checkbox.
59582
+ if (effectiveVisibility(access) !== 'custom' && toAdd.length > 0) {
59583
+ if (!confirm('Sharing this with specific people switches it off "everyone"/"private". The chosen people will be able to see it. Continue?')) return;
59584
+ }
59585
+ withBusy(saveBtn, function () {
59586
+ return fetchJson('/api/cloud/row-grants', {
59587
+ method: 'POST',
59588
+ headers: { 'content-type': 'application/json' },
59589
+ body: JSON.stringify({ table: tableName, pk: id, grant: toAdd, revoke: toRemove }),
59590
+ }).then(function () {
59591
+ // Mirror the committed state locally so the re-render's indicator
59592
+ // is correct. The first grant flips the row to custom server-side;
59593
+ // revoking the last leaves custom-0, which effectiveVisibility
59594
+ // renders as private.
59595
+ var list = [];
59596
+ members.forEach(function (m) { if (desired[m.role]) list.push(m.role); });
59597
+ access.grantees = list;
59598
+ if (list.length > 0) access.visibility = 'custom';
59599
+ openGrantsPanel = null; // a successful save closes the staging session
59600
+ invalidate(tableName);
59601
+ showToast('Sharing updated', {});
59602
+ reRender();
59603
+ }).catch(function (e) {
59604
+ // Surface loudly + leave the staged selection intact so the user
59605
+ // can retry; no silent partial-success.
59606
+ showToast('Sharing update failed: ' + e.message, {});
59607
+ });
59608
+ });
59609
+ });
59610
+ panel.hidden = false;
59611
+ }
59612
+ render();
59613
+ }
59614
+
59615
+ function closeGrantsPanel(panel) {
59616
+ if (panel) panel.hidden = true;
59617
+ openGrantsPanel = null;
59618
+ }
59619
+
59620
+ // Open (or toggle shut) the manage-access panel. Fetches the member list,
59621
+ // then stages from the row's current grantees. Opening must NOT pre-flip
59622
+ // the row to 'custom' \u2014 that left a never-shared row stuck at "custom (0)".
59623
+ function openManagePanel(triggerBtn) {
59004
59624
  var panel = content.querySelector('#grants-panel');
59005
59625
  if (!panel) return;
59006
- if (!panel.hidden) { panel.hidden = true; return; }
59007
- var access = row._access || {};
59008
- // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
59009
- // row the user never actually shared stuck at "custom (0)". The first
59010
- // grant flips it to custom server-side (lattice_grant_row); revoking the
59011
- // last leaves it custom-with-0-grantees, which now reads as private. So
59012
- // just load the member checklist.
59013
- var ensure = Promise.resolve();
59014
- withBusy(detailVisManage, function () {
59015
- return ensure.then(function () {
59016
- return fetchJson('/api/cloud/members');
59017
- }).then(function (d) {
59626
+ if (!panel.hidden) { closeGrantsPanel(panel); return; }
59627
+ withBusy(triggerBtn, function () {
59628
+ return fetchJson('/api/cloud/members').then(function (d) {
59018
59629
  // The grant target is a member ROLE: lattice_grant_row keys on the
59019
59630
  // role, and _access.grantees holds role names. List every member
59020
59631
  // except the owner (you don't grant the owner their own row).
59021
59632
  var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
59022
- var granted = {};
59023
- (access.grantees || []).forEach(function (g) { granted[g] = true; });
59024
- if (members.length === 0) {
59025
- panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
59026
- } else {
59027
- panel.innerHTML = '<div class="grants-title">Who can see this</div>' + members.map(function (m) {
59028
- var label = m.name || m.email || m.role;
59029
- return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
59030
- (granted[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
59031
- }).join('');
59032
- }
59033
- panel.hidden = false;
59034
- panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
59035
- cb.addEventListener('change', function () {
59036
- var role = cb.getAttribute('data-grant-role');
59037
- cb.disabled = true;
59038
- fetchJson('/api/cloud/row-grant', {
59039
- method: 'POST',
59040
- headers: { 'content-type': 'application/json' },
59041
- body: JSON.stringify({ table: tableName, pk: id, grantee: role, revoke: !cb.checked }),
59042
- }).then(function () {
59043
- var list = access.grantees || (access.grantees = []);
59044
- var at = list.indexOf(role);
59045
- if (cb.checked && at === -1) list.push(role);
59046
- if (!cb.checked && at !== -1) list.splice(at, 1);
59047
- // The first grant flips the row to custom server-side; mirror
59048
- // that locally so the indicator updates. Revoking the last leaves
59049
- // visibility 'custom' but effectiveVisibility renders custom-0 as
59050
- // private, so the label flips back to "Private to you".
59051
- if (list.length > 0) access.visibility = 'custom';
59052
- var infoEl = content.querySelector('#detail-vis-info');
59053
- if (infoEl) infoEl.textContent = visInfoLabel(access);
59054
- invalidate(tableName);
59055
- }).catch(function (e) {
59056
- cb.checked = !cb.checked; // revert the failed change
59057
- showToast('Access update failed: ' + e.message, {});
59058
- }).then(function () { cb.disabled = false; });
59059
- });
59060
- });
59061
- var infoEl = content.querySelector('#detail-vis-info');
59062
- if (infoEl) infoEl.textContent = visInfoLabel(access);
59633
+ var desired = {};
59634
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59635
+ openGrantsPanel = { table: tableName, pk: id };
59636
+ populateGrantsPanel(panel, members, desired);
59063
59637
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
59064
59638
  });
59639
+ }
59640
+
59641
+ var detailVisManage = content.querySelector('#detail-vis-manage');
59642
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
59643
+ openManagePanel(detailVisManage);
59065
59644
  });
59645
+
59646
+ // Preserve an open panel across a soft re-render: if the tracked panel
59647
+ // matches the row this view just repainted, re-open it and re-populate the
59648
+ // checklist from the freshly-fetched row._access WITHOUT any network call,
59649
+ // so a concurrent edit by another client doesn't lose a staged selection.
59650
+ if (openGrantsPanel && openGrantsPanel.table === tableName && openGrantsPanel.pk === id) {
59651
+ var rpanel = content.querySelector('#grants-panel');
59652
+ if (rpanel) {
59653
+ fetchJson('/api/cloud/members').then(function (d) {
59654
+ // Only re-populate if THIS panel is still the tracked-open one (a
59655
+ // newer navigation/save may have cleared it while members loaded).
59656
+ if (!openGrantsPanel || openGrantsPanel.table !== tableName || openGrantsPanel.pk !== id) return;
59657
+ var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
59658
+ var desired = {};
59659
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59660
+ populateGrantsPanel(rpanel, members, desired);
59661
+ }).catch(function () { /* best-effort restore; a click reopens it */ });
59662
+ }
59663
+ }
59066
59664
  }
59067
59665
  function renderDetail(content, tableName, id) {
59068
59666
  var myGen = renderGen;
@@ -63846,13 +64444,21 @@ var appJs = `
63846
64444
  }
63847
64445
  function uploadFile(file) {
63848
64446
  var done = pendingIngestItem(file.name || 'file');
64447
+ // Carry the composer's "Private mode" intent so an upload made while the
64448
+ // box is checked is stamped private at insert, instead of inheriting the
64449
+ // files-table default (which can be shared-to-everyone on a cloud). Read
64450
+ // the checkbox defensively \u2014 it may not be rendered. On a local workspace
64451
+ // the box is checked+disabled, so this is '1' there too; forced visibility
64452
+ // is a harmless no-op on the single-user SQLite path.
64453
+ var pv = document.getElementById('chat-private');
64454
+ var priv = pv && pv.checked ? '1' : '0';
63849
64455
  return fetch('/api/ingest/upload', {
63850
64456
  method: 'POST',
63851
64457
  // Percent-encode the filename: HTTP header values must be ISO-8859-1,
63852
64458
  // so a Unicode filename (emoji, smart quote, accent, em-dash) would
63853
64459
  // otherwise make fetch() throw "String contains non ISO-8859-1 code
63854
64460
  // point". The server decodeURIComponent()s it back.
63855
- headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file') },
64461
+ headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file'), 'x-lattice-private': priv },
63856
64462
  body: file,
63857
64463
  })
63858
64464
  .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); })
@@ -64200,6 +64806,7 @@ var guiAppHtml = `<!doctype html>
64200
64806
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
64201
64807
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
64202
64808
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
64809
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
64203
64810
  <button id="settings-gear" title="Settings" aria-label="Open settings">
64204
64811
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64205
64812
  <circle cx="12" cy="12" r="3"/>
@@ -64719,10 +65326,19 @@ function isUnderGlobalPrefix(packageRoot, execPath) {
64719
65326
  }
64720
65327
  function detectInstallContext(opts = {}) {
64721
65328
  const pkgName = opts.pkgName ?? "latticesql";
64722
- const cwd = opts.cwd ?? process.cwd();
64723
65329
  const env2 = opts.env ?? process.env;
64724
65330
  const execPath = opts.execPath ?? process.execPath;
64725
- const modulePath = opts.modulePath ?? process.argv[1] ?? cwd;
65331
+ const rawCwd = opts.cwd ?? process.cwd();
65332
+ const rawModulePath = opts.modulePath ?? process.argv[1] ?? rawCwd;
65333
+ const resolveReal = (p3) => {
65334
+ try {
65335
+ return (0, import_node_fs27.realpathSync)(p3);
65336
+ } catch {
65337
+ return p3;
65338
+ }
65339
+ };
65340
+ const modulePath = resolveReal(rawModulePath);
65341
+ const cwd = resolveReal(rawCwd);
64726
65342
  const packageRoot = findPackageRoot((0, import_node_path30.dirname)(modulePath), pkgName);
64727
65343
  if (packageRoot && (0, import_node_fs27.existsSync)((0, import_node_path30.join)(packageRoot, ".git"))) {
64728
65344
  return {
@@ -66508,6 +67124,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66508
67124
  });
66509
67125
  return true;
66510
67126
  }
67127
+ if (pathname === "/api/cloud/row-grants" && method === "POST") {
67128
+ await tryHandler(res, async () => {
67129
+ const body = await readJson(req);
67130
+ const table = typeof body.table === "string" ? body.table : "";
67131
+ const pk = typeof body.pk === "string" ? body.pk : "";
67132
+ const strList = (v2) => Array.isArray(v2) ? v2.filter((x2) => typeof x2 === "string") : [];
67133
+ const grant = strList(body.grant);
67134
+ const revoke = strList(body.revoke);
67135
+ if (!table || !pk) {
67136
+ sendJson(res, { error: "table and pk are required" }, 400);
67137
+ return;
67138
+ }
67139
+ if (ctx.db.getDialect() !== "postgres") {
67140
+ sendJson(res, { error: "Per-row sharing requires a cloud (Postgres) database" }, 400);
67141
+ return;
67142
+ }
67143
+ await batchRowGrants(ctx.db, table, pk, grant, revoke);
67144
+ sendJson(res, { ok: true, table, pk, granted: grant, revoked: revoke });
67145
+ });
67146
+ return true;
67147
+ }
66511
67148
  if (pathname === "/api/cloud/s3-config" && method === "GET") {
66512
67149
  await tryHandler(res, () => {
66513
67150
  const label = activeWorkspaceLabel(ctx.configPath);
@@ -67304,7 +67941,7 @@ function enrichContext(ctx) {
67304
67941
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {}
67305
67942
  };
67306
67943
  }
67307
- async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67944
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res, privateMode) {
67308
67945
  try {
67309
67946
  return await enrichWithLlm(
67310
67947
  mctx,
@@ -67316,7 +67953,9 @@ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67316
67953
  ctx.entityDescriptions,
67317
67954
  ctx.createJunction,
67318
67955
  ctx.aggressiveness,
67319
- ctx.createEntity
67956
+ ctx.createEntity,
67957
+ false,
67958
+ privateMode
67320
67959
  );
67321
67960
  } catch (e6) {
67322
67961
  const err = e6;
@@ -67395,7 +68034,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67395
68034
  source: "ingest",
67396
68035
  onColumnsAdded: columnDescriptionHook(ctx.db)
67397
68036
  };
68037
+ const headerPrivate = req.headers["x-lattice-private"] === "1";
67398
68038
  if (ctx.pathname === "/api/ingest/upload") {
68039
+ const forcePrivate2 = headerPrivate;
67399
68040
  const rawName = typeof req.headers["x-filename"] === "string" && req.headers["x-filename"] || "";
67400
68041
  let name2 = "upload";
67401
68042
  if (rawName) {
@@ -67493,10 +68134,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67493
68134
  ...blob ? { blob_path: blob.blob_path } : {}
67494
68135
  } : blob ? { ref_kind: "blob", blob_path: blob.blob_path } : {}
67495
68136
  };
67496
- const { id: id2 } = await createRow(mctx, "files", {
67497
- ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
67498
- ...uploadRow
67499
- });
68137
+ const { id: id2 } = await createRow(
68138
+ mctx,
68139
+ "files",
68140
+ {
68141
+ ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
68142
+ ...uploadRow
68143
+ },
68144
+ forcePrivate2 ? "private" : void 0
68145
+ );
67500
68146
  try {
67501
68147
  const dedupCtx = {
67502
68148
  db: ctx.db,
@@ -67522,7 +68168,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67522
68168
  }
67523
68169
  let suggestedLinks = [];
67524
68170
  if (!result.skip) {
67525
- const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
68171
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res, forcePrivate2);
67526
68172
  if (links === null) return true;
67527
68173
  suggestedLinks = links;
67528
68174
  }
@@ -67549,6 +68195,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67549
68195
  sendJson4(res, { error: e6.message }, 400);
67550
68196
  return true;
67551
68197
  }
68198
+ const forcePrivate = headerPrivate || body.private === true;
67552
68199
  if (ctx.pathname === "/api/ingest/text") {
67553
68200
  const rawText = typeof body.text === "string" ? body.text : "";
67554
68201
  if (!rawText.trim()) {
@@ -67559,7 +68206,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67559
68206
  if (sourceUrl) {
67560
68207
  try {
67561
68208
  const result = await ingestUrlAsFile(
67562
- { db: ctx.db, mctx, enrich: enrichContext(ctx) },
68209
+ { db: ctx.db, mctx, enrich: enrichContext(ctx), privateMode: forcePrivate },
67563
68210
  sourceUrl
67564
68211
  );
67565
68212
  sendJson4(
@@ -67588,11 +68235,25 @@ async function dispatchIngestRoute(req, res, ctx) {
67588
68235
  description: describe(content, mime2, title),
67589
68236
  extraction_status: "extracted"
67590
68237
  };
67591
- const { id: id2 } = await createRow(mctx, "files", {
67592
- ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
67593
- ...textRow
67594
- });
67595
- const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
68238
+ const { id: id2 } = await createRow(
68239
+ mctx,
68240
+ "files",
68241
+ {
68242
+ ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
68243
+ ...textRow
68244
+ },
68245
+ forcePrivate ? "private" : void 0
68246
+ );
68247
+ const suggestedLinks = await enrichOrFail(
68248
+ mctx,
68249
+ ctx.db,
68250
+ id2,
68251
+ content,
68252
+ title,
68253
+ ctx,
68254
+ res,
68255
+ forcePrivate
68256
+ );
67596
68257
  if (suggestedLinks === null) return true;
67597
68258
  sendJson4(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
67598
68259
  return true;
@@ -67631,10 +68292,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67631
68292
  size_bytes: size,
67632
68293
  extraction_status: "pending"
67633
68294
  };
67634
- const { id } = await createRow(mctx, "files", {
67635
- ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
67636
- ...localRow
67637
- });
68295
+ const { id } = await createRow(
68296
+ mctx,
68297
+ "files",
68298
+ {
68299
+ ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
68300
+ ...localRow
68301
+ },
68302
+ forcePrivate ? "private" : void 0
68303
+ );
67638
68304
  try {
67639
68305
  const result = await extractSource(ctx.db, abs, mime, name);
67640
68306
  await updateRow(mctx, "files", id, {
@@ -67652,7 +68318,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67652
68318
  ctx.entityDescriptions,
67653
68319
  ctx.createJunction,
67654
68320
  ctx.aggressiveness,
67655
- ctx.createEntity
68321
+ ctx.createEntity,
68322
+ false,
68323
+ forcePrivate
67656
68324
  );
67657
68325
  sendJson4(
67658
68326
  res,
@@ -68339,7 +69007,7 @@ function startBackgroundRender(active) {
68339
69007
  }
68340
69008
  bus.publish(e6);
68341
69009
  };
68342
- void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
69010
+ void db.renderInBackground(active.outputDir, { signal, onProgress, gateOnOpen: true }).then(
68343
69011
  () => {
68344
69012
  },
68345
69013
  (err) => {
@@ -68681,6 +69349,28 @@ async function startGuiServer(options) {
68681
69349
  setActive(next, created.id);
68682
69350
  return created.id;
68683
69351
  };
69352
+ const cleanupWorkspaceFiles = (root6, ws) => {
69353
+ if (!ws.configPath && ws.kind === "local") {
69354
+ (0, import_node_fs35.rmSync)(workspaceDir(root6, ws.dir), { recursive: true, force: true });
69355
+ } else if (ws.kind === "cloud") {
69356
+ if (ws.configPath && (0, import_node_fs35.existsSync)(ws.configPath)) {
69357
+ (0, import_node_fs35.rmSync)(ws.configPath, { force: true });
69358
+ }
69359
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69360
+ const label = labelMatch?.[1];
69361
+ if (label) {
69362
+ const stillUsed = listWorkspaces(root6).some(
69363
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69364
+ );
69365
+ if (!stillUsed) {
69366
+ try {
69367
+ deleteDbCredential(label);
69368
+ } catch {
69369
+ }
69370
+ }
69371
+ }
69372
+ }
69373
+ };
68684
69374
  const handleVirginRoute = async (req, res, pathname, method) => {
68685
69375
  if (method === "GET" && pathname === "/") {
68686
69376
  sendText(
@@ -68732,6 +69422,35 @@ async function startGuiServer(options) {
68732
69422
  }
68733
69423
  return true;
68734
69424
  }
69425
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
69426
+ if (!latticeRoot) {
69427
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
69428
+ return true;
69429
+ }
69430
+ const body = await readJson(req);
69431
+ if (typeof body.id !== "string") {
69432
+ sendJson(res, { error: "id must be a string" }, 400);
69433
+ return true;
69434
+ }
69435
+ const ws = getWorkspace(latticeRoot, body.id);
69436
+ if (!ws) {
69437
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
69438
+ return true;
69439
+ }
69440
+ removeWorkspace(latticeRoot, ws.id);
69441
+ try {
69442
+ cleanupWorkspaceFiles(latticeRoot, ws);
69443
+ } catch (e6) {
69444
+ sendJson(
69445
+ res,
69446
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
69447
+ 500
69448
+ );
69449
+ return true;
69450
+ }
69451
+ sendJson(res, { ok: true, switchedTo: null });
69452
+ return true;
69453
+ }
68735
69454
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
68736
69455
  await redeemInvite(createCloudWorkspace, req, res);
68737
69456
  return true;
@@ -68766,6 +69485,18 @@ async function startGuiServer(options) {
68766
69485
  );
68767
69486
  return;
68768
69487
  }
69488
+ if (method === "POST" && pathname === "/api/update/apply") {
69489
+ if (updateService) {
69490
+ void updateService.checkNow(true);
69491
+ sendJson(res, { ok: true, status: updateService.status() });
69492
+ } else {
69493
+ sendJson(res, {
69494
+ ok: false,
69495
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
69496
+ });
69497
+ }
69498
+ return;
69499
+ }
68769
69500
  if (!activeRef) {
68770
69501
  if (await handleVirginRoute(req, res, pathname, method)) return;
68771
69502
  sendJson(res, { error: "No active workspace" }, 409);
@@ -69859,26 +70590,7 @@ async function startGuiServer(options) {
69859
70590
  }
69860
70591
  removeWorkspace(latticeRoot, ws.id);
69861
70592
  try {
69862
- if (!ws.configPath && ws.kind === "local") {
69863
- (0, import_node_fs35.rmSync)(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
69864
- } else if (ws.kind === "cloud") {
69865
- if (ws.configPath && (0, import_node_fs35.existsSync)(ws.configPath)) {
69866
- (0, import_node_fs35.rmSync)(ws.configPath, { force: true });
69867
- }
69868
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69869
- const label = labelMatch?.[1];
69870
- if (label) {
69871
- const stillUsed = listWorkspaces(latticeRoot).some(
69872
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69873
- );
69874
- if (!stillUsed) {
69875
- try {
69876
- deleteDbCredential(label);
69877
- } catch {
69878
- }
69879
- }
69880
- }
69881
- }
70593
+ cleanupWorkspaceFiles(latticeRoot, ws);
69882
70594
  } catch (e6) {
69883
70595
  sendJson(
69884
70596
  res,
@@ -70344,7 +71056,9 @@ ${e6.stack ?? ""}`
70344
71056
  }
70345
71057
  }
70346
71058
  };
70347
- if (options.selfUpdate && guiVersion) {
71059
+ if (options.updateServiceFactory) {
71060
+ updateService = options.updateServiceFactory(broadcast);
71061
+ } else if (options.selfUpdate && guiVersion) {
70348
71062
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
70349
71063
  }
70350
71064
  const handleEventStream = (ws) => {