latticesql 3.4.3 → 3.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
 
@@ -615,14 +632,6 @@ function resolveDbPath(raw, configDir2) {
615
632
  }
616
633
  return resolve(configDir2, raw);
617
634
  }
618
- function warnDeprecatedRef(entity, field, target) {
619
- const key = `${entity}.${field}`;
620
- if (warnedDeprecatedRefs.has(key)) return;
621
- warnedDeprecatedRefs.add(key);
622
- console.warn(
623
- `Lattice: one-to-many \`ref:\` on "${entity}.${field}" \u2192 "${target}" is deprecated in favor of many-to-many junction tables and will be removed in 2.0.`
624
- );
625
- }
626
635
  function entityToTableDef(entityName, entity) {
627
636
  const rawFields = entity.fields;
628
637
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -649,7 +658,6 @@ function entityToTableDef(entityName, entity) {
649
658
  table: field.ref,
650
659
  foreignKey: fieldName
651
660
  };
652
- warnDeprecatedRef(entityName, fieldName, field.ref);
653
661
  }
654
662
  }
655
663
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -806,12 +814,10 @@ function parseEntityContexts(entityContexts) {
806
814
  }
807
815
  return result;
808
816
  }
809
- var warnedDeprecatedRefs;
810
817
  var init_parser = __esm({
811
818
  "src/config/parser.ts"() {
812
819
  "use strict";
813
820
  init_user_config();
814
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
815
821
  }
816
822
  });
817
823
 
@@ -960,10 +966,12 @@ function readManifest(outputDir) {
960
966
  function writeManifest(outputDir, manifest) {
961
967
  atomicWrite(manifestPath(outputDir), JSON.stringify(manifest, null, 2));
962
968
  }
969
+ var TEMPLATE_VERSION;
963
970
  var init_manifest = __esm({
964
971
  "src/lifecycle/manifest.ts"() {
965
972
  "use strict";
966
973
  init_writer();
974
+ TEMPLATE_VERSION = 1;
967
975
  }
968
976
  });
969
977
 
@@ -997,6 +1005,126 @@ var init_adapter = __esm({
997
1005
  }
998
1006
  });
999
1007
 
