latticesql 3.4.4 → 3.4.6

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
@@ -632,14 +632,6 @@ function resolveDbPath(raw, configDir2) {
632
632
  }
633
633
  return resolve(configDir2, raw);
634
634
  }
635
- function warnDeprecatedRef(entity, field, target) {
636
- const key = `${entity}.${field}`;
637
- if (warnedDeprecatedRefs.has(key)) return;
638
- warnedDeprecatedRefs.add(key);
639
- console.warn(
640
- `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.`
641
- );
642
- }
643
635
  function entityToTableDef(entityName, entity) {
644
636
  const rawFields = entity.fields;
645
637
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -666,7 +658,6 @@ function entityToTableDef(entityName, entity) {
666
658
  table: field.ref,
667
659
  foreignKey: fieldName
668
660
  };
669
- warnDeprecatedRef(entityName, fieldName, field.ref);
670
661
  }
671
662
  }
672
663
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -823,12 +814,10 @@ function parseEntityContexts(entityContexts) {
823
814
  }
824
815
  return result;
825
816
  }
826
- var warnedDeprecatedRefs;
827
817
  var init_parser = __esm({
828
818
  "src/config/parser.ts"() {
829
819
  "use strict";
830
820
  init_user_config();
831
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
832
821
  }
833
822
  });
834
823
 
@@ -10324,6 +10313,21 @@ var init_registry = __esm({
10324
10313
  ["table", "values"]
10325
10314
  )
10326
10315
  },
