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/README.md +4 -0
- package/dist/cli.js +1996 -878
- package/dist/index.cjs +352 -187
- package/dist/index.d.cts +15 -20
- package/dist/index.d.ts +15 -20
- package/dist/index.js +352 -187
- package/docs/api-reference.md +1370 -0
- package/docs/architecture.md +331 -0
- package/docs/assistant.md +138 -0
- package/docs/cli.md +515 -0
- package/docs/cloud.md +675 -0
- package/docs/collaboration.md +85 -0
- package/docs/configuration.md +416 -0
- package/docs/entity-context.md +510 -0
- package/docs/examples/agent-system.md +313 -0
- package/docs/examples/cms.md +366 -0
- package/docs/examples/ticket-tracker.md +313 -0
- package/docs/migrations.md +272 -0
- package/docs/templates.md +338 -0
- package/docs/workspaces.md +81 -0
- package/package.json +3 -2
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
|
|
3553
|
-
import { sep as
|
|
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:${
|
|
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] =
|
|
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
|
|
3594
|
-
import { join as
|
|
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 =
|
|
3601
|
+
const hasher = createHash4("sha1");
|
|
3602
3602
|
const cacheName = hasher.update(id).digest("hex");
|
|
3603
|
-
return
|
|
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
|
|
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] ||
|
|
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
|
|
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] ||
|
|
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
|
|
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 =
|
|
3781
|
+
resolvedFilepath = join17(homeDir, filepath.slice(2));
|
|
3782
3782
|
}
|
|
3783
3783
|
let resolvedConfigFilepath = configFilepath;
|
|
3784
3784
|
if (configFilepath.startsWith(relativeHomeDirPrefix)) {
|
|
3785
|
-
resolvedConfigFilepath =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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)) :
|
|
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
|
|
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 = (
|
|
10501
|
+
getNodeModulesParentDirs = (dirname14) => {
|
|
10502
10502
|
const cwd = process.cwd();
|
|
10503
|
-
if (!
|
|
10503
|
+
if (!dirname14) {
|
|
10504
10504
|
return [cwd];
|
|
10505
10505
|
}
|
|
10506
|
-
const normalizedPath = normalize(
|
|
10507
|
-
const parts = normalizedPath.split(
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
10585
|
-
const nodeModulesParentDirs = getNodeModulesParentDirs(
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
34056
|
+
import { createHash as createHash6, createPrivateKey, createPublicKey, sign } from "crypto";
|
|
34057
34057
|
import { promises as fs2 } from "fs";
|
|
34058
|
-
import { homedir as
|
|
34059
|
-
import { dirname as
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
34224
|
-
return
|
|
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
|
|
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] ??
|
|
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
|
|
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
|
|
39372
|
-
import { readFileSync as
|
|
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
|
-
|
|
39831
|
-
|
|
39832
|
-
|
|
39833
|
-
|
|
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
|
-
|
|
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
|
|
42138
|
-
* passthrough has elapsed. Dropped events are simply not delivered — the
|
|
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
|
|
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
|
|
42150
|
-
* `table-start`, `table-done`, `done`, and `error` — none of which
|
|
42151
|
-
* ever be dropped. Resetting on `table-start` gives each table a clean
|
|
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
|
|
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
|
-
|
|
42351
|
-
|
|
42352
|
-
|
|
42353
|
-
|
|
42354
|
-
|
|
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
|
-
|
|
42385
|
-
|
|
42386
|
-
|
|
42387
|
-
const
|
|
42388
|
-
|
|
42389
|
-
|
|
42390
|
-
|
|
42391
|
-
|
|
42392
|
-
|
|
42393
|
-
|
|
42394
|
-
|
|
42395
|
-
|
|
42396
|
-
|
|
42397
|
-
|
|
42398
|
-
|
|
42399
|
-
|
|
42400
|
-
|
|
42401
|
-
|
|
42402
|
-
|
|
42403
|
-
|
|
42404
|
-
|
|
42405
|
-
|
|
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
|
-
|
|
42410
|
-
|
|
42411
|
-
|
|
42412
|
-
|
|
42413
|
-
|
|
42414
|
-
|
|
42415
|
-
|
|
42416
|
-
|
|
42417
|
-
|
|
42418
|
-
|
|
42419
|
-
|
|
42420
|
-
|
|
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
|
-
|
|
42428
|
-
|
|
42429
|
-
|
|
42430
|
-
|
|
42431
|
-
|
|
42432
|
-
|
|
42433
|
-
|
|
42434
|
-
|
|
42435
|
-
|
|
42436
|
-
|
|
42437
|
-
|
|
42438
|
-
|
|
42439
|
-
|
|
42440
|
-
|
|
42441
|
-
|
|
42442
|
-
|
|
42443
|
-
|
|
42444
|
-
|
|
42445
|
-
|
|
42446
|
-
|
|
42447
|
-
|
|
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
|
-
|
|
42479
|
-
|
|
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:
|
|
42547
|
+
pct: 100
|
|
42488
42548
|
});
|
|
42549
|
+
return manifestEntry;
|
|
42489
42550
|
}
|
|
42490
|
-
|
|
42491
|
-
|
|
42492
|
-
|
|
42493
|
-
|
|
42494
|
-
|
|
42495
|
-
|
|
42496
|
-
|
|
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
|
|
46086
|
+
existsSync as existsSync22,
|
|
46029
46087
|
mkdirSync as mkdirSync10,
|
|
46030
|
-
readFileSync as
|
|
46031
|
-
readdirSync as
|
|
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
|
|
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
|
-
|
|
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.
|
|
48280
|
-
at: p.
|
|
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
|
-
|
|
48458
|
-
|
|
48459
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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 (
|
|
49949
|
+
if (isImageFile(row) && viewable) {
|
|
49742
49950
|
html += '<img src="' + blobUrl + '" alt="' + escapeHtml(row.original_name || 'image') + '">';
|
|
49743
|
-
} else if (mime === 'application/pdf' &&
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
|
51428
|
-
|
|
51429
|
-
|
|
51430
|
-
|
|
51431
|
-
|
|
51432
|
-
|
|
51433
|
-
|
|
51434
|
-
|
|
51435
|
-
'
|
|
51436
|
-
|
|
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) + '/
|
|
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({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
52929
|
-
|
|
52930
|
-
|
|
52931
|
-
|
|
52932
|
-
|
|
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
|
|
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
|
|
53233
|
+
'<span>' + escapeHtml(label) +
|
|
52950
53234
|
(m.isYou ? ' <span style="color:var(--accent);font-size:11px">(you)</span>' : '') +
|
|
52951
|
-
' <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
|
-
|
|
53117
|
-
|
|
53118
|
-
|
|
53119
|
-
|
|
53120
|
-
|
|
53121
|
-
|
|
53122
|
-
|
|
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
|
-
|
|
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 =
|
|
53136
|
-
'<
|
|
53137
|
-
'
|
|
53138
|
-
|
|
53139
|
-
|
|
53140
|
-
|
|
53141
|
-
|
|
53142
|
-
|
|
53143
|
-
'<
|
|
53144
|
-
|
|
53145
|
-
|
|
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 (
|
|
53163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54023
|
-
|
|
54024
|
-
|
|
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.
|
|
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
|
-
|
|
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('
|
|
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
|
|
54925
|
-
|
|
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
|
-
|
|
54941
|
-
|
|
54942
|
-
|
|
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
|
|
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}_${
|
|
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
|
-
|
|
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",
|
|
55045
|
-
|
|
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
|
|
55061
|
-
|
|
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").
|
|
55354
|
-
//
|
|
55355
|
-
|
|
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
|
-
|
|
55432
|
-
|
|
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,
|
|
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(
|
|
55437
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
55968
|
-
import { basename as basename4, dirname as
|
|
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 =
|
|
56981
|
+
const dir = dirname8(activeConfigPath);
|
|
55989
56982
|
const out = [];
|
|
55990
|
-
if (!
|
|
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 =
|
|
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,
|
|
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
|
|
56082
|
-
import { basename as basename6, dirname as
|
|
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
|
|
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 =
|
|
56302
|
-
const tokenSecret =
|
|
56303
|
-
const nonce =
|
|
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 {
|
|
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
|
|
56446
|
-
import { basename as basename5, dirname as
|
|
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
|
|
56452
|
-
import { join as
|
|
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 ??
|
|
57268
|
+
const legacy = process.env.LATTICE_CONFIG_DIR ?? join22(homedir5(), ".lattice");
|
|
56462
57269
|
const dest = rootConfigDir(root6);
|
|
56463
57270
|
const copied = [];
|
|
56464
|
-
if (!existsSync18(
|
|
56465
|
-
if (existsSync18(
|
|
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 =
|
|
57275
|
+
const src = join22(legacy, entry);
|
|
56469
57276
|
if (existsSync18(src)) {
|
|
56470
|
-
cpSync(src,
|
|
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 =
|
|
57286
|
+
const base = dirname9(resolve6(configPath));
|
|
56480
57287
|
for (const dir of ["context", ".", "generated"]) {
|
|
56481
57288
|
const abs = resolve6(base, dir);
|
|
56482
|
-
if (existsSync19(
|
|
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 =
|
|
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 =
|
|
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(
|
|
57352
|
+
if (!root6 && hasConfigFile) root6 = findLatticeRoot(dirname9(configAbs));
|
|
56546
57353
|
let freshRoot = false;
|
|
56547
57354
|
if (!root6) {
|
|
56548
|
-
root6 = ensureLatticeRoot(hasConfigFile ?
|
|
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, [
|
|
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,
|
|
56573
|
-
const root6 = findLatticeRoot(
|
|
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
|
|
56579
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
57484
|
+
return isAbsolute3(candidate) ? candidate : resolve7(configPath, "..", candidate);
|
|
56675
57485
|
}
|
|
56676
|
-
async function
|
|
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
|
-
|
|
56695
|
-
|
|
56696
|
-
|
|
56697
|
-
|
|
56698
|
-
|
|
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(
|
|
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
|
|
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, {
|
|
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
|
-
|
|
56914
|
-
...rows.map((r6) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
58160
|
+
return randomBytes8(48).toString("base64url");
|
|
57414
58161
|
}
|
|
57415
58162
|
function pkceChallengeFor(verifier) {
|
|
57416
|
-
return
|
|
58163
|
+
return createHash7("sha256").update(verifier).digest("base64url");
|
|
57417
58164
|
}
|
|
57418
58165
|
function generateState() {
|
|
57419
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58473
|
-
|
|
58474
|
-
|
|
58475
|
-
|
|
58476
|
-
|
|
58477
|
-
|
|
58478
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
60104
|
-
import { createReadStream as createReadStream2, existsSync as
|
|
60105
|
-
import { basename as basename9, join as
|
|
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 =
|
|
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 =
|
|
61071
|
+
const blobDir = join25(latticeRoot, "data", "blobs");
|
|
60124
61072
|
mkdirSync9(blobDir, { recursive: true });
|
|
60125
|
-
const destAbs =
|
|
60126
|
-
if (!
|
|
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
|
|
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)
|
|
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
|
-
|
|
60326
|
-
|
|
60327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ??
|
|
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 =
|
|
62066
|
+
const absPath = join27(entityDir, filename);
|
|
61068
62067
|
const relPath = [directoryRoot, slug, filename].join("/");
|
|
61069
|
-
if (!
|
|
61070
|
-
let content =
|
|
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 =
|
|
62078
|
+
const base = dirname12(resolve9(configPath));
|
|
61080
62079
|
for (const dir of ["context", ".", "generated"]) {
|
|
61081
62080
|
const abs = resolve9(base, dir);
|
|
61082
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
61189
|
-
|
|
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 (!
|
|
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 =
|
|
62282
|
+
const dir = dirname12(activeConfigPath);
|
|
61257
62283
|
const entries = [];
|
|
61258
|
-
for (const fname of
|
|
62284
|
+
for (const fname of readdirSync8(dir)) {
|
|
61259
62285
|
if (!fname.endsWith(".yml") && !fname.endsWith(".yaml")) continue;
|
|
61260
|
-
const full =
|
|
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 =
|
|
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 =
|
|
61289
|
-
if (
|
|
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(
|
|
62321
|
+
mkdirSync10(join27(dir, "data"), { recursive: true });
|
|
61296
62322
|
return configPath;
|
|
61297
62323
|
}
|
|
61298
62324
|
function sqliteFileForConfig(configPath) {
|
|
61299
|
-
const dbVal = parseDocument3(
|
|
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(
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
61413
|
-
|
|
61414
|
-
|
|
61415
|
-
|
|
61416
|
-
|
|
61417
|
-
|
|
61418
|
-
|
|
61419
|
-
|
|
61420
|
-
|
|
61421
|
-
|
|
61422
|
-
|
|
61423
|
-
|
|
61424
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
61646
|
-
if (!op || !p3.table_name || p3.table_name
|
|
61647
|
-
const
|
|
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
|
-
|
|
61651
|
-
|
|
61652
|
-
|
|
61653
|
-
|
|
61654
|
-
|
|
61655
|
-
|
|
61656
|
-
|
|
61657
|
-
|
|
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 &&
|
|
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 (!
|
|
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 =
|
|
63006
|
-
const offset =
|
|
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
|
|
63022
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 });
|