@lotics/cli 0.192.0 → 0.194.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 +1 -1
- package/dist/src/cli.js +1047 -177
- package/dist/src/client.d.ts +17 -11
- package/dist/src/client.js +17 -18
- package/docs/building_an_app.md +7 -0
- package/docs/cli_reference.md +5 -5
- package/package.json +2 -1
package/dist/src/cli.js
CHANGED
|
@@ -44372,10 +44372,9 @@ var LoticsRequestError = class extends Error {
|
|
|
44372
44372
|
status;
|
|
44373
44373
|
body;
|
|
44374
44374
|
};
|
|
44375
|
-
var API_BASE_URL = process.env.LOTICS_API_URL ?? "https://api.lotics.ai";
|
|
44376
44375
|
var WEB_APP_URL = process.env.LOTICS_WEB_URL ?? "https://lotics.ai";
|
|
44377
|
-
async function fetchOfficialStarters() {
|
|
44378
|
-
const read = await getPublicJson(`${
|
|
44376
|
+
async function fetchOfficialStarters(apiUrl) {
|
|
44377
|
+
const read = await getPublicJson(`${apiUrl}/v1/starters/official`, "list the packages");
|
|
44379
44378
|
if (!read.ok) {
|
|
44380
44379
|
throw new Error(
|
|
44381
44380
|
`Lotics answered ${read.status} listing the packages. If this keeps happening, browse ${WEB_APP_URL}/docs/cli.`
|
|
@@ -44395,16 +44394,16 @@ async function getPublicJson(url2, what) {
|
|
|
44395
44394
|
if (!response.ok) return { ok: false, status: response.status };
|
|
44396
44395
|
return { ok: true, body: await response.json() };
|
|
44397
44396
|
}
|
|
44398
|
-
async function readOfficialStarter(starter_id) {
|
|
44397
|
+
async function readOfficialStarter(apiUrl, starter_id) {
|
|
44399
44398
|
const read = await getPublicJson(
|
|
44400
|
-
`${
|
|
44399
|
+
`${apiUrl}/v1/starters/official/${encodeURIComponent(starter_id)}`,
|
|
44401
44400
|
`read ${starter_id}`
|
|
44402
44401
|
);
|
|
44403
44402
|
if (!read.ok) return read;
|
|
44404
44403
|
return { ok: true, package: read.body };
|
|
44405
44404
|
}
|
|
44406
|
-
async function getOfficialStarter(starter_id) {
|
|
44407
|
-
const read = await readOfficialStarter(starter_id);
|
|
44405
|
+
async function getOfficialStarter(apiUrl, starter_id) {
|
|
44406
|
+
const read = await readOfficialStarter(apiUrl, starter_id);
|
|
44408
44407
|
if (!read.ok) {
|
|
44409
44408
|
throw new Error(
|
|
44410
44409
|
`Lotics answered ${read.status} reading ${starter_id}. Only packages Lotics publishes can be read without an account.`
|
|
@@ -44413,10 +44412,10 @@ async function getOfficialStarter(starter_id) {
|
|
|
44413
44412
|
return read.package;
|
|
44414
44413
|
}
|
|
44415
44414
|
var PUBLIC_FETCH_TIMEOUT_MS = 1e4;
|
|
44416
|
-
async function startCliLogin(email3) {
|
|
44415
|
+
async function startCliLogin(apiUrl, email3) {
|
|
44417
44416
|
let response;
|
|
44418
44417
|
try {
|
|
44419
|
-
response = await fetch(`${
|
|
44418
|
+
response = await fetch(`${apiUrl}/v1/cli/login_requests`, {
|
|
44420
44419
|
method: "POST",
|
|
44421
44420
|
headers: { "Content-Type": "application/json" },
|
|
44422
44421
|
body: JSON.stringify({ email: email3 }),
|
|
@@ -44432,8 +44431,8 @@ async function startCliLogin(email3) {
|
|
|
44432
44431
|
}
|
|
44433
44432
|
return await response.json();
|
|
44434
44433
|
}
|
|
44435
|
-
async function pollCliLogin(request_id, secret) {
|
|
44436
|
-
const url2 = `${
|
|
44434
|
+
async function pollCliLogin(apiUrl, request_id, secret) {
|
|
44435
|
+
const url2 = `${apiUrl}/v1/cli/login_requests/${encodeURIComponent(request_id)}`;
|
|
44437
44436
|
let response;
|
|
44438
44437
|
try {
|
|
44439
44438
|
response = await fetch(url2, {
|
|
@@ -44460,14 +44459,14 @@ var LoticsClient = class {
|
|
|
44460
44459
|
* construction — surfaced so `lotics app dev` can show it in the banner. */
|
|
44461
44460
|
viewAsMemberId;
|
|
44462
44461
|
/** API URL the client is configured against. Read-only after construction.
|
|
44463
|
-
* Surfaced for callers that need to display
|
|
44464
|
-
*
|
|
44462
|
+
* Surfaced for callers that need to display it (`lotics app dev`'s banner) or
|
|
44463
|
+
* to hand it on (the wrapper page's RPC target, the scaffold's font proxy). */
|
|
44465
44464
|
baseUrl;
|
|
44466
44465
|
constructor(options) {
|
|
44467
44466
|
this.apiKey = options.apiKey;
|
|
44468
44467
|
this.workspaceId = options.workspaceId;
|
|
44469
44468
|
this.viewAsMemberId = options.viewAsMemberId;
|
|
44470
|
-
this.baseUrl =
|
|
44469
|
+
this.baseUrl = options.apiUrl;
|
|
44471
44470
|
}
|
|
44472
44471
|
/**
|
|
44473
44472
|
* The id is appended to the MESSAGE rather than carried on a field, because
|
|
@@ -44870,9 +44869,10 @@ var LoticsClient = class {
|
|
|
44870
44869
|
return tables.filter((t) => t !== null);
|
|
44871
44870
|
}
|
|
44872
44871
|
/**
|
|
44873
|
-
* Rename an app's public subdomain — its
|
|
44874
|
-
* Mirrors PUT /v1/apps/{app_id}/subdomain
|
|
44875
|
-
*
|
|
44872
|
+
* Rename an app's public subdomain — the label its origin is built on.
|
|
44873
|
+
* Mirrors PUT /v1/apps/{app_id}/subdomain, and returns the finished `origin`
|
|
44874
|
+
* because only the instance knows the domain and scheme it serves apps on.
|
|
44875
|
+
* The old subdomain stops resolving once the change lands.
|
|
44876
44876
|
*/
|
|
44877
44877
|
async setAppSubdomain(app_id, public_subdomain) {
|
|
44878
44878
|
return this.request(
|
|
@@ -45500,8 +45500,16 @@ function canonicalize(value2) {
|
|
|
45500
45500
|
var MAX_ERROR_CHARS = 2e3;
|
|
45501
45501
|
var MAX_SPOOL_BYTES = 1024 * 1024;
|
|
45502
45502
|
var MAX_FLUSH_EVENTS = 500;
|
|
45503
|
-
function spoolPath() {
|
|
45504
|
-
|
|
45503
|
+
function spoolPath(apiUrl) {
|
|
45504
|
+
const shard = crypto2.createHash("sha256").update(apiUrl).digest("hex").slice(0, 12);
|
|
45505
|
+
return path3.join(os2.homedir(), ".lotics", `telemetry-${shard}.ndjson`);
|
|
45506
|
+
}
|
|
45507
|
+
var targetApiUrl = "";
|
|
45508
|
+
function noteTarget(apiUrl) {
|
|
45509
|
+
targetApiUrl = apiUrl;
|
|
45510
|
+
}
|
|
45511
|
+
function invocationTarget() {
|
|
45512
|
+
return targetApiUrl;
|
|
45505
45513
|
}
|
|
45506
45514
|
var stderrTail = "";
|
|
45507
45515
|
function recordStderr(text) {
|
|
@@ -45561,8 +45569,8 @@ function buildEvent(exitCode, durationMs, now) {
|
|
|
45561
45569
|
...pendingScope
|
|
45562
45570
|
};
|
|
45563
45571
|
}
|
|
45564
|
-
function appendEvent(event) {
|
|
45565
|
-
const file2 = spoolPath();
|
|
45572
|
+
function appendEvent(event, apiUrl) {
|
|
45573
|
+
const file2 = spoolPath(apiUrl);
|
|
45566
45574
|
try {
|
|
45567
45575
|
fs3.mkdirSync(path3.dirname(file2), { recursive: true });
|
|
45568
45576
|
if ((fs3.statSync(file2, { throwIfNoEntry: false })?.size ?? 0) > MAX_SPOOL_BYTES) {
|
|
@@ -45573,11 +45581,11 @@ function appendEvent(event) {
|
|
|
45573
45581
|
} catch {
|
|
45574
45582
|
}
|
|
45575
45583
|
}
|
|
45576
|
-
function readSpool() {
|
|
45584
|
+
function readSpool(apiUrl) {
|
|
45577
45585
|
try {
|
|
45578
45586
|
const events = [];
|
|
45579
45587
|
const lines = [];
|
|
45580
|
-
for (const line of fs3.readFileSync(spoolPath(), "utf-8").split("\n")) {
|
|
45588
|
+
for (const line of fs3.readFileSync(spoolPath(apiUrl), "utf-8").split("\n")) {
|
|
45581
45589
|
if (line.trim() === "") continue;
|
|
45582
45590
|
try {
|
|
45583
45591
|
events.push(JSON.parse(line));
|
|
@@ -45590,32 +45598,34 @@ function readSpool() {
|
|
|
45590
45598
|
return { events: [], lines: [] };
|
|
45591
45599
|
}
|
|
45592
45600
|
}
|
|
45593
|
-
function clearSpool(sent) {
|
|
45601
|
+
function clearSpool(apiUrl, sent) {
|
|
45594
45602
|
try {
|
|
45595
|
-
const
|
|
45596
|
-
|
|
45603
|
+
const file2 = spoolPath(apiUrl);
|
|
45604
|
+
const current = fs3.readFileSync(file2, "utf-8");
|
|
45605
|
+
fs3.writeFileSync(file2, current.startsWith(sent) ? current.slice(sent.length) : "", {
|
|
45597
45606
|
mode: 384
|
|
45598
45607
|
});
|
|
45599
45608
|
} catch {
|
|
45600
45609
|
}
|
|
45601
45610
|
}
|
|
45602
|
-
function installTelemetry(startedAt2 = Date.now()) {
|
|
45611
|
+
function installTelemetry(target, startedAt2 = Date.now()) {
|
|
45603
45612
|
if (!telemetryEnabled()) return;
|
|
45613
|
+
noteTarget(target);
|
|
45604
45614
|
installStderrCapture();
|
|
45605
45615
|
process.on("exit", (code) => {
|
|
45606
45616
|
const event = buildEvent(code, Date.now() - startedAt2, /* @__PURE__ */ new Date());
|
|
45607
|
-
if (event !== null) appendEvent(event);
|
|
45617
|
+
if (event !== null) appendEvent(event, invocationTarget());
|
|
45608
45618
|
});
|
|
45609
45619
|
}
|
|
45610
|
-
function flushSpool(post, minEvents = 5) {
|
|
45620
|
+
function flushSpool(apiUrl, post, minEvents = 5) {
|
|
45611
45621
|
if (!telemetryEnabled()) return;
|
|
45612
|
-
const { events, lines } = readSpool();
|
|
45622
|
+
const { events, lines } = readSpool(apiUrl);
|
|
45613
45623
|
if (events.length < minEvents) return;
|
|
45614
45624
|
const batch = events.slice(0, MAX_FLUSH_EVENTS);
|
|
45615
45625
|
const sent = `${lines.slice(0, batch.length).join("\n")}
|
|
45616
45626
|
`;
|
|
45617
45627
|
void post(batch).then((outcome) => {
|
|
45618
|
-
if (outcome !== "retry") clearSpool(sent);
|
|
45628
|
+
if (outcome !== "retry") clearSpool(apiUrl, sent);
|
|
45619
45629
|
}).catch(() => {
|
|
45620
45630
|
});
|
|
45621
45631
|
}
|
|
@@ -45641,6 +45651,10 @@ async function postEvents(baseUrl, apiKey, events, timeoutMs = 5e3) {
|
|
|
45641
45651
|
import fs4 from "node:fs";
|
|
45642
45652
|
import path4 from "node:path";
|
|
45643
45653
|
import os3 from "node:os";
|
|
45654
|
+
var HOSTED_API_URL = "https://api.lotics.ai";
|
|
45655
|
+
function envApiUrl() {
|
|
45656
|
+
return process.env.LOTICS_API_URL ?? HOSTED_API_URL;
|
|
45657
|
+
}
|
|
45644
45658
|
function globalConfigFile() {
|
|
45645
45659
|
return path4.join(os3.homedir(), ".lotics", "config.json");
|
|
45646
45660
|
}
|
|
@@ -45663,12 +45677,34 @@ function configFileForScope(scope) {
|
|
|
45663
45677
|
return globalConfigFile();
|
|
45664
45678
|
}
|
|
45665
45679
|
function readConfigFile(file2) {
|
|
45680
|
+
let parsed;
|
|
45666
45681
|
try {
|
|
45667
45682
|
const raw = fs4.readFileSync(file2, "utf-8");
|
|
45668
|
-
|
|
45683
|
+
parsed = JSON.parse(raw);
|
|
45669
45684
|
} catch {
|
|
45670
45685
|
return null;
|
|
45671
45686
|
}
|
|
45687
|
+
return upgradeProfileApiUrls(file2, parsed);
|
|
45688
|
+
}
|
|
45689
|
+
function upgradeProfileApiUrls(file2, config2) {
|
|
45690
|
+
const profiles = config2.profiles;
|
|
45691
|
+
if (!profiles) return config2;
|
|
45692
|
+
const entries2 = Object.entries(profiles);
|
|
45693
|
+
if (!entries2.some(([, profile]) => !profile.api_url)) return config2;
|
|
45694
|
+
const upgraded = {
|
|
45695
|
+
...config2,
|
|
45696
|
+
profiles: Object.fromEntries(
|
|
45697
|
+
entries2.map(([orgId, profile]) => [
|
|
45698
|
+
orgId,
|
|
45699
|
+
{ ...profile, api_url: profile.api_url || HOSTED_API_URL }
|
|
45700
|
+
])
|
|
45701
|
+
)
|
|
45702
|
+
};
|
|
45703
|
+
try {
|
|
45704
|
+
writeConfigFile(file2, upgraded);
|
|
45705
|
+
} catch {
|
|
45706
|
+
}
|
|
45707
|
+
return upgraded;
|
|
45672
45708
|
}
|
|
45673
45709
|
function writeConfigFile(file2, config2) {
|
|
45674
45710
|
fs4.mkdirSync(path4.dirname(file2), { recursive: true, mode: 448 });
|
|
@@ -45715,6 +45751,11 @@ function resolveProfileByNameOrId(profiles, nameOrId) {
|
|
|
45715
45751
|
return null;
|
|
45716
45752
|
}
|
|
45717
45753
|
function resolveContext(flags, appWorkspaceId) {
|
|
45754
|
+
const resolved = resolveCredential(flags, appWorkspaceId);
|
|
45755
|
+
if (resolved) noteTarget(resolved.apiUrl);
|
|
45756
|
+
return resolved;
|
|
45757
|
+
}
|
|
45758
|
+
function resolveCredential(flags, appWorkspaceId) {
|
|
45718
45759
|
const envKey = process.env.LOTICS_API_KEY;
|
|
45719
45760
|
const envOrg = process.env.LOTICS_ORG;
|
|
45720
45761
|
const envWorkspace = process.env.LOTICS_WORKSPACE;
|
|
@@ -45732,6 +45773,7 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45732
45773
|
const [orgId, profile] = resolved;
|
|
45733
45774
|
return {
|
|
45734
45775
|
apiKey: profile.api_key,
|
|
45776
|
+
apiUrl: profile.api_url,
|
|
45735
45777
|
orgId,
|
|
45736
45778
|
orgName: profile.org_name,
|
|
45737
45779
|
workspaceId: wsOverride ?? profile.workspace_id,
|
|
@@ -45739,10 +45781,10 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45739
45781
|
};
|
|
45740
45782
|
}
|
|
45741
45783
|
if (flags.apiKey) {
|
|
45742
|
-
return { apiKey: flags.apiKey, workspaceId: wsOverride, source: "flag" };
|
|
45784
|
+
return { apiKey: flags.apiKey, apiUrl: envApiUrl(), workspaceId: wsOverride, source: "flag" };
|
|
45743
45785
|
}
|
|
45744
45786
|
if (envKey) {
|
|
45745
|
-
return { apiKey: envKey, workspaceId: wsOverride, source: "env_key" };
|
|
45787
|
+
return { apiKey: envKey, apiUrl: envApiUrl(), workspaceId: wsOverride, source: "env_key" };
|
|
45746
45788
|
}
|
|
45747
45789
|
const global2 = loadGlobalConfig();
|
|
45748
45790
|
const profiles = global2?.profiles ?? {};
|
|
@@ -45756,6 +45798,7 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45756
45798
|
const [orgId, profile] = resolved;
|
|
45757
45799
|
return {
|
|
45758
45800
|
apiKey: profile.api_key,
|
|
45801
|
+
apiUrl: profile.api_url,
|
|
45759
45802
|
orgId,
|
|
45760
45803
|
orgName: profile.org_name,
|
|
45761
45804
|
workspaceId: wsOverride ?? profile.workspace_id,
|
|
@@ -45772,6 +45815,7 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45772
45815
|
}
|
|
45773
45816
|
return {
|
|
45774
45817
|
apiKey: profile.api_key,
|
|
45818
|
+
apiUrl: profile.api_url,
|
|
45775
45819
|
orgId: local.active_org,
|
|
45776
45820
|
orgName: profile.org_name,
|
|
45777
45821
|
workspaceId: wsOverride ?? local.workspace_id ?? profile.workspace_id,
|
|
@@ -45789,6 +45833,7 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45789
45833
|
const [orgId, profile] = matches[0];
|
|
45790
45834
|
return {
|
|
45791
45835
|
apiKey: profile.api_key,
|
|
45836
|
+
apiUrl: profile.api_url,
|
|
45792
45837
|
orgId,
|
|
45793
45838
|
orgName: profile.org_name,
|
|
45794
45839
|
workspaceId: appWorkspaceId,
|
|
@@ -45805,6 +45850,7 @@ function resolveContext(flags, appWorkspaceId) {
|
|
|
45805
45850
|
}
|
|
45806
45851
|
return {
|
|
45807
45852
|
apiKey: profile.api_key,
|
|
45853
|
+
apiUrl: profile.api_url,
|
|
45808
45854
|
orgId: global2.active_org,
|
|
45809
45855
|
orgName: profile.org_name,
|
|
45810
45856
|
workspaceId: wsOverride ?? profile.workspace_id,
|
|
@@ -45819,6 +45865,7 @@ function upsertProfile(orgId, fields) {
|
|
|
45819
45865
|
const profile = {
|
|
45820
45866
|
api_key: fields.api_key,
|
|
45821
45867
|
org_name: fields.org_name,
|
|
45868
|
+
api_url: fields.api_url,
|
|
45822
45869
|
workspace_id: fields.workspace_id ?? existing?.workspace_id
|
|
45823
45870
|
};
|
|
45824
45871
|
saveGlobalConfig({
|
|
@@ -46001,7 +46048,7 @@ function resultSideEffects(result) {
|
|
|
46001
46048
|
}
|
|
46002
46049
|
|
|
46003
46050
|
// src/version.ts
|
|
46004
|
-
var VERSION = "0.
|
|
46051
|
+
var VERSION = "0.194.0";
|
|
46005
46052
|
|
|
46006
46053
|
// src/timezone.ts
|
|
46007
46054
|
function machineTimezone() {
|
|
@@ -46562,7 +46609,7 @@ var COMMANDS = [
|
|
|
46562
46609
|
" fields keep whatever is bound (a manifest is a",
|
|
46563
46610
|
" snapshot; replaying it would revert them). Change",
|
|
46564
46611
|
" those with set_app_agent, then pull.",
|
|
46565
|
-
" lotics app subdomain <new-subdomain> Rename the app's public
|
|
46612
|
+
" lotics app subdomain <new-subdomain> Rename the app's public address (its subdomain)",
|
|
46566
46613
|
` lotics app rename "<new name>" Rename the app's display name (launcher title)`,
|
|
46567
46614
|
" lotics app dev [path] Run the app locally with HMR (RPC forwarded to prod)"
|
|
46568
46615
|
]
|
|
@@ -46997,8 +47044,8 @@ export default defineConfig({
|
|
|
46997
47044
|
server: {
|
|
46998
47045
|
// Allow the sandboxed null-origin iframe used by \`lotics app dev\` to
|
|
46999
47046
|
// fetch HMR client + source modules from the dev server. Production
|
|
47000
|
-
// iframe loads modules from
|
|
47001
|
-
//
|
|
47047
|
+
// iframe loads modules from the API, which already permits null origin
|
|
47048
|
+
// via CORS.
|
|
47002
47049
|
cors: { origin: "*" },
|
|
47003
47050
|
// Chat-authoring previews reach this dev server through the sandbox proxy
|
|
47004
47051
|
// at \`5173-<id>-<token>.lotics-sandbox.app\`. Vite \u22655.4.12 rejects HMR
|
|
@@ -65067,7 +65114,7 @@ var appSchema = zod_default.object({
|
|
|
65067
65114
|
"Pointer to the currently-published app_versions row in R2. Set once the app has been deployed at least once via `lotics app deploy`; null before the first deploy."
|
|
65068
65115
|
),
|
|
65069
65116
|
public_subdomain: zod_default.string().describe(
|
|
65070
|
-
"DNS label for the app's public origin
|
|
65117
|
+
"DNS label for the app's public origin, under the instance's apps domain. Server-generated, high-entropy, and distinct from `id` (app_ids are not valid DNS labels). Assigned at creation for every app."
|
|
65071
65118
|
),
|
|
65072
65119
|
public_password_set: zod_default.boolean().optional().describe(
|
|
65073
65120
|
"Whether a shared password gates the public binding. True \u2192 anonymous visitors must authenticate at `/v1/apps/{app_id}/public/authenticate` before any publicAppAccess endpoint resolves. The hash itself is never sent over the wire; only this flag is exposed (and only on authenticated owner-side reads \u2014 the public by-subdomain response surfaces the same fact as `requires_password`)."
|
|
@@ -67683,6 +67730,7 @@ var fieldRoleSchema = zod_default.enum([
|
|
|
67683
67730
|
"amount",
|
|
67684
67731
|
"when",
|
|
67685
67732
|
"party",
|
|
67733
|
+
"parent",
|
|
67686
67734
|
"contact",
|
|
67687
67735
|
"verdict"
|
|
67688
67736
|
]);
|
|
@@ -67695,6 +67743,7 @@ var FIELD_ROLE_TYPES = {
|
|
|
67695
67743
|
amount: ["number", "formula", "rollup"],
|
|
67696
67744
|
when: ["date"],
|
|
67697
67745
|
party: ["select_record_link"],
|
|
67746
|
+
parent: ["select_record_link"],
|
|
67698
67747
|
contact: ["text"],
|
|
67699
67748
|
verdict: ["boolean", "formula"]
|
|
67700
67749
|
};
|
|
@@ -67705,6 +67754,7 @@ var SINGLE_FIELD_ROLES = [
|
|
|
67705
67754
|
"amount",
|
|
67706
67755
|
"when",
|
|
67707
67756
|
"party",
|
|
67757
|
+
"parent",
|
|
67708
67758
|
"contact",
|
|
67709
67759
|
"verdict"
|
|
67710
67760
|
];
|
|
@@ -67796,7 +67846,7 @@ var slot = (role, required2 = false) => ({ role, required: required2 });
|
|
|
67796
67846
|
var SHAPE_REGISTRY = {
|
|
67797
67847
|
lifecycle_desk: {
|
|
67798
67848
|
label: "lifecycle desk",
|
|
67799
|
-
slots: { stage: slot("lifecycle", true), identity: slot("identity", true), party: slot("party"), amount: slot("amount"), when: slot("when") },
|
|
67849
|
+
slots: { stage: slot("lifecycle", true), identity: slot("identity", true), mark: slot("mark"), party: slot("party"), amount: slot("amount"), when: slot("when") },
|
|
67800
67850
|
record: "drawer",
|
|
67801
67851
|
tabs: "lifecycle"
|
|
67802
67852
|
},
|
|
@@ -67820,7 +67870,7 @@ var SHAPE_REGISTRY = {
|
|
|
67820
67870
|
},
|
|
67821
67871
|
monitored_asset_set: {
|
|
67822
67872
|
label: "monitored-asset set",
|
|
67823
|
-
slots: { identity: slot("identity", true), level: slot("measure", true), stage: slot("lifecycle") },
|
|
67873
|
+
slots: { identity: slot("identity", true), mark: slot("mark"), level: slot("measure", true), stage: slot("lifecycle") },
|
|
67824
67874
|
record: "drawer",
|
|
67825
67875
|
tabs: "none"
|
|
67826
67876
|
},
|
|
@@ -67941,6 +67991,96 @@ function resolveScreen(app, screen, entityByAlias, roles) {
|
|
|
67941
67991
|
if (findings.length > 0) return { type: "invalid", findings };
|
|
67942
67992
|
return { type: "resolved", screen: { app, screen, entity, shapeLabel, record: record2, tabs, slots } };
|
|
67943
67993
|
}
|
|
67994
|
+
var CHILD_COLUMN_ROLES = [
|
|
67995
|
+
"identity",
|
|
67996
|
+
"mark",
|
|
67997
|
+
"party",
|
|
67998
|
+
"contact",
|
|
67999
|
+
"when",
|
|
68000
|
+
"expected_set",
|
|
68001
|
+
"measure",
|
|
68002
|
+
"amount",
|
|
68003
|
+
"lifecycle",
|
|
68004
|
+
"verdict"
|
|
68005
|
+
];
|
|
68006
|
+
function parentLink(child, entity, roles) {
|
|
68007
|
+
return child.fields.find(
|
|
68008
|
+
(field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role === "parent"
|
|
68009
|
+
);
|
|
68010
|
+
}
|
|
68011
|
+
function fieldsWithRole(entity, roles, role) {
|
|
68012
|
+
return entity.fields.filter((field) => roleOf(roles, entity.alias, field.alias)?.role === role);
|
|
68013
|
+
}
|
|
68014
|
+
function fileFields(entity, roles) {
|
|
68015
|
+
const files = entity.fields.filter((field) => field.type === "files");
|
|
68016
|
+
const mark = fieldsWithRole(entity, roles, "mark")[0];
|
|
68017
|
+
return mark === void 0 ? files : [mark, ...files.filter((field) => field.alias !== mark.alias)];
|
|
68018
|
+
}
|
|
68019
|
+
function recordHeader(screen) {
|
|
68020
|
+
const bound = (role) => screen.slots.find((entry) => entry.role === role && entry.field !== null)?.field ?? void 0;
|
|
68021
|
+
const title = bound("identity");
|
|
68022
|
+
if (screen.record === "drawer") return { title };
|
|
68023
|
+
return {
|
|
68024
|
+
title,
|
|
68025
|
+
subtitle: bound("when"),
|
|
68026
|
+
// Which number is THE figure is the screen's choice among the ones it
|
|
68027
|
+
// reads, so it comes off the slots — an amount the shape has no slot for
|
|
68028
|
+
// is not on the header and stays a fact.
|
|
68029
|
+
figure: bound("amount") ?? bound("measure")
|
|
68030
|
+
};
|
|
68031
|
+
}
|
|
68032
|
+
function recordSections(entity, entities, roles, header) {
|
|
68033
|
+
const lifecycle = fieldsWithRole(entity, roles, "lifecycle")[0];
|
|
68034
|
+
const ownSets = fieldsWithRole(entity, roles, "expected_set").filter(
|
|
68035
|
+
(field) => field.type === "select" && field.multi === true
|
|
68036
|
+
);
|
|
68037
|
+
const files = fileFields(entity, roles);
|
|
68038
|
+
const owned = new Set(
|
|
68039
|
+
[
|
|
68040
|
+
...lifecycle === void 0 ? [] : [lifecycle],
|
|
68041
|
+
...ownSets,
|
|
68042
|
+
...files,
|
|
68043
|
+
...header.title === void 0 ? [] : [header.title],
|
|
68044
|
+
...header.figure === void 0 ? [] : [header.figure]
|
|
68045
|
+
].map((field) => field.alias)
|
|
68046
|
+
);
|
|
68047
|
+
const facts = entity.fields.filter((field) => !owned.has(field.alias));
|
|
68048
|
+
const levels = {};
|
|
68049
|
+
for (const field of facts) {
|
|
68050
|
+
const decl = roleOf(roles, entity.alias, field.alias);
|
|
68051
|
+
if (decl?.role !== "measure" || decl.alert === void 0) continue;
|
|
68052
|
+
if (typeof decl.against === "number") {
|
|
68053
|
+
levels[field.alias] = { limit: decl.against, alert: decl.alert };
|
|
68054
|
+
continue;
|
|
68055
|
+
}
|
|
68056
|
+
const limit = entity.fields.find((candidate) => candidate.alias === decl.against);
|
|
68057
|
+
if (limit !== void 0) levels[field.alias] = { limit, alert: decl.alert };
|
|
68058
|
+
}
|
|
68059
|
+
const folded = new Set(
|
|
68060
|
+
Object.values(levels).flatMap((level) => typeof level.limit === "number" ? [] : [level.limit.alias])
|
|
68061
|
+
);
|
|
68062
|
+
const shown = facts.filter((field) => !folded.has(field.alias));
|
|
68063
|
+
const sets = ownSets.map((field) => ({ kind: "expected_set", source: "own", field }));
|
|
68064
|
+
const children = [];
|
|
68065
|
+
for (const child of entities) {
|
|
68066
|
+
const parentField = parentLink(child, entity, roles);
|
|
68067
|
+
if (parentField === void 0) continue;
|
|
68068
|
+
const setField = fieldsWithRole(child, roles, "expected_set")[0];
|
|
68069
|
+
if (setField !== void 0) {
|
|
68070
|
+
sets.push({ kind: "expected_set", source: "child", child, parentField, setField, filesField: fileFields(child, roles)[0] });
|
|
68071
|
+
continue;
|
|
68072
|
+
}
|
|
68073
|
+
const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role));
|
|
68074
|
+
children.push({ kind: "children", child, parentField, fields: byRole });
|
|
68075
|
+
}
|
|
68076
|
+
return [
|
|
68077
|
+
...shown.length > 0 ? [{ kind: "facts", fields: shown, levels }] : [],
|
|
68078
|
+
...lifecycle === void 0 ? [] : [{ kind: "progress", field: lifecycle }],
|
|
68079
|
+
...sets,
|
|
68080
|
+
...children,
|
|
68081
|
+
...files.length > 0 ? [{ kind: "files", fields: files }] : []
|
|
68082
|
+
];
|
|
68083
|
+
}
|
|
67944
68084
|
function validateWorkspacePlan(model, roles, apps) {
|
|
67945
68085
|
const findings = [];
|
|
67946
68086
|
for (const dup of findDuplicates(apps.map((app) => app.alias))) {
|
|
@@ -68021,28 +68161,83 @@ function roleCoverage(model, roles, rows) {
|
|
|
68021
68161
|
}
|
|
68022
68162
|
}
|
|
68023
68163
|
}
|
|
68164
|
+
for (const entity of model.entities) {
|
|
68165
|
+
for (const section of recordSections(entity, model.entities, roles, {})) {
|
|
68166
|
+
if (section.kind === "children" && fieldsWithRole(section.child, roles, "identity").length === 0) {
|
|
68167
|
+
notes.push({
|
|
68168
|
+
path: `field_roles.${section.child.alias}`,
|
|
68169
|
+
message: `${section.child.label} hangs under ${entity.label} and declares no identity \u2014 its rows on the record have no name`
|
|
68170
|
+
});
|
|
68171
|
+
}
|
|
68172
|
+
if (section.kind === "expected_set" && section.source === "child" && section.filesField === void 0) {
|
|
68173
|
+
notes.push({
|
|
68174
|
+
path: `field_roles.${section.child.alias}.${section.setField.alias}`,
|
|
68175
|
+
message: `${section.child.label} is the required set of ${entity.label} and carries no files field \u2014 the desk names what is missing with nothing to attach`
|
|
68176
|
+
});
|
|
68177
|
+
}
|
|
68178
|
+
}
|
|
68179
|
+
}
|
|
68024
68180
|
return notes;
|
|
68025
68181
|
}
|
|
68026
68182
|
|
|
68027
68183
|
// src/plan_starter.ts
|
|
68028
|
-
function
|
|
68184
|
+
function sectionChild(section) {
|
|
68185
|
+
if (section.kind === "children") return section.child;
|
|
68186
|
+
if (section.kind === "expected_set" && section.source === "child") return section.child;
|
|
68187
|
+
return void 0;
|
|
68188
|
+
}
|
|
68189
|
+
var DRAWN_ROLES = [
|
|
68190
|
+
"identity",
|
|
68191
|
+
"amount",
|
|
68192
|
+
"measure",
|
|
68193
|
+
"lifecycle",
|
|
68194
|
+
"when",
|
|
68195
|
+
"expected_set",
|
|
68196
|
+
"party",
|
|
68197
|
+
"contact",
|
|
68198
|
+
"verdict"
|
|
68199
|
+
];
|
|
68200
|
+
var columnPriority = (role) => DRAWN_ROLES.indexOf(role) + 1;
|
|
68201
|
+
var CHILD_COLUMNS_SHOWN = 5;
|
|
68202
|
+
function bindTable(at2, entity, wanted, aliased, live, missing) {
|
|
68203
|
+
const tables = aliased.filter((candidate) => candidate.table.name === entity.label);
|
|
68204
|
+
if (tables.length !== 1) {
|
|
68205
|
+
missing.push(
|
|
68206
|
+
tables.length === 0 ? `${at2}: no table is labelled "${entity.label}" (entity "${entity.alias}")` : `${at2}: ${tables.length} tables are labelled "${entity.label}" (${tables.map((candidate) => candidate.table.id).join(", ")}) \u2014 a label binds one`
|
|
68207
|
+
);
|
|
68208
|
+
return void 0;
|
|
68209
|
+
}
|
|
68210
|
+
const table = tables[0];
|
|
68211
|
+
const liveType = new Map(live.find((candidate) => candidate.id === table.table.id)?.fields.map((field) => [field.id, field.type]) ?? []);
|
|
68212
|
+
const fields = /* @__PURE__ */ new Map();
|
|
68213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
68214
|
+
for (const field of wanted) {
|
|
68215
|
+
if (seen.has(field.alias)) continue;
|
|
68216
|
+
seen.add(field.alias);
|
|
68217
|
+
const found = table.fields.filter((candidate) => candidate.field.name === field.label);
|
|
68218
|
+
if (found.length !== 1) {
|
|
68219
|
+
missing.push(
|
|
68220
|
+
found.length === 0 ? `${at2}: table "${table.table.name}" has no field labelled "${field.label}" (field "${field.alias}")` : `${at2}: table "${table.table.name}" has ${found.length} fields labelled "${field.label}" (${found.map((candidate) => candidate.field.id).join(", ")}) \u2014 a label binds one`
|
|
68221
|
+
);
|
|
68222
|
+
continue;
|
|
68223
|
+
}
|
|
68224
|
+
const type = liveType.get(found[0].field.id);
|
|
68225
|
+
if (type !== field.type) {
|
|
68226
|
+
missing.push(`${at2}: field "${field.label}" is a ${type ?? "field of unknown type"} in this workspace and a ${field.type} in the model`);
|
|
68227
|
+
continue;
|
|
68228
|
+
}
|
|
68229
|
+
fields.set(field.alias, { alias: found[0].alias, id: found[0].field.id, type: field.type, label: field.label });
|
|
68230
|
+
}
|
|
68231
|
+
return { table: table.table, tableAlias: table.alias, fields };
|
|
68232
|
+
}
|
|
68233
|
+
function bindScreens(screens, live, roles, entities) {
|
|
68029
68234
|
const aliased = aliasTables([...live]);
|
|
68030
68235
|
const bound = [];
|
|
68031
68236
|
const missing = [];
|
|
68032
68237
|
for (const screen of screens) {
|
|
68033
68238
|
const at2 = `${screen.app.alias}.${screen.screen.alias}`;
|
|
68034
|
-
const tables = aliased.filter((candidate) => candidate.table.name === screen.entity.label);
|
|
68035
|
-
if (tables.length !== 1) {
|
|
68036
|
-
missing.push(
|
|
68037
|
-
tables.length === 0 ? `${at2}: no table is labelled "${screen.entity.label}" (entity "${screen.entity.alias}")` : `${at2}: ${tables.length} tables are labelled "${screen.entity.label}" (${tables.map((candidate) => candidate.table.id).join(", ")}) \u2014 a label binds one`
|
|
68038
|
-
);
|
|
68039
|
-
continue;
|
|
68040
|
-
}
|
|
68041
|
-
const table = tables[0];
|
|
68042
|
-
const liveType = new Map(live.find((candidate) => candidate.id === table.table.id)?.fields.map((field) => [field.id, field.type]) ?? []);
|
|
68043
|
-
const fields = /* @__PURE__ */ new Map();
|
|
68044
|
-
const limits = /* @__PURE__ */ new Map();
|
|
68045
68239
|
const identity = screen.slots.find((slot2) => slot2.role === "identity" && slot2.field !== null)?.field ?? null;
|
|
68240
|
+
const limits = /* @__PURE__ */ new Map();
|
|
68046
68241
|
const wanted = [
|
|
68047
68242
|
...identity === null ? [] : [identity],
|
|
68048
68243
|
...screen.slots.flatMap((slot2) => slot2.field === null || slot2.field === identity ? [] : [slot2.field]),
|
|
@@ -68057,36 +68252,82 @@ function bindScreens(screens, live, roles) {
|
|
|
68057
68252
|
wanted.push(limit);
|
|
68058
68253
|
limits.set(slot2.field.alias, limit.alias);
|
|
68059
68254
|
}
|
|
68060
|
-
|
|
68061
|
-
|
|
68062
|
-
|
|
68063
|
-
|
|
68064
|
-
|
|
68065
|
-
|
|
68066
|
-
|
|
68067
|
-
|
|
68068
|
-
|
|
68069
|
-
|
|
68070
|
-
|
|
68071
|
-
|
|
68255
|
+
wanted.push(...screen.entity.fields);
|
|
68256
|
+
const own = bindTable(at2, screen.entity, wanted, aliased, live, missing);
|
|
68257
|
+
if (own === void 0) continue;
|
|
68258
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen));
|
|
68259
|
+
const children = /* @__PURE__ */ new Map();
|
|
68260
|
+
const bindChild = (section, child, parentField, drawn, set2, files) => {
|
|
68261
|
+
const found = bindTable(`${at2}.${child.alias}`, child, [parentField, ...drawn], aliased, live, missing);
|
|
68262
|
+
if (found === void 0) return;
|
|
68263
|
+
const parent = found.fields.get(parentField.alias);
|
|
68264
|
+
if (parent === void 0) return;
|
|
68265
|
+
const drawnRoles = /* @__PURE__ */ new Map();
|
|
68266
|
+
const fields = /* @__PURE__ */ new Map();
|
|
68267
|
+
for (const field of drawn) {
|
|
68268
|
+
const boundField = found.fields.get(field.alias);
|
|
68269
|
+
if (boundField === void 0) continue;
|
|
68270
|
+
fields.set(field.alias, boundField);
|
|
68271
|
+
const role = roleOf(roles, child.alias, field.alias)?.role;
|
|
68272
|
+
if (role !== void 0) drawnRoles.set(field.alias, role);
|
|
68273
|
+
}
|
|
68274
|
+
children.set(child.alias, {
|
|
68275
|
+
section,
|
|
68276
|
+
// Keyed by the ENTITY, not the screen: two screens over one entity read
|
|
68277
|
+
// the same rows through the same filter, and a second alias for them is
|
|
68278
|
+
// a second manifest entry, a second `.d.ts` entry and a second cache key
|
|
68279
|
+
// for one query.
|
|
68280
|
+
alias: `${screen.entity.alias}_${child.alias}`,
|
|
68281
|
+
param: `${screen.entity.alias}_id`,
|
|
68282
|
+
entity: child,
|
|
68283
|
+
table: found.table,
|
|
68284
|
+
tableAlias: found.tableAlias,
|
|
68285
|
+
parent,
|
|
68286
|
+
fields,
|
|
68287
|
+
roles: drawnRoles,
|
|
68288
|
+
identity: child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
|
|
68289
|
+
set: set2?.alias,
|
|
68290
|
+
files: files?.alias
|
|
68291
|
+
});
|
|
68292
|
+
};
|
|
68293
|
+
for (const section of sections) {
|
|
68294
|
+
if (section.kind === "children") {
|
|
68295
|
+
const drawn = section.fields.filter((field) => {
|
|
68296
|
+
const role = roleOf(roles, section.child.alias, field.alias)?.role;
|
|
68297
|
+
return role !== void 0 && DRAWN_ROLES.includes(role);
|
|
68298
|
+
});
|
|
68299
|
+
bindChild("children", section.child, section.parentField, drawn);
|
|
68072
68300
|
continue;
|
|
68073
68301
|
}
|
|
68074
|
-
|
|
68302
|
+
if (section.kind !== "expected_set" || section.source !== "child") continue;
|
|
68303
|
+
const name2 = section.child.fields.filter((field) => roleOf(roles, section.child.alias, field.alias)?.role === "identity");
|
|
68304
|
+
bindChild(
|
|
68305
|
+
"expected_set",
|
|
68306
|
+
section.child,
|
|
68307
|
+
section.parentField,
|
|
68308
|
+
[...name2, section.setField, ...section.filesField === void 0 ? [] : [section.filesField]],
|
|
68309
|
+
section.setField,
|
|
68310
|
+
section.filesField
|
|
68311
|
+
);
|
|
68075
68312
|
}
|
|
68076
|
-
bound.push({ screen, table:
|
|
68313
|
+
bound.push({ screen, table: own.table, tableAlias: own.tableAlias, fields: own.fields, limits, sections, children });
|
|
68077
68314
|
}
|
|
68078
68315
|
return { bound, missing };
|
|
68079
68316
|
}
|
|
68080
|
-
function planTableNames(screens) {
|
|
68081
|
-
|
|
68317
|
+
function planTableNames(screens, entities, roles) {
|
|
68318
|
+
const names = [];
|
|
68319
|
+
for (const screen of screens) {
|
|
68320
|
+
names.push(screen.entity.label);
|
|
68321
|
+
for (const section of recordSections(screen.entity, entities, roles, recordHeader(screen))) {
|
|
68322
|
+
const child = sectionChild(section);
|
|
68323
|
+
if (child !== void 0) names.push(child.label);
|
|
68324
|
+
}
|
|
68325
|
+
}
|
|
68326
|
+
return [...new Set(names)];
|
|
68082
68327
|
}
|
|
68083
68328
|
function planQueries(bound) {
|
|
68084
68329
|
const queries = {};
|
|
68085
68330
|
for (const entry of bound) {
|
|
68086
|
-
const columns = [];
|
|
68087
|
-
for (const field of entry.fields.values()) {
|
|
68088
|
-
columns.push(field.type === "files" ? { output: field.id, type: "files", source: field.id, limit: 1 } : field.id);
|
|
68089
|
-
}
|
|
68090
68331
|
const when = entry.screen.slots.find((slot2) => slot2.role === "when" && slot2.field !== null);
|
|
68091
68332
|
const whenId = when?.field == null ? void 0 : entry.fields.get(when.field.alias)?.id;
|
|
68092
68333
|
const sort = whenId === void 0 ? void 0 : [{ field_key: whenId, order: entry.screen.screen.shape === "lifecycle_desk" ? "asc" : "desc" }];
|
|
@@ -68094,10 +68335,32 @@ function planQueries(bound) {
|
|
|
68094
68335
|
ast: {
|
|
68095
68336
|
kind: "project",
|
|
68096
68337
|
from: { kind: "from_table", table_id: entry.table.id, ...sort === void 0 ? {} : { sort } },
|
|
68097
|
-
columns
|
|
68338
|
+
columns: [...entry.fields.values()].map((field) => field.id)
|
|
68098
68339
|
},
|
|
68099
68340
|
description: `${entry.screen.screen.label} \u2014 ${entry.screen.shapeLabel} over ${entry.screen.entity.label}`
|
|
68100
68341
|
};
|
|
68342
|
+
for (const child of entry.children.values()) {
|
|
68343
|
+
if (queries[child.alias] !== void 0) continue;
|
|
68344
|
+
queries[child.alias] = {
|
|
68345
|
+
ast: {
|
|
68346
|
+
kind: "project",
|
|
68347
|
+
from: {
|
|
68348
|
+
kind: "from_table",
|
|
68349
|
+
table_id: child.table.id,
|
|
68350
|
+
filter: {
|
|
68351
|
+
node_type: "condition",
|
|
68352
|
+
type: "select_record_link",
|
|
68353
|
+
field_key: child.parent.id,
|
|
68354
|
+
operator: "has_any_of",
|
|
68355
|
+
value: [`{{params.${child.param}}}`]
|
|
68356
|
+
}
|
|
68357
|
+
},
|
|
68358
|
+
columns: [...child.fields.values()].map((field) => field.id)
|
|
68359
|
+
},
|
|
68360
|
+
params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
|
|
68361
|
+
description: `${child.entity.label} \u2014 the rows under one ${entry.screen.entity.label}`
|
|
68362
|
+
};
|
|
68363
|
+
}
|
|
68101
68364
|
}
|
|
68102
68365
|
return queries;
|
|
68103
68366
|
}
|
|
@@ -68109,10 +68372,36 @@ var SHAPE_MODULE = {
|
|
|
68109
68372
|
monitored_asset_set: { module: "@lotics/ui/monitored_asset_set", component: "MonitoredAssetSet" },
|
|
68110
68373
|
trend_deep_dive: { module: "@lotics/ui/trend_deep_dive", component: "TrendDeepDive" }
|
|
68111
68374
|
};
|
|
68375
|
+
var RECORD_MODULES = {
|
|
68376
|
+
RecordPage: "@lotics/ui/record_page",
|
|
68377
|
+
RecordFacts: "@lotics/ui/record_facts",
|
|
68378
|
+
RecordProgress: "@lotics/ui/record_progress",
|
|
68379
|
+
RecordExpectedSet: "@lotics/ui/record_expected_set",
|
|
68380
|
+
RecordChildren: "@lotics/ui/record_children",
|
|
68381
|
+
RecordFiles: "@lotics/ui/record_files"
|
|
68382
|
+
};
|
|
68383
|
+
var SHAPE_CELLS = ["ContactCell", "DateCell", "IdentityCell", "MoneyCell", "NumberCell", "StageCell", "TextCell"];
|
|
68384
|
+
var SKELETON_ROWS = 5;
|
|
68385
|
+
var SKELETON_BAR = "72%";
|
|
68112
68386
|
var str = (value2) => JSON.stringify(value2);
|
|
68113
68387
|
function pascal(alias) {
|
|
68114
68388
|
return alias.split(/[^a-zA-Z0-9]+/).filter((part) => part !== "").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
68115
68389
|
}
|
|
68390
|
+
function camel(alias) {
|
|
68391
|
+
const name2 = pascal(alias);
|
|
68392
|
+
return name2 === "" ? "rows" : name2[0].toLowerCase() + name2.slice(1);
|
|
68393
|
+
}
|
|
68394
|
+
function currencyOf(field) {
|
|
68395
|
+
return "currency" in field && typeof field.currency === "string" ? field.currency : void 0;
|
|
68396
|
+
}
|
|
68397
|
+
function isMoney(field) {
|
|
68398
|
+
return currencyOf(field) !== void 0 || "format" in field && field.format === "currency";
|
|
68399
|
+
}
|
|
68400
|
+
function worthFormat(field) {
|
|
68401
|
+
if (!isMoney(field)) return `, format: "number"`;
|
|
68402
|
+
const stated = currencyOf(field);
|
|
68403
|
+
return stated === void 0 ? "" : `, currency: ${str(stated)}`;
|
|
68404
|
+
}
|
|
68116
68405
|
function slotProp(entry, roles, slot2) {
|
|
68117
68406
|
if (slot2.field === null) return null;
|
|
68118
68407
|
const field = entry.fields.get(slot2.field.alias);
|
|
@@ -68135,11 +68424,11 @@ function slotProp(entry, roles, slot2) {
|
|
|
68135
68424
|
const against = typeof decl.against === "number" ? `() => ${decl.against}` : `(r) => amount(r[T.${limit?.alias}])`;
|
|
68136
68425
|
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2}), against: ${against}, alert: ${str(decl.alert)} }}`;
|
|
68137
68426
|
}
|
|
68138
|
-
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2}) }}`;
|
|
68427
|
+
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${slot2.name === "worth" ? worthFormat(slot2.field) : ""} }}`;
|
|
68139
68428
|
}
|
|
68140
68429
|
case "amount": {
|
|
68141
|
-
const
|
|
68142
|
-
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${currency} }}`;
|
|
68430
|
+
const stated = currencyOf(slot2.field);
|
|
68431
|
+
return `${slot2.name}={{ label: ${label}, value: (r) => amount(${ref2})${stated === void 0 ? "" : `, currency: ${str(stated)}`} }}`;
|
|
68143
68432
|
}
|
|
68144
68433
|
case "when":
|
|
68145
68434
|
return `${slot2.name}={{ label: ${label}, value: (r) => row.text(${ref2}) || null }}`;
|
|
@@ -68157,6 +68446,13 @@ function slotProp(entry, roles, slot2) {
|
|
|
68157
68446
|
}
|
|
68158
68447
|
var CELLS = `import { readFiles, readLinks, readSelect, row } from "@lotics/app-sdk";
|
|
68159
68448
|
import { asColorName } from "@lotics/ui/colors";
|
|
68449
|
+
import { toDisplayFile, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
68450
|
+
import { formatDate } from "@lotics/ui/format_date";
|
|
68451
|
+
import { formatMoney } from "@lotics/ui/format_money";
|
|
68452
|
+
import type { FactValue } from "@lotics/ui/record_facts";
|
|
68453
|
+
|
|
68454
|
+
/** A select field's options, as the query's field-option read hands them over. */
|
|
68455
|
+
type OptionSource = { options: readonly { key: string; label: string; color?: string }[] } | undefined;
|
|
68160
68456
|
|
|
68161
68457
|
/** A number cell, or nothing \u2014 an empty cell is absent, never 0, and a decimal the query kept as text reads as its number. */
|
|
68162
68458
|
export const amount = (v: unknown): number | null =>
|
|
@@ -68168,79 +68464,560 @@ export const picture = (v: unknown): string | null => {
|
|
|
68168
68464
|
return file === undefined ? null : (file.thumbnail_url ?? file.url);
|
|
68169
68465
|
};
|
|
68170
68466
|
|
|
68467
|
+
/** A files cell as the kit draws a file \u2014 the conversion is the kit's, never an app's. */
|
|
68468
|
+
export const files = (v: unknown): DisplayFile[] => readFiles(v).map(toDisplayFile);
|
|
68469
|
+
|
|
68171
68470
|
/** A linked record's display name. */
|
|
68172
68471
|
export const party = (v: unknown): string | null => row.link(v)?.display ?? null;
|
|
68173
68472
|
|
|
68473
|
+
/** A linked record's id \u2014 where the link goes. */
|
|
68474
|
+
export const linked = (v: unknown): string => row.link(v)?.id ?? "";
|
|
68475
|
+
|
|
68174
68476
|
/** The option keys a select cell holds. */
|
|
68175
68477
|
export const keys = (v: unknown): string[] => readSelect(v).map((option) => option.key);
|
|
68176
68478
|
|
|
68177
68479
|
/** A select field's options as the stages a shape's strip or set is made of. */
|
|
68178
|
-
export const stagesOf = (field:
|
|
68480
|
+
export const stagesOf = (field: OptionSource) =>
|
|
68179
68481
|
(field?.options ?? []).map((option) => ({ key: option.key, label: option.label, color: option.color === undefined ? undefined : asColorName(option.color) }));
|
|
68180
68482
|
|
|
68483
|
+
/** The one option a cell holds, in the field's own colour. */
|
|
68484
|
+
export const stageOf = (field: OptionSource, v: unknown) => stagesOf(field).find((option) => option.key === row.opt(v)) ?? null;
|
|
68485
|
+
|
|
68486
|
+
/** The options a cell holds, in the field's own colours. */
|
|
68487
|
+
export const chosen = (field: OptionSource, v: unknown) => {
|
|
68488
|
+
const held = keys(v);
|
|
68489
|
+
return stagesOf(field).filter((option) => held.includes(option.key));
|
|
68490
|
+
};
|
|
68491
|
+
|
|
68492
|
+
/** The required options the record does not hold, by name \u2014 what is holding it where it is. */
|
|
68493
|
+
export const missing = (field: OptionSource, present: readonly string[]): string[] =>
|
|
68494
|
+
stagesOf(field)
|
|
68495
|
+
.filter((option) => !present.includes(option.key))
|
|
68496
|
+
.map((option) => option.label);
|
|
68497
|
+
|
|
68498
|
+
/** The gap in a required set, as a section's issue \u2014 what is MISSING, by name. Nothing missing, no issue. */
|
|
68499
|
+
export const gap = (field: OptionSource, present: readonly string[], say: (names: readonly string[]) => string) => {
|
|
68500
|
+
const absent = missing(field, present);
|
|
68501
|
+
return absent.length === 0 ? undefined : { level: "warning" as const, text: say(absent) };
|
|
68502
|
+
};
|
|
68503
|
+
|
|
68504
|
+
/** The option each child row was filed under \u2014 the present half of a required set. */
|
|
68505
|
+
export const filed = (rows: readonly Record<string, unknown>[], key: string): string[] =>
|
|
68506
|
+
rows.map((child) => row.opt(child[key]) ?? "").filter((option) => option !== "");
|
|
68507
|
+
|
|
68508
|
+
/** An amount as the headline figure, in the reader's locale. */
|
|
68509
|
+
export const money = (v: unknown, locale: string, none: string, currency?: string): string => {
|
|
68510
|
+
const value = amount(v);
|
|
68511
|
+
return value === null ? none : formatMoney(value, { locale, currency });
|
|
68512
|
+
};
|
|
68513
|
+
|
|
68514
|
+
/** A measure as the headline figure. */
|
|
68515
|
+
export const count = (v: unknown, locale: string, none: string): string => {
|
|
68516
|
+
const value = amount(v);
|
|
68517
|
+
return value === null ? none : value.toLocaleString(locale);
|
|
68518
|
+
};
|
|
68519
|
+
|
|
68520
|
+
/** A measure on the side of its limit that needs attention. */
|
|
68521
|
+
export const past = (value: unknown, limit: unknown, alert: "over" | "under"): boolean => {
|
|
68522
|
+
const read = amount(value);
|
|
68523
|
+
const against = amount(limit);
|
|
68524
|
+
if (read === null || against === null) return false;
|
|
68525
|
+
return alert === "over" ? read > against : read < against;
|
|
68526
|
+
};
|
|
68527
|
+
|
|
68528
|
+
/** A measure AND the limit it is judged by, as ONE fact \u2014 the meter the register's own row draws, the gap named under it. A row with no limit to read it against has nothing to be over or under, so it is the plain number. */
|
|
68529
|
+
export const level = (value: unknown, limit: unknown, alert: "over" | "under"): FactValue => {
|
|
68530
|
+
const against = amount(limit);
|
|
68531
|
+
return against === null
|
|
68532
|
+
? { kind: "number", value: amount(value) }
|
|
68533
|
+
: { kind: "level", value: amount(value), limit: against, alert };
|
|
68534
|
+
};
|
|
68535
|
+
|
|
68536
|
+
/** A date cell as the words a record's provenance line carries. */
|
|
68537
|
+
export const when = (v: unknown, locale: string): string => formatDate(row.text(v), { locale });
|
|
68538
|
+
|
|
68181
68539
|
/** Any cell, as the words a record surface shows for it. */
|
|
68182
68540
|
export const shown = (v: unknown): string => {
|
|
68183
68541
|
const options = readSelect(v);
|
|
68184
68542
|
if (options.length > 0) return options.map((option) => option.label).join(", ");
|
|
68185
68543
|
const links = readLinks(v);
|
|
68186
68544
|
if (links.length > 0) return links.map((link) => link.display).join(", ");
|
|
68187
|
-
const
|
|
68188
|
-
if (
|
|
68545
|
+
const attached = readFiles(v);
|
|
68546
|
+
if (attached.length > 0) return attached.map((file) => file.filename).join(", ");
|
|
68189
68547
|
if (typeof v === "boolean") return v ? "\u2713" : "";
|
|
68190
68548
|
return row.text(v);
|
|
68191
68549
|
};
|
|
68192
68550
|
`;
|
|
68193
|
-
|
|
68551
|
+
var CELL_HELPERS = [
|
|
68552
|
+
"amount",
|
|
68553
|
+
"chosen",
|
|
68554
|
+
"count",
|
|
68555
|
+
"filed",
|
|
68556
|
+
"files",
|
|
68557
|
+
"gap",
|
|
68558
|
+
"keys",
|
|
68559
|
+
"level",
|
|
68560
|
+
"linked",
|
|
68561
|
+
"missing",
|
|
68562
|
+
"money",
|
|
68563
|
+
"party",
|
|
68564
|
+
"past",
|
|
68565
|
+
"picture",
|
|
68566
|
+
"shown",
|
|
68567
|
+
"stageOf",
|
|
68568
|
+
"stagesOf",
|
|
68569
|
+
"when"
|
|
68570
|
+
];
|
|
68571
|
+
var SECTION_ICON = {
|
|
68572
|
+
facts: "info",
|
|
68573
|
+
progress: "workflow",
|
|
68574
|
+
/** A required set of PAPERS — one row per document the record owes. */
|
|
68575
|
+
documents: "file-stack",
|
|
68576
|
+
/** A required set of THINGS — services, checks — ticked off, nothing attached. */
|
|
68577
|
+
items: "list-checks",
|
|
68578
|
+
children: "list",
|
|
68579
|
+
files: "image"
|
|
68580
|
+
};
|
|
68581
|
+
function pagesByEntity(bound, app) {
|
|
68582
|
+
const pages = /* @__PURE__ */ new Map();
|
|
68583
|
+
for (const entry of bound) {
|
|
68584
|
+
if (entry.screen.app.alias !== app.alias || entry.screen.record !== "page") continue;
|
|
68585
|
+
if (!pages.has(entry.screen.entity.alias)) pages.set(entry.screen.entity.alias, entry.screen.screen.alias);
|
|
68586
|
+
}
|
|
68587
|
+
return pages;
|
|
68588
|
+
}
|
|
68589
|
+
function factSource(field, bound, role, ref2, options, pages, level) {
|
|
68590
|
+
const flag = `{ kind: "flag", value: row.bool(${ref2}), yes: words.record.yes, no: words.record.no }`;
|
|
68591
|
+
const value2 = (() => {
|
|
68592
|
+
if (role === "contact") return `{ kind: "contact", text: row.text(${ref2}) }`;
|
|
68593
|
+
if (role === "verdict") return flag;
|
|
68594
|
+
if (level !== void 0) return `level(${ref2}, ${level.against}, ${str(level.alert)})`;
|
|
68595
|
+
switch (field.type) {
|
|
68596
|
+
case "boolean":
|
|
68597
|
+
return flag;
|
|
68598
|
+
case "number": {
|
|
68599
|
+
const stated = currencyOf(field);
|
|
68600
|
+
return isMoney(field) ? `{ kind: "money", amount: amount(${ref2})${stated === void 0 ? "" : `, currency: ${str(stated)}`} }` : `{ kind: "number", value: amount(${ref2}) }`;
|
|
68601
|
+
}
|
|
68602
|
+
case "formula":
|
|
68603
|
+
case "rollup":
|
|
68604
|
+
return `{ kind: "number", value: amount(${ref2}) }`;
|
|
68605
|
+
case "date":
|
|
68606
|
+
return `{ kind: "date", date: row.text(${ref2}) || null }`;
|
|
68607
|
+
case "select":
|
|
68608
|
+
return field.multi === true ? `{ kind: "set", options: chosen(${options}, ${ref2}) }` : `{ kind: "stage", stage: stageOf(${options}, ${ref2}) }`;
|
|
68609
|
+
case "select_record_link": {
|
|
68610
|
+
const target = pages.get(field.target_entity);
|
|
68611
|
+
const open = target === void 0 ? "" : `, onOpen: () => navigate(${str(`/${target}/`)} + linked(${ref2}))`;
|
|
68612
|
+
return `{ kind: "link", name: party(${ref2})${open} }`;
|
|
68613
|
+
}
|
|
68614
|
+
case "files":
|
|
68615
|
+
return `{ kind: "files", files: files(${ref2}) }`;
|
|
68616
|
+
case "text":
|
|
68617
|
+
case "autonumber":
|
|
68618
|
+
return `{ kind: "text", text: row.text(${ref2}) }`;
|
|
68619
|
+
default:
|
|
68620
|
+
return `{ kind: "text", text: shown(${ref2}) }`;
|
|
68621
|
+
}
|
|
68622
|
+
})();
|
|
68623
|
+
return `{ label: ${str(bound.label)}, value: ${value2} }`;
|
|
68624
|
+
}
|
|
68625
|
+
function factsBlock(facts, pad) {
|
|
68626
|
+
return `<RecordFacts
|
|
68627
|
+
${pad} facts={[
|
|
68628
|
+
${facts.map((fact) => `${pad} ${fact},`).join("\n")}
|
|
68629
|
+
${pad} ]}
|
|
68630
|
+
${pad}/>`;
|
|
68631
|
+
}
|
|
68632
|
+
function levelRead(entry, level, read) {
|
|
68633
|
+
if (level === void 0) return void 0;
|
|
68634
|
+
if (typeof level.limit === "number") return { against: String(level.limit), alert: level.alert };
|
|
68635
|
+
const bound = entry.fields.get(level.limit.alias);
|
|
68636
|
+
return bound === void 0 ? void 0 : { against: read(bound.alias), alert: level.alert };
|
|
68637
|
+
}
|
|
68638
|
+
function factsSource(entry, roles, section, read, pages, pad) {
|
|
68639
|
+
const facts = section.fields.flatMap((field) => {
|
|
68640
|
+
const bound = entry.fields.get(field.alias);
|
|
68641
|
+
if (bound === void 0) return [];
|
|
68642
|
+
const role = roleOf(roles, entry.screen.entity.alias, field.alias)?.role;
|
|
68643
|
+
const level = levelRead(entry, section.levels[field.alias], read);
|
|
68644
|
+
return [factSource(field, bound, role, read(bound.alias), `fields[T.${bound.alias}]`, pages, level)];
|
|
68645
|
+
});
|
|
68646
|
+
return factsBlock(facts, pad);
|
|
68647
|
+
}
|
|
68648
|
+
function childFactsSource(child, pages, pad) {
|
|
68649
|
+
const rows = camel(child.alias);
|
|
68650
|
+
const facts = [...child.fields.keys()].flatMap((alias) => {
|
|
68651
|
+
const bound = child.fields.get(alias);
|
|
68652
|
+
const field = child.entity.fields.find((candidate) => candidate.alias === alias);
|
|
68653
|
+
if (bound === void 0 || field === void 0) return [];
|
|
68654
|
+
const ref2 = `child[F.${child.tableAlias}.${bound.alias}]`;
|
|
68655
|
+
return [factSource(field, bound, child.roles.get(alias), ref2, `${rows}Fields[F.${child.tableAlias}.${bound.alias}]`, pages)];
|
|
68656
|
+
});
|
|
68657
|
+
return factsBlock(facts, pad);
|
|
68658
|
+
}
|
|
68659
|
+
function roleColumn(spec) {
|
|
68660
|
+
const { role, bind, ref: ref2, options } = spec;
|
|
68661
|
+
const head = `{ key: ${str(spec.key)}, label: ${str(spec.label)}, priority: ${columnPriority(role)}`;
|
|
68662
|
+
switch (role) {
|
|
68663
|
+
case "identity":
|
|
68664
|
+
return `${head}, flex: 2, cell: (${bind}) => <IdentityCell title={${spec.type === "select_record_link" ? `party(${ref2}) ?? ""` : `row.text(${ref2})`}} /> }`;
|
|
68665
|
+
case "party":
|
|
68666
|
+
return `${head}, flex: 1, cell: (${bind}) => <TextCell value={party(${ref2}) ?? ""} /> }`;
|
|
68667
|
+
case "contact":
|
|
68668
|
+
return `${head}, width: 200, cell: (${bind}) => <ContactCell value={row.text(${ref2})} copyLabel={words.copyButton.copy} /> }`;
|
|
68669
|
+
case "when":
|
|
68670
|
+
return `${head}, width: 112, cell: (${bind}) => <DateCell value={row.text(${ref2})} /> }`;
|
|
68671
|
+
// An absent figure is ABSENT — a `?? 0` prints a plausible wrong number, and
|
|
68672
|
+
// the same value reads "—" in the facts beside it.
|
|
68673
|
+
case "measure":
|
|
68674
|
+
return `${head}, width: 96, align: "right", cell: (${bind}) => { const level = amount(${ref2}); return level === null ? null : <NumberCell value={level} />; } }`;
|
|
68675
|
+
case "amount":
|
|
68676
|
+
return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref2}); return sum === null ? null : <MoneyCell value={sum} />; } }`;
|
|
68677
|
+
case "lifecycle":
|
|
68678
|
+
return `${head}, width: 120, cell: (${bind}) => { const held = stageOf(${options}, ${ref2}); return held === null ? null : <StageCell stage={held} />; } }`;
|
|
68679
|
+
case "verdict":
|
|
68680
|
+
return `${head}, width: 120, cell: (${bind}) => { const answer = row.bool(${ref2}); return answer === null ? null : <TextCell value={answer ? words.record.yes : words.record.no} />; } }`;
|
|
68681
|
+
default:
|
|
68682
|
+
return `${head}, width: 120, cell: (${bind}) => <TextCell value={shown(${ref2})} /> }`;
|
|
68683
|
+
}
|
|
68684
|
+
}
|
|
68685
|
+
function childColumn(child, alias) {
|
|
68686
|
+
const field = child.fields.get(alias);
|
|
68687
|
+
const role = child.roles.get(alias);
|
|
68688
|
+
if (field === void 0 || role === void 0) return null;
|
|
68689
|
+
return roleColumn({
|
|
68690
|
+
role,
|
|
68691
|
+
key: field.alias,
|
|
68692
|
+
label: field.label,
|
|
68693
|
+
type: field.type,
|
|
68694
|
+
bind: "child",
|
|
68695
|
+
ref: `child[F.${child.tableAlias}.${field.alias}]`,
|
|
68696
|
+
options: `${camel(child.alias)}Fields[F.${child.tableAlias}.${field.alias}]`
|
|
68697
|
+
});
|
|
68698
|
+
}
|
|
68699
|
+
function childrenSource(child, pages, pad) {
|
|
68700
|
+
const rows = camel(child.alias);
|
|
68701
|
+
const drawn = [...child.fields.keys()].filter((alias) => child.roles.get(alias) !== void 0);
|
|
68702
|
+
const shown = new Set(
|
|
68703
|
+
[...drawn].sort((a, b) => columnPriority(child.roles.get(a) ?? "identity") - columnPriority(child.roles.get(b) ?? "identity")).slice(0, CHILD_COLUMNS_SHOWN)
|
|
68704
|
+
);
|
|
68705
|
+
const columns = drawn.flatMap((alias) => {
|
|
68706
|
+
const column = shown.has(alias) ? childColumn(child, alias) : null;
|
|
68707
|
+
return column === null ? [] : [column];
|
|
68708
|
+
});
|
|
68709
|
+
const name2 = child.identity === void 0 ? void 0 : child.fields.get(child.identity);
|
|
68710
|
+
const identity = name2 === void 0 ? `() => ""` : `(child) => ${name2.type === "select_record_link" ? `party(child[F.${child.tableAlias}.${name2.alias}]) ?? ""` : `row.text(child[F.${child.tableAlias}.${name2.alias}])`}`;
|
|
68711
|
+
return `<RecordChildren
|
|
68712
|
+
${pad} rows={${rows}.rows}
|
|
68713
|
+
${pad} rowKey={(child) => child.__source_record_id ?? ""}
|
|
68714
|
+
${pad} identity={${identity}}
|
|
68715
|
+
${pad} columns={[
|
|
68716
|
+
${columns.map((column) => `${pad} ${column},`).join("\n")}
|
|
68717
|
+
${pad} ]}
|
|
68718
|
+
${pad} record={{ render: (child) => (
|
|
68719
|
+
${pad} ${childFactsSource(child, pages, `${pad} `)}
|
|
68720
|
+
${pad} ) }}
|
|
68721
|
+
${pad} loading={${rows}.loading}
|
|
68722
|
+
${pad} error={${rows}.error === null ? undefined : { message: ${rows}.error, onRetry: ${rows}.refetch }}
|
|
68723
|
+
${pad}/>`;
|
|
68724
|
+
}
|
|
68725
|
+
function deskSource(child, pages, pad) {
|
|
68726
|
+
const rows = camel(child.alias);
|
|
68727
|
+
const set2 = child.set === void 0 ? void 0 : child.fields.get(child.set);
|
|
68728
|
+
if (set2 === void 0) return "";
|
|
68729
|
+
const key = `F.${child.tableAlias}.${set2.alias}`;
|
|
68730
|
+
const name2 = child.identity === void 0 ? void 0 : child.fields.get(child.identity);
|
|
68731
|
+
const attached = child.files === void 0 ? void 0 : child.fields.get(child.files);
|
|
68732
|
+
const target = pages.get(child.entity.alias);
|
|
68733
|
+
const named = name2 === void 0 ? `stageOf(${rows}Fields[${key}], child[${key}])?.label ?? ""` : name2.type === "select_record_link" ? `party(child[F.${child.tableAlias}.${name2.alias}]) ?? ""` : `row.text(child[F.${child.tableAlias}.${name2.alias}])`;
|
|
68734
|
+
const item = [
|
|
68735
|
+
`key: row.opt(child[${key}]) ?? ""`,
|
|
68736
|
+
`label: ${named}`,
|
|
68737
|
+
...attached === void 0 ? [] : [`files: files(child[F.${child.tableAlias}.${attached.alias}])`],
|
|
68738
|
+
...target === void 0 ? [] : [`onOpen: () => navigate(${str(`/${target}/`)} + (child.__source_record_id ?? ""))`]
|
|
68739
|
+
].join(", ");
|
|
68740
|
+
return `<RecordExpectedSet
|
|
68741
|
+
${pad} required={stagesOf(${rows}Fields[${key}])}
|
|
68742
|
+
${pad} present={${rows}.rows.map((child) => ({ ${item} }))}
|
|
68743
|
+
${pad} kind=${str(attached === void 0 ? "items" : "files")}${attached === void 0 ? "" : `
|
|
68744
|
+
${pad} onOpenFile={(file) => void openExternal(file.url)}`}
|
|
68745
|
+
${pad}/>`;
|
|
68746
|
+
}
|
|
68747
|
+
function recordSource(entry, roles, read, pages, pad, led) {
|
|
68748
|
+
const blockers = entry.sections.flatMap((section) => section.kind === "expected_set" && section.source === "own" ? [entry.fields.get(section.field.alias)] : []).flatMap((field) => field === void 0 ? [] : [`...missing(fields[T.${field.alias}], keys(${read(field.alias)}))`]).join(", ");
|
|
68749
|
+
let facts = null;
|
|
68750
|
+
const sections = [];
|
|
68751
|
+
for (const section of entry.sections) {
|
|
68752
|
+
switch (section.kind) {
|
|
68753
|
+
case "facts": {
|
|
68754
|
+
const { fields, levels } = section;
|
|
68755
|
+
facts = (at2) => factsSource(entry, roles, { fields, levels }, read, pages, at2);
|
|
68756
|
+
break;
|
|
68757
|
+
}
|
|
68758
|
+
case "progress": {
|
|
68759
|
+
const field = entry.fields.get(section.field.alias);
|
|
68760
|
+
if (field === void 0) break;
|
|
68761
|
+
sections.push({
|
|
68762
|
+
key: field.alias,
|
|
68763
|
+
label: field.label,
|
|
68764
|
+
icon: SECTION_ICON.progress,
|
|
68765
|
+
body: `<RecordProgress
|
|
68766
|
+
${pad} options={stagesOf(fields[T.${field.alias}])}
|
|
68767
|
+
${pad} value={row.opt(${read(field.alias)})}${blockers === "" ? "" : `
|
|
68768
|
+
${pad} blockers={[${blockers}]}`}
|
|
68769
|
+
${pad}/>`
|
|
68770
|
+
});
|
|
68771
|
+
break;
|
|
68772
|
+
}
|
|
68773
|
+
case "expected_set": {
|
|
68774
|
+
if (section.source === "own") {
|
|
68775
|
+
const field = entry.fields.get(section.field.alias);
|
|
68776
|
+
if (field === void 0) break;
|
|
68777
|
+
sections.push({
|
|
68778
|
+
key: field.alias,
|
|
68779
|
+
label: field.label,
|
|
68780
|
+
icon: SECTION_ICON.items,
|
|
68781
|
+
issue: `gap(fields[T.${field.alias}], keys(${read(field.alias)}), words.record.gap)`,
|
|
68782
|
+
// The entity's OWN multi-select: the chosen options are things the
|
|
68783
|
+
// record was sold or owes, not documents — there is nothing to
|
|
68784
|
+
// attach and nothing to show a face of.
|
|
68785
|
+
body: `<RecordExpectedSet
|
|
68786
|
+
${pad} required={stagesOf(fields[T.${field.alias}])}
|
|
68787
|
+
${pad} present={chosen(fields[T.${field.alias}], ${read(field.alias)}).map((option) => ({ key: option.key, label: option.label }))}
|
|
68788
|
+
${pad} kind="items"
|
|
68789
|
+
${pad}/>`
|
|
68790
|
+
});
|
|
68791
|
+
break;
|
|
68792
|
+
}
|
|
68793
|
+
const child = entry.children.get(section.child.alias);
|
|
68794
|
+
if (child === void 0 || child.set === void 0) break;
|
|
68795
|
+
const rows = camel(child.alias);
|
|
68796
|
+
const key = `F.${child.tableAlias}.${child.fields.get(child.set)?.alias ?? child.set}`;
|
|
68797
|
+
sections.push({
|
|
68798
|
+
key: child.entity.alias,
|
|
68799
|
+
label: child.entity.label,
|
|
68800
|
+
// A desk that attaches files is the DOCUMENTS it holds; one that
|
|
68801
|
+
// attaches nothing is a list of things ticked off.
|
|
68802
|
+
icon: child.files === void 0 ? SECTION_ICON.items : SECTION_ICON.documents,
|
|
68803
|
+
// The band says WHICH entry is missing, by name — a fraction is the
|
|
68804
|
+
// meter's caption, and the meter is already inside the section.
|
|
68805
|
+
issue: `gap(${rows}Fields[${key}], filed(${rows}.rows, ${key}), words.record.gap)`,
|
|
68806
|
+
body: deskSource(child, pages, pad)
|
|
68807
|
+
});
|
|
68808
|
+
break;
|
|
68809
|
+
}
|
|
68810
|
+
case "children": {
|
|
68811
|
+
const child = entry.children.get(section.child.alias);
|
|
68812
|
+
if (child === void 0) break;
|
|
68813
|
+
sections.push({
|
|
68814
|
+
key: child.entity.alias,
|
|
68815
|
+
label: child.entity.label,
|
|
68816
|
+
icon: SECTION_ICON.children,
|
|
68817
|
+
body: childrenSource(child, pages, pad)
|
|
68818
|
+
});
|
|
68819
|
+
break;
|
|
68820
|
+
}
|
|
68821
|
+
// ONE PILE, PICTURES FIRST: every files field the entity carries, the mark
|
|
68822
|
+
// leading, read into one section. A record's files are what it HOLDS, and
|
|
68823
|
+
// splitting them by the field they were filed in makes a reader open three
|
|
68824
|
+
// headings to see three photographs.
|
|
68825
|
+
//
|
|
68826
|
+
// EXCEPT THE ONE THE HEADER ALREADY DREW. A page leads with the mark's
|
|
68827
|
+
// picture, so a section over that same field restates it a hundred pixels
|
|
68828
|
+
// lower — and a section over what is LEFT of it reads "nothing here" on
|
|
68829
|
+
// the ordinary record whose likeness is one photograph, contradicting the
|
|
68830
|
+
// photograph above it. The mark is the header's; the section is every
|
|
68831
|
+
// OTHER files field, and a record with none has no files section.
|
|
68832
|
+
case "files": {
|
|
68833
|
+
const held = section.fields.flatMap((field) => {
|
|
68834
|
+
if (field.alias === led) return [];
|
|
68835
|
+
const bound = entry.fields.get(field.alias);
|
|
68836
|
+
return bound === void 0 ? [] : [{ field, bound }];
|
|
68837
|
+
});
|
|
68838
|
+
if (held.length === 0) break;
|
|
68839
|
+
const pile = held.length === 1 ? `files(${read(held[0].bound.alias)})` : `[${held.map(({ bound }) => `...files(${read(bound.alias)})`).join(", ")}]`;
|
|
68840
|
+
const marked = held.length === 1 && roleOf(roles, entry.screen.entity.alias, held[0].field.alias)?.role === "mark";
|
|
68841
|
+
sections.push({
|
|
68842
|
+
key: held[0].bound.alias,
|
|
68843
|
+
label: held.map(({ bound }) => bound.label).join(" \xB7 "),
|
|
68844
|
+
icon: SECTION_ICON.files,
|
|
68845
|
+
body: `<RecordFiles files={${pile}}${marked ? ` lead="photos"` : ""} />`
|
|
68846
|
+
});
|
|
68847
|
+
break;
|
|
68848
|
+
}
|
|
68849
|
+
}
|
|
68850
|
+
}
|
|
68851
|
+
return { facts, sections };
|
|
68852
|
+
}
|
|
68853
|
+
function recordPreamble(entry, text, source, scoped) {
|
|
68854
|
+
const lines = [];
|
|
68855
|
+
if (!scoped.navigate && text.includes("navigate(")) lines.push(` const navigate = useNavigate();`);
|
|
68856
|
+
if (text.includes("words.")) lines.push(` const words = useLoticsLocale();`);
|
|
68857
|
+
if (/\btag\b/.test(text)) lines.push(` const tag = useLocaleTag();`);
|
|
68858
|
+
if (!scoped.options && text.includes("fields[T.")) {
|
|
68859
|
+
lines.push(` const options = useFieldOptions(${str(entry.screen.screen.alias)});`, ` const { fields } = options;`);
|
|
68860
|
+
}
|
|
68861
|
+
if (entry.children.size > 0) lines.push(` const recordId = ${source};`);
|
|
68862
|
+
for (const child of entry.children.values()) {
|
|
68863
|
+
const rows = camel(child.alias);
|
|
68864
|
+
lines.push(` const ${rows} = useQuery(${str(child.alias)}, { ${child.param}: recordId }, { enabled: recordId !== "" });`);
|
|
68865
|
+
if (text.includes(`${rows}Fields[`)) lines.push(` const ${rows}Fields = useFieldOptions(${str(child.alias)}).fields;`);
|
|
68866
|
+
}
|
|
68867
|
+
return lines;
|
|
68868
|
+
}
|
|
68869
|
+
function drawerSection(section) {
|
|
68870
|
+
return ` <Section>
|
|
68871
|
+
<SectionHeading>
|
|
68872
|
+
<SectionHeadingTitle icon=${str(section.icon)}>${section.label}</SectionHeadingTitle>
|
|
68873
|
+
</SectionHeading>
|
|
68874
|
+
${section.body}
|
|
68875
|
+
</Section>`;
|
|
68876
|
+
}
|
|
68877
|
+
function pageSection(section) {
|
|
68878
|
+
const issue2 = section.issue === void 0 ? "" : `
|
|
68879
|
+
issue: ${section.issue},`;
|
|
68880
|
+
return ` {
|
|
68881
|
+
key: ${str(section.key)},
|
|
68882
|
+
label: ${str(section.label)},
|
|
68883
|
+
icon: ${str(section.icon)},${issue2}
|
|
68884
|
+
children: (
|
|
68885
|
+
${section.body}
|
|
68886
|
+
),
|
|
68887
|
+
}`;
|
|
68888
|
+
}
|
|
68889
|
+
function screenSource(entry, roles, pages) {
|
|
68194
68890
|
const { screen } = entry;
|
|
68195
68891
|
const name2 = pascal(screen.screen.alias);
|
|
68196
68892
|
const alias = screen.screen.alias;
|
|
68893
|
+
const shapeName = screen.screen.shape;
|
|
68894
|
+
const custom2 = shapeName === CUSTOM_SHAPE;
|
|
68895
|
+
const page = screen.record === "page";
|
|
68197
68896
|
const fieldsInOrder = [...entry.fields.values()];
|
|
68198
|
-
const recordRows = fieldsInOrder.map((field) => ` <DetailRow label=${str(field.label)}>
|
|
68199
|
-
<Text size="sm">{shown(r[T.${field.alias}])}</Text>
|
|
68200
|
-
</DetailRow>`).join("\n");
|
|
68201
68897
|
const identity = screen.slots.find((slot2) => slot2.role === "identity" && slot2.field !== null);
|
|
68202
68898
|
const identityField = identity?.field == null ? void 0 : entry.fields.get(identity.field.alias);
|
|
68203
|
-
const
|
|
68204
|
-
const
|
|
68205
|
-
const
|
|
68206
|
-
const
|
|
68207
|
-
|
|
68899
|
+
const read = (fieldAlias) => `${page ? "r?." : "r"}[T.${fieldAlias}]`;
|
|
68900
|
+
const named = (ref2) => identityField === void 0 ? void 0 : identityField.type === "select_record_link" ? `party(${ref2(identityField.alias)}) ?? ""` : `row.text(${ref2(identityField.alias)})`;
|
|
68901
|
+
const rowRead = (fieldAlias) => `r[T.${fieldAlias}]`;
|
|
68902
|
+
const identityRead = named(rowRead);
|
|
68903
|
+
const fallback = fieldsInOrder.length === 0 ? `""` : fieldsInOrder[0].alias;
|
|
68904
|
+
const rowTitle = identityRead ?? (fallback === `""` ? `""` : `shown(r[T.${fallback}])`);
|
|
68905
|
+
const pageTitle = named(read) ?? (fallback === `""` ? `""` : `shown(${read(fallback)})`);
|
|
68906
|
+
const markField = screen.entity.fields.find((field) => roleOf(roles, screen.entity.alias, field.alias)?.role === "mark");
|
|
68907
|
+
const markBound = markField === void 0 ? void 0 : entry.fields.get(markField.alias);
|
|
68908
|
+
const mark = markBound === void 0 ? "" : `
|
|
68909
|
+
mark={{ kind: ${str(shapeName === "party_register" ? "group" : "thing")}, name: ${pageTitle}, image: picture(${read(markBound.alias)}) }}`;
|
|
68910
|
+
const ledOnPage = markBound === void 0 ? void 0 : markField?.alias;
|
|
68911
|
+
const inDrawer = recordSource(entry, roles, read, pages, " ", void 0);
|
|
68912
|
+
const drawerBody = (() => {
|
|
68913
|
+
const parts = [
|
|
68914
|
+
...inDrawer.facts === null ? [] : [` ${inDrawer.facts(" ")}`],
|
|
68915
|
+
...inDrawer.sections.length === 0 ? [] : [` <SectionStack>
|
|
68916
|
+
${inDrawer.sections.map(drawerSection).join("\n")}
|
|
68917
|
+
</SectionStack>`]
|
|
68918
|
+
];
|
|
68919
|
+
return ` <Stack gap={24}>
|
|
68920
|
+
${parts.join("\n")}
|
|
68921
|
+
</Stack>`;
|
|
68922
|
+
})();
|
|
68923
|
+
const drawerPreamble = recordPreamble(entry, drawerBody, `r.__source_record_id ?? ""`, { navigate: false, options: false });
|
|
68924
|
+
const drawer = page || custom2 ? "" : `
|
|
68925
|
+
/** The record behind a row \u2014 the facts the plan left over, and the work its roles name. */
|
|
68208
68926
|
function ${name2}Record({ r }: { r: QueryRow }) {
|
|
68209
|
-
return (
|
|
68210
|
-
|
|
68211
|
-
${recordRows}
|
|
68212
|
-
</DetailTable>
|
|
68927
|
+
${drawerPreamble.join("\n")}${drawerPreamble.length === 0 ? "" : "\n"} return (
|
|
68928
|
+
${drawerBody}
|
|
68213
68929
|
);
|
|
68214
68930
|
}
|
|
68215
68931
|
`;
|
|
68932
|
+
const inPage = recordSource(entry, roles, read, pages, " ", ledOnPage);
|
|
68933
|
+
const pageSections = inPage.sections.length > 0 ? inPage.sections : [
|
|
68934
|
+
{
|
|
68935
|
+
key: "facts",
|
|
68936
|
+
label: screen.entity.label,
|
|
68937
|
+
icon: SECTION_ICON.facts,
|
|
68938
|
+
body: (inPage.facts ?? ((at2) => factsBlock([], at2)))(" ")
|
|
68939
|
+
}
|
|
68940
|
+
];
|
|
68941
|
+
const pageFacts = inPage.facts === null || inPage.sections.length === 0 ? "" : `
|
|
68942
|
+
facts={
|
|
68943
|
+
${inPage.facts(" ")}
|
|
68944
|
+
}`;
|
|
68945
|
+
const amountSlot = screen.slots.find((slot2) => slot2.role === "amount" && slot2.field !== null);
|
|
68946
|
+
const measureSlot = screen.slots.find((slot2) => slot2.role === "measure" && slot2.field !== null);
|
|
68947
|
+
const figure = (() => {
|
|
68948
|
+
if (amountSlot?.field != null) {
|
|
68949
|
+
const field2 = entry.fields.get(amountSlot.field.alias);
|
|
68950
|
+
if (field2 !== void 0) {
|
|
68951
|
+
const stated2 = currencyOf(amountSlot.field);
|
|
68952
|
+
return `
|
|
68953
|
+
figure={{ label: ${str(field2.label)}, value: money(${read(field2.alias)}, tag, words.record.none${stated2 === void 0 ? "" : `, ${str(stated2)}`}) }}`;
|
|
68954
|
+
}
|
|
68955
|
+
}
|
|
68956
|
+
if (measureSlot?.field == null) return "";
|
|
68957
|
+
const field = entry.fields.get(measureSlot.field.alias);
|
|
68958
|
+
if (field === void 0) return "";
|
|
68959
|
+
const decl = roleOf(roles, screen.entity.alias, measureSlot.field.alias);
|
|
68960
|
+
const limitAlias = entry.limits.get(measureSlot.field.alias);
|
|
68961
|
+
const limit = limitAlias === void 0 ? void 0 : entry.fields.get(limitAlias);
|
|
68962
|
+
const against = typeof decl?.against === "number" ? String(decl.against) : limit === void 0 ? void 0 : read(limit.alias);
|
|
68963
|
+
const tone = decl?.alert === void 0 || against === void 0 ? "" : `, tone: past(${read(field.alias)}, ${against}, ${str(decl.alert)}) ? "danger" : undefined`;
|
|
68964
|
+
const stated = currencyOf(measureSlot.field);
|
|
68965
|
+
const value2 = isMoney(measureSlot.field) ? `money(${read(field.alias)}, tag, words.record.none${stated === void 0 ? "" : `, ${str(stated)}`})` : `count(${read(field.alias)}, tag, words.record.none)`;
|
|
68966
|
+
return `
|
|
68967
|
+
figure={{ label: ${str(field.label)}, value: ${value2}${tone} }}`;
|
|
68968
|
+
})();
|
|
68969
|
+
const whenSlot = screen.slots.find((slot2) => slot2.role === "when" && slot2.field !== null);
|
|
68970
|
+
const whenField = whenSlot?.field == null ? void 0 : entry.fields.get(whenSlot.field.alias);
|
|
68971
|
+
const pageProps = ` title={${pageTitle}}${mark}${whenField === void 0 ? "" : `
|
|
68972
|
+
subtitle={when(${read(whenField.alias)}, tag) || undefined}`}${figure}
|
|
68973
|
+
back={{ label: ${str(screen.screen.label)}, onPress: () => navigate(-1) }}`;
|
|
68974
|
+
const pageReads = pageProps + pageFacts + pageSections.map(pageSection).join("");
|
|
68975
|
+
const pageState = pageReads.includes("fields[T.") ? ` loading={loading || options.loading}
|
|
68976
|
+
error={error === null ? (options.error === null ? undefined : { message: options.error, onRetry: options.refetch }) : { message: error, onRetry: refetch }}` : ` loading={loading}
|
|
68977
|
+
error={error === null ? undefined : { message: error, onRetry: refetch }}`;
|
|
68978
|
+
const pageBody = ` <RecordPage
|
|
68979
|
+
${pageProps}
|
|
68980
|
+
${pageState}
|
|
68981
|
+
missing={error === null && !loading && r === undefined}${pageFacts}
|
|
68982
|
+
sections={[
|
|
68983
|
+
${pageSections.map(pageSection).join(",\n")},
|
|
68984
|
+
]}
|
|
68985
|
+
/>`;
|
|
68986
|
+
const pagePreamble = recordPreamble(entry, pageBody, `r?.__source_record_id ?? ""`, { navigate: true, options: false });
|
|
68216
68987
|
const recordScreen = page ? `
|
|
68217
68988
|
/** The page a row opens: arrived at cold, so it stands without the list. */
|
|
68218
68989
|
export function ${name2}RecordScreen() {
|
|
68219
68990
|
const { id } = useParams();
|
|
68220
68991
|
const navigate = useNavigate();
|
|
68221
|
-
const
|
|
68222
|
-
const { rows } = useQuery(${str(alias)});
|
|
68992
|
+
const { rows, loading, error, refetch } = useQuery(${str(alias)});
|
|
68223
68993
|
const r = rows.find((candidate) => candidate.__source_record_id === id);
|
|
68224
|
-
return (
|
|
68225
|
-
|
|
68226
|
-
<BackButton onPress={() => navigate(-1)} />
|
|
68227
|
-
{r === undefined ? (
|
|
68228
|
-
<RegionState state="empty" message={words.empty} />
|
|
68229
|
-
) : (
|
|
68230
|
-
<>
|
|
68231
|
-
<RecordSummary title={${pageTitle}} />
|
|
68232
|
-
<${name2}Record r={r} />
|
|
68233
|
-
</>
|
|
68234
|
-
)}
|
|
68235
|
-
</Stack>
|
|
68994
|
+
${pagePreamble.join("\n")}${pagePreamble.length === 0 ? "" : "\n"} return (
|
|
68995
|
+
${pageBody}
|
|
68236
68996
|
);
|
|
68237
68997
|
}
|
|
68238
68998
|
` : "";
|
|
68239
68999
|
let body;
|
|
68240
69000
|
let shapeImport = "";
|
|
68241
|
-
const shapeName = screen.screen.shape;
|
|
68242
69001
|
if (shapeName === CUSTOM_SHAPE) {
|
|
68243
69002
|
const slots = screen.slots.map((slot2) => `${slot2.name} (${slot2.role}${slot2.field === null ? "" : `: ${slot2.field.label}`})`).join(", ");
|
|
69003
|
+
const drawn = screen.slots.flatMap((slot2) => {
|
|
69004
|
+
if (slot2.field === null || !DRAWN_ROLES.includes(slot2.role)) return [];
|
|
69005
|
+
const field = entry.fields.get(slot2.field.alias);
|
|
69006
|
+
if (field === void 0) return [];
|
|
69007
|
+
return [
|
|
69008
|
+
roleColumn({
|
|
69009
|
+
role: slot2.role,
|
|
69010
|
+
key: field.alias,
|
|
69011
|
+
label: field.label,
|
|
69012
|
+
type: field.type,
|
|
69013
|
+
bind: "r",
|
|
69014
|
+
ref: `r[T.${field.alias}]`,
|
|
69015
|
+
options: `fields[T.${field.alias}]`
|
|
69016
|
+
})
|
|
69017
|
+
];
|
|
69018
|
+
});
|
|
69019
|
+
const columns = drawn.length > 0 ? drawn : [`{ key: "identity", label: ${str(fieldsInOrder[0]?.label ?? "")}, flex: 2, cell: (r) => <IdentityCell title={${rowTitle}} /> }`];
|
|
69020
|
+
const usesFields = columns.some((column) => column.includes("fields["));
|
|
68244
69021
|
body = `
|
|
68245
69022
|
const T = F.${entry.tableAlias};
|
|
68246
69023
|
|
|
@@ -68248,27 +69025,54 @@ const T = F.${entry.tableAlias};
|
|
|
68248
69025
|
* ${screen.screen.label} \u2014 a CUSTOM screen: no registry shape fits it, so the plan
|
|
68249
69026
|
* declared its slots as roles and the kit is composed here by hand.
|
|
68250
69027
|
* Slots: ${slots}.
|
|
68251
|
-
*
|
|
68252
|
-
*
|
|
68253
|
-
*
|
|
69028
|
+
* Each column below is the cell that slot's ROLE names \u2014 the device a registry
|
|
69029
|
+
* shape draws that value with. Grow the screen from here
|
|
69030
|
+
* (\`node_modules/@lotics/ui/llms.txt\`), then file \`lotics report\` naming the
|
|
69031
|
+
* shape you wished existed.
|
|
68254
69032
|
*/
|
|
68255
69033
|
export function ${name2}Screen() {
|
|
68256
|
-
const { rows, loading, error, refetch } = useQuery(${str(alias)})
|
|
68257
|
-
const
|
|
68258
|
-
|
|
68259
|
-
|
|
68260
|
-
|
|
69034
|
+
const { rows, loading, error, refetch } = useQuery(${str(alias)});${usesFields ? `
|
|
69035
|
+
const options = useFieldOptions(${str(alias)});
|
|
69036
|
+
const { fields } = options;` : ""}
|
|
69037
|
+
const words = useLoticsLocale();${page ? "\n const navigate = useNavigate();" : ""}
|
|
69038
|
+
const columns: ShapeColumn<QueryRow>[] = [
|
|
69039
|
+
${columns.map((column) => ` ${column},`).join("\n")}
|
|
69040
|
+
];
|
|
69041
|
+
if (error !== null) return <RegionState state="error" message={error} onRetry={refetch} />;${usesFields ? `
|
|
69042
|
+
if (options.error !== null) return <RegionState state="error" message={options.error} onRetry={options.refetch} />;` : ""}
|
|
69043
|
+
// A READ IN FLIGHT WAITS AS THE REGISTER IT IS BECOMING, never a spinner: the
|
|
69044
|
+
// columns above are already known, so the rows land into this grid without
|
|
69045
|
+
// moving an edge. Five rows \u2014 enough for the grid to read as a register, too
|
|
69046
|
+
// few to claim how long the band will be \u2014 and each bar stops short of its
|
|
69047
|
+
// column so the row reads as cells rather than one slab.
|
|
69048
|
+
if (loading${usesFields ? " || options.loading" : ""})
|
|
69049
|
+
return (
|
|
69050
|
+
<Table columns={columns}>
|
|
69051
|
+
{Array.from({ length: ${SKELETON_ROWS} }, (_unused, index) => (
|
|
69052
|
+
<TableRow key={index}>
|
|
69053
|
+
{columns.map((column) => (
|
|
69054
|
+
<TableCell key={column.key}>
|
|
69055
|
+
<Skeleton width=${str(SKELETON_BAR)} />
|
|
69056
|
+
</TableCell>
|
|
69057
|
+
))}
|
|
69058
|
+
</TableRow>
|
|
69059
|
+
))}
|
|
69060
|
+
</Table>
|
|
69061
|
+
);
|
|
69062
|
+
if (rows.length === 0) return <RegionState state="empty" message={words.shape.empty} />;
|
|
68261
69063
|
return (
|
|
68262
|
-
<
|
|
69064
|
+
<Table columns={columns}>
|
|
68263
69065
|
{rows.map((r) => (
|
|
68264
|
-
|
|
68265
|
-
|
|
68266
|
-
|
|
69066
|
+
<TableRow key={r.__source_record_id}${page ? ` accessibilityLabel={${rowTitle}} onPress={() => navigate(${str(`/${alias}/`)} + (r.__source_record_id ?? ""))}` : ""}>
|
|
69067
|
+
{columns.map((column) => (
|
|
69068
|
+
<TableCell key={column.key}>{column.cell(r)}</TableCell>
|
|
69069
|
+
))}
|
|
69070
|
+
</TableRow>
|
|
68267
69071
|
))}
|
|
68268
|
-
</
|
|
69072
|
+
</Table>
|
|
68269
69073
|
);
|
|
68270
69074
|
}
|
|
68271
|
-
${
|
|
69075
|
+
${recordScreen}`;
|
|
68272
69076
|
} else {
|
|
68273
69077
|
const shape = SHAPE_MODULE[shapeName];
|
|
68274
69078
|
shapeImport = `import { ${shape.component} } from ${str(shape.module)};`;
|
|
@@ -68306,23 +69110,35 @@ ${state}
|
|
|
68306
69110
|
/>
|
|
68307
69111
|
);
|
|
68308
69112
|
}
|
|
68309
|
-
${
|
|
68310
|
-
}
|
|
68311
|
-
const sdk = [
|
|
68312
|
-
|
|
68313
|
-
|
|
69113
|
+
${drawer}${recordScreen}`;
|
|
69114
|
+
}
|
|
69115
|
+
const sdk = [
|
|
69116
|
+
body.includes("useFieldOptions(") ? "useFieldOptions" : "",
|
|
69117
|
+
body.includes("openExternal(") ? "openExternal" : "",
|
|
69118
|
+
body.includes("row.") ? "row" : "",
|
|
69119
|
+
"useQuery",
|
|
69120
|
+
// A screen whose record opens as a PAGE reads its row out of the query and
|
|
69121
|
+
// never types one of its own, so the row type is not always reached for.
|
|
69122
|
+
/\bQueryRow\b/.test(body) ? "type QueryRow" : ""
|
|
69123
|
+
].filter((each) => each !== "");
|
|
69124
|
+
const cells = CELL_HELPERS.filter((helper) => body.includes(`${helper}(`));
|
|
69125
|
+
const kit = (component, module2) => body.includes(`<${component}`) ? `import { ${component} } from ${str(module2)};` : "";
|
|
69126
|
+
const router = ["useNavigate", "useParams"].filter((hook) => body.includes(`${hook}()`));
|
|
69127
|
+
const sheet = [...SHAPE_CELLS.filter((cell) => body.includes(`<${cell} `)), ...body.includes("ShapeColumn<") ? ["type ShapeColumn"] : []];
|
|
69128
|
+
const locale = ["useLocaleTag", "useLoticsLocale"].filter((hook) => body.includes(`${hook}(`));
|
|
68314
69129
|
const imports = [
|
|
68315
69130
|
`import { ${sdk.join(", ")} } from "@lotics/app-sdk";`,
|
|
68316
|
-
|
|
68317
|
-
kit("BackButton", "@lotics/ui/back_button"),
|
|
68318
|
-
`import { DetailRow, DetailTable } from "@lotics/ui/detail_row";`,
|
|
69131
|
+
router.length === 0 ? "" : `import { ${router.join(", ")} } from "react-router";`,
|
|
68319
69132
|
shapeImport,
|
|
68320
|
-
|
|
68321
|
-
kit(
|
|
68322
|
-
kit("RecordSummary", "@lotics/ui/record_summary"),
|
|
69133
|
+
locale.length === 0 ? "" : `import { ${locale.join(", ")} } from "@lotics/ui/locale";`,
|
|
69134
|
+
...Object.entries(RECORD_MODULES).map(([component, module2]) => kit(component, module2)),
|
|
68323
69135
|
kit("RegionState", "@lotics/ui/region_state"),
|
|
69136
|
+
body.includes("<Section>") ? `import { Section, SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";` : "",
|
|
69137
|
+
kit("SectionStack", "@lotics/ui/section_stack"),
|
|
69138
|
+
sheet.length === 0 ? "" : `import { ${sheet.join(", ")} } from "@lotics/ui/shape_frame";`,
|
|
69139
|
+
kit("Skeleton", "@lotics/ui/skeleton"),
|
|
68324
69140
|
kit("Stack", "@lotics/ui/stack"),
|
|
68325
|
-
`import {
|
|
69141
|
+
body.includes("<Table ") ? `import { Table, TableCell, TableRow } from "@lotics/ui/table";` : "",
|
|
68326
69142
|
`import { F } from "../../.lotics/app_fields";`,
|
|
68327
69143
|
cells.length === 0 ? "" : `import { ${cells.join(", ")} } from "./cells";`
|
|
68328
69144
|
].filter((line) => line !== "");
|
|
@@ -68386,10 +69202,11 @@ export default function App() {
|
|
|
68386
69202
|
`;
|
|
68387
69203
|
}
|
|
68388
69204
|
function buildPlanFiles(app, bound, roles) {
|
|
69205
|
+
const pages = pagesByEntity(bound, app);
|
|
68389
69206
|
return [
|
|
68390
69207
|
{ path: "src/App.tsx", content: appSource(app, bound) },
|
|
68391
69208
|
{ path: "src/screens/cells.ts", content: CELLS },
|
|
68392
|
-
...bound.map((entry) => ({ path: `src/screens/${entry.screen.screen.alias}.tsx`, content: screenSource(entry, roles) }))
|
|
69209
|
+
...bound.map((entry) => ({ path: `src/screens/${entry.screen.screen.alias}.tsx`, content: screenSource(entry, roles, pages) }))
|
|
68393
69210
|
];
|
|
68394
69211
|
}
|
|
68395
69212
|
|
|
@@ -68942,8 +69759,8 @@ function noteCopiedContent(templates, knowledgeDocs) {
|
|
|
68942
69759
|
);
|
|
68943
69760
|
}
|
|
68944
69761
|
}
|
|
68945
|
-
async function libraryListPublic() {
|
|
68946
|
-
const [presets, packages] = await Promise.all([fetchPresetIndex(), fetchOfficialStarters()]);
|
|
69762
|
+
async function libraryListPublic(apiUrl) {
|
|
69763
|
+
const [presets, packages] = await Promise.all([fetchPresetIndex(), fetchOfficialStarters(apiUrl)]);
|
|
68947
69764
|
printPresetSection(presets);
|
|
68948
69765
|
printPackageSection(packages, "published by Lotics");
|
|
68949
69766
|
console.error(
|
|
@@ -69071,8 +69888,8 @@ function printPackageHead(pkg) {
|
|
|
69071
69888
|
console.log(`
|
|
69072
69889
|
version ${pkg.latest_version}`);
|
|
69073
69890
|
}
|
|
69074
|
-
async function libraryShowPublic(starter_id) {
|
|
69075
|
-
const pkg = await getOfficialStarter(starter_id);
|
|
69891
|
+
async function libraryShowPublic(apiUrl, starter_id) {
|
|
69892
|
+
const pkg = await getOfficialStarter(apiUrl, starter_id);
|
|
69076
69893
|
printPackageHead(pkg);
|
|
69077
69894
|
console.log(`trust official \u2014 reviewed by Lotics`);
|
|
69078
69895
|
printPackageEntities(pkg.contract.entities);
|
|
@@ -69280,7 +70097,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
69280
70097
|
}
|
|
69281
70098
|
|
|
69282
70099
|
// src/model_reference.md
|
|
69283
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "san_pham": { "ten": "identity", "anh": "mark" },\n "ton_kho": { "ton": { "role": "measure", "against": "ton_toi_thieu", "alert": "under" },\n "hieu_suat": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "kinh_doanh", "name": "Kinh doanh",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "khach_hang", "label": "Kh\xE1ch h\xE0ng", "shape": "party_register", "entity": "customer" },\n { "alias": "don_hang", "label": "\u0110\u01A1n h\xE0ng", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "bang_do", "label": "B\u1EA3ng \u0111o", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see.\n';
|
|
70100
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` links here. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet` and `lines:` a `RecordChildren`; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Each\nrecord is a header and facts here: neither entity carries a stage, a required\nset, files, or rows of its own. The Customers page states its name in the header\nand nowhere else; the Orders drawer has no `identity` to state, so every field\nit reads is a fact.\n';
|
|
69284
70101
|
|
|
69285
70102
|
// src/scaffold_commands.ts
|
|
69286
70103
|
function printModelReference() {
|
|
@@ -69411,6 +70228,39 @@ function resolvePlan(model) {
|
|
|
69411
70228
|
})
|
|
69412
70229
|
);
|
|
69413
70230
|
}
|
|
70231
|
+
function describeRecord(model, resolved) {
|
|
70232
|
+
const labels = (fields) => fields.map((field) => field.label).join(" \xB7 ");
|
|
70233
|
+
const header = recordHeader(resolved);
|
|
70234
|
+
const stated = [header.title, header.subtitle, header.figure].filter(
|
|
70235
|
+
(field) => field !== void 0
|
|
70236
|
+
);
|
|
70237
|
+
const sections = recordSections(resolved.entity, model.contract.entities, model.field_roles, header).map((section) => {
|
|
70238
|
+
switch (section.kind) {
|
|
70239
|
+
case "facts": {
|
|
70240
|
+
const fact = (field) => {
|
|
70241
|
+
const level = section.levels[field.alias];
|
|
70242
|
+
if (level === void 0) return field.label;
|
|
70243
|
+
return `${field.label} against ${typeof level.limit === "number" ? level.limit : level.limit.label}`;
|
|
70244
|
+
};
|
|
70245
|
+
return `facts (${section.fields.map(fact).join(" \xB7 ")})`;
|
|
70246
|
+
}
|
|
70247
|
+
case "progress":
|
|
70248
|
+
return section.field.type === "select" ? `progress: ${section.field.label} (${count(section.field.options.length, "stage")})` : `progress: ${section.field.label}`;
|
|
70249
|
+
case "expected_set": {
|
|
70250
|
+
const required2 = section.source === "own" ? section.field : section.setField;
|
|
70251
|
+
const name2 = section.source === "own" ? section.field.label : section.child.label;
|
|
70252
|
+
if (required2.type !== "select") return `documents: ${name2}`;
|
|
70253
|
+
const set2 = `${required2.options.length} required`;
|
|
70254
|
+
return section.source === "own" ? `documents: ${name2} (${set2})` : `documents: ${name2} (${required2.label}: ${set2})`;
|
|
70255
|
+
}
|
|
70256
|
+
case "children":
|
|
70257
|
+
return `lines: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
|
|
70258
|
+
case "files":
|
|
70259
|
+
return `files: ${labels(section.fields)}`;
|
|
70260
|
+
}
|
|
70261
|
+
});
|
|
70262
|
+
return [...stated.length === 0 ? [] : [`header (${labels(stated)})`], ...sections].join(" \xB7 ");
|
|
70263
|
+
}
|
|
69414
70264
|
function describePlan(model) {
|
|
69415
70265
|
const lines = [];
|
|
69416
70266
|
let lastApp;
|
|
@@ -69427,6 +70277,7 @@ function describePlan(model) {
|
|
|
69427
70277
|
lines.push(
|
|
69428
70278
|
` ${resolved.slots.map((entry) => `${entry.name} ${entry.field === null ? "(none)" : entry.field.label}`).join(" \xB7 ")}`
|
|
69429
70279
|
);
|
|
70280
|
+
lines.push(` record: ${describeRecord(model, resolved)}`);
|
|
69430
70281
|
}
|
|
69431
70282
|
return lines;
|
|
69432
70283
|
}
|
|
@@ -77492,7 +78343,7 @@ async function resolvePlanForCreate(client, from) {
|
|
|
77492
78343
|
if (typeof table_id !== "string" || typeof table_name !== "string") continue;
|
|
77493
78344
|
idsByName.set(table_name, [...idsByName.get(table_name) ?? [], table_id]);
|
|
77494
78345
|
}
|
|
77495
|
-
const names = planTableNames(screens);
|
|
78346
|
+
const names = planTableNames(screens, model.contract.entities, model.field_roles);
|
|
77496
78347
|
const absent = names.filter((name2) => (idsByName.get(name2) ?? []).length === 0);
|
|
77497
78348
|
const twice = names.filter((name2) => (idsByName.get(name2) ?? []).length > 1);
|
|
77498
78349
|
if (absent.length > 0) {
|
|
@@ -77509,7 +78360,7 @@ async function resolvePlanForCreate(client, from) {
|
|
|
77509
78360
|
const live = await client.getWorkspaceSchema(ids);
|
|
77510
78361
|
const unreadable = ids.filter((id) => !live.some((table) => table.id === id));
|
|
77511
78362
|
if (unreadable.length > 0) throw new Error(`could not read table(s) ${unreadable.join(", ")} \u2014 check this key's access.`);
|
|
77512
|
-
const { bound, missing } = bindScreens(screens, live, model.field_roles);
|
|
78363
|
+
const { bound, missing } = bindScreens(screens, live, model.field_roles, model.contract.entities);
|
|
77513
78364
|
if (missing.length > 0) {
|
|
77514
78365
|
throw new Error(
|
|
77515
78366
|
`the plan does not fit this workspace:
|
|
@@ -77541,7 +78392,7 @@ function withPlanFiles(starter, plan) {
|
|
|
77541
78392
|
async function appSetSubdomain(client, args) {
|
|
77542
78393
|
const meta3 = readAppMeta(process.cwd());
|
|
77543
78394
|
const result = await client.setAppSubdomain(meta3.app_id, args.subdomain);
|
|
77544
|
-
console.error(`Public address set:
|
|
78395
|
+
console.error(`Public address set: ${result.origin}`);
|
|
77545
78396
|
}
|
|
77546
78397
|
async function appRename(client, args) {
|
|
77547
78398
|
const meta3 = readAppMeta(process.cwd());
|
|
@@ -79363,33 +80214,37 @@ function signInInstructions(pending, options) {
|
|
|
79363
80214
|
${then}`;
|
|
79364
80215
|
}
|
|
79365
80216
|
async function startPendingLogin(email3) {
|
|
79366
|
-
const
|
|
80217
|
+
const apiUrl = envApiUrl();
|
|
80218
|
+
const request = await startCliLogin(apiUrl, email3);
|
|
79367
80219
|
const pending = {
|
|
79368
80220
|
request_id: request.request_id,
|
|
79369
80221
|
secret: request.secret,
|
|
79370
80222
|
code: request.code,
|
|
79371
80223
|
expires_at: request.expires_at,
|
|
79372
80224
|
email: email3,
|
|
79373
|
-
api_url:
|
|
80225
|
+
api_url: apiUrl
|
|
79374
80226
|
};
|
|
79375
80227
|
savePendingLogin(pending);
|
|
79376
80228
|
return pending;
|
|
79377
80229
|
}
|
|
79378
|
-
function saveApproval(state,
|
|
80230
|
+
function saveApproval(state, pending, options) {
|
|
79379
80231
|
requireNonEmpty(state.api_key, "api_key");
|
|
79380
80232
|
requireNonEmpty(state.organization_id, "organization_id");
|
|
79381
80233
|
requireNonEmpty(state.workspace_id, "workspace_id");
|
|
79382
80234
|
upsertProfile(state.organization_id, {
|
|
79383
80235
|
api_key: state.api_key,
|
|
79384
80236
|
org_name: state.organization_name,
|
|
80237
|
+
// The host that minted the key, taken from the request rather than re-read:
|
|
80238
|
+
// this is the whole of "which Lotics does this registration belong to".
|
|
80239
|
+
api_url: pending.api_url,
|
|
79385
80240
|
workspace_id: state.workspace_id
|
|
79386
80241
|
});
|
|
79387
80242
|
const existing = loadGlobalConfig() ?? {};
|
|
79388
|
-
saveGlobalConfig({ ...existing, email:
|
|
80243
|
+
saveGlobalConfig({ ...existing, email: pending.email });
|
|
79389
80244
|
setActiveOrg(state.organization_id, options?.local === true ? "local" : "global");
|
|
79390
80245
|
clearPendingLogin();
|
|
79391
80246
|
return {
|
|
79392
|
-
email:
|
|
80247
|
+
email: pending.email,
|
|
79393
80248
|
orgId: state.organization_id,
|
|
79394
80249
|
orgName: state.organization_name,
|
|
79395
80250
|
workspaceId: state.workspace_id
|
|
@@ -79399,14 +80254,15 @@ async function claimPendingLogin() {
|
|
|
79399
80254
|
const pending = loadPendingLogin();
|
|
79400
80255
|
if (!pending) return { status: "none" };
|
|
79401
80256
|
const expired = `The sign-in request expired. Run again: lotics auth login ${pending.email}`;
|
|
80257
|
+
const target = envApiUrl();
|
|
79402
80258
|
if (Date.parse(pending.expires_at) <= Date.now()) {
|
|
79403
80259
|
clearPendingLogin();
|
|
79404
|
-
return pending.api_url ===
|
|
80260
|
+
return pending.api_url === target ? { status: "gone", message: expired } : { status: "none" };
|
|
79405
80261
|
}
|
|
79406
|
-
if (pending.api_url !==
|
|
79407
|
-
const state = await pollCliLogin(pending.request_id, pending.secret);
|
|
80262
|
+
if (pending.api_url !== target) return { status: "none" };
|
|
80263
|
+
const state = await pollCliLogin(pending.api_url, pending.request_id, pending.secret);
|
|
79408
80264
|
if (state.status === "approved") {
|
|
79409
|
-
return { status: "signed_in", account: saveApproval(state, pending
|
|
80265
|
+
return { status: "signed_in", account: saveApproval(state, pending) };
|
|
79410
80266
|
}
|
|
79411
80267
|
if (state.status === "pending") {
|
|
79412
80268
|
return {
|
|
@@ -79427,14 +80283,14 @@ async function awaitCliLogin(email3, options) {
|
|
|
79427
80283
|
for (; ; ) {
|
|
79428
80284
|
let state;
|
|
79429
80285
|
try {
|
|
79430
|
-
state = await pollCliLogin(pending.request_id, pending.secret);
|
|
80286
|
+
state = await pollCliLogin(pending.api_url, pending.request_id, pending.secret);
|
|
79431
80287
|
} catch (error52) {
|
|
79432
80288
|
if (!(Date.now() < expiresAt)) throw error52;
|
|
79433
80289
|
await new Promise((resolve2) => setTimeout(resolve2, POLL_INTERVAL_MS));
|
|
79434
80290
|
continue;
|
|
79435
80291
|
}
|
|
79436
80292
|
if (state.status === "approved") {
|
|
79437
|
-
return saveApproval(state,
|
|
80293
|
+
return saveApproval(state, pending, options);
|
|
79438
80294
|
}
|
|
79439
80295
|
if (state.status === "expired") {
|
|
79440
80296
|
throw new Error(`That sign-in link expired. Run "lotics auth login ${email3}" again.`);
|
|
@@ -108451,8 +109307,8 @@ function prompt(question) {
|
|
|
108451
109307
|
});
|
|
108452
109308
|
});
|
|
108453
109309
|
}
|
|
108454
|
-
async function publicPost(path14, body) {
|
|
108455
|
-
const response = await fetch(`${
|
|
109310
|
+
async function publicPost(apiUrl, path14, body) {
|
|
109311
|
+
const response = await fetch(`${apiUrl}${path14}`, {
|
|
108456
109312
|
method: "POST",
|
|
108457
109313
|
headers: { "Content-Type": "application/json" },
|
|
108458
109314
|
body: JSON.stringify(body)
|
|
@@ -108465,7 +109321,8 @@ async function signupAccount(args) {
|
|
|
108465
109321
|
if (args.name) body.name = args.name;
|
|
108466
109322
|
const timezone = args.timezone ?? machineTimezone();
|
|
108467
109323
|
if (timezone) body.timezone = timezone;
|
|
108468
|
-
const
|
|
109324
|
+
const apiUrl = envApiUrl();
|
|
109325
|
+
const { ok, status, data: data2 } = await publicPost(apiUrl, "/v1/cli/signup", body);
|
|
108469
109326
|
if (!ok) {
|
|
108470
109327
|
if (status === 409) {
|
|
108471
109328
|
return { kind: "email_taken" };
|
|
@@ -108479,7 +109336,7 @@ async function signupAccount(args) {
|
|
|
108479
109336
|
const orgId = data2.organization_id;
|
|
108480
109337
|
const workspaceId = data2.workspace_id;
|
|
108481
109338
|
const orgName = args.name || args.email.split("@")[0];
|
|
108482
|
-
upsertProfile(orgId, { api_key: apiKey, org_name: orgName, workspace_id: workspaceId });
|
|
109339
|
+
upsertProfile(orgId, { api_key: apiKey, org_name: orgName, api_url: apiUrl, workspace_id: workspaceId });
|
|
108483
109340
|
const existing = loadGlobalConfig() ?? {};
|
|
108484
109341
|
saveGlobalConfig({ ...existing, email: args.email });
|
|
108485
109342
|
setActiveOrg(orgId, args.local ? "local" : "global");
|
|
@@ -108621,7 +109478,8 @@ async function handleSetup(providedKey, local) {
|
|
|
108621
109478
|
console.error("No API key provided.");
|
|
108622
109479
|
process.exit(1);
|
|
108623
109480
|
}
|
|
108624
|
-
const
|
|
109481
|
+
const apiUrl = envApiUrl();
|
|
109482
|
+
const client = new LoticsClient({ apiKey, apiUrl });
|
|
108625
109483
|
let info;
|
|
108626
109484
|
try {
|
|
108627
109485
|
info = await client.whoami();
|
|
@@ -108647,6 +109505,7 @@ Multiple workspaces in ${info.organization_name}. Run "lotics workspace select <
|
|
|
108647
109505
|
upsertProfile(info.organization_id, {
|
|
108648
109506
|
api_key: apiKey,
|
|
108649
109507
|
org_name: info.organization_name,
|
|
109508
|
+
api_url: apiUrl,
|
|
108650
109509
|
workspace_id: workspaceId
|
|
108651
109510
|
});
|
|
108652
109511
|
const existing = loadGlobalConfig() ?? {};
|
|
@@ -108684,9 +109543,9 @@ async function requireClient(flags, appWorkspaceId) {
|
|
|
108684
109543
|
}
|
|
108685
109544
|
const viewAsMemberId = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
|
|
108686
109545
|
noteScope({ org_id: ctx.orgId ?? void 0, workspace_id: ctx.workspaceId });
|
|
108687
|
-
flushSpool((events) => postEvents(
|
|
109546
|
+
flushSpool(ctx.apiUrl, (events) => postEvents(ctx.apiUrl, ctx.apiKey, events));
|
|
108688
109547
|
return {
|
|
108689
|
-
client: new LoticsClient({ apiKey: ctx.apiKey, workspaceId: ctx.workspaceId, viewAsMemberId }),
|
|
109548
|
+
client: new LoticsClient({ apiKey: ctx.apiKey, apiUrl: ctx.apiUrl, workspaceId: ctx.workspaceId, viewAsMemberId }),
|
|
108690
109549
|
ctx
|
|
108691
109550
|
};
|
|
108692
109551
|
}
|
|
@@ -108775,7 +109634,7 @@ function looksLikeModelFile(arg) {
|
|
|
108775
109634
|
}
|
|
108776
109635
|
async function main() {
|
|
108777
109636
|
setInvocation(commandPath(process.argv.slice(2)), sessionId(), VERSION);
|
|
108778
|
-
installTelemetry(startedAt);
|
|
109637
|
+
installTelemetry(envApiUrl(), startedAt);
|
|
108779
109638
|
const parsed = parseArgs(process.argv.slice(2));
|
|
108780
109639
|
const { restArgs, flags } = parsed;
|
|
108781
109640
|
let { command, subcommand, toolArgs } = parsed;
|
|
@@ -108995,7 +109854,7 @@ async function main() {
|
|
|
108995
109854
|
if (command === "library") {
|
|
108996
109855
|
if (subcommand === "list") {
|
|
108997
109856
|
if (resolveContext(flags) === null) {
|
|
108998
|
-
await libraryListPublic();
|
|
109857
|
+
await libraryListPublic(envApiUrl());
|
|
108999
109858
|
return;
|
|
109000
109859
|
}
|
|
109001
109860
|
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
@@ -109026,11 +109885,12 @@ async function main() {
|
|
|
109026
109885
|
}
|
|
109027
109886
|
if (flags.json) {
|
|
109028
109887
|
setMachineOutput(true);
|
|
109029
|
-
|
|
109030
|
-
|
|
109888
|
+
const shelfCtx = resolveContext(flags);
|
|
109889
|
+
if (shelfCtx === null) {
|
|
109890
|
+
emitJson(await getOfficialStarter(envApiUrl(), toolArgs));
|
|
109031
109891
|
return;
|
|
109032
109892
|
}
|
|
109033
|
-
const published = await readOfficialStarter(toolArgs);
|
|
109893
|
+
const published = await readOfficialStarter(shelfCtx.apiUrl, toolArgs);
|
|
109034
109894
|
if (published.ok) {
|
|
109035
109895
|
emitJson(published.package);
|
|
109036
109896
|
return;
|
|
@@ -109052,7 +109912,7 @@ async function main() {
|
|
|
109052
109912
|
return;
|
|
109053
109913
|
}
|
|
109054
109914
|
if (resolveContext(flags) === null) {
|
|
109055
|
-
await libraryShowPublic(toolArgs);
|
|
109915
|
+
await libraryShowPublic(envApiUrl(), toolArgs);
|
|
109056
109916
|
return;
|
|
109057
109917
|
}
|
|
109058
109918
|
const { client: client2, ctx: ctx2 } = await requireClient(flags);
|
|
@@ -109173,7 +110033,7 @@ async function main() {
|
|
|
109173
110033
|
}
|
|
109174
110034
|
noteScope({ org_id: reportCtx.orgId ?? void 0, workspace_id: reportCtx.workspaceId });
|
|
109175
110035
|
await reportCommand(
|
|
109176
|
-
new LoticsClient({ apiKey: reportCtx.apiKey, workspaceId: reportCtx.workspaceId }),
|
|
110036
|
+
new LoticsClient({ apiKey: reportCtx.apiKey, apiUrl: reportCtx.apiUrl, workspaceId: reportCtx.workspaceId }),
|
|
109177
110037
|
{
|
|
109178
110038
|
report: parsed2.report,
|
|
109179
110039
|
version: VERSION,
|
|
@@ -109205,23 +110065,28 @@ async function main() {
|
|
|
109205
110065
|
console.error('Not authenticated. Run "lotics auth signup", "lotics auth api-key <key>", or set LOTICS_API_KEY.');
|
|
109206
110066
|
process.exit(1);
|
|
109207
110067
|
}
|
|
109208
|
-
const client2 = new LoticsClient({ apiKey: ctx2.apiKey });
|
|
110068
|
+
const client2 = new LoticsClient({ apiKey: ctx2.apiKey, apiUrl: ctx2.apiUrl });
|
|
109209
110069
|
const info = await client2.whoami();
|
|
109210
110070
|
const existing = loadGlobalConfig() ?? {};
|
|
109211
110071
|
saveGlobalConfig({ ...existing, email: info.email });
|
|
109212
110072
|
const cached2 = existing.profiles?.[info.organization_id];
|
|
109213
110073
|
if (cached2 && cached2.org_name !== info.organization_name) {
|
|
109214
|
-
upsertProfile(info.organization_id, {
|
|
110074
|
+
upsertProfile(info.organization_id, {
|
|
110075
|
+
api_key: cached2.api_key,
|
|
110076
|
+
org_name: info.organization_name,
|
|
110077
|
+
api_url: cached2.api_url
|
|
110078
|
+
});
|
|
109215
110079
|
console.error(`Renamed since this key was saved: "${cached2.org_name}" \u2192 "${info.organization_name}" (updated locally)`);
|
|
109216
110080
|
}
|
|
109217
110081
|
const viewAs = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
|
|
109218
110082
|
if (flags.json) {
|
|
109219
|
-
console.log(JSON.stringify({ ...info, workspace_id: ctx2.workspaceId ?? null, source: ctx2.source, view_as: viewAs ?? null }, null, 2));
|
|
110083
|
+
console.log(JSON.stringify({ ...info, workspace_id: ctx2.workspaceId ?? null, api_url: ctx2.apiUrl, source: ctx2.source, view_as: viewAs ?? null }, null, 2));
|
|
109220
110084
|
} else {
|
|
109221
110085
|
console.log(`Name: ${info.name}`);
|
|
109222
110086
|
console.log(`Email: ${info.email}`);
|
|
109223
110087
|
console.log(`Org: ${info.organization_name} (${info.organization_id})`);
|
|
109224
110088
|
console.log(`Workspace: ${ctx2.workspaceId ?? "(none selected)"}`);
|
|
110089
|
+
console.log(`API: ${ctx2.apiUrl}`);
|
|
109225
110090
|
console.log(`Source: ${SOURCE_LABELS[ctx2.source]}`);
|
|
109226
110091
|
if (viewAs) console.log(`View as: ${viewAs} (admin preview; requires an admin key)`);
|
|
109227
110092
|
}
|
|
@@ -109302,7 +110167,7 @@ async function main() {
|
|
|
109302
110167
|
try {
|
|
109303
110168
|
const ctx2 = resolveContext(flags, appManifestWorkspaceId(command, subcommand, void 0, flags));
|
|
109304
110169
|
if (ctx2) {
|
|
109305
|
-
client2 = new LoticsClient({ apiKey: ctx2.apiKey, workspaceId: ctx2.workspaceId });
|
|
110170
|
+
client2 = new LoticsClient({ apiKey: ctx2.apiKey, apiUrl: ctx2.apiUrl, workspaceId: ctx2.workspaceId });
|
|
109306
110171
|
await resolveWorkspace(client2, ctx2);
|
|
109307
110172
|
}
|
|
109308
110173
|
} catch (err2) {
|
|
@@ -109323,7 +110188,7 @@ async function main() {
|
|
|
109323
110188
|
}
|
|
109324
110189
|
applyAppManifestWorkspace(ctx2, command, subcommand, projectDir, flags);
|
|
109325
110190
|
const viewAsMemberId = flags.viewAs ?? process.env.LOTICS_VIEW_AS;
|
|
109326
|
-
const client2 = new LoticsClient({ apiKey: ctx2.apiKey, workspaceId: ctx2.workspaceId, viewAsMemberId });
|
|
110191
|
+
const client2 = new LoticsClient({ apiKey: ctx2.apiKey, apiUrl: ctx2.apiUrl, workspaceId: ctx2.workspaceId, viewAsMemberId });
|
|
109327
110192
|
await resolveWorkspace(client2, ctx2);
|
|
109328
110193
|
await appCodegen({ projectDir, client: client2 });
|
|
109329
110194
|
return;
|
|
@@ -109353,7 +110218,11 @@ async function main() {
|
|
|
109353
110218
|
console.error("Note: a local pin (.lotics/config.json) overrides the global default in this directory. Use --local to change the pin here.");
|
|
109354
110219
|
}
|
|
109355
110220
|
}
|
|
109356
|
-
await validateOrgWorkspacePin(
|
|
110221
|
+
await validateOrgWorkspacePin(
|
|
110222
|
+
new LoticsClient({ apiKey: profile.api_key, apiUrl: profile.api_url }),
|
|
110223
|
+
orgId,
|
|
110224
|
+
profile
|
|
110225
|
+
);
|
|
109357
110226
|
return;
|
|
109358
110227
|
}
|
|
109359
110228
|
if (subcommand && subcommand !== "list") {
|
|
@@ -109372,6 +110241,7 @@ async function main() {
|
|
|
109372
110241
|
orgIds.map((id) => ({
|
|
109373
110242
|
org_id: id,
|
|
109374
110243
|
org_name: profiles[id].org_name,
|
|
110244
|
+
api_url: profiles[id].api_url,
|
|
109375
110245
|
workspace_id: profiles[id].workspace_id ?? null,
|
|
109376
110246
|
active: id === effectiveActive
|
|
109377
110247
|
})),
|
|
@@ -109381,7 +110251,7 @@ async function main() {
|
|
|
109381
110251
|
} else {
|
|
109382
110252
|
for (const id of orgIds) {
|
|
109383
110253
|
const marker = id === effectiveActive ? " (active)" : "";
|
|
109384
|
-
console.log(`${id} ${profiles[id].org_name}${marker}`);
|
|
110254
|
+
console.log(`${id} ${profiles[id].org_name} ${profiles[id].api_url}${marker}`);
|
|
109385
110255
|
}
|
|
109386
110256
|
}
|
|
109387
110257
|
return;
|
|
@@ -109414,7 +110284,7 @@ async function main() {
|
|
|
109414
110284
|
console.error(" lotics app workflow pull Rewrite src/workflows/*.ts from the server");
|
|
109415
110285
|
console.error(" lotics app workflow check [alias] Typecheck src/workflows bodies locally");
|
|
109416
110286
|
console.error(" lotics app query set <alias> Push lotics.queries.<alias> to apps.queries (no deploy)");
|
|
109417
|
-
console.error(" lotics app subdomain <new-subdomain> Rename the app's public
|
|
110287
|
+
console.error(" lotics app subdomain <new-subdomain> Rename the app's public address (its subdomain)");
|
|
109418
110288
|
console.error(` lotics app rename "<new name>" Rename the app's display name (launcher title)`);
|
|
109419
110289
|
console.error(" lotics app dev [path] Run the app locally with HMR + RPC forwarding");
|
|
109420
110290
|
process.exit(1);
|
|
@@ -109634,7 +110504,7 @@ Available workspaces:`);
|
|
|
109634
110504
|
const newSubdomain = toolArgs;
|
|
109635
110505
|
if (!newSubdomain) {
|
|
109636
110506
|
console.error("Usage: lotics app subdomain <new-subdomain>");
|
|
109637
|
-
console.error("Renames the app's public address \u2014
|
|
110507
|
+
console.error("Renames the app's public address \u2014 its subdomain under the apps domain");
|
|
109638
110508
|
process.exit(1);
|
|
109639
110509
|
}
|
|
109640
110510
|
await appSetSubdomain(client, { subdomain: newSubdomain });
|