10316
+ {
10317
+ name: "create_secret",
10318
+ description: "Store a secret/credential \u2014 an API key, password, OAuth token, connection string, etc. \u2014 by name in the encrypted secrets store. Use this whenever the user gives you a credential to save or asks you to remember/store a secret. WRITE-ONLY: you can save a secret but you can NEVER read, list, echo, or retrieve existing secret values \u2014 they are hidden from you. The value is encrypted at rest.",
10319
+ mutates: true,
10320
+ category: "row",
10321
+ args: obj(
10322
+ {
10323
+ name: str('A short label for the secret, e.g. "GitHub password" or "OpenAI API key".'),
10324
+ value: str("The secret value to store."),
10325
+ kind: str('Optional kind, e.g. "password", "api_key", "token", "connection_string".'),
10326
+ description: str("Optional note about what the secret is for.")
10327
+ },
10328
+ ["name", "value"]
10329
+ )
10330
+ },
10327
10331
  {
10328
10332
  name: "create_artifact",
10329
10333
  description: "Create a markdown document and save it as a file artifact. Use this whenever the user asks you to create, write, draft, or make a document, note, write-up, summary, report, or file \u2014 you author the content as GitHub-flavored markdown and it is saved in the files entity as a markdown artifact, then opened in the viewer for them. Prefer this over create_row on files for any document the user wants to keep. It follows the same sharing rules as any file (private mode \u2192 private).",
@@ -13167,6 +13171,7 @@ var init_ingest_url = __esm({
13167
13171
  });
13168
13172
 
13169
13173
  // src/gui/ai/dispatch.ts
13174
+ import { randomUUID } from "crypto";
13170
13175
  function visibilityDenialReason(opts) {
13171
13176
  if (opts.kind === "table") {
13172
13177
  return opts.canManageTableDefault ? null : "Only the workspace owner can change a table's default sharing.";
@@ -13382,6 +13387,21 @@ async function executeFunction(ctx, name, args) {
13382
13387
  );
13383
13388
  return { ok: true, result: { id } };
13384
13389
  }
13390
+ case "create_secret": {
13391
+ const secretName = requireString(args.name, "name");
13392
+ const secretValue2 = requireString(args.value, "value");
13393
+ const kind = typeof args.kind === "string" && args.kind ? args.kind : null;
13394
+ const description = typeof args.description === "string" && args.description ? args.description : null;
13395
+ const id = randomUUID();
13396
+ await ctx.db.insert("secrets", {
13397
+ id,
13398
+ name: secretName,
13399
+ value: secretValue2,
13400
+ kind,
13401
+ description
13402
+ });
13403
+ return { ok: true, result: { id, name: secretName } };
13404
+ }
13385
13405
  case "create_artifact": {
13386
13406
  const table = requireTable("files", ctx.validTables);
13387
13407
  const title = requireString(args.title, "title");
@@ -13712,6 +13732,7 @@ var init_dispatch = __esm({
13712
13732
  "get_history",
13713
13733
  "create_row",
13714
13734
  "create_artifact",
13735
+ "create_secret",
13715
13736
  "ingest_url",
13716
13737
  "set_definition",
13717
13738
  "set_visibility",
@@ -54094,6 +54115,8 @@ var css = `
54094
54115
  .app-version:empty { display: none; }
54095
54116
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
54096
54117
  .app-update[hidden] { display: none; }
54118
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
54119
+ #app-update-link[hidden] { display: none; }
54097
54120
  /* Unseen-change count next to a sidebar entity. */
54098
54121
  .nav-badge {
54099
54122
  display: inline-block; min-width: 16px; text-align: center;
@@ -55572,6 +55595,12 @@ var appJs = `
55572
55595
  // drag handle once the app has booted.
55573
55596
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
55574
55597
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
55598
+ // The version chip + manual-upgrade link live in the static shell (present
55599
+ // from first paint, in both the normal and virgin-state boots), so wire the
55600
+ // click handler and run the first availability check here \u2014 independent of
55601
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
55602
+ wireUpdateLink();
55603
+ checkUpdateAvailable();
55575
55604
  // Failsafe: never leave the overlay up forever if a fetch hangs without
55576
55605
  // rejecting, or a future early-return (e.g. the virgin-state screen)
55577
55606
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -56056,6 +56085,26 @@ var appJs = `
56056
56085
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
56057
56086
  setTimeout(function () { location.reload(); }, 600);
56058
56087
  }
56088
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
56089
+ // the version chip only when the server reports a newer, installable version.
56090
+ // The auto-updater installs in the background on its own cadence; this lets
56091
+ // the user force it now. Best-effort; the link stays hidden on any failure.
56092
+ function checkUpdateAvailable() {
56093
+ var el = document.getElementById('app-update-link');
56094
+ if (!el) return;
56095
+ fetch('/api/update/status')
56096
+ .then(function (r) { return r.ok ? r.json() : null; })
56097
+ .then(function (s) {
56098
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
56099
+ el.textContent = 'Update available \u2014 Upgrade';
56100
+ el.title = 'Install v' + s.latest + ' and restart';
56101
+ el.hidden = false;
56102
+ } else {
56103
+ el.hidden = true;
56104
+ }
56105
+ })
56106
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
56107
+ }
56059
56108
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
56060
56109
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
56061
56110
  function checkServerVersion() {
@@ -56069,6 +56118,31 @@ var appJs = `
56069
56118
  else hideUpdatePill();
56070
56119
  })
56071
56120
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
56121
+ // Refresh the manual-upgrade link alongside the reconnect version check.
56122
+ checkUpdateAvailable();
56123
+ }
56124
+ // Wire the manual-upgrade link's click: kick off the install (the server
56125
+ // installs the latest and restarts onto it) and surface the progress. On
56126
+ // success we do nothing else \u2014 the update-applied event + the reconnect
56127
+ // version check land the page on the new version (no manual reload). A
56128
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
56129
+ function wireUpdateLink() {
56130
+ var el = document.getElementById('app-update-link');
56131
+ if (!el) return;
56132
+ el.addEventListener('click', function (e) {
56133
+ e.preventDefault();
56134
+ el.hidden = true;
56135
+ showUpdatePill('Updating\u2026');
56136
+ fetch('/api/update/apply', { method: 'POST' })
56137
+ .then(function (r) { return r.json(); })
56138
+ .then(function (d) {
56139
+ if (d && d.ok === false) {
56140
+ hideUpdatePill();
56141
+ showToast(d.error || 'Update unavailable', {});
56142
+ }
56143
+ })
56144
+ .catch(function () { /* server may already be restarting */ });
56145
+ });
56072
56146
  }
56073
56147
  function dispatchStreamMessage(type, data) {
56074
56148
  if (type === 'realtime-state') {
@@ -62886,6 +62960,7 @@ var guiAppHtml = `<!doctype html>
62886
62960
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
62887
62961
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
62888
62962
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
62963
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
62889
62964
  <button id="settings-gear" title="Settings" aria-label="Open settings">
62890
62965
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
62891
62966
  <circle cx="12" cy="12" r="3"/>
@@ -64809,7 +64884,7 @@ function redeemInviteToken(email, token) {
64809
64884
  init_markdown();
64810
64885
  init_rls();
64811
64886
  init_adapter();
64812
- import { randomUUID } from "crypto";
64887
+ import { randomUUID as randomUUID2 } from "crypto";
64813
64888
 
64814
64889
  // src/framework/cloud-migration.ts
64815
64890
  init_lattice();
@@ -65604,7 +65679,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
65604
65679
  `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","email","expires_at")
65605
65680
  VALUES (?, ?, ?, ?, ?)`,
65606
65681
  [
65607
- randomUUID(),
65682
+ randomUUID2(),
65608
65683
  role,
65609
65684
  emailHash,
65610
65685
  // Plaintext email stored ONLY in this owner-only table so the owner's
@@ -68096,6 +68171,28 @@ async function startGuiServer(options) {
68096
68171
  setActive(next, created.id);
68097
68172
  return created.id;
68098
68173
  };
68174
+ const cleanupWorkspaceFiles = (root6, ws) => {
68175
+ if (!ws.configPath && ws.kind === "local") {
68176
+ rmSync(workspaceDir(root6, ws.dir), { recursive: true, force: true });
68177
+ } else if (ws.kind === "cloud") {
68178
+ if (ws.configPath && existsSync25(ws.configPath)) {
68179
+ rmSync(ws.configPath, { force: true });
68180
+ }
68181
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
68182
+ const label = labelMatch?.[1];
68183
+ if (label) {
68184
+ const stillUsed = listWorkspaces(root6).some(
68185
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
68186
+ );
68187
+ if (!stillUsed) {
68188
+ try {
68189
+ deleteDbCredential(label);
68190
+ } catch {
68191
+ }
68192
+ }
68193
+ }
68194
+ }
68195
+ };
68099
68196
  const handleVirginRoute = async (req, res, pathname, method) => {
68100
68197
  if (method === "GET" && pathname === "/") {
68101
68198
  sendText(
@@ -68147,6 +68244,35 @@ async function startGuiServer(options) {
68147
68244
  }
68148
68245
  return true;
68149
68246
  }
68247
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
68248
+ if (!latticeRoot) {
68249
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
68250
+ return true;
68251
+ }
68252
+ const body = await readJson(req);
68253
+ if (typeof body.id !== "string") {
68254
+ sendJson(res, { error: "id must be a string" }, 400);
68255
+ return true;
68256
+ }
68257
+ const ws = getWorkspace(latticeRoot, body.id);
68258
+ if (!ws) {
68259
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
68260
+ return true;
68261
+ }
68262
+ removeWorkspace(latticeRoot, ws.id);
68263
+ try {
68264
+ cleanupWorkspaceFiles(latticeRoot, ws);
68265
+ } catch (e6) {
68266
+ sendJson(
68267
+ res,
68268
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
68269
+ 500
68270
+ );
68271
+ return true;
68272
+ }
68273
+ sendJson(res, { ok: true, switchedTo: null });
68274
+ return true;
68275
+ }
68150
68276
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
68151
68277
  await redeemInvite(createCloudWorkspace, req, res);
68152
68278
  return true;
@@ -68181,6 +68307,18 @@ async function startGuiServer(options) {
68181
68307
  );
68182
68308
  return;
68183
68309
  }
68310
+ if (method === "POST" && pathname === "/api/update/apply") {
68311
+ if (updateService) {
68312
+ void updateService.checkNow(true);
68313
+ sendJson(res, { ok: true, status: updateService.status() });
68314
+ } else {
68315
+ sendJson(res, {
68316
+ ok: false,
68317
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
68318
+ });
68319
+ }
68320
+ return;
68321
+ }
68184
68322
  if (!activeRef) {
68185
68323
  if (await handleVirginRoute(req, res, pathname, method)) return;
68186
68324
  sendJson(res, { error: "No active workspace" }, 409);
@@ -69274,26 +69412,7 @@ async function startGuiServer(options) {
69274
69412
  }
69275
69413
  removeWorkspace(latticeRoot, ws.id);
69276
69414
  try {
69277
- if (!ws.configPath && ws.kind === "local") {
69278
- rmSync(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
69279
- } else if (ws.kind === "cloud") {
69280
- if (ws.configPath && existsSync25(ws.configPath)) {
69281
- rmSync(ws.configPath, { force: true });
69282
- }
69283
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69284
- const label = labelMatch?.[1];
69285
- if (label) {
69286
- const stillUsed = listWorkspaces(latticeRoot).some(
69287
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69288
- );
69289
- if (!stillUsed) {
69290
- try {
69291
- deleteDbCredential(label);
69292
- } catch {
69293
- }
69294
- }
69295
- }
69296
- }
69415
+ cleanupWorkspaceFiles(latticeRoot, ws);
69297
69416
  } catch (e6) {
69298
69417
  sendJson(
69299
69418
  res,
@@ -69759,7 +69878,9 @@ ${e6.stack ?? ""}`
69759
69878
  }
69760
69879
  }
69761
69880
  };
69762
- if (options.selfUpdate && guiVersion) {
69881
+ if (options.updateServiceFactory) {
69882
+ updateService = options.updateServiceFactory(broadcast);
69883
+ } else if (options.selfUpdate && guiVersion) {
69763
69884
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
69764
69885
  }
69765
69886
  const handleEventStream = (ws) => {
@@ -70132,7 +70253,7 @@ function printHelp() {
70132
70253
  );
70133
70254
  }
70134
70255
  function getVersion() {
70135
- if (true) return "3.4.4";
70256
+ if (true) return "3.4.6";
70136
70257
  try {
70137
70258
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
70138
70259
  const pkg = JSON.parse(readFileSync20(pkgPath, "utf-8"));
package/dist/index.cjs CHANGED
@@ -4527,14 +4527,6 @@ function resolveDbPath(raw, configDir2) {
4527
4527
  }
4528
4528
  return (0, import_node_path11.resolve)(configDir2, raw);
4529
4529
  }
4530
- function warnDeprecatedRef(entity, field, target) {
4531
- const key = `${entity}.${field}`;
4532
- if (warnedDeprecatedRefs.has(key)) return;
4533
- warnedDeprecatedRefs.add(key);
4534
- console.warn(
4535
- `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.`
4536
- );
4537
- }
4538
4530
  function entityToTableDef(entityName, entity) {
4539
4531
  const rawFields = entity.fields;
4540
4532
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -4561,7 +4553,6 @@ function entityToTableDef(entityName, entity) {
4561
4553
  table: field.ref,
4562
4554
  foreignKey: fieldName
4563
4555
  };
4564
- warnDeprecatedRef(entityName, fieldName, field.ref);
4565
4556
  }
4566
4557
  }
4567
4558
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -4718,7 +4709,7 @@ function parseEntityContexts(entityContexts) {
4718
4709
  }
4719
4710
  return result;
4720
4711
  }
4721
- var import_node_fs10, import_node_path11, import_yaml3, warnedDeprecatedRefs;
4712
+ var import_node_fs10, import_node_path11, import_yaml3;
4722
4713
  var init_parser = __esm({
4723
4714
  "src/config/parser.ts"() {
4724
4715
  "use strict";
@@ -4726,7 +4717,6 @@ var init_parser = __esm({
4726
4717
  import_node_path11 = require("path");
4727
4718
  import_yaml3 = require("yaml");
4728
4719
  init_user_config();
4729
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
4730
4720
  }
4731
4721
  });
4732
4722
 
@@ -50616,6 +50606,21 @@ var init_registry = __esm({
50616
50606
  ["table", "values"]
50617
50607
  )
50618
50608
  },
50609
+ {
50610
+ name: "create_secret",
50611
+ description: "Store a secret/credential \u2014 an API key, password, OAuth token, connection string, etc. \u2014 by name in the encrypted secrets store. Use this whenever the user gives you a credential to save or asks you to remember/store a secret. WRITE-ONLY: you can save a secret but you can NEVER read, list, echo, or retrieve existing secret values \u2014 they are hidden from you. The value is encrypted at rest.",
50612
+ mutates: true,
50613
+ category: "row",
50614
+ args: obj(
50615
+ {
50616
+ name: str('A short label for the secret, e.g. "GitHub password" or "OpenAI API key".'),
50617
+ value: str("The secret value to store."),
50618
+ kind: str('Optional kind, e.g. "password", "api_key", "token", "connection_string".'),
50619
+ description: str("Optional note about what the secret is for.")
50620
+ },
50621
+ ["name", "value"]
50622
+ )
50623
+ },
50619
50624
  {
50620
50625
  name: "create_artifact",
50621
50626
  description: "Create a markdown document and save it as a file artifact. Use this whenever the user asks you to create, write, draft, or make a document, note, write-up, summary, report, or file \u2014 you author the content as GitHub-flavored markdown and it is saved in the files entity as a markdown artifact, then opened in the viewer for them. Prefer this over create_row on files for any document the user wants to keep. It follows the same sharing rules as any file (private mode \u2192 private).",
@@ -53038,6 +53043,21 @@ async function executeFunction(ctx, name, args) {
53038
53043
  );
53039
53044
  return { ok: true, result: { id } };
53040
53045
  }
53046
+ case "create_secret": {
53047
+ const secretName = requireString(args.name, "name");
53048
+ const secretValue2 = requireString(args.value, "value");
53049
+ const kind = typeof args.kind === "string" && args.kind ? args.kind : null;
53050
+ const description = typeof args.description === "string" && args.description ? args.description : null;
53051
+ const id = (0, import_node_crypto18.randomUUID)();
53052
+ await ctx.db.insert("secrets", {
53053
+ id,
53054
+ name: secretName,
53055
+ value: secretValue2,
53056
+ kind,
53057
+ description
53058
+ });
53059
+ return { ok: true, result: { id, name: secretName } };
53060
+ }
53041
53061
  case "create_artifact": {
53042
53062
  const table = requireTable("files", ctx.validTables);
53043
53063
  const title = requireString(args.title, "title");
@@ -53341,10 +53361,11 @@ async function executeFunction(ctx, name, args) {
53341
53361
  return { ok: false, error: e6.message };
53342
53362
  }
53343
53363
  }
53344
- var DISPATCHABLE, ASSISTANT_HIDDEN_TABLES, SECRET_MASK, BULK_FILTER_OPS;
53364
+ var import_node_crypto18, DISPATCHABLE, ASSISTANT_HIDDEN_TABLES, SECRET_MASK, BULK_FILTER_OPS;
53345
53365
  var init_dispatch = __esm({
53346
53366
  "src/gui/ai/dispatch.ts"() {
53347
53367
  "use strict";
53368
+ import_node_crypto18 = require("crypto");
53348
53369
  init_registry();
53349
53370
  init_lattice_docs();
53350
53371
  init_fts();
@@ -53368,6 +53389,7 @@ var init_dispatch = __esm({
53368
53389
  "get_history",
53369
53390
  "create_row",
53370
53391
  "create_artifact",
53392
+ "create_secret",
53371
53393
  "ingest_url",
53372
53394
  "set_definition",
53373
53395
  "set_visibility",
@@ -55971,6 +55993,8 @@ var css = `
55971
55993
  .app-version:empty { display: none; }
55972
55994
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
55973
55995
  .app-update[hidden] { display: none; }
55996
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
55997
+ #app-update-link[hidden] { display: none; }
55974
55998
  /* Unseen-change count next to a sidebar entity. */
55975
55999
  .nav-badge {
55976
56000
  display: inline-block; min-width: 16px; text-align: center;
@@ -57449,6 +57473,12 @@ var appJs = `
57449
57473
  // drag handle once the app has booted.
57450
57474
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
57451
57475
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
57476
+ // The version chip + manual-upgrade link live in the static shell (present
57477
+ // from first paint, in both the normal and virgin-state boots), so wire the
57478
+ // click handler and run the first availability check here \u2014 independent of
57479
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
57480
+ wireUpdateLink();
57481
+ checkUpdateAvailable();
57452
57482
  // Failsafe: never leave the overlay up forever if a fetch hangs without
57453
57483
  // rejecting, or a future early-return (e.g. the virgin-state screen)
57454
57484
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -57933,6 +57963,26 @@ var appJs = `
57933
57963
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
57934
57964
  setTimeout(function () { location.reload(); }, 600);
57935
57965
  }
57966
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
57967
+ // the version chip only when the server reports a newer, installable version.
57968
+ // The auto-updater installs in the background on its own cadence; this lets
57969
+ // the user force it now. Best-effort; the link stays hidden on any failure.
57970
+ function checkUpdateAvailable() {
57971
+ var el = document.getElementById('app-update-link');
57972
+ if (!el) return;
57973
+ fetch('/api/update/status')
57974
+ .then(function (r) { return r.ok ? r.json() : null; })
57975
+ .then(function (s) {
57976
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
57977
+ el.textContent = 'Update available \u2014 Upgrade';
57978
+ el.title = 'Install v' + s.latest + ' and restart';
57979
+ el.hidden = false;
57980
+ } else {
57981
+ el.hidden = true;
57982
+ }
57983
+ })
57984
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
57985
+ }
57936
57986
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
57937
57987
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
57938
57988
  function checkServerVersion() {
@@ -57946,6 +57996,31 @@ var appJs = `
57946
57996
  else hideUpdatePill();
57947
57997
  })
57948
57998
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
57999
+ // Refresh the manual-upgrade link alongside the reconnect version check.
58000
+ checkUpdateAvailable();
58001
+ }
58002
+ // Wire the manual-upgrade link's click: kick off the install (the server
58003
+ // installs the latest and restarts onto it) and surface the progress. On
58004
+ // success we do nothing else \u2014 the update-applied event + the reconnect
58005
+ // version check land the page on the new version (no manual reload). A
58006
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
58007
+ function wireUpdateLink() {
58008
+ var el = document.getElementById('app-update-link');
58009
+ if (!el) return;
58010
+ el.addEventListener('click', function (e) {
58011
+ e.preventDefault();
58012
+ el.hidden = true;
58013
+ showUpdatePill('Updating\u2026');
58014
+ fetch('/api/update/apply', { method: 'POST' })
58015
+ .then(function (r) { return r.json(); })
58016
+ .then(function (d) {
58017
+ if (d && d.ok === false) {
58018
+ hideUpdatePill();
58019
+ showToast(d.error || 'Update unavailable', {});
58020
+ }
58021
+ })
58022
+ .catch(function () { /* server may already be restarting */ });
58023
+ });
57949
58024
  }
57950
58025
  function dispatchStreamMessage(type, data) {
57951
58026
  if (type === 'realtime-state') {
@@ -64763,6 +64838,7 @@ var guiAppHtml = `<!doctype html>
64763
64838
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
64764
64839
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
64765
64840
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
64841
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
64766
64842
  <button id="settings-gear" title="Settings" aria-label="Open settings">
64767
64843
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64768
64844
  <circle cx="12" cy="12" r="3"/>
@@ -66278,11 +66354,11 @@ var import_yaml6 = require("yaml");
66278
66354
  init_lattice();
66279
66355
  init_user_config();
66280
66356
  init_cloud_connect();
66281
- var import_node_crypto19 = require("crypto");
66357
+ var import_node_crypto20 = require("crypto");
66282
66358
  init_members();
66283
66359
 
66284
66360
  // src/cloud/invite.ts
66285
- var import_node_crypto18 = require("crypto");
66361
+ var import_node_crypto19 = require("crypto");
66286
66362
  var VERSION = 1;
66287
66363
  var SALT_LEN = 16;
66288
66364
  var SECRET_LEN = 32;
@@ -66300,15 +66376,15 @@ function poolerAwareUser(host, role, ownerUser) {
66300
66376
  return ref ? `${role}.${ref}` : role;
66301
66377
  }
66302
66378
  function deriveKey2(tokenSecret, email, salt) {
66303
- const emailSalt = Buffer.from((0, import_node_crypto18.scryptSync)(normalizeEmail(email), salt, KEY_LEN));
66304
- return Buffer.from((0, import_node_crypto18.hkdfSync)("sha256", tokenSecret, emailSalt, HKDF_INFO, KEY_LEN));
66379
+ const emailSalt = Buffer.from((0, import_node_crypto19.scryptSync)(normalizeEmail(email), salt, KEY_LEN));
66380
+ return Buffer.from((0, import_node_crypto19.hkdfSync)("sha256", tokenSecret, emailSalt, HKDF_INFO, KEY_LEN));
66305
66381
  }
66306
66382
  function mintInviteToken(input) {
66307
66383
  const email = normalizeEmail(input.email);
66308
66384
  if (!email) throw new Error("lattice: an invite must be bound to an email");
66309
- const salt = (0, import_node_crypto18.randomBytes)(SALT_LEN);
66310
- const tokenSecret = (0, import_node_crypto18.randomBytes)(SECRET_LEN);
66311
- const nonce = (0, import_node_crypto18.randomBytes)(NONCE_LEN);
66385
+ const salt = (0, import_node_crypto19.randomBytes)(SALT_LEN);
66386
+ const tokenSecret = (0, import_node_crypto19.randomBytes)(SECRET_LEN);
66387
+ const nonce = (0, import_node_crypto19.randomBytes)(NONCE_LEN);
66312
66388
  const key = deriveKey2(tokenSecret, email, salt);
66313
66389
  const payload = {
66314
66390
  v: 1,
@@ -66322,7 +66398,7 @@ function mintInviteToken(input) {
66322
66398
  expires_at: input.expiresAt.toISOString(),
66323
66399
  ...input.workspaceName?.trim() ? { workspace_name: input.workspaceName.trim() } : {}
66324
66400
  };
66325
- const cipher = (0, import_node_crypto18.createCipheriv)("aes-256-gcm", key, nonce);
66401
+ const cipher = (0, import_node_crypto19.createCipheriv)("aes-256-gcm", key, nonce);
66326
66402
  cipher.setAAD(Buffer.from(email, "utf8"));
66327
66403
  const ct = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
66328
66404
  const tag = cipher.getAuthTag();
@@ -66345,7 +66421,7 @@ function redeemInviteToken(email, token) {
66345
66421
  const tag = raw.subarray(raw.length - TAG_LEN2);
66346
66422
  const ct = raw.subarray(off, raw.length - TAG_LEN2);
66347
66423
  const key = deriveKey2(tokenSecret, normEmail, salt);
66348
- const decipher = (0, import_node_crypto18.createDecipheriv)("aes-256-gcm", key, nonce);
66424
+ const decipher = (0, import_node_crypto19.createDecipheriv)("aes-256-gcm", key, nonce);
66349
66425
  decipher.setAAD(Buffer.from(normEmail, "utf8"));
66350
66426
  decipher.setAuthTag(tag);
66351
66427
  let plaintext;
@@ -66373,7 +66449,7 @@ function redeemInviteToken(email, token) {
66373
66449
  init_markdown();
66374
66450
  init_rls();
66375
66451
  init_adapter();
66376
- var import_node_crypto20 = require("crypto");
66452
+ var import_node_crypto21 = require("crypto");
66377
66453
  init_parser();
66378
66454
  init_lattice_root();
66379
66455
  init_workspace();
@@ -66458,7 +66534,7 @@ function parseAndValidateLogo(dataUri) {
66458
66534
  error: `logo must be square (got ${String(dims.width)}\xD7${String(dims.height)})`
66459
66535
  };
66460
66536
  }
66461
- return { ok: true, mime, bytes, etag: (0, import_node_crypto19.createHash)("sha256").update(bytes).digest("hex") };
66537
+ return { ok: true, mime, bytes, etag: (0, import_node_crypto20.createHash)("sha256").update(bytes).digest("hex") };
66462
66538
  }
66463
66539
  function updateActiveWorkspaceToCloud(configPath, displayName, key) {
66464
66540
  const root6 = findLatticeRoot((0, import_node_path36.dirname)(configPath));
@@ -66968,7 +67044,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66968
67044
  `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","email","expires_at")
66969
67045
  VALUES (?, ?, ?, ?, ?)`,
66970
67046
  [
66971
- (0, import_node_crypto20.randomUUID)(),
67047
+ (0, import_node_crypto21.randomUUID)(),
66972
67048
  role,
66973
67049
  emailHash,
66974
67050
  // Plaintext email stored ONLY in this owner-only table so the owner's
@@ -67794,7 +67870,7 @@ var import_node_os9 = require("os");
67794
67870
  var import_node_path37 = require("path");
67795
67871
  init_mutations();
67796
67872
  init_extract();
67797
- var import_node_crypto21 = require("crypto");
67873
+ var import_node_crypto22 = require("crypto");
67798
67874
  init_assistant_routes();
67799
67875
  init_enrich();
67800
67876
  init_ingest_url();
@@ -68035,7 +68111,7 @@ async function dispatchIngestRoute(req, res, ctx) {
68035
68111
  let s3Status = null;
68036
68112
  const s3cfg = resolveActiveS3Config(ctx.configPath);
68037
68113
  if (s3cfg) {
68038
- const sha256 = blob?.sha256 ?? (0, import_node_crypto21.createHash)("sha256").update(buf).digest("hex");
68114
+ const sha256 = blob?.sha256 ?? (0, import_node_crypto22.createHash)("sha256").update(buf).digest("hex");
68039
68115
  const key = s3Key(s3cfg.prefix, sha256);
68040
68116
  try {
68041
68117
  const store = await createS3Store(s3cfg);
@@ -68067,7 +68143,7 @@ async function dispatchIngestRoute(req, res, ctx) {
68067
68143
  realPath = rawFilePath;
68068
68144
  }
68069
68145
  }
68070
- const fileSha = blob?.sha256 ?? s3Ref?.sha256 ?? (0, import_node_crypto21.createHash)("sha256").update(buf).digest("hex");
68146
+ const fileSha = blob?.sha256 ?? s3Ref?.sha256 ?? (0, import_node_crypto22.createHash)("sha256").update(buf).digest("hex");
68071
68147
  const uploadRow = {
68072
68148
  id: fileId,
68073
68149
  ...fileIdentity(name2, fileId),
@@ -69305,6 +69381,28 @@ async function startGuiServer(options) {
69305
69381
  setActive(next, created.id);
69306
69382
  return created.id;
69307
69383
  };
69384
+ const cleanupWorkspaceFiles = (root6, ws) => {
69385
+ if (!ws.configPath && ws.kind === "local") {
69386
+ (0, import_node_fs35.rmSync)(workspaceDir(root6, ws.dir), { recursive: true, force: true });
69387
+ } else if (ws.kind === "cloud") {
69388
+ if (ws.configPath && (0, import_node_fs35.existsSync)(ws.configPath)) {
69389
+ (0, import_node_fs35.rmSync)(ws.configPath, { force: true });
69390
+ }
69391
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69392
+ const label = labelMatch?.[1];
69393
+ if (label) {
69394
+ const stillUsed = listWorkspaces(root6).some(
69395
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69396
+ );
69397
+ if (!stillUsed) {
69398
+ try {
69399
+ deleteDbCredential(label);
69400
+ } catch {
69401
+ }
69402
+ }
69403
+ }
69404
+ }
69405
+ };
69308
69406
  const handleVirginRoute = async (req, res, pathname, method) => {
69309
69407
  if (method === "GET" && pathname === "/") {
69310
69408
  sendText(
@@ -69356,6 +69454,35 @@ async function startGuiServer(options) {
69356
69454
  }
69357
69455
  return true;
69358
69456
  }
69457
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
69458
+ if (!latticeRoot) {
69459
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
69460
+ return true;
69461
+ }
69462
+ const body = await readJson(req);
69463
+ if (typeof body.id !== "string") {
69464
+ sendJson(res, { error: "id must be a string" }, 400);
69465
+ return true;
69466
+ }
69467
+ const ws = getWorkspace(latticeRoot, body.id);
69468
+ if (!ws) {
69469
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
69470
+ return true;
69471
+ }
69472
+ removeWorkspace(latticeRoot, ws.id);
69473
+ try {
69474
+ cleanupWorkspaceFiles(latticeRoot, ws);
69475
+ } catch (e6) {
69476
+ sendJson(
69477
+ res,
69478
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
69479
+ 500
69480
+ );
69481
+ return true;
69482
+ }
69483
+ sendJson(res, { ok: true, switchedTo: null });
69484
+ return true;
69485
+ }
69359
69486
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
69360
69487
  await redeemInvite(createCloudWorkspace, req, res);
69361
69488
  return true;
@@ -69390,6 +69517,18 @@ async function startGuiServer(options) {
69390
69517
  );
69391
69518
  return;
69392
69519
  }
69520
+ if (method === "POST" && pathname === "/api/update/apply") {
69521
+ if (updateService) {
69522
+ void updateService.checkNow(true);
69523
+ sendJson(res, { ok: true, status: updateService.status() });
69524
+ } else {
69525
+ sendJson(res, {
69526
+ ok: false,
69527
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
69528
+ });
69529
+ }
69530
+ return;
69531
+ }
69393
69532
  if (!activeRef) {
69394
69533
  if (await handleVirginRoute(req, res, pathname, method)) return;
69395
69534
  sendJson(res, { error: "No active workspace" }, 409);
@@ -70483,26 +70622,7 @@ async function startGuiServer(options) {
70483
70622
  }
70484
70623
  removeWorkspace(latticeRoot, ws.id);
70485
70624
  try {
70486
- if (!ws.configPath && ws.kind === "local") {
70487
- (0, import_node_fs35.rmSync)(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
70488
- } else if (ws.kind === "cloud") {
70489
- if (ws.configPath && (0, import_node_fs35.existsSync)(ws.configPath)) {
70490
- (0, import_node_fs35.rmSync)(ws.configPath, { force: true });
70491
- }
70492
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
70493
- const label = labelMatch?.[1];
70494
- if (label) {
70495
- const stillUsed = listWorkspaces(latticeRoot).some(
70496
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
70497
- );
70498
- if (!stillUsed) {
70499
- try {
70500
- deleteDbCredential(label);
70501
- } catch {
70502
- }
70503
- }
70504
- }
70505
- }
70625
+ cleanupWorkspaceFiles(latticeRoot, ws);
70506
70626
  } catch (e6) {
70507
70627
  sendJson(
70508
70628
  res,
@@ -70968,7 +71088,9 @@ ${e6.stack ?? ""}`
70968
71088
  }
70969
71089
  }
70970
71090
  };
70971
- if (options.selfUpdate && guiVersion) {
71091
+ if (options.updateServiceFactory) {
71092
+ updateService = options.updateServiceFactory(broadcast);
71093
+ } else if (options.selfUpdate && guiVersion) {
70972
71094
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
70973
71095
  }
70974
71096
  const handleEventStream = (ws) => {
@@ -71110,7 +71232,7 @@ ${e6.stack ?? ""}`
71110
71232
  // src/cloud/file-source-key-store.ts
71111
71233
  var import_node_fs36 = require("fs");
71112
71234
  var import_node_path39 = require("path");
71113
- var import_node_crypto22 = require("crypto");
71235
+ var import_node_crypto23 = require("crypto");
71114
71236
  var ENC_HEADER = "LATTICE-KMS-v1\n";
71115
71237
  var SCRYPT_N = 1 << 15;
71116
71238
  var SCRYPT_R = 8;
@@ -71137,7 +71259,7 @@ var FileSourceKeyStore = class {
71137
71259
  getOrCreate(sourceId) {
71138
71260
  let key = this.cache.get(sourceId);
71139
71261
  if (!key) {
71140
- key = (0, import_node_crypto22.randomBytes)(KEY_LEN2);
71262
+ key = (0, import_node_crypto23.randomBytes)(KEY_LEN2);
71141
71263
  this.cache.set(sourceId, key);
71142
71264
  this.persist();
71143
71265
  }
@@ -71181,7 +71303,7 @@ var FileSourceKeyStore = class {
71181
71303
  const obj2 = {};
71182
71304
  for (const [k6, v2] of this.cache) obj2[k6] = v2.toString("base64");
71183
71305
  const encoded = this.encodeFile(obj2);
71184
- const tmpPath = `${this.path}.tmp-${process.pid.toString()}-${(0, import_node_crypto22.randomBytes)(4).toString("hex")}`;
71306
+ const tmpPath = `${this.path}.tmp-${process.pid.toString()}-${(0, import_node_crypto23.randomBytes)(4).toString("hex")}`;
71185
71307
  (0, import_node_fs36.writeFileSync)(tmpPath, encoded, { mode: 384 });
71186
71308
  try {
71187
71309
  (0, import_node_fs36.chmodSync)(tmpPath, 384);
@@ -71224,14 +71346,14 @@ var FileSourceKeyStore = class {
71224
71346
  if (passphrase === void 0) {
71225
71347
  throw new Error("lattice: key file is encrypted but no passphrase was configured");
71226
71348
  }
71227
- const derived = (0, import_node_crypto22.scryptSync)(passphrase, salt, KEY_LEN2, {
71349
+ const derived = (0, import_node_crypto23.scryptSync)(passphrase, salt, KEY_LEN2, {
71228
71350
  N: SCRYPT_N,
71229
71351
  r: SCRYPT_R,
71230
71352
  p: SCRYPT_P,
71231
71353
  maxmem: 64 * 1024 * 1024
71232
71354
  // raise Node's default 32MB cap so N=2^15 fits
71233
71355
  });
71234
- const decipher = (0, import_node_crypto22.createDecipheriv)("aes-256-gcm", derived, iv);
71356
+ const decipher = (0, import_node_crypto23.createDecipheriv)("aes-256-gcm", derived, iv);
71235
71357
  decipher.setAuthTag(tag);
71236
71358
  let plaintext;
71237
71359
  try {
@@ -71246,15 +71368,15 @@ var FileSourceKeyStore = class {
71246
71368
  if (!this.passphrase) {
71247
71369
  return Buffer.from(json, "utf8");
71248
71370
  }
71249
- const salt = (0, import_node_crypto22.randomBytes)(SALT_LEN2);
71250
- const iv = (0, import_node_crypto22.randomBytes)(IV_LEN2);
71251
- const derived = (0, import_node_crypto22.scryptSync)(this.passphrase, salt, KEY_LEN2, {
71371
+ const salt = (0, import_node_crypto23.randomBytes)(SALT_LEN2);
71372
+ const iv = (0, import_node_crypto23.randomBytes)(IV_LEN2);
71373
+ const derived = (0, import_node_crypto23.scryptSync)(this.passphrase, salt, KEY_LEN2, {
71252
71374
  N: SCRYPT_N,
71253
71375
  r: SCRYPT_R,
71254
71376
  p: SCRYPT_P,
71255
71377
  maxmem: 64 * 1024 * 1024
71256
71378
  });
71257
- const cipher = (0, import_node_crypto22.createCipheriv)("aes-256-gcm", derived, iv);
71379
+ const cipher = (0, import_node_crypto23.createCipheriv)("aes-256-gcm", derived, iv);
71258
71380
  const ct = Buffer.concat([cipher.update(json, "utf8"), cipher.final()]);
71259
71381
  const tag = cipher.getAuthTag();
71260
71382
  const body = `${salt.toString("hex")}:${iv.toString("hex")}:${Buffer.concat([ct, tag]).toString("hex")}`;
package/dist/index.d.cts CHANGED
@@ -4923,6 +4923,37 @@ interface PdfOptions {
4923
4923
  */
4924
4924
  declare function describePdf(auth: ClaudeAuth, path: string, opts?: PdfOptions): Promise<string>;
4925
4925
 
4926
+ /** How the running copy of the package was installed. */
4927
+ type InstallKind = 'global' | 'local' | 'npx' | 'linked-dev' | 'unknown';
4928
+ interface InstallContext {
4929
+ kind: InstallKind;
4930
+ /** True only when an `npm install` may safely upgrade this copy in place. */
4931
+ installable: boolean;
4932
+ /** Directory to run the install from (the consumer project root for `local`). */
4933
+ cwd: string;
4934
+ /** Resolved package root of the running copy, if found. */
4935
+ packageRoot: string | null;
4936
+ /** Human-readable explanation (logged / surfaced in `/api/update/status`). */
4937
+ reason: string;
4938
+ }
4939
+
4940
+ interface UpdateStatus {
4941
+ current: string;
4942
+ latest: string | null;
4943
+ kind: InstallContext['kind'];
4944
+ installable: boolean;
4945
+ checking: boolean;
4946
+ installing: boolean;
4947
+ lastError: string | null;
4948
+ }
4949
+ interface UpdateService {
4950
+ start(): void;
4951
+ stop(): void;
4952
+ status(): UpdateStatus;
4953
+ /** Run a check now (and install if applicable). Returns the resulting status. */
4954
+ checkNow(force?: boolean): Promise<UpdateStatus>;
4955
+ }
4956
+
4926
4957
  interface StartGuiServerOptions {
4927
4958
  /**
4928
4959
  * Active workspace config to open. NULL/empty ⇒ boot into the zero-workspace
@@ -4979,6 +5010,13 @@ interface StartGuiServerOptions {
4979
5010
  * `GET /api/version` + `GET /api/update/status` are served regardless.
4980
5011
  */
4981
5012
  selfUpdate?: boolean;
5013
+ /**
5014
+ * Test seam: supply the update service instead of building one from the real
5015
+ * npm-backed install context. Lets tests exercise the update routes against a
5016
+ * deterministic fake (no real registry check, no real npm install). When set,
5017
+ * it overrides `selfUpdate`'s default factory.
5018
+ */
5019
+ updateServiceFactory?: (emit: (type: string, data: unknown) => void) => UpdateService;
4982
5020
  }
4983
5021
  interface GuiServerHandle {
4984
5022
  server: Server;
package/dist/index.d.ts CHANGED
@@ -4923,6 +4923,37 @@ interface PdfOptions {
4923
4923
  */
4924
4924
  declare function describePdf(auth: ClaudeAuth, path: string, opts?: PdfOptions): Promise<string>;
4925
4925
 
4926
+ /** How the running copy of the package was installed. */
4927
+ type InstallKind = 'global' | 'local' | 'npx' | 'linked-dev' | 'unknown';
4928
+ interface InstallContext {
4929
+ kind: InstallKind;
4930
+ /** True only when an `npm install` may safely upgrade this copy in place. */
4931
+ installable: boolean;
4932
+ /** Directory to run the install from (the consumer project root for `local`). */
4933
+ cwd: string;
4934
+ /** Resolved package root of the running copy, if found. */
4935
+ packageRoot: string | null;
4936
+ /** Human-readable explanation (logged / surfaced in `/api/update/status`). */
4937
+ reason: string;
4938
+ }
4939
+
4940
+ interface UpdateStatus {
4941
+ current: string;
4942
+ latest: string | null;
4943
+ kind: InstallContext['kind'];
4944
+ installable: boolean;
4945
+ checking: boolean;
4946
+ installing: boolean;
4947
+ lastError: string | null;
4948
+ }
4949
+ interface UpdateService {
4950
+ start(): void;
4951
+ stop(): void;
4952
+ status(): UpdateStatus;
4953
+ /** Run a check now (and install if applicable). Returns the resulting status. */
4954
+ checkNow(force?: boolean): Promise<UpdateStatus>;
4955
+ }
4956
+
4926
4957
  interface StartGuiServerOptions {
4927
4958
  /**
4928
4959
  * Active workspace config to open. NULL/empty ⇒ boot into the zero-workspace
@@ -4979,6 +5010,13 @@ interface StartGuiServerOptions {
4979
5010
  * `GET /api/version` + `GET /api/update/status` are served regardless.
4980
5011
  */
4981
5012
  selfUpdate?: boolean;
5013
+ /**
5014
+ * Test seam: supply the update service instead of building one from the real
5015
+ * npm-backed install context. Lets tests exercise the update routes against a
5016
+ * deterministic fake (no real registry check, no real npm install). When set,
5017
+ * it overrides `selfUpdate`'s default factory.
5018
+ */
5019
+ updateServiceFactory?: (emit: (type: string, data: unknown) => void) => UpdateService;
4982
5020
  }
4983
5021
  interface GuiServerHandle {
4984
5022
  server: Server;
package/dist/index.js CHANGED
@@ -4531,14 +4531,6 @@ function resolveDbPath(raw, configDir2) {
4531
4531
  }
4532
4532
  return resolve2(configDir2, raw);
4533
4533
  }
4534
- function warnDeprecatedRef(entity, field, target) {
4535
- const key = `${entity}.${field}`;
4536
- if (warnedDeprecatedRefs.has(key)) return;
4537
- warnedDeprecatedRefs.add(key);
4538
- console.warn(
4539
- `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.`
4540
- );
4541
- }
4542
4534
  function entityToTableDef(entityName, entity) {
4543
4535
  const rawFields = entity.fields;
4544
4536
  if (!rawFields || typeof rawFields !== "object" || Array.isArray(rawFields)) {
@@ -4565,7 +4557,6 @@ function entityToTableDef(entityName, entity) {
4565
4557
  table: field.ref,
4566
4558
  foreignKey: fieldName
4567
4559
  };
4568
- warnDeprecatedRef(entityName, fieldName, field.ref);
4569
4560
  }
4570
4561
  }
4571
4562
  const primaryKey = entity.primaryKey ?? pkFromField;
@@ -4722,12 +4713,10 @@ function parseEntityContexts(entityContexts) {
4722
4713
  }
4723
4714
  return result;
4724
4715
  }
4725
- var warnedDeprecatedRefs;
4726
4716
  var init_parser = __esm({
4727
4717
  "src/config/parser.ts"() {
4728
4718
  "use strict";
4729
4719
  init_user_config();
4730
- warnedDeprecatedRefs = /* @__PURE__ */ new Set();
4731
4720
  }
4732
4721
  });
4733
4722
 
@@ -50608,6 +50597,21 @@ var init_registry = __esm({
50608
50597
  ["table", "values"]
50609
50598
  )
50610
50599
  },
50600
+ {
50601
+ name: "create_secret",
50602
+ description: "Store a secret/credential \u2014 an API key, password, OAuth token, connection string, etc. \u2014 by name in the encrypted secrets store. Use this whenever the user gives you a credential to save or asks you to remember/store a secret. WRITE-ONLY: you can save a secret but you can NEVER read, list, echo, or retrieve existing secret values \u2014 they are hidden from you. The value is encrypted at rest.",
50603
+ mutates: true,
50604
+ category: "row",
50605
+ args: obj(
50606
+ {
50607
+ name: str('A short label for the secret, e.g. "GitHub password" or "OpenAI API key".'),
50608
+ value: str("The secret value to store."),
50609
+ kind: str('Optional kind, e.g. "password", "api_key", "token", "connection_string".'),
50610
+ description: str("Optional note about what the secret is for.")
50611
+ },
50612
+ ["name", "value"]
50613
+ )
50614
+ },
50611
50615
  {
50612
50616
  name: "create_artifact",
50613
50617
  description: "Create a markdown document and save it as a file artifact. Use this whenever the user asks you to create, write, draft, or make a document, note, write-up, summary, report, or file \u2014 you author the content as GitHub-flavored markdown and it is saved in the files entity as a markdown artifact, then opened in the viewer for them. Prefer this over create_row on files for any document the user wants to keep. It follows the same sharing rules as any file (private mode \u2192 private).",
@@ -52814,6 +52818,7 @@ var init_ingest_url = __esm({
52814
52818
  });
52815
52819
 
52816
52820
  // src/gui/ai/dispatch.ts
52821
+ import { randomUUID } from "crypto";
52817
52822
  function visibilityDenialReason(opts) {
52818
52823
  if (opts.kind === "table") {
52819
52824
  return opts.canManageTableDefault ? null : "Only the workspace owner can change a table's default sharing.";
@@ -53029,6 +53034,21 @@ async function executeFunction(ctx, name, args) {
53029
53034
  );
53030
53035
  return { ok: true, result: { id } };
53031
53036
  }
53037
+ case "create_secret": {
53038
+ const secretName = requireString(args.name, "name");
53039
+ const secretValue2 = requireString(args.value, "value");
53040
+ const kind = typeof args.kind === "string" && args.kind ? args.kind : null;
53041
+ const description = typeof args.description === "string" && args.description ? args.description : null;
53042
+ const id = randomUUID();
53043
+ await ctx.db.insert("secrets", {
53044
+ id,
53045
+ name: secretName,
53046
+ value: secretValue2,
53047
+ kind,
53048
+ description
53049
+ });
53050
+ return { ok: true, result: { id, name: secretName } };
53051
+ }
53032
53052
  case "create_artifact": {
53033
53053
  const table = requireTable("files", ctx.validTables);
53034
53054
  const title = requireString(args.title, "title");
@@ -53359,6 +53379,7 @@ var init_dispatch = __esm({
53359
53379
  "get_history",
53360
53380
  "create_row",
53361
53381
  "create_artifact",
53382
+ "create_secret",
53362
53383
  "ingest_url",
53363
53384
  "set_definition",
53364
53385
  "set_visibility",
@@ -55797,6 +55818,8 @@ var css = `
55797
55818
  .app-version:empty { display: none; }
55798
55819
  .app-update { flex: 0 0 auto; color: var(--accent, #4a9); font-size: 12px; white-space: nowrap; }
55799
55820
  .app-update[hidden] { display: none; }
55821
+ #app-update-link { flex: 0 0 auto; margin-left: 8px; color: var(--accent, #4a9); font-size: 12px; cursor: pointer; white-space: nowrap; }
55822
+ #app-update-link[hidden] { display: none; }
55800
55823
  /* Unseen-change count next to a sidebar entity. */
55801
55824
  .nav-badge {
55802
55825
  display: inline-block; min-width: 16px; text-align: center;
@@ -57275,6 +57298,12 @@ var appJs = `
57275
57298
  // drag handle once the app has booted.
57276
57299
  var savedRail = parseInt(window.localStorage.getItem(RAIL_KEY) || '', 10);
57277
57300
  if (!isNaN(savedRail)) applyRailWidth(savedRail);
57301
+ // The version chip + manual-upgrade link live in the static shell (present
57302
+ // from first paint, in both the normal and virgin-state boots), so wire the
57303
+ // click handler and run the first availability check here \u2014 independent of
57304
+ // the async workspace bootstrap. checkServerVersion() refreshes it later.
57305
+ wireUpdateLink();
57306
+ checkUpdateAvailable();
57278
57307
  // Failsafe: never leave the overlay up forever if a fetch hangs without
57279
57308
  // rejecting, or a future early-return (e.g. the virgin-state screen)
57280
57309
  // bypasses the .then() tail. Idempotent, so a later real hide is a no-op.
@@ -57759,6 +57788,26 @@ var appJs = `
57759
57788
  showUpdatePill(label || 'Updated \u2014 reloading\u2026');
57760
57789
  setTimeout(function () { location.reload(); }, 600);
57761
57790
  }
57791
+ // Manual upgrade fallback: show an "Update available \u2014 Upgrade" link next to
57792
+ // the version chip only when the server reports a newer, installable version.
57793
+ // The auto-updater installs in the background on its own cadence; this lets
57794
+ // the user force it now. Best-effort; the link stays hidden on any failure.
57795
+ function checkUpdateAvailable() {
57796
+ var el = document.getElementById('app-update-link');
57797
+ if (!el) return;
57798
+ fetch('/api/update/status')
57799
+ .then(function (r) { return r.ok ? r.json() : null; })
57800
+ .then(function (s) {
57801
+ if (s && s.latest && s.current && s.latest !== s.current && s.installable) {
57802
+ el.textContent = 'Update available \u2014 Upgrade';
57803
+ el.title = 'Install v' + s.latest + ' and restart';
57804
+ el.hidden = false;
57805
+ } else {
57806
+ el.hidden = true;
57807
+ }
57808
+ })
57809
+ .catch(function () { /* best-effort \u2014 keep the link hidden */ });
57810
+ }
57762
57811
  // On every (re)connect, ask the server its version. A change vs BOOT_VERSION
57763
57812
  // means a relaunch onto new code \u2192 reload. Best-effort; never throws.
57764
57813
  function checkServerVersion() {
@@ -57772,6 +57821,31 @@ var appJs = `
57772
57821
  else hideUpdatePill();
57773
57822
  })
57774
57823
  .catch(function () { /* offline / mid-restart \u2014 the next reconnect retries */ });
57824
+ // Refresh the manual-upgrade link alongside the reconnect version check.
57825
+ checkUpdateAvailable();
57826
+ }
57827
+ // Wire the manual-upgrade link's click: kick off the install (the server
57828
+ // installs the latest and restarts onto it) and surface the progress. On
57829
+ // success we do nothing else \u2014 the update-applied event + the reconnect
57830
+ // version check land the page on the new version (no manual reload). A
57831
+ // false ok means the install can't run (unsupervised) \u2014 toast why.
57832
+ function wireUpdateLink() {
57833
+ var el = document.getElementById('app-update-link');
57834
+ if (!el) return;
57835
+ el.addEventListener('click', function (e) {
57836
+ e.preventDefault();
57837
+ el.hidden = true;
57838
+ showUpdatePill('Updating\u2026');
57839
+ fetch('/api/update/apply', { method: 'POST' })
57840
+ .then(function (r) { return r.json(); })
57841
+ .then(function (d) {
57842
+ if (d && d.ok === false) {
57843
+ hideUpdatePill();
57844
+ showToast(d.error || 'Update unavailable', {});
57845
+ }
57846
+ })
57847
+ .catch(function () { /* server may already be restarting */ });
57848
+ });
57775
57849
  }
57776
57850
  function dispatchStreamMessage(type, data) {
57777
57851
  if (type === 'realtime-state') {
@@ -64589,6 +64663,7 @@ var guiAppHtml = `<!doctype html>
64589
64663
  <span class="offline-pill" id="offline-pill" title="Edits queued offline \u2014 will sync when the cloud reconnects" hidden></span>
64590
64664
  <span class="app-update" id="app-update" title="A new version is being applied" hidden></span>
64591
64665
  <span class="app-version" id="app-version" title="Lattice version"><!--LATTICE_VERSION--></span>
64666
+ <a id="app-update-link" href="#" hidden>Update available \u2014 Upgrade</a>
64592
64667
  <button id="settings-gear" title="Settings" aria-label="Open settings">
64593
64668
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64594
64669
  <circle cx="12" cy="12" r="3"/>
@@ -66198,7 +66273,7 @@ function redeemInviteToken(email, token) {
66198
66273
  init_markdown();
66199
66274
  init_rls();
66200
66275
  init_adapter();
66201
- import { randomUUID } from "crypto";
66276
+ import { randomUUID as randomUUID2 } from "crypto";
66202
66277
  init_parser();
66203
66278
  init_lattice_root();
66204
66279
  init_workspace();
@@ -66793,7 +66868,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
66793
66868
  `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","email","expires_at")
66794
66869
  VALUES (?, ?, ?, ?, ?)`,
66795
66870
  [
66796
- randomUUID(),
66871
+ randomUUID2(),
66797
66872
  role,
66798
66873
  emailHash,
66799
66874
  // Plaintext email stored ONLY in this owner-only table so the owner's
@@ -69130,6 +69205,28 @@ async function startGuiServer(options) {
69130
69205
  setActive(next, created.id);
69131
69206
  return created.id;
69132
69207
  };
69208
+ const cleanupWorkspaceFiles = (root6, ws) => {
69209
+ if (!ws.configPath && ws.kind === "local") {
69210
+ rmSync(workspaceDir(root6, ws.dir), { recursive: true, force: true });
69211
+ } else if (ws.kind === "cloud") {
69212
+ if (ws.configPath && existsSync24(ws.configPath)) {
69213
+ rmSync(ws.configPath, { force: true });
69214
+ }
69215
+ const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
69216
+ const label = labelMatch?.[1];
69217
+ if (label) {
69218
+ const stillUsed = listWorkspaces(root6).some(
69219
+ (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
69220
+ );
69221
+ if (!stillUsed) {
69222
+ try {
69223
+ deleteDbCredential(label);
69224
+ } catch {
69225
+ }
69226
+ }
69227
+ }
69228
+ }
69229
+ };
69133
69230
  const handleVirginRoute = async (req, res, pathname, method) => {
69134
69231
  if (method === "GET" && pathname === "/") {
69135
69232
  sendText(
@@ -69181,6 +69278,35 @@ async function startGuiServer(options) {
69181
69278
  }
69182
69279
  return true;
69183
69280
  }
69281
+ if (method === "POST" && pathname === "/api/workspaces/delete") {
69282
+ if (!latticeRoot) {
69283
+ sendJson(res, { error: "No .lattice root \u2014 workspaces unavailable" }, 400);
69284
+ return true;
69285
+ }
69286
+ const body = await readJson(req);
69287
+ if (typeof body.id !== "string") {
69288
+ sendJson(res, { error: "id must be a string" }, 400);
69289
+ return true;
69290
+ }
69291
+ const ws = getWorkspace(latticeRoot, body.id);
69292
+ if (!ws) {
69293
+ sendJson(res, { error: `No workspace with id ${body.id}` }, 400);
69294
+ return true;
69295
+ }
69296
+ removeWorkspace(latticeRoot, ws.id);
69297
+ try {
69298
+ cleanupWorkspaceFiles(latticeRoot, ws);
69299
+ } catch (e6) {
69300
+ sendJson(
69301
+ res,
69302
+ { error: `Workspace unregistered but file cleanup failed: ${e6.message}` },
69303
+ 500
69304
+ );
69305
+ return true;
69306
+ }
69307
+ sendJson(res, { ok: true, switchedTo: null });
69308
+ return true;
69309
+ }
69184
69310
  if (method === "POST" && pathname === "/api/cloud/redeem-invite") {
69185
69311
  await redeemInvite(createCloudWorkspace, req, res);
69186
69312
  return true;
@@ -69215,6 +69341,18 @@ async function startGuiServer(options) {
69215
69341
  );
69216
69342
  return;
69217
69343
  }
69344
+ if (method === "POST" && pathname === "/api/update/apply") {
69345
+ if (updateService) {
69346
+ void updateService.checkNow(true);
69347
+ sendJson(res, { ok: true, status: updateService.status() });
69348
+ } else {
69349
+ sendJson(res, {
69350
+ ok: false,
69351
+ error: "Automatic update is not available for this install. Reinstall from https://latticesql.com to get the latest version."
69352
+ });
69353
+ }
69354
+ return;
69355
+ }
69218
69356
  if (!activeRef) {
69219
69357
  if (await handleVirginRoute(req, res, pathname, method)) return;
69220
69358
  sendJson(res, { error: "No active workspace" }, 409);
@@ -70308,26 +70446,7 @@ async function startGuiServer(options) {
70308
70446
  }
70309
70447
  removeWorkspace(latticeRoot, ws.id);
70310
70448
  try {
70311
- if (!ws.configPath && ws.kind === "local") {
70312
- rmSync(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
70313
- } else if (ws.kind === "cloud") {
70314
- if (ws.configPath && existsSync24(ws.configPath)) {
70315
- rmSync(ws.configPath, { force: true });
70316
- }
70317
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
70318
- const label = labelMatch?.[1];
70319
- if (label) {
70320
- const stillUsed = listWorkspaces(latticeRoot).some(
70321
- (w2) => w2.db.includes("${LATTICE_DB:" + label + "}")
70322
- );
70323
- if (!stillUsed) {
70324
- try {
70325
- deleteDbCredential(label);
70326
- } catch {
70327
- }
70328
- }
70329
- }
70330
- }
70449
+ cleanupWorkspaceFiles(latticeRoot, ws);
70331
70450
  } catch (e6) {
70332
70451
  sendJson(
70333
70452
  res,
@@ -70793,7 +70912,9 @@ ${e6.stack ?? ""}`
70793
70912
  }
70794
70913
  }
70795
70914
  };
70796
- if (options.selfUpdate && guiVersion) {
70915
+ if (options.updateServiceFactory) {
70916
+ updateService = options.updateServiceFactory(broadcast);
70917
+ } else if (options.selfUpdate && guiVersion) {
70797
70918
  updateService = createUpdateService({ currentVersion: guiVersion, emit: broadcast });
70798
70919
  }
70799
70920
  const handleEventStream = (ws) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latticesql",
3
- "version": "3.4.4",
3
+ "version": "3.4.6",
4
4
  "description": "Persistent structured memory for AI agent systems — pluggable SQLite or Postgres backend, LLM context bridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",