latticesql 3.4.3 → 3.4.4

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
  }
@@ -5243,6 +5444,7 @@ var init_lattice = __esm({
5243
5444
  init_shred();
5244
5445
  init_encryption();
5245
5446
  init_manifest();
5447
+ init_render_cursor();
5246
5448
  import_node_fs12 = require("fs");
5247
5449
  init_adapter();
5248
5450
  init_sqlite();
@@ -5314,6 +5516,14 @@ var init_lattice = __esm({
5314
5516
  _changelogTables = /* @__PURE__ */ new Set();
5315
5517
  /** Current task context string for relevance filtering. */
5316
5518
  _taskContext = "";
5519
+ /**
5520
+ * True when this connection opened against an already-provisioned cloud as a
5521
+ * SCOPED MEMBER (no role-management privilege → no CREATE/ALTER on the schema).
5522
+ * Set during init() by the same probe that decides introspect-only. Drives
5523
+ * {@link addColumn} to route DDL through the owner-side `lattice_member_add_column`
5524
+ * SECURITY DEFINER helper instead of issuing a raw ALTER the member can't run.
5525
+ */
5526
+ _cloudMemberOpen = false;
5317
5527
  _auditHandlers = [];
5318
5528
  _renderHandlers = [];
5319
5529
  _writebackHandlers = [];
@@ -5560,7 +5770,7 @@ var init_lattice = __esm({
5560
5770
  /** Async tail of init(). See {@link init} for the sync-validation phase. */
5561
5771
  async _initAsync(options) {
5562
5772
  let introspectOnly = options.introspectOnly === true;
5563
- if (!introspectOnly && this.getDialect() === "postgres") {
5773
+ if (this.getDialect() === "postgres") {
5564
5774
  try {
5565
5775
  const [marker, role] = await Promise.all([
5566
5776
  getAsyncOrSync(this._adapter, `SELECT to_regclass('__lattice_owners') AS reg`),
@@ -5571,7 +5781,9 @@ var init_lattice = __esm({
5571
5781
  ]);
5572
5782
  const provisioned = !!marker && marker.reg != null;
5573
5783
  const canCreateRoles = !!role && role.rolcreaterole === true;
5574
- introspectOnly = provisioned && !canCreateRoles;
5784
+ const memberOpen = provisioned && !canCreateRoles;
5785
+ introspectOnly = introspectOnly || memberOpen;
5786
+ this._cloudMemberOpen = memberOpen;
5575
5787
  } catch {
5576
5788
  }
5577
5789
  }
@@ -5659,6 +5871,26 @@ var init_lattice = __esm({
5659
5871
  getDialect() {
5660
5872
  return this._adapter.dialect;
5661
5873
  }
5874
+ /**
5875
+ * True when a table opts into the observation/changelog substrate
5876
+ * (`def.changelog`). Callers that want to bypass the high-level {@link delete}
5877
+ * with a transaction-scoped raw delete use this to know whether the table also
5878
+ * needs the changelog / write-hook / embedding side effects that only
5879
+ * `delete()` performs — so they can keep the high-level path for such tables.
5880
+ */
5881
+ isChangelogTracked(table) {
5882
+ return this._changelogTables.has(table);
5883
+ }
5884
+ /**
5885
+ * True when this connection opened as a scoped cloud MEMBER (see
5886
+ * {@link _cloudMemberOpen}). Callers use it to route DDL-bearing work through
5887
+ * the owner-side SECURITY DEFINER helpers rather than issuing DDL the member's
5888
+ * role can't run (e.g. {@link addColumn} regenerates the masking view inside
5889
+ * `lattice_member_add_column`, so the caller must not also try to regenerate it).
5890
+ */
5891
+ isCloudMemberOpen() {
5892
+ return this._cloudMemberOpen;
5893
+ }
5662
5894
  /**
5663
5895
  * Return the normalised primary-key column list for a registered
5664
5896
  * table. Falls back to `['id']` for tables registered via raw DDL
@@ -5735,7 +5967,15 @@ var init_lattice = __esm({
5735
5967
  assertSafeIdentifier(column, "column");
5736
5968
  const existing = await introspectColumnsAsyncOrSync(this._adapter, table);
5737
5969
  if (!existing.includes(column)) {
5738
- await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5970
+ if (this._cloudMemberOpen) {
5971
+ await runAsyncOrSync(this._adapter, `SELECT lattice_member_add_column(?, ?, ?)`, [
5972
+ table,
5973
+ column,
5974
+ typeSpec
5975
+ ]);
5976
+ } else {
5977
+ await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5978
+ }
5739
5979
  }
5740
5980
  const cols = await introspectColumnsAsyncOrSync(this._adapter, table);
5741
5981
  this._columnCache.set(table, new Set(cols));
@@ -6667,12 +6907,39 @@ var init_lattice = __esm({
6667
6907
  async renderInBackground(outputDir, opts = {}) {
6668
6908
  const notInit = this._notInitError();
6669
6909
  if (notInit) return notInit;
6910
+ if (opts.gateOnOpen && !opts.changedTables) {
6911
+ const start = Date.now();
6912
+ const recorded = readManifest(outputDir);
6913
+ if (recorded != null) {
6914
+ const live = await computeRenderCursor(this._adapter);
6915
+ if (cursorIsFresh(recorded, live)) {
6916
+ opts.onProgress?.({
6917
+ kind: "done",
6918
+ table: null,
6919
+ entitiesRendered: 0,
6920
+ entitiesTotal: 0,
6921
+ tableIndex: 0,
6922
+ tableCount: 0,
6923
+ pct: 100,
6924
+ durationMs: Date.now() - start
6925
+ });
6926
+ const skipped = {
6927
+ filesWritten: [],
6928
+ filesSkipped: 0,
6929
+ durationMs: Date.now() - start
6930
+ };
6931
+ for (const h6 of this._renderHandlers) h6(skipped);
6932
+ return skipped;
6933
+ }
6934
+ }
6935
+ }
6670
6936
  if (!opts.changedTables) {
6671
6937
  this._pendingRenderAll = false;
6672
6938
  this._pendingRenderTables = /* @__PURE__ */ new Set();
6673
6939
  this._autoRenderPending = false;
6674
6940
  }
6675
- return this._renderGuarded(outputDir, opts);
6941
+ const { gateOnOpen: _gateOnOpen, ...engineOpts } = opts;
6942
+ return this._renderGuarded(outputDir, engineOpts);
6676
6943
  }
6677
6944
  /**
6678
6945
  * Install a per-viewer read-relation resolver for ALL renders (initial,
@@ -47870,6 +48137,111 @@ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
47870
48137
  AND g."pk" = ANY(p_pks)
47871
48138
  AND o."owner_role" = session_user;
47872
48139
  $fn$;
48140
+
48141
+ -- Add a column to a user table AS THE OWNER, on behalf of a scoped member. A
48142
+ -- member's role has no CREATE/ALTER on the schema (the bootstrap REVOKEs CREATE
48143
+ -- from PUBLIC), so a member's GUI "add a field" write (createRow/updateRow with a
48144
+ -- field the table lacks) cannot run ALTER TABLE itself. This SECURITY DEFINER
48145
+ -- helper performs that ALTER \u2014 and the masking-view regen \u2014 with the owner's
48146
+ -- rights, so member-added columns behave identically to owner-added ones.
48147
+ --
48148
+ -- Injection-safe + minimal: p_table must be an existing BASE table in the current
48149
+ -- schema (rejected otherwise); p_type is whitelisted against the exact set the
48150
+ -- library's addColumn emits for an auto-added column (TEXT / INTEGER / REAL, plus
48151
+ -- BOOLEAN) \u2014 never interpolated raw; both identifiers go through %I (quote_ident).
48152
+ -- Member-callable (granted EXECUTE to the member group), but it can only widen the
48153
+ -- schema, never read or alter another member's data.
48154
+ CREATE OR REPLACE FUNCTION lattice_member_add_column(p_table text, p_column text, p_type text)
48155
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
48156
+ DECLARE
48157
+ v_type text;
48158
+ v_view text := p_table || '_v';
48159
+ v_has_view boolean;
48160
+ v_pk_expr text;
48161
+ v_select text;
48162
+ BEGIN
48163
+ -- Never alter internal bookkeeping tables (names start with "_"). The GUI only
48164
+ -- ever calls this for a user entity table; rejecting the rest is defense-in-depth
48165
+ -- against a member invoking the function directly against ownership/audit/policy
48166
+ -- tables.
48167
+ IF left(p_table, 1) = '_' THEN
48168
+ RAISE EXCEPTION 'lattice: cannot add a column to internal table "%"', p_table;
48169
+ END IF;
48170
+
48171
+ -- p_table must be a real base table in THIS schema (search_path is pinned to the
48172
+ -- cloud schema by pinDefinerSearchPath, so to_regclass resolves there).
48173
+ IF NOT EXISTS (
48174
+ SELECT 1 FROM pg_class c
48175
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48176
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND c.relkind = 'r'
48177
+ ) THEN
48178
+ RAISE EXCEPTION 'lattice: no such table "%"', p_table;
48179
+ END IF;
48180
+
48181
+ -- Whitelist the column type. These are exactly the specs addColumn's
48182
+ -- inferColumnType produces (TEXT / INTEGER / REAL); BOOLEAN is allowed too.
48183
+ -- Anything else is rejected \u2014 the type is spliced as %s (NOT %I), so it must be
48184
+ -- a known-safe literal and never caller-controlled SQL.
48185
+ v_type := upper(btrim(p_type));
48186
+ IF v_type NOT IN ('TEXT', 'INTEGER', 'REAL', 'BOOLEAN') THEN
48187
+ RAISE EXCEPTION 'lattice: unsupported column type "%"', p_type;
48188
+ END IF;
48189
+
48190
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS %I %s', p_table, p_column, v_type);
48191
+
48192
+ -- If the table is cell-masked (a "<table>_v" view exists, because some column has
48193
+ -- an audience), the view selects an explicit column list \u2014 so a new column is
48194
+ -- invisible to members until the view is regenerated. Rebuild it the same way the
48195
+ -- owner path (audienceViewSql / regenerateAudienceViewFromDb) does: pass every
48196
+ -- column through except those with an 'owner' audience in __lattice_column_policy
48197
+ -- (CASE WHEN lattice_is_owner(...) THEN col END), re-apply row visibility with
48198
+ -- WHERE lattice_row_visible(table, pk), and keep the member SELECT grant on the
48199
+ -- view. Unmasked tables need no regen \u2014 the member group's table-level base grant
48200
+ -- already covers the new column.
48201
+ SELECT EXISTS (
48202
+ SELECT 1 FROM pg_class c
48203
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48204
+ WHERE n.nspname = current_schema() AND c.relname = v_view AND c.relkind = 'v'
48205
+ ) INTO v_has_view;
48206
+
48207
+ IF v_has_view THEN
48208
+ -- Canonical pk expression: CAST("col" AS TEXT) joined by TAB (chr(9)) \u2014 the
48209
+ -- same serialization the RLS policies + audienceViewSql use.
48210
+ SELECT string_agg(format('CAST(%I AS TEXT)', a.attname), ' || chr(9) || '
48211
+ ORDER BY array_position(i.indkey, a.attnum))
48212
+ INTO v_pk_expr
48213
+ FROM pg_index i
48214
+ JOIN pg_class c ON c.oid = i.indrelid
48215
+ JOIN pg_namespace n ON n.oid = c.relnamespace
48216
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
48217
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND i.indisprimary;
48218
+ IF v_pk_expr IS NULL THEN
48219
+ RAISE EXCEPTION 'lattice: cannot regenerate mask view for "%": no primary key', p_table;
48220
+ END IF;
48221
+
48222
+ -- Build the masked SELECT list in column order, applying the per-column policy.
48223
+ SELECT string_agg(
48224
+ CASE
48225
+ WHEN cp."audience" = 'owner'
48226
+ THEN format('CASE WHEN lattice_is_owner(%L, %s) THEN %I END AS %I',
48227
+ p_table, v_pk_expr, cols.column_name, cols.column_name)
48228
+ ELSE format('%I', cols.column_name)
48229
+ END,
48230
+ ', ' ORDER BY cols.ordinal_position)
48231
+ INTO v_select
48232
+ FROM information_schema.columns cols
48233
+ LEFT JOIN "__lattice_column_policy" cp
48234
+ ON cp."table_name" = p_table AND cp."column_name" = cols.column_name
48235
+ AND cp."audience" NOT IN ('', 'everyone', 'row-audience')
48236
+ WHERE cols.table_schema = current_schema() AND cols.table_name = p_table;
48237
+
48238
+ EXECUTE format(
48239
+ 'CREATE OR REPLACE VIEW %I AS SELECT %s FROM %I WHERE lattice_row_visible(%L, %s)',
48240
+ v_view, v_select, p_table, p_table, v_pk_expr);
48241
+ EXECUTE format('GRANT SELECT ON %I TO ${MEMBER_GROUP}', v_view);
48242
+ END IF;
48243
+ END $fn$;
48244
+ GRANT EXECUTE ON FUNCTION lattice_member_add_column(text, text, text) TO ${MEMBER_GROUP};
47873
48245
  `;
47874
48246
  }
47875
48247
  });
@@ -47979,6 +48351,11 @@ async function revokeRow(db, table, pk, grantee) {
47979
48351
  assertPg(db);
47980
48352
  await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_row(?, ?, ?)`, [table, pk, grantee]);
47981
48353
  }
48354
+ async function batchRowGrants(db, table, pk, grant, revoke) {
48355
+ assertPg(db);
48356
+ for (const grantee of grant) await grantRow(db, table, pk, grantee);
48357
+ for (const grantee of revoke) await revokeRow(db, table, pk, grantee);
48358
+ }
47982
48359
  async function revokeMemberRole(db, role) {
47983
48360
  assertPg(db);
47984
48361
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
@@ -49082,18 +49459,9 @@ function sessionUndoneFilters(undone, sessionId) {
49082
49459
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
49083
49460
  return filters;
49084
49461
  }
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", {
49462
+ function buildAuditRow(table, rowId, op, before, after, sessionId, editTs) {
49463
+ return {
49091
49464
  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
49465
  ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
49098
49466
  table_name: table,
49099
49467
  row_id: rowId,
@@ -49102,7 +49470,9 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49102
49470
  after_json: after ? JSON.stringify(after) : null,
49103
49471
  undone: 0,
49104
49472
  session_id: sessionId ?? null
49105
- });
49473
+ };
49474
+ }
49475
+ function publishMutationFeed(feed, table, rowId, op, before, after, source) {
49106
49476
  const labelRow = op === "delete" ? before : after;
49107
49477
  feed.publish({
49108
49478
  table,
@@ -49112,17 +49482,28 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
49112
49482
  summary: feedSummary(op, table, labelRow)
49113
49483
  });
49114
49484
  }
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) {
49485
+ async function purgeRedoStack(db, sessionId) {
49119
49486
  const undone = await db.query("_lattice_gui_audit", {
49120
49487
  filters: sessionUndoneFilters(1, sessionId)
49121
49488
  });
49122
49489
  for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
49490
+ }
49491
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
49492
+ await purgeRedoStack(db, sessionId);
49493
+ await db.insert(
49494
+ "_lattice_gui_audit",
49495
+ buildAuditRow(table, rowId, op, before, after, sessionId, editTs)
49496
+ );
49497
+ publishMutationFeed(feed, table, rowId, op, before, after, source);
49498
+ }
49499
+ function isSchemaOp(operation2) {
49500
+ return operation2.startsWith(SCHEMA_OP_PREFIX);
49501
+ }
49502
+ async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
49503
+ await purgeRedoStack(db, sessionId);
49123
49504
  await db.insert("_lattice_gui_audit", {
49124
49505
  id: crypto.randomUUID(),
49125
- // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
49506
+ // Explicit ISO ts — see buildAuditRow (the SQLite-only strftime DEFAULT
49126
49507
  // rendered "Invalid Date" on the Postgres/cloud path).
49127
49508
  ts: (/* @__PURE__ */ new Date()).toISOString(),
49128
49509
  table_name: table,
@@ -49157,7 +49538,7 @@ async function ensureColumns(db, table, values) {
49157
49538
  const added = Object.keys(values).filter((k6) => !(k6 in existing));
49158
49539
  if (added.length === 0) return [];
49159
49540
  for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
49160
- if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49541
+ if (!db.isCloudMemberOpen() && db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
49161
49542
  const cols = db.getRegisteredColumns(table);
49162
49543
  const pk = db.getPrimaryKey(table);
49163
49544
  if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
@@ -49279,7 +49660,14 @@ async function deleteRow(ctx, table, id, hard) {
49279
49660
  ctx.clientTs
49280
49661
  );
49281
49662
  } else {
49282
- await ctx.db.delete(table, id);
49663
+ await hardDelete(ctx, table, id, before);
49664
+ }
49665
+ }
49666
+ async function hardDelete(ctx, table, id, before) {
49667
+ const withClient = ctx.db.adapter.withClient?.bind(ctx.db.adapter);
49668
+ const pkCols = ctx.db.getPrimaryKey(table);
49669
+ const pkCol = pkCols.length === 1 ? pkCols[0] : void 0;
49670
+ if (!withClient || ctx.db.isChangelogTracked(table) || pkCol === void 0) {
49283
49671
  await appendAudit(
49284
49672
  ctx.db,
49285
49673
  ctx.feed,
@@ -49292,10 +49680,30 @@ async function deleteRow(ctx, table, id, hard) {
49292
49680
  ctx.sessionId,
49293
49681
  ctx.clientTs
49294
49682
  );
49683
+ await ctx.db.delete(table, id);
49684
+ return;
49295
49685
  }
49686
+ const auditRow = buildAuditRow(table, id, "delete", before, null, ctx.sessionId, ctx.clientTs);
49687
+ await purgeRedoStack(ctx.db, ctx.sessionId);
49688
+ const auditCols = AUDIT_COLUMNS.map((c6) => `"${c6}"`).join(", ");
49689
+ const auditPlaceholders = AUDIT_COLUMNS.map(() => "?").join(", ");
49690
+ const auditValues = AUDIT_COLUMNS.map((c6) => auditRow[c6]);
49691
+ const pkColQuoted = pkCol.replace(/"/g, '""');
49692
+ await withClient(async (tx) => {
49693
+ await tx.run(
49694
+ `INSERT INTO "_lattice_gui_audit" (${auditCols}) VALUES (${auditPlaceholders})`,
49695
+ auditValues
49696
+ );
49697
+ await tx.run(`DELETE FROM "${table.replace(/"/g, '""')}" WHERE "${pkColQuoted}" = ?`, [id]);
49698
+ });
49699
+ publishMutationFeed(ctx.feed, table, id, "delete", before, null, ctx.source);
49296
49700
  }
49297
- async function linkRows(ctx, table, body) {
49298
- await ctx.db.link(table, body);
49701
+ async function linkRows(ctx, table, body, forceVisibility) {
49702
+ if (forceVisibility !== void 0) {
49703
+ await ctx.db.insertForcingVisibility(table, body, forceVisibility);
49704
+ } else {
49705
+ await ctx.db.link(table, body);
49706
+ }
49299
49707
  await appendAudit(ctx.db, ctx.feed, table, null, "link", null, body, ctx.source, ctx.sessionId);
49300
49708
  }
49301
49709
  async function unlinkRows(ctx, table, body) {
@@ -49433,13 +49841,24 @@ async function revertEntry(ctx, id) {
49433
49841
  });
49434
49842
  return { ok: true, entry };
49435
49843
  }
49436
- var import_node_crypto15, SCHEMA_OP_PREFIX;
49844
+ var import_node_crypto15, AUDIT_COLUMNS, SCHEMA_OP_PREFIX;
49437
49845
  var init_mutations = __esm({
49438
49846
  "src/gui/mutations.ts"() {
49439
49847
  "use strict";
49440
49848
  import_node_crypto15 = require("crypto");
49441
49849
  init_cloud_connect();
49442
49850
  init_audience();
49851
+ AUDIT_COLUMNS = [
49852
+ "id",
49853
+ "ts",
49854
+ "table_name",
49855
+ "row_id",
49856
+ "operation",
49857
+ "before_json",
49858
+ "after_json",
49859
+ "undone",
49860
+ "session_id"
49861
+ ];
49443
49862
  SCHEMA_OP_PREFIX = "schema.";
49444
49863
  }
49445
49864
  });
@@ -49727,6 +50146,10 @@ async function readMachineCredential(db, kind) {
49727
50146
  }
49728
50147
  return null;
49729
50148
  }
50149
+ async function resolveAnthropicKey(db) {
50150
+ if (isAssistantCredentialCleared(CREDENTIALS.anthropic.kind)) return null;
50151
+ return await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50152
+ }
49730
50153
  function getAggressiveness() {
49731
50154
  const n3 = readPreferences().aggressiveness;
49732
50155
  if (!Number.isFinite(n3)) return DEFAULT_AGGRESSIVENESS;
@@ -49757,6 +50180,7 @@ async function getVoiceCredential(db) {
49757
50180
  return null;
49758
50181
  }
49759
50182
  async function hasCredential(db, name, envVar) {
50183
+ if (isAssistantCredentialCleared(CREDENTIALS[name].kind)) return false;
49760
50184
  return Boolean(await readMachineCredential(db, CREDENTIALS[name].kind)) || Boolean(process.env[envVar]);
49761
50185
  }
49762
50186
  async function resolveClaudeAuth(db) {
@@ -49779,7 +50203,7 @@ async function resolveClaudeAuth(db) {
49779
50203
  } catch {
49780
50204
  }
49781
50205
  }
49782
- const apiKey = await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
50206
+ const apiKey = await resolveAnthropicKey(db);
49783
50207
  return apiKey ? { apiKey } : null;
49784
50208
  }
49785
50209
  async function hasClaudeAuth(db) {
@@ -49876,6 +50300,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49876
50300
  }
49877
50301
  const cred = CREDENTIALS[name];
49878
50302
  setAssistantCredential(cred.kind, key);
50303
+ clearAssistantCredentialCleared(cred.kind);
49879
50304
  if (db) {
49880
50305
  for (const row of await liveSecretsOfKind(db, cred.kind)) {
49881
50306
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -49892,6 +50317,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
49892
50317
  return true;
49893
50318
  }
49894
50319
  deleteAssistantCredential(CREDENTIALS[name].kind);
50320
+ setAssistantCredentialCleared(CREDENTIALS[name].kind);
49895
50321
  if (db) {
49896
50322
  for (const row of await liveSecretsOfKind(db, CREDENTIALS[name].kind)) {
49897
50323
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -52082,7 +52508,7 @@ function buildSchema(db) {
52082
52508
  }
52083
52509
  return out;
52084
52510
  }
52085
- async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false) {
52511
+ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false, privateMode = false) {
52086
52512
  if (!text.trim()) return [];
52087
52513
  const auth = await resolveClaudeAuth(db);
52088
52514
  if (!auth) {
@@ -52104,6 +52530,7 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52104
52530
  });
52105
52531
  return [];
52106
52532
  }
52533
+ const forceVis = privateMode ? "private" : void 0;
52107
52534
  const temperature = aggressivenessToTemperature(aggressiveness);
52108
52535
  let description = "";
52109
52536
  try {
@@ -52146,11 +52573,16 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52146
52573
  }
52147
52574
  if (jx) {
52148
52575
  try {
52149
- await linkRows(mctx, jx.junction, {
52150
- id: crypto.randomUUID(),
52151
- [jx.fileFk]: fileId,
52152
- [jx.otherFk]: m4.id
52153
- });
52576
+ await linkRows(
52577
+ mctx,
52578
+ jx.junction,
52579
+ {
52580
+ id: crypto.randomUUID(),
52581
+ [jx.fileFk]: fileId,
52582
+ [jx.otherFk]: m4.id
52583
+ },
52584
+ forceVis
52585
+ );
52154
52586
  linkedCount++;
52155
52587
  if (created) {
52156
52588
  mctx.feed.publish({
@@ -52209,16 +52641,21 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52209
52641
  if ("name" in cols && row.name == null) row.name = obj2.label;
52210
52642
  if ("title" in cols && row.title == null) row.title = obj2.label;
52211
52643
  try {
52212
- const { id: rowId } = await createRow(mctx, entity, row);
52644
+ const { id: rowId } = await createRow(mctx, entity, row, forceVis);
52213
52645
  createdCount++;
52214
52646
  const ent = entity;
52215
52647
  const jx = junctions.find((j6) => j6.otherTable === ent) ?? (createJunction ? await createJunction(ent) : null);
52216
52648
  if (jx) {
52217
- await linkRows(mctx, jx.junction, {
52218
- id: crypto.randomUUID(),
52219
- [jx.fileFk]: fileId,
52220
- [jx.otherFk]: rowId
52221
- });
52649
+ await linkRows(
52650
+ mctx,
52651
+ jx.junction,
52652
+ {
52653
+ id: crypto.randomUUID(),
52654
+ [jx.fileFk]: fileId,
52655
+ [jx.otherFk]: rowId
52656
+ },
52657
+ forceVis
52658
+ );
52222
52659
  }
52223
52660
  } catch (e6) {
52224
52661
  console.warn(`[ingest] create ${entity} from document failed:`, e6.message);
@@ -52232,12 +52669,17 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
52232
52669
  try {
52233
52670
  const title = name.replace(/\.[^./\\]+$/, "").trim() || "Note";
52234
52671
  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
- });
52672
+ const { id: noteId } = await createRow(
52673
+ mctx,
52674
+ "notes",
52675
+ {
52676
+ id: crypto.randomUUID(),
52677
+ title,
52678
+ body,
52679
+ source_file_id: fileId
52680
+ },
52681
+ forceVis
52682
+ );
52241
52683
  mctx.feed.publish({
52242
52684
  table: "notes",
52243
52685
  op: "insert",
@@ -52351,7 +52793,8 @@ async function ingestUrlAsFile(ctx, rawUrl, opts = {}) {
52351
52793
  ctx.enrich.createJunction,
52352
52794
  ctx.enrich.aggressiveness,
52353
52795
  ctx.enrich.createEntity,
52354
- true
52796
+ true,
52797
+ ctx.privateMode === true
52355
52798
  );
52356
52799
  }
52357
52800
  return {
@@ -53229,13 +53672,22 @@ function loadSdk() {
53229
53672
  throw new Error("Could not resolve the Anthropic constructor from '@anthropic-ai/sdk'");
53230
53673
  return ctor;
53231
53674
  }
53232
- function createAnthropicClient(auth) {
53233
- const Anthropic = loadSdk();
53675
+ function buildAnthropicConfig(auth) {
53234
53676
  const config = {};
53235
- if (auth.authToken) config.authToken = auth.authToken;
53236
- else if (auth.apiKey) config.apiKey = auth.apiKey;
53677
+ if (auth.authToken) {
53678
+ config.authToken = auth.authToken;
53679
+ config.apiKey = null;
53680
+ } else if (auth.apiKey) {
53681
+ config.apiKey = auth.apiKey;
53682
+ } else {
53683
+ config.apiKey = null;
53684
+ }
53237
53685
  if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
53238
- const sdk = new Anthropic(config);
53686
+ return config;
53687
+ }
53688
+ function createAnthropicClient(auth) {
53689
+ const Anthropic = loadSdk();
53690
+ const sdk = new Anthropic(buildAnthropicConfig(auth));
53239
53691
  return {
53240
53692
  async runTurn(params) {
53241
53693
  const stream = sdk.messages.stream({
@@ -54670,8 +55122,14 @@ var MEMBER_READABLE_BOOKKEEPING = [
54670
55122
  },
54671
55123
  {
54672
55124
  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"
55125
+ // UPDATE + DELETE are needed by undo/redo/revert (flips an entry's `undone`)
55126
+ // and the redo-stack purge on a new mutation (deletes the session's undone
55127
+ // entries). Safe because enableGuiAuditRls installs per-op UPDATE and DELETE
55128
+ // policies whose USING is `row_id IS NULL OR lattice_row_visible(table_name,
55129
+ // row_id)` — so a member can only update/delete audit rows for entities it can
55130
+ // already see (or schema-level entries that carry no row data).
55131
+ privs: "SELECT, INSERT, UPDATE, DELETE",
55132
+ 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
55133
  },
54676
55134
  {
54677
55135
  name: "__lattice_user_identity",
@@ -55073,6 +55531,19 @@ async function normalizeImage(path2, maxBytes) {
55073
55531
  function renderJpeg(sharp, path2, quality) {
55074
55532
  return sharp(path2).rotate().resize({ width: MAX_DIM, height: MAX_DIM, fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
55075
55533
  }
55534
+ function buildVisionAnthropicConfig(auth) {
55535
+ const config = {};
55536
+ if (auth.authToken) {
55537
+ config.authToken = auth.authToken;
55538
+ config.apiKey = null;
55539
+ } else if (auth.apiKey) {
55540
+ config.apiKey = auth.apiKey;
55541
+ } else {
55542
+ config.apiKey = null;
55543
+ }
55544
+ if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
55545
+ return config;
55546
+ }
55076
55547
  function defaultSender(auth) {
55077
55548
  return async (input) => {
55078
55549
  const importMetaUrl = import_meta3.url;
@@ -55080,11 +55551,7 @@ function defaultSender(auth) {
55080
55551
  const sdk = req("@anthropic-ai/sdk");
55081
55552
  const Anthropic = sdk.Anthropic ?? sdk.default;
55082
55553
  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);
55554
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
55088
55555
  const res = await client.messages.create({
55089
55556
  model: input.model,
55090
55557
  max_tokens: 1024,
@@ -55111,11 +55578,7 @@ function defaultPdfSender(auth) {
55111
55578
  const sdk = req("@anthropic-ai/sdk");
55112
55579
  const Anthropic = sdk.Anthropic ?? sdk.default;
55113
55580
  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);
55581
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
55119
55582
  const res = await client.messages.create({
55120
55583
  model: input.model,
55121
55584
  max_tokens: 4096,
@@ -56055,6 +56518,8 @@ var css = `
56055
56518
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
56056
56519
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
56057
56520
  .grants-panel .grants-row input { accent-color: var(--accent); }
56521
+ .grants-panel .grants-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
56522
+ .grants-panel .grants-dirty { font-size: 12px; }
56058
56523
 
56059
56524
  /* Inline create-row at the bottom of every table */
56060
56525
  tr.create-row td { background: var(--surface-2); }
@@ -58521,6 +58986,15 @@ var appJs = `
58521
58986
  // Per-table view state: 'live' (default) or 'trash' (soft-deleted rows).
58522
58987
  var tableViewMode = {};
58523
58988
 
58989
+ // The (table, pk) of the per-row "Manage access" grants panel that is
58990
+ // currently open, or null when none is. A soft re-render (a concurrent edit
58991
+ // by another client fires pg_notify \u2192 realtime refresh \u2192 renderRoute({soft})
58992
+ // \u2192 renderDetail/renderFsItem repaint) would otherwise re-create the detail
58993
+ // view with the panel collapsed, dropping a staged multi-select mid-edit.
58994
+ // wireRowSharing reads this after each repaint and re-opens + re-populates the
58995
+ // panel WITHOUT any network call, so the staged selection survives.
58996
+ var openGrantsPanel = null;
58997
+
58524
58998
  function renderTable(content, tableName) {
58525
58999
  var myGen = renderGen;
58526
59000
  clearUnseen(tableName);
@@ -58999,70 +59473,151 @@ var appJs = `
58999
59473
  }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
59000
59474
  });
59001
59475
  });
59002
- var detailVisManage = content.querySelector('#detail-vis-manage');
59003
- if (detailVisManage) detailVisManage.addEventListener('click', function () {
59476
+ var access = row._access || {};
59477
+
59478
+ // Render the staged member checklist + a single "Save sharing" / "Cancel"
59479
+ // into the panel. Checkbox toggles mutate ONLY the local desired map \u2014
59480
+ // NO network call per toggle (the old design auto-saved live, one POST per
59481
+ // checkbox, and each grant's pg_notify collapsed the panel). A single batch
59482
+ // request fires on Save. members is the already-fetched list; desired
59483
+ // seeds from the row's current grantees (or a caller-supplied staged map
59484
+ // when re-opening after a soft re-render).
59485
+ function populateGrantsPanel(panel, members, desired) {
59486
+ // Snapshot the CURRENT (committed) grantees so Save can diff desired-vs-
59487
+ // current into adds/removes. effectiveVisibility decides whether we're
59488
+ // actually switching INTO specific-people mode (custom-0 reads as private).
59489
+ var current = {};
59490
+ (access.grantees || []).forEach(function (g) { current[g] = true; });
59491
+ if (members.length === 0) {
59492
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
59493
+ panel.hidden = false;
59494
+ return;
59495
+ }
59496
+ function dirtyCount() {
59497
+ var n = 0;
59498
+ members.forEach(function (m) {
59499
+ if (!!desired[m.role] !== !!current[m.role]) n++;
59500
+ });
59501
+ return n;
59502
+ }
59503
+ function render() {
59504
+ var changed = dirtyCount();
59505
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' +
59506
+ members.map(function (m) {
59507
+ var label = m.name || m.email || m.role;
59508
+ return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
59509
+ (desired[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
59510
+ }).join('') +
59511
+ '<div class="grants-actions">' +
59512
+ '<button class="btn primary" id="grants-save"' + (changed ? '' : ' disabled') + '>Save sharing</button>' +
59513
+ '<button class="btn" id="grants-cancel">Cancel</button>' +
59514
+ '<span class="grants-dirty muted">' + (changed ? (changed === 1 ? '1 change' : changed + ' changes') : 'No changes') + '</span>' +
59515
+ '</div>';
59516
+ panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
59517
+ cb.addEventListener('change', function () {
59518
+ var role = cb.getAttribute('data-grant-role');
59519
+ if (cb.checked) desired[role] = true; else delete desired[role];
59520
+ render(); // re-render to refresh the dirty indicator + Save state
59521
+ });
59522
+ });
59523
+ var cancelBtn = panel.querySelector('#grants-cancel');
59524
+ if (cancelBtn) cancelBtn.addEventListener('click', function () { closeGrantsPanel(panel); });
59525
+ var saveBtn = panel.querySelector('#grants-save');
59526
+ if (saveBtn) saveBtn.addEventListener('click', function () {
59527
+ var toAdd = [];
59528
+ var toRemove = [];
59529
+ members.forEach(function (m) {
59530
+ var want = !!desired[m.role];
59531
+ var have = !!current[m.role];
59532
+ if (want && !have) toAdd.push(m.role);
59533
+ if (!want && have) toRemove.push(m.role);
59534
+ });
59535
+ if (toAdd.length === 0 && toRemove.length === 0) { closeGrantsPanel(panel); return; }
59536
+ // Confirm the mode change ONCE, here \u2014 only when actually switching
59537
+ // INTO specific-people mode (effective vis isn't already custom AND we
59538
+ // are adding at least one grantee). Never per checkbox.
59539
+ if (effectiveVisibility(access) !== 'custom' && toAdd.length > 0) {
59540
+ if (!confirm('Sharing this with specific people switches it off "everyone"/"private". The chosen people will be able to see it. Continue?')) return;
59541
+ }
59542
+ withBusy(saveBtn, function () {
59543
+ return fetchJson('/api/cloud/row-grants', {
59544
+ method: 'POST',
59545
+ headers: { 'content-type': 'application/json' },
59546
+ body: JSON.stringify({ table: tableName, pk: id, grant: toAdd, revoke: toRemove }),
59547
+ }).then(function () {
59548
+ // Mirror the committed state locally so the re-render's indicator
59549
+ // is correct. The first grant flips the row to custom server-side;
59550
+ // revoking the last leaves custom-0, which effectiveVisibility
59551
+ // renders as private.
59552
+ var list = [];
59553
+ members.forEach(function (m) { if (desired[m.role]) list.push(m.role); });
59554
+ access.grantees = list;
59555
+ if (list.length > 0) access.visibility = 'custom';
59556
+ openGrantsPanel = null; // a successful save closes the staging session
59557
+ invalidate(tableName);
59558
+ showToast('Sharing updated', {});
59559
+ reRender();
59560
+ }).catch(function (e) {
59561
+ // Surface loudly + leave the staged selection intact so the user
59562
+ // can retry; no silent partial-success.
59563
+ showToast('Sharing update failed: ' + e.message, {});
59564
+ });
59565
+ });
59566
+ });
59567
+ panel.hidden = false;
59568
+ }
59569
+ render();
59570
+ }
59571
+
59572
+ function closeGrantsPanel(panel) {
59573
+ if (panel) panel.hidden = true;
59574
+ openGrantsPanel = null;
59575
+ }
59576
+
59577
+ // Open (or toggle shut) the manage-access panel. Fetches the member list,
59578
+ // then stages from the row's current grantees. Opening must NOT pre-flip
59579
+ // the row to 'custom' \u2014 that left a never-shared row stuck at "custom (0)".
59580
+ function openManagePanel(triggerBtn) {
59004
59581
  var panel = content.querySelector('#grants-panel');
59005
59582
  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) {
59583
+ if (!panel.hidden) { closeGrantsPanel(panel); return; }
59584
+ withBusy(triggerBtn, function () {
59585
+ return fetchJson('/api/cloud/members').then(function (d) {
59018
59586
  // The grant target is a member ROLE: lattice_grant_row keys on the
59019
59587
  // role, and _access.grantees holds role names. List every member
59020
59588
  // except the owner (you don't grant the owner their own row).
59021
59589
  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);
59590
+ var desired = {};
59591
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59592
+ openGrantsPanel = { table: tableName, pk: id };
59593
+ populateGrantsPanel(panel, members, desired);
59063
59594
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
59064
59595
  });
59596
+ }
59597
+
59598
+ var detailVisManage = content.querySelector('#detail-vis-manage');
59599
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
59600
+ openManagePanel(detailVisManage);
59065
59601
  });
59602
+
59603
+ // Preserve an open panel across a soft re-render: if the tracked panel
59604
+ // matches the row this view just repainted, re-open it and re-populate the
59605
+ // checklist from the freshly-fetched row._access WITHOUT any network call,
59606
+ // so a concurrent edit by another client doesn't lose a staged selection.
59607
+ if (openGrantsPanel && openGrantsPanel.table === tableName && openGrantsPanel.pk === id) {
59608
+ var rpanel = content.querySelector('#grants-panel');
59609
+ if (rpanel) {
59610
+ fetchJson('/api/cloud/members').then(function (d) {
59611
+ // Only re-populate if THIS panel is still the tracked-open one (a
59612
+ // newer navigation/save may have cleared it while members loaded).
59613
+ if (!openGrantsPanel || openGrantsPanel.table !== tableName || openGrantsPanel.pk !== id) return;
59614
+ var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
59615
+ var desired = {};
59616
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
59617
+ populateGrantsPanel(rpanel, members, desired);
59618
+ }).catch(function () { /* best-effort restore; a click reopens it */ });
59619
+ }
59620
+ }
59066
59621
  }
59067
59622
  function renderDetail(content, tableName, id) {
59068
59623
  var myGen = renderGen;
@@ -63846,13 +64401,21 @@ var appJs = `
63846
64401
  }
63847
64402
  function uploadFile(file) {
63848
64403
  var done = pendingIngestItem(file.name || 'file');
64404
+ // Carry the composer's "Private mode" intent so an upload made while the
64405
+ // box is checked is stamped private at insert, instead of inheriting the
64406
+ // files-table default (which can be shared-to-everyone on a cloud). Read
64407
+ // the checkbox defensively \u2014 it may not be rendered. On a local workspace
64408
+ // the box is checked+disabled, so this is '1' there too; forced visibility
64409
+ // is a harmless no-op on the single-user SQLite path.
64410
+ var pv = document.getElementById('chat-private');
64411
+ var priv = pv && pv.checked ? '1' : '0';
63849
64412
  return fetch('/api/ingest/upload', {
63850
64413
  method: 'POST',
63851
64414
  // Percent-encode the filename: HTTP header values must be ISO-8859-1,
63852
64415
  // so a Unicode filename (emoji, smart quote, accent, em-dash) would
63853
64416
  // otherwise make fetch() throw "String contains non ISO-8859-1 code
63854
64417
  // point". The server decodeURIComponent()s it back.
63855
- headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file') },
64418
+ headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file'), 'x-lattice-private': priv },
63856
64419
  body: file,
63857
64420
  })
63858
64421
  .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); })
@@ -64719,10 +65282,19 @@ function isUnderGlobalPrefix(packageRoot, execPath) {
64719
65282
  }
64720
65283
  function detectInstallContext(opts = {}) {
64721
65284
  const pkgName = opts.pkgName ?? "latticesql";
64722
- const cwd = opts.cwd ?? process.cwd();
64723
65285
  const env2 = opts.env ?? process.env;
64724
65286
  const execPath = opts.execPath ?? process.execPath;
64725
- const modulePath = opts.modulePath ?? process.argv[1] ?? cwd;
65287
+ const rawCwd = opts.cwd ?? process.cwd();
65288
+ const rawModulePath = opts.modulePath ?? process.argv[1] ?? rawCwd;
65289
+ const resolveReal = (p3) => {
65290
+ try {
65291
+ return (0, import_node_fs27.realpathSync)(p3);
65292
+ } catch {
65293
+ return p3;
65294
+ }
65295
+ };
65296
+ const modulePath = resolveReal(rawModulePath);
65297
+ const cwd = resolveReal(rawCwd);
64726
65298
  const packageRoot = findPackageRoot((0, import_node_path30.dirname)(modulePath), pkgName);
64727
65299
  if (packageRoot && (0, import_node_fs27.existsSync)((0, import_node_path30.join)(packageRoot, ".git"))) {
64728
65300
  return {
@@ -66508,6 +67080,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66508
67080
  });
66509
67081
  return true;
66510
67082
  }
67083
+ if (pathname === "/api/cloud/row-grants" && method === "POST") {
67084
+ await tryHandler(res, async () => {
67085
+ const body = await readJson(req);
67086
+ const table = typeof body.table === "string" ? body.table : "";
67087
+ const pk = typeof body.pk === "string" ? body.pk : "";
67088
+ const strList = (v2) => Array.isArray(v2) ? v2.filter((x2) => typeof x2 === "string") : [];
67089
+ const grant = strList(body.grant);
67090
+ const revoke = strList(body.revoke);
67091
+ if (!table || !pk) {
67092
+ sendJson(res, { error: "table and pk are required" }, 400);
67093
+ return;
67094
+ }
67095
+ if (ctx.db.getDialect() !== "postgres") {
67096
+ sendJson(res, { error: "Per-row sharing requires a cloud (Postgres) database" }, 400);
67097
+ return;
67098
+ }
67099
+ await batchRowGrants(ctx.db, table, pk, grant, revoke);
67100
+ sendJson(res, { ok: true, table, pk, granted: grant, revoked: revoke });
67101
+ });
67102
+ return true;
67103
+ }
66511
67104
  if (pathname === "/api/cloud/s3-config" && method === "GET") {
66512
67105
  await tryHandler(res, () => {
66513
67106
  const label = activeWorkspaceLabel(ctx.configPath);
@@ -67304,7 +67897,7 @@ function enrichContext(ctx) {
67304
67897
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {}
67305
67898
  };
67306
67899
  }
67307
- async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67900
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res, privateMode) {
67308
67901
  try {
67309
67902
  return await enrichWithLlm(
67310
67903
  mctx,
@@ -67316,7 +67909,9 @@ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
67316
67909
  ctx.entityDescriptions,
67317
67910
  ctx.createJunction,
67318
67911
  ctx.aggressiveness,
67319
- ctx.createEntity
67912
+ ctx.createEntity,
67913
+ false,
67914
+ privateMode
67320
67915
  );
67321
67916
  } catch (e6) {
67322
67917
  const err = e6;
@@ -67395,7 +67990,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67395
67990
  source: "ingest",
67396
67991
  onColumnsAdded: columnDescriptionHook(ctx.db)
67397
67992
  };
67993
+ const headerPrivate = req.headers["x-lattice-private"] === "1";
67398
67994
  if (ctx.pathname === "/api/ingest/upload") {
67995
+ const forcePrivate2 = headerPrivate;
67399
67996
  const rawName = typeof req.headers["x-filename"] === "string" && req.headers["x-filename"] || "";
67400
67997
  let name2 = "upload";
67401
67998
  if (rawName) {
@@ -67493,10 +68090,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67493
68090
  ...blob ? { blob_path: blob.blob_path } : {}
67494
68091
  } : blob ? { ref_kind: "blob", blob_path: blob.blob_path } : {}
67495
68092
  };
67496
- const { id: id2 } = await createRow(mctx, "files", {
67497
- ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
67498
- ...uploadRow
67499
- });
68093
+ const { id: id2 } = await createRow(
68094
+ mctx,
68095
+ "files",
68096
+ {
68097
+ ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
68098
+ ...uploadRow
68099
+ },
68100
+ forcePrivate2 ? "private" : void 0
68101
+ );
67500
68102
  try {
67501
68103
  const dedupCtx = {
67502
68104
  db: ctx.db,
@@ -67522,7 +68124,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67522
68124
  }
67523
68125
  let suggestedLinks = [];
67524
68126
  if (!result.skip) {
67525
- const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
68127
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res, forcePrivate2);
67526
68128
  if (links === null) return true;
67527
68129
  suggestedLinks = links;
67528
68130
  }
@@ -67549,6 +68151,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67549
68151
  sendJson4(res, { error: e6.message }, 400);
67550
68152
  return true;
67551
68153
  }
68154
+ const forcePrivate = headerPrivate || body.private === true;
67552
68155
  if (ctx.pathname === "/api/ingest/text") {
67553
68156
  const rawText = typeof body.text === "string" ? body.text : "";
67554
68157
  if (!rawText.trim()) {
@@ -67559,7 +68162,7 @@ async function dispatchIngestRoute(req, res, ctx) {
67559
68162
  if (sourceUrl) {
67560
68163
  try {
67561
68164
  const result = await ingestUrlAsFile(
67562
- { db: ctx.db, mctx, enrich: enrichContext(ctx) },
68165
+ { db: ctx.db, mctx, enrich: enrichContext(ctx), privateMode: forcePrivate },
67563
68166
  sourceUrl
67564
68167
  );
67565
68168
  sendJson4(
@@ -67588,11 +68191,25 @@ async function dispatchIngestRoute(req, res, ctx) {
67588
68191
  description: describe(content, mime2, title),
67589
68192
  extraction_status: "extracted"
67590
68193
  };
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);
68194
+ const { id: id2 } = await createRow(
68195
+ mctx,
68196
+ "files",
68197
+ {
68198
+ ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
68199
+ ...textRow
68200
+ },
68201
+ forcePrivate ? "private" : void 0
68202
+ );
68203
+ const suggestedLinks = await enrichOrFail(
68204
+ mctx,
68205
+ ctx.db,
68206
+ id2,
68207
+ content,
68208
+ title,
68209
+ ctx,
68210
+ res,
68211
+ forcePrivate
68212
+ );
67596
68213
  if (suggestedLinks === null) return true;
67597
68214
  sendJson4(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
67598
68215
  return true;
@@ -67631,10 +68248,15 @@ async function dispatchIngestRoute(req, res, ctx) {
67631
68248
  size_bytes: size,
67632
68249
  extraction_status: "pending"
67633
68250
  };
67634
- const { id } = await createRow(mctx, "files", {
67635
- ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
67636
- ...localRow
67637
- });
68251
+ const { id } = await createRow(
68252
+ mctx,
68253
+ "files",
68254
+ {
68255
+ ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
68256
+ ...localRow
68257
+ },
68258
+ forcePrivate ? "private" : void 0
68259
+ );
67638
68260
  try {
67639
68261
  const result = await extractSource(ctx.db, abs, mime, name);
67640
68262
  await updateRow(mctx, "files", id, {
@@ -67652,7 +68274,9 @@ async function dispatchIngestRoute(req, res, ctx) {
67652
68274
  ctx.entityDescriptions,
67653
68275
  ctx.createJunction,
67654
68276
  ctx.aggressiveness,
67655
- ctx.createEntity
68277
+ ctx.createEntity,
68278
+ false,
68279
+ forcePrivate
67656
68280
  );
67657
68281
  sendJson4(
67658
68282
  res,
@@ -68339,7 +68963,7 @@ function startBackgroundRender(active) {
68339
68963
  }
68340
68964
  bus.publish(e6);
68341
68965
  };
68342
- void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
68966
+ void db.renderInBackground(active.outputDir, { signal, onProgress, gateOnOpen: true }).then(
68343
68967
  () => {
68344
68968
  },
68345
68969
  (err) => {