latticesql 3.4.2 → 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/cli.js CHANGED
@@ -507,7 +507,23 @@ function deleteAssistantCredential(kind) {
507
507
  void _removed;
508
508
  saveAssistantCredentials(rest);
509
509
  }
510
- var MASTER_KEY_FILENAME, IDENTITY_FILENAME, EMPTY_IDENTITY, PREFERENCES_FILENAME, DEFAULT_PREFERENCES, DB_CREDENTIALS_FILENAME, CRED_LOCK_FILENAME, LOCK_STALE_MS, LOCK_TIMEOUT_MS, lockDepthInProcess, S3_CONFIG_FILENAME, ASSISTANT_CREDENTIALS_FILENAME;
510
+ function isAssistantCredentialCleared(kind) {
511
+ return loadAssistantCredentials()[CLEARED_SENTINEL_PREFIX + kind] === "1";
512
+ }
513
+ function setAssistantCredentialCleared(kind) {
514
+ const creds = loadAssistantCredentials();
515
+ creds[CLEARED_SENTINEL_PREFIX + kind] = "1";
516
+ saveAssistantCredentials(creds);
517
+ }
518
+ function clearAssistantCredentialCleared(kind) {
519
+ const creds = loadAssistantCredentials();
520
+ const sentinel = CLEARED_SENTINEL_PREFIX + kind;
521
+ if (!(sentinel in creds)) return;
522
+ const { [sentinel]: _removed, ...rest } = creds;
523
+ void _removed;
524
+ saveAssistantCredentials(rest);
525
+ }
526
+ var MASTER_KEY_FILENAME, IDENTITY_FILENAME, EMPTY_IDENTITY, PREFERENCES_FILENAME, DEFAULT_PREFERENCES, DB_CREDENTIALS_FILENAME, CRED_LOCK_FILENAME, LOCK_STALE_MS, LOCK_TIMEOUT_MS, lockDepthInProcess, S3_CONFIG_FILENAME, ASSISTANT_CREDENTIALS_FILENAME, CLEARED_SENTINEL_PREFIX;
511
527
  var init_user_config = __esm({
512
528
  "src/framework/user-config.ts"() {
513
529
  "use strict";
@@ -530,6 +546,7 @@ var init_user_config = __esm({
530
546
  lockDepthInProcess = 0;
531
547
  S3_CONFIG_FILENAME = "s3-config.enc";
532
548
  ASSISTANT_CREDENTIALS_FILENAME = "assistant-credentials.enc";
549
+ CLEARED_SENTINEL_PREFIX = "__cleared__:";
533
550
  }
534
551
  });
535
552
 
@@ -960,10 +977,12 @@ function readManifest(outputDir) {
960
977
  function writeManifest(outputDir, manifest) {
961
978
  atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
962
979
  }
980
+ var TEMPLATE_VERSION;
963
981
  var init_manifest = __esm({
964
982
  "src/lifecycle/manifest.ts"() {
965
983
  "use strict";
966
984
  init_writer();
985
+ TEMPLATE_VERSION = 1;
967
986
  }
968
987
  });
969
988
 
@@ -997,6 +1016,126 @@ var init_adapter = __esm({
997
1016
  }
998
1017
  });
999
1018
 
1019
+ // src/lifecycle/render-cursor.ts
1020
+ function markToString(v2) {
1021
+ if (v2 == null) return null;
1022
+ if (v2 instanceof Date) return v2.toISOString();
1023
+ if (typeof v2 === "string") return v2;
1024
+ if (typeof v2 === "number" || typeof v2 === "bigint" || typeof v2 === "boolean") return String(v2);
1025
+ return null;
1026
+ }
1027
+ function padNumericMark(v2) {
1028
+ const s2 = markToString(v2);
1029
+ if (s2 == null) return null;
1030
+ if (/^\d+$/.test(s2)) return s2.padStart(20, "0");
1031
+ return s2;
1032
+ }
1033
+ async function changelogExists(adapter) {
1034
+ if (adapter.dialect === "postgres") {
1035
+ const row2 = await getAsyncOrSync(
1036
+ adapter,
1037
+ `SELECT to_regclass('__lattice_changelog') AS reg`
1038
+ );
1039
+ return !!row2 && row2.reg != null;
1040
+ }
1041
+ const row = await getAsyncOrSync(
1042
+ adapter,
1043
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='__lattice_changelog'`
1044
+ );
1045
+ return !!row;
1046
+ }
1047
+ async function changelogMark(adapter) {
1048
+ try {
1049
+ if (!await changelogExists(adapter)) return null;
1050
+ const col = adapter.dialect === "postgres" ? "seq" : "rowid";
1051
+ const row = await getAsyncOrSync(
1052
+ adapter,
1053
+ `SELECT MAX(${col}) AS m FROM __lattice_changelog`
1054
+ );
1055
+ return padNumericMark(row?.m);
1056
+ } catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+ async function sharingMarks(adapter) {
1061
+ if (adapter.dialect !== "postgres") return { grants: null, owners: null };
1062
+ try {
1063
+ const reg = await getAsyncOrSync(
1064
+ adapter,
1065
+ `SELECT to_regclass('__lattice_changes') AS reg`
1066
+ );
1067
+ const hasFeed = !!reg && reg.reg != null;
1068
+ if (hasFeed) {
1069
+ const row = await getAsyncOrSync(
1070
+ adapter,
1071
+ `SELECT COUNT(*) AS n, MAX(seq) AS m FROM lattice_changes_since(0, 1000)`
1072
+ );
1073
+ const digest = digestOf(row?.n, row?.m);
1074
+ return { grants: digest, owners: digest };
1075
+ }
1076
+ } catch {
1077
+ }
1078
+ let owners = null;
1079
+ let grants = null;
1080
+ try {
1081
+ const o3 = await getAsyncOrSync(
1082
+ adapter,
1083
+ `SELECT COUNT(*) AS n, MAX(updated_at) AS m FROM __lattice_owners`
1084
+ );
1085
+ owners = digestOf(o3?.n, o3?.m);
1086
+ } catch {
1087
+ owners = null;
1088
+ }
1089
+ try {
1090
+ const g6 = await getAsyncOrSync(
1091
+ adapter,
1092
+ `SELECT COUNT(*) AS n, MAX(granted_at) AS m FROM __lattice_row_grants`
1093
+ );
1094
+ grants = digestOf(g6?.n, g6?.m);
1095
+ } catch {
1096
+ grants = null;
1097
+ }
1098
+ return { grants, owners };
1099
+ }
1100
+ function digestOf(count, max) {
1101
+ const n3 = padNumericMark(count);
1102
+ if (n3 == null) return null;
1103
+ const m4 = markToString(max) ?? "";
1104
+ return `${n3}#${m4}`;
1105
+ }
1106
+ async function computeRenderCursor(adapter) {
1107
+ try {
1108
+ const [changelog, sharing] = await Promise.all([changelogMark(adapter), sharingMarks(adapter)]);
1109
+ return { changelog, grants: sharing.grants, owners: sharing.owners };
1110
+ } catch {
1111
+ return { ...EMPTY_CURSOR };
1112
+ }
1113
+ }
1114
+ function cursorIsFresh(recorded, live, templateVersion = TEMPLATE_VERSION) {
1115
+ if (recorded == null) return false;
1116
+ if (recorded.templateVersion !== templateVersion) return false;
1117
+ const rc = recorded.cursor;
1118
+ if (rc == null) return false;
1119
+ if (!fieldFresh(rc.changelog, live.changelog, (r6, l4) => l4 <= r6)) return false;
1120
+ if (!fieldFresh(rc.grants, live.grants, (r6, l4) => l4 === r6)) return false;
1121
+ if (!fieldFresh(rc.owners, live.owners, (r6, l4) => l4 === r6)) return false;
1122
+ return true;
1123
+ }
1124
+ function fieldFresh(recorded, live, ok) {
1125
+ if (recorded == null && live == null) return true;
1126
+ if (recorded == null || live == null) return false;
1127
+ return ok(recorded, live);
1128
+ }
1129
+ var EMPTY_CURSOR;
1130
+ var init_render_cursor = __esm({
1131
+ "src/lifecycle/render-cursor.ts"() {
1132
+ "use strict";
1133
+ init_adapter();
1134
+ init_manifest();
1135
+ EMPTY_CURSOR = { changelog: null, grants: null, owners: null };
1136
+ }
1137
+ });
1138
+
1000
1139
  // src/db/sqlite.ts
1001
1140
  import Database from "better-sqlite3";
1002
1141
  var SQLiteAdapter;
@@ -3035,7 +3174,18 @@ var init_concurrency = __esm({
3035
3174
  // src/render/engine.ts
3036
3175
  import { join as join7, basename, isAbsolute, resolve as resolve3, sep } from "path";
3037
3176
  import { mkdirSync as mkdirSync5, existsSync as existsSync7, copyFileSync as copyFileSync2 } from "fs";
3038
- var YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
3177
+ function entityContentChanged(fresh, prior) {
3178
+ const freshKeys = Object.keys(fresh);
3179
+ const priorKeys = Object.keys(prior);
3180
+ if (freshKeys.length !== priorKeys.length) return true;
3181
+ for (const k6 of freshKeys) {
3182
+ const p3 = prior[k6];
3183
+ if (p3 == null) return true;
3184
+ if (p3.hash === "" || p3.hash !== fresh[k6]?.hash) return true;
3185
+ }
3186
+ return false;
3187
+ }
3188
+ var DeferredTableProgress, YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
3039
3189
  var init_engine = __esm({
3040
3190
  "src/render/engine.ts"() {
3041
3191
  "use strict";
@@ -3045,9 +3195,44 @@ var init_engine = __esm({
3045
3195
  init_entity_query();
3046
3196
  init_entity_templates();
3047
3197
  init_manifest();
3198
+ init_render_cursor();
3048
3199
  init_cleanup();
3049
3200
  init_progress();
3050
3201
  init_concurrency();
3202
+ DeferredTableProgress = class {
3203
+ constructor(throttle) {
3204
+ this.throttle = throttle;
3205
+ }
3206
+ changed = false;
3207
+ pendingStart = null;
3208
+ /** Buffer the `table-start` event; emitted only if/when the table changes. */
3209
+ start(event) {
3210
+ if (this.changed) {
3211
+ this.throttle.force(event);
3212
+ return;
3213
+ }
3214
+ this.pendingStart = event;
3215
+ }
3216
+ /** Mark that an entity's content changed — flush the held `table-start` once. */
3217
+ markChanged() {
3218
+ if (this.changed) return;
3219
+ this.changed = true;
3220
+ if (this.pendingStart) {
3221
+ this.throttle.force(this.pendingStart);
3222
+ this.pendingStart = null;
3223
+ }
3224
+ }
3225
+ /** Coalesced per-entity progress — dropped entirely until the table changed. */
3226
+ tick(event) {
3227
+ if (!this.changed) return;
3228
+ this.throttle.tick(event);
3229
+ }
3230
+ /** Lifecycle event (`table-done`) — emitted only if the table changed. */
3231
+ force(event) {
3232
+ if (!this.changed) return;
3233
+ this.throttle.force(event);
3234
+ }
3235
+ };
3051
3236
  YIELD_EVERY_ENTITIES = 200;
3052
3237
  RENDER_TABLE_CONCURRENCY = 4;
3053
3238
  NOOP_RENDER = () => "";
@@ -3164,20 +3349,23 @@ var init_engine = __esm({
3164
3349
  }
3165
3350
  const content = def.tokenBudget ? applyTokenBudget(rows, def.render, def.tokenBudget, def.prioritizeBy) : def.render(rows);
3166
3351
  const filePath = join7(outputDir, def.outputFile);
3167
- if (atomicWrite(filePath, content)) {
3352
+ const wrote = atomicWrite(filePath, content);
3353
+ if (wrote) {
3168
3354
  filesWritten.push(filePath);
3169
3355
  } else {
3170
3356
  counters.skipped++;
3171
3357
  }
3172
- throttle.force({
3173
- kind: "table-done",
3174
- table: name,
3175
- entitiesRendered: rows.length,
3176
- entitiesTotal: rows.length,
3177
- tableIndex: 0,
3178
- tableCount: 0,
3179
- pct: 100
3180
- });
3358
+ if (wrote) {
3359
+ throttle.force({
3360
+ kind: "table-done",
3361
+ table: name,
3362
+ entitiesRendered: rows.length,
3363
+ entitiesTotal: rows.length,
3364
+ tableIndex: 0,
3365
+ tableCount: 0,
3366
+ pct: 100
3367
+ });
3368
+ }
3181
3369
  }
3182
3370
  for (const [name, def] of this._schema.getMultis()) {
3183
3371
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
@@ -3191,32 +3379,38 @@ var init_engine = __esm({
3191
3379
  tables[t8] = await this._schema.queryTable(this._adapter, t8, this._readRel);
3192
3380
  }
3193
3381
  }
3382
+ let wroteAny = false;
3194
3383
  for (const key of keys) {
3195
3384
  const content = def.render(key, tables);
3196
3385
  const filePath = join7(outputDir, def.outputFile(key));
3197
3386
  if (atomicWrite(filePath, content)) {
3198
3387
  filesWritten.push(filePath);
3388
+ wroteAny = true;
3199
3389
  } else {
3200
3390
  counters.skipped++;
3201
3391
  }
3202
3392
  }
3203
- throttle.force({
3204
- kind: "table-done",
3205
- table: name,
3206
- entitiesRendered: keys.length,
3207
- entitiesTotal: keys.length,
3208
- tableIndex: 0,
3209
- tableCount: 0,
3210
- pct: 100
3211
- });
3393
+ if (wroteAny) {
3394
+ throttle.force({
3395
+ kind: "table-done",
3396
+ table: name,
3397
+ entitiesRendered: keys.length,
3398
+ entitiesTotal: keys.length,
3399
+ tableIndex: 0,
3400
+ tableCount: 0,
3401
+ pct: 100
3402
+ });
3403
+ }
3212
3404
  }
3405
+ const priorManifest = readManifest(outputDir);
3213
3406
  const entityContextManifest = await this._renderEntityContexts(
3214
3407
  outputDir,
3215
3408
  filesWritten,
3216
3409
  counters,
3217
3410
  throttle,
3218
3411
  signal,
3219
- opts.changedTables
3412
+ opts.changedTables,
3413
+ priorManifest
3220
3414
  );
3221
3415
  if (entityContextManifest === null) {
3222
3416
  return this._abortedResult(filesWritten, counters, start);
@@ -3227,10 +3421,13 @@ var init_engine = __esm({
3227
3421
  const prev = readManifest(outputDir);
3228
3422
  entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
3229
3423
  }
3424
+ const cursor = await computeRenderCursor(this._adapter);
3230
3425
  writeManifest(outputDir, {
3231
3426
  version: 2,
3232
3427
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3233
- entityContexts
3428
+ entityContexts,
3429
+ templateVersion: TEMPLATE_VERSION,
3430
+ cursor
3234
3431
  });
3235
3432
  }
3236
3433
  const result = {
@@ -3296,7 +3493,7 @@ var init_engine = __esm({
3296
3493
  * partial tree). Progress is reported through `throttle`; abort is observed
3297
3494
  * via `signal`.
3298
3495
  */
3299
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
3496
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables, priorManifest) {
3300
3497
  const protectedTables = /* @__PURE__ */ new Set();
3301
3498
  for (const [t8, d6] of this._schema.getEntityContexts()) {
3302
3499
  if (d6.protected) protectedTables.add(t8);
@@ -3315,8 +3512,10 @@ var init_engine = __esm({
3315
3512
  const baseRows = await this._schema.queryTable(this._adapter, table, this._readRel);
3316
3513
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
3317
3514
  const directoryRoot = def.directoryRoot ?? table;
3515
+ const deferred = new DeferredTableProgress(throttle);
3516
+ const priorEntities = priorManifest?.entityContexts[table]?.entities ?? {};
3318
3517
  const entitiesTotal = allRows.length;
3319
- throttle.force({
3518
+ deferred.start({
3320
3519
  kind: "table-start",
3321
3520
  table,
3322
3521
  entitiesRendered: 0,
@@ -3325,6 +3524,7 @@ var init_engine = __esm({
3325
3524
  tableCount,
3326
3525
  pct: 0
3327
3526
  });
3527
+ if (Object.keys(priorEntities).length !== entitiesTotal) deferred.markChanged();
3328
3528
  const manifestEntry = {
3329
3529
  directoryRoot,
3330
3530
  ...def.index ? { indexFile: def.index.outputFile } : {},
@@ -3440,8 +3640,10 @@ var init_engine = __esm({
3440
3640
  }
3441
3641
  }
3442
3642
  manifestEntry.entities[slug] = entityFileHashes;
3643
+ const priorHashes = normalizeEntityFiles(priorEntities[slug] ?? {});
3644
+ if (entityContentChanged(entityFileHashes, priorHashes)) deferred.markChanged();
3443
3645
  const entitiesRendered = i6 + 1;
3444
- throttle.tick({
3646
+ deferred.tick({
3445
3647
  kind: "table-progress",
3446
3648
  table,
3447
3649
  entitiesRendered,
@@ -3451,7 +3653,7 @@ var init_engine = __esm({
3451
3653
  pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
3452
3654
  });
3453
3655
  }
3454
- throttle.force({
3656
+ deferred.force({
3455
3657
  kind: "table-done",
3456
3658
  table,
3457
3659
  entitiesRendered: entitiesTotal,
@@ -5085,6 +5287,7 @@ var init_lattice = __esm({
5085
5287
  init_shred();
5086
5288
  init_encryption();
5087
5289
  init_manifest();
5290
+ init_render_cursor();
5088
5291
  init_adapter();
5089
5292
  init_sqlite();
5090
5293
  init_postgres();
@@ -5155,6 +5358,14 @@ var init_lattice = __esm({
5155
5358
  _changelogTables = /* @__PURE__ */ new Set();
5156
5359
  /** Current task context string for relevance filtering. */
5157
5360
  _taskContext = "";
5361
+ /**
5362
+ * True when this connection opened against an already-provisioned cloud as a
5363
+ * SCOPED MEMBER (no role-management privilege → no CREATE/ALTER on the schema).
5364
+ * Set during init() by the same probe that decides introspect-only. Drives
5365
+ * {@link addColumn} to route DDL through the owner-side `lattice_member_add_column`
5366
+ * SECURITY DEFINER helper instead of issuing a raw ALTER the member can't run.
5367
+ */
5368
+ _cloudMemberOpen = false;
5158
5369
  _auditHandlers = [];
5159
5370
  _renderHandlers = [];
5160
5371
  _writebackHandlers = [];
@@ -5401,7 +5612,7 @@ var init_lattice = __esm({
5401
5612
  /** Async tail of init(). See {@link init} for the sync-validation phase. */
5402
5613
  async _initAsync(options) {
5403
5614
  let introspectOnly = options.introspectOnly === true;
5404
- if (!introspectOnly && this.getDialect() === "postgres") {
5615
+ if (this.getDialect() === "postgres") {
5405
5616
  try {
5406
5617
  const [marker, role] = await Promise.all([
5407
5618
  getAsyncOrSync(this._adapter, `SELECT to_regclass('__lattice_owners') AS reg`),
@@ -5412,7 +5623,9 @@ var init_lattice = __esm({
5412
5623
  ]);
5413
5624
  const provisioned = !!marker && marker.reg != null;
5414
5625
  const canCreateRoles = !!role && role.rolcreaterole === true;
5415
- introspectOnly = provisioned && !canCreateRoles;
5626
+ const memberOpen = provisioned && !canCreateRoles;
5627
+ introspectOnly = introspectOnly || memberOpen;
5628
+ this._cloudMemberOpen = memberOpen;
5416
5629
  } catch {
5417
5630
  }
5418
5631
  }
@@ -5500,6 +5713,26 @@ var init_lattice = __esm({
5500
5713
  getDialect() {
5501
5714
  return this._adapter.dialect;
5502
5715
  }
5716
+ /**
5717
+ * True when a table opts into the observation/changelog substrate
5718
+ * (`def.changelog`). Callers that want to bypass the high-level {@link delete}
5719
+ * with a transaction-scoped raw delete use this to know whether the table also
5720
+ * needs the changelog / write-hook / embedding side effects that only
5721
+ * `delete()` performs — so they can keep the high-level path for such tables.
5722
+ */
5723
+ isChangelogTracked(table) {
5724
+ return this._changelogTables.has(table);
5725
+ }
5726
+ /**
5727
+ * True when this connection opened as a scoped cloud MEMBER (see
5728
+ * {@link _cloudMemberOpen}). Callers use it to route DDL-bearing work through
5729
+ * the owner-side SECURITY DEFINER helpers rather than issuing DDL the member's
5730
+ * role can't run (e.g. {@link addColumn} regenerates the masking view inside
5731
+ * `lattice_member_add_column`, so the caller must not also try to regenerate it).
5732
+ */
5733
+ isCloudMemberOpen() {
5734
+ return this._cloudMemberOpen;
5735
+ }
5503
5736
  /**
5504
5737
  * Return the normalised primary-key column list for a registered
5505
5738
  * table. Falls back to `['id']` for tables registered via raw DDL
@@ -5576,7 +5809,15 @@ var init_lattice = __esm({
5576
5809
  assertSafeIdentifier(column, "column");
5577
5810
  const existing = await introspectColumnsAsyncOrSync(this._adapter, table);
5578
5811
  if (!existing.includes(column)) {
5579
- await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5812
+ if (this._cloudMemberOpen) {
5813
+ await runAsyncOrSync(this._adapter, `SELECT lattice_member_add_column(?, ?, ?)`, [
5814
+ table,
5815
+ column,
5816
+ typeSpec
5817
+ ]);
5818
+ } else {
5819
+ await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5820
+ }
5580
5821
  }
5581
5822
  const cols = await introspectColumnsAsyncOrSync(this._adapter, table);
5582
5823
  this._columnCache.set(table, new Set(cols));
@@ -6508,12 +6749,39 @@ var init_lattice = __esm({
6508
6749
  async renderInBackground(outputDir, opts = {}) {
6509
6750
  const notInit = this._notInitError();
6510
6751
  if (notInit) return notInit;
6752
+ if (opts.gateOnOpen && !opts.changedTables) {
6753
+ const start = Date.now();
6754
+ const recorded = readManifest(outputDir);
6755
+ if (recorded != null) {
6756
+ const live = await computeRenderCursor(this._adapter);
6757
+ if (cursorIsFresh(recorded, live)) {
6758
+ opts.onProgress?.({
6759
+ kind: "done",
6760
+ table: null,
6761
+ entitiesRendered: 0,
6762
+ entitiesTotal: 0,
6763
+ tableIndex: 0,
6764
+ tableCount: 0,
6765
+ pct: 100,
6766
+ durationMs: Date.now() - start
6767
+ });
6768
+ const skipped = {
6769
+ filesWritten: [],
6770
+ filesSkipped: 0,
6771
+ durationMs: Date.now() - start
6772
+ };
6773
+ for (const h6 of this._renderHandlers) h6(skipped);
6774
+ return skipped;
6775
+ }
6776
+ }
6777
+ }
6511
6778
  if (!opts.changedTables) {
6512
6779
  this._pendingRenderAll = false;
6513
6780
  this._pendingRenderTables = /* @__PURE__ */ new Set();
6514
6781
  this._autoRenderPending = false;
6515
6782
  }
6516
- return this._renderGuarded(outputDir, opts);
6783
+ const { gateOnOpen: _gateOnOpen, ...engineOpts } = opts;
6784
+ return this._renderGuarded(outputDir, engineOpts);
6517
6785
  }
6518
6786
  /**
6519
6787
  * Install a per-viewer read-relation resolver for ALL renders (initial,
@@ -8623,6 +8891,111 @@ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
8623
8891
  AND g."pk" = ANY(p_pks)
8624
8892
  AND o."owner_role" = session_user;
8625
8893
  $fn$;
8894
+
8895
+ -- Add a column to a user table AS THE OWNER, on behalf of a scoped member. A
8896
+ -- member's role has no CREATE/ALTER on the schema (the bootstrap REVOKEs CREATE
8897
+ -- from PUBLIC), so a member's GUI "add a field" write (createRow/updateRow with a
8898
+ -- field the table lacks) cannot run ALTER TABLE itself. This SECURITY DEFINER
8899
+ -- helper performs that ALTER \u2014 and the masking-view regen \u2014 with the owner's
8900
+ -- rights, so member-added columns behave identically to owner-added ones.
8901
+ --
8902
+ -- Injection-safe + minimal: p_table must be an existing BASE table in the current
8903
+ -- schema (rejected otherwise); p_type is whitelisted against the exact set the
8904
+ -- library's addColumn emits for an auto-added column (TEXT / INTEGER / REAL, plus
8905
+ -- BOOLEAN) \u2014 never interpolated raw; both identifiers go through %I (quote_ident).
8906
+ -- Member-callable (granted EXECUTE to the member group), but it can only widen the
8907
+ -- schema, never read or alter another member's data.
8908
+ CREATE OR REPLACE FUNCTION lattice_member_add_column(p_table text, p_column text, p_type text)
8909
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
8910
+ DECLARE
8911
+ v_type text;
8912
+ v_view text := p_table || '_v';
8913
+ v_has_view boolean;
8914
+ v_pk_expr text;
8915
+ v_select text;
8916
+ BEGIN
8917
+ -- Never alter internal bookkeeping tables (names start with "_"). The GUI only
8918
+ -- ever calls this for a user entity table; rejecting the rest is defense-in-depth
8919
+ -- against a member invoking the function directly against ownership/audit/policy
8920
+ -- tables.
8921
+ IF left(p_table, 1) = '_' THEN
8922
+ RAISE EXCEPTION 'lattice: cannot add a column to internal table "%"', p_table;
8923
+ END IF;
8924
+
8925
+ -- p_table must be a real base table in THIS schema (search_path is pinned to the
8926
+ -- cloud schema by pinDefinerSearchPath, so to_regclass resolves there).
8927
+ IF NOT EXISTS (
8928
+ SELECT 1 FROM pg_class c
8929
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8930
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND c.relkind = 'r'
8931
+ ) THEN
8932
+ RAISE EXCEPTION 'lattice: no such table "%"', p_table;
8933
+ END IF;
8934
+
8935
+ -- Whitelist the column type. These are exactly the specs addColumn's
8936
+ -- inferColumnType produces (TEXT / INTEGER / REAL); BOOLEAN is allowed too.
8937
+ -- Anything else is rejected \u2014 the type is spliced as %s (NOT %I), so it must be
8938
+ -- a known-safe literal and never caller-controlled SQL.
8939
+ v_type := upper(btrim(p_type));
8940
+ IF v_type NOT IN ('TEXT', 'INTEGER', 'REAL', 'BOOLEAN') THEN
8941
+ RAISE EXCEPTION 'lattice: unsupported column type "%"', p_type;
8942
+ END IF;
8943
+
8944
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS %I %s', p_table, p_column, v_type);
8945
+
8946
+ -- If the table is cell-masked (a "<table>_v" view exists, because some column has
8947
+ -- an audience), the view selects an explicit column list \u2014 so a new column is
8948
+ -- invisible to members until the view is regenerated. Rebuild it the same way the
8949
+ -- owner path (audienceViewSql / regenerateAudienceViewFromDb) does: pass every
8950
+ -- column through except those with an 'owner' audience in __lattice_column_policy
8951
+ -- (CASE WHEN lattice_is_owner(...) THEN col END), re-apply row visibility with
8952
+ -- WHERE lattice_row_visible(table, pk), and keep the member SELECT grant on the
8953
+ -- view. Unmasked tables need no regen \u2014 the member group's table-level base grant
8954
+ -- already covers the new column.
8955
+ SELECT EXISTS (
8956
+ SELECT 1 FROM pg_class c
8957
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8958
+ WHERE n.nspname = current_schema() AND c.relname = v_view AND c.relkind = 'v'
8959
+ ) INTO v_has_view;
8960
+
8961
+ IF v_has_view THEN
8962
+ -- Canonical pk expression: CAST("col" AS TEXT) joined by TAB (chr(9)) \u2014 the
8963
+ -- same serialization the RLS policies + audienceViewSql use.
8964
+ SELECT string_agg(format('CAST(%I AS TEXT)', a.attname), ' || chr(9) || '
8965
+ ORDER BY array_position(i.indkey, a.attnum))
8966
+ INTO v_pk_expr
8967
+ FROM pg_index i
8968
+ JOIN pg_class c ON c.oid = i.indrelid
8969
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8970
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
8971
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND i.indisprimary;
8972
+ IF v_pk_expr IS NULL THEN
8973
+ RAISE EXCEPTION 'lattice: cannot regenerate mask view for "%": no primary key', p_table;
8974
+ END IF;
8975
+
8976
+ -- Build the masked SELECT list in column order, applying the per-column policy.
8977
+ SELECT string_agg(
8978
+ CASE
8979
+ WHEN cp."audience" = 'owner'
8980
+ THEN format('CASE WHEN lattice_is_owner(%L, %s) THEN %I END AS %I',
8981
+ p_table, v_pk_expr, cols.column_name, cols.column_name)
8982
+ ELSE format('%I', cols.column_name)
8983
+ END,
8984
+ ', ' ORDER BY cols.ordinal_position)
8985
+ INTO v_select
8986
+ FROM information_schema.columns cols
8987
+ LEFT JOIN "__lattice_column_policy" cp
8988
+ ON cp."table_name" = p_table AND cp."column_name" = cols.column_name
8989
+ AND cp."audience" NOT IN ('', 'everyone', 'row-audience')
8990
+ WHERE cols.table_schema = current_schema() AND cols.table_name = p_table;
8991
+
8992
+ EXECUTE format(
8993
+ 'CREATE OR REPLACE VIEW %I AS SELECT %s FROM %I WHERE lattice_row_visible(%L, %s)',
8994
+ v_view, v_select, p_table, p_table, v_pk_expr);
8995
+ EXECUTE format('GRANT SELECT ON %I TO ${MEMBER_GROUP}', v_view);
8996
+ END IF;
8997
+ END $fn$;
8998
+ GRANT EXECUTE ON FUNCTION lattice_member_add_column(text, text, text) TO ${MEMBER_GROUP};
8626
8999
  `;
8627
9000
  }
8628
9001
  });
@@ -8796,18 +9169,9 @@ function sessionUndoneFilters(undone, sessionId) {
8796
9169
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
8797
9170
  return filters;
8798
9171
  }
8799
- async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
8800
- const undone = await db.query("_lattice_gui_audit", {
8801
- filters: sessionUndoneFilters(1, sessionId)
8802
- });
8803
- for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
8804
- await db.insert("_lattice_gui_audit", {
9172
+ function buildAuditRow(table, rowId, op, before, after, sessionId, editTs) {
9173
+ return {
8805
9174
  id: crypto.randomUUID(),
8806
- // Set ts explicitly (don't rely on the column DEFAULT — it uses the
8807
- // SQLite-only `strftime(...)`, which doesn't yield a parseable ISO string
8808
- // on Postgres, so cloud history rendered "Invalid Date"). #4.6 — honor the
8809
- // originating client's validated edit time when present (an offline edit
8810
- // replayed later records when it was MADE, not when it synced), else now().
8811
9175
  ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
8812
9176
  table_name: table,
8813
9177
  row_id: rowId,
@@ -8816,7 +9180,9 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
8816
9180
  after_json: after ? JSON.stringify(after) : null,
8817
9181
  undone: 0,
8818
9182
  session_id: sessionId ?? null
8819
- });
9183
+ };
9184
+ }
9185
+ function publishMutationFeed(feed, table, rowId, op, before, after, source) {
8820
9186
  const labelRow = op === "delete" ? before : after;
8821
9187
  feed.publish({
8822
9188
  table,
@@ -8826,17 +9192,28 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
8826
9192
  summary: feedSummary(op, table, labelRow)
8827
9193
  });
8828
9194
  }
8829
- function isSchemaOp(operation2) {
8830
- return operation2.startsWith(SCHEMA_OP_PREFIX);
8831
- }
8832
- async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
9195
+ async function purgeRedoStack(db, sessionId) {
8833
9196
  const undone = await db.query("_lattice_gui_audit", {
8834
9197
  filters: sessionUndoneFilters(1, sessionId)
8835
9198
  });
8836
9199
  for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
9200
+ }
9201
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
9202
+ await purgeRedoStack(db, sessionId);
9203
+ await db.insert(
9204
+ "_lattice_gui_audit",
9205
+ buildAuditRow(table, rowId, op, before, after, sessionId, editTs)
9206
+ );
9207
+ publishMutationFeed(feed, table, rowId, op, before, after, source);
9208
+ }
9209
+ function isSchemaOp(operation2) {
9210
+ return operation2.startsWith(SCHEMA_OP_PREFIX);
9211
+ }
9212
+ async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
9213
+ await purgeRedoStack(db, sessionId);
8837
9214
  await db.insert("_lattice_gui_audit", {
8838
9215
  id: crypto.randomUUID(),
8839
- // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
9216
+ // Explicit ISO ts — see buildAuditRow (the SQLite-only strftime DEFAULT
8840
9217
  // rendered "Invalid Date" on the Postgres/cloud path).
8841
9218
  ts: (/* @__PURE__ */ new Date()).toISOString(),
8842
9219
  table_name: table,
@@ -8871,7 +9248,7 @@ async function ensureColumns(db, table, values) {
8871
9248
  const added = Object.keys(values).filter((k6) => !(k6 in existing));
8872
9249
  if (added.length === 0) return [];
8873
9250
  for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
8874
- if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
9251
+ if (!db.isCloudMemberOpen() && db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
8875
9252
  const cols = db.getRegisteredColumns(table);
8876
9253
  const pk = db.getPrimaryKey(table);
8877
9254
  if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
@@ -8993,7 +9370,14 @@ async function deleteRow(ctx, table, id, hard) {
8993
9370
  ctx.clientTs
8994
9371
  );
8995
9372
  } else {
8996
- await ctx.db.delete(table, id);
9373
+ await hardDelete(ctx, table, id, before);
9374
+ }
9375
+ }
9376
+ async function hardDelete(ctx, table, id, before) {
9377
+ const withClient = ctx.db.adapter.withClient?.bind(ctx.db.adapter);
9378
+ const pkCols = ctx.db.getPrimaryKey(table);
9379
+ const pkCol = pkCols.length === 1 ? pkCols[0] : void 0;
9380
+ if (!withClient || ctx.db.isChangelogTracked(table) || pkCol === void 0) {
8997
9381
  await appendAudit(
8998
9382
  ctx.db,
8999
9383
  ctx.feed,
@@ -9006,10 +9390,30 @@ async function deleteRow(ctx, table, id, hard) {
9006
9390
  ctx.sessionId,
9007
9391
  ctx.clientTs
9008
9392
  );
9393
+ await ctx.db.delete(table, id);
9394
+ return;
9009
9395
  }
9396
+ const auditRow = buildAuditRow(table, id, "delete", before, null, ctx.sessionId, ctx.clientTs);
9397
+ await purgeRedoStack(ctx.db, ctx.sessionId);
9398
+ const auditCols = AUDIT_COLUMNS.map((c6) => `"${c6}"`).join(", ");
9399
+ const auditPlaceholders = AUDIT_COLUMNS.map(() => "?").join(", ");
9400
+ const auditValues = AUDIT_COLUMNS.map((c6) => auditRow[c6]);
9401
+ const pkColQuoted = pkCol.replace(/"/g, '""');
9402
+ await withClient(async (tx) => {
9403
+ await tx.run(
9404
+ `INSERT INTO "_lattice_gui_audit" (${auditCols}) VALUES (${auditPlaceholders})`,
9405
+ auditValues
9406
+ );
9407
+ await tx.run(`DELETE FROM "${table.replace(/"/g, '""')}" WHERE "${pkColQuoted}" = ?`, [id]);
9408
+ });
9409
+ publishMutationFeed(ctx.feed, table, id, "delete", before, null, ctx.source);
9010
9410
  }
9011
- async function linkRows(ctx, table, body) {
9012
- await ctx.db.link(table, body);
9411
+ async function linkRows(ctx, table, body, forceVisibility) {
9412
+ if (forceVisibility !== void 0) {
9413
+ await ctx.db.insertForcingVisibility(table, body, forceVisibility);
9414
+ } else {
9415
+ await ctx.db.link(table, body);
9416
+ }
9013
9417
  await appendAudit(ctx.db, ctx.feed, table, null, "link", null, body, ctx.source, ctx.sessionId);
9014
9418
  }
9015
9419
  async function unlinkRows(ctx, table, body) {
@@ -9147,12 +9551,23 @@ async function revertEntry(ctx, id) {
9147
9551
  });
9148
9552
  return { ok: true, entry };
9149
9553
  }
9150
- var SCHEMA_OP_PREFIX;
9554
+ var AUDIT_COLUMNS, SCHEMA_OP_PREFIX;
9151
9555
  var init_mutations = __esm({
9152
9556
  "src/gui/mutations.ts"() {
9153
9557
  "use strict";
9154
9558
  init_cloud_connect();
9155
9559
  init_audience();
9560
+ AUDIT_COLUMNS = [
9561
+ "id",
9562
+ "ts",
9563
+ "table_name",
9564
+ "row_id",
9565
+ "operation",
9566
+ "before_json",
9567
+ "after_json",
9568
+ "undone",
9569
+ "session_id"
9570
+ ];
9156
9571
  SCHEMA_OP_PREFIX = "schema.";
9157
9572
  }
9158
9573
  });
@@ -9439,6 +9854,10 @@ async function readMachineCredential(db, kind) {
9439
9854
  }
9440
9855
  return null;
9441
9856
  }
9857
+ async function resolveAnthropicKey(db) {
9858
+ if (isAssistantCredentialCleared(CREDENTIALS.anthropic.kind)) return null;
9859
+ return await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
9860
+ }
9442
9861
  function getAggressiveness() {
9443
9862
  const n3 = readPreferences().aggressiveness;
9444
9863
  if (!Number.isFinite(n3)) return DEFAULT_AGGRESSIVENESS;
@@ -9469,6 +9888,7 @@ async function getVoiceCredential(db) {
9469
9888
  return null;
9470
9889
  }
9471
9890
  async function hasCredential(db, name, envVar) {
9891
+ if (isAssistantCredentialCleared(CREDENTIALS[name].kind)) return false;
9472
9892
  return Boolean(await readMachineCredential(db, CREDENTIALS[name].kind)) || Boolean(process.env[envVar]);
9473
9893
  }
9474
9894
  async function resolveClaudeAuth(db) {
@@ -9491,7 +9911,7 @@ async function resolveClaudeAuth(db) {
9491
9911
  } catch {
9492
9912
  }
9493
9913
  }
9494
- const apiKey = await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
9914
+ const apiKey = await resolveAnthropicKey(db);
9495
9915
  return apiKey ? { apiKey } : null;
9496
9916
  }
9497
9917
  async function hasClaudeAuth(db) {
@@ -9588,6 +10008,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
9588
10008
  }
9589
10009
  const cred = CREDENTIALS[name];
9590
10010
  setAssistantCredential(cred.kind, key);
10011
+ clearAssistantCredentialCleared(cred.kind);
9591
10012
  if (db) {
9592
10013
  for (const row of await liveSecretsOfKind(db, cred.kind)) {
9593
10014
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -9604,6 +10025,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
9604
10025
  return true;
9605
10026
  }
9606
10027
  deleteAssistantCredential(CREDENTIALS[name].kind);
10028
+ setAssistantCredentialCleared(CREDENTIALS[name].kind);
9607
10029
  if (db) {
9608
10030
  for (const row of await liveSecretsOfKind(db, CREDENTIALS[name].kind)) {
9609
10031
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -10810,6 +11232,11 @@ async function revokeRow(db, table, pk, grantee) {
10810
11232
  assertPg(db);
10811
11233
  await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_row(?, ?, ?)`, [table, pk, grantee]);
10812
11234
  }
11235
+ async function batchRowGrants(db, table, pk, grant, revoke) {
11236
+ assertPg(db);
11237
+ for (const grantee of grant) await grantRow(db, table, pk, grantee);
11238
+ for (const grantee of revoke) await revokeRow(db, table, pk, grantee);
11239
+ }
10813
11240
  async function revokeMemberRole(db, role) {
10814
11241
  assertPg(db);
10815
11242
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
@@ -12053,7 +12480,7 @@ function buildSchema(db) {
12053
12480
  }
12054
12481
  return out;
12055
12482
  }
12056
- async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false) {
12483
+ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false, privateMode = false) {
12057
12484
  if (!text.trim()) return [];
12058
12485
  const auth = await resolveClaudeAuth(db);
12059
12486
  if (!auth) {
@@ -12075,6 +12502,7 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12075
12502
  });
12076
12503
  return [];
12077
12504
  }
12505
+ const forceVis = privateMode ? "private" : void 0;
12078
12506
  const temperature = aggressivenessToTemperature(aggressiveness);
12079
12507
  let description = "";
12080
12508
  try {
@@ -12117,11 +12545,16 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12117
12545
  }
12118
12546
  if (jx) {
12119
12547
  try {
12120
- await linkRows(mctx, jx.junction, {
12121
- id: crypto.randomUUID(),
12122
- [jx.fileFk]: fileId,
12123
- [jx.otherFk]: m4.id
12124
- });
12548
+ await linkRows(
12549
+ mctx,
12550
+ jx.junction,
12551
+ {
12552
+ id: crypto.randomUUID(),
12553
+ [jx.fileFk]: fileId,
12554
+ [jx.otherFk]: m4.id
12555
+ },
12556
+ forceVis
12557
+ );
12125
12558
  linkedCount++;
12126
12559
  if (created) {
12127
12560
  mctx.feed.publish({
@@ -12180,16 +12613,21 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12180
12613
  if ("name" in cols && row.name == null) row.name = obj2.label;
12181
12614
  if ("title" in cols && row.title == null) row.title = obj2.label;
12182
12615
  try {
12183
- const { id: rowId } = await createRow(mctx, entity, row);
12616
+ const { id: rowId } = await createRow(mctx, entity, row, forceVis);
12184
12617
  createdCount++;
12185
12618
  const ent = entity;
12186
12619
  const jx = junctions.find((j6) => j6.otherTable === ent) ?? (createJunction ? await createJunction(ent) : null);
12187
12620
  if (jx) {
12188
- await linkRows(mctx, jx.junction, {
12189
- id: crypto.randomUUID(),
12190
- [jx.fileFk]: fileId,
12191
- [jx.otherFk]: rowId
12192
- });
12621
+ await linkRows(
12622
+ mctx,
12623
+ jx.junction,
12624
+ {
12625
+ id: crypto.randomUUID(),
12626
+ [jx.fileFk]: fileId,
12627
+ [jx.otherFk]: rowId
12628
+ },
12629
+ forceVis
12630
+ );
12193
12631
  }
12194
12632
  } catch (e6) {
12195
12633
  console.warn(`[ingest] create ${entity} from document failed:`, e6.message);
@@ -12203,12 +12641,17 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12203
12641
  try {
12204
12642
  const title = name.replace(/\.[^./\\]+$/, "").trim() || "Note";
12205
12643
  const body = description.length > 0 ? description : text.slice(0, 2e3);
12206
- const { id: noteId } = await createRow(mctx, "notes", {
12207
- id: crypto.randomUUID(),
12208
- title,
12209
- body,
12210
- source_file_id: fileId
12211
- });
12644
+ const { id: noteId } = await createRow(
12645
+ mctx,
12646
+ "notes",
12647
+ {
12648
+ id: crypto.randomUUID(),
12649
+ title,
12650
+ body,
12651
+ source_file_id: fileId
12652
+ },
12653
+ forceVis
12654
+ );
12212
12655
  mctx.feed.publish({
12213
12656
  table: "notes",
12214
12657
  op: "insert",
@@ -12694,7 +13137,8 @@ async function ingestUrlAsFile(ctx, rawUrl, opts = {}) {
12694
13137
  ctx.enrich.createJunction,
12695
13138
  ctx.enrich.aggressiveness,
12696
13139
  ctx.enrich.createEntity,
12697
- true
13140
+ true,
13141
+ ctx.privateMode === true
12698
13142
  );
12699
13143
  }
12700
13144
  return {
@@ -13573,13 +14017,22 @@ function loadSdk() {
13573
14017
  throw new Error("Could not resolve the Anthropic constructor from '@anthropic-ai/sdk'");
13574
14018
  return ctor;
13575
14019
  }
13576
- function createAnthropicClient(auth) {
13577
- const Anthropic = loadSdk();
14020
+ function buildAnthropicConfig(auth) {
13578
14021
  const config = {};
13579
- if (auth.authToken) config.authToken = auth.authToken;
13580
- else if (auth.apiKey) config.apiKey = auth.apiKey;
14022
+ if (auth.authToken) {
14023
+ config.authToken = auth.authToken;
14024
+ config.apiKey = null;
14025
+ } else if (auth.apiKey) {
14026
+ config.apiKey = auth.apiKey;
14027
+ } else {
14028
+ config.apiKey = null;
14029
+ }
13581
14030
  if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
13582
- const sdk = new Anthropic(config);
14031
+ return config;
14032
+ }
14033
+ function createAnthropicClient(auth) {
14034
+ const Anthropic = loadSdk();
14035
+ const sdk = new Anthropic(buildAnthropicConfig(auth));
13583
14036
  return {
13584
14037
  async runTurn(params) {
13585
14038
  const stream = sdk.messages.stream({
@@ -53145,7 +53598,7 @@ async function checkForUpdate(pkgName, currentVersion, opts = {}) {
53145
53598
  // src/update-context.ts
53146
53599
  init_user_config();
53147
53600
  import { execFileSync } from "child_process";
53148
- import { existsSync as existsSync14, lstatSync, readFileSync as readFileSync10 } from "fs";
53601
+ import { existsSync as existsSync14, lstatSync, readFileSync as readFileSync10, realpathSync } from "fs";
53149
53602
  import { dirname as dirname7, join as join13, sep as sep2 } from "path";
53150
53603
  var SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
53151
53604
  function isValidVersion(v2) {
@@ -53175,10 +53628,19 @@ function isUnderGlobalPrefix(packageRoot, execPath) {
53175
53628
  }
53176
53629
  function detectInstallContext(opts = {}) {
53177
53630
  const pkgName = opts.pkgName ?? "latticesql";
53178
- const cwd = opts.cwd ?? process.cwd();
53179
53631
  const env2 = opts.env ?? process.env;
53180
53632
  const execPath = opts.execPath ?? process.execPath;
53181
- const modulePath = opts.modulePath ?? process.argv[1] ?? cwd;
53633
+ const rawCwd = opts.cwd ?? process.cwd();
53634
+ const rawModulePath = opts.modulePath ?? process.argv[1] ?? rawCwd;
53635
+ const resolveReal = (p3) => {
53636
+ try {
53637
+ return realpathSync(p3);
53638
+ } catch {
53639
+ return p3;
53640
+ }
53641
+ };
53642
+ const modulePath = resolveReal(rawModulePath);
53643
+ const cwd = resolveReal(rawCwd);
53182
53644
  const packageRoot = findPackageRoot(dirname7(modulePath), pkgName);
53183
53645
  if (packageRoot && existsSync14(join13(packageRoot, ".git"))) {
53184
53646
  return {
@@ -54179,6 +54641,8 @@ var css = `
54179
54641
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
54180
54642
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
54181
54643
  .grants-panel .grants-row input { accent-color: var(--accent); }
54644
+ .grants-panel .grants-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
54645
+ .grants-panel .grants-dirty { font-size: 12px; }
54182
54646
 
54183
54647
  /* Inline create-row at the bottom of every table */
54184
54648
  tr.create-row td { background: var(--surface-2); }
@@ -54523,6 +54987,25 @@ var css = `
54523
54987
  animation: feedSpin 0.7s linear infinite; vertical-align: middle;
54524
54988
  }
54525
54989
  @keyframes feedSpin { to { transform: rotate(360deg); } }
54990
+ /* Batch-upload progress bar \u2014 pinned to the top of the feed while a
54991
+ multi-file drop drains through the bounded-concurrency queue. */
54992
+ .ingest-progress {
54993
+ position: sticky; top: 0; z-index: 3;
54994
+ display: flex; flex-direction: column; gap: 6px;
54995
+ padding: 8px 10px; border-radius: 8px;
54996
+ background: var(--surface); border: 1px solid rgba(190, 242, 100, 0.22);
54997
+ box-shadow: var(--shadow-1), var(--glow-accent-soft);
54998
+ }
54999
+ .ingest-progress-label { font-size: 12px; font-weight: 500; color: var(--text); }
55000
+ .ingest-progress-track {
55001
+ height: 6px; border-radius: 999px; overflow: hidden; background: var(--border-strong);
55002
+ }
55003
+ .ingest-progress-fill {
55004
+ height: 100%; width: 0%; border-radius: 999px;
55005
+ background: linear-gradient(90deg, var(--accent-deep), var(--accent));
55006
+ box-shadow: 0 0 8px rgba(190, 242, 100, 0.5);
55007
+ transition: width 0.3s ease;
55008
+ }
54526
55009
  .assistant-rail {
54527
55010
  position: relative;
54528
55011
  background:
@@ -56626,6 +57109,15 @@ var appJs = `
56626
57109
  // Per-table view state: 'live' (default) or 'trash' (soft-deleted rows).
56627
57110
  var tableViewMode = {};
56628
57111
 
57112
+ // The (table, pk) of the per-row "Manage access" grants panel that is
57113
+ // currently open, or null when none is. A soft re-render (a concurrent edit
57114
+ // by another client fires pg_notify \u2192 realtime refresh \u2192 renderRoute({soft})
57115
+ // \u2192 renderDetail/renderFsItem repaint) would otherwise re-create the detail
57116
+ // view with the panel collapsed, dropping a staged multi-select mid-edit.
57117
+ // wireRowSharing reads this after each repaint and re-opens + re-populates the
57118
+ // panel WITHOUT any network call, so the staged selection survives.
57119
+ var openGrantsPanel = null;
57120
+
56629
57121
  function renderTable(content, tableName) {
56630
57122
  var myGen = renderGen;
56631
57123
  clearUnseen(tableName);
@@ -57104,70 +57596,151 @@ var appJs = `
57104
57596
  }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
57105
57597
  });
57106
57598
  });
57107
- var detailVisManage = content.querySelector('#detail-vis-manage');
57108
- if (detailVisManage) detailVisManage.addEventListener('click', function () {
57599
+ var access = row._access || {};
57600
+
57601
+ // Render the staged member checklist + a single "Save sharing" / "Cancel"
57602
+ // into the panel. Checkbox toggles mutate ONLY the local desired map \u2014
57603
+ // NO network call per toggle (the old design auto-saved live, one POST per
57604
+ // checkbox, and each grant's pg_notify collapsed the panel). A single batch
57605
+ // request fires on Save. members is the already-fetched list; desired
57606
+ // seeds from the row's current grantees (or a caller-supplied staged map
57607
+ // when re-opening after a soft re-render).
57608
+ function populateGrantsPanel(panel, members, desired) {
57609
+ // Snapshot the CURRENT (committed) grantees so Save can diff desired-vs-
57610
+ // current into adds/removes. effectiveVisibility decides whether we're
57611
+ // actually switching INTO specific-people mode (custom-0 reads as private).
57612
+ var current = {};
57613
+ (access.grantees || []).forEach(function (g) { current[g] = true; });
57614
+ if (members.length === 0) {
57615
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
57616
+ panel.hidden = false;
57617
+ return;
57618
+ }
57619
+ function dirtyCount() {
57620
+ var n = 0;
57621
+ members.forEach(function (m) {
57622
+ if (!!desired[m.role] !== !!current[m.role]) n++;
57623
+ });
57624
+ return n;
57625
+ }
57626
+ function render() {
57627
+ var changed = dirtyCount();
57628
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' +
57629
+ members.map(function (m) {
57630
+ var label = m.name || m.email || m.role;
57631
+ return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
57632
+ (desired[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
57633
+ }).join('') +
57634
+ '<div class="grants-actions">' +
57635
+ '<button class="btn primary" id="grants-save"' + (changed ? '' : ' disabled') + '>Save sharing</button>' +
57636
+ '<button class="btn" id="grants-cancel">Cancel</button>' +
57637
+ '<span class="grants-dirty muted">' + (changed ? (changed === 1 ? '1 change' : changed + ' changes') : 'No changes') + '</span>' +
57638
+ '</div>';
57639
+ panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
57640
+ cb.addEventListener('change', function () {
57641
+ var role = cb.getAttribute('data-grant-role');
57642
+ if (cb.checked) desired[role] = true; else delete desired[role];
57643
+ render(); // re-render to refresh the dirty indicator + Save state
57644
+ });
57645
+ });
57646
+ var cancelBtn = panel.querySelector('#grants-cancel');
57647
+ if (cancelBtn) cancelBtn.addEventListener('click', function () { closeGrantsPanel(panel); });
57648
+ var saveBtn = panel.querySelector('#grants-save');
57649
+ if (saveBtn) saveBtn.addEventListener('click', function () {
57650
+ var toAdd = [];
57651
+ var toRemove = [];
57652
+ members.forEach(function (m) {
57653
+ var want = !!desired[m.role];
57654
+ var have = !!current[m.role];
57655
+ if (want && !have) toAdd.push(m.role);
57656
+ if (!want && have) toRemove.push(m.role);
57657
+ });
57658
+ if (toAdd.length === 0 && toRemove.length === 0) { closeGrantsPanel(panel); return; }
57659
+ // Confirm the mode change ONCE, here \u2014 only when actually switching
57660
+ // INTO specific-people mode (effective vis isn't already custom AND we
57661
+ // are adding at least one grantee). Never per checkbox.
57662
+ if (effectiveVisibility(access) !== 'custom' && toAdd.length > 0) {
57663
+ if (!confirm('Sharing this with specific people switches it off "everyone"/"private". The chosen people will be able to see it. Continue?')) return;
57664
+ }
57665
+ withBusy(saveBtn, function () {
57666
+ return fetchJson('/api/cloud/row-grants', {
57667
+ method: 'POST',
57668
+ headers: { 'content-type': 'application/json' },
57669
+ body: JSON.stringify({ table: tableName, pk: id, grant: toAdd, revoke: toRemove }),
57670
+ }).then(function () {
57671
+ // Mirror the committed state locally so the re-render's indicator
57672
+ // is correct. The first grant flips the row to custom server-side;
57673
+ // revoking the last leaves custom-0, which effectiveVisibility
57674
+ // renders as private.
57675
+ var list = [];
57676
+ members.forEach(function (m) { if (desired[m.role]) list.push(m.role); });
57677
+ access.grantees = list;
57678
+ if (list.length > 0) access.visibility = 'custom';
57679
+ openGrantsPanel = null; // a successful save closes the staging session
57680
+ invalidate(tableName);
57681
+ showToast('Sharing updated', {});
57682
+ reRender();
57683
+ }).catch(function (e) {
57684
+ // Surface loudly + leave the staged selection intact so the user
57685
+ // can retry; no silent partial-success.
57686
+ showToast('Sharing update failed: ' + e.message, {});
57687
+ });
57688
+ });
57689
+ });
57690
+ panel.hidden = false;
57691
+ }
57692
+ render();
57693
+ }
57694
+
57695
+ function closeGrantsPanel(panel) {
57696
+ if (panel) panel.hidden = true;
57697
+ openGrantsPanel = null;
57698
+ }
57699
+
57700
+ // Open (or toggle shut) the manage-access panel. Fetches the member list,
57701
+ // then stages from the row's current grantees. Opening must NOT pre-flip
57702
+ // the row to 'custom' \u2014 that left a never-shared row stuck at "custom (0)".
57703
+ function openManagePanel(triggerBtn) {
57109
57704
  var panel = content.querySelector('#grants-panel');
57110
57705
  if (!panel) return;
57111
- if (!panel.hidden) { panel.hidden = true; return; }
57112
- var access = row._access || {};
57113
- // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
57114
- // row the user never actually shared stuck at "custom (0)". The first
57115
- // grant flips it to custom server-side (lattice_grant_row); revoking the
57116
- // last leaves it custom-with-0-grantees, which now reads as private. So
57117
- // just load the member checklist.
57118
- var ensure = Promise.resolve();
57119
- withBusy(detailVisManage, function () {
57120
- return ensure.then(function () {
57121
- return fetchJson('/api/cloud/members');
57122
- }).then(function (d) {
57706
+ if (!panel.hidden) { closeGrantsPanel(panel); return; }
57707
+ withBusy(triggerBtn, function () {
57708
+ return fetchJson('/api/cloud/members').then(function (d) {
57123
57709
  // The grant target is a member ROLE: lattice_grant_row keys on the
57124
57710
  // role, and _access.grantees holds role names. List every member
57125
57711
  // except the owner (you don't grant the owner their own row).
57126
57712
  var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
57127
- var granted = {};
57128
- (access.grantees || []).forEach(function (g) { granted[g] = true; });
57129
- if (members.length === 0) {
57130
- panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
57131
- } else {
57132
- panel.innerHTML = '<div class="grants-title">Who can see this</div>' + members.map(function (m) {
57133
- var label = m.name || m.email || m.role;
57134
- return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
57135
- (granted[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
57136
- }).join('');
57137
- }
57138
- panel.hidden = false;
57139
- panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
57140
- cb.addEventListener('change', function () {
57141
- var role = cb.getAttribute('data-grant-role');
57142
- cb.disabled = true;
57143
- fetchJson('/api/cloud/row-grant', {
57144
- method: 'POST',
57145
- headers: { 'content-type': 'application/json' },
57146
- body: JSON.stringify({ table: tableName, pk: id, grantee: role, revoke: !cb.checked }),
57147
- }).then(function () {
57148
- var list = access.grantees || (access.grantees = []);
57149
- var at = list.indexOf(role);
57150
- if (cb.checked && at === -1) list.push(role);
57151
- if (!cb.checked && at !== -1) list.splice(at, 1);
57152
- // The first grant flips the row to custom server-side; mirror
57153
- // that locally so the indicator updates. Revoking the last leaves
57154
- // visibility 'custom' but effectiveVisibility renders custom-0 as
57155
- // private, so the label flips back to "Private to you".
57156
- if (list.length > 0) access.visibility = 'custom';
57157
- var infoEl = content.querySelector('#detail-vis-info');
57158
- if (infoEl) infoEl.textContent = visInfoLabel(access);
57159
- invalidate(tableName);
57160
- }).catch(function (e) {
57161
- cb.checked = !cb.checked; // revert the failed change
57162
- showToast('Access update failed: ' + e.message, {});
57163
- }).then(function () { cb.disabled = false; });
57164
- });
57165
- });
57166
- var infoEl = content.querySelector('#detail-vis-info');
57167
- if (infoEl) infoEl.textContent = visInfoLabel(access);
57713
+ var desired = {};
57714
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
57715
+ openGrantsPanel = { table: tableName, pk: id };
57716
+ populateGrantsPanel(panel, members, desired);
57168
57717
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
57169
57718
  });
57719
+ }
57720
+
57721
+ var detailVisManage = content.querySelector('#detail-vis-manage');
57722
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
57723
+ openManagePanel(detailVisManage);
57170
57724
  });
57725
+
57726
+ // Preserve an open panel across a soft re-render: if the tracked panel
57727
+ // matches the row this view just repainted, re-open it and re-populate the
57728
+ // checklist from the freshly-fetched row._access WITHOUT any network call,
57729
+ // so a concurrent edit by another client doesn't lose a staged selection.
57730
+ if (openGrantsPanel && openGrantsPanel.table === tableName && openGrantsPanel.pk === id) {
57731
+ var rpanel = content.querySelector('#grants-panel');
57732
+ if (rpanel) {
57733
+ fetchJson('/api/cloud/members').then(function (d) {
57734
+ // Only re-populate if THIS panel is still the tracked-open one (a
57735
+ // newer navigation/save may have cleared it while members loaded).
57736
+ if (!openGrantsPanel || openGrantsPanel.table !== tableName || openGrantsPanel.pk !== id) return;
57737
+ var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
57738
+ var desired = {};
57739
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
57740
+ populateGrantsPanel(rpanel, members, desired);
57741
+ }).catch(function () { /* best-effort restore; a click reopens it */ });
57742
+ }
57743
+ }
57171
57744
  }
57172
57745
  function renderDetail(content, tableName, id) {
57173
57746
  var myGen = renderGen;
@@ -61859,6 +62432,63 @@ var appJs = `
61859
62432
  // Browsers can't expose the local path, so we POST the bytes; the
61860
62433
  // server extracts + summarizes, then discards them (path stays null).
61861
62434
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
62435
+ // Cap how many uploads are in flight at once. A browser allows only ~6
62436
+ // HTTP/1.1 connections per host, so a bulk drop of N files would fire N
62437
+ // upload POSTs in parallel and saturate that budget \u2014 every other data
62438
+ // request (entities, rows, navigation) then queues for minutes behind the
62439
+ // multi-minute ingests and the GUI looks frozen. Holding uploads to a few
62440
+ // at a time leaves connections free for the rest of the app (and eases the
62441
+ // AI rate limit each ingest hits server-side). The realtime/feed streams are
62442
+ // already off this budget \u2014 they share one WebSocket \u2014 so this is the last
62443
+ // place a big batch could starve the connection pool.
62444
+ var INGEST_MAX_CONCURRENCY = 3;
62445
+ // Run a batch of upload thunks with at most \`limit\` in flight, calling
62446
+ // onProgress(done, total) as each settles. One failure never stalls the
62447
+ // batch \u2014 uploadFile surfaces its own error and resolves.
62448
+ function runIngestBatch(thunks, limit, onProgress) {
62449
+ return new Promise(function (resolve) {
62450
+ var total = thunks.length, idx = 0, done = 0;
62451
+ function startNext() {
62452
+ if (idx >= total) return;
62453
+ var thunk = thunks[idx++];
62454
+ Promise.resolve().then(thunk).catch(function () { /* already surfaced */ }).then(function () {
62455
+ done++;
62456
+ if (onProgress) onProgress(done, total);
62457
+ if (done === total) resolve(); else startNext();
62458
+ });
62459
+ }
62460
+ for (var i = 0; i < Math.min(limit, total); i++) startNext();
62461
+ });
62462
+ }
62463
+ // A batch-upload progress bar pinned to the top of the rail feed
62464
+ // ("Analyzing N of M\u2026"). The per-file "Analyzing <name>\u2026" cards still
62465
+ // appear, but only INGEST_MAX_CONCURRENCY at a time; this gives the
62466
+ // whole-batch view that the individual cards can't. Returns
62467
+ // { update(done, total), done() }.
62468
+ function ingestProgress(total) {
62469
+ var feedEl = document.getElementById('rail-feed');
62470
+ if (!feedEl) return { update: function () {}, done: function () {} };
62471
+ railEmptyGone();
62472
+ var wrap = document.createElement('div');
62473
+ wrap.className = 'ingest-progress';
62474
+ wrap.innerHTML =
62475
+ '<div class="ingest-progress-label">Analyzing 0 of ' + total + '\u2026</div>' +
62476
+ '<div class="ingest-progress-track"><div class="ingest-progress-fill"></div></div>';
62477
+ feedEl.insertBefore(wrap, feedEl.firstChild);
62478
+ var label = wrap.querySelector('.ingest-progress-label');
62479
+ var fill = wrap.querySelector('.ingest-progress-fill');
62480
+ return {
62481
+ update: function (n, t) {
62482
+ if (label) label.textContent = 'Analyzing ' + n + ' of ' + t + '\u2026';
62483
+ if (fill) fill.style.width = Math.round((n / t) * 100) + '%';
62484
+ },
62485
+ done: function () {
62486
+ if (fill) fill.style.width = '100%';
62487
+ if (label) label.textContent = 'Analyzed ' + total + ' file' + (total === 1 ? '' : 's');
62488
+ setTimeout(function () { if (wrap.parentNode) wrap.parentNode.removeChild(wrap); }, 2500);
62489
+ },
62490
+ };
62491
+ }
61862
62492
  // Append a transient "Analyzing <file>\u2026" row to the feed so the user sees
61863
62493
  // the ingest is processing in the background; returns a disposer. The real
61864
62494
  // create/link feed events stream in over SSE as the server materializes them.
@@ -61894,13 +62524,21 @@ var appJs = `
61894
62524
  }
61895
62525
  function uploadFile(file) {
61896
62526
  var done = pendingIngestItem(file.name || 'file');
62527
+ // Carry the composer's "Private mode" intent so an upload made while the
62528
+ // box is checked is stamped private at insert, instead of inheriting the
62529
+ // files-table default (which can be shared-to-everyone on a cloud). Read
62530
+ // the checkbox defensively \u2014 it may not be rendered. On a local workspace
62531
+ // the box is checked+disabled, so this is '1' there too; forced visibility
62532
+ // is a harmless no-op on the single-user SQLite path.
62533
+ var pv = document.getElementById('chat-private');
62534
+ var priv = pv && pv.checked ? '1' : '0';
61897
62535
  return fetch('/api/ingest/upload', {
61898
62536
  method: 'POST',
61899
62537
  // Percent-encode the filename: HTTP header values must be ISO-8859-1,
61900
62538
  // so a Unicode filename (emoji, smart quote, accent, em-dash) would
61901
62539
  // otherwise make fetch() throw "String contains non ISO-8859-1 code
61902
62540
  // point". The server decodeURIComponent()s it back.
61903
- headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file') },
62541
+ headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file'), 'x-lattice-private': priv },
61904
62542
  body: file,
61905
62543
  })
61906
62544
  .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); })
@@ -61918,7 +62556,14 @@ var appJs = `
61918
62556
  });
61919
62557
  return;
61920
62558
  }
61921
- for (var i = 0; i < files.length; i++) uploadFile(files[i]);
62559
+ // Multi-file: drain through the bounded-concurrency queue (so a big drop
62560
+ // can't saturate the connection budget) with a batch progress bar.
62561
+ var bar = ingestProgress(files.length);
62562
+ var thunks = [];
62563
+ for (var i = 0; i < files.length; i++) {
62564
+ (function (f) { thunks.push(function () { return uploadFile(f); }); })(files[i]);
62565
+ }
62566
+ runIngestBatch(thunks, INGEST_MAX_CONCURRENCY, bar.update).then(bar.done);
61922
62567
  }
61923
62568
  // Mobile: tapping the handle expands/collapses the bottom drawer.
61924
62569
  function initRailDrawer() {
@@ -62989,8 +63634,14 @@ var MEMBER_READABLE_BOOKKEEPING = [
62989
63634
  },
62990
63635
  {
62991
63636
  name: "_lattice_gui_audit",
62992
- privs: "SELECT, INSERT",
62993
- why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
63637
+ // UPDATE + DELETE are needed by undo/redo/revert (flips an entry's `undone`)
63638
+ // and the redo-stack purge on a new mutation (deletes the session's undone
63639
+ // entries). Safe because enableGuiAuditRls installs per-op UPDATE and DELETE
63640
+ // policies whose USING is `row_id IS NULL OR lattice_row_visible(table_name,
63641
+ // row_id)` — so a member can only update/delete audit rows for entities it can
63642
+ // already see (or schema-level entries that carry no row data).
63643
+ privs: "SELECT, INSERT, UPDATE, DELETE",
63644
+ why: "GUI undo/redo/revert + redo-stack purge + version history; RLS (enableGuiAuditRls) scopes every op to entries whose underlying row the member can see"
62994
63645
  },
62995
63646
  {
62996
63647
  name: "__lattice_user_identity",
@@ -65065,6 +65716,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
65065
65716
  });
65066
65717
  return true;
65067
65718
  }
65719
+ if (pathname === "/api/cloud/row-grants" && method === "POST") {
65720
+ await tryHandler(res, async () => {
65721
+ const body = await readJson(req);
65722
+ const table = typeof body.table === "string" ? body.table : "";
65723
+ const pk = typeof body.pk === "string" ? body.pk : "";
65724
+ const strList = (v2) => Array.isArray(v2) ? v2.filter((x2) => typeof x2 === "string") : [];
65725
+ const grant = strList(body.grant);
65726
+ const revoke = strList(body.revoke);
65727
+ if (!table || !pk) {
65728
+ sendJson(res, { error: "table and pk are required" }, 400);
65729
+ return;
65730
+ }
65731
+ if (ctx.db.getDialect() !== "postgres") {
65732
+ sendJson(res, { error: "Per-row sharing requires a cloud (Postgres) database" }, 400);
65733
+ return;
65734
+ }
65735
+ await batchRowGrants(ctx.db, table, pk, grant, revoke);
65736
+ sendJson(res, { ok: true, table, pk, granted: grant, revoked: revoke });
65737
+ });
65738
+ return true;
65739
+ }
65068
65740
  if (pathname === "/api/cloud/s3-config" && method === "GET") {
65069
65741
  await tryHandler(res, () => {
65070
65742
  const label = activeWorkspaceLabel(ctx.configPath);
@@ -65807,6 +66479,19 @@ async function normalizeImage(path2, maxBytes) {
65807
66479
  function renderJpeg(sharp, path2, quality) {
65808
66480
  return sharp(path2).rotate().resize({ width: MAX_DIM, height: MAX_DIM, fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
65809
66481
  }
66482
+ function buildVisionAnthropicConfig(auth) {
66483
+ const config = {};
66484
+ if (auth.authToken) {
66485
+ config.authToken = auth.authToken;
66486
+ config.apiKey = null;
66487
+ } else if (auth.apiKey) {
66488
+ config.apiKey = auth.apiKey;
66489
+ } else {
66490
+ config.apiKey = null;
66491
+ }
66492
+ if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
66493
+ return config;
66494
+ }
65810
66495
  function defaultSender(auth) {
65811
66496
  return async (input) => {
65812
66497
  const importMetaUrl = import.meta.url;
@@ -65814,11 +66499,7 @@ function defaultSender(auth) {
65814
66499
  const sdk = req("@anthropic-ai/sdk");
65815
66500
  const Anthropic = sdk.Anthropic ?? sdk.default;
65816
66501
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
65817
- const config = {};
65818
- if (auth.authToken) config.authToken = auth.authToken;
65819
- else if (auth.apiKey) config.apiKey = auth.apiKey;
65820
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
65821
- const client = new Anthropic(config);
66502
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
65822
66503
  const res = await client.messages.create({
65823
66504
  model: input.model,
65824
66505
  max_tokens: 1024,
@@ -65845,11 +66526,7 @@ function defaultPdfSender(auth) {
65845
66526
  const sdk = req("@anthropic-ai/sdk");
65846
66527
  const Anthropic = sdk.Anthropic ?? sdk.default;
65847
66528
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
65848
- const config = {};
65849
- if (auth.authToken) config.authToken = auth.authToken;
65850
- else if (auth.apiKey) config.apiKey = auth.apiKey;
65851
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
65852
- const client = new Anthropic(config);
66529
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
65853
66530
  const res = await client.messages.create({
65854
66531
  model: input.model,
65855
66532
  max_tokens: 4096,
@@ -66011,7 +66688,7 @@ function enrichContext(ctx) {
66011
66688
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {}
66012
66689
  };
66013
66690
  }
66014
- async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
66691
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res, privateMode) {
66015
66692
  try {
66016
66693
  return await enrichWithLlm(
66017
66694
  mctx,
@@ -66023,7 +66700,9 @@ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
66023
66700
  ctx.entityDescriptions,
66024
66701
  ctx.createJunction,
66025
66702
  ctx.aggressiveness,
66026
- ctx.createEntity
66703
+ ctx.createEntity,
66704
+ false,
66705
+ privateMode
66027
66706
  );
66028
66707
  } catch (e6) {
66029
66708
  const err = e6;
@@ -66102,7 +66781,9 @@ async function dispatchIngestRoute(req, res, ctx) {
66102
66781
  source: "ingest",
66103
66782
  onColumnsAdded: columnDescriptionHook(ctx.db)
66104
66783
  };
66784
+ const headerPrivate = req.headers["x-lattice-private"] === "1";
66105
66785
  if (ctx.pathname === "/api/ingest/upload") {
66786
+ const forcePrivate2 = headerPrivate;
66106
66787
  const rawName = typeof req.headers["x-filename"] === "string" && req.headers["x-filename"] || "";
66107
66788
  let name2 = "upload";
66108
66789
  if (rawName) {
@@ -66200,10 +66881,15 @@ async function dispatchIngestRoute(req, res, ctx) {
66200
66881
  ...blob ? { blob_path: blob.blob_path } : {}
66201
66882
  } : blob ? { ref_kind: "blob", blob_path: blob.blob_path } : {}
66202
66883
  };
66203
- const { id: id2 } = await createRow(mctx, "files", {
66204
- ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
66205
- ...uploadRow
66206
- });
66884
+ const { id: id2 } = await createRow(
66885
+ mctx,
66886
+ "files",
66887
+ {
66888
+ ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
66889
+ ...uploadRow
66890
+ },
66891
+ forcePrivate2 ? "private" : void 0
66892
+ );
66207
66893
  try {
66208
66894
  const dedupCtx = {
66209
66895
  db: ctx.db,
@@ -66229,7 +66915,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66229
66915
  }
66230
66916
  let suggestedLinks = [];
66231
66917
  if (!result.skip) {
66232
- const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
66918
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res, forcePrivate2);
66233
66919
  if (links === null) return true;
66234
66920
  suggestedLinks = links;
66235
66921
  }
@@ -66256,6 +66942,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66256
66942
  sendJson4(res, { error: e6.message }, 400);
66257
66943
  return true;
66258
66944
  }
66945
+ const forcePrivate = headerPrivate || body.private === true;
66259
66946
  if (ctx.pathname === "/api/ingest/text") {
66260
66947
  const rawText = typeof body.text === "string" ? body.text : "";
66261
66948
  if (!rawText.trim()) {
@@ -66266,7 +66953,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66266
66953
  if (sourceUrl) {
66267
66954
  try {
66268
66955
  const result = await ingestUrlAsFile(
66269
- { db: ctx.db, mctx, enrich: enrichContext(ctx) },
66956
+ { db: ctx.db, mctx, enrich: enrichContext(ctx), privateMode: forcePrivate },
66270
66957
  sourceUrl
66271
66958
  );
66272
66959
  sendJson4(
@@ -66295,11 +66982,25 @@ async function dispatchIngestRoute(req, res, ctx) {
66295
66982
  description: describe(content, mime2, title),
66296
66983
  extraction_status: "extracted"
66297
66984
  };
66298
- const { id: id2 } = await createRow(mctx, "files", {
66299
- ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
66300
- ...textRow
66301
- });
66302
- const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
66985
+ const { id: id2 } = await createRow(
66986
+ mctx,
66987
+ "files",
66988
+ {
66989
+ ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
66990
+ ...textRow
66991
+ },
66992
+ forcePrivate ? "private" : void 0
66993
+ );
66994
+ const suggestedLinks = await enrichOrFail(
66995
+ mctx,
66996
+ ctx.db,
66997
+ id2,
66998
+ content,
66999
+ title,
67000
+ ctx,
67001
+ res,
67002
+ forcePrivate
67003
+ );
66303
67004
  if (suggestedLinks === null) return true;
66304
67005
  sendJson4(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
66305
67006
  return true;
@@ -66338,10 +67039,15 @@ async function dispatchIngestRoute(req, res, ctx) {
66338
67039
  size_bytes: size,
66339
67040
  extraction_status: "pending"
66340
67041
  };
66341
- const { id } = await createRow(mctx, "files", {
66342
- ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
66343
- ...localRow
66344
- });
67042
+ const { id } = await createRow(
67043
+ mctx,
67044
+ "files",
67045
+ {
67046
+ ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
67047
+ ...localRow
67048
+ },
67049
+ forcePrivate ? "private" : void 0
67050
+ );
66345
67051
  try {
66346
67052
  const result = await extractSource(ctx.db, abs, mime, name);
66347
67053
  await updateRow(mctx, "files", id, {
@@ -66359,7 +67065,9 @@ async function dispatchIngestRoute(req, res, ctx) {
66359
67065
  ctx.entityDescriptions,
66360
67066
  ctx.createJunction,
66361
67067
  ctx.aggressiveness,
66362
- ctx.createEntity
67068
+ ctx.createEntity,
67069
+ false,
67070
+ forcePrivate
66363
67071
  );
66364
67072
  sendJson4(
66365
67073
  res,
@@ -67046,7 +67754,7 @@ function startBackgroundRender(active) {
67046
67754
  }
67047
67755
  bus.publish(e6);
67048
67756
  };
67049
- void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
67757
+ void db.renderInBackground(active.outputDir, { signal, onProgress, gateOnOpen: true }).then(
67050
67758
  () => {
67051
67759
  },
67052
67760
  (err) => {
@@ -69424,7 +70132,7 @@ function printHelp() {
69424
70132
  );
69425
70133
  }
69426
70134
  function getVersion() {
69427
- if (true) return "3.4.2";
70135
+ if (true) return "3.4.4";
69428
70136
  try {
69429
70137
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
69430
70138
  const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8"));