latticesql 3.1.0 → 3.2.0

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
@@ -3549,8 +3549,8 @@ var init_types = __esm({
3549
3549
  });
3550
3550
 
3551
3551
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getHomeDir.js
3552
- import { homedir as homedir4 } from "os";
3553
- import { sep as sep4 } from "path";
3552
+ import { homedir as homedir3 } from "os";
3553
+ import { sep as sep3 } from "path";
3554
3554
  var homeDirCache, getHomeDirCacheKey, getHomeDir;
3555
3555
  var init_getHomeDir = __esm({
3556
3556
  "node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getHomeDir.js"() {
@@ -3563,7 +3563,7 @@ var init_getHomeDir = __esm({
3563
3563
  return "DEFAULT";
3564
3564
  };
3565
3565
  getHomeDir = () => {
3566
- const { HOME, USERPROFILE, HOMEPATH, HOMEDRIVE = `C:${sep4}` } = process.env;
3566
+ const { HOME, USERPROFILE, HOMEPATH, HOMEDRIVE = `C:${sep3}` } = process.env;
3567
3567
  if (HOME)
3568
3568
  return HOME;
3569
3569
  if (USERPROFILE)
@@ -3572,7 +3572,7 @@ var init_getHomeDir = __esm({
3572
3572
  return `${HOMEDRIVE}${HOMEPATH}`;
3573
3573
  const homeDirCacheKey = getHomeDirCacheKey();
3574
3574
  if (!homeDirCache[homeDirCacheKey])
3575
- homeDirCache[homeDirCacheKey] = homedir4();
3575
+ homeDirCache[homeDirCacheKey] = homedir3();
3576
3576
  return homeDirCache[homeDirCacheKey];
3577
3577
  };
3578
3578
  }
@@ -3590,17 +3590,17 @@ var init_getProfileName = __esm({
3590
3590
  });
3591
3591
 
3592
3592
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getSSOTokenFilepath.js
3593
- import { createHash as createHash3 } from "crypto";
3594
- import { join as join17 } from "path";
3593
+ import { createHash as createHash4 } from "crypto";
3594
+ import { join as join14 } from "path";
3595
3595
  var getSSOTokenFilepath;
3596
3596
  var init_getSSOTokenFilepath = __esm({
3597
3597
  "node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getSSOTokenFilepath.js"() {
3598
3598
  "use strict";
3599
3599
  init_getHomeDir();
3600
3600
  getSSOTokenFilepath = (id) => {
3601
- const hasher = createHash3("sha1");
3601
+ const hasher = createHash4("sha1");
3602
3602
  const cacheName = hasher.update(id).digest("hex");
3603
- return join17(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
3603
+ return join14(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
3604
3604
  };
3605
3605
  }
3606
3606
  });
@@ -3658,26 +3658,26 @@ var init_getConfigData = __esm({
3658
3658
  });
3659
3659
 
3660
3660
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getConfigFilepath.js
3661
- import { join as join18 } from "path";
3661
+ import { join as join15 } from "path";
3662
3662
  var ENV_CONFIG_PATH, getConfigFilepath;
3663
3663
  var init_getConfigFilepath = __esm({
3664
3664
  "node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getConfigFilepath.js"() {
3665
3665
  "use strict";
3666
3666
  init_getHomeDir();
3667
3667
  ENV_CONFIG_PATH = "AWS_CONFIG_FILE";
3668
- getConfigFilepath = () => process.env[ENV_CONFIG_PATH] || join18(getHomeDir(), ".aws", "config");
3668
+ getConfigFilepath = () => process.env[ENV_CONFIG_PATH] || join15(getHomeDir(), ".aws", "config");
3669
3669
  }
3670
3670
  });
3671
3671
 
3672
3672
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getCredentialsFilepath.js
3673
- import { join as join19 } from "path";
3673
+ import { join as join16 } from "path";
3674
3674
  var ENV_CREDENTIALS_PATH, getCredentialsFilepath;
3675
3675
  var init_getCredentialsFilepath = __esm({
3676
3676
  "node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/getCredentialsFilepath.js"() {
3677
3677
  "use strict";
3678
3678
  init_getHomeDir();
3679
3679
  ENV_CREDENTIALS_PATH = "AWS_SHARED_CREDENTIALS_FILE";
3680
- getCredentialsFilepath = () => process.env[ENV_CREDENTIALS_PATH] || join19(getHomeDir(), ".aws", "credentials");
3680
+ getCredentialsFilepath = () => process.env[ENV_CREDENTIALS_PATH] || join16(getHomeDir(), ".aws", "credentials");
3681
3681
  }
3682
3682
  });
3683
3683
 
@@ -3759,7 +3759,7 @@ var init_readFile = __esm({
3759
3759
  });
3760
3760
 
3761
3761
  // node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/loadSharedConfigFiles.js
3762
- import { join as join20 } from "path";
3762
+ import { join as join17 } from "path";
3763
3763
  var swallowError, loadSharedConfigFiles;
3764
3764
  var init_loadSharedConfigFiles = __esm({
3765
3765
  "node_modules/@smithy/core/dist-es/submodules/config/shared-ini-file-loader/loadSharedConfigFiles.js"() {
@@ -3778,11 +3778,11 @@ var init_loadSharedConfigFiles = __esm({
3778
3778
  const relativeHomeDirPrefix = "~/";
3779
3779
  let resolvedFilepath = filepath;
3780
3780
  if (filepath.startsWith(relativeHomeDirPrefix)) {
3781
- resolvedFilepath = join20(homeDir, filepath.slice(2));
3781
+ resolvedFilepath = join17(homeDir, filepath.slice(2));
3782
3782
  }
3783
3783
  let resolvedConfigFilepath = configFilepath;
3784
3784
  if (configFilepath.startsWith(relativeHomeDirPrefix)) {
3785
- resolvedConfigFilepath = join20(homeDir, configFilepath.slice(2));
3785
+ resolvedConfigFilepath = join17(homeDir, configFilepath.slice(2));
3786
3786
  }
3787
3787
  const parsedFiles = await Promise.all([
3788
3788
  readFile2(resolvedConfigFilepath, {
@@ -3903,13 +3903,13 @@ var init_getSelectorName = __esm({
3903
3903
  });
3904
3904
 
3905
3905
  // node_modules/@smithy/core/dist-es/submodules/config/node-config-provider/fromEnv.js
3906
- var fromEnv2;
3906
+ var fromEnv;
3907
3907
  var init_fromEnv = __esm({
3908
3908
  "node_modules/@smithy/core/dist-es/submodules/config/node-config-provider/fromEnv.js"() {
3909
3909
  "use strict";
3910
3910
  init_CredentialsProviderError();
3911
3911
  init_getSelectorName();
3912
- fromEnv2 = (envVarSelector, options) => async () => {
3912
+ fromEnv = (envVarSelector, options) => async () => {
3913
3913
  try {
3914
3914
  const config = envVarSelector(process.env, options);
3915
3915
  if (config === void 0) {
@@ -3976,7 +3976,7 @@ var init_configLoader = __esm({
3976
3976
  loadConfig = ({ environmentVariableSelector, configFileSelector, default: defaultValue }, configuration = {}) => {
3977
3977
  const { signingName, logger: logger2 } = configuration;
3978
3978
  const envOptions = { signingName, logger: logger2 };
3979
- return memoize(chain(fromEnv2(environmentVariableSelector, envOptions), fromSharedConfigFiles(configFileSelector, configuration), fromStatic(defaultValue)));
3979
+ return memoize(chain(fromEnv(environmentVariableSelector, envOptions), fromSharedConfigFiles(configFileSelector, configuration), fromStatic(defaultValue)));
3980
3980
  };
3981
3981
  }
3982
3982
  });
@@ -5374,7 +5374,7 @@ var init_endpoints2 = __esm({
5374
5374
  });
5375
5375
 
5376
5376
  // node_modules/@smithy/core/dist-es/submodules/serde/hash-node/hash-node.js
5377
- import { createHash as createHash4, createHmac } from "crypto";
5377
+ import { createHash as createHash5, createHmac } from "crypto";
5378
5378
  function castSourceData(toCast, encoding) {
5379
5379
  if (Buffer.isBuffer(toCast)) {
5380
5380
  return toCast;
@@ -5409,7 +5409,7 @@ var init_hash_node = __esm({
5409
5409
  return Promise.resolve(this.hash.digest());
5410
5410
  }
5411
5411
  reset() {
5412
- this.hash = this.secret ? createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : createHash4(this.algorithmIdentifier);
5412
+ this.hash = this.secret ? createHmac(this.algorithmIdentifier, castSourceData(this.secret)) : createHash5(this.algorithmIdentifier);
5413
5413
  }
5414
5414
  };
5415
5415
  }
@@ -10493,20 +10493,20 @@ var init_getRuntimeUserAgentPair = __esm({
10493
10493
  });
10494
10494
 
10495
10495
  // node_modules/@aws-sdk/core/dist-es/submodules/client/util-user-agent-node/getNodeModulesParentDirs.js
10496
- import { normalize, sep as sep5 } from "path";
10496
+ import { normalize, sep as sep4 } from "path";
10497
10497
  var getNodeModulesParentDirs;
10498
10498
  var init_getNodeModulesParentDirs = __esm({
10499
10499
  "node_modules/@aws-sdk/core/dist-es/submodules/client/util-user-agent-node/getNodeModulesParentDirs.js"() {
10500
10500
  "use strict";
10501
- getNodeModulesParentDirs = (dirname13) => {
10501
+ getNodeModulesParentDirs = (dirname14) => {
10502
10502
  const cwd = process.cwd();
10503
- if (!dirname13) {
10503
+ if (!dirname14) {
10504
10504
  return [cwd];
10505
10505
  }
10506
- const normalizedPath = normalize(dirname13);
10507
- const parts = normalizedPath.split(sep5);
10506
+ const normalizedPath = normalize(dirname14);
10507
+ const parts = normalizedPath.split(sep4);
10508
10508
  const nodeModulesIndex = parts.indexOf("node_modules");
10509
- const parentDir = nodeModulesIndex !== -1 ? parts.slice(0, nodeModulesIndex).join(sep5) : normalizedPath;
10509
+ const parentDir = nodeModulesIndex !== -1 ? parts.slice(0, nodeModulesIndex).join(sep4) : normalizedPath;
10510
10510
  if (cwd === parentDir) {
10511
10511
  return [cwd];
10512
10512
  }
@@ -10556,7 +10556,7 @@ var init_getSanitizedDevTypeScriptVersion = __esm({
10556
10556
 
10557
10557
  // node_modules/@aws-sdk/core/dist-es/submodules/client/util-user-agent-node/getTypeScriptUserAgentPair.js
10558
10558
  import { readFile as readFile3 } from "fs/promises";
10559
- import { join as join21 } from "path";
10559
+ import { join as join18 } from "path";
10560
10560
  var tscVersion, TS_PACKAGE_JSON, getTypeScriptUserAgentPair;
10561
10561
  var init_getTypeScriptUserAgentPair = __esm({
10562
10562
  "node_modules/@aws-sdk/core/dist-es/submodules/client/util-user-agent-node/getTypeScriptUserAgentPair.js"() {
@@ -10565,7 +10565,7 @@ var init_getTypeScriptUserAgentPair = __esm({
10565
10565
  init_getNodeModulesParentDirs();
10566
10566
  init_getSanitizedDevTypeScriptVersion();
10567
10567
  init_getSanitizedTypeScriptVersion();
10568
- TS_PACKAGE_JSON = join21("node_modules", "typescript", "package.json");
10568
+ TS_PACKAGE_JSON = join18("node_modules", "typescript", "package.json");
10569
10569
  getTypeScriptUserAgentPair = async () => {
10570
10570
  if (tscVersion === null) {
10571
10571
  return void 0;
@@ -10581,12 +10581,12 @@ var init_getTypeScriptUserAgentPair = __esm({
10581
10581
  tscVersion = null;
10582
10582
  return void 0;
10583
10583
  }
10584
- const dirname13 = typeof __dirname !== "undefined" ? __dirname : void 0;
10585
- const nodeModulesParentDirs = getNodeModulesParentDirs(dirname13);
10584
+ const dirname14 = typeof __dirname !== "undefined" ? __dirname : void 0;
10585
+ const nodeModulesParentDirs = getNodeModulesParentDirs(dirname14);
10586
10586
  let versionFromApp;
10587
10587
  for (const nodeModulesParentDir of nodeModulesParentDirs) {
10588
10588
  try {
10589
- const appPackageJsonPath = join21(nodeModulesParentDir, "package.json");
10589
+ const appPackageJsonPath = join18(nodeModulesParentDir, "package.json");
10590
10590
  const packageJson = await readFile3(appPackageJsonPath, "utf-8");
10591
10591
  const { dependencies, devDependencies } = JSON.parse(packageJson);
10592
10592
  const version = devDependencies?.typescript ?? dependencies?.typescript;
@@ -10605,7 +10605,7 @@ var init_getTypeScriptUserAgentPair = __esm({
10605
10605
  let versionFromNodeModules;
10606
10606
  for (const nodeModulesParentDir of nodeModulesParentDirs) {
10607
10607
  try {
10608
- const tsPackageJsonPath = join21(nodeModulesParentDir, TS_PACKAGE_JSON);
10608
+ const tsPackageJsonPath = join18(nodeModulesParentDir, TS_PACKAGE_JSON);
10609
10609
  const packageJson = await readFile3(tsPackageJsonPath, "utf-8");
10610
10610
  const { version } = JSON.parse(packageJson);
10611
10611
  const sanitizedVersion2 = getSanitizedTypeScriptVersion(version);
@@ -28345,7 +28345,7 @@ var init_package = __esm({
28345
28345
  });
28346
28346
 
28347
28347
  // node_modules/@aws-sdk/credential-provider-env/dist-es/fromEnv.js
28348
- var ENV_KEY, ENV_SECRET, ENV_SESSION, ENV_EXPIRATION, ENV_CREDENTIAL_SCOPE, ENV_ACCOUNT_ID, fromEnv3;
28348
+ var ENV_KEY, ENV_SECRET, ENV_SESSION, ENV_EXPIRATION, ENV_CREDENTIAL_SCOPE, ENV_ACCOUNT_ID, fromEnv2;
28349
28349
  var init_fromEnv2 = __esm({
28350
28350
  "node_modules/@aws-sdk/credential-provider-env/dist-es/fromEnv.js"() {
28351
28351
  "use strict";
@@ -28357,7 +28357,7 @@ var init_fromEnv2 = __esm({
28357
28357
  ENV_EXPIRATION = "AWS_CREDENTIAL_EXPIRATION";
28358
28358
  ENV_CREDENTIAL_SCOPE = "AWS_CREDENTIAL_SCOPE";
28359
28359
  ENV_ACCOUNT_ID = "AWS_ACCOUNT_ID";
28360
- fromEnv3 = (init) => async () => {
28360
+ fromEnv2 = (init) => async () => {
28361
28361
  init?.logger?.debug("@aws-sdk/credential-provider-env - fromEnv");
28362
28362
  const accessKeyId = process.env[ENV_KEY];
28363
28363
  const secretAccessKey = process.env[ENV_SECRET];
@@ -28391,7 +28391,7 @@ __export(dist_es_exports, {
28391
28391
  ENV_KEY: () => ENV_KEY,
28392
28392
  ENV_SECRET: () => ENV_SECRET,
28393
28393
  ENV_SESSION: () => ENV_SESSION,
28394
- fromEnv: () => fromEnv3
28394
+ fromEnv: () => fromEnv2
28395
28395
  });
28396
28396
  var init_dist_es11 = __esm({
28397
28397
  "node_modules/@aws-sdk/credential-provider-env/dist-es/index.js"() {
@@ -34053,10 +34053,10 @@ var init_signin = __esm({
34053
34053
  });
34054
34054
 
34055
34055
  // node_modules/@aws-sdk/credential-provider-login/dist-es/LoginCredentialsFetcher.js
34056
- import { createHash as createHash5, createPrivateKey, createPublicKey, sign } from "crypto";
34056
+ import { createHash as createHash6, createPrivateKey, createPublicKey, sign } from "crypto";
34057
34057
  import { promises as fs2 } from "fs";
34058
- import { homedir as homedir5 } from "os";
34059
- import { dirname as dirname10, join as join22 } from "path";
34058
+ import { homedir as homedir4 } from "os";
34059
+ import { dirname as dirname7, join as join19 } from "path";
34060
34060
  var LoginCredentialsFetcher;
34061
34061
  var init_LoginCredentialsFetcher = __esm({
34062
34062
  "node_modules/@aws-sdk/credential-provider-login/dist-es/LoginCredentialsFetcher.js"() {
@@ -34210,7 +34210,7 @@ var init_LoginCredentialsFetcher = __esm({
34210
34210
  }
34211
34211
  async saveToken(token) {
34212
34212
  const tokenFilePath = this.getTokenFilePath();
34213
- const directory = dirname10(tokenFilePath);
34213
+ const directory = dirname7(tokenFilePath);
34214
34214
  try {
34215
34215
  await fs2.mkdir(directory, { recursive: true });
34216
34216
  } catch (error) {
@@ -34218,10 +34218,10 @@ var init_LoginCredentialsFetcher = __esm({
34218
34218
  await fs2.writeFile(tokenFilePath, JSON.stringify(token, null, 2), "utf8");
34219
34219
  }
34220
34220
  getTokenFilePath() {
34221
- const directory = process.env.AWS_LOGIN_CACHE_DIRECTORY ?? join22(homedir5(), ".aws", "login", "cache");
34221
+ const directory = process.env.AWS_LOGIN_CACHE_DIRECTORY ?? join19(homedir4(), ".aws", "login", "cache");
34222
34222
  const loginSessionBytes = Buffer.from(this.loginSession, "utf8");
34223
- const loginSessionSha256 = createHash5("sha256").update(loginSessionBytes).digest("hex");
34224
- return join22(directory, `${loginSessionSha256}.json`);
34223
+ const loginSessionSha256 = createHash6("sha256").update(loginSessionBytes).digest("hex");
34224
+ return join19(directory, `${loginSessionSha256}.json`);
34225
34225
  }
34226
34226
  derToRawSignature(derSignature) {
34227
34227
  let offset = 2;
@@ -34578,7 +34578,7 @@ var init_fromWebToken = __esm({
34578
34578
  });
34579
34579
 
34580
34580
  // node_modules/@aws-sdk/credential-provider-web-identity/dist-es/fromTokenFile.js
34581
- import { readFileSync as readFileSync15 } from "fs";
34581
+ import { readFileSync as readFileSync12 } from "fs";
34582
34582
  var ENV_TOKEN_FILE, ENV_ROLE_ARN, ENV_ROLE_SESSION_NAME, fromTokenFile;
34583
34583
  var init_fromTokenFile = __esm({
34584
34584
  "node_modules/@aws-sdk/credential-provider-web-identity/dist-es/fromTokenFile.js"() {
@@ -34601,7 +34601,7 @@ var init_fromTokenFile = __esm({
34601
34601
  }
34602
34602
  const credentials = await fromWebToken({
34603
34603
  ...init,
34604
- webIdentityToken: externalDataInterceptor?.getTokenRecord?.()[webIdentityTokenFile] ?? readFileSync15(webIdentityTokenFile, { encoding: "ascii" }),
34604
+ webIdentityToken: externalDataInterceptor?.getTokenRecord?.()[webIdentityTokenFile] ?? readFileSync12(webIdentityTokenFile, { encoding: "ascii" }),
34605
34605
  roleArn,
34606
34606
  roleSessionName
34607
34607
  })(awsIdentityProperties);
@@ -34752,7 +34752,7 @@ var init_defaultProvider = __esm({
34752
34752
  });
34753
34753
  }
34754
34754
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromEnv");
34755
- return fromEnv3(init)();
34755
+ return fromEnv2(init)();
34756
34756
  },
34757
34757
  async (awsIdentityProperties) => {
34758
34758
  init.logger?.debug("@aws-sdk/credential-provider-node - defaultProvider::fromSSO");
@@ -39368,8 +39368,8 @@ var init_dist_es22 = __esm({
39368
39368
  });
39369
39369
 
39370
39370
  // src/cli.ts
39371
- import { resolve as resolve10, dirname as dirname12 } from "path";
39372
- import { readFileSync as readFileSync17 } from "fs";
39371
+ import { resolve as resolve10, dirname as dirname13 } from "path";
39372
+ import { readFileSync as readFileSync18 } from "fs";
39373
39373
  import { execSync } from "child_process";
39374
39374
  import { parse as parse3 } from "yaml";
39375
39375
 
@@ -39826,14 +39826,27 @@ function buildParsedConfig(raw, sourceName, configDir2) {
39826
39826
  const entityContexts = parseEntityContexts(config.entityContexts);
39827
39827
  return name !== void 0 ? { dbPath, name, tables, entityContexts } : { dbPath, tables, entityContexts };
39828
39828
  }
39829
+ function isDbRefShaped(raw) {
39830
+ return /^\s*\$\{LATTICE_DB:/.test(raw);
39831
+ }
39832
+ function parseDbRef(raw) {
39833
+ const m4 = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
39834
+ return m4 ? { label: m4[1] ?? "" } : null;
39835
+ }
39829
39836
  function resolveDbPath(raw, configDir2) {
39830
- const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(raw.trim());
39831
- if (labelMatch) {
39832
- const label = labelMatch[1] ?? "";
39833
- const url = getDbCredential(label);
39837
+ if (isDbRefShaped(raw)) {
39838
+ const ref = parseDbRef(raw);
39839
+ if (!ref) {
39840
+ throw new Error(
39841
+ `Lattice: malformed \${LATTICE_DB:\u2026} reference ${JSON.stringify(
39842
+ raw.trim()
39843
+ )} \u2014 the label may contain only [A-Za-z0-9._-] (no spaces). This usually means a workspace was created with an unsanitized name.`
39844
+ );
39845
+ }
39846
+ const url = getDbCredential(ref.label);
39834
39847
  if (!url) {
39835
39848
  throw new Error(
39836
- `Lattice: config references \${LATTICE_DB:${label}} but no credential is saved for "${label}". Save one via the GUI's Database panel or set LATTICE_DB_${label}.`
39849
+ `Lattice: config references \${LATTICE_DB:${ref.label}} but no credential is saved for "${ref.label}". Save one via the GUI's Database panel or set LATTICE_DB_${ref.label}.`
39837
39850
  );
39838
39851
  }
39839
39852
  return url;
@@ -39841,6 +39854,13 @@ function resolveDbPath(raw, configDir2) {
39841
39854
  if (/^postgres(ql)?:\/\//i.test(raw) || raw.startsWith("file:") || raw === ":memory:") {
39842
39855
  return raw;
39843
39856
  }
39857
+ if (raw.includes("${")) {
39858
+ throw new Error(
39859
+ `Lattice: refusing to treat ${JSON.stringify(
39860
+ raw.trim()
39861
+ )} as a database path \u2014 it looks like a malformed variable reference, not a file path.`
39862
+ );
39863
+ }
39844
39864
  return resolve(configDir2, raw);
39845
39865
  }
39846
39866
  var warnedDeprecatedRefs = /* @__PURE__ */ new Set();
@@ -42128,37 +42148,63 @@ var THROTTLE_WINDOW_MS = 200;
42128
42148
  var ProgressThrottle = class {
42129
42149
  cb;
42130
42150
  windowMs;
42131
- lastEmit = 0;
42151
+ /**
42152
+ * Last passthrough time, keyed per table (`event.table`, or `''` for the
42153
+ * table-less `done`/`error` lifecycle events). Per-table — not a single shared
42154
+ * clock — so when tables render CONCURRENTLY each one keeps its own ~5/sec
42155
+ * budget: a fast table can't consume the window and starve a slow table's
42156
+ * progress. `force` (table-start) resets only that table's budget.
42157
+ */
42158
+ lastEmit = /* @__PURE__ */ new Map();
42132
42159
  constructor(cb, windowMs = THROTTLE_WINDOW_MS) {
42133
42160
  this.cb = cb;
42134
42161
  this.windowMs = windowMs;
42135
42162
  }
42136
42163
  /**
42137
- * Emit a `table-progress` event, but only if the window since the last
42138
- * passthrough has elapsed. Dropped events are simply not delivered — the next
42139
- * one that survives carries the latest running count.
42164
+ * Emit a `table-progress` event, but only if the window since this table's
42165
+ * last passthrough has elapsed. Dropped events are simply not delivered — the
42166
+ * next one that survives carries the latest running count.
42140
42167
  */
42141
42168
  tick(event) {
42142
42169
  if (!this.cb) return;
42170
+ const key = event.table ?? "";
42143
42171
  const now = Date.now();
42144
- if (now - this.lastEmit < this.windowMs) return;
42145
- this.lastEmit = now;
42172
+ if (now - (this.lastEmit.get(key) ?? 0) < this.windowMs) return;
42173
+ this.lastEmit.set(key, now);
42146
42174
  this.cb(event);
42147
42175
  }
42148
42176
  /**
42149
- * Emit a lifecycle event immediately and reset the throttle window. Use for
42150
- * `table-start`, `table-done`, `done`, and `error` — none of which should
42151
- * ever be dropped. Resetting on `table-start` gives each table a clean budget.
42177
+ * Emit a lifecycle event immediately and reset this table's throttle window.
42178
+ * Use for `table-start`, `table-done`, `done`, and `error` — none of which
42179
+ * should ever be dropped. Resetting on `table-start` gives each table a clean
42180
+ * budget.
42152
42181
  */
42153
42182
  force(event) {
42154
42183
  if (!this.cb) return;
42155
- this.lastEmit = Date.now();
42184
+ this.lastEmit.set(event.table ?? "", Date.now());
42156
42185
  this.cb(event);
42157
42186
  }
42158
42187
  };
42159
42188
 
42189
+ // src/concurrency.ts
42190
+ async function mapWithConcurrency(items, limit, fn) {
42191
+ const results = new Array(items.length);
42192
+ let next = 0;
42193
+ const workerCount = Math.max(1, Math.min(limit, items.length));
42194
+ const workers = Array.from({ length: workerCount }, async () => {
42195
+ for (; ; ) {
42196
+ const i6 = next++;
42197
+ if (i6 >= items.length) break;
42198
+ results[i6] = await fn(items[i6], i6);
42199
+ }
42200
+ });
42201
+ await Promise.all(workers);
42202
+ return results;
42203
+ }
42204
+
42160
42205
  // src/render/engine.ts
42161
42206
  var YIELD_EVERY_ENTITIES = 200;
42207
+ var RENDER_TABLE_CONCURRENCY = 4;
42162
42208
  var NOOP_RENDER = () => "";
42163
42209
  var RenderEngine = class {
42164
42210
  _schema;
@@ -42340,163 +42386,175 @@ var RenderEngine = class {
42340
42386
  * via `signal`.
42341
42387
  */
42342
42388
  async _renderEntityContexts(outputDir, filesWritten, counters, throttle, signal) {
42343
- const manifestData = {};
42344
42389
  const protectedTables = /* @__PURE__ */ new Set();
42345
42390
  for (const [t8, d6] of this._schema.getEntityContexts()) {
42346
42391
  if (d6.protected) protectedTables.add(t8);
42347
42392
  }
42348
42393
  const entityTables = [...this._schema.getEntityContexts()];
42349
42394
  const tableCount = entityTables.length;
42350
- for (let tableIndex = 0; tableIndex < tableCount; tableIndex++) {
42351
- if (signal?.aborted) return null;
42352
- const [table, def] = entityTables[tableIndex];
42353
- const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
42354
- const allRows = await this._schema.queryTable(this._adapter, table);
42355
- const directoryRoot = def.directoryRoot ?? table;
42356
- const entitiesTotal = allRows.length;
42357
- throttle.force({
42358
- kind: "table-start",
42359
- table,
42360
- entitiesRendered: 0,
42361
- entitiesTotal,
42362
- tableIndex,
42363
- tableCount,
42364
- pct: 0
42365
- });
42366
- const manifestEntry = {
42367
- directoryRoot,
42368
- ...def.index ? { indexFile: def.index.outputFile } : {},
42369
- declaredFiles: Object.keys(def.files),
42370
- protectedFiles: def.protectedFiles ?? [],
42371
- entities: {}
42372
- };
42373
- if (def.index) {
42374
- const indexPath = join7(outputDir, def.index.outputFile);
42375
- if (atomicWrite(indexPath, def.index.render(allRows))) {
42376
- filesWritten.push(indexPath);
42377
- } else {
42378
- counters.skipped++;
42379
- }
42380
- }
42381
- for (let i6 = 0; i6 < allRows.length; i6++) {
42382
- const entityRow = allRows[i6];
42395
+ if (signal?.aborted) return null;
42396
+ const renderedEntries = await mapWithConcurrency(
42397
+ entityTables,
42398
+ RENDER_TABLE_CONCURRENCY,
42399
+ async ([table, def], tableIndex) => {
42383
42400
  if (signal?.aborted) return null;
42384
- if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
42385
- await new Promise((r6) => setImmediate(r6));
42386
- }
42387
- const rawSlug = def.slug(entityRow);
42388
- const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
42389
- if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
42390
- throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
42391
- }
42392
- const entityDir = def.directory ? join7(outputDir, def.directory(entityRow)) : join7(outputDir, directoryRoot, slug);
42393
- const resolvedDir = resolve3(entityDir);
42394
- const resolvedBase = resolve3(outputDir);
42395
- if (!resolvedDir.startsWith(resolvedBase + sep) && resolvedDir !== resolvedBase) {
42396
- throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
42397
- }
42398
- mkdirSync5(entityDir, { recursive: true });
42399
- if (def.attachFileColumn) {
42400
- const filePath = entityRow[def.attachFileColumn];
42401
- if (filePath && typeof filePath === "string" && filePath.length > 0) {
42402
- if (def.attachFileMode === "reference") {
42403
- const refPath = join7(entityDir, `${basename(filePath)}.ref.md`);
42404
- try {
42405
- atomicWrite(refPath, `# Reference
42401
+ const entityPk = this._schema.getPrimaryKey(table)[0] ?? "id";
42402
+ const allRows = await this._schema.queryTable(this._adapter, table);
42403
+ const directoryRoot = def.directoryRoot ?? table;
42404
+ const entitiesTotal = allRows.length;
42405
+ throttle.force({
42406
+ kind: "table-start",
42407
+ table,
42408
+ entitiesRendered: 0,
42409
+ entitiesTotal,
42410
+ tableIndex,
42411
+ tableCount,
42412
+ pct: 0
42413
+ });
42414
+ const manifestEntry = {
42415
+ directoryRoot,
42416
+ ...def.index ? { indexFile: def.index.outputFile } : {},
42417
+ declaredFiles: Object.keys(def.files),
42418
+ protectedFiles: def.protectedFiles ?? [],
42419
+ entities: {}
42420
+ };
42421
+ if (def.index) {
42422
+ const indexPath = join7(outputDir, def.index.outputFile);
42423
+ if (atomicWrite(indexPath, def.index.render(allRows))) {
42424
+ filesWritten.push(indexPath);
42425
+ } else {
42426
+ counters.skipped++;
42427
+ }
42428
+ }
42429
+ for (let i6 = 0; i6 < allRows.length; i6++) {
42430
+ const entityRow = allRows[i6];
42431
+ if (signal?.aborted) return null;
42432
+ if (i6 > 0 && i6 % YIELD_EVERY_ENTITIES === 0) {
42433
+ await new Promise((r6) => setImmediate(r6));
42434
+ }
42435
+ const rawSlug = def.slug(entityRow);
42436
+ const slug = rawSlug.replace(/[\u00A0\u2000-\u200B\u202F\u205F\u3000]/g, " ").replace(/[\u0000-\u001F\u007F]/g, "");
42437
+ if (/[^a-zA-Z0-9.\-_ @(),#&'+:;!~[\]]/.test(slug)) {
42438
+ throw new Error(`Invalid slug "${slug}": contains characters outside the allowed set`);
42439
+ }
42440
+ const entityDir = def.directory ? join7(outputDir, def.directory(entityRow)) : join7(outputDir, directoryRoot, slug);
42441
+ const resolvedDir = resolve3(entityDir);
42442
+ const resolvedBase = resolve3(outputDir);
42443
+ if (!resolvedDir.startsWith(resolvedBase + sep) && resolvedDir !== resolvedBase) {
42444
+ throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
42445
+ }
42446
+ mkdirSync5(entityDir, { recursive: true });
42447
+ if (def.attachFileColumn) {
42448
+ const filePath = entityRow[def.attachFileColumn];
42449
+ if (filePath && typeof filePath === "string" && filePath.length > 0) {
42450
+ if (def.attachFileMode === "reference") {
42451
+ const refPath = join7(entityDir, `${basename(filePath)}.ref.md`);
42452
+ try {
42453
+ atomicWrite(refPath, `# Reference
42406
42454
 
42407
42455
  - **location:** ${filePath}
42408
42456
  `);
42409
- filesWritten.push(refPath);
42410
- } catch {
42411
- }
42412
- } else {
42413
- const absPath = isAbsolute(filePath) ? filePath : resolve3(outputDir, filePath);
42414
- if (existsSync7(absPath)) {
42415
- const destPath = join7(entityDir, basename(absPath));
42416
- if (!existsSync7(destPath)) {
42417
- try {
42418
- copyFileSync2(absPath, destPath);
42419
- filesWritten.push(destPath);
42420
- } catch {
42457
+ filesWritten.push(refPath);
42458
+ } catch {
42459
+ }
42460
+ } else {
42461
+ const absPath = isAbsolute(filePath) ? filePath : resolve3(outputDir, filePath);
42462
+ if (existsSync7(absPath)) {
42463
+ const destPath = join7(entityDir, basename(absPath));
42464
+ if (!existsSync7(destPath)) {
42465
+ try {
42466
+ copyFileSync2(absPath, destPath);
42467
+ filesWritten.push(destPath);
42468
+ } catch {
42469
+ }
42421
42470
  }
42422
42471
  }
42423
42472
  }
42424
42473
  }
42425
42474
  }
42426
- }
42427
- const renderedFiles = /* @__PURE__ */ new Map();
42428
- const entityFileHashes = {};
42429
- const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
42430
- for (const [filename, spec] of Object.entries(def.files)) {
42431
- if (signal?.aborted) return null;
42432
- const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
42433
- const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
42434
- const rows = await resolveEntitySource(
42435
- source,
42436
- entityRow,
42437
- entityPk,
42438
- this._adapter,
42439
- protection
42440
- );
42441
- if (spec.omitIfEmpty && rows.length === 0) continue;
42442
- const renderFn = compileEntityRender(spec.render);
42443
- const content = truncateContent(renderFn(rows), spec.budget);
42444
- renderedFiles.set(filename, content);
42445
- entityFileHashes[filename] = { hash: contentHash(content) };
42446
- const filePath = join7(entityDir, filename);
42447
- if (atomicWrite(filePath, content)) {
42448
- filesWritten.push(filePath);
42449
- } else {
42450
- counters.skipped++;
42451
- }
42452
- }
42453
- const fileKeys = Object.keys(def.files);
42454
- const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
42455
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
42456
- { outputFile: fileKeys[0] }
42457
- ) : void 0);
42458
- if (effectiveCombined && renderedFiles.size > 0) {
42459
- const excluded = new Set(effectiveCombined.exclude ?? []);
42460
- const parts = [];
42461
- for (const filename of Object.keys(def.files)) {
42462
- if (!excluded.has(filename) && renderedFiles.has(filename)) {
42463
- parts.push(renderedFiles.get(filename) ?? "");
42464
- }
42465
- }
42466
- if (parts.length > 0) {
42467
- const combinedContent = parts.join("\n\n---\n\n");
42468
- const combinedPath = join7(entityDir, effectiveCombined.outputFile);
42469
- if (atomicWrite(combinedPath, combinedContent)) {
42470
- filesWritten.push(combinedPath);
42475
+ const renderedFiles = /* @__PURE__ */ new Map();
42476
+ const entityFileHashes = {};
42477
+ const protection = protectedTables.size > 0 ? { protectedTables, currentTable: table } : void 0;
42478
+ for (const [filename, spec] of Object.entries(def.files)) {
42479
+ if (signal?.aborted) return null;
42480
+ const mergeDefaults = def.sourceDefaults && spec.source.type !== "self" && spec.source.type !== "custom" && spec.source.type !== "enriched";
42481
+ const source = mergeDefaults ? { ...def.sourceDefaults, ...spec.source } : spec.source;
42482
+ const rows = await resolveEntitySource(
42483
+ source,
42484
+ entityRow,
42485
+ entityPk,
42486
+ this._adapter,
42487
+ protection
42488
+ );
42489
+ if (spec.omitIfEmpty && rows.length === 0) continue;
42490
+ const renderFn = compileEntityRender(spec.render);
42491
+ const content = truncateContent(renderFn(rows), spec.budget);
42492
+ renderedFiles.set(filename, content);
42493
+ entityFileHashes[filename] = { hash: contentHash(content) };
42494
+ const filePath = join7(entityDir, filename);
42495
+ if (atomicWrite(filePath, content)) {
42496
+ filesWritten.push(filePath);
42471
42497
  } else {
42472
42498
  counters.skipped++;
42473
42499
  }
42474
- renderedFiles.set(effectiveCombined.outputFile, combinedContent);
42475
- entityFileHashes[effectiveCombined.outputFile] = { hash: contentHash(combinedContent) };
42476
42500
  }
42501
+ const fileKeys = Object.keys(def.files);
42502
+ const effectiveCombined = def.combined ?? (fileKeys.length > 1 && renderedFiles.size > 1 ? (
42503
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
42504
+ { outputFile: fileKeys[0] }
42505
+ ) : void 0);
42506
+ if (effectiveCombined && renderedFiles.size > 0) {
42507
+ const excluded = new Set(effectiveCombined.exclude ?? []);
42508
+ const parts = [];
42509
+ for (const filename of Object.keys(def.files)) {
42510
+ if (!excluded.has(filename) && renderedFiles.has(filename)) {
42511
+ parts.push(renderedFiles.get(filename) ?? "");
42512
+ }
42513
+ }
42514
+ if (parts.length > 0) {
42515
+ const combinedContent = parts.join("\n\n---\n\n");
42516
+ const combinedPath = join7(entityDir, effectiveCombined.outputFile);
42517
+ if (atomicWrite(combinedPath, combinedContent)) {
42518
+ filesWritten.push(combinedPath);
42519
+ } else {
42520
+ counters.skipped++;
42521
+ }
42522
+ renderedFiles.set(effectiveCombined.outputFile, combinedContent);
42523
+ entityFileHashes[effectiveCombined.outputFile] = {
42524
+ hash: contentHash(combinedContent)
42525
+ };
42526
+ }
42527
+ }
42528
+ manifestEntry.entities[slug] = entityFileHashes;
42529
+ const entitiesRendered = i6 + 1;
42530
+ throttle.tick({
42531
+ kind: "table-progress",
42532
+ table,
42533
+ entitiesRendered,
42534
+ entitiesTotal,
42535
+ tableIndex,
42536
+ tableCount,
42537
+ pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
42538
+ });
42477
42539
  }
42478
- manifestEntry.entities[slug] = entityFileHashes;
42479
- const entitiesRendered = i6 + 1;
42480
- throttle.tick({
42481
- kind: "table-progress",
42540
+ throttle.force({
42541
+ kind: "table-done",
42482
42542
  table,
42483
- entitiesRendered,
42543
+ entitiesRendered: entitiesTotal,
42484
42544
  entitiesTotal,
42485
42545
  tableIndex,
42486
42546
  tableCount,
42487
- pct: entitiesTotal > 0 ? entitiesRendered / entitiesTotal * 100 : 100
42547
+ pct: 100
42488
42548
  });
42549
+ return manifestEntry;
42489
42550
  }
42490
- manifestData[table] = manifestEntry;
42491
- throttle.force({
42492
- kind: "table-done",
42493
- table,
42494
- entitiesRendered: entitiesTotal,
42495
- entitiesTotal,
42496
- tableIndex,
42497
- tableCount,
42498
- pct: 100
42499
- });
42551
+ );
42552
+ if (signal?.aborted) return null;
42553
+ const manifestData = {};
42554
+ for (let i6 = 0; i6 < renderedEntries.length; i6++) {
42555
+ const entry = renderedEntries[i6];
42556
+ if (entry == null) return null;
42557
+ manifestData[entityTables[i6][0]] = entry;
42500
42558
  }
42501
42559
  return manifestData;
42502
42560
  }
@@ -46025,15 +46083,15 @@ async function checkForUpdate(pkgName, currentVersion) {
46025
46083
  import { createServer } from "http";
46026
46084
  import { spawn as spawn2 } from "child_process";
46027
46085
  import {
46028
- existsSync as existsSync21,
46086
+ existsSync as existsSync22,
46029
46087
  mkdirSync as mkdirSync10,
46030
- readFileSync as readFileSync16,
46031
- readdirSync as readdirSync7,
46088
+ readFileSync as readFileSync17,
46089
+ readdirSync as readdirSync8,
46032
46090
  rmSync,
46033
46091
  unlinkSync as unlinkSync5,
46034
46092
  writeFileSync as writeFileSync8
46035
46093
  } from "fs";
46036
- import { basename as basename11, dirname as dirname11, join as join26, resolve as resolve9, sep as sep6 } from "path";
46094
+ import { basename as basename11, dirname as dirname12, join as join27, resolve as resolve9, sep as sep6 } from "path";
46037
46095
  import { parseDocument as parseDocument3 } from "yaml";
46038
46096
 
46039
46097
  // src/gui/http.ts
@@ -46064,11 +46122,14 @@ function readJson(req, opts = {}) {
46064
46122
  req.on("error", reject);
46065
46123
  });
46066
46124
  }
46067
- async function tryHandler(res, fn) {
46125
+ async function tryHandler(res, fn, label = "request") {
46068
46126
  try {
46069
46127
  await fn();
46070
46128
  } catch (e6) {
46071
- sendJson(res, { error: e6.message }, 500);
46129
+ const err = e6;
46130
+ console.error(`[gui] ${label} failed: ${err.message}
46131
+ ${err.stack ?? ""}`);
46132
+ sendJson(res, { error: err.message }, 500);
46072
46133
  }
46073
46134
  }
46074
46135
 
@@ -46714,6 +46775,11 @@ var css = `
46714
46775
  /* \u2500\u2500 Top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
46715
46776
  header.topbar {
46716
46777
  display: flex; align-items: center; gap: 12px;
46778
+ /* The topbar's backdrop-filter creates a stacking context; without an
46779
+ explicit z-index it (and its dropdowns like .db-menu) get painted under
46780
+ the main content. 100 keeps dropdowns above the dashboard cards while
46781
+ staying below drawers (120/130), modals (1000), and toasts (2000). */
46782
+ position: relative; z-index: 100;
46717
46783
  min-height: 56px; padding: 8px 20px;
46718
46784
  background: var(--glass);
46719
46785
  -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur);
@@ -47871,6 +47937,9 @@ var css = `
47871
47937
  margin-top: 6px; font-size: 12px; color: var(--text-muted); cursor: pointer;
47872
47938
  }
47873
47939
  .rail-composer .composer-private input { cursor: pointer; }
47940
+ /* On a local workspace the toggle is a checked, read-only indicator. */
47941
+ .rail-composer .composer-private.is-disabled { opacity: 0.6; cursor: default; }
47942
+ .rail-composer .composer-private.is-disabled input { cursor: not-allowed; }
47874
47943
  .rail-composer .composer-private-hint { color: var(--text-muted); opacity: 0.8; font-size: 11px; }
47875
47944
  .rail-composer .composer-mic {
47876
47945
  flex: 0 0 auto; height: 38px; width: 38px; font-size: 15px;
@@ -47961,7 +48030,35 @@ var appJs = `
47961
48030
  columnMeta: {},
47962
48031
  systemTables: [],
47963
48032
  preferences: { show_system_tables: false, analytics: true },
47964
- };
48033
+ // Server-resolved analytics consent (stored pref AND env opt-outs). Drives
48034
+ // window.LatticeGA. False until loaded \u2192 no tracking before consent is known.
48035
+ analyticsEffective: false,
48036
+ // Whether the GUI may "Open in Finder" (LATTICE_LOCAL_OPEN, default on).
48037
+ localOpen: true,
48038
+ };
48039
+
48040
+ // Anonymous analytics passthrough \u2014 a no-op unless window.LatticeGA exists and
48041
+ // consent is on. Params are sanitized to coarse enums/bools/numbers by
48042
+ // LatticeGA.track (never table names, ids, queries, or PII).
48043
+ function gaTrack(name, params) {
48044
+ if (window.LatticeGA) window.LatticeGA.track(name, params || {});
48045
+ }
48046
+ // Coarse route TYPE from the hash \u2014 the prefix segment(s) ONLY, never the
48047
+ // table / row-id / db segments that follow (those would be identifying).
48048
+ // Fed to LatticeGA.pageView (which itself never sends the raw hash).
48049
+ function routeType(hash) {
48050
+ var h = String(hash || '#/');
48051
+ if (h === '#/' || h === '') return 'dashboard';
48052
+ var parts = h.replace(/^#/, '').split('/').filter(Boolean);
48053
+ var top = (parts[0] || 'other').toLowerCase();
48054
+ if (!/^[a-z0-9_-]+$/.test(top)) return 'other';
48055
+ if (top === 'settings') {
48056
+ var sub = (parts[1] || 'root').toLowerCase();
48057
+ return /^[a-z0-9_-]+$/.test(sub) ? 'settings_' + sub : 'settings_root';
48058
+ }
48059
+ if (top === 'fs' || top === 'objects' || top === 'system') return top;
48060
+ return 'other';
48061
+ }
47965
48062
 
47966
48063
  function isSecretColumn(tableName, colName) {
47967
48064
  var t = state.columnMeta[tableName];
@@ -47979,10 +48076,43 @@ var appJs = `
47979
48076
  function displayFor(name) {
47980
48077
  var override = state.iconOverrides[name];
47981
48078
  var base = DISPLAY[name];
47982
- var icon = (override && override.icon) || (base && base.icon) || DEFAULT_ICON;
48079
+ var icon = (override && override.icon) || (base && base.icon) || autoEmojiFor(name) || DEFAULT_ICON;
47983
48080
  var label = (base && base.label) || titleCase(name);
47984
48081
  return { label: label, icon: icon };
47985
48082
  }
48083
+ // Pick an apt emoji from an entity's NAME when the user hasn't set one and it
48084
+ // isn't in the built-in DISPLAY map. Keyword match against the de-underscored,
48085
+ // lower-cased name; returns null when nothing fits so displayFor falls back to
48086
+ // DEFAULT_ICON ("only if an emoji is apt"). Purely presentational \u2014 no persistence.
48087
+ var AUTO_EMOJI = [
48088
+ [/\\b(meetings?|calendar|events?|appointments?|schedule)\\b/, '\u{1F4C5}'],
48089
+ [/\\b(people|person|contacts?|users?|members?|staff|teams?|customers?|clients?|leads?|attendees?)\\b/, '\u{1F465}'],
48090
+ [/\\b(messages?|emails?|mail|inbox|chats?|conversations?|communications?|comms?)\\b/, '\u2709\uFE0F'],
48091
+ [/\\b(projects?)\\b/, '\u{1F680}'],
48092
+ [/\\b(files?|documents?|docs?|attachments?)\\b/, '\u{1F4C4}'],
48093
+ [/\\b(repos?|repositor(?:y|ies)|commits?|branches?)\\b/, '\u{1F4BF}'],
48094
+ [/\\b(invoices?|payments?|billing|transactions?|expenses?|orders?|purchases?)\\b/, '\u{1F9FE}'],
48095
+ [/\\b(revenue|budgets?|salar(?:y|ies)|prices?|pricing|costs?|finances?|financial)\\b/, '\u{1F4B0}'],
48096
+ [/\\b(companies|company|orgs?|organizations?|accounts?|businesses|business|vendors?|firms?|suppliers?)\\b/, '\u{1F3E2}'],
48097
+ [/\\b(tasks?|todos?|tickets?|issues?|jobs?|bugs?)\\b/, '\u2705'],
48098
+ [/\\b(policies|policy|insurance|claims?|coverage)\\b/, '\u{1F6E1}\uFE0F'],
48099
+ [/\\b(secrets?|credentials?|keys?|tokens?|passwords?)\\b/, '\u{1F510}'],
48100
+ [/\\b(notes?|memos?)\\b/, '\u{1F4DD}'],
48101
+ [/\\b(products?|items?|inventory|skus?)\\b/, '\u{1F4E6}'],
48102
+ [/\\b(reports?|analytics|metrics?|stats?|dashboards?)\\b/, '\u{1F4CA}'],
48103
+ [/\\b(contracts?|agreements?|legal|ndas?)\\b/, '\u{1F4DC}'],
48104
+ [/\\b(certificates?|certs?|licen[cs]es?)\\b/, '\u{1F393}'],
48105
+ [/\\b(properties|property|buildings?|estate|addresses|address|locations?|places?)\\b/, '\u{1F3E0}'],
48106
+ [/\\b(agents?|bots?|assistants?)\\b/, '\u{1F916}'],
48107
+ [/\\b(aliases|alias|tags?|labels?|categor(?:y|ies)|types?)\\b/, '\u{1F3F7}\uFE0F'],
48108
+ ];
48109
+ function autoEmojiFor(name) {
48110
+ var s = String(name || '').replace(/_/g, ' ').toLowerCase();
48111
+ for (var i = 0; i < AUTO_EMOJI.length; i++) {
48112
+ if (AUTO_EMOJI[i][0].test(s)) return AUTO_EMOJI[i][1];
48113
+ }
48114
+ return null;
48115
+ }
47986
48116
  function titleCase(s) {
47987
48117
  return s.replace(/_/g, ' ').replace(/\\b\\w/g, function (c) { return c.toUpperCase(); });
47988
48118
  }
@@ -48175,6 +48305,14 @@ var appJs = `
48175
48305
  state.columnMeta = results[2] || {};
48176
48306
  state.systemTables = (results[3] && results[3].tables) || [];
48177
48307
  state.preferences = results[4] || { show_system_tables: false, analytics: true };
48308
+ state.analyticsEffective = !!(results[4] && results[4].analytics_effective);
48309
+ // local_open defaults true (the server defaults it on) \u2014 drives whether the
48310
+ // file view offers "Open in Finder". Treat a missing field as enabled.
48311
+ state.localOpen = !results[4] || results[4].local_open !== false;
48312
+ // Boot analytics with the resolved consent (no network contact when off),
48313
+ // then record the session open. advanced_mode is a boolean \u2014 safe to send.
48314
+ if (window.LatticeGA) window.LatticeGA.init(state.analyticsEffective);
48315
+ gaTrack('app_open', { advanced_mode: advancedMode() });
48178
48316
  document.body.classList.toggle('advanced-mode', advancedMode());
48179
48317
  wireSettingsDrawer();
48180
48318
  renderWsSwitcher(results[5]);
@@ -48226,6 +48364,21 @@ var appJs = `
48226
48364
  var m = /^#\\/objects\\/([^/]+)/.exec(hash);
48227
48365
  return m ? m[1] : null;
48228
48366
  }
48367
+ // The record the user is currently viewing \u2014 the deepest table/id pair in the
48368
+ // route (a file/row detail). Returned to the chat as activeContext so "this
48369
+ // file"/"this row" resolves to it. null when just browsing a list / dashboard.
48370
+ function activeElement() {
48371
+ var hash = location.hash || '#/';
48372
+ var segs = (typeof fsParse === 'function') ? fsParse(hash) : null;
48373
+ if (segs && segs.length >= 2) {
48374
+ // segments alternate table,id,table,id\u2026 \u2014 take the last complete pair.
48375
+ var lastId = (segs.length % 2 === 0) ? segs.length - 1 : segs.length - 2;
48376
+ if (lastId >= 1) return { table: segs[lastId - 1], id: segs[lastId] };
48377
+ }
48378
+ var m = /^#\\/objects\\/([^/]+)\\/([^/]+)/.exec(hash);
48379
+ if (m) return { table: decodeURIComponent(m[1]), id: decodeURIComponent(m[2]) };
48380
+ return null;
48381
+ }
48229
48382
  // Briefly highlight a row that just changed (data-id === pk) in the view.
48230
48383
  function flashRow(pk) {
48231
48384
  var content = document.getElementById('content');
@@ -48275,9 +48428,12 @@ var appJs = `
48275
48428
  // current view (no badge) so we don't bother suppressing the self-echo.
48276
48429
  function onRealtimeChange(p) {
48277
48430
  if (!p || !p.table_name || !p.pk) return;
48431
+ // The NOTIFY envelope carries owner_role (the editor's login role) +
48432
+ // created_at \u2014 NOT owner_user_id / client_ts (which were never emitted, so
48433
+ // "last edited by" always showed nothing). #4.2
48278
48434
  lastEditedByPk[leKey(p.table_name, p.pk)] = {
48279
- userId: p.owner_user_id || null,
48280
- at: p.client_ts || p.created_at || '',
48435
+ userId: p.owner_role || null,
48436
+ at: p.created_at || '',
48281
48437
  };
48282
48438
  if (!isRowDataOp(p.op)) return;
48283
48439
  if (p.table_name === currentViewTable()) flashRow(p.pk);
@@ -48394,6 +48550,10 @@ var appJs = `
48394
48550
  * IndexedDB and replayed on reconnect; returns { queued: true }.
48395
48551
  */
48396
48552
  function rowWrite(method, path, body) {
48553
+ // Coarse, anonymized analytics \u2014 the verb only, never the path/table/ids.
48554
+ var gaEvent =
48555
+ method === 'POST' ? 'row_create' : method === 'PUT' ? 'row_update' : method === 'DELETE' ? 'row_delete' : '';
48556
+ if (gaEvent) gaTrack(gaEvent, {});
48397
48557
  var editId = newEditId();
48398
48558
  var clientTs = new Date().toISOString();
48399
48559
  var item = { editId: editId, method: method, path: path, body: body || null, clientTs: clientTs, status: 'pending', attempts: 0 };
@@ -48454,15 +48614,19 @@ var appJs = `
48454
48614
  body: it.body != null ? JSON.stringify(it.body) : undefined,
48455
48615
  }).then(function (r) {
48456
48616
  if (r.ok) return idbDelete(it.editId);
48457
- if (r.status === 409) {
48458
- // Unshared / stale schema \u2014 can't replay; mark failed (kept for
48459
- // inspection, surfaced) rather than silently dropped.
48617
+ // #4.5 \u2014 ANY 4xx is permanent for a replay: 409 (unshared / stale
48618
+ // schema / row gone), 403 (RLS owner-only), 404 (row not visible),
48619
+ // 400 (bad edit). Mark the edit failed + surface it (dead-letter)
48620
+ // instead of retrying it forever; only 5xx / network errors are
48621
+ // transient and left pending for the next drain. Previously only 409
48622
+ // was caught, so an RLS-rejected edit retried endlessly, unseen.
48623
+ if (r.status >= 400 && r.status < 500) {
48460
48624
  it.status = 'failed';
48461
48625
  return idbPut(it).then(function () {
48462
- showToast('An offline edit could not sync (the object changed). See pending edits.', {});
48626
+ showToast('An offline edit could not sync (the object changed or you lack access). See pending edits.', {});
48463
48627
  });
48464
48628
  }
48465
- // Other server error \u2014 leave pending, retry on the next drain.
48629
+ // Transient server error (5xx) \u2014 leave pending, retry on the next drain.
48466
48630
  return Promise.resolve();
48467
48631
  }).then(function () { return step(i + 1); },
48468
48632
  function () { return Promise.resolve(); /* network error \u2014 stop draining */ });
@@ -48578,7 +48742,7 @@ var appJs = `
48578
48742
  var fill = card.querySelector('.card-render-fill');
48579
48743
  if (fill) fill.style.width = clamped + '%';
48580
48744
  var pctEl = card.querySelector('.card-render-pct');
48581
- if (pctEl) pctEl.textContent = clamped + '%';
48745
+ if (pctEl) pctEl.textContent = 'Rendering ' + clamped + '%...';
48582
48746
  }
48583
48747
  // Clear the overlay for a finished/aborted table.
48584
48748
  function clearCardProgress(table) {
@@ -48767,7 +48931,7 @@ var appJs = `
48767
48931
  else if (e.key === 'Enter') {
48768
48932
  e.preventDefault();
48769
48933
  var q = input.value.trim();
48770
- if (q) askAssistant(q);
48934
+ if (q) { gaTrack('search', {}); askAssistant(q); } // event only \u2014 never the query text
48771
48935
  }
48772
48936
  });
48773
48937
  }
@@ -48853,6 +49017,7 @@ var appJs = `
48853
49017
 
48854
49018
  /** Standard undo: hit /api/history/undo and refresh views. */
48855
49019
  function undoLast() {
49020
+ gaTrack('history_action', { action: 'undo' });
48856
49021
  return fetchJson('/api/history/undo', { method: 'POST' })
48857
49022
  .then(afterMutation)
48858
49023
  .catch(function (err) { showToast('Undo failed: ' + err.message, {}); });
@@ -48863,12 +49028,14 @@ var appJs = `
48863
49028
  // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
48864
49029
  function wireHistoryControls() {
48865
49030
  document.getElementById('undo-btn').addEventListener('click', function () {
49031
+ gaTrack('history_action', { action: 'undo' });
48866
49032
  fetchJson('/api/history/undo', { method: 'POST' })
48867
49033
  .then(function () { return afterMutation(); })
48868
49034
  .then(function () { showToast('Last change undone', {}); })
48869
49035
  .catch(function (err) { showToast('Undo failed: ' + err.message, {}); });
48870
49036
  });
48871
49037
  document.getElementById('redo-btn').addEventListener('click', function () {
49038
+ gaTrack('history_action', { action: 'redo' });
48872
49039
  fetchJson('/api/history/redo', { method: 'POST' })
48873
49040
  .then(function () { return afterMutation(); })
48874
49041
  .then(function () { showToast('Redone', {}); })
@@ -48916,6 +49083,18 @@ var appJs = `
48916
49083
  state.systemTables = (results[3] && results[3].tables) || [];
48917
49084
  renderWsSwitcher(results[4]);
48918
49085
  renderSidebar();
49086
+ // renderWsSwitcher set cloudMode from the new workspace's kind; re-render
49087
+ // the composer so the Private-mode toggle reflects local vs cloud (it is
49088
+ // forced checked+disabled on local). See #7.
49089
+ renderComposer();
49090
+ // #G \u2014 the chat rail is per-workspace (chat_threads/chat_messages live in
49091
+ // the workspace DB), so a switch/create must reset it to the NEW
49092
+ // workspace's conversation instead of stranding the previous one on screen.
49093
+ // (Reset inline rather than via newChat() so it doesn't fire the
49094
+ // assistant_thread_new analytics event on every switch.)
49095
+ currentThreadId = null;
49096
+ clearChat();
49097
+ refreshThreadList(true);
48919
49098
  if (location.hash !== '#/') location.hash = '#/';
48920
49099
  else renderRoute();
48921
49100
  loadedTables = {};
@@ -49019,6 +49198,7 @@ var appJs = `
49019
49198
  b.addEventListener('click', function () {
49020
49199
  var id = b.getAttribute('data-id');
49021
49200
  if (id === currentId) { menu.hidden = true; return; }
49201
+ gaTrack('workspace_switch', {}); // event only \u2014 never the workspace id/name
49022
49202
  // Surface "switching" on the stable header button for the WHOLE
49023
49203
  // switch (POST + reloadEverything), independent of the ephemeral
49024
49204
  // menu-item withBusy spinner \u2014 the menu can close/rebuild mid-switch.
@@ -49099,6 +49279,10 @@ var appJs = `
49099
49279
  var ul = document.getElementById('object-nav');
49100
49280
  var prefix = advancedMode() ? '#/objects/' : '#/fs/';
49101
49281
  var firstClass = state.entities.tables.filter(function (t) { return !isJunction(t); });
49282
+ // Objects list is ordered alphabetically by display label (case-insensitive).
49283
+ firstClass.sort(function (a, b) {
49284
+ return displayFor(a.name).label.toLowerCase().localeCompare(displayFor(b.name).label.toLowerCase());
49285
+ });
49102
49286
  ul.innerHTML = firstClass.map(function (t) {
49103
49287
  var d = displayFor(t.name);
49104
49288
  var unseen = unseenByTable[t.name] || 0;
@@ -49143,6 +49327,7 @@ var appJs = `
49143
49327
  highlightActive();
49144
49328
  var content = document.getElementById('content');
49145
49329
  var hash = location.hash || '#/';
49330
+ if (window.LatticeGA) window.LatticeGA.pageView(routeType(hash));
49146
49331
 
49147
49332
  if (hash === '#/' || hash === '') { renderDashboard(content); return; }
49148
49333
 
@@ -49240,7 +49425,7 @@ var appJs = `
49240
49425
  // bottom-edge bar (width = %); the pill is the \u27F3 <pct>% corner badge.
49241
49426
  '<div class="card-render" aria-hidden="true">' +
49242
49427
  '<div class="card-render-fill"></div>' +
49243
- '<span class="card-render-pill"><span class="spinner" aria-hidden="true"></span><span class="card-render-pct">0%</span></span>' +
49428
+ '<span class="card-render-pill"><span class="spinner" aria-hidden="true"></span><span class="card-render-pct">Rendering 0%...</span></span>' +
49244
49429
  '</div>' +
49245
49430
  '</a>';
49246
49431
  }).join('');
@@ -49728,19 +49913,42 @@ var appJs = `
49728
49913
  // Bytes are viewable when there's a local copy OR an S3-backed cloud_ref \u2014 the
49729
49914
  // /blob route resolves local-or-S3 transparently, so the browser just hits it.
49730
49915
  function hasViewableFile(row) {
49731
- return hasLocalFile(row) ||
49732
- (row.ref_kind === 'cloud_ref' && row.ref_provider === 's3' && (row.ref_uri || row.blob_path));
49916
+ return hasLocalFile(row) || isS3File(row);
49917
+ }
49918
+ // The file's bytes live in S3 (cloud). Download (not Open in Finder) applies.
49919
+ function isS3File(row) {
49920
+ return row.ref_kind === 'cloud_ref' && row.ref_provider === 's3' && !!(row.ref_uri || row.blob_path);
49921
+ }
49922
+ // True when the row's bytes are reachable on THIS machine's disk (so "Open in
49923
+ // Finder" is meaningful). Mirrors the server's localPathOf: a legacy path, a
49924
+ // local_ref, or a blob/cloud_ref whose blob_path was kept locally.
49925
+ function hasLocalBytes(row) {
49926
+ return !!(
49927
+ row.path ||
49928
+ (row.ref_kind === 'local_ref' && row.ref_uri) ||
49929
+ ((row.ref_kind === 'blob' || row.ref_kind === 'cloud_ref') && row.blob_path)
49930
+ );
49931
+ }
49932
+ var IMAGE_EXTS = ['png','jpg','jpeg','gif','webp','bmp','svg','avif','heic','heif','ico','tif','tiff'];
49933
+ function isImageFile(row) {
49934
+ // Detect by mime, FALLING BACK to the filename extension \u2014 an upload that
49935
+ // didn't record a mime still previews (the inline image was silently missing).
49936
+ if (String(row.mime || '').indexOf('image/') === 0) return true;
49937
+ var name = String(row.original_name || '').toLowerCase();
49938
+ var dot = name.lastIndexOf('.');
49939
+ return dot >= 0 && IMAGE_EXTS.indexOf(name.slice(dot + 1)) >= 0;
49733
49940
  }
49734
49941
  function renderFilePreview(row) {
49735
49942
  var host = document.getElementById('file-preview'); if (!host || !row) return;
49736
49943
  var id = row.id;
49737
49944
  var mime = row.mime || '';
49738
49945
  var blobUrl = '/api/files/' + encodeURIComponent(id) + '/blob';
49946
+ var viewable = hasViewableFile(row);
49739
49947
  var html = '';
49740
49948
  if (row.description) html += '<div class="file-desc">' + escapeHtml(row.description) + '</div>';
49741
- if (mime.indexOf('image/') === 0 && hasViewableFile(row)) {
49949
+ if (isImageFile(row) && viewable) {
49742
49950
  html += '<img src="' + blobUrl + '" alt="' + escapeHtml(row.original_name || 'image') + '">';
49743
- } else if (mime === 'application/pdf' && hasViewableFile(row)) {
49951
+ } else if (mime === 'application/pdf' && viewable) {
49744
49952
  html += '<iframe src="' + blobUrl + '" title="PDF preview"></iframe>';
49745
49953
  } else if (row.extracted_text && MD_MIMES.indexOf(mime) >= 0) {
49746
49954
  html += '<div class="md-body">' + mdToHtml(String(row.extracted_text).slice(0, 40000)) + '</div>';
@@ -49750,10 +49958,15 @@ var appJs = `
49750
49958
  html += '<div class="file-unsupported">No inline preview for this file type' +
49751
49959
  (mime ? ' (' + escapeHtml(mime) + ')' : '') + '.</div>';
49752
49960
  }
49753
- if (hasViewableFile(row)) {
49961
+ // Open in Finder vs Download are MUTUALLY EXCLUSIVE: a file on this machine's
49962
+ // disk opens locally (when LATTICE_LOCAL_OPEN is on); a cloud (S3) file with
49963
+ // no local copy is downloaded. Never both \u2014 there's only ever one source.
49964
+ var canOpen = hasLocalBytes(row) && state.localOpen;
49965
+ var canDownload = isS3File(row) && !hasLocalBytes(row);
49966
+ if (canOpen || canDownload) {
49754
49967
  html += '<div class="file-actions">' +
49755
- (hasLocalFile(row) ? '<button class="btn" id="file-open">Open in Finder</button>' : '') +
49756
- '<a class="btn" href="' + blobUrl + '" download="' + escapeHtml(row.original_name || 'file') + '">Download</a>' +
49968
+ (canOpen ? '<button class="btn" id="file-open">Open in Finder</button>' : '') +
49969
+ (canDownload ? '<a class="btn" href="' + blobUrl + '" download="' + escapeHtml(row.original_name || 'file') + '">Download</a>' : '') +
49757
49970
  '</div>';
49758
49971
  }
49759
49972
  host.innerHTML = html;
@@ -50105,6 +50318,7 @@ var appJs = `
50105
50318
  return window.localStorage.getItem(FS_KEYS.advanced) === '1';
50106
50319
  }
50107
50320
  function setAdvancedMode(on) {
50321
+ gaTrack('setting_change', { setting: 'advanced_mode', value: !!on }); // coarse enum + bool
50108
50322
  window.localStorage.setItem(FS_KEYS.advanced, on ? '1' : '0');
50109
50323
  document.body.classList.toggle('advanced-mode', on);
50110
50324
  // Preserve context: map the current location between the file-system
@@ -50607,7 +50821,6 @@ var appJs = `
50607
50821
  var body = document.getElementById('drawer-body');
50608
50822
  if (!body) return;
50609
50823
  if (tab === 'database') renderDatabaseSettings(body);
50610
- else if (tab === 'chat') renderChatSettings(body);
50611
50824
  else if (tab === 'lattice') renderLatticeSettings(body);
50612
50825
  else renderUserConfig(body);
50613
50826
  }
@@ -50683,7 +50896,8 @@ var appJs = `
50683
50896
  function renderHistory(content) {
50684
50897
  var firstClass = state.entities.tables
50685
50898
  .filter(function (t) { return !isJunction(t); })
50686
- .map(function (t) { return t.name; });
50899
+ .map(function (t) { return t.name; })
50900
+ .sort(function (a, b) { return displayFor(a).label.toLowerCase().localeCompare(displayFor(b).label.toLowerCase()); });
50687
50901
  var options = '<option value="">All entities</option>' +
50688
50902
  firstClass.map(function (n) {
50689
50903
  var sel = n === historyFilterTable ? ' selected' : '';
@@ -50723,6 +50937,7 @@ var appJs = `
50723
50937
  mount.querySelectorAll('button.history-revert').forEach(function (btn) {
50724
50938
  btn.addEventListener('click', function () {
50725
50939
  var id = btn.getAttribute('data-id');
50940
+ gaTrack('history_action', { action: 'revert' });
50726
50941
  fetchJson('/api/history/revert/' + encodeURIComponent(id), { method: 'POST' })
50727
50942
  .then(afterMutation)
50728
50943
  .then(function () {
@@ -51288,6 +51503,7 @@ var appJs = `
51288
51503
  headers: { 'content-type': 'application/json' },
51289
51504
  body: JSON.stringify({ name: name, icon: icon || undefined }),
51290
51505
  }).then(function () {
51506
+ gaTrack('table_create', {}); // event only \u2014 never the table name
51291
51507
  // New node not in the current graph \u2192 rebuild it (in place, no
51292
51508
  // route change so the drawer scroll is preserved).
51293
51509
  return dmRefreshPanel(name, true);
@@ -51406,6 +51622,8 @@ var appJs = `
51406
51622
  dmLinks.forEach(function (lk) { linkedTargets[lk.other] = 1; });
51407
51623
  var linkTargets = ((state.entities && state.entities.tables) || []).filter(function (rt) {
51408
51624
  return !isJunction(rt) && rt.name !== tableName && !linkedTargets[rt.name];
51625
+ }).sort(function (a, b) {
51626
+ return displayFor(a.name).label.toLowerCase().localeCompare(displayFor(b.name).label.toLowerCase());
51409
51627
  });
51410
51628
  var addLinkHtml = linkTargets.length
51411
51629
  ? '<div class="dm-row-inline" style="margin-top:8px">' +
@@ -51424,22 +51642,30 @@ var appJs = `
51424
51642
  // tables, show no sharing control.
51425
51643
  var canShare = !!(t && t.ownedByMe === true);
51426
51644
  var isShared = !!(t && t.shared);
51427
- var shareRow = canShare
51428
- ? '<label>Cloud sharing</label>' +
51429
- '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
51430
- '<button class="btn' + (isShared ? '' : ' primary') + '" id="dm-share-btn">' +
51431
- (isShared ? 'Make private' : 'Share with workspace') +
51432
- '</button>' +
51433
- '<span style="font-size:12px;color:var(--text-muted)">' +
51434
- (isShared ? 'Visible to everyone on this cloud workspace.' : 'Private to you. Share to make it visible to everyone on this cloud workspace.') +
51435
- '</span>' +
51436
- '</div>'
51437
- : '';
51645
+ var neverShare = !!(t && t.neverShare);
51646
+ // A never-share table (e.g. secrets) can NEVER be shared \u2014 its rows are a
51647
+ // hard-private floor \u2014 so the "Share with workspace" button must not exist
51648
+ // for it; show a static note instead.
51649
+ var shareRow = !canShare
51650
+ ? ''
51651
+ : neverShare
51652
+ ? '<label>Cloud sharing</label>' +
51653
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
51654
+ '<span style="font-size:12px;color:var(--text-muted)">\u{1F512} Private to you \u2014 this table is never shared.</span>' +
51655
+ '</div>'
51656
+ : '<label>Cloud sharing</label>' +
51657
+ '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
51658
+ '<button class="btn' + (isShared ? '' : ' primary') + '" id="dm-share-btn">' +
51659
+ (isShared ? 'Make private' : 'Share with workspace') +
51660
+ '</button>' +
51661
+ '<span style="font-size:12px;color:var(--text-muted)">' +
51662
+ (isShared ? 'Visible to everyone on this cloud workspace.' : 'Private to you. Share to make it visible to everyone on this cloud workspace.') +
51663
+ '</span>' +
51664
+ '</div>';
51438
51665
  // Owner-only "new rows default to" control, shown for a shared table.
51439
51666
  // A never-share table's rows are always private, so the default-visibility
51440
51667
  // select is disabled while never-share is on.
51441
51668
  var defaultVis = (t && t.defaultRowVisibility) || 'private';
51442
- var neverShare = !!(t && t.neverShare);
51443
51669
  var defaultVisRow = canShare && isShared
51444
51670
  ? '<label>New rows default to</label>' +
51445
51671
  '<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">' +
@@ -51515,11 +51741,18 @@ var appJs = `
51515
51741
  wireEntityEditPanel(panel, tableName);
51516
51742
  var shareBtn = panel.querySelector('#dm-share-btn');
51517
51743
  if (shareBtn) shareBtn.addEventListener('click', function () {
51744
+ // "Shared" maps to the table's default row visibility = everyone (vs
51745
+ // owner-private) under the 3.1 RLS model, so the toggle drives the
51746
+ // existing default-row-visibility endpoint. (The old /share endpoint was
51747
+ // removed in the RLS rewrite \u2014 calling it 404'd, which is why the control
51748
+ // appeared dead.)
51749
+ var nextVis = isShared ? 'private' : 'everyone';
51750
+ gaTrack('data_model_share', { visibility: nextVis }); // coarse enum only, no table name
51518
51751
  withBusy(shareBtn, function () {
51519
- return fetchJson('/api/schema/entities/' + encodeURIComponent(tableName) + '/share', {
51752
+ return fetchJson('/api/schema/entities/' + encodeURIComponent(tableName) + '/default-row-visibility', {
51520
51753
  method: 'POST',
51521
51754
  headers: { 'content-type': 'application/json' },
51522
- body: JSON.stringify({ share: !isShared }),
51755
+ body: JSON.stringify({ visibility: nextVis }),
51523
51756
  }).then(function () {
51524
51757
  // Rebuild the graph (not just the panel) so the node's share-status
51525
51758
  // colour (gnode-shared/gnode-private) recolours immediately from the
@@ -51851,6 +52084,7 @@ var appJs = `
51851
52084
  return fetchJson('/api/schema/entities/' + encodeURIComponent(tableName), {
51852
52085
  method: 'DELETE',
51853
52086
  }).then(function () {
52087
+ gaTrack('table_delete', {}); // event only \u2014 never the table name
51854
52088
  return dmRefreshPanel(null, true);
51855
52089
  }).then(function () {
51856
52090
  showToast('Table "' + tableName + '" deleted', {});
@@ -52182,6 +52416,7 @@ var appJs = `
52182
52416
  }
52183
52417
 
52184
52418
  function submitLocal() {
52419
+ gaTrack('workspace_create', { kind: 'local' }); // coarse enum only, no name
52185
52420
  // Create + activate a new local workspace in the registry (the single
52186
52421
  // source of truth). The friendly name is the workspace display name \u2014
52187
52422
  // no separate slug/config-file/rename dance.
@@ -52202,6 +52437,7 @@ var appJs = `
52202
52437
  // is no per-table sharing at creation time.
52203
52438
  var fields = parsePostgresUrl(wizState.cloudUrl.trim(), wizState.name.trim());
52204
52439
  if (!fields) return Promise.reject(new Error('Cloud URL must be a valid postgres:// connection string.'));
52440
+ gaTrack('workspace_create', { kind: 'cloud' }); // coarse enum only, no name/URL
52205
52441
  return fetchJson('/api/workspaces/create', {
52206
52442
  method: 'POST',
52207
52443
  headers: { 'content-type': 'application/json' },
@@ -52465,8 +52701,11 @@ var appJs = `
52465
52701
  '<span>Send anonymous analytics</span>' +
52466
52702
  '</label>' +
52467
52703
  '<p class="lead" style="margin:8px 0 0;font-size:12px;color:var(--text-muted)">' +
52468
- 'Anonymous analytics will be shared with Lattice using ' +
52469
- '<a href="https://scarf.sh" target="_blank" rel="noopener">Scarf</a>.' +
52704
+ 'Anonymous usage analytics \u2014 via ' +
52705
+ '<a href="https://scarf.sh" target="_blank" rel="noopener">Scarf</a> for installs and ' +
52706
+ 'Google Analytics inside the app \u2014 help us improve Lattice. No table or column names, ' +
52707
+ 'row data, queries, file names, or personal info are ever sent: only coarse, anonymized ' +
52708
+ 'events. Respects Do-Not-Track.' +
52470
52709
  '</p>' +
52471
52710
  '<div id="pref-msg" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>' +
52472
52711
  '</div>';
@@ -52487,7 +52726,18 @@ var appJs = `
52487
52726
  .catch(function (e) { msg.textContent = 'Failed: ' + e.message; });
52488
52727
  }
52489
52728
  host.querySelector('#pref-analytics').addEventListener('change', function (e) {
52490
- savePref({ analytics: !!e.target.checked });
52729
+ var on = !!e.target.checked;
52730
+ // Apply browser-analytics consent immediately. Record the opt-in AFTER
52731
+ // enabling (track needs consent) and the opt-out BEFORE disabling.
52732
+ if (on) {
52733
+ if (window.LatticeGA) window.LatticeGA.setConsent(true);
52734
+ gaTrack('analytics_opt_in', {});
52735
+ } else {
52736
+ gaTrack('analytics_opt_out', {});
52737
+ if (window.LatticeGA) window.LatticeGA.setConsent(false);
52738
+ }
52739
+ state.analyticsEffective = on;
52740
+ savePref({ analytics: on });
52491
52741
  });
52492
52742
  }
52493
52743
 
@@ -52542,11 +52792,15 @@ var appJs = `
52542
52792
  '<h2>Workspace Settings</h2>' +
52543
52793
  '<div id="db-name-host"><div class="placeholder" style="padding:14px">Loading workspace name\u2026</div></div>' +
52544
52794
  '<div id="dbconfig-host"><div class="placeholder" style="padding:18px">Loading database configuration\u2026</div></div>' +
52795
+ // System Prompt subsection \u2014 directly beneath Database connection,
52796
+ // owner-only (the panel renders nothing for members / local).
52797
+ '<div id="system-prompt-host"></div>' +
52545
52798
  '<div id="data-model-host"><div class="placeholder" style="padding:18px">Loading data model\u2026</div></div>' +
52546
52799
  '<div id="db-danger-host"></div>' +
52547
52800
  '</div>';
52548
52801
  renderDatabaseNamePanel(document.getElementById('db-name-host'));
52549
52802
  renderDatabasePanel(document.getElementById('dbconfig-host'));
52803
+ renderSystemPromptPanel(document.getElementById('system-prompt-host'));
52550
52804
  renderDataModelInto(document.getElementById('data-model-host'));
52551
52805
  renderDatabaseDangerZone(document.getElementById('db-danger-host'));
52552
52806
  }
@@ -52788,6 +53042,7 @@ var appJs = `
52788
53042
  host.querySelectorAll('tr.ws-row[data-switch-id]').forEach(function (row) {
52789
53043
  row.addEventListener('click', function () {
52790
53044
  var id = row.getAttribute('data-switch-id');
53045
+ gaTrack('workspace_switch', {}); // event only \u2014 never the workspace id/name
52791
53046
  // Switch the workspace AND close the settings drawer at the same time \u2014
52792
53047
  // close immediately (concurrent with the switch) so it isn't left open.
52793
53048
  closeSettingsDrawer();
@@ -52925,12 +53180,34 @@ var appJs = `
52925
53180
  if (info.state === 'cloud-member') {
52926
53181
  membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">You are a member of this cloud.</div>';
52927
53182
  } else {
52928
- membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div>';
52929
- fetchJson('/api/cloud/members').then(function (data) {
52930
- membersHost.innerHTML = renderMembersList((data && data.members) || []);
52931
- }).catch(function (e) {
52932
- membersHost.innerHTML = '<div style="font-size:12px;color:var(--warn)">Could not load members: ' + escapeHtml(e.message) + '</div>';
52933
- });
53183
+ var loadMembers = function () {
53184
+ membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div>';
53185
+ fetchJson('/api/cloud/members').then(function (data) {
53186
+ membersHost.innerHTML = renderMembersList((data && data.members) || [], isOwner);
53187
+ membersHost.querySelectorAll('[data-kick]').forEach(function (btn) {
53188
+ btn.addEventListener('click', function () {
53189
+ var role = btn.getAttribute('data-kick');
53190
+ if (!role) return;
53191
+ if (!window.confirm('Remove this member? They lose access immediately.')) return;
53192
+ withBusy(btn, function () {
53193
+ return fetchJson('/api/cloud/remove-member', {
53194
+ method: 'POST',
53195
+ headers: { 'content-type': 'application/json' },
53196
+ body: JSON.stringify({ role: role }),
53197
+ }).then(function () {
53198
+ showToast('Member removed', {});
53199
+ loadMembers();
53200
+ }).catch(function (e) {
53201
+ showToast('Could not remove member: ' + (e && e.message ? e.message : e), {});
53202
+ });
53203
+ });
53204
+ });
53205
+ });
53206
+ }).catch(function (e) {
53207
+ membersHost.innerHTML = '<div style="font-size:12px;color:var(--warn)">Could not load members: ' + escapeHtml(e.message) + '</div>';
53208
+ });
53209
+ };
53210
+ loadMembers();
52934
53211
  }
52935
53212
  }
52936
53213
  void isOwner;
@@ -52938,18 +53215,27 @@ var appJs = `
52938
53215
 
52939
53216
  /** Members list (owner + member roles), recovered from latticesql 1.14.0
52940
53217
  * (commit 2862959), adapted to the RLS-cloud member model. */
52941
- function renderMembersList(members) {
53218
+ function renderMembersList(members, canManage) {
52942
53219
  if (!members.length) {
52943
53220
  return '<div class="members-list"><h4>Members</h4>' +
52944
53221
  '<div style="font-size:12px;color:var(--text-muted)">Just you.</div></div>';
52945
53222
  }
52946
53223
  var rows = members.map(function (m) {
52947
- var pill = m.isOwner ? 'Owner' : 'Member';
53224
+ var isOwner = m.status === 'owner';
53225
+ var pill = isOwner ? 'Owner' : (m.status === 'invited' ? 'Invited' : 'Member');
53226
+ // Show a human name (display name, else the email's local part, else the
53227
+ // role) + the email \u2014 NOT the bare Postgres role as the primary label.
53228
+ var label = (m.name && String(m.name).trim()) || m.role;
53229
+ var kick = canManage && !m.isYou && !isOwner
53230
+ ? '<button class="btn destructive" data-kick="' + escapeHtml(m.role) + '">Kick</button>'
53231
+ : '';
52948
53232
  return '<div class="member-row" data-role="' + escapeHtml(m.role) + '">' +
52949
- '<span><code>' + escapeHtml(m.role) + '</code>' +
53233
+ '<span>' + escapeHtml(label) +
52950
53234
  (m.isYou ? ' <span style="color:var(--accent);font-size:11px">(you)</span>' : '') +
52951
- ' <span class="role-tag' + (m.isOwner ? '' : ' role-member') + '">' + pill + '</span>' +
53235
+ (m.email ? ' <span style="color:var(--text-muted);font-size:11px">' + escapeHtml(m.email) + '</span>' : '') +
53236
+ ' <span class="role-tag' + (isOwner ? '' : ' role-member') + '">' + pill + '</span>' +
52952
53237
  '</span>' +
53238
+ kick +
52953
53239
  '</div>';
52954
53240
  }).join('');
52955
53241
  return '<div class="members-list"><h4>Members</h4>' + rows + '</div>';
@@ -53113,36 +53399,29 @@ var appJs = `
53113
53399
  // Chat settings (drawer tab): the cloud chat system prompt, edited INLINE
53114
53400
  // with a Save button \u2014 no overlay. Owner-only (the GET returns the text only
53115
53401
  // to an owner); members / local workspaces see a short note instead.
53116
- function renderChatSettings(content) {
53117
- content.innerHTML =
53118
- '<div class="teams-page">' +
53119
- '<h2>Chat</h2>' +
53120
- '<div id="chat-settings-host"><div class="placeholder" style="padding:18px">Loading\u2026</div></div>' +
53121
- '</div>';
53122
- var host = document.getElementById('chat-settings-host');
53402
+ // The cloud chat System Prompt editor \u2014 a subsection of Settings \u2192 Workspace,
53403
+ // beneath Database connection. Owner-only: renders nothing for a member or a
53404
+ // local workspace (the GET reports supported=false / canEdit=false there), so
53405
+ // the subsection simply doesn't appear for them.
53406
+ function renderSystemPromptPanel(host) {
53407
+ if (!host) return;
53408
+ host.innerHTML = '';
53123
53409
  fetchJson('/api/cloud/system-prompt').then(function (cfg) {
53124
- var panelOpen =
53125
- '<div class="dbconfig-panel" style="padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
53126
- '<h3 style="margin:0 0 8px">Chat system prompt</h3>';
53127
- if (!cfg || cfg.canEdit !== true) {
53128
- host.innerHTML = panelOpen +
53129
- '<p style="font-size:12px;color:var(--text-muted);margin:0">' +
53130
- 'The chat system prompt is owner-only and applies to a cloud workspace. ' +
53131
- 'Nothing to edit here for this workspace.</p></div>';
53132
- return;
53133
- }
53410
+ if (!cfg || cfg.supported !== true || cfg.canEdit !== true) return; // owner+cloud only
53134
53411
  var current = typeof cfg.prompt === 'string' ? cfg.prompt : '';
53135
- host.innerHTML = panelOpen +
53136
- '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
53137
- 'Added to every member chat in this cloud. Members cannot see or edit it \u2014 only you, the owner, can.</p>' +
53138
- '<textarea id="chat-system-prompt" rows="10" style="width:100%;font-family:inherit;resize:vertical" ' +
53139
- 'placeholder="e.g. Always answer in a formal tone. Our fiscal year starts in July.">' +
53140
- escapeHtml(current) + '</textarea>' +
53141
- '<div style="margin-top:10px;display:flex;align-items:center;gap:10px">' +
53142
- '<button class="btn primary" id="chat-prompt-save">Save</button>' +
53143
- '<span id="chat-prompt-msg" style="font-size:12px;color:var(--text-muted)"></span>' +
53144
- '</div>' +
53145
- '</div>';
53412
+ host.innerHTML =
53413
+ '<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
53414
+ '<h3 style="margin:0 0 8px">System Prompt</h3>' +
53415
+ '<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">' +
53416
+ 'Added to every member chat in this cloud workspace. Members cannot see or edit it \u2014 only you, the owner, can.</p>' +
53417
+ '<textarea id="chat-system-prompt" rows="10" style="width:100%;font-family:inherit;resize:vertical" ' +
53418
+ 'placeholder="e.g. Always answer in a formal tone. Our fiscal year starts in July.">' +
53419
+ escapeHtml(current) + '</textarea>' +
53420
+ '<div style="margin-top:10px;display:flex;align-items:center;gap:10px">' +
53421
+ '<button class="btn primary" id="chat-prompt-save">Save</button>' +
53422
+ '<span id="chat-prompt-msg" style="font-size:12px;color:var(--text-muted)"></span>' +
53423
+ '</div>' +
53424
+ '</div>';
53146
53425
  var saveBtn = document.getElementById('chat-prompt-save');
53147
53426
  var msg = document.getElementById('chat-prompt-msg');
53148
53427
  if (saveBtn) saveBtn.addEventListener('click', function () {
@@ -53159,9 +53438,8 @@ var appJs = `
53159
53438
  if (msg) msg.textContent = 'Failed: ' + (e && e.message ? e.message : String(e));
53160
53439
  });
53161
53440
  });
53162
- }).catch(function (err) {
53163
- host.innerHTML = '<div class="placeholder">Could not load: ' +
53164
- escapeHtml(err && err.message ? err.message : String(err)) + '</div>';
53441
+ }).catch(function () {
53442
+ // Not a cloud / not the owner / probe failed \u2014 leave the subsection empty.
53165
53443
  });
53166
53444
  }
53167
53445
 
@@ -53188,6 +53466,7 @@ var appJs = `
53188
53466
  headers: { 'content-type': 'application/json' },
53189
53467
  body: JSON.stringify({ email: data.email }),
53190
53468
  }).then(function (res) {
53469
+ gaTrack('member_invite', {}); // event only \u2014 never the invitee email
53191
53470
  showInviteTokenModal(res || {});
53192
53471
  });
53193
53472
  },
@@ -53249,6 +53528,7 @@ var appJs = `
53249
53528
  'added-link': ['Added', 'link', 'links'],
53250
53529
  'deleted-link': ['Deleted', 'link', 'links'],
53251
53530
  'created-link': ['Created', 'link table', 'link tables'],
53531
+ 'linked-rel': ['Linked', 'relationship', 'relationships'],
53252
53532
  };
53253
53533
  function schemaAction(summary) {
53254
53534
  var s = String(summary || '');
@@ -53256,10 +53536,18 @@ var appJs = `
53256
53536
  if (/^Created table/.test(s)) return 'created-table';
53257
53537
  if (/^Deleted table/.test(s)) return 'deleted-table';
53258
53538
  if (/^Renamed table/.test(s)) return 'renamed-table';
53259
- if (/^Added a column/.test(s)) return 'added-column';
53539
+ // Two emitters: the generic "Added a column to X" and the specific
53540
+ // "Added column(s) a, b to X" (ingest auto-creates columns). Both group.
53541
+ if (/^Added (a )?column/.test(s)) return 'added-column';
53260
53542
  if (/^Renamed a column/.test(s)) return 'renamed-column';
53261
53543
  if (/^Added a link/.test(s)) return 'added-link';
53262
53544
  if (/^Deleted a link/.test(s)) return 'deleted-link';
53545
+ // Junction-materialization summaries ("Linked files \u2194 project",
53546
+ // "Linked authors \u2194 books") from materializeJunction \u2014 these arrive as a
53547
+ // schema op but matched no rule above, so they used to return null and
53548
+ // spam one ungrouped pill per link. Collapse a run into "Linked N
53549
+ // relationships".
53550
+ if (/^Linked .+ \u2194 /.test(s)) return 'linked-rel';
53263
53551
  return null; // unknown schema op: keep it ungrouped (stay honest)
53264
53552
  }
53265
53553
  // Group identical-TYPE events into one counted pill regardless of which
@@ -53567,6 +53855,7 @@ var appJs = `
53567
53855
  feedGroups = {};
53568
53856
  }
53569
53857
  function newChat() {
53858
+ gaTrack('assistant_thread_new', {});
53570
53859
  currentThreadId = null;
53571
53860
  clearChat();
53572
53861
  var sel = document.getElementById('rail-threads');
@@ -53730,6 +54019,8 @@ var appJs = `
53730
54019
  function sendChat(text) {
53731
54020
  if (chatBusy || !text) return;
53732
54021
  chatBusy = true;
54022
+ gaTrack('assistant_message', {}); // no message text \u2014 just the event
54023
+
53733
54024
  // Open a fresh turn scope: this turn's activity cards group together (no
53734
54025
  // window expiry) and their timers measure from now.
53735
54026
  feedTurnId += 1;
@@ -53751,7 +54042,8 @@ var appJs = `
53751
54042
  var privateMode = !!(privEl && privEl.checked);
53752
54043
  fetch('/api/chat', {
53753
54044
  method: 'POST', headers: { 'content-type': 'application/json' },
53754
- body: JSON.stringify({ message: text, history: historyToSend, threadId: currentThreadId, privateMode: privateMode })
54045
+ // activeContext: the record on screen, so "this file"/"this row" resolves.
54046
+ body: JSON.stringify({ message: text, history: historyToSend, threadId: currentThreadId, privateMode: privateMode, activeContext: activeElement() })
53755
54047
  }).then(function (r) {
53756
54048
  if (!r.ok || !r.body) {
53757
54049
  return r.json().then(function (j) { throw new Error(j.error || ('HTTP ' + r.status)); });
@@ -53960,6 +54252,7 @@ var appJs = `
53960
54252
  }
53961
54253
  function uploadFiles(files) {
53962
54254
  if (!files) return;
54255
+ gaTrack('file_ingest', { count: files.length }); // count only \u2014 never file names
53963
54256
  for (var i = 0; i < files.length; i++) uploadFile(files[i]);
53964
54257
  }
53965
54258
  // Mobile: tapping the handle expands/collapses the bottom drawer.
@@ -54019,9 +54312,14 @@ var appJs = `
54019
54312
  '</div>' +
54020
54313
  // Private mode \u2014 when checked, items the assistant adds on this send
54021
54314
  // stay private to me (passed as privateMode in the /api/chat body).
54022
- '<label class="composer-private">' +
54023
- '<input type="checkbox" id="chat-private" /> Private mode ' +
54024
- '<span class="composer-private-hint">New items I add stay private to you</span>' +
54315
+ // Local workspaces are inherently single-user/private, so on local we
54316
+ // force the box checked + disabled as a read-only indicator (cloudMode
54317
+ // is set from the workspace kind before the composer renders).
54318
+ '<label class="composer-private' + (cloudMode ? '' : ' is-disabled') + '">' +
54319
+ '<input type="checkbox" id="chat-private"' + (cloudMode ? '' : ' checked disabled') + ' /> Private mode ' +
54320
+ '<span class="composer-private-hint">' +
54321
+ (cloudMode ? 'New items I add stay private to you' : 'Local workspaces are always private') +
54322
+ '</span>' +
54025
54323
  '</label>' +
54026
54324
  '<input type="file" id="chat-file" multiple style="display:none">';
54027
54325
  var input = document.getElementById('chat-input');
@@ -54080,6 +54378,90 @@ var appJs = `
54080
54378
  })();
54081
54379
  `;
54082
54380
 
54381
+ // src/gui/app/analytics.ts
54382
+ var analyticsJs = `
54383
+ (function () {
54384
+ var MEASUREMENT_ID = 'G-3M1RPJ4ZB3';
54385
+ var DISABLE_FLAG = 'ga-disable-' + MEASUREMENT_ID;
54386
+ var loaded = false;
54387
+ var consent = false;
54388
+
54389
+ window.dataLayer = window.dataLayer || [];
54390
+ function gtag() { window.dataLayer.push(arguments); }
54391
+
54392
+ function doNotTrack() {
54393
+ var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
54394
+ return dnt === '1' || dnt === 'yes' || dnt === true;
54395
+ }
54396
+
54397
+ // Inject gtag.js exactly once, and only when called (i.e. only with consent).
54398
+ function load() {
54399
+ if (loaded) return;
54400
+ loaded = true;
54401
+ var s = document.createElement('script');
54402
+ s.async = true;
54403
+ s.src = 'https://www.googletagmanager.com/gtag/js?id=' + MEASUREMENT_ID;
54404
+ document.head.appendChild(s);
54405
+ gtag('js', new Date());
54406
+ gtag('config', MEASUREMENT_ID, {
54407
+ send_page_view: false,
54408
+ allow_google_signals: false,
54409
+ allow_ad_personalization_signals: false,
54410
+ anonymize_ip: true,
54411
+ });
54412
+ }
54413
+
54414
+ // Allow only a small, safe set of value types. Strings must be short enum-like
54415
+ // tokens; anything free-form (a table name, email, path, query, row content)
54416
+ // is DROPPED, not sent.
54417
+ function sanitize(params) {
54418
+ var out = {};
54419
+ if (!params || typeof params !== 'object') return out;
54420
+ Object.keys(params).forEach(function (k) {
54421
+ var v = params[k];
54422
+ if (typeof v === 'boolean') out[k] = v;
54423
+ else if (typeof v === 'number' && isFinite(v)) out[k] = v;
54424
+ else if (typeof v === 'string' && /^[a-z0-9_.-]{1,40}$/.test(v)) out[k] = v;
54425
+ });
54426
+ return out;
54427
+ }
54428
+
54429
+ window.LatticeGA = {
54430
+ MEASUREMENT_ID: MEASUREMENT_ID,
54431
+ // Called once at boot with the resolved consent. Loads gtag.js only if
54432
+ // consented (and DNT off); otherwise no network contact happens at all.
54433
+ init: function (enabled) {
54434
+ consent = !!enabled;
54435
+ window[DISABLE_FLAG] = !consent;
54436
+ if (consent && !doNotTrack()) load();
54437
+ },
54438
+ // Toggle consent at runtime (the preferences checkbox). Turning it on lazily
54439
+ // loads gtag.js; turning it off sets GA's own kill switch.
54440
+ setConsent: function (enabled) {
54441
+ consent = !!enabled;
54442
+ window[DISABLE_FLAG] = !consent;
54443
+ if (consent && !doNotTrack()) load();
54444
+ },
54445
+ track: function (name, params) {
54446
+ if (!consent || !loaded) return;
54447
+ if (typeof name !== 'string' || !/^[a-z0-9_]{1,40}$/.test(name)) return;
54448
+ gtag('event', name, sanitize(params));
54449
+ },
54450
+ // A synthetic, non-identifying page_view: only the coarse route TYPE, never
54451
+ // the real hash (which embeds table names / row ids / db names).
54452
+ pageView: function (routeType) {
54453
+ if (!consent || !loaded) return;
54454
+ var t =
54455
+ typeof routeType === 'string' && /^[a-z0-9_-]{1,40}$/.test(routeType) ? routeType : 'unknown';
54456
+ gtag('event', 'page_view', {
54457
+ page_location: 'https://app.lattice.local/' + t,
54458
+ page_title: t,
54459
+ });
54460
+ },
54461
+ };
54462
+ })();
54463
+ `;
54464
+
54083
54465
  // src/gui/app.ts
54084
54466
  var guiAppHtml = `<!doctype html>
54085
54467
  <html lang="en">
@@ -54171,13 +54553,13 @@ var guiAppHtml = `<!doctype html>
54171
54553
  </div>
54172
54554
  <div class="drawer-tabs" id="drawer-tabs">
54173
54555
  <button class="drawer-tab" data-tab="database">Workspace</button>
54174
- <button class="drawer-tab" data-tab="chat">Chat</button>
54175
54556
  <button class="drawer-tab" data-tab="lattice">Lattice</button>
54176
54557
  <button class="drawer-tab" data-tab="user">User</button>
54177
54558
  </div>
54178
54559
  <div class="drawer-body" id="drawer-body"></div>
54179
54560
  </aside>
54180
54561
 
54562
+ <script>${analyticsJs}</script>
54181
54563
  <script>${appJs}</script>
54182
54564
  </body>
54183
54565
  </html>`;
@@ -54211,6 +54593,7 @@ function loadPg() {
54211
54593
  }
54212
54594
  var CHANNEL = "lattice_changes";
54213
54595
  var BACKOFF_MS = [1e3, 2e3, 5e3, 1e4];
54596
+ var CATCHUP_LIMIT = 500;
54214
54597
  var RealtimeBroker = class {
54215
54598
  url;
54216
54599
  emitter = new EventEmitter();
@@ -54219,6 +54602,11 @@ var RealtimeBroker = class {
54219
54602
  reconnectTimer = null;
54220
54603
  reconnectAttempt = 0;
54221
54604
  stopped = false;
54605
+ /** Highest change seq delivered so far — the catch-up cursor (#4.4). */
54606
+ lastSeq = 0;
54607
+ /** True once an initial connection has succeeded, so a later open is a RECONNECT
54608
+ * (and should catch up the gap) rather than the first connect (REST seeds it). */
54609
+ hasConnected = false;
54222
54610
  constructor(connectionUrl) {
54223
54611
  if (!isPostgresUrl(connectionUrl)) {
54224
54612
  throw new Error(`RealtimeBroker: connectionUrl must be a postgres:// URL`);
@@ -54248,7 +54636,7 @@ var RealtimeBroker = class {
54248
54636
  client.on("notification", (msg) => {
54249
54637
  if (msg.channel !== CHANNEL) return;
54250
54638
  const payload = parsePayload(msg.payload);
54251
- if (payload) this.emitter.emit("payload", payload);
54639
+ if (payload) this.deliver(payload);
54252
54640
  });
54253
54641
  try {
54254
54642
  await client.connect();
@@ -54256,6 +54644,8 @@ var RealtimeBroker = class {
54256
54644
  this.client = client;
54257
54645
  this.reconnectAttempt = 0;
54258
54646
  this.setState("connected");
54647
+ if (this.hasConnected) await this.catchUp(client);
54648
+ this.hasConnected = true;
54259
54649
  } catch (err) {
54260
54650
  console.warn("[realtime] LISTEN connect failed:", err.message);
54261
54651
  try {
@@ -54265,6 +54655,42 @@ var RealtimeBroker = class {
54265
54655
  this.scheduleReconnect();
54266
54656
  }
54267
54657
  }
54658
+ /** Emit a payload to subscribers and advance the catch-up cursor (#4.4). */
54659
+ deliver(payload) {
54660
+ if (payload.seq > this.lastSeq) this.lastSeq = payload.seq;
54661
+ this.emitter.emit("payload", payload);
54662
+ }
54663
+ /**
54664
+ * Replay the changes missed during a LISTEN gap (#4.4). Reads them through the
54665
+ * SECURITY DEFINER `lattice_changes_since(cursor, limit)`, which returns ONLY
54666
+ * the rows the connecting role may see (same visibility gate as live fan-out)
54667
+ * and is bounded. Each replayed change is delivered like a live one (advancing
54668
+ * the cursor). Best-effort: any error is logged + skipped (never throws into the
54669
+ * connect path). No-op until we've seen at least one change (cursor at 0 means
54670
+ * the REST load already has the current state — nothing to catch up to).
54671
+ */
54672
+ async catchUp(client) {
54673
+ if (this.lastSeq <= 0) return;
54674
+ try {
54675
+ const r6 = await client.query(
54676
+ `SELECT seq, table_name, pk, op, owner_role, created_at FROM lattice_changes_since($1, $2)`,
54677
+ [this.lastSeq, CATCHUP_LIMIT]
54678
+ );
54679
+ for (const row of r6.rows) {
54680
+ const created = row.created_at;
54681
+ this.deliver({
54682
+ seq: Number(row.seq),
54683
+ table_name: typeof row.table_name === "string" ? row.table_name : null,
54684
+ pk: typeof row.pk === "string" ? row.pk : null,
54685
+ op: typeof row.op === "string" ? row.op : "upsert",
54686
+ owner_role: typeof row.owner_role === "string" ? row.owner_role : null,
54687
+ created_at: created instanceof Date ? created.toISOString() : typeof created === "string" ? created : ""
54688
+ });
54689
+ }
54690
+ } catch (e6) {
54691
+ console.warn("[realtime] catch-up replay failed (skipping):", e6.message);
54692
+ }
54693
+ }
54268
54694
  handleClientError(err) {
54269
54695
  console.warn("[realtime] pg client error:", err.message);
54270
54696
  }
@@ -54321,6 +54747,11 @@ var RealtimeBroker = class {
54321
54747
  this.emitter.removeAllListeners();
54322
54748
  }
54323
54749
  };
54750
+ function feedOpForChange(op) {
54751
+ if (op === "upsert" || op === "INSERT" || op === "UPDATE") return "update";
54752
+ if (op === "delete" || op === "DELETE") return "delete";
54753
+ return null;
54754
+ }
54324
54755
  function parsePayload(raw) {
54325
54756
  if (!raw) return null;
54326
54757
  try {
@@ -54328,13 +54759,11 @@ function parsePayload(raw) {
54328
54759
  if (typeof obj2.seq !== "number" || typeof obj2.op !== "string") return null;
54329
54760
  return {
54330
54761
  seq: obj2.seq,
54331
- team_id: typeof obj2.team_id === "string" ? obj2.team_id : "",
54332
54762
  table_name: typeof obj2.table_name === "string" ? obj2.table_name : null,
54333
54763
  pk: typeof obj2.pk === "string" ? obj2.pk : null,
54334
54764
  op: obj2.op,
54335
- owner_user_id: typeof obj2.owner_user_id === "string" ? obj2.owner_user_id : null,
54336
- created_at: typeof obj2.created_at === "string" ? obj2.created_at : "",
54337
- client_ts: typeof obj2.client_ts === "string" ? obj2.client_ts : null
54765
+ owner_role: typeof obj2.owner_role === "string" ? obj2.owner_role : null,
54766
+ created_at: typeof obj2.created_at === "string" ? obj2.created_at : ""
54338
54767
  };
54339
54768
  } catch {
54340
54769
  return null;
@@ -54345,7 +54774,7 @@ function parsePayload(raw) {
54345
54774
  async function cloudRlsInstalled(probe) {
54346
54775
  const row = await getAsyncOrSync(
54347
54776
  probe.adapter,
54348
- `SELECT to_regclass('public.__lattice_owners') AS reg`
54777
+ `SELECT to_regclass('__lattice_owners') AS reg`
54349
54778
  );
54350
54779
  return !!row && row.reg != null;
54351
54780
  }
@@ -54395,6 +54824,25 @@ async function probeCloud(targetUrl) {
54395
54824
  }
54396
54825
  }
54397
54826
  }
54827
+ async function claimMemberInvite(targetUrl) {
54828
+ let conn = null;
54829
+ try {
54830
+ conn = new Lattice(targetUrl);
54831
+ await conn.init({ introspectOnly: true });
54832
+ const row = await getAsyncOrSync(conn.adapter, `SELECT lattice_claim_invite() AS ok`);
54833
+ const ok = row?.ok;
54834
+ return { claimed: ok === true || ok === "t" || ok === 1 };
54835
+ } catch (e6) {
54836
+ return { claimed: false, error: e6.message };
54837
+ } finally {
54838
+ if (conn) {
54839
+ try {
54840
+ conn.close();
54841
+ } catch {
54842
+ }
54843
+ }
54844
+ }
54845
+ }
54398
54846
 
54399
54847
  // src/cloud/discover.ts
54400
54848
  async function discoverCloudTables(db) {
@@ -54429,10 +54877,20 @@ async function discoverCloudTables(db) {
54429
54877
  return out;
54430
54878
  }
54431
54879
 
54432
- // src/cloud/members.ts
54433
- import { randomBytes as randomBytes5 } from "crypto";
54434
-
54435
54880
  // src/cloud/rls.ts
54881
+ async function runCloudBootstrapSql(db, sql) {
54882
+ const adapter = db.adapter;
54883
+ if (adapter.withClient) {
54884
+ await adapter.withClient(async (tx) => {
54885
+ await tx.run("SELECT pg_advisory_xact_lock($1::bigint)", [
54886
+ LATTICE_MIGRATION_LOCK_ID.toString()
54887
+ ]);
54888
+ await tx.run(sql);
54889
+ });
54890
+ } else {
54891
+ await runAsyncOrSync(adapter, sql);
54892
+ }
54893
+ }
54436
54894
  function isPg(db) {
54437
54895
  return db.getDialect() === "postgres";
54438
54896
  }
@@ -54566,12 +55024,42 @@ CREATE TABLE IF NOT EXISTS "__lattice_member_invites" (
54566
55024
  "id" text PRIMARY KEY,
54567
55025
  "role" text NOT NULL,
54568
55026
  "email_hash" text NOT NULL,
55027
+ "email" text,
54569
55028
  "created_by" text NOT NULL DEFAULT session_user,
54570
55029
  "created_at" timestamptz NOT NULL DEFAULT now(),
54571
55030
  "expires_at" timestamptz NOT NULL,
54572
55031
  "redeemed_at" timestamptz,
54573
55032
  "revoked_at" timestamptz
54574
55033
  );
55034
+ -- Plaintext invitee email (owner-only table; members have no grant) so the
55035
+ -- owner's Members list can show who each member is. Added via ALTER so clouds
55036
+ -- created before this column converge to it on the owner's next open (the
55037
+ -- bootstrap is now run directly + idempotently, not version-gated).
55038
+ ALTER TABLE "__lattice_member_invites" ADD COLUMN IF NOT EXISTS "email" text;
55039
+
55040
+ -- #3.1 \u2014 one-time-use + revocation enforcement. After a member authenticates to
55041
+ -- the cloud with their minted credential, the join path calls this to CLAIM the
55042
+ -- invite. The single atomic UPDATE stamps redeemed_at and returns true ONLY when
55043
+ -- an invite for the CALLING role (session_user) is still pending: not already
55044
+ -- redeemed (one-time-use), not revoked, and not expired. A replayed redeem of a
55045
+ -- leaked token, a revoked invite, or an expired one returns false, so the caller
55046
+ -- rejects the join. Members have no direct grant on the owner-only
55047
+ -- __lattice_member_invites table \u2014 this SECURITY DEFINER function is the only
55048
+ -- path, and it can claim ONLY the caller's own invite (keyed on session_user,
55049
+ -- never a caller-supplied parameter, so one member can't burn another's invite).
55050
+ CREATE OR REPLACE FUNCTION lattice_claim_invite()
55051
+ RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER AS $fn$
55052
+ DECLARE v_ok boolean;
55053
+ BEGIN
55054
+ UPDATE "__lattice_member_invites"
55055
+ SET "redeemed_at" = now()
55056
+ WHERE "role" = session_user
55057
+ AND "redeemed_at" IS NULL
55058
+ AND "revoked_at" IS NULL
55059
+ AND "expires_at" > now()
55060
+ RETURNING true INTO v_ok;
55061
+ RETURN COALESCE(v_ok, false);
55062
+ END $fn$;
54575
55063
 
54576
55064
  -- Visibility check. SECURITY DEFINER so it reads bookkeeping the member can't;
54577
55065
  -- keyed on session_user (the member's login role). A row with no ownership record
@@ -54854,6 +55342,62 @@ END $fn$;
54854
55342
  DROP TRIGGER IF EXISTS "lattice_notify_change_trg" ON "__lattice_changes";
54855
55343
  CREATE TRIGGER "lattice_notify_change_trg" AFTER INSERT ON "__lattice_changes"
54856
55344
  FOR EACH ROW EXECUTE FUNCTION lattice_notify_change();
55345
+
55346
+ -- #4.4 \u2014 seq-based catch-up after a realtime gap. NOTIFY is fire-and-forget, so a
55347
+ -- broker that drops its LISTEN (network blip, laptop sleep) misses every change
55348
+ -- during the gap. The broker tracks the highest seq it delivered and, on
55349
+ -- reconnect, replays what it missed via this function. Members have NO direct
55350
+ -- grant on __lattice_changes (reading it raw would leak every change on the
55351
+ -- cloud), so this SECURITY DEFINER function is the only path and it returns ONLY
55352
+ -- the rows the CALLING role can see: keyed on session_user via lattice_row_visible
55353
+ -- (same gate as live fan-out, #4.3). Deletes are excluded \u2014 the ownership record
55354
+ -- is gone post-delete so visibility can't be verified, and replaying them would
55355
+ -- leak deleted-row pks (the client reconciles deletes on its reconnect refetch).
55356
+ -- Bounded (LIMIT clamped \u2264 1000) so a long gap can't stream the whole table (Rule:
55357
+ -- bounded reads on a hot path).
55358
+ CREATE OR REPLACE FUNCTION lattice_changes_since(p_seq bigint, p_limit int)
55359
+ RETURNS TABLE(seq bigint, table_name text, pk text, op text, owner_role text, created_at timestamptz)
55360
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
55361
+ SELECT c."seq", c."table_name", c."pk", c."op", c."owner_role", c."created_at"
55362
+ FROM "__lattice_changes" c
55363
+ WHERE c."seq" > p_seq
55364
+ AND c."op" = 'upsert'
55365
+ AND lattice_row_visible(c."table_name", c."pk")
55366
+ ORDER BY c."seq" ASC
55367
+ LIMIT GREATEST(0, LEAST(COALESCE(p_limit, 500), 1000));
55368
+ $fn$;
55369
+
55370
+ -- #2.1 \u2014 per-row access summary for the connecting role. The GUI attaches this as
55371
+ -- each row's _access so the sharing affordance renders, but __lattice_owners is
55372
+ -- owner-only bookkeeping (members have no grant), so a member reading it directly
55373
+ -- got "permission denied". This SECURITY DEFINER function returns visibility +
55374
+ -- whether the CALLER owns the row, ONLY for the rows the caller can actually see
55375
+ -- (lattice_row_visible, keyed on session_user) \u2014 so a member learns nothing about
55376
+ -- rows hidden from it. Member-callable; the owner gets the same view of its rows.
55377
+ CREATE OR REPLACE FUNCTION lattice_rows_access(p_table text, p_pks text[])
55378
+ RETURNS TABLE(pk text, visibility text, owned boolean)
55379
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
55380
+ SELECT o."pk", o."visibility", (o."owner_role" = session_user) AS owned
55381
+ FROM "__lattice_owners" o
55382
+ WHERE o."table_name" = p_table
55383
+ AND o."pk" = ANY(p_pks)
55384
+ AND lattice_row_visible(o."table_name", o."pk");
55385
+ $fn$;
55386
+
55387
+ -- #2.1 \u2014 grantees of a CALLER-OWNED custom-shared row (who you shared YOUR row
55388
+ -- with). Only the row owner sees this (the WHERE pins owner_role = session_user),
55389
+ -- so a member can't enumerate another owner's grants. __lattice_row_grants is
55390
+ -- member-ungranted, so this SECURITY DEFINER function is the member-safe path.
55391
+ CREATE OR REPLACE FUNCTION lattice_row_grantees(p_table text, p_pks text[])
55392
+ RETURNS TABLE(pk text, grantee_role text)
55393
+ LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
55394
+ SELECT g."pk", g."grantee_role"
55395
+ FROM "__lattice_row_grants" g
55396
+ JOIN "__lattice_owners" o ON o."table_name" = g."table_name" AND o."pk" = g."pk"
55397
+ WHERE g."table_name" = p_table
55398
+ AND g."pk" = ANY(p_pks)
55399
+ AND o."owner_role" = session_user;
55400
+ $fn$;
54857
55401
  `;
54858
55402
  function tableRlsSql(table, pkCols) {
54859
55403
  const q3 = `"${table.replace(/"/g, '""')}"`;
@@ -54921,28 +55465,14 @@ CREATE TRIGGER "${trg}" AFTER INSERT OR UPDATE OR DELETE ON ${q3}
54921
55465
  async function installCloudRls(db) {
54922
55466
  if (!isPg(db)) return;
54923
55467
  const schema = await cloudSchema(db);
54924
- const migration = {
54925
- // v3 added the audience helpers; v4 the role model; v5 the per-card override
54926
- // model (__lattice_cell_grants + lattice_cell_visible / lattice_grant_cell);
54927
- // v6 added per-table policy (__lattice_table_policy: default_row_visibility +
54928
- // never_share, enforced in the insert trigger + share/grant guards), the
54929
- // canonical column-audience store (__lattice_column_policy), lattice_is_owner,
54930
- // and the owner-only setters; v7 pins search_path on every SECURITY DEFINER
54931
- // helper (closes the pg_temp-shadow RLS bypass) + revokes schema CREATE from
54932
- // PUBLIC. The bootstrap is fully idempotent.
54933
- version: "internal:cloud-rls:bootstrap:v7",
54934
- sql: pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema)
54935
- };
54936
- await db.migrate([migration]);
55468
+ const sql = pinDefinerSearchPath(CLOUD_RLS_BOOTSTRAP_SQL, schema) + revokeSchemaCreateSql(schema);
55469
+ await runCloudBootstrapSql(db, sql);
54937
55470
  }
54938
55471
  async function enableChangelogRls(db) {
54939
55472
  if (!isPg(db)) return;
54940
- const migration = {
54941
- // v2: ground-truth/audit entries are owner-only (was lattice_row_visible),
54942
- // closing the masked-column-via-history leak. Bump re-installs the policy on
54943
- // existing clouds.
54944
- version: "internal:cloud-rls:changelog:v2",
54945
- sql: `
55473
+ await runCloudBootstrapSql(
55474
+ db,
55475
+ `
54946
55476
  ALTER TABLE "__lattice_changelog" ENABLE ROW LEVEL SECURITY;
54947
55477
  ALTER TABLE "__lattice_changelog" FORCE ROW LEVEL SECURITY;
54948
55478
  GRANT SELECT, INSERT ON "__lattice_changelog" TO ${MEMBER_GROUP};
@@ -54952,6 +55482,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
54952
55482
  CASE
54953
55483
  WHEN "change_kind" = 'derived' THEN
54954
55484
  "source_ref" IS NOT NULL
55485
+ AND jsonb_array_length("source_ref"::jsonb) > 0
54955
55486
  AND NOT EXISTS (
54956
55487
  SELECT 1 FROM jsonb_array_elements_text("source_ref"::jsonb) AS src(sid)
54957
55488
  WHERE NOT lattice_source_visible(src.sid)
@@ -54962,8 +55493,7 @@ CREATE POLICY "lattice_changelog_sel" ON "__lattice_changelog" FOR SELECT USING
54962
55493
  DROP POLICY IF EXISTS "lattice_changelog_ins" ON "__lattice_changelog";
54963
55494
  CREATE POLICY "lattice_changelog_ins" ON "__lattice_changelog" FOR INSERT WITH CHECK (true);
54964
55495
  `
54965
- };
54966
- await db.migrate([migration]);
55496
+ );
54967
55497
  }
54968
55498
  async function enableRlsForTable(db, table, pkCols) {
54969
55499
  if (!isPg(db)) return;
@@ -54987,7 +55517,87 @@ async function backfillOwnership(db, table, pkCols) {
54987
55517
  );
54988
55518
  }
54989
55519
 
55520
+ // src/cloud/settings.ts
55521
+ import { createHash as createHash2, randomBytes as randomBytes5 } from "crypto";
55522
+ var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
55523
+ var CLOUD_SETTING_INVITE_SALT = "invite_email_salt";
55524
+ async function getOrCreateInviteSalt(db) {
55525
+ const existing = await getCloudSettingStrict(db, CLOUD_SETTING_INVITE_SALT);
55526
+ if (existing) return existing;
55527
+ const salt = randomBytes5(16).toString("hex");
55528
+ await setCloudSetting(db, CLOUD_SETTING_INVITE_SALT, salt);
55529
+ return salt;
55530
+ }
55531
+ function hashInviteEmail(salt, email) {
55532
+ return createHash2("sha256").update(`${salt}
55533
+ ${email.trim().toLowerCase()}`).digest("hex");
55534
+ }
55535
+ var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
55536
+ -- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
55537
+ -- so a member's SELECT is denied (the VALUE is unreadable \u2014 the catalog may still
55538
+ -- reveal the table exists, like every other __lattice_* table); the SECURITY
55539
+ -- DEFINER getter below is the only member-reachable read path.
55540
+ CREATE TABLE IF NOT EXISTS "__lattice_cloud_settings" (
55541
+ "key" text PRIMARY KEY,
55542
+ "value" text,
55543
+ "updated_by" text NOT NULL DEFAULT session_user,
55544
+ "updated_at" timestamptz NOT NULL DEFAULT now()
55545
+ );
55546
+
55547
+ -- Read a setting. SECURITY DEFINER so a scoped member (no direct grant on the
55548
+ -- table) can still read it to inject into their own chat. App-mediated ceiling:
55549
+ -- this returns the value to whoever calls it, so the secrecy is at the product
55550
+ -- surface (UI + API), not against a member's own SQL session.
55551
+ CREATE OR REPLACE FUNCTION lattice_get_cloud_setting(p_key text)
55552
+ RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
55553
+ SELECT "value" FROM "__lattice_cloud_settings" WHERE "key" = p_key LIMIT 1
55554
+ $fn$;
55555
+
55556
+ -- Owner-only write. Raises unless the caller can create roles (a cloud owner /
55557
+ -- DBA) \u2014 members get no write path even though the function is callable.
55558
+ CREATE OR REPLACE FUNCTION lattice_set_cloud_setting(p_key text, p_value text)
55559
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
55560
+ BEGIN
55561
+ IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
55562
+ RAISE EXCEPTION 'lattice: only a cloud owner may change workspace settings';
55563
+ END IF;
55564
+ INSERT INTO "__lattice_cloud_settings" ("key", "value", "updated_by", "updated_at")
55565
+ VALUES (p_key, p_value, session_user, now())
55566
+ ON CONFLICT ("key") DO UPDATE
55567
+ SET "value" = EXCLUDED."value", "updated_by" = session_user, "updated_at" = now();
55568
+ END $fn$;
55569
+ `;
55570
+ async function installCloudSettings(db) {
55571
+ if (db.getDialect() !== "postgres") return;
55572
+ const schema = await cloudSchema(db);
55573
+ await runCloudBootstrapSql(db, pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema));
55574
+ }
55575
+ async function getCloudSetting(db, key) {
55576
+ if (db.getDialect() !== "postgres") return null;
55577
+ try {
55578
+ const row = await getAsyncOrSync(db.adapter, `SELECT lattice_get_cloud_setting(?) AS value`, [
55579
+ key
55580
+ ]);
55581
+ const v2 = row?.value;
55582
+ return typeof v2 === "string" && v2.length > 0 ? v2 : null;
55583
+ } catch {
55584
+ return null;
55585
+ }
55586
+ }
55587
+ async function getCloudSettingStrict(db, key) {
55588
+ if (db.getDialect() !== "postgres") return null;
55589
+ const row = await getAsyncOrSync(db.adapter, `SELECT lattice_get_cloud_setting(?) AS value`, [
55590
+ key
55591
+ ]);
55592
+ const v2 = row?.value;
55593
+ return typeof v2 === "string" && v2.length > 0 ? v2 : null;
55594
+ }
55595
+ async function setCloudSetting(db, key, value) {
55596
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_cloud_setting(?, ?)`, [key, value]);
55597
+ }
55598
+
54990
55599
  // src/cloud/members.ts
55600
+ import { randomBytes as randomBytes6 } from "crypto";
54991
55601
  var ROLE_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
54992
55602
  var HEX_PW_RE = /^[0-9a-f]{16,}$/;
54993
55603
  function assertPg(db) {
@@ -54998,11 +55608,11 @@ function assertPg(db) {
54998
55608
  }
54999
55609
  }
55000
55610
  function generateMemberPassword() {
55001
- return randomBytes5(24).toString("hex");
55611
+ return randomBytes6(24).toString("hex");
55002
55612
  }
55003
55613
  function memberRoleName(label) {
55004
55614
  const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "member";
55005
- return `lm_${base}_${randomBytes5(3).toString("hex")}`.slice(0, 63);
55615
+ return `lm_${base}_${randomBytes6(3).toString("hex")}`.slice(0, 63);
55006
55616
  }
55007
55617
  async function provisionMemberRole(db, role, password) {
55008
55618
  assertPg(db);
@@ -55016,7 +55626,12 @@ async function provisionMemberRole(db, role, password) {
55016
55626
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
55017
55627
  CREATE ROLE "${role}" LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
55018
55628
  ELSE
55019
- ALTER ROLE "${role}" WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE PASSWORD '${password}';
55629
+ -- Re-invite of an EXISTING role: set ONLY what changed (login + password).
55630
+ -- Restating NOSUPERUSER/superuser-class attrs trips Supabase supautils
55631
+ -- ("only superuser may alter the SUPERUSER attribute", 42501) since the
55632
+ -- owner 'postgres' isn't a true superuser. The role was already created
55633
+ -- NOSUPERUSER NOCREATEDB NOCREATEROLE, so there is nothing to restate.
55634
+ ALTER ROLE "${role}" WITH LOGIN PASSWORD '${password}';
55020
55635
  END IF;
55021
55636
  END $LATTICE$`
55022
55637
  );
@@ -55038,13 +55653,10 @@ async function rowAccessSummaries(db, table, pks) {
55038
55653
  const out = /* @__PURE__ */ new Map();
55039
55654
  if (db.getDialect() !== "postgres" || pks.length === 0) return out;
55040
55655
  if (!await cloudRlsInstalled(db)) return out;
55041
- const placeholders = pks.map(() => "?").join(", ");
55042
55656
  const owners = await allAsyncOrSync(
55043
55657
  db.adapter,
55044
- `SELECT "pk", "visibility", ("owner_role" = session_user) AS owned
55045
- FROM "__lattice_owners"
55046
- WHERE "table_name" = ? AND "pk" IN (${placeholders})`,
55047
- [table, ...pks]
55658
+ `SELECT "pk", "visibility", "owned" FROM lattice_rows_access(?, ?)`,
55659
+ [table, [...pks]]
55048
55660
  );
55049
55661
  for (const o3 of owners) {
55050
55662
  out.set(o3.pk, {
@@ -55054,12 +55666,10 @@ async function rowAccessSummaries(db, table, pks) {
55054
55666
  }
55055
55667
  const customPks = owners.filter((o3) => o3.visibility === "custom").map((o3) => o3.pk);
55056
55668
  if (customPks.length > 0) {
55057
- const cph = customPks.map(() => "?").join(", ");
55058
55669
  const grants = await allAsyncOrSync(
55059
55670
  db.adapter,
55060
- `SELECT "pk", "grantee_role" FROM "__lattice_row_grants"
55061
- WHERE "table_name" = ? AND "pk" IN (${cph})`,
55062
- [table, ...customPks]
55671
+ `SELECT "pk", "grantee_role" FROM lattice_row_grantees(?, ?)`,
55672
+ [table, customPks]
55063
55673
  );
55064
55674
  for (const g6 of grants) {
55065
55675
  const a6 = out.get(g6.pk);
@@ -55103,6 +55713,31 @@ async function revokeCell(db, table, pk, column, grantee) {
55103
55713
  grantee
55104
55714
  ]);
55105
55715
  }
55716
+ async function revokeMemberRole(db, role) {
55717
+ assertPg(db);
55718
+ if (!ROLE_RE.test(role)) throw new Error(`lattice: invalid member role name "${role}"`);
55719
+ const exists = await getAsyncOrSync(
55720
+ db.adapter,
55721
+ `SELECT 1 AS x FROM pg_roles WHERE rolname = ?`,
55722
+ [role]
55723
+ );
55724
+ if (!exists) return;
55725
+ for (const stmt of [`REASSIGN OWNED BY "${role}" TO CURRENT_USER`, `DROP OWNED BY "${role}"`]) {
55726
+ try {
55727
+ await runAsyncOrSync(db.adapter, stmt);
55728
+ } catch (e6) {
55729
+ if (!isInsufficientPrivilege(e6)) throw e6;
55730
+ console.warn(
55731
+ `[cloud] "${stmt.split(" ").slice(0, 2).join(" ")} \u2026" skipped (insufficient privilege; a scoped member owns no objects): ${e6.message}`
55732
+ );
55733
+ }
55734
+ }
55735
+ await runAsyncOrSync(db.adapter, `DROP ROLE IF EXISTS "${role}"`);
55736
+ }
55737
+ function isInsufficientPrivilege(e6) {
55738
+ const err = e6 ?? {};
55739
+ return err.code === "42501" || /permission denied/i.test(err.message ?? "");
55740
+ }
55106
55741
 
55107
55742
  // src/gui/feed.ts
55108
55743
  import { EventEmitter as EventEmitter2 } from "events";
@@ -55155,6 +55790,9 @@ var FeedBus = class {
55155
55790
  }
55156
55791
  };
55157
55792
 
55793
+ // src/gui/mutations.ts
55794
+ import { createHash as createHash3 } from "crypto";
55795
+
55158
55796
  // src/cloud/audience.ts
55159
55797
  var ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
55160
55798
  var COL_RE = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
@@ -55341,7 +55979,7 @@ function sessionUndoneFilters(undone, sessionId) {
55341
55979
  if (sessionId) filters.push({ col: "session_id", op: "eq", val: sessionId });
55342
55980
  return filters;
55343
55981
  }
55344
- async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId) {
55982
+ async function appendAudit(db, feed, table, rowId, op, before, after, source = "gui", sessionId, editTs) {
55345
55983
  const undone = await db.query("_lattice_gui_audit", {
55346
55984
  filters: sessionUndoneFilters(1, sessionId)
55347
55985
  });
@@ -55350,9 +55988,10 @@ async function appendAudit(db, feed, table, rowId, op, before, after, source = "
55350
55988
  id: crypto.randomUUID(),
55351
55989
  // Set ts explicitly (don't rely on the column DEFAULT — it uses the
55352
55990
  // SQLite-only `strftime(...)`, which doesn't yield a parseable ISO string
55353
- // on Postgres, so cloud history rendered "Invalid Date"). Mirrors the
55354
- // explicit `client_ts` below; adapter-agnostic.
55355
- ts: (/* @__PURE__ */ new Date()).toISOString(),
55991
+ // on Postgres, so cloud history rendered "Invalid Date"). #4.6 — honor the
55992
+ // originating client's validated edit time when present (an offline edit
55993
+ // replayed later records when it was MADE, not when it synced), else now().
55994
+ ts: sanitizeEditTs(editTs) ?? (/* @__PURE__ */ new Date()).toISOString(),
55356
55995
  table_name: table,
55357
55996
  row_id: rowId,
55358
55997
  operation: op,
@@ -55394,6 +56033,16 @@ async function recordSchemaAudit(db, feed, table, operation2, before, after, sum
55394
56033
  });
55395
56034
  feed.publish({ table, op: "schema", rowId: null, source, summary });
55396
56035
  }
56036
+ function sanitizeEditTs(raw) {
56037
+ if (!raw) return null;
56038
+ const t8 = Date.parse(raw);
56039
+ if (Number.isNaN(t8)) return null;
56040
+ if (t8 > Date.now() + 5 * 60 * 1e3) return null;
56041
+ return new Date(t8).toISOString();
56042
+ }
56043
+ function writeConflict(message) {
56044
+ return Object.assign(new Error(message), { code: "row_write_conflict" });
56045
+ }
55397
56046
  function inferColumnType(v2) {
55398
56047
  if (typeof v2 === "number") return Number.isInteger(v2) ? "INTEGER" : "REAL";
55399
56048
  if (typeof v2 === "boolean") return "INTEGER";
@@ -55428,13 +56077,38 @@ async function announceAddedColumns(ctx, table, added) {
55428
56077
  ctx.sessionId
55429
56078
  );
55430
56079
  }
55431
- async function createRow(ctx, table, values, forceVisibility) {
55432
- const addedCols = await ensureColumns(ctx.db, table, values);
56080
+ function deriveRowIdFromEditId(editId) {
56081
+ return createHash3("sha256").update(editId).digest("hex").slice(0, 32);
56082
+ }
56083
+ async function createRow(ctx, table, values, forceVisibility, editId) {
56084
+ const pk = ctx.db.getPrimaryKey(table);
56085
+ const isDefaultPk = pk.length === 1 && pk[0] === "id";
56086
+ let toInsert = values;
56087
+ if (editId && isDefaultPk) {
56088
+ const provided = values.id;
56089
+ const hasId = typeof provided === "string" || typeof provided === "number";
56090
+ const targetId = hasId ? String(provided) : deriveRowIdFromEditId(editId);
56091
+ if (!hasId) toInsert = { ...values, id: targetId };
56092
+ const existing = await ctx.db.get(table, targetId);
56093
+ if (existing !== null) return { id: targetId, row: existing, idempotent: true };
56094
+ }
56095
+ const addedCols = await ensureColumns(ctx.db, table, toInsert);
55433
56096
  await announceAddedColumns(ctx, table, addedCols);
55434
- const id = forceVisibility !== void 0 ? await ctx.db.insertForcingVisibility(table, values, forceVisibility) : await ctx.db.insert(table, values);
56097
+ const id = forceVisibility !== void 0 ? await ctx.db.insertForcingVisibility(table, toInsert, forceVisibility) : await ctx.db.insert(table, toInsert);
55435
56098
  const row = await ctx.db.get(table, id);
55436
- await appendAudit(ctx.db, ctx.feed, table, id, "insert", null, row, ctx.source, ctx.sessionId);
55437
- return { id, row };
56099
+ await appendAudit(
56100
+ ctx.db,
56101
+ ctx.feed,
56102
+ table,
56103
+ id,
56104
+ "insert",
56105
+ null,
56106
+ row,
56107
+ ctx.source,
56108
+ ctx.sessionId,
56109
+ ctx.clientTs
56110
+ );
56111
+ return { id, row, idempotent: false };
55438
56112
  }
55439
56113
  function storedValueMatches(stored, requested) {
55440
56114
  if (stored === requested) return true;
@@ -55453,7 +56127,7 @@ function rowsEqual(a6, b6) {
55453
56127
  async function updateRow(ctx, table, id, values) {
55454
56128
  const before = await ctx.db.get(table, id);
55455
56129
  if (before === null) {
55456
- throw new Error(`Cannot update "${table}": no row with id "${id}"`);
56130
+ throw writeConflict(`Cannot update "${table}": no row with id "${id}"`);
55457
56131
  }
55458
56132
  const addedCols = await ensureColumns(ctx.db, table, values);
55459
56133
  await announceAddedColumns(ctx, table, addedCols);
@@ -55464,7 +56138,7 @@ async function updateRow(ctx, table, id, values) {
55464
56138
  (k6) => !storedValueMatches(before[k6], values[k6])
55465
56139
  );
55466
56140
  if (wantedChange && rowsEqual(before, after)) {
55467
- throw new Error("Row update did not persist \u2014 the data source may be read-only");
56141
+ throw writeConflict("Row update did not persist \u2014 the data source may be read-only");
55468
56142
  }
55469
56143
  }
55470
56144
  await appendAudit(
@@ -55476,14 +56150,15 @@ async function updateRow(ctx, table, id, values) {
55476
56150
  before,
55477
56151
  after,
55478
56152
  ctx.source,
55479
- ctx.sessionId
56153
+ ctx.sessionId,
56154
+ ctx.clientTs
55480
56155
  );
55481
56156
  return { row: after };
55482
56157
  }
55483
56158
  async function deleteRow(ctx, table, id, hard) {
55484
56159
  const before = await ctx.db.get(table, id);
55485
56160
  if (before === null) {
55486
- throw new Error(`Cannot delete from "${table}": no row with id "${id}"`);
56161
+ throw writeConflict(`Cannot delete from "${table}": no row with id "${id}"`);
55487
56162
  }
55488
56163
  if (!hard && ctx.softDeletable.has(table)) {
55489
56164
  await ctx.db.update(table, id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
@@ -55497,7 +56172,8 @@ async function deleteRow(ctx, table, id, hard) {
55497
56172
  before,
55498
56173
  after,
55499
56174
  ctx.source,
55500
- ctx.sessionId
56175
+ ctx.sessionId,
56176
+ ctx.clientTs
55501
56177
  );
55502
56178
  } else {
55503
56179
  await ctx.db.delete(table, id);
@@ -55510,7 +56186,8 @@ async function deleteRow(ctx, table, id, hard) {
55510
56186
  before,
55511
56187
  null,
55512
56188
  ctx.source,
55513
- ctx.sessionId
56189
+ ctx.sessionId,
56190
+ ctx.clientTs
55514
56191
  );
55515
56192
  }
55516
56193
  }
@@ -55669,7 +56346,49 @@ function saveConfigDoc(configPath, doc) {
55669
56346
  writeFileSync6(configPath, doc.toString(), "utf8");
55670
56347
  }
55671
56348
 
56349
+ // src/cloud/setup.ts
56350
+ async function secureNewCloudTable(db, table, pk) {
56351
+ if (db.getDialect() !== "postgres") return;
56352
+ if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) return;
56353
+ if (pk.length === 0) return;
56354
+ await backfillOwnership(db, table, pk);
56355
+ await enableRlsForTable(db, table, pk);
56356
+ const cols = db.getRegisteredColumns(table);
56357
+ if (cols) {
56358
+ await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
56359
+ await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
56360
+ }
56361
+ }
56362
+ async function secureCloud(db) {
56363
+ if (db.getDialect() !== "postgres") return;
56364
+ await installCloudRls(db);
56365
+ await installCloudSettings(db);
56366
+ await db.ensureObservationSubstrate();
56367
+ await enableChangelogRls(db);
56368
+ const registered = db.getRegisteredTableNames();
56369
+ for (const table of registered) {
56370
+ await secureNewCloudTable(db, table, db.getPrimaryKey(table));
56371
+ }
56372
+ if (registered.includes("secrets")) {
56373
+ await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
56374
+ }
56375
+ await runAsyncOrSync(
56376
+ db.adapter,
56377
+ `DO $LATTICE$ BEGIN
56378
+ IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
56379
+ EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
56380
+ END IF;
56381
+ END $LATTICE$`
56382
+ );
56383
+ }
56384
+
55672
56385
  // src/gui/schema-ops.ts
56386
+ async function secureRuntimeTableIfCloud(active, name, pk) {
56387
+ const db = active.db;
56388
+ if (db.getDialect() !== "postgres") return;
56389
+ if (!(await cloudRlsInstalled(db) && await canManageRoles(db))) return;
56390
+ await secureNewCloudTable(db, name, pk);
56391
+ }
55673
56392
  async function listPhysicalUserTables(active) {
55674
56393
  const adapter = active.db._adapter;
55675
56394
  if (!adapter.allAsync) return active.db.getRegisteredTableNames();
@@ -55739,6 +56458,7 @@ async function materializeJunction(active, jName, colA, refA, colB, refB, summar
55739
56458
  active.validTables.add(jName);
55740
56459
  active.junctionTables.add(jName);
55741
56460
  syncCanonicalContexts(active);
56461
+ await secureRuntimeTableIfCloud(active, jName, ["id"]);
55742
56462
  await recordSchemaOp(
55743
56463
  active,
55744
56464
  "schema.create_junction",
@@ -55831,6 +56551,7 @@ async function createUserEntity(active, name, columns, sessionId, opts) {
55831
56551
  active.validTables.add(entity);
55832
56552
  active.softDeletable.add(entity);
55833
56553
  syncCanonicalContexts(active);
56554
+ await secureRuntimeTableIfCloud(active, entity, ["id"]);
55834
56555
  await recordSchemaOp(
55835
56556
  active,
55836
56557
  "schema.create_entity",
@@ -55964,8 +56685,280 @@ async function aiDeleteEntity(active, name, resolution, sessionId) {
55964
56685
  }
55965
56686
 
55966
56687
  // src/gui/userconfig-routes.ts
55967
- import { existsSync as existsSync15, readdirSync as readdirSync5 } from "fs";
55968
- import { basename as basename4, dirname as dirname7, join as join14 } from "path";
56688
+ import { existsSync as existsSync16, readdirSync as readdirSync5 } from "fs";
56689
+ import { basename as basename4, dirname as dirname8, join as join21 } from "path";
56690
+
56691
+ // src/gui/files-routes.ts
56692
+ import { createReadStream, statSync as statSync5 } from "fs";
56693
+ import { isAbsolute as isAbsolute2, join as join20 } from "path";
56694
+ import { spawn } from "child_process";
56695
+
56696
+ // src/framework/s3-store.ts
56697
+ var S3UnavailableError = class extends Error {
56698
+ constructor(message) {
56699
+ super(message);
56700
+ this.name = "S3UnavailableError";
56701
+ }
56702
+ };
56703
+ function s3Key(prefix, sha256) {
56704
+ const p3 = prefix.replace(/^\/+|\/+$/g, "");
56705
+ return p3 ? `${p3}/${sha256}` : sha256;
56706
+ }
56707
+ async function createS3Store(cfg) {
56708
+ let mod;
56709
+ try {
56710
+ mod = await Promise.resolve().then(() => (init_dist_es22(), dist_es_exports8));
56711
+ } catch {
56712
+ throw new S3UnavailableError(
56713
+ 'S3 file storage requires the optional "@aws-sdk/client-s3" dependency, which is not installed'
56714
+ );
56715
+ }
56716
+ const { S3Client: S3Client2, PutObjectCommand: PutObjectCommand2, GetObjectCommand: GetObjectCommand2, HeadObjectCommand: HeadObjectCommand2 } = mod;
56717
+ const client = new S3Client2({
56718
+ region: cfg.region,
56719
+ // forcePathStyle is required for S3-compatible endpoints (R2 / MinIO /
56720
+ // LocalStack) which don't support virtual-hosted-style bucket subdomains.
56721
+ ...cfg.endpoint ? { endpoint: cfg.endpoint, forcePathStyle: true } : {},
56722
+ ...cfg.credentials ? { credentials: cfg.credentials } : {}
56723
+ });
56724
+ return {
56725
+ async put(key, body, opts) {
56726
+ await client.send(
56727
+ new PutObjectCommand2({
56728
+ Bucket: cfg.bucket,
56729
+ Key: key,
56730
+ Body: body,
56731
+ ...opts?.contentType ? { ContentType: opts.contentType } : {}
56732
+ })
56733
+ );
56734
+ },
56735
+ async get(key) {
56736
+ const out = await client.send(new GetObjectCommand2({ Bucket: cfg.bucket, Key: key }));
56737
+ const body = out.Body;
56738
+ if (!body) throw new Error(`S3: object "${key}" has no body`);
56739
+ return body;
56740
+ },
56741
+ async exists(key) {
56742
+ try {
56743
+ await client.send(new HeadObjectCommand2({ Bucket: cfg.bucket, Key: key }));
56744
+ return true;
56745
+ } catch (e6) {
56746
+ const err = e6;
56747
+ if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) return false;
56748
+ throw e6;
56749
+ }
56750
+ }
56751
+ };
56752
+ }
56753
+
56754
+ // src/framework/s3-config.ts
56755
+ import { readFileSync as readFileSync13, existsSync as existsSync15 } from "fs";
56756
+ var DEFAULT_PREFIX = "blobs";
56757
+ function activeWorkspaceLabel(configPath) {
56758
+ if (!existsSync15(configPath)) return null;
56759
+ try {
56760
+ const text = readFileSync13(configPath, "utf8");
56761
+ const m4 = /^\s*db:\s*\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}\s*$/m.exec(text);
56762
+ return m4 ? m4[1] ?? null : null;
56763
+ } catch {
56764
+ return null;
56765
+ }
56766
+ }
56767
+ function mergeS3ConfigForSave(prev, body) {
56768
+ const str2 = (v2) => typeof v2 === "string" ? v2 : void 0;
56769
+ const pick = (b6, p3, trim) => {
56770
+ const bv = trim ? str2(b6)?.trim() : str2(b6);
56771
+ if (bv) return bv;
56772
+ const pv = str2(p3);
56773
+ if (pv) return pv;
56774
+ return void 0;
56775
+ };
56776
+ const prefix = pick(body.prefix, prev.prefix, true);
56777
+ const endpoint = pick(body.endpoint, prev.endpoint, true);
56778
+ const accessKeyId = pick(body.accessKeyId, prev.accessKeyId, false);
56779
+ const secretAccessKey = pick(body.secretAccessKey, prev.secretAccessKey, false);
56780
+ return {
56781
+ enabled: body.enabled === true,
56782
+ bucket: str2(body.bucket)?.trim() ?? "",
56783
+ region: str2(body.region)?.trim() ?? "",
56784
+ ...prefix ? { prefix } : {},
56785
+ ...endpoint ? { endpoint } : {},
56786
+ ...accessKeyId ? { accessKeyId } : {},
56787
+ ...secretAccessKey ? { secretAccessKey } : {}
56788
+ };
56789
+ }
56790
+ function coerce(raw) {
56791
+ if (!raw) return null;
56792
+ const enabled = raw.enabled === true;
56793
+ const bucket = typeof raw.bucket === "string" ? raw.bucket : "";
56794
+ const region = typeof raw.region === "string" ? raw.region : "";
56795
+ if (!enabled || !bucket || !region) return null;
56796
+ const prefix = typeof raw.prefix === "string" && raw.prefix ? raw.prefix : DEFAULT_PREFIX;
56797
+ const endpoint = typeof raw.endpoint === "string" && raw.endpoint ? raw.endpoint : void 0;
56798
+ const accessKeyId = typeof raw.accessKeyId === "string" ? raw.accessKeyId : "";
56799
+ const secretAccessKey = typeof raw.secretAccessKey === "string" ? raw.secretAccessKey : "";
56800
+ if (accessKeyId && !secretAccessKey || !accessKeyId && secretAccessKey) {
56801
+ console.warn(
56802
+ "[s3-config] only one of accessKeyId/secretAccessKey is set; ignoring the partial credential and using the default AWS credential chain. Supply both, or neither."
56803
+ );
56804
+ }
56805
+ return {
56806
+ enabled: true,
56807
+ bucket,
56808
+ region,
56809
+ prefix,
56810
+ ...endpoint ? { endpoint } : {},
56811
+ ...accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}
56812
+ };
56813
+ }
56814
+ function fromEnv3() {
56815
+ const bucket = process.env.LATTICE_S3_BUCKET;
56816
+ const region = process.env.LATTICE_S3_REGION ?? process.env.AWS_REGION;
56817
+ if (!bucket || !region) return null;
56818
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
56819
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
56820
+ return {
56821
+ enabled: true,
56822
+ bucket,
56823
+ region,
56824
+ prefix: process.env.LATTICE_S3_PREFIX ?? DEFAULT_PREFIX,
56825
+ ...process.env.LATTICE_S3_ENDPOINT ? { endpoint: process.env.LATTICE_S3_ENDPOINT } : {},
56826
+ ...accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}
56827
+ };
56828
+ }
56829
+ function resolveActiveS3Config(configPath) {
56830
+ if (configPath) {
56831
+ const label = activeWorkspaceLabel(configPath);
56832
+ if (label) {
56833
+ const stored = coerce(getS3ConfigRaw(label));
56834
+ if (stored) return stored;
56835
+ }
56836
+ }
56837
+ return fromEnv3();
56838
+ }
56839
+
56840
+ // src/gui/files-routes.ts
56841
+ function localFileOpenEnabled() {
56842
+ return process.env.LATTICE_LOCAL_OPEN !== "0";
56843
+ }
56844
+ function localPathOf(row, latticeRoot) {
56845
+ if (typeof row.path === "string" && row.path) return row.path;
56846
+ if (row.ref_kind === "local_ref" && typeof row.ref_uri === "string" && row.ref_uri) {
56847
+ return row.ref_uri;
56848
+ }
56849
+ if ((row.ref_kind === "blob" || row.ref_kind === "cloud_ref") && typeof row.blob_path === "string" && row.blob_path) {
56850
+ return isAbsolute2(row.blob_path) ? row.blob_path : latticeRoot ? join20(latticeRoot, row.blob_path) : null;
56851
+ }
56852
+ return null;
56853
+ }
56854
+ function s3RefOf(row) {
56855
+ if (row.ref_kind !== "cloud_ref" || row.ref_provider !== "s3") return null;
56856
+ if (typeof row.source_json === "string" && row.source_json) {
56857
+ try {
56858
+ const j6 = JSON.parse(row.source_json);
56859
+ if (typeof j6.bucket === "string" && typeof j6.key === "string") {
56860
+ return { bucket: j6.bucket, key: j6.key };
56861
+ }
56862
+ } catch {
56863
+ }
56864
+ }
56865
+ if (typeof row.ref_uri === "string") {
56866
+ const m4 = /^s3:\/\/([^/]+)\/(.+)$/.exec(row.ref_uri);
56867
+ if (m4) return { bucket: m4[1] ?? "", key: m4[2] ?? "" };
56868
+ }
56869
+ return null;
56870
+ }
56871
+ function localFileExists(loc) {
56872
+ if (!loc) return false;
56873
+ try {
56874
+ return statSync5(loc).isFile();
56875
+ } catch {
56876
+ return false;
56877
+ }
56878
+ }
56879
+ function sanitizeFilename(name) {
56880
+ return name.replace(/[\r\n"\\]/g, "_");
56881
+ }
56882
+ function blobResponseHeaders(contentType, name) {
56883
+ return {
56884
+ "content-type": contentType,
56885
+ "content-disposition": `inline; filename="${name}"`,
56886
+ "cache-control": "no-store",
56887
+ "x-content-type-options": "nosniff",
56888
+ "content-security-policy": "default-src 'none'; sandbox"
56889
+ };
56890
+ }
56891
+ var BLOB_RE = /^\/api\/files\/([^/]+)\/blob$/;
56892
+ var OPEN_RE = /^\/api\/files\/([^/]+)\/open-in-finder$/;
56893
+ async function dispatchFilesRoute(req, res, ctx) {
56894
+ const blobMatch = BLOB_RE.exec(ctx.pathname);
56895
+ if (blobMatch && ctx.method === "GET") {
56896
+ const id = decodeURIComponent(blobMatch[1] ?? "");
56897
+ const row = await ctx.db.get("files", id);
56898
+ if (!row || row.deleted_at) {
56899
+ sendJson(res, { error: "file not found" }, 404);
56900
+ return true;
56901
+ }
56902
+ const name = sanitizeFilename(row.original_name ?? "file");
56903
+ const contentType = typeof row.mime === "string" && row.mime ? row.mime : "application/octet-stream";
56904
+ const loc = localPathOf(row, ctx.latticeRoot);
56905
+ if (localFileExists(loc)) {
56906
+ res.writeHead(200, blobResponseHeaders(contentType, name));
56907
+ const stream = createReadStream(loc);
56908
+ stream.on("error", () => res.destroy());
56909
+ stream.pipe(res);
56910
+ return true;
56911
+ }
56912
+ const s3 = s3RefOf(row);
56913
+ const s3cfg = s3 ? resolveActiveS3Config(ctx.configPath) : null;
56914
+ if (s3 && s3cfg) {
56915
+ try {
56916
+ const store = await createS3Store(s3cfg);
56917
+ const stream = await store.get(s3.key);
56918
+ res.writeHead(200, blobResponseHeaders(contentType, name));
56919
+ stream.on("error", () => res.destroy());
56920
+ stream.pipe(res);
56921
+ } catch (e6) {
56922
+ sendJson(res, { error: `file bytes unavailable from S3: ${e6.message}` }, 502);
56923
+ }
56924
+ return true;
56925
+ }
56926
+ sendJson(
56927
+ res,
56928
+ { error: "this file has no underlying blob here (text-only ingest, or S3 not configured)" },
56929
+ 404
56930
+ );
56931
+ return true;
56932
+ }
56933
+ const openMatch = OPEN_RE.exec(ctx.pathname);
56934
+ if (openMatch && ctx.method === "POST") {
56935
+ if (!localFileOpenEnabled()) {
56936
+ sendJson(res, { enabled: false });
56937
+ return true;
56938
+ }
56939
+ const id = decodeURIComponent(openMatch[1] ?? "");
56940
+ const row = await ctx.db.get("files", id);
56941
+ const loc = row ? localPathOf(row, ctx.latticeRoot) : null;
56942
+ if (!loc) {
56943
+ sendJson(res, { error: "file has no local path" }, 404);
56944
+ return true;
56945
+ }
56946
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
56947
+ try {
56948
+ const child = spawn(opener, [loc], { detached: true, stdio: "ignore" });
56949
+ child.on("error", () => {
56950
+ });
56951
+ child.unref();
56952
+ sendJson(res, { enabled: true, opened: true });
56953
+ } catch (e6) {
56954
+ sendJson(res, { enabled: true, opened: false, error: e6.message }, 500);
56955
+ }
56956
+ return true;
56957
+ }
56958
+ return false;
56959
+ }
56960
+
56961
+ // src/gui/userconfig-routes.ts
55969
56962
  async function upsertIdentityRow(db, identity) {
55970
56963
  const existing = await db.get("__lattice_user_identity", "singleton");
55971
56964
  const updated_at = (/* @__PURE__ */ new Date()).toISOString();
@@ -55985,12 +56978,12 @@ async function upsertIdentityRow(db, identity) {
55985
56978
  }
55986
56979
  }
55987
56980
  function listProjectConfigs(activeConfigPath) {
55988
- const dir = dirname7(activeConfigPath);
56981
+ const dir = dirname8(activeConfigPath);
55989
56982
  const out = [];
55990
- if (!existsSync15(dir)) return out;
56983
+ if (!existsSync16(dir)) return out;
55991
56984
  for (const fname of readdirSync5(dir)) {
55992
56985
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
55993
- const full = join14(dir, fname);
56986
+ const full = join21(dir, fname);
55994
56987
  try {
55995
56988
  const parsed = parseConfigFile(full);
55996
56989
  out.push({
@@ -56026,7 +57019,11 @@ async function dispatchUserConfigRoute(req, res, ctx) {
56026
57019
  }
56027
57020
  if (pathname === "/api/userconfig/preferences" && method === "GET") {
56028
57021
  await tryHandler(res, () => {
56029
- sendJson(res, readPreferences());
57022
+ sendJson(res, {
57023
+ ...readPreferences(),
57024
+ analytics_effective: analyticsEnabled(),
57025
+ local_open: localFileOpenEnabled()
57026
+ });
56030
57027
  return Promise.resolve();
56031
57028
  });
56032
57029
  return true;
@@ -56078,203 +57075,12 @@ async function dispatchUserConfigRoute(req, res, ctx) {
56078
57075
  }
56079
57076
 
56080
57077
  // src/gui/dbconfig-routes.ts
56081
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync7 } from "fs";
56082
- import { basename as basename6, dirname as dirname9, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve7, sep as sep3 } from "path";
57078
+ import { readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
57079
+ import { basename as basename6, dirname as dirname10, isAbsolute as isAbsolute3, relative as relative2, resolve as resolve7, sep as sep5 } from "path";
56083
57080
  import { parseDocument as parseDocument2 } from "yaml";
56084
57081
 
56085
- // src/framework/s3-config.ts
56086
- import { readFileSync as readFileSync12, existsSync as existsSync16 } from "fs";
56087
- var DEFAULT_PREFIX = "blobs";
56088
- function activeWorkspaceLabel(configPath) {
56089
- if (!existsSync16(configPath)) return null;
56090
- try {
56091
- const text = readFileSync12(configPath, "utf8");
56092
- const m4 = /^\s*db:\s*\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}\s*$/m.exec(text);
56093
- return m4 ? m4[1] ?? null : null;
56094
- } catch {
56095
- return null;
56096
- }
56097
- }
56098
- function mergeS3ConfigForSave(prev, body) {
56099
- const str2 = (v2) => typeof v2 === "string" ? v2 : void 0;
56100
- const pick = (b6, p3, trim) => {
56101
- const bv = trim ? str2(b6)?.trim() : str2(b6);
56102
- if (bv) return bv;
56103
- const pv = str2(p3);
56104
- if (pv) return pv;
56105
- return void 0;
56106
- };
56107
- const prefix = pick(body.prefix, prev.prefix, true);
56108
- const endpoint = pick(body.endpoint, prev.endpoint, true);
56109
- const accessKeyId = pick(body.accessKeyId, prev.accessKeyId, false);
56110
- const secretAccessKey = pick(body.secretAccessKey, prev.secretAccessKey, false);
56111
- return {
56112
- enabled: body.enabled === true,
56113
- bucket: str2(body.bucket)?.trim() ?? "",
56114
- region: str2(body.region)?.trim() ?? "",
56115
- ...prefix ? { prefix } : {},
56116
- ...endpoint ? { endpoint } : {},
56117
- ...accessKeyId ? { accessKeyId } : {},
56118
- ...secretAccessKey ? { secretAccessKey } : {}
56119
- };
56120
- }
56121
- function coerce(raw) {
56122
- if (!raw) return null;
56123
- const enabled = raw.enabled === true;
56124
- const bucket = typeof raw.bucket === "string" ? raw.bucket : "";
56125
- const region = typeof raw.region === "string" ? raw.region : "";
56126
- if (!enabled || !bucket || !region) return null;
56127
- const prefix = typeof raw.prefix === "string" && raw.prefix ? raw.prefix : DEFAULT_PREFIX;
56128
- const endpoint = typeof raw.endpoint === "string" && raw.endpoint ? raw.endpoint : void 0;
56129
- const accessKeyId = typeof raw.accessKeyId === "string" ? raw.accessKeyId : "";
56130
- const secretAccessKey = typeof raw.secretAccessKey === "string" ? raw.secretAccessKey : "";
56131
- if (accessKeyId && !secretAccessKey || !accessKeyId && secretAccessKey) {
56132
- console.warn(
56133
- "[s3-config] only one of accessKeyId/secretAccessKey is set; ignoring the partial credential and using the default AWS credential chain. Supply both, or neither."
56134
- );
56135
- }
56136
- return {
56137
- enabled: true,
56138
- bucket,
56139
- region,
56140
- prefix,
56141
- ...endpoint ? { endpoint } : {},
56142
- ...accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}
56143
- };
56144
- }
56145
- function fromEnv() {
56146
- const bucket = process.env.LATTICE_S3_BUCKET;
56147
- const region = process.env.LATTICE_S3_REGION ?? process.env.AWS_REGION;
56148
- if (!bucket || !region) return null;
56149
- const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
56150
- const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
56151
- return {
56152
- enabled: true,
56153
- bucket,
56154
- region,
56155
- prefix: process.env.LATTICE_S3_PREFIX ?? DEFAULT_PREFIX,
56156
- ...process.env.LATTICE_S3_ENDPOINT ? { endpoint: process.env.LATTICE_S3_ENDPOINT } : {},
56157
- ...accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}
56158
- };
56159
- }
56160
- function resolveActiveS3Config(configPath) {
56161
- if (configPath) {
56162
- const label = activeWorkspaceLabel(configPath);
56163
- if (label) {
56164
- const stored = coerce(getS3ConfigRaw(label));
56165
- if (stored) return stored;
56166
- }
56167
- }
56168
- return fromEnv();
56169
- }
56170
-
56171
- // src/cloud/settings.ts
56172
- var CLOUD_SETTING_SYSTEM_PROMPT = "chat_system_prompt";
56173
- var CLOUD_SETTINGS_BOOTSTRAP_SQL = `
56174
- -- Owner-controlled, cloud-wide key/value settings. No grant to the member group,
56175
- -- so a member's SELECT is denied (the VALUE is unreadable \u2014 the catalog may still
56176
- -- reveal the table exists, like every other __lattice_* table); the SECURITY
56177
- -- DEFINER getter below is the only member-reachable read path.
56178
- CREATE TABLE IF NOT EXISTS "__lattice_cloud_settings" (
56179
- "key" text PRIMARY KEY,
56180
- "value" text,
56181
- "updated_by" text NOT NULL DEFAULT session_user,
56182
- "updated_at" timestamptz NOT NULL DEFAULT now()
56183
- );
56184
-
56185
- -- Read a setting. SECURITY DEFINER so a scoped member (no direct grant on the
56186
- -- table) can still read it to inject into their own chat. App-mediated ceiling:
56187
- -- this returns the value to whoever calls it, so the secrecy is at the product
56188
- -- surface (UI + API), not against a member's own SQL session.
56189
- CREATE OR REPLACE FUNCTION lattice_get_cloud_setting(p_key text)
56190
- RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $fn$
56191
- SELECT "value" FROM "__lattice_cloud_settings" WHERE "key" = p_key LIMIT 1
56192
- $fn$;
56193
-
56194
- -- Owner-only write. Raises unless the caller can create roles (a cloud owner /
56195
- -- DBA) \u2014 members get no write path even though the function is callable.
56196
- CREATE OR REPLACE FUNCTION lattice_set_cloud_setting(p_key text, p_value text)
56197
- RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $fn$
56198
- BEGIN
56199
- IF NOT (SELECT rolcreaterole FROM pg_roles WHERE rolname = session_user) THEN
56200
- RAISE EXCEPTION 'lattice: only a cloud owner may change workspace settings';
56201
- END IF;
56202
- INSERT INTO "__lattice_cloud_settings" ("key", "value", "updated_by", "updated_at")
56203
- VALUES (p_key, p_value, session_user, now())
56204
- ON CONFLICT ("key") DO UPDATE
56205
- SET "value" = EXCLUDED."value", "updated_by" = session_user, "updated_at" = now();
56206
- END $fn$;
56207
- `;
56208
- async function installCloudSettings(db) {
56209
- if (db.getDialect() !== "postgres") return;
56210
- const schema = await cloudSchema(db);
56211
- const migration = {
56212
- // v2 pins search_path on the two SECURITY DEFINER helpers (closes the
56213
- // pg_temp-shadow class of bypass on the settings getter/setter).
56214
- version: "internal:cloud-settings:v2",
56215
- sql: pinDefinerSearchPath(CLOUD_SETTINGS_BOOTSTRAP_SQL, schema)
56216
- };
56217
- await db.migrate([migration]);
56218
- }
56219
- async function getCloudSetting(db, key) {
56220
- if (db.getDialect() !== "postgres") return null;
56221
- try {
56222
- const row = await getAsyncOrSync(db.adapter, `SELECT lattice_get_cloud_setting(?) AS value`, [
56223
- key
56224
- ]);
56225
- const v2 = row?.value;
56226
- return typeof v2 === "string" && v2.length > 0 ? v2 : null;
56227
- } catch {
56228
- return null;
56229
- }
56230
- }
56231
- async function getCloudSettingStrict(db, key) {
56232
- if (db.getDialect() !== "postgres") return null;
56233
- const row = await getAsyncOrSync(db.adapter, `SELECT lattice_get_cloud_setting(?) AS value`, [
56234
- key
56235
- ]);
56236
- const v2 = row?.value;
56237
- return typeof v2 === "string" && v2.length > 0 ? v2 : null;
56238
- }
56239
- async function setCloudSetting(db, key, value) {
56240
- await runAsyncOrSync(db.adapter, `SELECT lattice_set_cloud_setting(?, ?)`, [key, value]);
56241
- }
56242
-
56243
- // src/cloud/setup.ts
56244
- async function secureCloud(db) {
56245
- if (db.getDialect() !== "postgres") return;
56246
- await installCloudRls(db);
56247
- await installCloudSettings(db);
56248
- await db.ensureObservationSubstrate();
56249
- await enableChangelogRls(db);
56250
- const registered = db.getRegisteredTableNames();
56251
- for (const table of registered) {
56252
- if (table.startsWith("__lattice_") || table.startsWith("_lattice_")) continue;
56253
- const pk = db.getPrimaryKey(table);
56254
- if (pk.length === 0) continue;
56255
- await backfillOwnership(db, table, pk);
56256
- await enableRlsForTable(db, table, pk);
56257
- const cols = db.getRegisteredColumns(table);
56258
- if (cols) {
56259
- await seedColumnPolicyFromYaml(db, table, db.getColumnAudience(table));
56260
- await regenerateAudienceViewFromDb(db, table, Object.keys(cols), pk);
56261
- }
56262
- }
56263
- if (registered.includes("secrets")) {
56264
- await runAsyncOrSync(db.adapter, `SELECT lattice_set_table_never_share('secrets', true)`);
56265
- }
56266
- await runAsyncOrSync(
56267
- db.adapter,
56268
- `DO $LATTICE$ BEGIN
56269
- IF to_regclass('__lattice_user_identity') IS NOT NULL THEN
56270
- EXECUTE 'GRANT SELECT, INSERT, UPDATE ON "__lattice_user_identity" TO ${MEMBER_GROUP}';
56271
- END IF;
56272
- END $LATTICE$`
56273
- );
56274
- }
56275
-
56276
57082
  // src/cloud/invite.ts
56277
- import { randomBytes as randomBytes6, scryptSync as scryptSync2, hkdfSync, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "crypto";
57083
+ import { randomBytes as randomBytes7, scryptSync as scryptSync2, hkdfSync, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv2 } from "crypto";
56278
57084
  var VERSION = 1;
56279
57085
  var SALT_LEN = 16;
56280
57086
  var SECRET_LEN = 32;
@@ -56298,9 +57104,9 @@ function deriveKey2(tokenSecret, email, salt) {
56298
57104
  function mintInviteToken(input) {
56299
57105
  const email = normalizeEmail(input.email);
56300
57106
  if (!email) throw new Error("lattice: an invite must be bound to an email");
56301
- const salt = randomBytes6(SALT_LEN);
56302
- const tokenSecret = randomBytes6(SECRET_LEN);
56303
- const nonce = randomBytes6(NONCE_LEN);
57107
+ const salt = randomBytes7(SALT_LEN);
57108
+ const tokenSecret = randomBytes7(SECRET_LEN);
57109
+ const nonce = randomBytes7(NONCE_LEN);
56304
57110
  const key = deriveKey2(tokenSecret, email, salt);
56305
57111
  const payload = {
56306
57112
  v: 1,
@@ -56311,7 +57117,8 @@ function mintInviteToken(input) {
56311
57117
  password: input.password,
56312
57118
  role: input.role,
56313
57119
  email,
56314
- expires_at: input.expiresAt.toISOString()
57120
+ expires_at: input.expiresAt.toISOString(),
57121
+ ...input.workspaceName?.trim() ? { workspace_name: input.workspaceName.trim() } : {}
56315
57122
  };
56316
57123
  const cipher = createCipheriv2("aes-256-gcm", key, nonce);
56317
57124
  cipher.setAAD(Buffer.from(email, "utf8"));
@@ -56361,7 +57168,7 @@ function redeemInviteToken(email, token) {
56361
57168
  }
56362
57169
 
56363
57170
  // src/gui/dbconfig-routes.ts
56364
- import { createHash as createHash2, randomUUID } from "crypto";
57171
+ import { randomUUID } from "crypto";
56365
57172
 
56366
57173
  // src/framework/cloud-migration.ts
56367
57174
  import { existsSync as existsSync17, renameSync as renameSync3, unlinkSync as unlinkSync4 } from "fs";
@@ -56442,14 +57249,14 @@ function archiveLocalSqlite(dbPath) {
56442
57249
  }
56443
57250
 
56444
57251
  // src/framework/gui-bootstrap.ts
56445
- import { existsSync as existsSync19, readFileSync as readFileSync13, readdirSync as readdirSync6 } from "fs";
56446
- import { basename as basename5, dirname as dirname8, join as join16, resolve as resolve6 } from "path";
57252
+ import { existsSync as existsSync19, readFileSync as readFileSync14, readdirSync as readdirSync6 } from "fs";
57253
+ import { basename as basename5, dirname as dirname9, join as join23, resolve as resolve6 } from "path";
56447
57254
  import { parse as parseYaml } from "yaml";
56448
57255
 
56449
57256
  // src/framework/migrate-to-root.ts
56450
57257
  import { cpSync, existsSync as existsSync18, mkdirSync as mkdirSync8 } from "fs";
56451
- import { homedir as homedir3 } from "os";
56452
- import { join as join15 } from "path";
57258
+ import { homedir as homedir5 } from "os";
57259
+ import { join as join22 } from "path";
56453
57260
  var LEGACY_ENTRIES = [
56454
57261
  "master.key",
56455
57262
  "identity.json",
@@ -56458,16 +57265,16 @@ var LEGACY_ENTRIES = [
56458
57265
  "keys"
56459
57266
  ];
56460
57267
  function importLegacyUserConfig(root6) {
56461
- const legacy = process.env.LATTICE_CONFIG_DIR ?? join15(homedir3(), ".lattice");
57268
+ const legacy = process.env.LATTICE_CONFIG_DIR ?? join22(homedir5(), ".lattice");
56462
57269
  const dest = rootConfigDir(root6);
56463
57270
  const copied = [];
56464
- if (!existsSync18(join15(legacy, "master.key"))) return { migrated: false, copied };
56465
- if (existsSync18(join15(dest, "master.key"))) return { migrated: false, copied };
57271
+ if (!existsSync18(join22(legacy, "master.key"))) return { migrated: false, copied };
57272
+ if (existsSync18(join22(dest, "master.key"))) return { migrated: false, copied };
56466
57273
  mkdirSync8(dest, { recursive: true });
56467
57274
  for (const entry of LEGACY_ENTRIES) {
56468
- const src = join15(legacy, entry);
57275
+ const src = join22(legacy, entry);
56469
57276
  if (existsSync18(src)) {
56470
- cpSync(src, join15(dest, entry), { recursive: true });
57277
+ cpSync(src, join22(dest, entry), { recursive: true });
56471
57278
  copied.push(entry);
56472
57279
  }
56473
57280
  }
@@ -56476,17 +57283,17 @@ function importLegacyUserConfig(root6) {
56476
57283
 
56477
57284
  // src/framework/gui-bootstrap.ts
56478
57285
  function resolveContextDirForConfig(configPath) {
56479
- const base = dirname8(resolve6(configPath));
57286
+ const base = dirname9(resolve6(configPath));
56480
57287
  for (const dir of ["context", ".", "generated"]) {
56481
57288
  const abs = resolve6(base, dir);
56482
- if (existsSync19(join16(abs, ".lattice", "manifest.json"))) return abs;
57289
+ if (existsSync19(join23(abs, ".lattice", "manifest.json"))) return abs;
56483
57290
  }
56484
57291
  return resolve6(base, "context");
56485
57292
  }
56486
57293
  function readConfigMeta(absPath) {
56487
57294
  let raw;
56488
57295
  try {
56489
- raw = readFileSync13(absPath, "utf8");
57296
+ raw = readFileSync14(absPath, "utf8");
56490
57297
  } catch {
56491
57298
  return null;
56492
57299
  }
@@ -56532,7 +57339,7 @@ function reconcileWorkspaceRegistry(root6, scanDirs) {
56532
57339
  }
56533
57340
  for (const fname of entries) {
56534
57341
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
56535
- const full = join16(abs, fname);
57342
+ const full = join23(abs, fname);
56536
57343
  if (findWorkspaceByConfigPath(root6, full)) continue;
56537
57344
  adoptConfigAsWorkspace(root6, full, { makeActive: false });
56538
57345
  }
@@ -56542,10 +57349,10 @@ function ensureRootForGui(opts) {
56542
57349
  const configAbs = resolve6(opts.configPath);
56543
57350
  const hasConfigFile = existsSync19(configAbs);
56544
57351
  let root6 = findLatticeRoot(opts.startDir);
56545
- if (!root6 && hasConfigFile) root6 = findLatticeRoot(dirname8(configAbs));
57352
+ if (!root6 && hasConfigFile) root6 = findLatticeRoot(dirname9(configAbs));
56546
57353
  let freshRoot = false;
56547
57354
  if (!root6) {
56548
- root6 = ensureLatticeRoot(hasConfigFile ? dirname8(configAbs) : opts.startDir);
57355
+ root6 = ensureLatticeRoot(hasConfigFile ? dirname9(configAbs) : opts.startDir);
56549
57356
  freshRoot = true;
56550
57357
  }
56551
57358
  importLegacyUserConfig(root6);
@@ -56555,7 +57362,7 @@ function ensureRootForGui(opts) {
56555
57362
  ...opts.displayName !== void 0 ? { displayName: opts.displayName } : {}
56556
57363
  });
56557
57364
  }
56558
- reconcileWorkspaceRegistry(root6, [dirname8(configAbs), dirname8(root6)]);
57365
+ reconcileWorkspaceRegistry(root6, [dirname9(configAbs), dirname9(root6)]);
56559
57366
  const ws = getActiveWorkspace(root6) ?? addWorkspace(root6, { displayName: opts.displayName ?? "My Workspace" });
56560
57367
  const paths = resolveWorkspacePaths(root6, ws);
56561
57368
  return {
@@ -56569,14 +57376,17 @@ function ensureRootForGui(opts) {
56569
57376
 
56570
57377
  // src/gui/dbconfig-routes.ts
56571
57378
  var MAX_SYSTEM_PROMPT_CHARS = 1e5;
56572
- function updateActiveWorkspaceToCloud(configPath, label) {
56573
- const root6 = findLatticeRoot(dirname9(configPath));
57379
+ function updateActiveWorkspaceToCloud(configPath, displayName, key) {
57380
+ const root6 = findLatticeRoot(dirname10(configPath));
56574
57381
  if (!root6) return;
56575
57382
  registerOrUpdateCloudWorkspace(root6, {
56576
57383
  configPath,
56577
57384
  contextDir: resolveContextDirForConfig(configPath),
56578
- displayName: label,
56579
- db: "${LATTICE_DB:" + label + "}",
57385
+ displayName,
57386
+ // The credential key + ${LATTICE_DB:…} reference must be a SANITIZED,
57387
+ // space-free label (resolveDbPath rejects anything else); the human
57388
+ // displayName is separate.
57389
+ db: "${LATTICE_DB:" + key + "}",
56580
57390
  makeActive: true
56581
57391
  });
56582
57392
  }
@@ -56605,7 +57415,7 @@ async function computeState(db) {
56605
57415
  return await canManageRoles(db) ? "cloud-owner" : "cloud-member";
56606
57416
  }
56607
57417
  async function describeCurrent(configPath, db) {
56608
- const rawYaml = readFileSync14(configPath, "utf8");
57418
+ const rawYaml = readFileSync15(configPath, "utf8");
56609
57419
  const doc = parseDocument2(rawYaml);
56610
57420
  const rawDb = doc.get("db");
56611
57421
  const dbLine = typeof rawDb === "string" ? rawDb.trim() : "";
@@ -56646,7 +57456,7 @@ async function describeCurrent(configPath, db) {
56646
57456
  };
56647
57457
  }
56648
57458
  function rewriteDbLine(configPath, newValue) {
56649
- const doc = parseDocument2(readFileSync14(configPath, "utf8"));
57459
+ const doc = parseDocument2(readFileSync15(configPath, "utf8"));
56650
57460
  doc.set("db", newValue);
56651
57461
  writeFileSync7(configPath, doc.toString(), "utf8");
56652
57462
  }
@@ -56671,9 +57481,27 @@ function parseSaveBody(body) {
56671
57481
  return null;
56672
57482
  }
56673
57483
  function resolveRelativeToConfig(configPath, candidate) {
56674
- return isAbsolute2(candidate) ? candidate : resolve7(configPath, "..", candidate);
57484
+ return isAbsolute3(candidate) ? candidate : resolve7(configPath, "..", candidate);
56675
57485
  }
56676
- async function joinCloudAsMember(ctx, res, fields, label) {
57486
+ async function reclaimStaleInviteRoles(db, emailHash) {
57487
+ const stale = await allAsyncOrSync(
57488
+ db.adapter,
57489
+ `SELECT DISTINCT "role" FROM "__lattice_member_invites"
57490
+ WHERE "redeemed_at" IS NULL AND "revoked_at" IS NULL
57491
+ AND ("email_hash" = ? OR "expires_at" <= now())`,
57492
+ [emailHash]
57493
+ );
57494
+ for (const { role } of stale) {
57495
+ await revokeMemberRole(db, role);
57496
+ await runAsyncOrSync(
57497
+ db.adapter,
57498
+ `UPDATE "__lattice_member_invites" SET "revoked_at" = now()
57499
+ WHERE "role" = ? AND "revoked_at" IS NULL`,
57500
+ [role]
57501
+ );
57502
+ }
57503
+ }
57504
+ async function joinCloudAsMember(ctx, res, fields, label, opts = {}) {
56677
57505
  const url = buildPostgresUrl(fields);
56678
57506
  const probe = await probeCloud(url);
56679
57507
  if (!probe.reachable) {
@@ -56691,11 +57519,27 @@ async function joinCloudAsMember(ctx, res, fields, label) {
56691
57519
  );
56692
57520
  return;
56693
57521
  }
56694
- saveDbCredential(label, url);
56695
- rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + label + "}");
56696
- updateActiveWorkspaceToCloud(ctx.configPath, label);
56697
- await ctx.swap();
56698
- sendJson(res, { ok: true, label, isCloud: true });
57522
+ if (opts.claimInvite) {
57523
+ const claim = await claimMemberInvite(url);
57524
+ if (!claim.claimed) {
57525
+ sendJson(
57526
+ res,
57527
+ {
57528
+ ok: false,
57529
+ error: claim.error ?? "This invite has already been used, was revoked, or has expired. Ask the owner for a new one."
57530
+ },
57531
+ 403
57532
+ );
57533
+ return;
57534
+ }
57535
+ }
57536
+ const key = slugify(label) || "cloud";
57537
+ try {
57538
+ const workspaceId = await ctx.createCloudWorkspace(label, key, url);
57539
+ sendJson(res, { ok: true, label, isCloud: true, workspaceId });
57540
+ } catch (e6) {
57541
+ sendJson(res, { ok: false, error: e6.message }, 500);
57542
+ }
56699
57543
  }
56700
57544
  async function dispatchDbConfigRoute(req, res, ctx) {
56701
57545
  const { pathname, method } = ctx;
@@ -56729,7 +57573,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56729
57573
  }
56730
57574
  const abs = resolveRelativeToConfig(ctx.configPath, parsed.path);
56731
57575
  const rel = relative2(resolve7(ctx.configPath, ".."), abs);
56732
- const dbLine = rel.startsWith("..") ? abs : "./" + rel.split(sep3).join("/");
57576
+ const dbLine = rel.startsWith("..") ? abs : "./" + rel.split(sep5).join("/");
56733
57577
  rewriteDbLine(ctx.configPath, dbLine);
56734
57578
  sendJson(res, { ok: true, type: "sqlite", path: dbLine });
56735
57579
  });
@@ -56841,7 +57685,7 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56841
57685
  const backupPath = archiveLocalSqlite(sourceDbPath);
56842
57686
  saveDbCredential(parsed.label, url);
56843
57687
  rewriteDbLine(ctx.configPath, "${LATTICE_DB:" + parsed.label + "}");
56844
- updateActiveWorkspaceToCloud(ctx.configPath, parsed.label);
57688
+ updateActiveWorkspaceToCloud(ctx.configPath, parsed.label, parsed.label);
56845
57689
  await ctx.swap();
56846
57690
  sendJson(res, {
56847
57691
  ok: true,
@@ -56895,9 +57739,26 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56895
57739
  return;
56896
57740
  }
56897
57741
  const me = await getAsyncOrSync(ctx.db.adapter, `SELECT session_user AS u`);
56898
- const owner = me?.u ?? "";
57742
+ const ownerRole = me?.u ?? "";
57743
+ const idRow = await getAsyncOrSync(
57744
+ ctx.db.adapter,
57745
+ `SELECT display_name, email FROM "__lattice_user_identity" WHERE id = 'singleton'`
57746
+ ).catch(() => void 0);
57747
+ const trimmedOwnerName = idRow?.display_name?.trim() ?? "";
57748
+ const ownerName = trimmedOwnerName.length > 0 ? trimmedOwnerName : ownerRole;
57749
+ const ownerEmail = idRow?.email ?? "";
56899
57750
  if (!await canManageRoles(ctx.db)) {
56900
- sendJson(res, { members: owner ? [{ role: owner, isOwner: false, isYou: true }] : [] });
57751
+ sendJson(res, {
57752
+ members: ownerRole ? [
57753
+ {
57754
+ role: ownerRole,
57755
+ name: ownerName,
57756
+ email: ownerEmail,
57757
+ status: "member",
57758
+ isYou: true
57759
+ }
57760
+ ] : []
57761
+ });
56901
57762
  return;
56902
57763
  }
56903
57764
  const rows = await allAsyncOrSync(
@@ -56906,12 +57767,27 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56906
57767
  FROM pg_auth_members am
56907
57768
  JOIN pg_roles g ON g.oid = am.roleid AND g.rolname = ?
56908
57769
  JOIN pg_roles m ON m.oid = am.member
57770
+ WHERE m.rolname <> ?
56909
57771
  ORDER BY m.rolname`,
56910
- [MEMBER_GROUP]
57772
+ [MEMBER_GROUP, ownerRole]
56911
57773
  );
57774
+ const invites = await allAsyncOrSync(
57775
+ ctx.db.adapter,
57776
+ `SELECT DISTINCT ON ("role") "role", "email", "redeemed_at"
57777
+ FROM "__lattice_member_invites"
57778
+ WHERE "revoked_at" IS NULL
57779
+ ORDER BY "role", "created_at" DESC`
57780
+ ).catch(() => []);
57781
+ const inviteByRole = new Map(invites.map((r6) => [r6.role, r6]));
56912
57782
  const members = [
56913
- ...owner ? [{ role: owner, isOwner: true, isYou: true }] : [],
56914
- ...rows.map((r6) => ({ role: r6.role, isOwner: false, isYou: r6.role === owner }))
57783
+ { role: ownerRole, name: ownerName, email: ownerEmail, status: "owner", isYou: true },
57784
+ ...rows.map((r6) => {
57785
+ const inv = inviteByRole.get(r6.role);
57786
+ const email = inv?.email ?? "";
57787
+ const name = email ? email.split("@")[0] ?? r6.role : r6.role;
57788
+ const status = inv && inv.redeemed_at == null ? "invited" : "member";
57789
+ return { role: r6.role, name, email, status, isYou: false };
57790
+ })
56915
57791
  ];
56916
57792
  sendJson(res, { members });
56917
57793
  });
@@ -56938,22 +57814,37 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56938
57814
  sendJson(res, { error: "Could not resolve the cloud connection coordinates" }, 500);
56939
57815
  return;
56940
57816
  }
57817
+ const emailHash = hashInviteEmail(await getOrCreateInviteSalt(ctx.db), email);
57818
+ await reclaimStaleInviteRoles(ctx.db, emailHash);
56941
57819
  const role = memberRoleName(email);
56942
57820
  const password = generateMemberPassword();
56943
57821
  await provisionMemberRole(ctx.db, role, password);
56944
57822
  await assertScopedMemberRole(ctx.db, role);
56945
- const me = await getAsyncOrSync(ctx.db.adapter, `SELECT session_user AS u`);
56946
- const user = poolerAwareUser(coords.host, role, me?.u ?? "");
57823
+ const user = poolerAwareUser(coords.host, role, coords.user);
56947
57824
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3);
56948
- const token = mintInviteToken({ coords, user, password, role, email, expiresAt });
57825
+ const inviteRoot = findLatticeRoot(dirname10(ctx.configPath));
57826
+ const ownerWs = inviteRoot ? getActiveWorkspace(inviteRoot) : null;
57827
+ const token = mintInviteToken({
57828
+ coords,
57829
+ user,
57830
+ password,
57831
+ role,
57832
+ email,
57833
+ expiresAt,
57834
+ ...ownerWs?.displayName ? { workspaceName: ownerWs.displayName } : {}
57835
+ });
56949
57836
  await runAsyncOrSync(
56950
57837
  ctx.db.adapter,
56951
- `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","expires_at")
56952
- VALUES (?, ?, ?, ?)`,
57838
+ `INSERT INTO "__lattice_member_invites" ("id","role","email_hash","email","expires_at")
57839
+ VALUES (?, ?, ?, ?, ?)`,
56953
57840
  [
56954
57841
  randomUUID(),
56955
57842
  role,
56956
- createHash2("sha256").update(email.trim().toLowerCase()).digest("hex"),
57843
+ emailHash,
57844
+ // Plaintext email stored ONLY in this owner-only table so the owner's
57845
+ // Members list can show who each member is (the hash above stays for
57846
+ // tamper-evident audit; the password is still never stored anywhere).
57847
+ email.trim().toLowerCase(),
56957
57848
  expiresAt.toISOString()
56958
57849
  ]
56959
57850
  );
@@ -56961,6 +57852,39 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56961
57852
  });
56962
57853
  return true;
56963
57854
  }
57855
+ if (pathname === "/api/cloud/remove-member" && method === "POST") {
57856
+ await tryHandler(res, async () => {
57857
+ if (ctx.db.getDialect() !== "postgres" || !await cloudRlsInstalled(ctx.db)) {
57858
+ sendJson(res, { error: "The active database is not a Lattice cloud" }, 400);
57859
+ return;
57860
+ }
57861
+ if (!await canManageRoles(ctx.db)) {
57862
+ sendJson(res, { error: "Only a cloud owner can remove members" }, 403);
57863
+ return;
57864
+ }
57865
+ const body = await readJson(req);
57866
+ const role = typeof body.role === "string" ? body.role : "";
57867
+ if (!role) {
57868
+ sendJson(res, { error: "A member role is required" }, 400);
57869
+ return;
57870
+ }
57871
+ const me = await getAsyncOrSync(ctx.db.adapter, `SELECT session_user AS u`);
57872
+ if (role === (me?.u ?? "")) {
57873
+ sendJson(res, { error: "You cannot remove yourself (the owner)" }, 400);
57874
+ return;
57875
+ }
57876
+ await revokeMemberRole(ctx.db, role);
57877
+ await runAsyncOrSync(
57878
+ ctx.db.adapter,
57879
+ `UPDATE "__lattice_member_invites" SET "revoked_at" = now() WHERE "role" = ? AND "revoked_at" IS NULL`,
57880
+ [role]
57881
+ ).catch((e6) => {
57882
+ console.error("[cloud] mark invite revoked failed:", e6.message);
57883
+ });
57884
+ sendJson(res, { ok: true, role });
57885
+ });
57886
+ return true;
57887
+ }
56964
57888
  if (pathname === "/api/cloud/redeem-invite" && method === "POST") {
56965
57889
  await tryHandler(res, async () => {
56966
57890
  const body = await readJson(req);
@@ -56981,7 +57905,9 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56981
57905
  sendJson(res, { ok: false, error: e6.message }, 400);
56982
57906
  return;
56983
57907
  }
56984
- const label = typeof body.label === "string" && body.label.trim() ? body.label.trim() : "Cloud workspace";
57908
+ const fromCloud = payload.workspace_name?.trim() ?? "";
57909
+ const fromBody = typeof body.label === "string" ? body.label.trim() : "";
57910
+ const label = fromCloud.length > 0 ? fromCloud : fromBody.length > 0 ? fromBody : "Cloud workspace";
56985
57911
  await joinCloudAsMember(
56986
57912
  ctx,
56987
57913
  res,
@@ -56992,7 +57918,9 @@ async function dispatchDbConfigRoute(req, res, ctx) {
56992
57918
  user: payload.user,
56993
57919
  password: payload.password
56994
57920
  },
56995
- label
57921
+ label,
57922
+ { claimInvite: true }
57923
+ // #3.1 — enforce one-time-use + revocation on redeem
56996
57924
  );
56997
57925
  });
56998
57926
  return true;
@@ -57157,10 +58085,10 @@ async function dispatchDbConfigRoute(req, res, ctx) {
57157
58085
  sendJson(res, { error: "name must be 200 characters or fewer" }, 400);
57158
58086
  return;
57159
58087
  }
57160
- const doc = parseDocument2(readFileSync14(ctx.configPath, "utf8"));
58088
+ const doc = parseDocument2(readFileSync15(ctx.configPath, "utf8"));
57161
58089
  doc.set("name", name);
57162
58090
  writeFileSync7(ctx.configPath, doc.toString(), "utf8");
57163
- const root6 = findLatticeRoot(dirname9(ctx.configPath));
58091
+ const root6 = findLatticeRoot(dirname10(ctx.configPath));
57164
58092
  if (root6) renameWorkspaceByConfigPath(root6, ctx.configPath, name);
57165
58093
  const info = await describeCurrent(ctx.configPath, ctx.db);
57166
58094
  sendJson(res, { ok: true, kind: info.isCloud ? "cloud" : "local", name });
@@ -57170,195 +58098,14 @@ async function dispatchDbConfigRoute(req, res, ctx) {
57170
58098
  return false;
57171
58099
  }
57172
58100
  function activeCloudCoords(configPath) {
57173
- const doc = parseDocument2(readFileSync14(configPath, "utf8"));
58101
+ const doc = parseDocument2(readFileSync15(configPath, "utf8"));
57174
58102
  const rawDb = doc.get("db");
57175
58103
  const dbLine = typeof rawDb === "string" ? rawDb.trim() : "";
57176
58104
  const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(dbLine);
57177
58105
  const url = labelMatch ? getDbCredential(labelMatch[1] ?? "") : dbLine;
57178
58106
  if (!url) return null;
57179
58107
  const parsed = parsePostgresUrl(url);
57180
- return parsed ? { host: parsed.host, port: parsed.port, dbname: parsed.dbname } : null;
57181
- }
57182
-
57183
- // src/gui/files-routes.ts
57184
- import { createReadStream, statSync as statSync5 } from "fs";
57185
- import { isAbsolute as isAbsolute3, join as join23 } from "path";
57186
- import { spawn } from "child_process";
57187
-
57188
- // src/framework/s3-store.ts
57189
- var S3UnavailableError = class extends Error {
57190
- constructor(message) {
57191
- super(message);
57192
- this.name = "S3UnavailableError";
57193
- }
57194
- };
57195
- function s3Key(prefix, sha256) {
57196
- const p3 = prefix.replace(/^\/+|\/+$/g, "");
57197
- return p3 ? `${p3}/${sha256}` : sha256;
57198
- }
57199
- async function createS3Store(cfg) {
57200
- let mod;
57201
- try {
57202
- mod = await Promise.resolve().then(() => (init_dist_es22(), dist_es_exports8));
57203
- } catch {
57204
- throw new S3UnavailableError(
57205
- 'S3 file storage requires the optional "@aws-sdk/client-s3" dependency, which is not installed'
57206
- );
57207
- }
57208
- const { S3Client: S3Client2, PutObjectCommand: PutObjectCommand2, GetObjectCommand: GetObjectCommand2, HeadObjectCommand: HeadObjectCommand2 } = mod;
57209
- const client = new S3Client2({
57210
- region: cfg.region,
57211
- // forcePathStyle is required for S3-compatible endpoints (R2 / MinIO /
57212
- // LocalStack) which don't support virtual-hosted-style bucket subdomains.
57213
- ...cfg.endpoint ? { endpoint: cfg.endpoint, forcePathStyle: true } : {},
57214
- ...cfg.credentials ? { credentials: cfg.credentials } : {}
57215
- });
57216
- return {
57217
- async put(key, body, opts) {
57218
- await client.send(
57219
- new PutObjectCommand2({
57220
- Bucket: cfg.bucket,
57221
- Key: key,
57222
- Body: body,
57223
- ...opts?.contentType ? { ContentType: opts.contentType } : {}
57224
- })
57225
- );
57226
- },
57227
- async get(key) {
57228
- const out = await client.send(new GetObjectCommand2({ Bucket: cfg.bucket, Key: key }));
57229
- const body = out.Body;
57230
- if (!body) throw new Error(`S3: object "${key}" has no body`);
57231
- return body;
57232
- },
57233
- async exists(key) {
57234
- try {
57235
- await client.send(new HeadObjectCommand2({ Bucket: cfg.bucket, Key: key }));
57236
- return true;
57237
- } catch (e6) {
57238
- const err = e6;
57239
- if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) return false;
57240
- throw e6;
57241
- }
57242
- }
57243
- };
57244
- }
57245
-
57246
- // src/gui/files-routes.ts
57247
- function localPathOf(row, latticeRoot) {
57248
- if (typeof row.path === "string" && row.path) return row.path;
57249
- if (row.ref_kind === "local_ref" && typeof row.ref_uri === "string" && row.ref_uri) {
57250
- return row.ref_uri;
57251
- }
57252
- if ((row.ref_kind === "blob" || row.ref_kind === "cloud_ref") && typeof row.blob_path === "string" && row.blob_path) {
57253
- return isAbsolute3(row.blob_path) ? row.blob_path : latticeRoot ? join23(latticeRoot, row.blob_path) : null;
57254
- }
57255
- return null;
57256
- }
57257
- function s3RefOf(row) {
57258
- if (row.ref_kind !== "cloud_ref" || row.ref_provider !== "s3") return null;
57259
- if (typeof row.source_json === "string" && row.source_json) {
57260
- try {
57261
- const j6 = JSON.parse(row.source_json);
57262
- if (typeof j6.bucket === "string" && typeof j6.key === "string") {
57263
- return { bucket: j6.bucket, key: j6.key };
57264
- }
57265
- } catch {
57266
- }
57267
- }
57268
- if (typeof row.ref_uri === "string") {
57269
- const m4 = /^s3:\/\/([^/]+)\/(.+)$/.exec(row.ref_uri);
57270
- if (m4) return { bucket: m4[1] ?? "", key: m4[2] ?? "" };
57271
- }
57272
- return null;
57273
- }
57274
- function localFileExists(loc) {
57275
- if (!loc) return false;
57276
- try {
57277
- return statSync5(loc).isFile();
57278
- } catch {
57279
- return false;
57280
- }
57281
- }
57282
- function sanitizeFilename(name) {
57283
- return name.replace(/[\r\n"\\]/g, "_");
57284
- }
57285
- function blobResponseHeaders(contentType, name) {
57286
- return {
57287
- "content-type": contentType,
57288
- "content-disposition": `inline; filename="${name}"`,
57289
- "cache-control": "no-store",
57290
- "x-content-type-options": "nosniff",
57291
- "content-security-policy": "default-src 'none'; sandbox"
57292
- };
57293
- }
57294
- var BLOB_RE = /^\/api\/files\/([^/]+)\/blob$/;
57295
- var OPEN_RE = /^\/api\/files\/([^/]+)\/open-in-finder$/;
57296
- async function dispatchFilesRoute(req, res, ctx) {
57297
- const blobMatch = BLOB_RE.exec(ctx.pathname);
57298
- if (blobMatch && ctx.method === "GET") {
57299
- const id = decodeURIComponent(blobMatch[1] ?? "");
57300
- const row = await ctx.db.get("files", id);
57301
- if (!row || row.deleted_at) {
57302
- sendJson(res, { error: "file not found" }, 404);
57303
- return true;
57304
- }
57305
- const name = sanitizeFilename(row.original_name ?? "file");
57306
- const contentType = typeof row.mime === "string" && row.mime ? row.mime : "application/octet-stream";
57307
- const loc = localPathOf(row, ctx.latticeRoot);
57308
- if (localFileExists(loc)) {
57309
- res.writeHead(200, blobResponseHeaders(contentType, name));
57310
- const stream = createReadStream(loc);
57311
- stream.on("error", () => res.destroy());
57312
- stream.pipe(res);
57313
- return true;
57314
- }
57315
- const s3 = s3RefOf(row);
57316
- const s3cfg = s3 ? resolveActiveS3Config(ctx.configPath) : null;
57317
- if (s3 && s3cfg) {
57318
- try {
57319
- const store = await createS3Store(s3cfg);
57320
- const stream = await store.get(s3.key);
57321
- res.writeHead(200, blobResponseHeaders(contentType, name));
57322
- stream.on("error", () => res.destroy());
57323
- stream.pipe(res);
57324
- } catch (e6) {
57325
- sendJson(res, { error: `file bytes unavailable from S3: ${e6.message}` }, 502);
57326
- }
57327
- return true;
57328
- }
57329
- sendJson(
57330
- res,
57331
- { error: "this file has no underlying blob here (text-only ingest, or S3 not configured)" },
57332
- 404
57333
- );
57334
- return true;
57335
- }
57336
- const openMatch = OPEN_RE.exec(ctx.pathname);
57337
- if (openMatch && ctx.method === "POST") {
57338
- if (process.env.LATTICE_LOCAL_OPEN !== "1") {
57339
- sendJson(res, { enabled: false });
57340
- return true;
57341
- }
57342
- const id = decodeURIComponent(openMatch[1] ?? "");
57343
- const row = await ctx.db.get("files", id);
57344
- const loc = row ? localPathOf(row, ctx.latticeRoot) : null;
57345
- if (!loc) {
57346
- sendJson(res, { error: "file has no local path" }, 404);
57347
- return true;
57348
- }
57349
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
57350
- try {
57351
- const child = spawn(opener, [loc], { detached: true, stdio: "ignore" });
57352
- child.on("error", () => {
57353
- });
57354
- child.unref();
57355
- sendJson(res, { enabled: true, opened: true });
57356
- } catch (e6) {
57357
- sendJson(res, { enabled: true, opened: false, error: e6.message }, 500);
57358
- }
57359
- return true;
57360
- }
57361
- return false;
58108
+ return parsed ? { host: parsed.host, port: parsed.port, dbname: parsed.dbname, user: parsed.user } : null;
57362
58109
  }
57363
58110
 
57364
58111
  // src/gui/ai/transcribe.ts
@@ -57396,7 +58143,7 @@ async function transcribe(opts) {
57396
58143
  }
57397
58144
 
57398
58145
  // src/gui/ai/oauth.ts
57399
- import { createHash as createHash6, randomBytes as randomBytes7 } from "crypto";
58146
+ import { createHash as createHash7, randomBytes as randomBytes8 } from "crypto";
57400
58147
  function readOAuthConfig(env2 = process.env) {
57401
58148
  const authorizeUrl = env2.ANTHROPIC_OAUTH_AUTHORIZE_URL;
57402
58149
  const tokenUrl = env2.ANTHROPIC_OAUTH_TOKEN_URL;
@@ -57410,13 +58157,13 @@ function oauthConfigured(env2 = process.env) {
57410
58157
  return readOAuthConfig(env2) !== null;
57411
58158
  }
57412
58159
  function generatePkceVerifier() {
57413
- return randomBytes7(48).toString("base64url");
58160
+ return randomBytes8(48).toString("base64url");
57414
58161
  }
57415
58162
  function pkceChallengeFor(verifier) {
57416
- return createHash6("sha256").update(verifier).digest("base64url");
58163
+ return createHash7("sha256").update(verifier).digest("base64url");
57417
58164
  }
57418
58165
  function generateState() {
57419
- return randomBytes7(24).toString("base64url");
58166
+ return randomBytes8(24).toString("base64url");
57420
58167
  }
57421
58168
  function buildAuthorizeUrl(cfg, state2, codeChallenge) {
57422
58169
  const params = new URLSearchParams({
@@ -57837,15 +58584,37 @@ var REGISTRY = [
57837
58584
  category: "read",
57838
58585
  args: obj({})
57839
58586
  },
58587
+ {
58588
+ name: "lattice_help",
58589
+ description: "Look up how LATTICE ITSELF works in its documentation \u2014 features and usage (private mode, row/table sharing & visibility, cloud workspaces & members & invites, files/ingest, the data model, the assistant, history/undo, secrets, analytics). Use this whenever the user asks what a Lattice feature is or how to do something IN Lattice \u2014 NOT for questions about their own data. Returns the most relevant documentation sections.",
58590
+ mutates: false,
58591
+ category: "read",
58592
+ args: obj(
58593
+ {
58594
+ query: str(
58595
+ 'What about Lattice to look up, e.g. "what is private mode" or "how do I invite a member".'
58596
+ )
58597
+ },
58598
+ ["query"]
58599
+ )
58600
+ },
57840
58601
  {
57841
58602
  name: "list_rows",
57842
- description: "List rows in a table. Omits soft-deleted rows unless includeDeleted is true.",
58603
+ description: "List rows in a table (paginated, max 200/page). For a large table, page through it with limit + successive offsets instead of trying to read it all at once. Omits soft-deleted rows unless includeDeleted is true.",
57843
58604
  mutates: false,
57844
58605
  category: "read",
57845
58606
  args: obj(
57846
58607
  {
57847
58608
  table: str("Table name to list rows from."),
57848
- includeDeleted: { type: "boolean", description: "Include soft-deleted rows." }
58609
+ includeDeleted: { type: "boolean", description: "Include soft-deleted rows." },
58610
+ limit: {
58611
+ type: "number",
58612
+ description: "Max rows to return (1\u2013200, default 200). Use a smaller page for big tables."
58613
+ },
58614
+ offset: {
58615
+ type: "number",
58616
+ description: "Rows to skip from the start \u2014 combine with limit to page through a table."
58617
+ }
57849
58618
  },
57850
58619
  ["table"]
57851
58620
  )
@@ -58134,12 +58903,120 @@ function getFunction(name) {
58134
58903
  return BY_NAME.get(name);
58135
58904
  }
58136
58905
 
58906
+ // src/gui/ai/lattice-docs.ts
58907
+ import { readFileSync as readFileSync16, readdirSync as readdirSync7, existsSync as existsSync20 } from "fs";
58908
+ import { dirname as dirname11, join as join24 } from "path";
58909
+ import { fileURLToPath as fileURLToPath2 } from "url";
58910
+ var _docsDir;
58911
+ function findDocsDir() {
58912
+ if (_docsDir !== void 0) return _docsDir;
58913
+ let dir;
58914
+ try {
58915
+ dir = dirname11(fileURLToPath2(import.meta.url));
58916
+ } catch {
58917
+ dir = process.cwd();
58918
+ }
58919
+ for (let i6 = 0; i6 < 8; i6++) {
58920
+ const candidate = join24(dir, "docs");
58921
+ if (existsSync20(join24(candidate, "cloud.md"))) {
58922
+ _docsDir = candidate;
58923
+ return _docsDir;
58924
+ }
58925
+ const parent = dirname11(dir);
58926
+ if (parent === dir) break;
58927
+ dir = parent;
58928
+ }
58929
+ _docsDir = null;
58930
+ return _docsDir;
58931
+ }
58932
+ var MAX_SECTION_CHARS = 2400;
58933
+ var _cache = /* @__PURE__ */ new Map();
58934
+ function sectionsOf(file, md) {
58935
+ const lines = md.split("\n");
58936
+ const out = [];
58937
+ let heading = file.replace(/\.md$/, "");
58938
+ let buf = [];
58939
+ const flush2 = () => {
58940
+ const text = buf.join("\n").trim();
58941
+ if (text) out.push({ file, heading, text: text.slice(0, MAX_SECTION_CHARS) });
58942
+ buf = [];
58943
+ };
58944
+ for (const line of lines) {
58945
+ const m4 = /^#{1,3}\s+(.+)$/.exec(line);
58946
+ if (m4) {
58947
+ flush2();
58948
+ heading = (m4[1] ?? heading).trim();
58949
+ }
58950
+ buf.push(line);
58951
+ }
58952
+ flush2();
58953
+ return out;
58954
+ }
58955
+ function allSections() {
58956
+ const dir = findDocsDir();
58957
+ if (!dir) return [];
58958
+ const key = dir;
58959
+ const cached = _cache.get(key);
58960
+ if (cached) return cached;
58961
+ const out = [];
58962
+ let files = [];
58963
+ try {
58964
+ files = readdirSync7(dir).filter((f6) => f6.endsWith(".md"));
58965
+ } catch {
58966
+ files = [];
58967
+ }
58968
+ for (const f6 of files) {
58969
+ try {
58970
+ out.push(...sectionsOf(f6, readFileSync16(join24(dir, f6), "utf8")));
58971
+ } catch {
58972
+ }
58973
+ }
58974
+ _cache.set(key, out);
58975
+ return out;
58976
+ }
58977
+ function searchLatticeDocs(query, limit = 4) {
58978
+ const sections = allSections();
58979
+ if (sections.length === 0) {
58980
+ return {
58981
+ sections: [],
58982
+ note: "Lattice documentation is not bundled with this build; answer only from what you reliably know about Lattice, and say if you are unsure."
58983
+ };
58984
+ }
58985
+ const q3 = query.toLowerCase().trim();
58986
+ const terms = q3.split(/[^a-z0-9]+/).filter((w2) => w2.length > 2);
58987
+ if (terms.length === 0) {
58988
+ return { sections: [], available: [...new Set(sections.map((s2) => s2.heading))].slice(0, 40) };
58989
+ }
58990
+ const scored = sections.map((s2) => {
58991
+ const head = s2.heading.toLowerCase();
58992
+ const body = s2.text.toLowerCase();
58993
+ let score = 0;
58994
+ if (body.includes(q3)) score += 4;
58995
+ for (const t8 of terms) {
58996
+ if (head.includes(t8)) score += 3;
58997
+ if (body.includes(t8)) score += 1;
58998
+ }
58999
+ return { s: s2, score };
59000
+ }).filter((x2) => x2.score > 0).sort((a6, b6) => b6.score - a6.score);
59001
+ if (scored.length === 0) {
59002
+ return { sections: [], available: [...new Set(sections.map((s2) => s2.heading))].slice(0, 40) };
59003
+ }
59004
+ return {
59005
+ sections: scored.slice(0, limit).map((x2) => ({
59006
+ source: x2.s.file,
59007
+ heading: x2.s.heading,
59008
+ text: x2.s.text
59009
+ }))
59010
+ };
59011
+ }
59012
+
58137
59013
  // src/gui/ai/dispatch.ts
58138
59014
  var DISPATCHABLE = /* @__PURE__ */ new Set([
58139
59015
  "list_entities",
58140
59016
  "list_rows",
58141
59017
  "get_row",
58142
59018
  "search",
59019
+ "lattice_help",
58143
59020
  "get_history",
58144
59021
  "create_row",
58145
59022
  "update_row",
@@ -58215,7 +59092,13 @@ async function executeFunction(ctx, name, args) {
58215
59092
  const includeDeleted = args.includeDeleted === true;
58216
59093
  const cols = ctx.db.getRegisteredColumns(table);
58217
59094
  const orderBy = cols && "created_at" in cols ? "created_at" : ctx.db.getPrimaryKey(table)[0] ?? "id";
58218
- const opts = { limit: 200, orderBy, orderDir: "asc" };
59095
+ const limit = Math.min(
59096
+ 200,
59097
+ Math.max(1, typeof args.limit === "number" ? Math.floor(args.limit) : 200)
59098
+ );
59099
+ const offset = Math.max(0, typeof args.offset === "number" ? Math.floor(args.offset) : 0);
59100
+ const opts = { limit, orderBy, orderDir: "asc" };
59101
+ if (offset > 0) opts.offset = offset;
58219
59102
  if (ctx.softDeletable.has(table) && !includeDeleted) {
58220
59103
  opts.filters = [{ col: "deleted_at", op: "isNull" }];
58221
59104
  }
@@ -58230,6 +59113,10 @@ async function executeFunction(ctx, name, args) {
58230
59113
  if (row === null) return { ok: false, error: "Row not found" };
58231
59114
  return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
58232
59115
  }
59116
+ case "lattice_help": {
59117
+ const query = requireString(args.query, "query");
59118
+ return { ok: true, result: searchLatticeDocs(query) };
59119
+ }
58233
59120
  case "search": {
58234
59121
  const query = requireString(args.query, "query");
58235
59122
  let tables = [...ctx.validTables];
@@ -58400,6 +59287,32 @@ function capToolResult(s2) {
58400
59287
  function capToolInput(input) {
58401
59288
  return JSON.stringify(input).length > MAX_TOOL_INPUT_CHARS ? { _truncated: true } : input;
58402
59289
  }
59290
+ var LIVE_TOOL_RESULT_CHARS = 16e3;
59291
+ function capLiveToolResult(s2) {
59292
+ if (s2.length <= LIVE_TOOL_RESULT_CHARS) return s2;
59293
+ return s2.slice(0, LIVE_TOOL_RESULT_CHARS) + `
59294
+ \u2026[truncated ${String(
59295
+ s2.length - LIVE_TOOL_RESULT_CHARS
59296
+ )} chars \u2014 this result was too large to include in full. Read it in smaller pieces: list_rows with a smaller limit + offset, or a narrower filter.]`;
59297
+ }
59298
+ var MAX_CONTEXT_RECOVERY_TRIMS = 8;
59299
+ var TRIMMED_PLACEHOLDER = "[earlier tool result omitted to fit the context window]";
59300
+ function isContextLengthError(e6) {
59301
+ const msg = (e6 instanceof Error ? e6.message : String(e6)).toLowerCase();
59302
+ return msg.includes("prompt is too long") || msg.includes("context length") || msg.includes("context_length") || msg.includes("context window") || msg.includes("too many tokens") || msg.includes("maximum") && msg.includes("token");
59303
+ }
59304
+ function trimOldestToolResult(messages) {
59305
+ for (const m4 of messages) {
59306
+ if (m4.role !== "user" || !Array.isArray(m4.content)) continue;
59307
+ for (const b6 of m4.content) {
59308
+ if (b6.type === "tool_result" && typeof b6.content === "string" && b6.content.length > TRIMMED_PLACEHOLDER.length && b6.content !== TRIMMED_PLACEHOLDER) {
59309
+ b6.content = TRIMMED_PLACEHOLDER;
59310
+ return true;
59311
+ }
59312
+ }
59313
+ }
59314
+ return false;
59315
+ }
58403
59316
  var BASE_SYSTEM_PROMPT = [
58404
59317
  "You are the assistant inside a Lattice database GUI. Help the user inspect and edit their data by calling the provided tools.",
58405
59318
  "",
@@ -58408,8 +59321,10 @@ var BASE_SYSTEM_PROMPT = [
58408
59321
  "- To relate two tables (link their rows), call create_relationship(table_a, table_b) to get a junction + its two foreign-key columns, then `link` each pair using those columns. If the junction already exists, just `link`.",
58409
59322
  "- Use the exact table names from the schema (or one you just created) \u2014 never guess a name for a table that should already exist.",
58410
59323
  "- Prefer reading (list_rows, get_row) before writing.",
59324
+ '- Work in small batches on large tables. NEVER try to load an entire big table at once \u2014 page through it with list_rows using `limit` + successive `offset` values, and process bulk edits a page at a time. If a tool result says it was truncated, do NOT re-request the whole thing; narrow it (a filter, or a smaller limit/offset) and continue. Use the row counts under "Current database" to decide how many pages you need.',
58411
59325
  '- When you point the user at a specific row/object \u2014 especially if they ask you to "link", "open", or "show" it \u2014 make it clickable with an INLINE link in this exact form: [short label](lattice://<table>/<id>), using the real table name and the row id from your tool results (e.g. [the offer contract](lattice://contracts/9b7c60f0-fbc2-4f87-a550-c59e3c5d761f)). It renders as a pill that opens that object in the GUI. Only link ids you actually retrieved \u2014 never invent one \u2014 and prefer the user-facing record (the contract/person/etc. row) over an internal `files` id.',
58412
59326
  "- Attached files are rows in the `files` table; a file's full text content (CSV, document, etc.) is in its `extracted_text` column. To work from an attached file, read the relevant `files` row(s) and parse `extracted_text` \u2014 never guess a file's contents.",
59327
+ `- When the user asks about LATTICE ITSELF \u2014 what a feature is or how to use it (e.g. "what is private mode", "how does sharing work", "how do I invite someone") \u2014 call lattice_help with their question and answer from what it returns. Do NOT answer such questions from memory, and do NOT search the user's data for them.`,
58413
59328
  '- A tool result that contains "error" means the call FAILED. Do NOT claim success or proceed as if it returned data \u2014 read the error, correct your arguments, and retry.',
58414
59329
  "- For bulk work, emit several tool calls in one turn instead of one at a time. Every change is recorded in version history and can be undone.",
58415
59330
  "- When you change data, briefly confirm what you did. Be concise."
@@ -58435,7 +59350,7 @@ async function buildSchemaContext(d6) {
58435
59350
  }
58436
59351
  return lines.join("\n");
58437
59352
  }
58438
- function buildSystemPrompt(schema, operatorName, cloudSystemPrompt) {
59353
+ function buildSystemPrompt(schema, operatorName, cloudSystemPrompt, activeContext) {
58439
59354
  const who = operatorName && operatorName.trim().length > 0 ? `
58440
59355
 
58441
59356
  # Who you are assisting
@@ -58444,7 +59359,11 @@ You are assisting ${operatorName.trim()}. When the user says "me" / "my", they m
58444
59359
 
58445
59360
  # Workspace instructions
58446
59361
  ${cloudSystemPrompt.trim()}` : "";
58447
- return `${BASE_SYSTEM_PROMPT}${who}${workspace}
59362
+ const view = activeContext?.table && activeContext.id ? `
59363
+
59364
+ # What the user is viewing
59365
+ The user is currently viewing the "${activeContext.table}" record with id "${activeContext.id}". When they say "this", "this file", "this row", "this record", "it", or similar without naming a specific record, they mean THAT one \u2014 operate on it directly (read it with get_row, change or delete it by that id) rather than asking which record they mean.` : "";
59366
+ return `${BASE_SYSTEM_PROMPT}${who}${workspace}${view}
58448
59367
 
58449
59368
  # Current database
58450
59369
  ${schema}`;
@@ -58462,21 +59381,34 @@ async function* runChat(opts) {
58462
59381
  const system = buildSystemPrompt(
58463
59382
  await buildSchemaContext(opts.dispatch),
58464
59383
  opts.operatorName,
58465
- opts.cloudSystemPrompt
59384
+ opts.cloudSystemPrompt,
59385
+ opts.activeContext
58466
59386
  );
58467
59387
  let loop = 0;
58468
59388
  try {
58469
59389
  for (; loop < MAX_TOOL_LOOPS; loop++) {
58470
59390
  const deltas = [];
58471
59391
  yield { type: "assistant_message_start", id: `m${String(loop)}` };
58472
- const turn = await opts.client.runTurn({
58473
- model,
58474
- system,
58475
- messages,
58476
- tools,
58477
- ...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
58478
- onText: (d6) => deltas.push(d6)
58479
- });
59392
+ let turn;
59393
+ for (let trims = 0; ; trims++) {
59394
+ deltas.length = 0;
59395
+ try {
59396
+ turn = await opts.client.runTurn({
59397
+ model,
59398
+ system,
59399
+ messages,
59400
+ tools,
59401
+ ...opts.temperature !== void 0 ? { temperature: opts.temperature } : {},
59402
+ onText: (d6) => deltas.push(d6)
59403
+ });
59404
+ break;
59405
+ } catch (e6) {
59406
+ if (trims < MAX_CONTEXT_RECOVERY_TRIMS && isContextLengthError(e6) && trimOldestToolResult(messages)) {
59407
+ continue;
59408
+ }
59409
+ throw e6;
59410
+ }
59411
+ }
58480
59412
  for (const d6 of deltas) yield { type: "text_delta", delta: d6 };
58481
59413
  yield { type: "assistant_message_end" };
58482
59414
  const assistantBlocks = [];
@@ -58491,7 +59423,8 @@ async function* runChat(opts) {
58491
59423
  yield { type: "tool_use", id: tu.id, name: tu.name };
58492
59424
  const res = await executeFunction(opts.dispatch, tu.name, tu.input);
58493
59425
  yield { type: "tool_result", toolUseId: tu.id, isError: !res.ok };
58494
- const content = JSON.stringify(res.ok ? res.result : { error: res.error });
59426
+ const rawContent = JSON.stringify(res.ok ? res.result : { error: res.error });
59427
+ const content = capLiveToolResult(rawContent);
58495
59428
  resultBlocks.push({
58496
59429
  type: "tool_result",
58497
59430
  tool_use_id: tu.id,
@@ -58502,7 +59435,7 @@ async function* runChat(opts) {
58502
59435
  id: tu.id,
58503
59436
  name: tu.name,
58504
59437
  input: capToolInput(tu.input),
58505
- content: capToolResult(content),
59438
+ content: capToolResult(rawContent),
58506
59439
  isError: !res.ok
58507
59440
  });
58508
59441
  }
@@ -58515,7 +59448,10 @@ async function* runChat(opts) {
58515
59448
  };
58516
59449
  }
58517
59450
  } catch (e6) {
58518
- yield { type: "error", message: e6.message };
59451
+ const raw = e6 instanceof Error ? e6.message : String(e6);
59452
+ console.error("[chat] turn failed:", raw);
59453
+ const message = isContextLengthError(e6) ? "That request was too large for me to process in one step, even after trimming older context. Try narrowing it, or start a new chat \u2014 your data is safe." : raw;
59454
+ yield { type: "error", message };
58519
59455
  }
58520
59456
  yield { type: "done" };
58521
59457
  }
@@ -58796,6 +59732,16 @@ function readJson3(req) {
58796
59732
  req.on("error", reject);
58797
59733
  });
58798
59734
  }
59735
+ function parseActiveContext(raw, validTables) {
59736
+ if (!raw || typeof raw !== "object") return void 0;
59737
+ const table = raw.table;
59738
+ const id = raw.id;
59739
+ if (typeof table !== "string" || typeof id !== "string") return void 0;
59740
+ if (!validTables.has(table)) return void 0;
59741
+ const trimmedId = id.trim();
59742
+ if (trimmedId.length === 0 || trimmedId.length > 256) return void 0;
59743
+ return { table, id: trimmedId };
59744
+ }
58799
59745
  function mapHistory(raw) {
58800
59746
  if (!Array.isArray(raw)) return [];
58801
59747
  const out = [];
@@ -59014,6 +59960,7 @@ async function dispatchChatRoute(req, res, ctx) {
59014
59960
  return true;
59015
59961
  }
59016
59962
  const requestedThread = typeof body.threadId === "string" ? body.threadId : null;
59963
+ const activeContext = parseActiveContext(body.activeContext, ctx.validTables);
59017
59964
  const history = await rehydrateHistory(ctx.db, requestedThread, mapHistory(body.history), null);
59018
59965
  let threadId = "";
59019
59966
  try {
@@ -59071,6 +60018,7 @@ async function dispatchChatRoute(req, res, ctx) {
59071
60018
  // resolves "me"/"my" without asking for a name it already has.
59072
60019
  operatorName: readIdentity().display_name,
59073
60020
  ...cloudSystemPrompt ? { cloudSystemPrompt } : {},
60021
+ ...activeContext ? { activeContext } : {},
59074
60022
  // Capture each executed tool call (capped) for cross-turn replay memory.
59075
60023
  onToolRecord: (rec) => {
59076
60024
  turns[turns.length - 1]?.toolCalls.push(rec);
@@ -59149,7 +60097,7 @@ async function dispatchChatRoute(req, res, ctx) {
59149
60097
  import { statSync as statSync7 } from "fs";
59150
60098
  import { writeFile as writeFile2, rm } from "fs/promises";
59151
60099
  import { tmpdir as tmpdir2 } from "os";
59152
- import { basename as basename10, extname as extname2, resolve as resolve8, join as join25 } from "path";
60100
+ import { basename as basename10, extname as extname2, resolve as resolve8, join as join26 } from "path";
59153
60101
 
59154
60102
  // src/gui/ai/extract.ts
59155
60103
  import { readFile as readFile5 } from "fs/promises";
@@ -60100,12 +61048,12 @@ async function renderViaPlaywright(url, timeoutMs) {
60100
61048
  }
60101
61049
 
60102
61050
  // src/framework/blob-store.ts
60103
- import { createHash as createHash7 } from "crypto";
60104
- import { createReadStream as createReadStream2, existsSync as existsSync20, mkdirSync as mkdirSync9, statSync as statSync6, copyFileSync as copyFileSync3 } from "fs";
60105
- import { basename as basename9, join as join24 } from "path";
61051
+ import { createHash as createHash8 } from "crypto";
61052
+ import { createReadStream as createReadStream2, existsSync as existsSync21, mkdirSync as mkdirSync9, statSync as statSync6, copyFileSync as copyFileSync3 } from "fs";
61053
+ import { basename as basename9, join as join25 } from "path";
60106
61054
  async function hashFile(srcPath) {
60107
61055
  return new Promise((resolve11, reject) => {
60108
- const hash = createHash7("sha256");
61056
+ const hash = createHash8("sha256");
60109
61057
  const stream = createReadStream2(srcPath);
60110
61058
  stream.on("data", (chunk) => hash.update(chunk));
60111
61059
  stream.on("error", reject);
@@ -60120,10 +61068,10 @@ async function attachBlob(srcPath, latticeRoot) {
60120
61068
  throw new Error(`attachBlob: ${srcPath} is not a regular file`);
60121
61069
  }
60122
61070
  const sha256 = await hashFile(srcPath);
60123
- const blobDir = join24(latticeRoot, "data", "blobs");
61071
+ const blobDir = join25(latticeRoot, "data", "blobs");
60124
61072
  mkdirSync9(blobDir, { recursive: true });
60125
- const destAbs = join24(blobDir, sha256);
60126
- if (!existsSync20(destAbs)) {
61073
+ const destAbs = join25(blobDir, sha256);
61074
+ if (!existsSync21(destAbs)) {
60127
61075
  copyFileSync3(srcPath, destAbs);
60128
61076
  }
60129
61077
  return {
@@ -60137,7 +61085,7 @@ async function attachBlob(srcPath, latticeRoot) {
60137
61085
  }
60138
61086
 
60139
61087
  // src/gui/ingest-routes.ts
60140
- import { createHash as createHash8 } from "crypto";
61088
+ import { createHash as createHash9 } from "crypto";
60141
61089
  function fileSlug(name, id) {
60142
61090
  const base = slugify(name.replace(/\.[^./\\]+$/, "")) || "file";
60143
61091
  return `${base}-${id.slice(0, 8)}`;
@@ -60287,11 +61235,23 @@ function buildSchema(db) {
60287
61235
  async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptions, createJunction, aggressiveness = DEFAULT_AGGRESSIVENESS, createEntity) {
60288
61236
  if (!text.trim()) return [];
60289
61237
  const auth = await resolveClaudeAuth(db);
60290
- if (!auth) return [];
61238
+ if (!auth) {
61239
+ console.warn("[ingest] auto-link skipped \u2014 no AI credentials configured");
61240
+ return [];
61241
+ }
60291
61242
  let client;
60292
61243
  try {
60293
61244
  client = createAnthropicClient(auth);
60294
- } catch {
61245
+ } catch (e6) {
61246
+ const msg = e6.message;
61247
+ console.error("[ingest] auto-link unavailable \u2014 Anthropic client init failed:", msg);
61248
+ mctx.feed.publish({
61249
+ table: "files",
61250
+ op: "update",
61251
+ rowId: fileId,
61252
+ source: "ingest",
61253
+ summary: `Couldn't auto-link "${name}": AI client unavailable`
61254
+ });
60295
61255
  return [];
60296
61256
  }
60297
61257
  const temperature = aggressivenessToTemperature(aggressiveness);
@@ -60322,10 +61282,15 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
60322
61282
  created = true;
60323
61283
  }
60324
61284
  } catch (e6) {
60325
- console.warn(
60326
- `[ingest] auto-create junction files\u2194${m4.table} failed:`,
60327
- e6.message
60328
- );
61285
+ const msg = e6.message;
61286
+ console.error(`[ingest] auto-create junction files\u2194${m4.table} failed:`, msg);
61287
+ mctx.feed.publish({
61288
+ table: "files",
61289
+ op: "update",
61290
+ rowId: fileId,
61291
+ source: "ingest",
61292
+ summary: `Couldn't create link table files \u2194 ${m4.table}: ${msg}`
61293
+ });
60329
61294
  }
60330
61295
  }
60331
61296
  if (jx) {
@@ -60346,7 +61311,15 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
60346
61311
  });
60347
61312
  }
60348
61313
  } catch (e6) {
60349
- console.warn(`[ingest] auto-link to ${m4.table} failed:`, e6.message);
61314
+ const msg = e6.message;
61315
+ console.error(`[ingest] auto-link to ${m4.table} failed:`, msg);
61316
+ mctx.feed.publish({
61317
+ table: "files",
61318
+ op: "update",
61319
+ rowId: fileId,
61320
+ source: "ingest",
61321
+ summary: `Couldn't auto-link "${name}" to ${m4.table}: ${msg}`
61322
+ });
60350
61323
  }
60351
61324
  } else {
60352
61325
  mctx.feed.publish({
@@ -60420,7 +61393,15 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
60420
61393
  }
60421
61394
  return matches;
60422
61395
  } catch (e6) {
60423
- console.warn("[ingest] classify failed:", e6.message);
61396
+ const msg = e6.message;
61397
+ console.error("[ingest] classify failed:", msg);
61398
+ mctx.feed.publish({
61399
+ table: "files",
61400
+ op: "update",
61401
+ rowId: fileId,
61402
+ source: "ingest",
61403
+ summary: `Couldn't auto-link "${name}": ${msg}`
61404
+ });
60424
61405
  return [];
60425
61406
  }
60426
61407
  }
@@ -60536,7 +61517,7 @@ async function dispatchIngestRoute(req, res, ctx) {
60536
61517
  sendJson4(res, { error: "empty upload" }, 400);
60537
61518
  return true;
60538
61519
  }
60539
- const tmp = join25(tmpdir2(), `lattice-ingest-${crypto.randomUUID()}${extname2(name2)}`);
61520
+ const tmp = join26(tmpdir2(), `lattice-ingest-${crypto.randomUUID()}${extname2(name2)}`);
60540
61521
  let result;
60541
61522
  let blob = null;
60542
61523
  try {
@@ -60557,7 +61538,7 @@ async function dispatchIngestRoute(req, res, ctx) {
60557
61538
  let s3Status = null;
60558
61539
  const s3cfg = resolveActiveS3Config(ctx.configPath);
60559
61540
  if (s3cfg) {
60560
- const sha256 = blob?.sha256 ?? createHash8("sha256").update(buf).digest("hex");
61541
+ const sha256 = blob?.sha256 ?? createHash9("sha256").update(buf).digest("hex");
60561
61542
  const key = s3Key(s3cfg.prefix, sha256);
60562
61543
  try {
60563
61544
  const store = await createS3Store(s3cfg);
@@ -60969,6 +61950,8 @@ async function entitiesWithCounts(db, configPath, outputDir) {
60969
61950
  const policy = policies[t8.name];
60970
61951
  t8.defaultRowVisibility = policy?.defaultRowVisibility ?? "private";
60971
61952
  t8.neverShare = policy?.neverShare ?? false;
61953
+ t8.ownedByMe = true;
61954
+ t8.shared = t8.defaultRowVisibility === "everyone";
60972
61955
  }
60973
61956
  }
60974
61957
  return { ...payload, tables: enrichedTables };
@@ -61027,6 +62010,22 @@ var CONTEXT_PATH = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/context$/;
61027
62010
  var ROW_HISTORY_PATH = /^\/api\/tables\/([^/]+)\/rows\/([^/]+)\/history$/;
61028
62011
  var LAST_EDITED_PATH = /^\/api\/tables\/([^/]+)\/last-edited$/;
61029
62012
  var LINK_PATH = /^\/api\/tables\/([^/]+)\/(link|unlink)$/;
62013
+ function headerValue(req, name) {
62014
+ const raw = req.headers[name.toLowerCase()];
62015
+ const v2 = Array.isArray(raw) ? raw[0] : raw;
62016
+ const trimmed = typeof v2 === "string" ? v2.trim() : "";
62017
+ return trimmed.length > 0 ? trimmed : void 0;
62018
+ }
62019
+ var MAX_ROWS_PAGE = 1e3;
62020
+ var DEFAULT_ROWS_PAGE = 500;
62021
+ function parsePageParam(raw, kind) {
62022
+ if (raw === null) return kind === "limit" ? DEFAULT_ROWS_PAGE : 0;
62023
+ if (!/^\d+$/.test(raw.trim())) return "invalid";
62024
+ const n3 = Number(raw);
62025
+ if (!Number.isFinite(n3)) return "invalid";
62026
+ if (kind === "limit") return Math.min(Math.max(1, n3), MAX_ROWS_PAGE);
62027
+ return Math.max(0, n3);
62028
+ }
61030
62029
  function deriveSlugFromManifest(row, knownSlugs) {
61031
62030
  const candidateFields = ["slug", "id", "name"];
61032
62031
  for (const field of candidateFields) {
@@ -61064,10 +62063,10 @@ function readRowContext(outputDir, locator, secretCols) {
61064
62063
  throw new Error(`Path traversal detected: slug "${slug}" escapes output directory`);
61065
62064
  }
61066
62065
  return fileNames.map((filename) => {
61067
- const absPath = join26(entityDir, filename);
62066
+ const absPath = join27(entityDir, filename);
61068
62067
  const relPath = [directoryRoot, slug, filename].join("/");
61069
- if (!existsSync21(absPath)) return { name: filename, path: relPath, content: "" };
61070
- let content = readFileSync16(absPath, "utf8");
62068
+ if (!existsSync22(absPath)) return { name: filename, path: relPath, content: "" };
62069
+ let content = readFileSync17(absPath, "utf8");
61071
62070
  for (const col of secretCols) {
61072
62071
  const re = new RegExp(`^(${col}):.*$`, "gm");
61073
62072
  content = content.replace(re, `$1: \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022`);
@@ -61076,17 +62075,17 @@ function readRowContext(outputDir, locator, secretCols) {
61076
62075
  });
61077
62076
  }
61078
62077
  function resolveOutputDirForConfig(configPath) {
61079
- const base = dirname11(resolve9(configPath));
62078
+ const base = dirname12(resolve9(configPath));
61080
62079
  for (const dir of ["context", ".", "generated"]) {
61081
62080
  const abs = resolve9(base, dir);
61082
- if (existsSync21(join26(abs, ".lattice", "manifest.json"))) return abs;
62081
+ if (existsSync22(join27(abs, ".lattice", "manifest.json"))) return abs;
61083
62082
  }
61084
62083
  return resolve9(base, "context");
61085
62084
  }
61086
62085
  async function openConfig(configPath, outputDir, autoRender = false) {
61087
62086
  const parsed = parseConfigFile(configPath);
61088
62087
  if (!/^postgres(ql)?:\/\//i.test(parsed.dbPath) && !parsed.dbPath.startsWith("file:") && parsed.dbPath !== ":memory:") {
61089
- mkdirSync10(dirname11(parsed.dbPath), { recursive: true });
62088
+ mkdirSync10(dirname12(parsed.dbPath), { recursive: true });
61090
62089
  }
61091
62090
  const encryptionKey = getOrCreateMasterKey();
61092
62091
  const db = new Lattice({ config: configPath }, { encryptionKey });
@@ -61159,6 +62158,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61159
62158
  }
61160
62159
  }
61161
62160
  let memberOpen = false;
62161
+ const maskedReadViews = /* @__PURE__ */ new Map();
61162
62162
  if (db.getDialect() === "postgres") {
61163
62163
  const peek = new Lattice({ config: configPath }, { encryptionKey });
61164
62164
  try {
@@ -61167,7 +62167,9 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61167
62167
  memberOpen = !await canManageRoles(peek);
61168
62168
  if (memberOpen) {
61169
62169
  const declared = new Set(db.getRegisteredTableNames());
61170
- for (const t8 of await discoverCloudTables(peek)) {
62170
+ const discovered = await discoverCloudTables(peek);
62171
+ const knownTables = /* @__PURE__ */ new Set([...declared, ...discovered.map((t8) => t8.name)]);
62172
+ for (const t8 of discovered) {
61171
62173
  if (declared.has(t8.name)) continue;
61172
62174
  db.define(t8.name, {
61173
62175
  columns: Object.fromEntries(t8.columns.map((c6) => [c6, "TEXT"])),
@@ -61176,6 +62178,15 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61176
62178
  outputFile: `${t8.name}/.lattice/${t8.name}.md`
61177
62179
  });
61178
62180
  }
62181
+ const views = await allAsyncOrSync(
62182
+ peek.adapter,
62183
+ `SELECT table_name AS name FROM information_schema.views
62184
+ WHERE table_schema = current_schema() AND table_name LIKE '%\\_v' ESCAPE '\\'`
62185
+ );
62186
+ for (const { name } of views) {
62187
+ const base = name.slice(0, -2);
62188
+ if (knownTables.has(base)) maskedReadViews.set(base, name);
62189
+ }
61179
62190
  }
61180
62191
  }
61181
62192
  } catch {
@@ -61185,8 +62196,22 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61185
62196
  }
61186
62197
  await db.init(memberOpen ? { introspectOnly: true } : {});
61187
62198
  await syncUserIdentityRow(db);
61188
- await adoptNativeEntities(db);
61189
- await retireLegacyPreferenceSecrets(db);
62199
+ if (!memberOpen) {
62200
+ await adoptNativeEntities(db);
62201
+ await retireLegacyPreferenceSecrets(db);
62202
+ }
62203
+ if (db.getDialect() === "postgres" && !memberOpen) {
62204
+ try {
62205
+ if (await cloudRlsInstalled(db) && await canManageRoles(db)) {
62206
+ await installCloudRls(db);
62207
+ await installCloudSettings(db);
62208
+ await db.ensureObservationSubstrate();
62209
+ await enableChangelogRls(db);
62210
+ }
62211
+ } catch (e6) {
62212
+ console.error("[openConfig] cloud bootstrap converge failed:", e6.message);
62213
+ }
62214
+ }
61190
62215
  const validTables = new Set(parsed.tables.map((t8) => t8.name));
61191
62216
  for (const name of db.getRegisteredTableNames()) {
61192
62217
  if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
@@ -61222,7 +62247,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61222
62247
  }
61223
62248
  if (autoRender) {
61224
62249
  db.enableAutoRender(outputDir);
61225
- if (!existsSync21(manifestPath(outputDir))) {
62250
+ if (!existsSync22(manifestPath(outputDir))) {
61226
62251
  writeManifest(outputDir, {
61227
62252
  version: 2,
61228
62253
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -61245,7 +62270,8 @@ async function openConfig(configPath, outputDir, autoRender = false) {
61245
62270
  autoRender,
61246
62271
  renderProgress: new RenderProgressBus(),
61247
62272
  renderAbort: new AbortController(),
61248
- renderState: { phase: "idle", tables: {} }
62273
+ renderState: { phase: "idle", tables: {} },
62274
+ maskedReadViews
61249
62275
  };
61250
62276
  }
61251
62277
  function friendlyConfigName(parsedName, configPath) {
@@ -61253,11 +62279,11 @@ function friendlyConfigName(parsedName, configPath) {
61253
62279
  return basename11(configPath).replace(/\.(ya?ml)$/, "");
61254
62280
  }
61255
62281
  function listConfigs(activeConfigPath) {
61256
- const dir = dirname11(activeConfigPath);
62282
+ const dir = dirname12(activeConfigPath);
61257
62283
  const entries = [];
61258
- for (const fname of readdirSync7(dir)) {
62284
+ for (const fname of readdirSync8(dir)) {
61259
62285
  if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
61260
- const full = join26(dir, fname);
62286
+ const full = join27(dir, fname);
61261
62287
  try {
61262
62288
  const parsed = parseConfigFile(full);
61263
62289
  entries.push({
@@ -61282,37 +62308,37 @@ function listConfigs(activeConfigPath) {
61282
62308
  return entries.sort((a6, b6) => a6.label.localeCompare(b6.label));
61283
62309
  }
61284
62310
  function createBlankConfig(activeConfigPath, dbName) {
61285
- const dir = dirname11(activeConfigPath);
62311
+ const dir = dirname12(activeConfigPath);
61286
62312
  const slug = dbName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
61287
62313
  if (!slug) throw new Error("Workspace name must contain at least one alphanumeric character");
61288
- const configPath = join26(dir, `${slug}.config.yml`);
61289
- if (existsSync21(configPath)) throw new Error(`Config already exists: ${slug}.config.yml`);
62314
+ const configPath = join27(dir, `${slug}.config.yml`);
62315
+ if (existsSync22(configPath)) throw new Error(`Config already exists: ${slug}.config.yml`);
61290
62316
  const yaml = `db: ./data/${slug}.db
61291
62317
 
61292
62318
  entities: {}
61293
62319
  `;
61294
62320
  writeFileSync8(configPath, yaml, "utf8");
61295
- mkdirSync10(join26(dir, "data"), { recursive: true });
62321
+ mkdirSync10(join27(dir, "data"), { recursive: true });
61296
62322
  return configPath;
61297
62323
  }
61298
62324
  function sqliteFileForConfig(configPath) {
61299
- const dbVal = parseDocument3(readFileSync16(configPath, "utf8")).get("db");
62325
+ const dbVal = parseDocument3(readFileSync17(configPath, "utf8")).get("db");
61300
62326
  const raw = (typeof dbVal === "string" ? dbVal : "").trim();
61301
62327
  if (!raw) return null;
61302
62328
  if (isPostgresUrl(raw) || raw.startsWith("${LATTICE_DB:")) return null;
61303
62329
  if (raw === ":memory:" || raw.startsWith("file:")) return null;
61304
- return resolve9(dirname11(configPath), raw);
62330
+ return resolve9(dirname12(configPath), raw);
61305
62331
  }
61306
62332
  function deleteDatabaseFiles(targetConfigPath) {
61307
62333
  const sqliteFile = sqliteFileForConfig(targetConfigPath);
61308
62334
  unlinkSync5(targetConfigPath);
61309
62335
  let deletedDbFile = null;
61310
- if (sqliteFile && existsSync21(sqliteFile)) {
62336
+ if (sqliteFile && existsSync22(sqliteFile)) {
61311
62337
  unlinkSync5(sqliteFile);
61312
62338
  deletedDbFile = sqliteFile;
61313
62339
  for (const suffix of ["-wal", "-shm", "-journal"]) {
61314
62340
  const sidecar = sqliteFile + suffix;
61315
- if (existsSync21(sidecar)) unlinkSync5(sidecar);
62341
+ if (existsSync22(sidecar)) unlinkSync5(sidecar);
61316
62342
  }
61317
62343
  }
61318
62344
  return { deletedConfig: basename11(targetConfigPath), deletedDbFile };
@@ -61371,6 +62397,27 @@ function startBackgroundRender(active) {
61371
62397
  }
61372
62398
  );
61373
62399
  }
62400
+ async function changeVisibleToActiveRole(db, payload) {
62401
+ if (db.getDialect() !== "postgres") return true;
62402
+ if (payload.op === "delete" || payload.op === "DELETE") return true;
62403
+ if (!payload.table_name || !payload.pk) return false;
62404
+ try {
62405
+ const row = await getAsyncOrSync(db.adapter, `SELECT lattice_row_visible(?, ?) AS v`, [
62406
+ payload.table_name,
62407
+ payload.pk
62408
+ ]);
62409
+ return row?.v === true || row?.v === "t" || row?.v === 1;
62410
+ } catch (e6) {
62411
+ console.warn("[realtime] visibility probe failed (dropping change):", e6.message);
62412
+ return false;
62413
+ }
62414
+ }
62415
+ function isDeleteOp(op) {
62416
+ return op === "delete" || op === "DELETE";
62417
+ }
62418
+ function readRelationFor(active, table) {
62419
+ return active.maskedReadViews.get(table) ?? table;
62420
+ }
61374
62421
  async function attachRowAccess(db, table, rows) {
61375
62422
  if (rows.length === 0) return;
61376
62423
  const pkCols = db.getPrimaryKey(table);
@@ -61409,20 +62456,24 @@ async function reopenSameConfig(active, autoRender) {
61409
62456
  }
61410
62457
  async function syncUserIdentityRow(db) {
61411
62458
  const identity = readIdentity();
61412
- const existing = await db.get("__lattice_user_identity", "singleton");
61413
- if (existing) {
61414
- await db.update("__lattice_user_identity", "singleton", {
61415
- display_name: identity.display_name,
61416
- email: identity.email,
61417
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
61418
- });
61419
- } else {
61420
- await db.insert("__lattice_user_identity", {
61421
- id: "singleton",
61422
- display_name: identity.display_name,
61423
- email: identity.email,
61424
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
61425
- });
62459
+ try {
62460
+ const existing = await db.get("__lattice_user_identity", "singleton");
62461
+ if (existing) {
62462
+ await db.update("__lattice_user_identity", "singleton", {
62463
+ display_name: identity.display_name,
62464
+ email: identity.email,
62465
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
62466
+ });
62467
+ } else {
62468
+ await db.insert("__lattice_user_identity", {
62469
+ id: "singleton",
62470
+ display_name: identity.display_name,
62471
+ email: identity.email,
62472
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
62473
+ });
62474
+ }
62475
+ } catch (e6) {
62476
+ console.warn("[openConfig] skipped user-identity mirror:", e6.message);
61426
62477
  }
61427
62478
  }
61428
62479
  async function applySchemaConfig(active, entry, direction, autoRender) {
@@ -61536,7 +62587,7 @@ async function startGuiServer(options) {
61536
62587
  const autoRender = options.autoRender ?? false;
61537
62588
  const sessionId = crypto.randomUUID();
61538
62589
  let active = await openConfig(configPath, outputDir, autoRender);
61539
- const latticeRoot = findLatticeRoot(dirname11(configPath));
62590
+ const latticeRoot = findLatticeRoot(dirname12(configPath));
61540
62591
  let currentWorkspaceId = null;
61541
62592
  if (latticeRoot) {
61542
62593
  const launched = listWorkspaces(latticeRoot).find(
@@ -61601,7 +62652,11 @@ data: ${JSON.stringify(data)}
61601
62652
  writeEvent("state", { mode: "cloud", state: state2 });
61602
62653
  });
61603
62654
  const offPayload = broker?.subscribePayload((payload) => {
61604
- writeEvent("change", payload);
62655
+ void changeVisibleToActiveRole(active.db, payload).then((visible) => {
62656
+ if (!visible) return;
62657
+ const out = isDeleteOp(payload.op) ? { ...payload, owner_role: null } : payload;
62658
+ writeEvent("change", out);
62659
+ });
61605
62660
  });
61606
62661
  const cleanup = () => {
61607
62662
  clearInterval(keepalive);
@@ -61637,24 +62692,31 @@ data: ${JSON.stringify(data)}
61637
62692
  }
61638
62693
  }, 25e3);
61639
62694
  const recentSelf = /* @__PURE__ */ new Map();
62695
+ const isFeedHiddenTable = (t8) => t8.startsWith("_lattice") || t8.startsWith("__lattice") || isInternalNativeEntity(t8);
61640
62696
  const offFeed = active.feed.subscribe((e6) => {
62697
+ if (e6.table && isFeedHiddenTable(e6.table)) return;
61641
62698
  recentSelf.set(`${e6.table ?? ""}:${e6.rowId ?? ""}:${e6.op}`, Date.now());
61642
62699
  writeFeed(e6);
61643
62700
  });
61644
62701
  const offBroker = active.realtime?.subscribePayload((p3) => {
61645
- const op = p3.op === "INSERT" ? "insert" : p3.op === "UPDATE" ? "update" : p3.op === "DELETE" ? "delete" : null;
61646
- if (!op || !p3.table_name || p3.table_name.startsWith("_lattice")) return;
61647
- const key = `${p3.table_name}:${p3.pk ?? ""}:${op}`;
62702
+ const op = feedOpForChange(p3.op);
62703
+ if (!op || !p3.table_name || isFeedHiddenTable(p3.table_name)) return;
62704
+ const tableName = p3.table_name;
62705
+ const key = `${tableName}:${p3.pk ?? ""}:${op}`;
61648
62706
  const seen = recentSelf.get(key);
61649
62707
  if (seen && Date.now() - seen < 5e3) return;
61650
- writeFeed({
61651
- seq: p3.seq,
61652
- table: p3.table_name,
61653
- op,
61654
- rowId: p3.pk,
61655
- source: "cli",
61656
- ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
61657
- summary: `${op} on ${p3.table_name} (another client)`
62708
+ void changeVisibleToActiveRole(active.db, p3).then((visible) => {
62709
+ if (!visible) return;
62710
+ writeFeed({
62711
+ seq: p3.seq,
62712
+ table: tableName,
62713
+ op,
62714
+ rowId: p3.pk,
62715
+ source: "cli",
62716
+ actor: isDeleteOp(p3.op) ? void 0 : p3.owner_role ?? void 0,
62717
+ ts: p3.created_at || (/* @__PURE__ */ new Date()).toISOString(),
62718
+ summary: `${op} on ${tableName} (another client)`
62719
+ });
61658
62720
  });
61659
62721
  });
61660
62722
  const cleanup = () => {
@@ -62739,7 +63801,7 @@ data: ${JSON.stringify(data)}
62739
63801
  if (!ws.configPath && ws.kind === "local") {
62740
63802
  rmSync(workspaceDir(latticeRoot, ws.dir), { recursive: true, force: true });
62741
63803
  } else if (ws.kind === "cloud") {
62742
- if (ws.configPath && existsSync21(ws.configPath)) {
63804
+ if (ws.configPath && existsSync22(ws.configPath)) {
62743
63805
  rmSync(ws.configPath, { force: true });
62744
63806
  }
62745
63807
  const labelMatch = /^\$\{LATTICE_DB:([A-Za-z0-9._-]+)\}$/.exec(ws.db.trim());
@@ -62789,7 +63851,7 @@ data: ${JSON.stringify(data)}
62789
63851
  return;
62790
63852
  }
62791
63853
  const newPath = resolve9(body.path);
62792
- if (!existsSync21(newPath)) {
63854
+ if (!existsSync22(newPath)) {
62793
63855
  sendJson(res, { error: `Config not found: ${newPath}` }, 400);
62794
63856
  return;
62795
63857
  }
@@ -62998,12 +64060,19 @@ data: ${JSON.stringify(data)}
62998
64060
  feed: active.feed,
62999
64061
  softDeletable: active.softDeletable,
63000
64062
  source: "gui",
63001
- sessionId
64063
+ sessionId,
64064
+ // #4.6 — the originating client's true edit time, honored for the
64065
+ // audit timestamp so an offline edit shows when it was made.
64066
+ clientTs: headerValue(req, "x-lattice-client-ts")
63002
64067
  };
63003
64068
  if (id === null) {
63004
64069
  if (method === "GET") {
63005
- const limit = Number(url2.searchParams.get("limit") ?? "500");
63006
- const offset = Number(url2.searchParams.get("offset") ?? "0");
64070
+ const limit = parsePageParam(url2.searchParams.get("limit"), "limit");
64071
+ const offset = parsePageParam(url2.searchParams.get("offset"), "offset");
64072
+ if (limit === "invalid" || offset === "invalid") {
64073
+ sendJson(res, { error: "limit and offset must be non-negative integers" }, 400);
64074
+ return;
64075
+ }
63007
64076
  const deletedMode = url2.searchParams.get("deleted");
63008
64077
  const queryOpts = { limit, offset };
63009
64078
  if (active.softDeletable.has(table) && deletedMode !== "any") {
@@ -63011,20 +64080,29 @@ data: ${JSON.stringify(data)}
63011
64080
  { col: "deleted_at", op: deletedMode === "only" ? "isNotNull" : "isNull" }
63012
64081
  ];
63013
64082
  }
63014
- const rows = await active.db.query(table, queryOpts);
64083
+ const rows = await active.db.query(readRelationFor(active, table), queryOpts);
63015
64084
  await attachRowAccess(active.db, table, rows);
63016
64085
  sendJson(res, { rows });
63017
64086
  return;
63018
64087
  }
63019
64088
  if (method === "POST") {
63020
64089
  const body = await readJson(req);
63021
- const { id: newId } = await createRow(mctx, table, body);
63022
- sendJson(res, { id: newId }, 201);
64090
+ const editId = headerValue(req, "x-lattice-edit-id");
64091
+ const created = await createRow(mctx, table, body, void 0, editId);
64092
+ sendJson(res, { id: created.id }, created.idempotent ? 200 : 201);
63023
64093
  return;
63024
64094
  }
63025
64095
  } else {
63026
64096
  if (method === "GET") {
63027
- const row = await active.db.get(table, id);
64097
+ const readRel = readRelationFor(active, table);
64098
+ let row;
64099
+ if (readRel === table) {
64100
+ row = await active.db.get(table, id);
64101
+ } else {
64102
+ const pkCol = active.db.getPrimaryKey(table)[0] ?? "id";
64103
+ const found = await active.db.query(readRel, { where: { [pkCol]: id }, limit: 1 });
64104
+ row = found[0] ?? null;
64105
+ }
63028
64106
  if (row === null) {
63029
64107
  sendJson(res, { error: "Row not found" }, 404);
63030
64108
  return;
@@ -63123,7 +64201,7 @@ data: ${JSON.stringify(data)}
63123
64201
  createJunction: (otherTable) => createFileJunction(active, otherTable, sessionId),
63124
64202
  createEntity: (entity, columns) => createUserEntity(active, entity, columns, sessionId),
63125
64203
  aggressiveness: getAggressiveness(),
63126
- latticeRoot: dirname11(active.configPath),
64204
+ latticeRoot: dirname12(active.configPath),
63127
64205
  configPath: active.configPath,
63128
64206
  pathname,
63129
64207
  method
@@ -63133,7 +64211,7 @@ data: ${JSON.stringify(data)}
63133
64211
  if (pathname.startsWith("/api/files/")) {
63134
64212
  const handled = await dispatchFilesRoute(req, res, {
63135
64213
  db: active.db,
63136
- latticeRoot: dirname11(active.configPath),
64214
+ latticeRoot: dirname12(active.configPath),
63137
64215
  configPath: active.configPath,
63138
64216
  pathname,
63139
64217
  method
@@ -63151,6 +64229,42 @@ data: ${JSON.stringify(data)}
63151
64229
  await disposeActive(active);
63152
64230
  active = next;
63153
64231
  startBackgroundRender(active);
64232
+ },
64233
+ // Join a cloud as a NEW workspace (never hijack the active one): save
64234
+ // the credential, scaffold a new cloud workspace, open + activate it.
64235
+ // Atomic — on open failure, roll back the half-created workspace +
64236
+ // credential and rethrow (so a failed join leaves nothing behind).
64237
+ createCloudWorkspace: async (displayName, key, url3) => {
64238
+ if (!latticeRoot) {
64239
+ throw new Error("No .lattice root \u2014 cannot create a cloud workspace");
64240
+ }
64241
+ saveDbCredential(key, url3);
64242
+ let created;
64243
+ try {
64244
+ created = addWorkspace(latticeRoot, {
64245
+ displayName,
64246
+ db: "${LATTICE_DB:" + key + "}",
64247
+ makeActive: false
64248
+ });
64249
+ } catch (e6) {
64250
+ deleteDbCredential(key);
64251
+ throw e6;
64252
+ }
64253
+ const paths = resolveWorkspacePaths(latticeRoot, created);
64254
+ let next;
64255
+ try {
64256
+ next = await openConfig(paths.configPath, paths.contextDir, autoRender);
64257
+ } catch (e6) {
64258
+ removeWorkspace(latticeRoot, created.id);
64259
+ deleteDbCredential(key);
64260
+ throw e6;
64261
+ }
64262
+ setActiveWorkspace(latticeRoot, created.id);
64263
+ await disposeActive(active);
64264
+ active = next;
64265
+ startBackgroundRender(active);
64266
+ currentWorkspaceId = created.id;
64267
+ return created.id;
63154
64268
  }
63155
64269
  });
63156
64270
  if (handled) return;
@@ -63166,6 +64280,10 @@ data: ${JSON.stringify(data)}
63166
64280
  sendJson(res, { error: e6.message }, 403);
63167
64281
  return;
63168
64282
  }
64283
+ if (e6.code === "row_write_conflict") {
64284
+ sendJson(res, { error: e6.message }, 409);
64285
+ return;
64286
+ }
63169
64287
  console.error(
63170
64288
  `[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e6.message}
63171
64289
  ${e6.stack ?? ""}`
@@ -63380,7 +64498,7 @@ function printHelp() {
63380
64498
  function getVersion() {
63381
64499
  try {
63382
64500
  const pkgPath = new URL("../package.json", import.meta.url).pathname;
63383
- const pkg = JSON.parse(readFileSync17(pkgPath, "utf-8"));
64501
+ const pkg = JSON.parse(readFileSync18(pkgPath, "utf-8"));
63384
64502
  return pkg.version;
63385
64503
  } catch {
63386
64504
  return "unknown";
@@ -63414,7 +64532,7 @@ function runGenerate(args) {
63414
64532
  const configPath = resolve10(args.config);
63415
64533
  let raw;
63416
64534
  try {
63417
- raw = readFileSync17(configPath, "utf-8");
64535
+ raw = readFileSync18(configPath, "utf-8");
63418
64536
  } catch {
63419
64537
  console.error(`Error: cannot read config file at "${configPath}"`);
63420
64538
  process.exit(1);
@@ -63430,7 +64548,7 @@ function runGenerate(args) {
63430
64548
  console.error('Error: config must have an "entities" key');
63431
64549
  process.exit(1);
63432
64550
  }
63433
- const configDir2 = dirname12(configPath);
64551
+ const configDir2 = dirname13(configPath);
63434
64552
  const outDir = resolve10(args.out);
63435
64553
  try {
63436
64554
  const result = generateAll({ config, configDir: configDir2, outDir, scaffold: args.scaffold });