@lotics/cli 0.258.0 → 0.258.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +153 -12
- package/docs/cli_reference.md +1 -1
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -52,7 +52,7 @@ var __toESM = (mod2, isNodeMode, target) => (target = mod2 != null ? __create(__
|
|
|
52
52
|
var define_LOTICS_KIT_VERSIONS_default;
|
|
53
53
|
var init_define_LOTICS_KIT_VERSIONS = __esm({
|
|
54
54
|
"<define:__LOTICS_KIT_VERSIONS__>"() {
|
|
55
|
-
define_LOTICS_KIT_VERSIONS_default = { runtime: "0.41.
|
|
55
|
+
define_LOTICS_KIT_VERSIONS_default = { runtime: "0.41.1" };
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -63933,7 +63933,7 @@ function resultSideEffects(result) {
|
|
|
63933
63933
|
|
|
63934
63934
|
// src/version.ts
|
|
63935
63935
|
init_define_LOTICS_KIT_VERSIONS();
|
|
63936
|
-
var VERSION = "0.258.
|
|
63936
|
+
var VERSION = "0.258.1";
|
|
63937
63937
|
|
|
63938
63938
|
// src/timezone.ts
|
|
63939
63939
|
init_define_LOTICS_KIT_VERSIONS();
|
|
@@ -77997,6 +77997,54 @@ async function queryShapeSchema(shape, fieldsByTableId) {
|
|
|
77997
77997
|
}
|
|
77998
77998
|
}
|
|
77999
77999
|
|
|
78000
|
+
// ../shared/src/app_query_index_targets.ts
|
|
78001
|
+
init_define_LOTICS_KIT_VERSIONS();
|
|
78002
|
+
var TEXT_FILTER_OPERATORS = /* @__PURE__ */ new Set(["equals", "is_any_of"]);
|
|
78003
|
+
var NUMBER_FILTER_OPERATORS = /* @__PURE__ */ new Set([
|
|
78004
|
+
"equals",
|
|
78005
|
+
"greater_than",
|
|
78006
|
+
"less_than",
|
|
78007
|
+
"greater_than_or_equal_to",
|
|
78008
|
+
"less_than_or_equal_to"
|
|
78009
|
+
]);
|
|
78010
|
+
var DATE_FILTER_OPERATORS = /* @__PURE__ */ new Set([
|
|
78011
|
+
"on",
|
|
78012
|
+
"before",
|
|
78013
|
+
"after",
|
|
78014
|
+
"on_or_before",
|
|
78015
|
+
"on_or_after",
|
|
78016
|
+
"between"
|
|
78017
|
+
]);
|
|
78018
|
+
var MEMBERSHIP_FIELD_TYPES = /* @__PURE__ */ new Set(["select", "select_member", "select_record_link"]);
|
|
78019
|
+
var MEMBERSHIP_OPERATORS = /* @__PURE__ */ new Set(["has_any_of", "has_all_of", "is_current_member"]);
|
|
78020
|
+
function conditionUsesIndex(field2, operator) {
|
|
78021
|
+
const category = fieldValueCategory(field2);
|
|
78022
|
+
if (category !== null && filterOperatorSargable(category, operator)) return true;
|
|
78023
|
+
return MEMBERSHIP_FIELD_TYPES.has(field2.type) && MEMBERSHIP_OPERATORS.has(operator);
|
|
78024
|
+
}
|
|
78025
|
+
function fieldValueCategory(field2) {
|
|
78026
|
+
switch (field2.type) {
|
|
78027
|
+
case "text":
|
|
78028
|
+
return "text";
|
|
78029
|
+
case "number":
|
|
78030
|
+
return "number";
|
|
78031
|
+
case "date":
|
|
78032
|
+
return "date";
|
|
78033
|
+
default:
|
|
78034
|
+
return null;
|
|
78035
|
+
}
|
|
78036
|
+
}
|
|
78037
|
+
function filterOperatorSargable(category, operator) {
|
|
78038
|
+
switch (category) {
|
|
78039
|
+
case "text":
|
|
78040
|
+
return TEXT_FILTER_OPERATORS.has(operator);
|
|
78041
|
+
case "number":
|
|
78042
|
+
return NUMBER_FILTER_OPERATORS.has(operator);
|
|
78043
|
+
case "date":
|
|
78044
|
+
return DATE_FILTER_OPERATORS.has(operator);
|
|
78045
|
+
}
|
|
78046
|
+
}
|
|
78047
|
+
|
|
78000
78048
|
// src/app_query_check.ts
|
|
78001
78049
|
var tableAnswerSchema = zod_default.object({ workspace_id: zod_default.string(), fields: zod_default.array(tableFieldSchema) });
|
|
78002
78050
|
async function readTable(client, workspaceId, tableId2) {
|
|
@@ -78015,32 +78063,118 @@ async function checkQueries(client, workspaceId, queries) {
|
|
|
78015
78063
|
reads.set(tableId2, known);
|
|
78016
78064
|
return known;
|
|
78017
78065
|
};
|
|
78066
|
+
const fieldsByTableId = /* @__PURE__ */ new Map();
|
|
78018
78067
|
const verdicts = [];
|
|
78068
|
+
const passed = [];
|
|
78019
78069
|
for (const [alias2, declaration] of Object.entries(queries).sort(([a], [b]) => a < b ? -1 : 1)) {
|
|
78020
78070
|
const unreadable = [];
|
|
78021
78071
|
const load = async (tableId2) => {
|
|
78022
78072
|
const table = await read2(tableId2);
|
|
78023
78073
|
if (table.kind === "unreadable") unreadable.push(table.reason);
|
|
78024
|
-
|
|
78074
|
+
if (table.kind !== "fields") return null;
|
|
78075
|
+
fieldsByTableId.set(tableId2, table.fields);
|
|
78076
|
+
return table.fields;
|
|
78025
78077
|
};
|
|
78026
|
-
const
|
|
78027
|
-
|
|
78078
|
+
const gate = await gateQuery(alias2, declaration, load);
|
|
78079
|
+
if (gate.ok) passed.push({ alias: alias2, declaration: gate.value });
|
|
78080
|
+
verdicts.push({ alias: alias2, refusal: gate.ok ? null : gate.refusal, unreadable: unreadable[0] });
|
|
78028
78081
|
}
|
|
78029
78082
|
return {
|
|
78030
78083
|
refused: verdicts.flatMap(({ alias: alias2, refusal, unreadable }) => refusal === null || unreadable !== void 0 ? [] : [{ alias: alias2, refusal }]),
|
|
78031
|
-
unreached: verdicts.flatMap(({ alias: alias2, refusal, unreadable }) => refusal !== null && unreadable !== void 0 ? [{ alias: alias2, reason: unreadable }] : [])
|
|
78084
|
+
unreached: verdicts.flatMap(({ alias: alias2, refusal, unreadable }) => refusal !== null && unreadable !== void 0 ? [{ alias: alias2, reason: unreadable }] : []),
|
|
78085
|
+
fullScans: await fullTableScans(client, passed, fieldsByTableId)
|
|
78032
78086
|
};
|
|
78033
78087
|
}
|
|
78034
|
-
async function
|
|
78088
|
+
async function gateQuery(alias2, declaration, load) {
|
|
78035
78089
|
const gate = queryDeclarationShapes(alias2, declaration, true);
|
|
78036
|
-
if (!gate.ok) return gate
|
|
78090
|
+
if (!gate.ok) return gate;
|
|
78037
78091
|
for (const shape of gate.value.shapes) {
|
|
78038
78092
|
const loaded = await loadQueryTables(shape, load);
|
|
78039
|
-
if (!loaded.ok) return loaded
|
|
78093
|
+
if (!loaded.ok) return loaded;
|
|
78040
78094
|
const schema = await queryShapeSchema(shape, loaded.value.fieldsByTableId);
|
|
78041
|
-
if (!schema.ok) return schema
|
|
78095
|
+
if (!schema.ok) return schema;
|
|
78042
78096
|
}
|
|
78043
|
-
return
|
|
78097
|
+
return { ok: true, value: gate.value.declaration };
|
|
78098
|
+
}
|
|
78099
|
+
var FULL_SCAN_WARNING_RECORDS = 2e3;
|
|
78100
|
+
var multiTableCountSchema = zod_default.object({
|
|
78101
|
+
_multi_table_count: zod_default.object({ tables: zod_default.array(zod_default.object({ table_id: zod_default.string(), table_name: zod_default.string(), total: zod_default.number() })) })
|
|
78102
|
+
});
|
|
78103
|
+
async function fullTableScans(client, queries, fieldsByTableId) {
|
|
78104
|
+
const scans = /* @__PURE__ */ new Map();
|
|
78105
|
+
for (const { alias: alias2, declaration } of queries) {
|
|
78106
|
+
const params = Object.entries(declaration.params ?? {});
|
|
78107
|
+
const required2 = params.flatMap(([name, param]) => param.required === false ? [] : [name]);
|
|
78108
|
+
const template = parseQueryNode(declaration.ast);
|
|
78109
|
+
const placeable = /* @__PURE__ */ new Set();
|
|
78110
|
+
const reach = (node) => {
|
|
78111
|
+
if (node.kind === "filter") reach(node.from);
|
|
78112
|
+
else if (node.kind === "union") node.sources.forEach(reach);
|
|
78113
|
+
else if (node.kind === "project" && node.from.kind === "from_table") placeable.add(node.from);
|
|
78114
|
+
};
|
|
78115
|
+
walk(template, (node) => {
|
|
78116
|
+
if (node.kind === "filter") reach(node.from);
|
|
78117
|
+
});
|
|
78118
|
+
walk(template, (node) => {
|
|
78119
|
+
if (node.kind !== "from_table" || placeable.has(node)) return;
|
|
78120
|
+
const fields = fieldsByTableId.get(node.table_id);
|
|
78121
|
+
if (fields === void 0) throw new Error(`Query "${alias2}" passed the gate without its table ${node.table_id} loaded.`);
|
|
78122
|
+
const read2 = /* @__PURE__ */ new Set();
|
|
78123
|
+
collectParamTokens([node.filter, node.search], read2);
|
|
78124
|
+
const optional2 = params.flatMap(([name, param]) => param.required === false && read2.has(name) ? [name] : []);
|
|
78125
|
+
const calls = [
|
|
78126
|
+
{ label: optional2.length === 0 ? "on every call" : `without ${optional2.map((name) => `\`${name}\``).join(" or ")}`, provided: required2 },
|
|
78127
|
+
...optional2.map((name) => ({ label: `with only \`${name}\` set`, provided: [...required2, name] }))
|
|
78128
|
+
];
|
|
78129
|
+
for (const call of calls) {
|
|
78130
|
+
const scan = pruneUnprovidedParamConditions(node, new Set(call.provided));
|
|
78131
|
+
if (scan.kind !== "from_table" || scan.filter === void 0 || scan.search?.trim()) continue;
|
|
78132
|
+
const faults = unservedConditions(scan.filter, fields);
|
|
78133
|
+
if (faults === null || faults.length === 0) continue;
|
|
78134
|
+
const key = `${alias2}
|
|
78135
|
+
${node.table_id}`;
|
|
78136
|
+
const found = scans.get(key) ?? { alias: alias2, tableId: node.table_id, calls: [], faults: /* @__PURE__ */ new Set() };
|
|
78137
|
+
if (!found.calls.includes(call.label)) found.calls.push(call.label);
|
|
78138
|
+
for (const fault of faults) found.faults.add(describeCondition(fault, fields));
|
|
78139
|
+
scans.set(key, found);
|
|
78140
|
+
}
|
|
78141
|
+
});
|
|
78142
|
+
}
|
|
78143
|
+
if (scans.size === 0) return [];
|
|
78144
|
+
const tableIds = [...new Set([...scans.values()].map(({ tableId: tableId2 }) => tableId2))];
|
|
78145
|
+
const res = await client.execute("query_records", { tables: tableIds.map((table_id) => ({ table_id })) }, { format: "json" });
|
|
78146
|
+
const counted = multiTableCountSchema.safeParse(res.result);
|
|
78147
|
+
const sizes = new Map(counted.success ? counted.data._multi_table_count.tables.map((table) => [table.table_id, table]) : []);
|
|
78148
|
+
const unweighed = res.error ?? "its record count is not one this CLI can read";
|
|
78149
|
+
return [...scans.values()].flatMap(({ alias: alias2, tableId: tableId2, calls, faults }) => {
|
|
78150
|
+
const size2 = sizes.get(tableId2);
|
|
78151
|
+
if (size2 !== void 0 && size2.total < FULL_SCAN_WARNING_RECORDS) return [];
|
|
78152
|
+
const table = size2 === void 0 ? `${tableId2} (size unread: ${unweighed})` : `${size2.table_name} (${size2.total.toLocaleString("en-US")} records)`;
|
|
78153
|
+
return [{
|
|
78154
|
+
alias: alias2,
|
|
78155
|
+
finding: `${alias2} \xB7 ${table}: ${calls.join(", or ")}, its filter rests on ${[...faults].join(", ")}, which no index serves \u2014 each such call reads every record.`
|
|
78156
|
+
}];
|
|
78157
|
+
});
|
|
78158
|
+
}
|
|
78159
|
+
function unservedConditions(filter2, fields) {
|
|
78160
|
+
if (filter2.node_type === "group") {
|
|
78161
|
+
const children = filter2.children.map((child) => unservedConditions(child, fields));
|
|
78162
|
+
const served2 = filter2.logic === "and" ? children.includes(null) : children.length > 0 && children.every((child) => child === null);
|
|
78163
|
+
return served2 ? null : children.flatMap((child) => child ?? []);
|
|
78164
|
+
}
|
|
78165
|
+
if (filter2.node_type === "traversal") return [filter2];
|
|
78166
|
+
if (filter2.type === "current_member") return [];
|
|
78167
|
+
if (filter2.type === "record_id") return filter2.operator === "is_any_of" ? null : [filter2];
|
|
78168
|
+
const field2 = fields.find((one) => one.key === filter2.field_key);
|
|
78169
|
+
return field2 !== void 0 && conditionUsesIndex(field2, filter2.operator) ? null : [filter2];
|
|
78170
|
+
}
|
|
78171
|
+
function describeCondition(node, fields) {
|
|
78172
|
+
if (node.node_type === "traversal") {
|
|
78173
|
+
const link = fields.find((one) => one.key === node.path[0]);
|
|
78174
|
+
return `\`${node.condition.operator}\` across link "${link?.name ?? node.path[0]}"`;
|
|
78175
|
+
}
|
|
78176
|
+
const field2 = fields.find((one) => one.key === node.field_key);
|
|
78177
|
+
return field2 === void 0 ? `\`${node.operator}\`` : `\`${node.operator}\` on ${field2.type} "${field2.name}"`;
|
|
78044
78178
|
}
|
|
78045
78179
|
function reportQueryVerdict(verdict) {
|
|
78046
78180
|
if (verdict.refused.length > 0) {
|
|
@@ -78053,6 +78187,13 @@ function reportQueryVerdict(verdict) {
|
|
|
78053
78187
|
`\u26A0 Not held to the server's query gate, a table each reads being unreadable here: ${verdict.unreached.map(({ alias: alias2, reason }) => `${alias2} (${reason})`).join(", ")}.`
|
|
78054
78188
|
);
|
|
78055
78189
|
}
|
|
78190
|
+
if (verdict.fullScans.length > 0) {
|
|
78191
|
+
warn(
|
|
78192
|
+
`\u26A0 ${verdict.fullScans.length} scan${verdict.fullScans.length === 1 ? "" : "s"} no index can narrow (advisory, nothing is refused):
|
|
78193
|
+
` + verdict.fullScans.map(({ finding }) => ` ${finding}`).join("\n") + `
|
|
78194
|
+
An index serves an exact match (\`equals\`/\`is_any_of\`, indexed at deploy) for a whole identifier, and the source's \`search\` for free text. The same term in \`search\` beside a \`contains\` returns exactly the \`contains\` rows, found through that index when the term is selective.`
|
|
78195
|
+
);
|
|
78196
|
+
}
|
|
78056
78197
|
return verdict.refused.length;
|
|
78057
78198
|
}
|
|
78058
78199
|
|
|
@@ -89338,7 +89479,7 @@ async function appCheck(client, args = {}) {
|
|
|
89338
89479
|
// fails the run whatever the probes found: everything measured after the
|
|
89339
89480
|
// refusal describes a surface no reader could have put the app in.
|
|
89340
89481
|
refusedWrites > 0) {
|
|
89341
|
-
fail("The findings above block a deploy.");
|
|
89482
|
+
fail("The findings above block a deploy or a publish.");
|
|
89342
89483
|
}
|
|
89343
89484
|
console.error(
|
|
89344
89485
|
"Checked the app's types, bindings, queries, capabilities, agent schemas, workflow bodies and id portability" + (args.screens !== void 0 ? ", and the screens above" : "") + " \u2014 nothing blocking."
|
package/docs/cli_reference.md
CHANGED
|
@@ -68,7 +68,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
68
68
|
| `lotics docs` \| `lotics docs <area>[/<section>]` | The index of the reference docs, **resolved out of the packages installed beside this project** — the model reference alone is carried by this CLI (`lotics docs model`). **Both levels are discovered by looking**: every `@lotics/*` package carrying an `AGENTS.md` or a `docs/` in any `node_modules/@lotics` from the current directory UPWARD (nearest wins, so a hoisted root copy never shadows the one a project's own imports resolve to), and within each, every area it actually ships. Titles come from each file's own `# heading` and the version from the installed `package.json`, so a doc OR a whole package added upstream appears with no change to this CLI, and a skewed install is visible rather than reassuring. A package's index is named after the package (`lotics docs ui`), never `index`. `@lotics/app-runtime`, `@lotics/ui` and `@lotics/cli` sort first as a reading ORDER, not a filter. **Output is ONE PAGE, 16 KB, navigation included**: a doc that does not fit prints its opening and the addresses that reach into it — its sections with their sizes, or, for a reference that is one table (this file, the kit's catalog), the name of every row. `lotics docs <area>/<section>` prints that section, a path of sections (`model/field/select`) finds each inside the one before, and `lotics docs ui/catalog/Button` that one row; a unique prefix is enough, and an address matching two parts is refused with both. Both levels print to **stdout** — the index is the payload of a bare `lotics docs`, so `lotics docs | grep -i excel` works — with only the provenance line on stderr; a name two packages share is refused with both qualified forms (`lotics docs ui/templates`) rather than resolved silently. Needs no auth. Outside a project only `@lotics/cli`'s own resolve, and it says so. |
|
|
69
69
|
| `lotics docs model` \| `lotics docs model/<section>[/…]` | **The model reference, from inside the binary** — the one doc this CLI carries rather than resolves, because it describes this CLI's own model checker; listed first by `lotics docs`, at this CLI's version. Its first page is what a model composes with, the working order (jobs → entities and fields → `records` → one app per job → `scaffold check` → `app preview` → `workspace build`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `"<entity-alias>:<ref>"`), the rules, the `apply` list (packages copied in after the model's own tables, each with an optional `bind` onto them), the `preset` block (a published model's branches and its at-most-two questions), the **`from` form** — `{from, variants, rename, entities, rows, records, write_rules, apps, apply}`, which names a preset by SLUG instead of restating it — and one complete worked example. **Offline, no account.** |
|
|
70
70
|
| `lotics report '<json>'` \| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** — `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` — invoking it IS the consent that passive collection needs an opt-in for — but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** — a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |
|
|
71
|
-
| `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion, so a stale tree fails before it pushes or builds. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__` **in a project that still ships react-native** (read off its own `package.json` — an app on the React DOM kit bundles none of it and the check would be advice to define two globals nothing reads), and a `window.open` in the app's own source (each fails ONLY in the deployed app: dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the runtime this app builds against has fallen behind what is published** — `@lotics/app-runtime` alone, since the kit an app lists sits at the runtime's range, read from `node_modules` rather than the range because a caret is minor-locked below 1.0 (`^0.13.x` can never resolve `0.14`, and `npm update` does nothing). A release LINE behind is loud and names `lotics app kit --published` and `@lotics/ui`'s `MIGRATION.md`; anything smaller is one quiet `npm update` line, because a warning that fires on every deploy is one the reader stops seeing. An app still listing `@lotics/app-sdk` is told it moved into the runtime and which verb migrates it. The lookup is bounded and every failure is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, one of three rules `check` runs that a deploy does not** (the others are the undeclared call site below and `package.json#lotics.writes`/`deletes`, held against what every workflow body writes and deletes) — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `library publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there and cannot invert a fake. **And an `app_` id anywhere in `app.json`**: the spec names no other app, so a pasted one is re-pointed by nothing. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **And every bound workflow body, checked as `app workflow check` checks it** — the same isolated per-alias program against the pulled `.lotics/workflows/<alias>.globals.d.ts`, then the server's `verify_only` verdict on each body that passed — and on each body no local pass could type (no globals, no `typescript`), since that is what a push would meet — which exits 1 on any issue and warns when the server is not reached. The server verifies a body once, at the save that wrote it, so a body whose declared types have since moved stays stored, matching what is live, and is refused by the next writer — a starter copy, in somebody else's workspace. The verdict is as fresh as those types, which `pull`, `workflow pull` and `codegen` refresh. **And the app's own `npm run typecheck`**, after regenerating the `.lotics/*.d.ts` companions from the manifest — the same run a deploy makes before building, so a filter or sort key the query does not project fails here rather than at the first member's request. **Exits 1 on that, on a body the types refuse, on a failing typecheck, and on what a `deploy` would REFUSE** — **every manifest query the server would refuse** — held to the server's own query gate (the declaration, each query it runs as, the tables it reads through `get_table`, its filters and the columns it outputs; the owner's reach and the SQL compile stay the server's), in the server's words — and a query or workflow `description` over the 300-character capability cap, which is a binding no `set` and no deploy will take (the rule is `@lotics/shared`'s, the same one the server refuses with, and `workflow set` / `query set` ask it before sending anything — so a long line costs one edit rather than a failed push per alias). **And every manifest query declared with NO description**, named in one line: that sentence is what a chat or MCP caller chooses between aliases by, and an alias is a JS identifier. `app create --from` deliberately writes none — a template over a shape's own English reads like a line about the business while saying nothing — so a generated app is told once, here, which lines are the author's to write. Both are things a deploy would refuse, so CI gating on a green check means a deploy will not refuse. **Every binding the project has ahead of the app** — an agent schema that disagrees with the live app, an edited workflow body or declaration, edited agent prose, a changed query — **is named as what the deploy will push**, each with the one-alias `set` verb, and never fails the check: the deploy pushes it itself, so a check red over it disagreed with the deploy it stands in for (and with the check `app regenerate` runs). Genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. **`--screens` adds the rendered surface, and is its own entry below.** It runs after the typecheck and only when it passed — a type error renders nothing to measure — and its findings, and any write it refused, fold into this command's exit code. **It also refuses a call site the manifest no longer declares.** `useQuery`/`useWorkflow` keep a bare-string overload for a computed alias, so deleting or renaming an alias leaves every call site compiling and failing only when the screen renders — the one edit most likely to orphan a call site is the one the generated types cannot catch. The alias literals in `src/` are matched against `package.json#lotics.queries`/`.workflows` (the manifest, not the live row: the server still SERVES an alias whose declaration was just deleted, because a deploy never unbinds), and an undeclared one exits 1. `app codegen` prints the same finding as a warning, since it is the command an author runs right after editing the manifest. **A failing body that is byte-identical to the one the server is running is labelled as such**: its `lotics.synced.workflows.<alias>.content` baseline proves the file has not been edited since it was pushed or pulled, so the failure is a grammar migration the stored body is owed rather than a stale checkout — a link field reads as an id array, so drop `.id` or descend with `linked(…)`. Until the body is edited and `set`, the stored one keeps running as it always has. **And a query that reads past a table's ROW RULE.** A table's `private_filters` bind the CALLER, and an app query's caller is the app's OWNER — the viewer needs no table access, `app:use` is the grant — so the rule passes and every row is served. For each table a declared query reads (`GET /v1/tables/{id}`, the one surface that serves the rule), the viewer predicate — `current_member in_any_group`, or a member field's `is_current_member` / `is_not_current_member` — is attributed to the SCAN it guards: a `from_table`'s own `filter`, or an enclosing `filter` node's predicate, whose rows are the ones that scan produced. So a clause written over one table never silences the finding for the ruled table joined beside it, and every scan no clause covers is named with its table. A table this credential may not read (403, or 404 for one that is gone) is dropped — a rule that cannot be read is not evidence of one — while any other failure of that read fails the command, since "I could not ask" must never render as "there is no rule". Advisory, never part of the exit code: an app that deliberately serves the whole table to a desk of people who may all read it is legitimate, and nothing here can tell the two apart. **And it names the workflows a chat or MCP caller is offered with no description** (one `get_app_capabilities` read, the reader's own view of the app): that text is what those callers choose between aliases by, and without it they choose by the alias. It cannot be counted from the manifest — a workflow bound out of band is not declared there at all. Advisory, never part of the exit code. **It regenerates `.lotics/app_fields.ts` from the live schema before typechecking**, as a deploy does — a gitignored map from a moved schema otherwise passes. **And three claims the app makes**: a bound body's writes against `package.json#lotics.writes`, read as the step tree the server would store, exit 1 on a field no entry covers, and declaring none only warns; a description carrying `<placeholder>` syntax exits 1, since the catalogue escapes angle brackets — state the format as an example; so does one naming a desk `query_apps` does not list. |
|
|
71
|
+
| `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion, so a stale tree fails before it pushes or builds. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__` **in a project that still ships react-native** (read off its own `package.json` — an app on the React DOM kit bundles none of it and the check would be advice to define two globals nothing reads), and a `window.open` in the app's own source (each fails ONLY in the deployed app: dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the runtime this app builds against has fallen behind what is published** — `@lotics/app-runtime` alone, since the kit an app lists sits at the runtime's range, read from `node_modules` rather than the range because a caret is minor-locked below 1.0 (`^0.13.x` can never resolve `0.14`, and `npm update` does nothing). A release LINE behind is loud and names `lotics app kit --published` and `@lotics/ui`'s `MIGRATION.md`; anything smaller is one quiet `npm update` line, because a warning that fires on every deploy is one the reader stops seeing. An app still listing `@lotics/app-sdk` is told it moved into the runtime and which verb migrates it. The lookup is bounded and every failure is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, one of three rules `check` runs that a deploy does not** (the others are the undeclared call site below and `package.json#lotics.writes`/`deletes`, held against what every workflow body writes and deletes) — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `library publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there and cannot invert a fake. **And an `app_` id anywhere in `app.json`**: the spec names no other app, so a pasted one is re-pointed by nothing. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **And every bound workflow body, checked as `app workflow check` checks it** — the same isolated per-alias program against the pulled `.lotics/workflows/<alias>.globals.d.ts`, then the server's `verify_only` verdict on each body that passed — and on each body no local pass could type (no globals, no `typescript`), since that is what a push would meet — which exits 1 on any issue and warns when the server is not reached. The server verifies a body once, at the save that wrote it, so a body whose declared types have since moved stays stored, matching what is live, and is refused by the next writer — a starter copy, in somebody else's workspace. The verdict is as fresh as those types, which `pull`, `workflow pull` and `codegen` refresh. **And the app's own `npm run typecheck`**, after regenerating the `.lotics/*.d.ts` companions from the manifest — the same run a deploy makes before building, so a filter or sort key the query does not project fails here rather than at the first member's request. **Exits 1 on that, on a body the types refuse, on a failing typecheck, and on what a `deploy` would REFUSE** — **every manifest query the server would refuse** — held to the server's own query gate (the declaration, each query it runs as, the tables it reads through `get_table`, its filters and the columns it outputs; the owner's reach and the SQL compile stay the server's), in the server's words — and a query or workflow `description` over the 300-character capability cap, which is a binding no `set` and no deploy will take (the rule is `@lotics/shared`'s, the same one the server refuses with, and `workflow set` / `query set` ask it before sending anything — so a long line costs one edit rather than a failed push per alias). **And every manifest query declared with NO description**, named in one line: that sentence is what a chat or MCP caller chooses between aliases by, and an alias is a JS identifier. `app create --from` deliberately writes none — a template over a shape's own English reads like a line about the business while saying nothing — so a generated app is told once, here, which lines are the author's to write. Both are things a deploy would refuse, so CI gating on a green check means a deploy will not refuse. **Every binding the project has ahead of the app** — an agent schema that disagrees with the live app, an edited workflow body or declaration, edited agent prose, a changed query — **is named as what the deploy will push**, each with the one-alias `set` verb, and never fails the check: the deploy pushes it itself, so a check red over it disagreed with the deploy it stands in for (and with the check `app regenerate` runs). Genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. **`--screens` adds the rendered surface, and is its own entry below.** It runs after the typecheck and only when it passed — a type error renders nothing to measure — and its findings, and any write it refused, fold into this command's exit code. **It also refuses a call site the manifest no longer declares.** `useQuery`/`useWorkflow` keep a bare-string overload for a computed alias, so deleting or renaming an alias leaves every call site compiling and failing only when the screen renders — the one edit most likely to orphan a call site is the one the generated types cannot catch. The alias literals in `src/` are matched against `package.json#lotics.queries`/`.workflows` (the manifest, not the live row: the server still SERVES an alias whose declaration was just deleted, because a deploy never unbinds), and an undeclared one exits 1. `app codegen` prints the same finding as a warning, since it is the command an author runs right after editing the manifest. **A failing body that is byte-identical to the one the server is running is labelled as such**: its `lotics.synced.workflows.<alias>.content` baseline proves the file has not been edited since it was pushed or pulled, so the failure is a grammar migration the stored body is owed rather than a stale checkout — a link field reads as an id array, so drop `.id` or descend with `linked(…)`. Until the body is edited and `set`, the stored one keeps running as it always has. **And a query that reads past a table's ROW RULE.** A table's `private_filters` bind the CALLER, and an app query's caller is the app's OWNER — the viewer needs no table access, `app:use` is the grant — so the rule passes and every row is served. For each table a declared query reads (`GET /v1/tables/{id}`, the one surface that serves the rule), the viewer predicate — `current_member in_any_group`, or a member field's `is_current_member` / `is_not_current_member` — is attributed to the SCAN it guards: a `from_table`'s own `filter`, or an enclosing `filter` node's predicate, whose rows are the ones that scan produced. So a clause written over one table never silences the finding for the ruled table joined beside it, and every scan no clause covers is named with its table. A table this credential may not read (403, or 404 for one that is gone) is dropped — a rule that cannot be read is not evidence of one — while any other failure of that read fails the command, since "I could not ask" must never render as "there is no rule". Advisory, never part of the exit code: an app that deliberately serves the whole table to a desk of people who may all read it is legitimate, and nothing here can tell the two apart. **And a scan no index can narrow**, on a table of 2,000 records or more: each `from_table` is judged with only the required params set, then with each optional param its filter reads set alone, pruned as the runtime prunes an omitted param, and named with the params that leave it unserved and the conditions at fault. A filter is served when it cannot hold without an index-served condition — any branch of an AND, every branch of an OR — or its source carries a `search`; an index serves an exact match (`equals`/`is_any_of` on text, comparisons on numbers and dates, built at deploy) `has_any_of`/`has_all_of` on a select, member or link, and `is_current_member`, never a substring match. A call whose filter keeps no condition asked for every record and is not named; a scan beneath a `filter` over its projection is not judged, since the compiler may move that filter into it. A push prints the same advisory for the queries it sends. Advisory, never part of the exit code. **And it names the workflows a chat or MCP caller is offered with no description** (one `get_app_capabilities` read, the reader's own view of the app): that text is what those callers choose between aliases by, and without it they choose by the alias. It cannot be counted from the manifest — a workflow bound out of band is not declared there at all. Advisory, never part of the exit code. **It regenerates `.lotics/app_fields.ts` from the live schema before typechecking**, as a deploy does — a gitignored map from a moved schema otherwise passes. **And three claims the app makes**: a bound body's writes against `package.json#lotics.writes`, read as the step tree the server would store, exit 1 on a field no entry covers, and declaring none only warns; a description carrying `<placeholder>` syntax exits 1, since the catalogue escapes angle brackets — state the format as an example; so does one naming a desk `query_apps` does not list. |
|
|
72
72
|
| `lotics app check --screens [--screen <label>] [--width <n>] [--shots <dir>] [--changed]` | **`--screens` adds the rendered surface**: the app is served the way `app dev` serves it (its real data, this key), rendered headless in Chrome (`CHROME_PATH`/`LOTICS_CHROME`, then Playwright's, then system) at 1280 and 375. **The screens are its navigation's destinations** — a `nav` landmark's `a[href]` or `role="link"` (an app's route lives in its router, so the kit's shell renders each screen as a button carrying the link role and no `href`), else the first tab strip, else the root — in that order, because a screen's own lifecycle desk draws a tablist too, and reaching for one first walks a screen's STAGES as if they were the app's. **Each screen is then WALKED THROUGH ITS OWN DOORS**, nothing configured per app: every door is named by something the document states. A record: a row stamped `data-opens="page"`, a real `a[href]`, or an EMPTY DOOR — a named control with no text and no child element, the only legal whole-row press target, which reaches a drawer register, a Schedule row and a hand-written row alike. On a record — a page, or a drawer standing over its register, whose doors are the drawer's and never the register's behind it: the acts menu (`aria-haspopup`), the first child row, and every tab (a drawer's sections, the companion) and every section that is a page of its own, each measured and walked for the dialogs its acts and adds raise, a dialog the record already opened not twice. On any surface: every dialog a visible primary or secondary act raises — nothing announces one, so a press is kept only where an overlay appeared. **THE RUN IS A READ, AND THE NETWORK IS WHERE THAT IS ENFORCED — never a rule about what the page may draw.** The app frame holds no credential and Chrome runs on a throwaway profile, so every call the app makes reaches the workspace through this CLI and nowhere else, and this CLI serves an ALLOWLIST of reads. Everything outside it is refused before the call leaves the machine — a workflow run, a record write, an upload, an agent run, a comment, and any op this CLI does not know, which is refused because it is not on the list rather than because anyone listed it. A refused call HEADS the report, naming the surface, the control the app had focused and the RPC by its alias (never a payload value), and fails the run on its own: a walk that provoked a write left the app in a state no reader could have put it in, so nothing measured under it means anything. What the walk does is bounded on the page as well: nothing inside an open overlay but the record drawer it opened, nothing typed, no submit and nothing in a form, no act the kit marks costly (`data-tone` danger/warning) and none whose own name is the write, in either language — and nothing is FOCUSED, because focus cannot be taken from one field without leaving another, and a field that saves itself when the reader leaves it saves on exactly that. Where a reading needs the focused state, as the focus-ring rule does, Chrome is asked to PAINT `:focus-visible` and release it again: the cascade answers, focus never moves and no event is dispatched. Each overlay closes with Escape, confirmed closed; a record is descended at most twice; the walk stops at twenty-four surfaces per screen and NAMES each door it left. Each surface is measured once no request is in flight, and the measurable probes of `@lotics/ui` docs/reviewing.md run over the DOM, each finding printing the rule it IS — its law, its section and the one edit that answers it — so the numbers need no key. What they exempt is what the screen itself declares: a register's ordinal gutter (a column counting to the row count is the shape's numbering, which no app can treat), a strip whose list carries `data-order="sequence"` (a lifecycle rail, which composition.md permits under a screen's tabs), a hairline or `clip-path`-clipped leaf (the visually-hidden node a control plants for a screen reader), and a leaf whose own computed line clamp states a count over a sentence. **A meter counts as an encoding only where it draws a POSITION** — `aria-valuenow` inside a range with room left. A bar pinned at its own maximum — what a meter alarmed AT its maximum draws on every alarmed row — reads the same as every value above it, and one with no maximum states none; both are counted in the census's `devices` and out of its `encoded`, so "nothing drawn" and "drawn and saying nothing" never read alike. The bare values that remain are grouped into columns, each named by the heading over it, so a finding says WHICH slot draws its figures as words. **Two finding classes read what geometry cannot.** `clutter` (docs/hierarchy.md): a second primary act, a value in two places on a record, a box reserving more lines than it holds, markers on over half a form's fields, a second accent. `right_form` (docs/screen.md's device index): a boolean as a two-option select, a day run as a repeated date column where the kit ships `Schedule`. Each prints a line per rule fired, with three offenders. **Four read room and counts** (reviewing.md §8l–§8o): `reserved_blank`, a register column 40px or wider that is blank (no text, no control) on more than half the rows in view, or a row-act gutter leaving 40px beside the acts on them — a row a complete register draws for nothing stored (`data-ghost`, a day nobody filed) is no row of the set; `shed_with_room`, a column the register's width gave up (the kit states them as `data-shed`) that, with the gap between two columns, fits in what its slots hold beyond what each must seat — its heading and widest ink, a fixed column's whole width, the acts most rows have; `action_off_heading`, a section's create (a control stating `data-create`) drawn in its body rather than its heading row; and `partial_count`, a count or `n of N` equal to the rows in hand of a read the server cut on the screen being walked, where the server's own count of the same alias, params and filter — asked by this CLI, a read — says otherwise. **Three read a record's anatomy** (docs/hierarchy.md), over each record on the surface with the title heading it: `record_primary`, a primary act in a block's heading row — a block's add is secondary beside the record's own acts (two under one heading are `clutter`'s); `fact_repeats`, a field whose value reads the same as the header's own words, whatever its case; and `empty_block`, a block that draws nothing under its heading, not even the line saying it is empty. A census line per screen (text runs, money strings, bare values against the devices reading, tab strips) prints first, so a clean verdict over a screen that rendered nothing cannot pass; a screen that renders no text is itself a finding, and one still changing after fifteen seconds is measured as it is. **A cold dependency optimisation is waited out, not measured**: the first paint has its own bound, far longer than settle's, since an app's modules load after its document completes and Vite holds them; only a MOUNTED, idle, textless frame is blank at once, and the finding names the wait and its blocker. It also RELOADS the page under the probe, so a width is measured again once; a second reload is a page that keeps moving and fails. **`--screen <label>` and `--width <n>` narrow a run** (repeatable, comma-separated; the label matches case-insensitively as a substring), for the author iterating on one screen who would otherwise pay a typecheck, a Vite boot and every screen at both widths on every edit; the clean verdict then names only the widths covered, and a `--screen` matching nothing is refused. **`--shots <dir>` writes what the run measured** — a `<surface>@<width>.png` and a `<surface>@<width>.json` per surface, off the SAME settled frame the probes read, so a shot and a finding can never describe different pixels. The PNG is the WHOLE surface: an app scrolls inside a box of its own, so the window is grown to the height the probe measured and put back, and an overlay a resize dismissed is shot as the viewport, the sidecar saying so (`app dev`'s header band is in it — the band the app was laid out under). The JSON is the half a picture cannot carry: the nav's first item's left edge, the title and first section heading, the primary acts by name, label/value pairs against what the folds state, values drawn twice, money that wraps, text the layout cut, and the console errors and uncaught exceptions the frame raised. Named `<nn>-<slug>` plus one `__<step>` per door taken (`__record`, `__menu`, `__child`, `__fold`, `__dialog-<n>`, `__tab-<its name>`, `__section-<its name>`); `<nn>` is the walk's index, which lists the directory in reading order and keeps two labels that fold to one ASCII slug apart. The row a record surface opened from is the sidecar's `opened_from` and that screen's census line, never a file name — it is a person's data. The directory is created if missing, and refused before the dev server boots when it cannot be; `--shots` without `--screens` is refused. Findings exit 1 like the rest. **Where it renders**: the app's files are copied into the render project of its DEPENDENCY SET — `~/.lotics/render/<key>/`, keyed by the manifest's `dependencies` and `devDependencies` as written, a `file:` tarball by its bytes — the one `app preview` renders in, so the install and Vite's dependency optimisation are paid once per set rather than per app; one render holds a project at a time (`<key>.lock` — a second run waits, naming the app and pid, and takes over a dead hold). Where that install resolved another runtime or kit than the app's own, the run says which, since a deploy builds the app's own. The run ends with what each phase cost: `preflight · install|reuse · walk`. **`--changed`** walks only when something the screens READ has moved since this checkout's last walk — each part of `app.json` (every record apart), each declared query, each bundled source file, the field map, the ranges, the kit the render resolved and the CLI version, whose probes measure them — read against `.lotics/screens_check.json`, which every walk writes; with nothing moved, that walk's report is printed again under its timestamp and still fails the run. An app's screens all read its one spec, so a move walks the app whole. It walks, saying why, with no earlier walk, a different `--screen`/`--width`, or `LOTICS_UI_SRC` set (a linked working copy has no fingerprint); the workspace's rows are not an input. `--changed` without `--screens`, or beside `--shots`, is refused. |
|
|
73
73
|
| `lotics app preview <model.json>#<app> [--shots <dir>] [--width <n>] [--screen <label>] [--kit <path>]` | **The app a model states, RENDERED — before a table exists, and with no credential in the process.** The model is checked offline, the named app is compiled against the workspace the model WOULD become (synthetic `tbl_`/`fld_`/`opt_`/`grp_` ids, minted positionally off the file), and every read the app makes is answered from the model's own `rows` — projected under the columns the query names, narrowed by the params it declares and sorted the way it states, so a child block under a record holds that record's rows and not every parent's. An entity the app reads that states no `rows` gets three synthesized, so a register never measures clean over nothing. Then the SAME headless walk `app check --screens` takes: the register, the record its first row opens in its door, and the add dialog, at 1280 and 375, measured by the same probes. `--shots <dir>` writes a PNG and a sidecar per surface into `<dir>/<app>/`, so apps previewed into one directory never overwrite each other. **Exits 1 on any finding**, exactly as the check does, and prints what each phase cost — bind, install-or-reuse, walk. **Nothing is created and nothing is reached** — no app row, no table, no workspace; the only network it needs is the npm registry, and only when the kit has moved. It renders in the cached render project of its dependency set (`~/.lotics/render/<key>/`, shared with `app check --screens` and with every model whose app lists the same set, and held by one render at a time — a second run waits, naming the app and pid holding it, and takes over a hold whose process is gone): the runtime and its kit are PUBLISHED packages, so a project is what a preview needs to install them into — the CLI has no renderer of its own. **It installs the runtime this CLI was built for**, and the kit through it, never `latest`: the spec this binary's generator writes is the one that runtime reads, and a version that published mid-session is a runtime nobody checked the model against. A version the registry does not serve yet is refused before npm runs, naming `--kit` — needed only until that runtime is published. **`--kit <checkout>`** (repeatable) — a checkout's ROOT, which names the app-runtime and the ui it holds, or one of those two packages — renders against that checkout instead — built, packed and proven the way `lotics app kit` installs one into an app — and the run prints that it rendered against a LOCAL CHECKOUT, first and last, so the render is never read as the published one. The project is a CACHE and never a source: `npm install` runs on the first render of a set and again only when a checkout's tarball did, and every file in it is rewritten from the model on each run. **A document a row names by path is served** from the project's own static root, as the named file a workspace would hold, so a files block and a record's picture read what the model states; a `fil_` id is carried by name. **`--record <ref>`** opens that row's record by its own address — a row of the app's entity, by the ref the model gives it, open or closed — where the walk otherwise opens the first row the register lists; a ref naming no such row is refused with the refs there are. **Every read is answered at the BRIDGE**, not inside the page. The app SDK's design-time fixture (`registerMockFixture` + `?__mock=1`) is the wrong half of this: the flag lives in the app's own url, and the first record page is a navigation the app's router performs — so from that surface onwards the flag is gone and every read falls through anyway. The bridge is the one place every call arrives whatever the url says, so `query`, `field_options`, `members` and `context` are answered there from one reading of the rows, and the generated entry ships EXACTLY as `app create --from` writes it. **A preview writes nothing**: a workflow, an upload or an agent run meets the same read gate `app check --screens` uses, so the app draws its own refusal path rather than reporting a save nothing moved for, and the call is reported above the census the way the check reports one. **What came out empty, it names**: a `formula`, `rollup`, `lookup` or `autonumber` is computed here rather than stated in the model, and the census lists each computed column that stayed empty on EVERY row of a table that has rows — named by outcome, since a total drawn blank is either the app's own answer or a hole and only the run can say which. The census reads the CELLS and not the field types, so nothing is exempt by kind. **A computed column that came out `#ERROR:` is named too, and the run refuses it** before anything is installed: a wall of red measures clean, and a run that drew it and exited 0 would tell its author the app is fine. **It computes in UTC**, because a model states no timezone — a row's `@today`, an autonumber's `{YEAR}` and a record's own clock all land on the run's own date at UTC midnight, so two runs of one model draw the same register wherever they are made. |
|
|
74
74
|
| `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. A deploy runs this same verb for every alias whose declaration or body is ahead of the app, so this command is the one-alias spelling of what a release does, not a step a release leaves to a person. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. A push also prints any non-blocking verify warnings, including an input the alias declares that the body never reads. A first bind MINTS the workflow row, and the id it echoes is written back into `package.json#lotics.workflows.<alias>.workflow_id` — the same surgical write the derived `outputs` gets. Without it a hand-declared alias ended up shaped unlike its siblings, so anything reading the manifest (an audit, a port to another workspace, a person comparing two blocks) had to treat a missing id as normal, which is exactly how a genuinely missing one stops being visible. **`--acknowledge-breaking-api`** carries this write out even though it breaks what the app's published API promises, snapshotting the broken contract as a new version; without it such a write is refused and every breaking change is named (see `app api`). |
|