1008
+ // src/lifecycle/render-cursor.ts
1009
+ function markToString(v2) {
1010
+ if (v2 == null) return null;
1011
+ if (v2 instanceof Date) return v2.toISOString();
1012
+ if (typeof v2 === "string") return v2;
1013
+ if (typeof v2 === "number" || typeof v2 === "bigint" || typeof v2 === "boolean") return String(v2);
1014
+ return null;
1015
+ }
1016
+ function padNumericMark(v2) {
1017
+ const s2 = markToString(v2);
1018
+ if (s2 == null) return null;
1019
+ if (/^\d+$/.test(s2)) return s2.padStart(20, "0");
1020
+ return s2;
1021
+ }
1022
+ async function changelogExists(adapter) {
1023
+ if (adapter.dialect === "postgres") {
1024
+ const row2 = await getAsyncOrSync(
1025
+ adapter,
1026
+ `SELECT to_regclass('__lattice_changelog') AS reg`
1027
+ );
1028
+ return !!row2 && row2.reg != null;
1029
+ }
1030
+ const row = await getAsyncOrSync(
1031
+ adapter,
1032
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='__lattice_changelog'`
1033
+ );
1034
+ return !!row;
1035
+ }
1036
+ async function changelogMark(adapter) {
1037
+ try {
1038
+ if (!await changelogExists(adapter)) return null;
1039
+ const col = adapter.dialect === "postgres" ? "seq" : "rowid";
1040
+ const row = await getAsyncOrSync(
1041
+ adapter,
1042
+ `SELECT MAX(${col}) AS m FROM __lattice_changelog`
1043
+ );
1044
+ return padNumericMark(row?.m);
1045
+ } catch {
1046
+ return null;
1047
+ }
1048
+ }
1049
+ async function sharingMarks(adapter) {
1050
+ if (adapter.dialect !== "postgres") return { grants: null, owners: null };
1051
+ try {
1052
+ const reg = await getAsyncOrSync(
1053
+ adapter,
1054
+ `SELECT to_regclass('__lattice_changes') AS reg`
1055
+ );
1056
+ const hasFeed = !!reg && reg.reg != null;
1057
+ if (hasFeed) {
1058
+ const row = await getAsyncOrSync(
1059
+ adapter,
1060
+ `SELECT COUNT(*) AS n, MAX(seq) AS m FROM lattice_changes_since(0, 1000)`
1061
+ );
1062
+ const digest = digestOf(row?.n, row?.m);
1063
+ return { grants: digest, owners: digest };
1064
+ }
1065
+ } catch {
1066
+ }
1067
+ let owners = null;
1068
+ let grants = null;
1069
+ try {
1070
+ const o3 = await getAsyncOrSync(
1071
+ adapter,
1072
+ `SELECT COUNT(*) AS n, MAX(updated_at) AS m FROM __lattice_owners`
1073
+ );
1074
+ owners = digestOf(o3?.n, o3?.m);
1075
+ } catch {
1076
+ owners = null;
1077
+ }
1078
+ try {
1079
+ const g6 = await getAsyncOrSync(
1080
+ adapter,
1081
+ `SELECT COUNT(*) AS n, MAX(granted_at) AS m FROM __lattice_row_grants`
1082
+ );
1083
+ grants = digestOf(g6?.n, g6?.m);
1084
+ } catch {
1085
+ grants = null;
1086
+ }
1087
+ return { grants, owners };
1088
+ }
1089
+ function digestOf(count, max) {
1090
+ const n3 = padNumericMark(count);
1091
+ if (n3 == null) return null;
1092
+ const m4 = markToString(max) ?? "";
1093
+ return `${n3}#${m4}`;
1094
+ }
1095
+ async function computeRenderCursor(adapter) {
1096
+ try {
1097
+ const [changelog, sharing] = await Promise.all([changelogMark(adapter), sharingMarks(adapter)]);
1098
+ return { changelog, grants: sharing.grants, owners: sharing.owners };
1099
+ } catch {
1100
+ return { ...EMPTY_CURSOR };
1101
+ }
1102
+ }
1103
+ function cursorIsFresh(recorded, live, templateVersion = TEMPLATE_VERSION) {
1104
+ if (recorded == null) return false;
1105
+ if (recorded.templateVersion !== templateVersion) return false;
1106
+ const rc = recorded.cursor;
1107
+ if (rc == null) return false;
1108
+ if (!fieldFresh(rc.changelog, live.changelog, (r6, l4) => l4 <= r6)) return false;
1109
+ if (!fieldFresh(rc.grants, live.grants, (r6, l4) => l4 === r6)) return false;
1110
+ if (!fieldFresh(rc.owners, live.owners, (r6, l4) => l4 === r6)) return false;
1111
+ return true;
1112
+ }
1113
+ function fieldFresh(recorded, live, ok) {
1114
+ if (recorded == null && live == null) return true;
1115
+ if (recorded == null || live == null) return false;
1116
+ return ok(recorded, live);
1117
+ }
1118
+ var EMPTY_CURSOR;
1119
+ var init_render_cursor = __esm({
1120
+ "src/lifecycle/render-cursor.ts"() {
1121
+ "use strict";
1122
+ init_adapter();
1123
+ init_manifest();
1124
+ EMPTY_CURSOR = { changelog: null, grants: null, owners: null };
1125
+ }
1126
+ });
1127
+
1000
1128
  // src/db/sqlite.ts
1001
1129
  import Database from "better-sqlite3";
1002
1130
  var SQLiteAdapter;
@@ -3035,7 +3163,18 @@ var init_concurrency = __esm({
3035
3163
  // src/render/engine.ts
3036
3164
  import { join as join7, basename, isAbsolute, resolve as resolve3, sep } from "path";
3037
3165
  import { mkdirSync as mkdirSync5, existsSync as existsSync7, copyFileSync as copyFileSync2 } from "fs";
3038
- var YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
3166
+ function entityContentChanged(fresh, prior) {
3167
+ const freshKeys = Object.keys(fresh);
3168
+ const priorKeys = Object.keys(prior);
3169
+ if (freshKeys.length !== priorKeys.length) return true;
3170
+ for (const k6 of freshKeys) {
3171
+ const p3 = prior[k6];
3172
+ if (p3 == null) return true;
3173
+ if (p3.hash === "" || p3.hash !== fresh[k6]?.hash) return true;
3174
+ }
3175
+ return false;
3176
+ }
3177
+ var DeferredTableProgress, YIELD_EVERY_ENTITIES, RENDER_TABLE_CONCURRENCY, NOOP_RENDER, RenderEngine;
3039
3178
  var init_engine = __esm({
3040
3179
  "src/render/engine.ts"() {
3041
3180
  "use strict";
@@ -3045,9 +3184,44 @@ var init_engine = __esm({
3045
3184
  init_entity_query();
3046
3185
  init_entity_templates();
3047
3186
  init_manifest();
3187
+ init_render_cursor();
3048
3188
  init_cleanup();
3049
3189
  init_progress();
3050
3190
  init_concurrency();
3191
+ DeferredTableProgress = class {
3192
+ constructor(throttle) {
3193
+ this.throttle = throttle;
3194
+ }
3195
+ changed = false;
3196
+ pendingStart = null;
3197
+ /** Buffer the `table-start` event; emitted only if/when the table changes. */
3198
+ start(event) {
3199
+ if (this.changed) {
3200
+ this.throttle.force(event);
3201
+ return;
3202
+ }
3203
+ this.pendingStart = event;
3204
+ }
3205
+ /** Mark that an entity's content changed — flush the held `table-start` once. */
3206
+ markChanged() {
3207
+ if (this.changed) return;
3208
+ this.changed = true;
3209
+ if (this.pendingStart) {
3210
+ this.throttle.force(this.pendingStart);
3211
+ this.pendingStart = null;
3212
+ }
3213
+ }
3214
+ /** Coalesced per-entity progress — dropped entirely until the table changed. */
3215
+ tick(event) {
3216
+ if (!this.changed) return;
3217
+ this.throttle.tick(event);
3218
+ }
3219
+ /** Lifecycle event (`table-done`) — emitted only if the table changed. */
3220
+ force(event) {
3221
+ if (!this.changed) return;
3222
+ this.throttle.force(event);
3223
+ }
3224
+ };
3051
3225
  YIELD_EVERY_ENTITIES = 200;
3052
3226
  RENDER_TABLE_CONCURRENCY = 4;
3053
3227
  NOOP_RENDER = () => "";
@@ -3164,20 +3338,23 @@ var init_engine = __esm({
3164
3338
  }
3165
3339
  const content = def.tokenBudget ? applyTokenBudget(rows, def.render, def.tokenBudget, def.prioritizeBy) : def.render(rows);
3166
3340
  const filePath = join7(outputDir, def.outputFile);
3167
- if (atomicWrite(filePath, content)) {
3341
+ const wrote = atomicWrite(filePath, content);
3342
+ if (wrote) {
3168
3343
  filesWritten.push(filePath);
3169
3344
  } else {
3170
3345
  counters.skipped++;
3171
3346
  }
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
- });
3347
+ if (wrote) {
3348
+ throttle.force({
3349
+ kind: "table-done",
3350
+ table: name,
3351
+ entitiesRendered: rows.length,
3352
+ entitiesTotal: rows.length,
3353
+ tableIndex: 0,
3354
+ tableCount: 0,
3355
+ pct: 100
3356
+ });
3357
+ }
3181
3358
  }
3182
3359
  for (const [name, def] of this._schema.getMultis()) {
3183
3360
  if (signal?.aborted) return this._abortedResult(filesWritten, counters, start);
@@ -3191,32 +3368,38 @@ var init_engine = __esm({
3191
3368
  tables[t8] = await this._schema.queryTable(this._adapter, t8, this._readRel);
3192
3369
  }
3193
3370
  }
3371
+ let wroteAny = false;
3194
3372
  for (const key of keys) {
3195
3373
  const content = def.render(key, tables);
3196
3374
  const filePath = join7(outputDir, def.outputFile(key));
3197
3375
  if (atomicWrite(filePath, content)) {
3198
3376
  filesWritten.push(filePath);
3377
+ wroteAny = true;
3199
3378
  } else {
3200
3379
  counters.skipped++;
3201
3380
  }
3202
3381
  }
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
- });
3382
+ if (wroteAny) {
3383
+ throttle.force({
3384
+ kind: "table-done",
3385
+ table: name,
3386
+ entitiesRendered: keys.length,
3387
+ entitiesTotal: keys.length,
3388
+ tableIndex: 0,
3389
+ tableCount: 0,
3390
+ pct: 100
3391
+ });
3392
+ }
3212
3393
  }
3394
+ const priorManifest = readManifest(outputDir);
3213
3395
  const entityContextManifest = await this._renderEntityContexts(
3214
3396
  outputDir,
3215
3397
  filesWritten,
3216
3398
  counters,
3217
3399
  throttle,
3218
3400
  signal,
3219
- opts.changedTables
3401
+ opts.changedTables,
3402
+ priorManifest
3220
3403
  );
3221
3404
  if (entityContextManifest === null) {
3222
3405
  return this._abortedResult(filesWritten, counters, start);
@@ -3227,10 +3410,13 @@ var init_engine = __esm({
3227
3410
  const prev = readManifest(outputDir);
3228
3411
  entityContexts = { ...prev?.entityContexts ?? {}, ...entityContextManifest };
3229
3412
  }
3413
+ const cursor = await computeRenderCursor(this._adapter);
3230
3414
  writeManifest(outputDir, {
3231
3415
  version: 2,
3232
3416
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
3233
- entityContexts
3417
+ entityContexts,
3418
+ templateVersion: TEMPLATE_VERSION,
3419
+ cursor
3234
3420
  });
3235
3421
  }
3236
3422
  const result = {
@@ -3296,7 +3482,7 @@ var init_engine = __esm({
3296
3482
  * partial tree). Progress is reported through `throttle`; abort is observed
3297
3483
  * via `signal`.
3298
3484
  */
3299
- async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables) {
3485
+ async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal, changedTables, priorManifest) {
3300
3486
  const protectedTables = /* @__PURE__ */ new Set();
3301
3487
  for (const [t8, d6] of this._schema.getEntityContexts()) {
3302
3488
  if (d6.protected) protectedTables.add(t8);
@@ -3315,8 +3501,10 @@ var init_engine = __esm({
3315
3501
  const baseRows = await this._schema.queryTable(this._adapter, table, this._readRel);
3316
3502
  const allRows = this._foldRows ? await this._foldRows(table, baseRows) : baseRows;
3317
3503
  const directoryRoot = def.directoryRoot ?? table;
3504
+ const deferred = new DeferredTableProgress(throttle);
3505
+ const priorEntities = priorManifest?.entityContexts[table]?.entities ?? {};
3318
3506
  const entitiesTotal = allRows.length;
3319
- throttle.force({
3507
+ deferred.start({
3320
3508
  kind: "table-start",
3321
3509
  table,
3322
3510
  entitiesRendered: 0,
@@ -3325,6 +3513,7 @@ var init_engine = __esm({
3325
3513
  tableCount,
3326
3514
  pct: 0
3327
3515
  });
3516
+ if (Object.keys(priorEntities).length !== entitiesTotal) deferred.markChanged();
3328
3517
  const manifestEntry = {
3329
3518
  directoryRoot,
3330
3519
  ...def.index ? { indexFile: def.index.outputFile } : {},
@@ -3440,8 +3629,10 @@ var init_engine = __esm({
3440
3629
  }
3441
3630
  }
3442
3631
  manifestEntry.entities[slug] = entityFileHashes;
3632
+ const priorHashes = normalizeEntityFiles(priorEntities[slug] ?? {});
3633
+ if (entityContentChanged(entityFileHashes, priorHashes)) deferred.markChanged();
3443
3634
  const entitiesRendered = i6 + 1;
3444
- throttle.tick({
3635
+ deferred.tick({
3445
3636
  kind: "table-progress",
3446
3637
  table,
3447
3638
  entitiesRendered,
@@ -3451,7 +3642,7 @@ var init_engine = __esm({
3451
3642
  pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
3452
3643
  });
3453
3644
  }
3454
- throttle.force({
3645
+ deferred.force({
3455
3646
  kind: "table-done",
3456
3647
  table,
3457
3648
  entitiesRendered: entitiesTotal,
@@ -5085,6 +5276,7 @@ var init_lattice = __esm({
5085
5276
  init_shred();
5086
5277
  init_encryption();
5087
5278
  init_manifest();
5279
+ init_render_cursor();
5088
5280
  init_adapter();
5089
5281
  init_sqlite();
5090
5282
  init_postgres();
@@ -5155,6 +5347,14 @@ var init_lattice = __esm({
5155
5347
  _changelogTables = /* @__PURE__ */ new Set();
5156
5348
  /** Current task context string for relevance filtering. */
5157
5349
  _taskContext = "";
5350
+ /**
5351
+ * True when this connection opened against an already-provisioned cloud as a
5352
+ * SCOPED MEMBER (no role-management privilege → no CREATE/ALTER on the schema).
5353
+ * Set during init() by the same probe that decides introspect-only. Drives
5354
+ * {@link addColumn} to route DDL through the owner-side `lattice_member_add_column`
5355
+ * SECURITY DEFINER helper instead of issuing a raw ALTER the member can't run.
5356
+ */
5357
+ _cloudMemberOpen = false;
5158
5358
  _auditHandlers = [];
5159
5359
  _renderHandlers = [];
5160
5360
  _writebackHandlers = [];
@@ -5401,7 +5601,7 @@ var init_lattice = __esm({
5401
5601
  /** Async tail of init(). See {@link init} for the sync-validation phase. */
5402
5602
  async _initAsync(options) {
5403
5603
  let introspectOnly = options.introspectOnly === true;
5404
- if (!introspectOnly && this.getDialect() === "postgres") {
5604
+ if (this.getDialect() === "postgres") {
5405
5605
  try {
5406
5606
  const [marker, role] = await Promise.all([
5407
5607
  getAsyncOrSync(this._adapter, `SELECT to_regclass('__lattice_owners') AS reg`),
@@ -5412,7 +5612,9 @@ var init_lattice = __esm({
5412
5612
  ]);
5413
5613
  const provisioned = !!marker && marker.reg != null;
5414
5614
  const canCreateRoles = !!role && role.rolcreaterole === true;
5415
- introspectOnly = provisioned && !canCreateRoles;
5615
+ const memberOpen = provisioned && !canCreateRoles;
5616
+ introspectOnly = introspectOnly || memberOpen;
5617
+ this._cloudMemberOpen = memberOpen;
5416
5618
  } catch {
5417
5619
  }
5418
5620
  }
@@ -5500,6 +5702,26 @@ var init_lattice = __esm({
5500
5702
  getDialect() {
5501
5703
  return this._adapter.dialect;
5502
5704
  }
5705
+ /**
5706
+ * True when a table opts into the observation/changelog substrate
5707
+ * (`def.changelog`). Callers that want to bypass the high-level {@link delete}
5708
+ * with a transaction-scoped raw delete use this to know whether the table also
5709
+ * needs the changelog / write-hook / embedding side effects that only
5710
+ * `delete()` performs — so they can keep the high-level path for such tables.
5711
+ */
5712
+ isChangelogTracked(table) {
5713
+ return this._changelogTables.has(table);
5714
+ }
5715
+ /**
5716
+ * True when this connection opened as a scoped cloud MEMBER (see
5717
+ * {@link _cloudMemberOpen}). Callers use it to route DDL-bearing work through
5718
+ * the owner-side SECURITY DEFINER helpers rather than issuing DDL the member's
5719
+ * role can't run (e.g. {@link addColumn} regenerates the masking view inside
5720
+ * `lattice_member_add_column`, so the caller must not also try to regenerate it).
5721
+ */
5722
+ isCloudMemberOpen() {
5723
+ return this._cloudMemberOpen;
5724
+ }
5503
5725
  /**
5504
5726
  * Return the normalised primary-key column list for a registered
5505
5727
  * table. Falls back to `['id']` for tables registered via raw DDL
@@ -5576,7 +5798,15 @@ var init_lattice = __esm({
5576
5798
  assertSafeIdentifier(column, "column");
5577
5799
  const existing = await introspectColumnsAsyncOrSync(this._adapter, table);
5578
5800
  if (!existing.includes(column)) {
5579
- await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5801
+ if (this._cloudMemberOpen) {
5802
+ await runAsyncOrSync(this._adapter, `SELECT lattice_member_add_column(?, ?, ?)`, [
5803
+ table,
5804
+ column,
5805
+ typeSpec
5806
+ ]);
5807
+ } else {
5808
+ await addColumnAsyncOrSync(this._adapter, table, column, typeSpec);
5809
+ }
5580
5810
  }
5581
5811
  const cols = await introspectColumnsAsyncOrSync(this._adapter, table);
5582
5812
  this._columnCache.set(table, new Set(cols));
@@ -6508,12 +6738,39 @@ var init_lattice = __esm({
6508
6738
  async renderInBackground(outputDir, opts = {}) {
6509
6739
  const notInit = this._notInitError();
6510
6740
  if (notInit) return notInit;
6741
+ if (opts.gateOnOpen && !opts.changedTables) {
6742
+ const start = Date.now();
6743
+ const recorded = readManifest(outputDir);
6744
+ if (recorded != null) {
6745
+ const live = await computeRenderCursor(this._adapter);
6746
+ if (cursorIsFresh(recorded, live)) {
6747
+ opts.onProgress?.({
6748
+ kind: "done",
6749
+ table: null,
6750
+ entitiesRendered: 0,
6751
+ entitiesTotal: 0,
6752
+ tableIndex: 0,
6753
+ tableCount: 0,
6754
+ pct: 100,
6755
+ durationMs: Date.now() - start
6756
+ });
6757
+ const skipped = {
6758
+ filesWritten: [],
6759
+ filesSkipped: 0,
6760
+ durationMs: Date.now() - start
6761
+ };
6762
+ for (const h6 of this._renderHandlers) h6(skipped);
6763
+ return skipped;
6764
+ }
6765
+ }
6766
+ }
6511
6767
  if (!opts.changedTables) {
6512
6768
  this._pendingRenderAll = false;
6513
6769
  this._pendingRenderTables = /* @__PURE__ */ new Set();
6514
6770
  this._autoRenderPending = false;
6515
6771
  }
6516
- return this._renderGuarded(outputDir, opts);
6772
+ const { gateOnOpen: _gateOnOpen, ...engineOpts } = opts;
6773
+ return this._renderGuarded(outputDir, engineOpts);
6517
6774
  }
6518
6775
  /**
6519
6776
  * Install a per-viewer read-relation resolver for ALL renders (initial,
@@ -8623,6 +8880,111 @@ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
8623
8880
  AND g."pk" = ANY(p_pks)
8624
8881
  AND o."owner_role" = session_user;
8625
8882
  $fn$;
8883
+
8884
+ -- Add a column to a user table AS THE OWNER, on behalf of a scoped member. A
8885
+ -- member's role has no CREATE/ALTER on the schema (the bootstrap REVOKEs CREATE
8886
+ -- from PUBLIC), so a member's GUI "add a field" write (createRow/updateRow with a
8887
+ -- field the table lacks) cannot run ALTER TABLE itself. This SECURITY DEFINER
8888
+ -- helper performs that ALTER \u2014 and the masking-view regen \u2014 with the owner's
8889
+ -- rights, so member-added columns behave identically to owner-added ones.
8890
+ --
8891
+ -- Injection-safe + minimal: p_table must be an existing BASE table in the current
8892
+ -- schema (rejected otherwise); p_type is whitelisted against the exact set the
8893
+ -- library's addColumn emits for an auto-added column (TEXT / INTEGER / REAL, plus
8894
+ -- BOOLEAN) \u2014 never interpolated raw; both identifiers go through %I (quote_ident).
8895
+ -- Member-callable (granted EXECUTE to the member group), but it can only widen the
8896
+ -- schema, never read or alter another member's data.
8897
+ CREATE OR REPLACE FUNCTION lattice_member_add_column(p_table text, p_column text, p_type text)
8898
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
8899
+ DECLARE
8900
+ v_type text;
8901
+ v_view text := p_table || '_v';
8902
+ v_has_view boolean;
8903
+ v_pk_expr text;
8904
+ v_select text;
8905
+ BEGIN
8906
+ -- Never alter internal bookkeeping tables (names start with "_"). The GUI only
8907
+ -- ever calls this for a user entity table; rejecting the rest is defense-in-depth
8908
+ -- against a member invoking the function directly against ownership/audit/policy
8909
+ -- tables.
8910
+ IF left(p_table, 1) = '_' THEN
8911
+ RAISE EXCEPTION 'lattice: cannot add a column to internal table "%"', p_table;
8912
+ END IF;
8913
+
8914
+ -- p_table must be a real base table in THIS schema (search_path is pinned to the
8915
+ -- cloud schema by pinDefinerSearchPath, so to_regclass resolves there).
8916
+ IF NOT EXISTS (
8917
+ SELECT 1 FROM pg_class c
8918
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8919
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND c.relkind = 'r'
8920
+ ) THEN
8921
+ RAISE EXCEPTION 'lattice: no such table "%"', p_table;
8922
+ END IF;
8923
+
8924
+ -- Whitelist the column type. These are exactly the specs addColumn's
8925
+ -- inferColumnType produces (TEXT / INTEGER / REAL); BOOLEAN is allowed too.
8926
+ -- Anything else is rejected \u2014 the type is spliced as %s (NOT %I), so it must be
8927
+ -- a known-safe literal and never caller-controlled SQL.
8928
+ v_type := upper(btrim(p_type));
8929
+ IF v_type NOT IN ('TEXT', 'INTEGER', 'REAL', 'BOOLEAN') THEN
8930
+ RAISE EXCEPTION 'lattice: unsupported column type "%"', p_type;
8931
+ END IF;
8932
+
8933
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS %I %s', p_table, p_column, v_type);
8934
+
8935
+ -- If the table is cell-masked (a "<table>_v" view exists, because some column has
8936
+ -- an audience), the view selects an explicit column list \u2014 so a new column is
8937
+ -- invisible to members until the view is regenerated. Rebuild it the same way the
8938
+ -- owner path (audienceViewSql / regenerateAudienceViewFromDb) does: pass every
8939
+ -- column through except those with an 'owner' audience in __lattice_column_policy
8940
+ -- (CASE WHEN lattice_is_owner(...) THEN col END), re-apply row visibility with
8941
+ -- WHERE lattice_row_visible(table, pk), and keep the member SELECT grant on the
8942
+ -- view. Unmasked tables need no regen \u2014 the member group's table-level base grant
8943
+ -- already covers the new column.
8944
+ SELECT EXISTS (
8945
+ SELECT 1 FROM pg_class c
8946
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8947
+ WHERE n.nspname = current_schema() AND c.relname = v_view AND c.relkind = 'v'
8948
+ ) INTO v_has_view;
8949
+
8950
+ IF v_has_view THEN
8951
+ -- Canonical pk expression: CAST("col" AS TEXT) joined by TAB (chr(9)) \u2014 the
8952
+ -- same serialization the RLS policies + audienceViewSql use.
8953
+ SELECT string_agg(format('CAST(%I AS TEXT)', a.attname), ' || chr(9) || '
8954
+ ORDER BY array_position(i.indkey, a.attnum))
8955
+ INTO v_pk_expr
8956
+ FROM pg_index i
8957
+ JOIN pg_class c ON c.oid = i.indrelid
8958
+ JOIN pg_namespace n ON n.oid = c.relnamespace
8959
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
8960
+ WHERE n.nspname = current_schema() AND c.relname = p_table AND i.indisprimary;
8961
+ IF v_pk_expr IS NULL THEN
8962
+ RAISE EXCEPTION 'lattice: cannot regenerate mask view for "%": no primary key', p_table;
8963
+ END IF;
8964
+
8965
+ -- Build the masked SELECT list in column order, applying the per-column policy.
8966
+ SELECT string_agg(
8967
+ CASE
8968
+ WHEN cp."audience" = 'owner'
8969
+ THEN format('CASE WHEN lattice_is_owner(%L, %s) THEN %I END AS %I',
8970
+ p_table, v_pk_expr, cols.column_name, cols.column_name)
8971
+ ELSE format('%I', cols.column_name)
8972
+ END,
8973
+ ', ' ORDER BY cols.ordinal_position)
8974
+ INTO v_select
8975
+ FROM information_schema.columns cols
8976
+ LEFT JOIN "__lattice_column_policy" cp
8977
+ ON cp."table_name" = p_table AND cp."column_name" = cols.column_name
8978
+ AND cp."audience" NOT IN ('', 'everyone', 'row-audience')
8979
+ WHERE cols.table_schema = current_schema() AND cols.table_name = p_table;
8980
+
8981
+ EXECUTE format(
8982
+ 'CREATE OR REPLACE VIEW %I AS SELECT %s FROM %I WHERE lattice_row_visible(%L, %s)',
8983
+ v_view, v_select, p_table, p_table, v_pk_expr);
8984
+ EXECUTE format('GRANT SELECT ON %I TO ${MEMBER_GROUP}', v_view);
8985
+ END IF;
8986
+ END $fn$;
8987
+ GRANT EXECUTE ON FUNCTION lattice_member_add_column(text, text, text) TO ${MEMBER_GROUP};
8626
8988
  `;
8627
8989
  }
8628
8990
  });
@@ -8796,18 +9158,9 @@ function sessionUndoneFilters(undone, sessionId) {
8796
9158
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
8797
9159
  return filters;
8798
9160
  }
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", {
9161
+ function buildAuditRow(table, rowId, op, before, after, sessionId, editTs) {
9162
+ return {
8805
9163
  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
9164
  ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
8812
9165
  table_name: table,
8813
9166
  row_id: rowId,
@@ -8816,7 +9169,9 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
8816
9169
  after_json: after ? JSON.stringify(after) : null,
8817
9170
  undone: 0,
8818
9171
  session_id: sessionId ?? null
8819
- });
9172
+ };
9173
+ }
9174
+ function publishMutationFeed(feed, table, rowId, op, before, after, source) {
8820
9175
  const labelRow = op === "delete" ? before : after;
8821
9176
  feed.publish({
8822
9177
  table,
@@ -8826,17 +9181,28 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
8826
9181
  summary: feedSummary(op, table, labelRow)
8827
9182
  });
8828
9183
  }
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) {
9184
+ async function purgeRedoStack(db, sessionId) {
8833
9185
  const undone = await db.query("_lattice_gui_audit", {
8834
9186
  filters: sessionUndoneFilters(1, sessionId)
8835
9187
  });
8836
9188
  for (const r6 of undone) await db.delete("_lattice_gui_audit", r6.id);
9189
+ }
9190
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
9191
+ await purgeRedoStack(db, sessionId);
9192
+ await db.insert(
9193
+ "_lattice_gui_audit",
9194
+ buildAuditRow(table, rowId, op, before, after, sessionId, editTs)
9195
+ );
9196
+ publishMutationFeed(feed, table, rowId, op, before, after, source);
9197
+ }
9198
+ function isSchemaOp(operation2) {
9199
+ return operation2.startsWith(SCHEMA_OP_PREFIX);
9200
+ }
9201
+ async function recordSchemaAudit(db, feed, table, operation2, before, after, summary, source = "gui", sessionId) {
9202
+ await purgeRedoStack(db, sessionId);
8837
9203
  await db.insert("_lattice_gui_audit", {
8838
9204
  id: crypto.randomUUID(),
8839
- // Explicit ISO ts — see appendAudit (the SQLite-only strftime DEFAULT
9205
+ // Explicit ISO ts — see buildAuditRow (the SQLite-only strftime DEFAULT
8840
9206
  // rendered "Invalid Date" on the Postgres/cloud path).
8841
9207
  ts: (/* @__PURE__ */ new Date()).toISOString(),
8842
9208
  table_name: table,
@@ -8871,7 +9237,7 @@ async function ensureColumns(db, table, values) {
8871
9237
  const added = Object.keys(values).filter((k6) => !(k6 in existing));
8872
9238
  if (added.length === 0) return [];
8873
9239
  for (const col of added) await db.addColumn(table, col, inferColumnType(values[col]));
8874
- if (db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
9240
+ if (!db.isCloudMemberOpen() && db.getDialect() === "postgres" && await cloudRlsInstalled(db)) {
8875
9241
  const cols = db.getRegisteredColumns(table);
8876
9242
  const pk = db.getPrimaryKey(table);
8877
9243
  if (cols && pk.length > 0) await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
@@ -8993,7 +9359,14 @@ async function deleteRow(ctx, table, id, hard) {
8993
9359
  ctx.clientTs
8994
9360
  );
8995
9361
  } else {
8996
- await ctx.db.delete(table, id);
9362
+ await hardDelete(ctx, table, id, before);
9363
+ }
9364
+ }
9365
+ async function hardDelete(ctx, table, id, before) {
9366
+ const withClient = ctx.db.adapter.withClient?.bind(ctx.db.adapter);
9367
+ const pkCols = ctx.db.getPrimaryKey(table);
9368
+ const pkCol = pkCols.length === 1 ? pkCols[0] : void 0;
9369
+ if (!withClient || ctx.db.isChangelogTracked(table) || pkCol === void 0) {
8997
9370
  await appendAudit(
8998
9371
  ctx.db,
8999
9372
  ctx.feed,
@@ -9006,10 +9379,30 @@ async function deleteRow(ctx, table, id, hard) {
9006
9379
  ctx.sessionId,
9007
9380
  ctx.clientTs
9008
9381
  );
9382
+ await ctx.db.delete(table, id);
9383
+ return;
9009
9384
  }
9385
+ const auditRow = buildAuditRow(table, id, "delete", before, null, ctx.sessionId, ctx.clientTs);
9386
+ await purgeRedoStack(ctx.db, ctx.sessionId);
9387
+ const auditCols = AUDIT_COLUMNS.map((c6) => `"${c6}"`).join(", ");
9388
+ const auditPlaceholders = AUDIT_COLUMNS.map(() => "?").join(", ");
9389
+ const auditValues = AUDIT_COLUMNS.map((c6) => auditRow[c6]);
9390
+ const pkColQuoted = pkCol.replace(/"/g, '""');
9391
+ await withClient(async (tx) => {
9392
+ await tx.run(
9393
+ `INSERT INTO "_lattice_gui_audit" (${auditCols}) VALUES (${auditPlaceholders})`,
9394
+ auditValues
9395
+ );
9396
+ await tx.run(`DELETE FROM "${table.replace(/"/g, '""')}" WHERE "${pkColQuoted}" = ?`, [id]);
9397
+ });
9398
+ publishMutationFeed(ctx.feed, table, id, "delete", before, null, ctx.source);
9010
9399
  }
9011
- async function linkRows(ctx, table, body) {
9012
- await ctx.db.link(table, body);
9400
+ async function linkRows(ctx, table, body, forceVisibility) {
9401
+ if (forceVisibility !== void 0) {
9402
+ await ctx.db.insertForcingVisibility(table, body, forceVisibility);
9403
+ } else {
9404
+ await ctx.db.link(table, body);
9405
+ }
9013
9406
  await appendAudit(ctx.db, ctx.feed, table, null, "link", null, body, ctx.source, ctx.sessionId);
9014
9407
  }
9015
9408
  async function unlinkRows(ctx, table, body) {
@@ -9147,12 +9540,23 @@ async function revertEntry(ctx, id) {
9147
9540
  });
9148
9541
  return { ok: true, entry };
9149
9542
  }
9150
- var SCHEMA_OP_PREFIX;
9543
+ var AUDIT_COLUMNS, SCHEMA_OP_PREFIX;
9151
9544
  var init_mutations = __esm({
9152
9545
  "src/gui/mutations.ts"() {
9153
9546
  "use strict";
9154
9547
  init_cloud_connect();
9155
9548
  init_audience();
9549
+ AUDIT_COLUMNS = [
9550
+ "id",
9551
+ "ts",
9552
+ "table_name",
9553
+ "row_id",
9554
+ "operation",
9555
+ "before_json",
9556
+ "after_json",
9557
+ "undone",
9558
+ "session_id"
9559
+ ];
9156
9560
  SCHEMA_OP_PREFIX = "schema.";
9157
9561
  }
9158
9562
  });
@@ -9439,6 +9843,10 @@ async function readMachineCredential(db, kind) {
9439
9843
  }
9440
9844
  return null;
9441
9845
  }
9846
+ async function resolveAnthropicKey(db) {
9847
+ if (isAssistantCredentialCleared(CREDENTIALS.anthropic.kind)) return null;
9848
+ return await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
9849
+ }
9442
9850
  function getAggressiveness() {
9443
9851
  const n3 = readPreferences().aggressiveness;
9444
9852
  if (!Number.isFinite(n3)) return DEFAULT_AGGRESSIVENESS;
@@ -9469,6 +9877,7 @@ async function getVoiceCredential(db) {
9469
9877
  return null;
9470
9878
  }
9471
9879
  async function hasCredential(db, name, envVar) {
9880
+ if (isAssistantCredentialCleared(CREDENTIALS[name].kind)) return false;
9472
9881
  return Boolean(await readMachineCredential(db, CREDENTIALS[name].kind)) || Boolean(process.env[envVar]);
9473
9882
  }
9474
9883
  async function resolveClaudeAuth(db) {
@@ -9491,7 +9900,7 @@ async function resolveClaudeAuth(db) {
9491
9900
  } catch {
9492
9901
  }
9493
9902
  }
9494
- const apiKey = await readMachineCredential(db, CREDENTIALS.anthropic.kind) ?? process.env.ANTHROPIC_API_KEY ?? null;
9903
+ const apiKey = await resolveAnthropicKey(db);
9495
9904
  return apiKey ? { apiKey } : null;
9496
9905
  }
9497
9906
  async function hasClaudeAuth(db) {
@@ -9588,6 +9997,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
9588
9997
  }
9589
9998
  const cred = CREDENTIALS[name];
9590
9999
  setAssistantCredential(cred.kind, key);
10000
+ clearAssistantCredentialCleared(cred.kind);
9591
10001
  if (db) {
9592
10002
  for (const row of await liveSecretsOfKind(db, cred.kind)) {
9593
10003
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -9604,6 +10014,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
9604
10014
  return true;
9605
10015
  }
9606
10016
  deleteAssistantCredential(CREDENTIALS[name].kind);
10017
+ setAssistantCredentialCleared(CREDENTIALS[name].kind);
9607
10018
  if (db) {
9608
10019
  for (const row of await liveSecretsOfKind(db, CREDENTIALS[name].kind)) {
9609
10020
  await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -10810,6 +11221,11 @@ async function revokeRow(db, table, pk, grantee) {
10810
11221
  assertPg(db);
10811
11222
  await runAsyncOrSync(db.adapter, `SELECT lattice_revoke_row(?, ?, ?)`, [table, pk, grantee]);
10812
11223
  }
11224
+ async function batchRowGrants(db, table, pk, grant, revoke) {
11225
+ assertPg(db);
11226
+ for (const grantee of grant) await grantRow(db, table, pk, grantee);
11227
+ for (const grantee of revoke) await revokeRow(db, table, pk, grantee);
11228
+ }
10813
11229
  async function revokeMemberRole(db, role) {
10814
11230
  assertPg(db);
10815
11231
  if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
@@ -12053,7 +12469,7 @@ function buildSchema(db) {
12053
12469
  }
12054
12470
  return out;
12055
12471
  }
12056
- async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false) {
12472
+ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity, untrusted = false, privateMode = false) {
12057
12473
  if (!text.trim()) return [];
12058
12474
  const auth = await resolveClaudeAuth(db);
12059
12475
  if (!auth) {
@@ -12075,6 +12491,7 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12075
12491
  });
12076
12492
  return [];
12077
12493
  }
12494
+ const forceVis = privateMode ? "private" : void 0;
12078
12495
  const temperature = aggressivenessToTemperature(aggressiveness);
12079
12496
  let description = "";
12080
12497
  try {
@@ -12117,11 +12534,16 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12117
12534
  }
12118
12535
  if (jx) {
12119
12536
  try {
12120
- await linkRows(mctx, jx.junction, {
12121
- id: crypto.randomUUID(),
12122
- [jx.fileFk]: fileId,
12123
- [jx.otherFk]: m4.id
12124
- });
12537
+ await linkRows(
12538
+ mctx,
12539
+ jx.junction,
12540
+ {
12541
+ id: crypto.randomUUID(),
12542
+ [jx.fileFk]: fileId,
12543
+ [jx.otherFk]: m4.id
12544
+ },
12545
+ forceVis
12546
+ );
12125
12547
  linkedCount++;
12126
12548
  if (created) {
12127
12549
  mctx.feed.publish({
@@ -12180,16 +12602,21 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12180
12602
  if ("name" in cols && row.name == null) row.name = obj2.label;
12181
12603
  if ("title" in cols && row.title == null) row.title = obj2.label;
12182
12604
  try {
12183
- const { id: rowId } = await createRow(mctx, entity, row);
12605
+ const { id: rowId } = await createRow(mctx, entity, row, forceVis);
12184
12606
  createdCount++;
12185
12607
  const ent = entity;
12186
12608
  const jx = junctions.find((j6) => j6.otherTable === ent) ?? (createJunction ? await createJunction(ent) : null);
12187
12609
  if (jx) {
12188
- await linkRows(mctx, jx.junction, {
12189
- id: crypto.randomUUID(),
12190
- [jx.fileFk]: fileId,
12191
- [jx.otherFk]: rowId
12192
- });
12610
+ await linkRows(
12611
+ mctx,
12612
+ jx.junction,
12613
+ {
12614
+ id: crypto.randomUUID(),
12615
+ [jx.fileFk]: fileId,
12616
+ [jx.otherFk]: rowId
12617
+ },
12618
+ forceVis
12619
+ );
12193
12620
  }
12194
12621
  } catch (e6) {
12195
12622
  console.warn(`[ingest] create ${entity} from document failed:`, e6.message);
@@ -12203,12 +12630,17 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
12203
12630
  try {
12204
12631
  const title = name.replace(/\.[^./\\]+$/, "").trim() || "Note";
12205
12632
  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
- });
12633
+ const { id: noteId } = await createRow(
12634
+ mctx,
12635
+ "notes",
12636
+ {
12637
+ id: crypto.randomUUID(),
12638
+ title,
12639
+ body,
12640
+ source_file_id: fileId
12641
+ },
12642
+ forceVis
12643
+ );
12212
12644
  mctx.feed.publish({
12213
12645
  table: "notes",
12214
12646
  op: "insert",
@@ -12694,7 +13126,8 @@ async function ingestUrlAsFile(ctx, rawUrl, opts = {}) {
12694
13126
  ctx.enrich.createJunction,
12695
13127
  ctx.enrich.aggressiveness,
12696
13128
  ctx.enrich.createEntity,
12697
- true
13129
+ true,
13130
+ ctx.privateMode === true
12698
13131
  );
12699
13132
  }
12700
13133
  return {
@@ -13573,13 +14006,22 @@ function loadSdk() {
13573
14006
  throw new Error("Could not resolve the Anthropic constructor from '@anthropic-ai/sdk'");
13574
14007
  return ctor;
13575
14008
  }
13576
- function createAnthropicClient(auth) {
13577
- const Anthropic = loadSdk();
14009
+ function buildAnthropicConfig(auth) {
13578
14010
  const config = {};
13579
- if (auth.authToken) config.authToken = auth.authToken;
13580
- else if (auth.apiKey) config.apiKey = auth.apiKey;
14011
+ if (auth.authToken) {
14012
+ config.authToken = auth.authToken;
14013
+ config.apiKey = null;
14014
+ } else if (auth.apiKey) {
14015
+ config.apiKey = auth.apiKey;
14016
+ } else {
14017
+ config.apiKey = null;
14018
+ }
13581
14019
  if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
13582
- const sdk = new Anthropic(config);
14020
+ return config;
14021
+ }
14022
+ function createAnthropicClient(auth) {
14023
+ const Anthropic = loadSdk();
14024
+ const sdk = new Anthropic(buildAnthropicConfig(auth));
13583
14025
  return {
13584
14026
  async runTurn(params) {
13585
14027
  const stream = sdk.messages.stream({
@@ -53145,7 +53587,7 @@ async function checkForUpdate(pkgName, currentVersion, opts = {}) {
53145
53587
  // src/update-context.ts
53146
53588
  init_user_config();
53147
53589
  import { execFileSync } from "child_process";
53148
- import { existsSync as existsSync14, lstatSync, readFileSync as readFileSync10 } from "fs";
53590
+ import { existsSync as existsSync14, lstatSync, readFileSync as readFileSync10, realpathSync } from "fs";
53149
53591
  import { dirname as dirname7, join as join13, sep as sep2 } from "path";
53150
53592
  var SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
53151
53593
  function isValidVersion(v2) {
@@ -53175,10 +53617,19 @@ function isUnderGlobalPrefix(packageRoot, execPath) {
53175
53617
  }
53176
53618
  function detectInstallContext(opts = {}) {
53177
53619
  const pkgName = opts.pkgName ?? "latticesql";
53178
- const cwd = opts.cwd ?? process.cwd();
53179
53620
  const env2 = opts.env ?? process.env;
53180
53621
  const execPath = opts.execPath ?? process.execPath;
53181
- const modulePath = opts.modulePath ?? process.argv[1] ?? cwd;
53622
+ const rawCwd = opts.cwd ?? process.cwd();
53623
+ const rawModulePath = opts.modulePath ?? process.argv[1] ?? rawCwd;
53624
+ const resolveReal = (p3) => {
53625
+ try {
53626
+ return realpathSync(p3);
53627
+ } catch {
53628
+ return p3;
53629
+ }
53630
+ };
53631
+ const modulePath = resolveReal(rawModulePath);
53632
+ const cwd = resolveReal(rawCwd);
53182
53633
  const packageRoot = findPackageRoot(dirname7(modulePath), pkgName);
53183
53634
  if (packageRoot && existsSync14(join13(packageRoot, ".git"))) {
53184
53635
  return {
@@ -53632,6 +54083,8 @@ var css = `
53632
54083
  .app-version:empty { display: none; }
53633
54084
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
53634
54085
  .app-update[hidden] { display: none; }
54086
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
54087
+ #app-update-link[hidden] { display: none; }
53635
54088
  /* Unseen-change count next to a sidebar entity. */
53636
54089
  .nav-badge {
53637
54090
  display: inline-block; min-width: 16px; text-align: center;
@@ -54179,6 +54632,8 @@ var css = `
54179
54632
  .grants-panel .grants-title { font-weight: 600; margin-bottom: 6px; }
54180
54633
  .grants-panel .grants-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
54181
54634
  .grants-panel .grants-row input { accent-color: var(--accent); }
54635
+ .grants-panel .grants-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border); }
54636
+ .grants-panel .grants-dirty { font-size: 12px; }
54182
54637
 
54183
54638
  /* Inline create-row at the bottom of every table */
54184
54639
  tr.create-row td { background: var(--surface-2); }
@@ -55108,6 +55563,12 @@ var appJs = `
55108
55563
  // drag handle once the app has booted.
55109
55564
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
55110
55565
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
55566
+ // The version chip + manual-upgrade link live in the static shell (present
55567
+ // from first paint, in both the normal and virgin-state boots), so wire the
55568
+ // click handler and run the first availability check here \u2014 independent of
55569
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
55570
+ wireUpdateLink();
55571
+ checkUpdateAvailable();
55111
55572
  // Failsafe: never leave the overlay up forever if a fetch hangs without
55112
55573
  // rejecting, or a future early-return (e.g. the virgin-state screen)
55113
55574
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -55592,6 +56053,26 @@ var appJs = `
55592
56053
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
55593
56054
  setTimeout(function () { location.reload(); }, 600);
55594
56055
  }
56056
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
56057
+ // the version chip only when the server reports a newer, installable version.
56058
+ // The auto-updater installs in the background on its own cadence; this lets
56059
+ // the user force it now. Best-effort; the link stays hidden on any failure.
56060
+ function checkUpdateAvailable() {
56061
+ var el = document.getElementById('app-update-link');
56062
+ if (!el) return;
56063
+ fetch('/api/update/status')
56064
+ .then(function (r) { return r.ok ? r.json() : null; })
56065
+ .then(function (s) {
56066
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
56067
+ el.textContent = 'Update available \u2014 Upgrade';
56068
+ el.title = 'Install v' + s.latest + ' and restart';
56069
+ el.hidden = false;
56070
+ } else {
56071
+ el.hidden = true;
56072
+ }
56073
+ })
56074
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
56075
+ }
55595
56076
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
55596
56077
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
55597
56078
  function checkServerVersion() {
@@ -55605,6 +56086,31 @@ var appJs = `
55605
56086
  else hideUpdatePill();
55606
56087
  })
55607
56088
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
56089
+ // Refresh the manual-upgrade link alongside the reconnect version check.
56090
+ checkUpdateAvailable();
56091
+ }
56092
+ // Wire the manual-upgrade link's click: kick off the install (the server
56093
+ // installs the latest and restarts onto it) and surface the progress. On
56094
+ // success we do nothing else \u2014 the update-applied event + the reconnect
56095
+ // version check land the page on the new version (no manual reload). A
56096
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
56097
+ function wireUpdateLink() {
56098
+ var el = document.getElementById('app-update-link');
56099
+ if (!el) return;
56100
+ el.addEventListener('click', function (e) {
56101
+ e.preventDefault();
56102
+ el.hidden = true;
56103
+ showUpdatePill('Updating\u2026');
56104
+ fetch('/api/update/apply', { method: 'POST' })
56105
+ .then(function (r) { return r.json(); })
56106
+ .then(function (d) {
56107
+ if (d && d.ok === false) {
56108
+ hideUpdatePill();
56109
+ showToast(d.error || 'Update unavailable', {});
56110
+ }
56111
+ })
56112
+ .catch(function () { /* server may already be restarting */ });
56113
+ });
55608
56114
  }
55609
56115
  function dispatchStreamMessage(type, data) {
55610
56116
  if (type === 'realtime-state') {
@@ -56645,6 +57151,15 @@ var appJs = `
56645
57151
  // Per-table view state: 'live' (default) or 'trash' (soft-deleted rows).
56646
57152
  var tableViewMode = {};
56647
57153
 
57154
+ // The (table, pk) of the per-row "Manage access" grants panel that is
57155
+ // currently open, or null when none is. A soft re-render (a concurrent edit
57156
+ // by another client fires pg_notify \u2192 realtime refresh \u2192 renderRoute({soft})
57157
+ // \u2192 renderDetail/renderFsItem repaint) would otherwise re-create the detail
57158
+ // view with the panel collapsed, dropping a staged multi-select mid-edit.
57159
+ // wireRowSharing reads this after each repaint and re-opens + re-populates the
57160
+ // panel WITHOUT any network call, so the staged selection survives.
57161
+ var openGrantsPanel = null;
57162
+
56648
57163
  function renderTable(content, tableName) {
56649
57164
  var myGen = renderGen;
56650
57165
  clearUnseen(tableName);
@@ -57123,70 +57638,151 @@ var appJs = `
57123
57638
  }).catch(function (e) { showToast('Visibility update failed: ' + e.message, {}); });
57124
57639
  });
57125
57640
  });
57126
- var detailVisManage = content.querySelector('#detail-vis-manage');
57127
- if (detailVisManage) detailVisManage.addEventListener('click', function () {
57641
+ var access = row._access || {};
57642
+
57643
+ // Render the staged member checklist + a single "Save sharing" / "Cancel"
57644
+ // into the panel. Checkbox toggles mutate ONLY the local desired map \u2014
57645
+ // NO network call per toggle (the old design auto-saved live, one POST per
57646
+ // checkbox, and each grant's pg_notify collapsed the panel). A single batch
57647
+ // request fires on Save. members is the already-fetched list; desired
57648
+ // seeds from the row's current grantees (or a caller-supplied staged map
57649
+ // when re-opening after a soft re-render).
57650
+ function populateGrantsPanel(panel, members, desired) {
57651
+ // Snapshot the CURRENT (committed) grantees so Save can diff desired-vs-
57652
+ // current into adds/removes. effectiveVisibility decides whether we're
57653
+ // actually switching INTO specific-people mode (custom-0 reads as private).
57654
+ var current = {};
57655
+ (access.grantees || []).forEach(function (g) { current[g] = true; });
57656
+ if (members.length === 0) {
57657
+ panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
57658
+ panel.hidden = false;
57659
+ return;
57660
+ }
57661
+ function dirtyCount() {
57662
+ var n = 0;
57663
+ members.forEach(function (m) {
57664
+ if (!!desired[m.role] !== !!current[m.role]) n++;
57665
+ });
57666
+ return n;
57667
+ }
57668
+ function render() {
57669
+ var changed = dirtyCount();
57670
+ panel.innerHTML = '<div class="grants-title">Who can see this</div>' +
57671
+ members.map(function (m) {
57672
+ var label = m.name || m.email || m.role;
57673
+ return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
57674
+ (desired[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
57675
+ }).join('') +
57676
+ '<div class="grants-actions">' +
57677
+ '<button class="btn primary" id="grants-save"' + (changed ? '' : ' disabled') + '>Save sharing</button>' +
57678
+ '<button class="btn" id="grants-cancel">Cancel</button>' +
57679
+ '<span class="grants-dirty muted">' + (changed ? (changed === 1 ? '1 change' : changed + ' changes') : 'No changes') + '</span>' +
57680
+ '</div>';
57681
+ panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
57682
+ cb.addEventListener('change', function () {
57683
+ var role = cb.getAttribute('data-grant-role');
57684
+ if (cb.checked) desired[role] = true; else delete desired[role];
57685
+ render(); // re-render to refresh the dirty indicator + Save state
57686
+ });
57687
+ });
57688
+ var cancelBtn = panel.querySelector('#grants-cancel');
57689
+ if (cancelBtn) cancelBtn.addEventListener('click', function () { closeGrantsPanel(panel); });
57690
+ var saveBtn = panel.querySelector('#grants-save');
57691
+ if (saveBtn) saveBtn.addEventListener('click', function () {
57692
+ var toAdd = [];
57693
+ var toRemove = [];
57694
+ members.forEach(function (m) {
57695
+ var want = !!desired[m.role];
57696
+ var have = !!current[m.role];
57697
+ if (want && !have) toAdd.push(m.role);
57698
+ if (!want && have) toRemove.push(m.role);
57699
+ });
57700
+ if (toAdd.length === 0 && toRemove.length === 0) { closeGrantsPanel(panel); return; }
57701
+ // Confirm the mode change ONCE, here \u2014 only when actually switching
57702
+ // INTO specific-people mode (effective vis isn't already custom AND we
57703
+ // are adding at least one grantee). Never per checkbox.
57704
+ if (effectiveVisibility(access) !== 'custom' && toAdd.length > 0) {
57705
+ if (!confirm('Sharing this with specific people switches it off "everyone"/"private". The chosen people will be able to see it. Continue?')) return;
57706
+ }
57707
+ withBusy(saveBtn, function () {
57708
+ return fetchJson('/api/cloud/row-grants', {
57709
+ method: 'POST',
57710
+ headers: { 'content-type': 'application/json' },
57711
+ body: JSON.stringify({ table: tableName, pk: id, grant: toAdd, revoke: toRemove }),
57712
+ }).then(function () {
57713
+ // Mirror the committed state locally so the re-render's indicator
57714
+ // is correct. The first grant flips the row to custom server-side;
57715
+ // revoking the last leaves custom-0, which effectiveVisibility
57716
+ // renders as private.
57717
+ var list = [];
57718
+ members.forEach(function (m) { if (desired[m.role]) list.push(m.role); });
57719
+ access.grantees = list;
57720
+ if (list.length > 0) access.visibility = 'custom';
57721
+ openGrantsPanel = null; // a successful save closes the staging session
57722
+ invalidate(tableName);
57723
+ showToast('Sharing updated', {});
57724
+ reRender();
57725
+ }).catch(function (e) {
57726
+ // Surface loudly + leave the staged selection intact so the user
57727
+ // can retry; no silent partial-success.
57728
+ showToast('Sharing update failed: ' + e.message, {});
57729
+ });
57730
+ });
57731
+ });
57732
+ panel.hidden = false;
57733
+ }
57734
+ render();
57735
+ }
57736
+
57737
+ function closeGrantsPanel(panel) {
57738
+ if (panel) panel.hidden = true;
57739
+ openGrantsPanel = null;
57740
+ }
57741
+
57742
+ // Open (or toggle shut) the manage-access panel. Fetches the member list,
57743
+ // then stages from the row's current grantees. Opening must NOT pre-flip
57744
+ // the row to 'custom' \u2014 that left a never-shared row stuck at "custom (0)".
57745
+ function openManagePanel(triggerBtn) {
57128
57746
  var panel = content.querySelector('#grants-panel');
57129
57747
  if (!panel) return;
57130
- if (!panel.hidden) { panel.hidden = true; return; }
57131
- var access = row._access || {};
57132
- // Opening the panel must NOT pre-flip the row to 'custom' \u2014 that left a
57133
- // row the user never actually shared stuck at "custom (0)". The first
57134
- // grant flips it to custom server-side (lattice_grant_row); revoking the
57135
- // last leaves it custom-with-0-grantees, which now reads as private. So
57136
- // just load the member checklist.
57137
- var ensure = Promise.resolve();
57138
- withBusy(detailVisManage, function () {
57139
- return ensure.then(function () {
57140
- return fetchJson('/api/cloud/members');
57141
- }).then(function (d) {
57748
+ if (!panel.hidden) { closeGrantsPanel(panel); return; }
57749
+ withBusy(triggerBtn, function () {
57750
+ return fetchJson('/api/cloud/members').then(function (d) {
57142
57751
  // The grant target is a member ROLE: lattice_grant_row keys on the
57143
57752
  // role, and _access.grantees holds role names. List every member
57144
57753
  // except the owner (you don't grant the owner their own row).
57145
57754
  var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
57146
- var granted = {};
57147
- (access.grantees || []).forEach(function (g) { granted[g] = true; });
57148
- if (members.length === 0) {
57149
- panel.innerHTML = '<div class="muted">No other members in this workspace yet.</div>';
57150
- } else {
57151
- panel.innerHTML = '<div class="grants-title">Who can see this</div>' + members.map(function (m) {
57152
- var label = m.name || m.email || m.role;
57153
- return '<label class="grants-row"><input type="checkbox" data-grant-role="' + escapeHtml(m.role) + '"' +
57154
- (granted[m.role] ? ' checked' : '') + '> ' + escapeHtml(label) + '</label>';
57155
- }).join('');
57156
- }
57157
- panel.hidden = false;
57158
- panel.querySelectorAll('[data-grant-role]').forEach(function (cb) {
57159
- cb.addEventListener('change', function () {
57160
- var role = cb.getAttribute('data-grant-role');
57161
- cb.disabled = true;
57162
- fetchJson('/api/cloud/row-grant', {
57163
- method: 'POST',
57164
- headers: { 'content-type': 'application/json' },
57165
- body: JSON.stringify({ table: tableName, pk: id, grantee: role, revoke: !cb.checked }),
57166
- }).then(function () {
57167
- var list = access.grantees || (access.grantees = []);
57168
- var at = list.indexOf(role);
57169
- if (cb.checked && at === -1) list.push(role);
57170
- if (!cb.checked && at !== -1) list.splice(at, 1);
57171
- // The first grant flips the row to custom server-side; mirror
57172
- // that locally so the indicator updates. Revoking the last leaves
57173
- // visibility 'custom' but effectiveVisibility renders custom-0 as
57174
- // private, so the label flips back to "Private to you".
57175
- if (list.length > 0) access.visibility = 'custom';
57176
- var infoEl = content.querySelector('#detail-vis-info');
57177
- if (infoEl) infoEl.textContent = visInfoLabel(access);
57178
- invalidate(tableName);
57179
- }).catch(function (e) {
57180
- cb.checked = !cb.checked; // revert the failed change
57181
- showToast('Access update failed: ' + e.message, {});
57182
- }).then(function () { cb.disabled = false; });
57183
- });
57184
- });
57185
- var infoEl = content.querySelector('#detail-vis-info');
57186
- if (infoEl) infoEl.textContent = visInfoLabel(access);
57755
+ var desired = {};
57756
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
57757
+ openGrantsPanel = { table: tableName, pk: id };
57758
+ populateGrantsPanel(panel, members, desired);
57187
57759
  }).catch(function (e) { showToast('Could not load members: ' + e.message, {}); });
57188
57760
  });
57761
+ }
57762
+
57763
+ var detailVisManage = content.querySelector('#detail-vis-manage');
57764
+ if (detailVisManage) detailVisManage.addEventListener('click', function () {
57765
+ openManagePanel(detailVisManage);
57189
57766
  });
57767
+
57768
+ // Preserve an open panel across a soft re-render: if the tracked panel
57769
+ // matches the row this view just repainted, re-open it and re-populate the
57770
+ // checklist from the freshly-fetched row._access WITHOUT any network call,
57771
+ // so a concurrent edit by another client doesn't lose a staged selection.
57772
+ if (openGrantsPanel && openGrantsPanel.table === tableName && openGrantsPanel.pk === id) {
57773
+ var rpanel = content.querySelector('#grants-panel');
57774
+ if (rpanel) {
57775
+ fetchJson('/api/cloud/members').then(function (d) {
57776
+ // Only re-populate if THIS panel is still the tracked-open one (a
57777
+ // newer navigation/save may have cleared it while members loaded).
57778
+ if (!openGrantsPanel || openGrantsPanel.table !== tableName || openGrantsPanel.pk !== id) return;
57779
+ var members = ((d && d.members) || []).filter(function (m) { return !m.isYou && m.status !== 'owner'; });
57780
+ var desired = {};
57781
+ (access.grantees || []).forEach(function (g) { desired[g] = true; });
57782
+ populateGrantsPanel(rpanel, members, desired);
57783
+ }).catch(function () { /* best-effort restore; a click reopens it */ });
57784
+ }
57785
+ }
57190
57786
  }
57191
57787
  function renderDetail(content, tableName, id) {
57192
57788
  var myGen = renderGen;
@@ -61970,13 +62566,21 @@ var appJs = `
61970
62566
  }
61971
62567
  function uploadFile(file) {
61972
62568
  var done = pendingIngestItem(file.name || 'file');
62569
+ // Carry the composer's "Private mode" intent so an upload made while the
62570
+ // box is checked is stamped private at insert, instead of inheriting the
62571
+ // files-table default (which can be shared-to-everyone on a cloud). Read
62572
+ // the checkbox defensively \u2014 it may not be rendered. On a local workspace
62573
+ // the box is checked+disabled, so this is '1' there too; forced visibility
62574
+ // is a harmless no-op on the single-user SQLite path.
62575
+ var pv = document.getElementById('chat-private');
62576
+ var priv = pv && pv.checked ? '1' : '0';
61973
62577
  return fetch('/api/ingest/upload', {
61974
62578
  method: 'POST',
61975
62579
  // Percent-encode the filename: HTTP header values must be ISO-8859-1,
61976
62580
  // so a Unicode filename (emoji, smart quote, accent, em-dash) would
61977
62581
  // otherwise make fetch() throw "String contains non ISO-8859-1 code
61978
62582
  // point". The server decodeURIComponent()s it back.
61979
- headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file') },
62583
+ headers: { 'content-type': file.type || 'application/octet-stream', 'x-filename': encodeURIComponent(file.name || 'file'), 'x-lattice-private': priv },
61980
62584
  body: file,
61981
62585
  })
61982
62586
  .then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); })
@@ -62324,6 +62928,7 @@ var guiAppHtml = `<!doctype html>
62324
62928
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
62325
62929
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
62326
62930
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
62931
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
62327
62932
  <button id="settings-gear" title="Settings" aria-label="Open settings">
62328
62933
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
62329
62934
  <circle cx="12" cy="12" r="3"/>
@@ -63072,8 +63677,14 @@ var MEMBER_READABLE_BOOKKEEPING = [
63072
63677
  },
63073
63678
  {
63074
63679
  name: "_lattice_gui_audit",
63075
- privs: "SELECT, INSERT",
63076
- why: "GUI undo/redo + version history; RLS (enableGuiAuditRls) scopes reads to entries whose underlying row the member can see"
63680
+ // UPDATE + DELETE are needed by undo/redo/revert (flips an entry's `undone`)
63681
+ // and the redo-stack purge on a new mutation (deletes the session's undone
63682
+ // entries). Safe because enableGuiAuditRls installs per-op UPDATE and DELETE
63683
+ // policies whose USING is `row_id IS NULL OR lattice_row_visible(table_name,
63684
+ // row_id)` — so a member can only update/delete audit rows for entities it can
63685
+ // already see (or schema-level entries that carry no row data).
63686
+ privs: "SELECT, INSERT, UPDATE, DELETE",
63687
+ why: "GUI undo/redo/revert + redo-stack purge + version history; RLS (enableGuiAuditRls) scopes every op to entries whose underlying row the member can see"
63077
63688
  },
63078
63689
  {
63079
63690
  name: "__lattice_user_identity",
@@ -65148,6 +65759,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
65148
65759
  });
65149
65760
  return true;
65150
65761
  }
65762
+ if (pathname === "/api/cloud/row-grants" && method === "POST") {
65763
+ await tryHandler(res, async () => {
65764
+ const body = await readJson(req);
65765
+ const table = typeof body.table === "string" ? body.table : "";
65766
+ const pk = typeof body.pk === "string" ? body.pk : "";
65767
+ const strList = (v2) => Array.isArray(v2) ? v2.filter((x2) => typeof x2 === "string") : [];
65768
+ const grant = strList(body.grant);
65769
+ const revoke = strList(body.revoke);
65770
+ if (!table || !pk) {
65771
+ sendJson(res, { error: "table and pk are required" }, 400);
65772
+ return;
65773
+ }
65774
+ if (ctx.db.getDialect() !== "postgres") {
65775
+ sendJson(res, { error: "Per-row sharing requires a cloud (Postgres) database" }, 400);
65776
+ return;
65777
+ }
65778
+ await batchRowGrants(ctx.db, table, pk, grant, revoke);
65779
+ sendJson(res, { ok: true, table, pk, granted: grant, revoked: revoke });
65780
+ });
65781
+ return true;
65782
+ }
65151
65783
  if (pathname === "/api/cloud/s3-config" && method === "GET") {
65152
65784
  await tryHandler(res, () => {
65153
65785
  const label = activeWorkspaceLabel(ctx.configPath);
@@ -65890,6 +66522,19 @@ async function normalizeImage(path2, maxBytes) {
65890
66522
  function renderJpeg(sharp, path2, quality) {
65891
66523
  return sharp(path2).rotate().resize({ width: MAX_DIM, height: MAX_DIM, fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
65892
66524
  }
66525
+ function buildVisionAnthropicConfig(auth) {
66526
+ const config = {};
66527
+ if (auth.authToken) {
66528
+ config.authToken = auth.authToken;
66529
+ config.apiKey = null;
66530
+ } else if (auth.apiKey) {
66531
+ config.apiKey = auth.apiKey;
66532
+ } else {
66533
+ config.apiKey = null;
66534
+ }
66535
+ if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
66536
+ return config;
66537
+ }
65893
66538
  function defaultSender(auth) {
65894
66539
  return async (input) => {
65895
66540
  const importMetaUrl = import.meta.url;
@@ -65897,11 +66542,7 @@ function defaultSender(auth) {
65897
66542
  const sdk = req("@anthropic-ai/sdk");
65898
66543
  const Anthropic = sdk.Anthropic ?? sdk.default;
65899
66544
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
65900
- const config = {};
65901
- if (auth.authToken) config.authToken = auth.authToken;
65902
- else if (auth.apiKey) config.apiKey = auth.apiKey;
65903
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
65904
- const client = new Anthropic(config);
66545
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
65905
66546
  const res = await client.messages.create({
65906
66547
  model: input.model,
65907
66548
  max_tokens: 1024,
@@ -65928,11 +66569,7 @@ function defaultPdfSender(auth) {
65928
66569
  const sdk = req("@anthropic-ai/sdk");
65929
66570
  const Anthropic = sdk.Anthropic ?? sdk.default;
65930
66571
  if (!Anthropic) throw new Error("Could not resolve Anthropic from '@anthropic-ai/sdk'");
65931
- const config = {};
65932
- if (auth.authToken) config.authToken = auth.authToken;
65933
- else if (auth.apiKey) config.apiKey = auth.apiKey;
65934
- if (auth.betaHeader) config.defaultHeaders = { "anthropic-beta": auth.betaHeader };
65935
- const client = new Anthropic(config);
66572
+ const client = new Anthropic(buildVisionAnthropicConfig(auth));
65936
66573
  const res = await client.messages.create({
65937
66574
  model: input.model,
65938
66575
  max_tokens: 4096,
@@ -66094,7 +66731,7 @@ function enrichContext(ctx) {
66094
66731
  ...ctx.createEntity ? { createEntity: ctx.createEntity } : {}
66095
66732
  };
66096
66733
  }
66097
- async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
66734
+ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res, privateMode) {
66098
66735
  try {
66099
66736
  return await enrichWithLlm(
66100
66737
  mctx,
@@ -66106,7 +66743,9 @@ async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
66106
66743
  ctx.entityDescriptions,
66107
66744
  ctx.createJunction,
66108
66745
  ctx.aggressiveness,
66109
- ctx.createEntity
66746
+ ctx.createEntity,
66747
+ false,
66748
+ privateMode
66110
66749
  );
66111
66750
  } catch (e6) {
66112
66751
  const err = e6;
@@ -66185,7 +66824,9 @@ async function dispatchIngestRoute(req, res, ctx) {
66185
66824
  source: "ingest",
66186
66825
  onColumnsAdded: columnDescriptionHook(ctx.db)
66187
66826
  };
66827
+ const headerPrivate = req.headers["x-lattice-private"] === "1";
66188
66828
  if (ctx.pathname === "/api/ingest/upload") {
66829
+ const forcePrivate2 = headerPrivate;
66189
66830
  const rawName = typeof req.headers["x-filename"] === "string" && req.headers["x-filename"] || "";
66190
66831
  let name2 = "upload";
66191
66832
  if (rawName) {
@@ -66283,10 +66924,15 @@ async function dispatchIngestRoute(req, res, ctx) {
66283
66924
  ...blob ? { blob_path: blob.blob_path } : {}
66284
66925
  } : blob ? { ref_kind: "blob", blob_path: blob.blob_path } : {}
66285
66926
  };
66286
- const { id: id2 } = await createRow(mctx, "files", {
66287
- ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
66288
- ...uploadRow
66289
- });
66927
+ const { id: id2 } = await createRow(
66928
+ mctx,
66929
+ "files",
66930
+ {
66931
+ ...await requiredFileDefaults(ctx.db, name2, fileId, uploadRow),
66932
+ ...uploadRow
66933
+ },
66934
+ forcePrivate2 ? "private" : void 0
66935
+ );
66290
66936
  try {
66291
66937
  const dedupCtx = {
66292
66938
  db: ctx.db,
@@ -66312,7 +66958,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66312
66958
  }
66313
66959
  let suggestedLinks = [];
66314
66960
  if (!result.skip) {
66315
- const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
66961
+ const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res, forcePrivate2);
66316
66962
  if (links === null) return true;
66317
66963
  suggestedLinks = links;
66318
66964
  }
@@ -66339,6 +66985,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66339
66985
  sendJson4(res, { error: e6.message }, 400);
66340
66986
  return true;
66341
66987
  }
66988
+ const forcePrivate = headerPrivate || body.private === true;
66342
66989
  if (ctx.pathname === "/api/ingest/text") {
66343
66990
  const rawText = typeof body.text === "string" ? body.text : "";
66344
66991
  if (!rawText.trim()) {
@@ -66349,7 +66996,7 @@ async function dispatchIngestRoute(req, res, ctx) {
66349
66996
  if (sourceUrl) {
66350
66997
  try {
66351
66998
  const result = await ingestUrlAsFile(
66352
- { db: ctx.db, mctx, enrich: enrichContext(ctx) },
66999
+ { db: ctx.db, mctx, enrich: enrichContext(ctx), privateMode: forcePrivate },
66353
67000
  sourceUrl
66354
67001
  );
66355
67002
  sendJson4(
@@ -66378,11 +67025,25 @@ async function dispatchIngestRoute(req, res, ctx) {
66378
67025
  description: describe(content, mime2, title),
66379
67026
  extraction_status: "extracted"
66380
67027
  };
66381
- const { id: id2 } = await createRow(mctx, "files", {
66382
- ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
66383
- ...textRow
66384
- });
66385
- const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
67028
+ const { id: id2 } = await createRow(
67029
+ mctx,
67030
+ "files",
67031
+ {
67032
+ ...await requiredFileDefaults(ctx.db, title, textFileId, textRow),
67033
+ ...textRow
67034
+ },
67035
+ forcePrivate ? "private" : void 0
67036
+ );
67037
+ const suggestedLinks = await enrichOrFail(
67038
+ mctx,
67039
+ ctx.db,
67040
+ id2,
67041
+ content,
67042
+ title,
67043
+ ctx,
67044
+ res,
67045
+ forcePrivate
67046
+ );
66386
67047
  if (suggestedLinks === null) return true;
66387
67048
  sendJson4(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
66388
67049
  return true;
@@ -66421,10 +67082,15 @@ async function dispatchIngestRoute(req, res, ctx) {
66421
67082
  size_bytes: size,
66422
67083
  extraction_status: "pending"
66423
67084
  };
66424
- const { id } = await createRow(mctx, "files", {
66425
- ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
66426
- ...localRow
66427
- });
67085
+ const { id } = await createRow(
67086
+ mctx,
67087
+ "files",
67088
+ {
67089
+ ...await requiredFileDefaults(ctx.db, name, localFileId, localRow),
67090
+ ...localRow
67091
+ },
67092
+ forcePrivate ? "private" : void 0
67093
+ );
66428
67094
  try {
66429
67095
  const result = await extractSource(ctx.db, abs, mime, name);
66430
67096
  await updateRow(mctx, "files", id, {
@@ -66442,7 +67108,9 @@ async function dispatchIngestRoute(req, res, ctx) {
66442
67108
  ctx.entityDescriptions,
66443
67109
  ctx.createJunction,
66444
67110
  ctx.aggressiveness,
66445
- ctx.createEntity
67111
+ ctx.createEntity,
67112
+ false,
67113
+ forcePrivate
66446
67114
  );
66447
67115
  sendJson4(
66448
67116
  res,
@@ -67129,7 +67797,7 @@ function startBackgroundRender(active) {
67129
67797
  }
67130
67798
  bus.publish(e6);
67131
67799
  };
67132
- void db.renderInBackground(active.outputDir, { signal, onProgress }).then(
67800
+ void db.renderInBackground(active.outputDir, { signal, onProgress, gateOnOpen: true }).then(
67133
67801
  () => {
67134
67802
  },
67135
67803
  (err) => {
@@ -67471,6 +68139,28 @@ async function startGuiServer(options) {
67471
68139
  setActive(next, created.id);
67472
68140
  return created.id;
67473
68141
  };
68142
+ const cleanupWorkspaceFiles = (root6, ws) => {
68143
+ if (!ws.configPath && ws.kind === "local") {
68144
+ rmSync(workspaceDir(root6, ws.dir), { recursive: true, force: true });
68145
+ } else if (ws.kind === "cloud") {
68146
+ if (ws.configPath && existsSync25(ws.configPath)) {
68147
+ rmSync(ws.configPath, { force: true });
68148
+ }
68149
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
68150
+ const label = labelMatch?.[1];
68151
+ if (label) {
68152
+ const stillUsed = listWorkspaces(root6).some(
68153
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
68154
+ );
68155
+ if (!stillUsed) {
68156
+ try {
68157
+ deleteDbCredential(label);
68158
+ } catch {
68159
+ }
68160
+ }
68161
+ }
68162
+ }
68163
+ };
67474
68164
  const handleVirginRoute = async (req, res, pathname, method) => {
67475
68165
  if (method === "GET" && pathname === "/") {
67476
68166
  sendText(
@@ -67522,6 +68212,35 @@ async function startGuiServer(options) {
67522
68212
  }
67523
68213
  return true;
67524
68214
  }
68215
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
68216
+ if (!latticeRoot) {
68217
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
68218
+ return true;
68219
+ }
68220
+ const body = await readJson(req);
68221
+ if (typeof body.id !== "string") {
68222
+ sendJson(res, { error: "id must be a string" }, 400);
68223
+ return true;
68224
+ }
68225
+ const ws = getWorkspace(latticeRoot, body.id);
68226
+ if (!ws) {
68227
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
68228
+ return true;
68229
+ }
68230
+ removeWorkspace(latticeRoot, ws.id);
68231
+ try {
68232
+ cleanupWorkspaceFiles(latticeRoot, ws);
68233
+ } catch (e6) {
68234
+ sendJson(
68235
+ res,
68236
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
68237
+ 500
68238
+ );
68239
+ return true;
68240
+ }
68241
+ sendJson(res, { ok: true, switchedTo: null });
68242
+ return true;
68243
+ }
67525
68244
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
67526
68245
  await redeemInvite(createCloudWorkspace, req, res);
67527
68246
  return true;
@@ -67556,6 +68275,18 @@ async function startGuiServer(options) {
67556
68275
  );
67557
68276
  return;
67558
68277
  }
68278
+ if (method === "POST" && pathname === "/api/update/apply") {
68279
+ if (updateService) {
68280
+ void updateService.checkNow(true);
68281
+ sendJson(res, { ok: true, status: updateService.status() });
68282
+ } else {
68283
+ sendJson(res, {
68284
+ ok: false,
68285
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
68286
+ });
68287
+ }
68288
+ return;
68289
+ }
67559
68290
  if (!activeRef) {
67560
68291
  if (await handleVirginRoute(req, res, pathname, method)) return;
67561
68292
  sendJson(res, { error: "No active workspace" }, 409);
@@ -68649,26 +69380,7 @@ async function startGuiServer(options) {
68649
69380
  }
68650
69381
  removeWorkspace(latticeRoot, ws.id);
68651
69382
  try {
68652
- if (!ws.configPath && ws.kind === "local") {
68653
- rmSync(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
68654
- } else if (ws.kind === "cloud") {
68655
- if (ws.configPath && existsSync25(ws.configPath)) {
68656
- rmSync(ws.configPath, { force: true });
68657
- }
68658
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
68659
- const label = labelMatch?.[1];
68660
- if (label) {
68661
- const stillUsed = listWorkspaces(latticeRoot).some(
68662
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
68663
- );
68664
- if (!stillUsed) {
68665
- try {
68666
- deleteDbCredential(label);
68667
- } catch {
68668
- }
68669
- }
68670
- }
68671
- }
69383
+ cleanupWorkspaceFiles(latticeRoot, ws);
68672
69384
  } catch (e6) {
68673
69385
  sendJson(
68674
69386
  res,
@@ -69134,7 +69846,9 @@ ${e6.stack ?? ""}`
69134
69846
  }
69135
69847
  }
69136
69848
  };
69137
- if (options.selfUpdate && guiVersion) {
69849
+ if (options.updateServiceFactory) {
69850
+ updateService = options.updateServiceFactory(broadcast);
69851
+ } else if (options.selfUpdate && guiVersion) {
69138
69852
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
69139
69853
  }
69140
69854
  const handleEventStream = (ws) => {
@@ -69507,7 +70221,7 @@ function printHelp() {
69507
70221
  );
69508
70222
  }
69509
70223
  function getVersion() {
69510
- if (true) return "3.4.3";
70224
+ if (true) return "3.4.5";
69511
70225
  try {
69512
70226
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
69513
70227
  const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8"));