@lotics/cli 0.253.0 → 0.254.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +1142 -456
- package/docs/cli_reference.md +1 -1
- package/docs/migration.md +13 -0
- 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.
|
|
55
|
+
define_LOTICS_KIT_VERSIONS_default = { runtime: "0.39.0" };
|
|
56
56
|
}
|
|
57
57
|
});
|
|
58
58
|
|
|
@@ -54089,9 +54089,12 @@ var alias = contractAliasSchema;
|
|
|
54089
54089
|
var fieldAlias = contractAliasSchema.describe("A field alias of the entity named beside it");
|
|
54090
54090
|
var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54091
54091
|
var selectsHold = (what) => zod_default.record(fieldAlias, zod_default.array(alias).min(1)).describe(what);
|
|
54092
|
+
var conditionsHold = (what) => zod_default.record(fieldAlias, zod_default.union([zod_default.array(alias).min(1), zod_default.boolean()])).describe(what);
|
|
54092
54093
|
var milestoneSchema = zod_default.object({
|
|
54093
54094
|
field: fieldAlias.describe("A date field: the stage is reached on the day it holds"),
|
|
54094
|
-
when:
|
|
54095
|
+
when: conditionsHold(
|
|
54096
|
+
"The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it"
|
|
54097
|
+
)
|
|
54095
54098
|
}).strict();
|
|
54096
54099
|
var recordStatusSchema = zod_default.object({
|
|
54097
54100
|
field: fieldAlias.optional().describe("The single select that is the row's state \u2014 or `milestones` instead"),
|
|
@@ -54126,7 +54129,10 @@ var recordDisplaySchema = zod_default.object({
|
|
|
54126
54129
|
tolerance: zod_default.record(alias, zod_default.number().min(0)).optional().describe(
|
|
54127
54130
|
"How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun"
|
|
54128
54131
|
),
|
|
54129
|
-
due: zod_default.array(fieldAlias).min(1).optional().describe("Dates that are deadlines: each counts down and turns overdue")
|
|
54132
|
+
due: zod_default.array(fieldAlias).min(1).optional().describe("Dates that are deadlines: each counts down and turns overdue"),
|
|
54133
|
+
starts: zod_default.record(fieldAlias, zod_default.enum(["today", "me"])).optional().describe(
|
|
54134
|
+
"What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default`"
|
|
54135
|
+
)
|
|
54130
54136
|
}).strict();
|
|
54131
54137
|
var recordsSchema = zod_default.record(alias, recordDisplaySchema).describe("How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one");
|
|
54132
54138
|
var readingWhere = zod_default.record(fieldAlias, zod_default.union([zod_default.array(alias).min(1), zod_default.boolean()])).describe("Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed");
|
|
@@ -54186,6 +54192,9 @@ var registerSchema = zod_default.object({
|
|
|
54186
54192
|
search: zod_default.array(fieldAlias).min(1).optional().describe(
|
|
54187
54193
|
"The fields the search box matches (a link by its row's title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row"
|
|
54188
54194
|
),
|
|
54195
|
+
group: fieldAlias.optional().describe(
|
|
54196
|
+
"The field the rows open grouped by, the reader's grouping starting there \u2014 a period's rows read under each period, one per person; absent, ungrouped"
|
|
54197
|
+
),
|
|
54189
54198
|
sort: zod_default.object({
|
|
54190
54199
|
field: fieldAlias.describe("The field rows are ordered by"),
|
|
54191
54200
|
desc: zod_default.literal(true).optional().describe("Latest or largest first; absent, soonest or smallest first")
|
|
@@ -54198,8 +54207,10 @@ var registerSchema = zod_default.object({
|
|
|
54198
54207
|
]).optional().describe("The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them"),
|
|
54199
54208
|
readings: zod_default.array(figureReadingSchema).min(1).optional().describe("Readings of the register's own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them")
|
|
54200
54209
|
}).strict();
|
|
54201
|
-
var shownWhen =
|
|
54202
|
-
|
|
54210
|
+
var shownWhen = conditionsHold(
|
|
54211
|
+
"Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value"
|
|
54212
|
+
);
|
|
54213
|
+
var blockCreateSchema = (fallback) => zod_default.union([zod_default.array(fieldAlias).min(1), zod_default.literal(false)]).optional().describe(`The child's fields asked when a row is added here; absent, ${fallback} a person writes; false, rows are not added here`);
|
|
54203
54214
|
var blockTitle = zod_default.string().min(1).optional().describe("The block's heading; absent, the child entity's or the field's own label");
|
|
54204
54215
|
var blockVia = fieldAlias.optional().describe("The child's link to this record, where it has more than one");
|
|
54205
54216
|
var metricBlockSchema = metricReadingSchema.extend({ via: blockVia, when: shownWhen.optional() }).strict();
|
|
@@ -54212,7 +54223,7 @@ var blockSchema = zod_default.union([
|
|
|
54212
54223
|
via: blockVia,
|
|
54213
54224
|
title: blockTitle,
|
|
54214
54225
|
columns: zod_default.array(fieldAlias).optional().describe("The child's fields read as columns after its title"),
|
|
54215
|
-
create: blockCreateSchema,
|
|
54226
|
+
create: blockCreateSchema("its title, its picture, the block's columns and every required field"),
|
|
54216
54227
|
where: selectsHold(
|
|
54217
54228
|
"Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg's fees, of a record holding both legs'). Each select is one the child requires, so no row stands in no block"
|
|
54218
54229
|
).optional(),
|
|
@@ -54222,15 +54233,29 @@ var blockSchema = zod_default.union([
|
|
|
54222
54233
|
of: fieldAlias.optional().describe(
|
|
54223
54234
|
"With `expect` on a select: the record's own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title's"
|
|
54224
54235
|
),
|
|
54236
|
+
under: fieldAlias.optional().describe(
|
|
54237
|
+
"The child's one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers, the lines no document covers first under their own heading, where the document's add makes one from the lines picked. Never also a rows block of the document"
|
|
54238
|
+
),
|
|
54239
|
+
when: shownWhen.optional()
|
|
54240
|
+
}).strict(),
|
|
54241
|
+
zod_default.object({
|
|
54242
|
+
agenda: alias.describe(
|
|
54243
|
+
"A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child's row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day's order"
|
|
54244
|
+
),
|
|
54245
|
+
via: blockVia,
|
|
54246
|
+
title: blockTitle,
|
|
54247
|
+
columns: zod_default.array(fieldAlias).optional().describe("The child's facts read on an entry's line after its title"),
|
|
54248
|
+
create: blockCreateSchema("its title, its picture, the block's columns and every required field"),
|
|
54249
|
+
start: fieldAlias.optional().describe("The record's date that is the first day: each day's heading counts from it (Day 1, Day 2); absent, the weekday and the date alone"),
|
|
54225
54250
|
when: shownWhen.optional()
|
|
54226
54251
|
}).strict(),
|
|
54227
54252
|
zod_default.object({
|
|
54228
54253
|
timeline: alias.describe(
|
|
54229
|
-
"A child entity read as dated entries, newest first, with a composer where the app writes it \u2014 never the record's status history, which
|
|
54254
|
+
"A child entity read as a log of dated entries, newest first, with a composer where the app writes it (entries planned ahead are an `agenda`) \u2014 never the record's status history, which `record.history` draws beside the record"
|
|
54230
54255
|
),
|
|
54231
54256
|
via: blockVia,
|
|
54232
54257
|
title: blockTitle,
|
|
54233
|
-
create: blockCreateSchema,
|
|
54258
|
+
create: blockCreateSchema("its title and every required field"),
|
|
54234
54259
|
when: shownWhen.optional()
|
|
54235
54260
|
}).strict(),
|
|
54236
54261
|
zod_default.object({
|
|
@@ -54259,6 +54284,9 @@ var blockSchema = zod_default.union([
|
|
|
54259
54284
|
function isRowsBlock(block2) {
|
|
54260
54285
|
return "rows" in block2 && !("pivot" in block2);
|
|
54261
54286
|
}
|
|
54287
|
+
function isAgendaBlock(block2) {
|
|
54288
|
+
return "agenda" in block2;
|
|
54289
|
+
}
|
|
54262
54290
|
function isReadingBlock(block2) {
|
|
54263
54291
|
return "metric" in block2 || "breakdown" in block2 || "trend" in block2 || "pivot" in block2;
|
|
54264
54292
|
}
|
|
@@ -54287,7 +54315,10 @@ var recordSchema = zod_default.object(
|
|
|
54287
54315
|
sections: zod_default.array(recordSectionSchema).min(1).optional().describe(
|
|
54288
54316
|
"The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows"
|
|
54289
54317
|
),
|
|
54290
|
-
comments: zod_default.literal(true).optional().describe("The record's comment thread: who wrote what and when, with a composer, beside the sections")
|
|
54318
|
+
comments: zod_default.literal(true).optional().describe("The record's comment thread: who wrote what and when, with a composer, beside the sections"),
|
|
54319
|
+
history: zod_default.literal(true).optional().describe(
|
|
54320
|
+
"The record's status history \u2014 each move, when and who \u2014 beside the sections, for a lifecycle whose past steps are looked up (a shipment, a container's gate moves, an order); the entity's `status.history` is written either way. Absent, it is drawn nowhere"
|
|
54321
|
+
)
|
|
54291
54322
|
},
|
|
54292
54323
|
{
|
|
54293
54324
|
error: (issue2) => issue2.code === "unrecognized_keys" && issue2.keys.some((key) => RETIRED_RECORD_KEYS.has(key)) ? "`facts` and `blocks` are replaced by `sections` \u2014 each a title, its `fields`, `blocks` and `acts`, in the order the work reaches them; docs/app_system.md" : void 0
|
|
@@ -54312,7 +54343,9 @@ var actSchema = zod_default.object({
|
|
|
54312
54343
|
"`rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row's menu)"
|
|
54313
54344
|
),
|
|
54314
54345
|
of: alias.optional().describe("A rows block's child entity the act works on: offered on each child row, its conditions and its write that row's"),
|
|
54315
|
-
when:
|
|
54346
|
+
when: conditionsHold(
|
|
54347
|
+
"The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value"
|
|
54348
|
+
).optional(),
|
|
54316
54349
|
requires: zod_default.array(fieldAlias).min(1).optional().describe("Fields that must be filled first; a blocked act names each one missing"),
|
|
54317
54350
|
recommends: zod_default.array(fieldAlias).min(1).optional().describe("Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank"),
|
|
54318
54351
|
asks: zod_default.array(zod_default.union([fieldAlias, actInputSchema])).min(1).optional().describe("What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs"),
|
|
@@ -54350,7 +54383,7 @@ var modelAppSchema = zod_default.object({
|
|
|
54350
54383
|
reads: appReads,
|
|
54351
54384
|
writes: zod_default.literal("children").optional().describe("The reader adds and edits the record's child rows but not the record itself"),
|
|
54352
54385
|
register: registerSchema.optional().describe("The rows: which columns and filters, the order, how a row is added"),
|
|
54353
|
-
record: recordSchema.optional().describe("What a row opens: the door, its sections and its comment thread"),
|
|
54386
|
+
record: recordSchema.optional().describe("What a row opens: the door, its sections, and beside them its comment thread and its status history where stated"),
|
|
54354
54387
|
acts: zod_default.array(actSchema).optional().describe("What the reader does to a record \u2014 each a press with its conditions and its write"),
|
|
54355
54388
|
checks: zod_default.array(checkSchema).optional().describe("Formulas that warn while they stand, or refuse the acts and saves they name")
|
|
54356
54389
|
}).strict();
|
|
@@ -54388,9 +54421,12 @@ var PERIOD_TO_PARAM = "to";
|
|
|
54388
54421
|
function isDashboardSpec(file2) {
|
|
54389
54422
|
return "dashboard" in file2;
|
|
54390
54423
|
}
|
|
54424
|
+
var URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
54425
|
+
var MAIL_ADDRESS = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
54391
54426
|
function contactHref(text) {
|
|
54392
54427
|
const said = text.trim();
|
|
54393
|
-
if (
|
|
54428
|
+
if (URI_SCHEME.test(said)) return void 0;
|
|
54429
|
+
if (MAIL_ADDRESS.test(said)) return `mailto:${said}`;
|
|
54394
54430
|
if (!/^\+?[\d\s().-]+$/.test(said) || !(/^[+0(]/.test(said) || /\d\s+\d/.test(said))) return void 0;
|
|
54395
54431
|
const digits = said.replace(/\D/g, "");
|
|
54396
54432
|
return digits.length >= 8 && digits.length <= 15 ? `tel:${said.startsWith("+") ? "+" : ""}${digits}` : void 0;
|
|
@@ -54398,12 +54434,16 @@ function contactHref(text) {
|
|
|
54398
54434
|
function navigatesSections(drawn) {
|
|
54399
54435
|
return drawn >= 3;
|
|
54400
54436
|
}
|
|
54437
|
+
function heldTogether(one, other2) {
|
|
54438
|
+
if ("value" in one || "value" in other2) return "value" in one && "value" in other2 && one.value === other2.value;
|
|
54439
|
+
return one.options.some((option) => other2.options.includes(option));
|
|
54440
|
+
}
|
|
54401
54441
|
function offeredTogether(one, other2) {
|
|
54402
54442
|
const theirs = other2.when;
|
|
54403
54443
|
if (one.when === void 0 || theirs === void 0) return false;
|
|
54404
54444
|
const shared = one.when.flatMap((held2) => {
|
|
54405
54445
|
const match2 = theirs.find((candidate) => candidate.field === held2.field);
|
|
54406
|
-
return match2 === void 0 ? [] : [held2
|
|
54446
|
+
return match2 === void 0 ? [] : [heldTogether(held2, match2)];
|
|
54407
54447
|
});
|
|
54408
54448
|
return shared.length > 0 && shared.every(Boolean);
|
|
54409
54449
|
}
|
|
@@ -54474,6 +54514,13 @@ function recordDisplay(model, alias2) {
|
|
|
54474
54514
|
}
|
|
54475
54515
|
return void 0;
|
|
54476
54516
|
}
|
|
54517
|
+
function whenConditions(when) {
|
|
54518
|
+
return Object.entries(when ?? {}).map(([field2, held2]) => typeof held2 === "boolean" ? { field: field2, value: held2 } : { field: field2, options: held2 });
|
|
54519
|
+
}
|
|
54520
|
+
function heldOptions(when, field2) {
|
|
54521
|
+
const held2 = when?.[field2];
|
|
54522
|
+
return Array.isArray(held2) ? held2 : void 0;
|
|
54523
|
+
}
|
|
54477
54524
|
function milestonesOf(display) {
|
|
54478
54525
|
return display?.status?.milestones?.map((stage) => typeof stage === "string" ? { field: stage, when: {} } : { field: stage.field, when: stage.when });
|
|
54479
54526
|
}
|
|
@@ -54493,7 +54540,7 @@ function placedAct(model, app, act) {
|
|
|
54493
54540
|
alias: act.alias,
|
|
54494
54541
|
...act.of === void 0 ? {} : { of: act.of },
|
|
54495
54542
|
...typeof moved === "string" ? { to: moved } : {},
|
|
54496
|
-
...act.when === void 0 ? {} : { when:
|
|
54543
|
+
...act.when === void 0 ? {} : { when: whenConditions(act.when) },
|
|
54497
54544
|
...act.danger === true ? { danger: true } : {}
|
|
54498
54545
|
};
|
|
54499
54546
|
}
|
|
@@ -54514,14 +54561,14 @@ function actHome(model, app, act) {
|
|
|
54514
54561
|
const order = field2?.type === "select" ? (field2.options ?? []).map((option) => option.alias) : [];
|
|
54515
54562
|
if (typeof moved !== "string" || !order.includes(moved)) return void 0;
|
|
54516
54563
|
const sections = app.record?.sections ?? [];
|
|
54517
|
-
const held2 = act.when
|
|
54564
|
+
const held2 = heldOptions(act.when, status);
|
|
54518
54565
|
const own = held2 === void 0 ? -1 : sections.findIndex((section) => held2.every((option) => section.at?.includes(option) === true));
|
|
54519
54566
|
const into = sections.findIndex((section) => section.at?.includes(moved) === true);
|
|
54520
54567
|
const earlier = (from, to) => order.indexOf(from) < order.indexOf(to);
|
|
54521
54568
|
const forward = held2 !== void 0 && held2.some((option) => earlier(option, moved));
|
|
54522
54569
|
const passed = (stage) => stage.includes(order[0] ?? "") || actsOver(model, app.entity).some((one) => {
|
|
54523
54570
|
const to = one.set?.[status];
|
|
54524
|
-
return typeof to === "string" && stage.includes(to) && (one.when
|
|
54571
|
+
return typeof to === "string" && stage.includes(to) && (heldOptions(one.when, status) ?? []).some((from) => earlier(from, to));
|
|
54525
54572
|
});
|
|
54526
54573
|
const correction = isCorrectionAct(model, app, act);
|
|
54527
54574
|
if (own >= 0 && (into < 0 || into === own || forward || correction || passed(sections[into]?.at ?? []))) return { section: own, entry: false };
|
|
@@ -54617,7 +54664,7 @@ function entityLocks(model, entity) {
|
|
|
54617
54664
|
);
|
|
54618
54665
|
}
|
|
54619
54666
|
function withEntityLocks(model, app) {
|
|
54620
|
-
const children =
|
|
54667
|
+
const children = recordChildren(model, app.entity, recordBlocks(app.record));
|
|
54621
54668
|
const gathered = [
|
|
54622
54669
|
...entityLocks(model, app.entity).map((check2) => ({ field: check2.field, ...check2.blocks === void 0 ? {} : { blocks: check2.blocks } })),
|
|
54623
54670
|
...children.filter((child) => child !== app.entity).flatMap((child) => entityLocks(model, child).map((check2) => ({ ...check2, of: child })))
|
|
@@ -54773,11 +54820,59 @@ function childList(model, entity, field2) {
|
|
|
54773
54820
|
);
|
|
54774
54821
|
return back.length > 0 && back.every((one) => one.type === "select_record_link" && one.cardinality === "one");
|
|
54775
54822
|
}
|
|
54823
|
+
function agendaAxis(model, child) {
|
|
54824
|
+
const display = model.records?.[child.alias];
|
|
54825
|
+
const line = display === void 0 ? [] : [display.title, ...display.subtitle ?? []];
|
|
54826
|
+
const typed = line.flatMap((name) => {
|
|
54827
|
+
const field2 = fieldOf(child, name);
|
|
54828
|
+
return field2 === void 0 ? [] : [{ name, field: field2, type: resolvedFieldType(field2, child, model.entities) }];
|
|
54829
|
+
});
|
|
54830
|
+
const at2 = typed.findIndex((one) => one.type === "date");
|
|
54831
|
+
const day = typed[at2];
|
|
54832
|
+
if (day === void 0) {
|
|
54833
|
+
return { refused: `"${child.alias}" rows name no date on their line (\`records.${child.alias}\` title or subtitle) \u2014 an entry stands on the day it holds` };
|
|
54834
|
+
}
|
|
54835
|
+
const hour = (one) => one.type === "date" && one.field.type === "date" && one.field.format === "datetime";
|
|
54836
|
+
const time3 = typed.find(hour);
|
|
54837
|
+
if (time3 !== void 0) return { day: day.name, time: time3.name };
|
|
54838
|
+
const part = typed.slice(at2 + 1).find((one) => isSingleSelect(one.field));
|
|
54839
|
+
return { day: day.name, ...part === void 0 ? {} : { part: part.name } };
|
|
54840
|
+
}
|
|
54841
|
+
function documentUnder(model, parent, child, field2) {
|
|
54842
|
+
const link = fieldOf(child, field2);
|
|
54843
|
+
if (link === void 0) return { refused: `names "${field2}", which is not a field of "${child.alias}"` };
|
|
54844
|
+
if (link.type !== "select_record_link" || link.cardinality !== "one") {
|
|
54845
|
+
return { refused: `"${field2}" is ${link.type === "select_record_link" ? "a link to several rows" : `a ${link.type} field`} \u2014 a line stands under the one document its one-link names` };
|
|
54846
|
+
}
|
|
54847
|
+
const document2 = entityOf(model, link.target_entity);
|
|
54848
|
+
if (document2 === void 0) return { refused: `"${field2}" links to "${link.target_entity}", which this model does not declare` };
|
|
54849
|
+
const back = document2.fields.find(
|
|
54850
|
+
(one) => one.type === "select_record_link" && one.target_entity === child.alias && (one.cardinality ?? "many") === "many" && (one.paired_field_alias === field2 || link.paired_field_alias === one.alias)
|
|
54851
|
+
);
|
|
54852
|
+
if (back === void 0) {
|
|
54853
|
+
return { refused: `"${document2.alias}" has no many-link paired with "${field2}" \u2014 a document names the lines it covers; pair one both ways` };
|
|
54854
|
+
}
|
|
54855
|
+
const own = childLink(document2, parent, void 0);
|
|
54856
|
+
if ("refused" in own) return { refused: `stands the lines under "${document2.alias}", which is read under this record by its one link to "${parent}": it ${own.refused.replace(/^reads "[^"]+", which /, "")}` };
|
|
54857
|
+
return { field: field2, entity: document2.alias, via: own.link, back: back.alias };
|
|
54858
|
+
}
|
|
54859
|
+
function recordChildren(model, parent, blocks) {
|
|
54860
|
+
return unique(
|
|
54861
|
+
blocks.flatMap((block2) => {
|
|
54862
|
+
if (isAgendaBlock(block2)) return [block2.agenda];
|
|
54863
|
+
if (!isRowsBlock(block2)) return [];
|
|
54864
|
+
const child = entityOf(model, block2.rows);
|
|
54865
|
+
const document2 = child === void 0 || block2.under === void 0 ? void 0 : documentUnder(model, parent, child, block2.under);
|
|
54866
|
+
return [block2.rows, ...document2 === void 0 || "refused" in document2 ? [] : [document2.entity]];
|
|
54867
|
+
})
|
|
54868
|
+
);
|
|
54869
|
+
}
|
|
54776
54870
|
|
|
54777
54871
|
// ../shared/src/schemas/app_model_validate_parts.ts
|
|
54778
54872
|
init_define_LOTICS_KIT_VERSIONS();
|
|
54779
54873
|
var error51 = (path40, message2) => ({ severity: "error", path: path40, message: message2 });
|
|
54780
|
-
|
|
54874
|
+
var note = (path40, message2) => ({ severity: "note", path: path40, message: message2 });
|
|
54875
|
+
function checkWhen(entities, entity, when, at2, place) {
|
|
54781
54876
|
const findings = [];
|
|
54782
54877
|
for (const [name, options] of Object.entries(when ?? {})) {
|
|
54783
54878
|
const field2 = fieldOf(entity, name);
|
|
@@ -54785,8 +54880,18 @@ function checkSelectsHold(entity, when, at2) {
|
|
|
54785
54880
|
findings.push(error51(`${at2}.${name}`, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
54786
54881
|
continue;
|
|
54787
54882
|
}
|
|
54788
|
-
if (
|
|
54789
|
-
|
|
54883
|
+
if (typeof options === "boolean") {
|
|
54884
|
+
if (field2.type === "boolean") continue;
|
|
54885
|
+
if (field2.type === "formula" && resolvedFieldType(field2, entity, entities) === "boolean") {
|
|
54886
|
+
if (place === "stage") findings.push(error51(`${at2}.${name}`, `"${name}" is a formula \u2014 a stage is read as a write leaves the row, before a formula over it is computed; wait on a stored yes/no`));
|
|
54887
|
+
continue;
|
|
54888
|
+
}
|
|
54889
|
+
findings.push(error51(`${at2}.${name}`, `"${name}" is a ${field2.type} field \u2014 true or false waits on a yes/no, stored or a formula of one`));
|
|
54890
|
+
continue;
|
|
54891
|
+
}
|
|
54892
|
+
if (field2.type !== "select" || field2.multi === true) {
|
|
54893
|
+
const kind = field2.type === "select" ? "multi-select" : `${field2.type} field`;
|
|
54894
|
+
findings.push(error51(`${at2}.${name}`, `"${name}" is a ${kind} \u2014 options wait on a single select; a yes/no waits on \`true\` or \`false\``));
|
|
54790
54895
|
continue;
|
|
54791
54896
|
}
|
|
54792
54897
|
const declared = new Set(field2.options.map((option) => option.alias));
|
|
@@ -54886,7 +54991,7 @@ function checkDashboard(model, app, at2) {
|
|
|
54886
54991
|
|
|
54887
54992
|
// ../shared/src/schemas/app_model_validate_milestones.ts
|
|
54888
54993
|
init_define_LOTICS_KIT_VERSIONS();
|
|
54889
|
-
function checkMilestones(entity, display, at2) {
|
|
54994
|
+
function checkMilestones(entities, entity, display, at2) {
|
|
54890
54995
|
const stages = milestonesOf(display);
|
|
54891
54996
|
if (stages === void 0) return [];
|
|
54892
54997
|
const findings = [];
|
|
@@ -54903,7 +55008,7 @@ function checkMilestones(entity, display, at2) {
|
|
|
54903
55008
|
}
|
|
54904
55009
|
if (seen.has(stage.field)) findings.push(error51(path40, `"${stage.field}" is already a milestone \u2014 each date marks one stage`));
|
|
54905
55010
|
seen.add(stage.field);
|
|
54906
|
-
findings.push(...
|
|
55011
|
+
findings.push(...checkWhen(entities, entity, stage.when, `${path40}.when`, "stage"));
|
|
54907
55012
|
}
|
|
54908
55013
|
for (const [index, closed] of (display.status?.closed ?? []).entries()) {
|
|
54909
55014
|
if (!seen.has(closed)) findings.push(error51(`${at2}.status.closed.${index}`, `names "${closed}", which is not one of the milestones`));
|
|
@@ -54963,7 +55068,7 @@ function checkRecords(model) {
|
|
|
54963
55068
|
const history = historyShape(model, alias2);
|
|
54964
55069
|
if (history !== void 0 && "refused" in history) findings.push(error51(`${at2}.status.history`, history.refused));
|
|
54965
55070
|
}
|
|
54966
|
-
findings.push(...checkMilestones(entity, display, at2));
|
|
55071
|
+
findings.push(...checkMilestones(model.entities, entity, display, at2));
|
|
54967
55072
|
if (display.figure !== void 0) {
|
|
54968
55073
|
const figure = field2(display.figure, `${at2}.figure`);
|
|
54969
55074
|
if (figure !== void 0 && valueType(figure) !== "number") {
|
|
@@ -54990,6 +55095,17 @@ function checkRecords(model) {
|
|
|
54990
55095
|
findings.push(error51(`${at2}.tolerance.${name}`, `tolerates an excess on "${name}", which \`limits\` does not bound \u2014 a tolerance sits on a meter`));
|
|
54991
55096
|
}
|
|
54992
55097
|
}
|
|
55098
|
+
for (const [name, start] of Object.entries(display.starts ?? {})) {
|
|
55099
|
+
const path40 = `${at2}.starts.${name}`;
|
|
55100
|
+
const started = field2(name, path40);
|
|
55101
|
+
if (started === void 0) continue;
|
|
55102
|
+
if (!isAsked(started)) findings.push(error51(path40, `"${name}" is a ${started.type} field nobody writes, so an add never starts it`));
|
|
55103
|
+
else if (start === "today" && (started.type !== "date" || started.format === "date_range" || started.format === "datetime_range")) {
|
|
55104
|
+
findings.push(error51(path40, `"${name}" is ${started.type === "date" ? "a span of days" : `a ${started.type} field`} \u2014 \`today\` starts a date or a moment`));
|
|
55105
|
+
} else if (start === "me" && started.type !== "select_member") {
|
|
55106
|
+
findings.push(error51(path40, `"${name}" is a ${started.type} field \u2014 \`me\` starts a member field at the reader adding the row`));
|
|
55107
|
+
}
|
|
55108
|
+
}
|
|
54993
55109
|
for (const [index, name] of (display.due ?? []).entries()) {
|
|
54994
55110
|
const due = field2(name, `${at2}.due.${index}`);
|
|
54995
55111
|
if (due !== void 0 && valueType(due) !== "date") findings.push(error51(`${at2}.due.${index}`, `"${name}" is not a date, so it cannot fall due`));
|
|
@@ -54998,238 +55114,19 @@ function checkRecords(model) {
|
|
|
54998
55114
|
return findings;
|
|
54999
55115
|
}
|
|
55000
55116
|
|
|
55001
|
-
// ../shared/src/schemas/
|
|
55002
|
-
init_define_LOTICS_KIT_VERSIONS();
|
|
55003
|
-
|
|
55004
|
-
// ../shared/src/app_workflow_inputs.ts
|
|
55117
|
+
// ../shared/src/schemas/app_model_validate_visuals.ts
|
|
55005
55118
|
init_define_LOTICS_KIT_VERSIONS();
|
|
55006
|
-
var RECORD_INPUT = "record_id";
|
|
55007
|
-
var RECORDS_INPUT = "record_ids";
|
|
55008
|
-
var RECORDS_INPUT_MAX = 200;
|
|
55009
|
-
var ADDED_SUFFIX = "_added";
|
|
55010
|
-
var REMOVED_SUFFIX = "_removed";
|
|
55011
55119
|
|
|
55012
|
-
// ../shared/src/schemas/
|
|
55013
|
-
function checkChildCheck(model, check2, children, acts, path40) {
|
|
55014
|
-
const child = check2.of === void 0 ? void 0 : entityOf(model, check2.of);
|
|
55015
|
-
if (child === void 0 || !children.has(child.alias)) {
|
|
55016
|
-
return [error51(`${path40}.of`, `names "${String(check2.of)}", which no rows block of this record holds \u2014 a check of a child is of rows the record lists`)];
|
|
55017
|
-
}
|
|
55018
|
-
const field2 = fieldOf(child, check2.field);
|
|
55019
|
-
if (field2 === void 0) return [error51(`${path40}.field`, `names "${check2.field}", which is not a field of "${child.alias}"`)];
|
|
55020
|
-
const result = field2.type === "formula" ? resolvedFieldType(field2, child, model.entities) : void 0;
|
|
55021
|
-
const findings = [];
|
|
55022
|
-
if (result === void 0 || !["boolean", "text"].includes(result)) {
|
|
55023
|
-
findings.push(error51(`${path40}.field`, `"${check2.field}" is a ${field2.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
|
|
55024
|
-
}
|
|
55025
|
-
if (check2.blocks !== void 0 && check2.blocks !== "all") {
|
|
55026
|
-
for (const [index, target] of check2.blocks.entries()) {
|
|
55027
|
-
if (target === "edit" || target === "delete" || acts.get(target) === child.alias) continue;
|
|
55028
|
-
findings.push(error51(`${path40}.blocks.${index}`, `names "${target}" \u2014 a check of a child row refuses its saves ("edit"), its Delete ("delete"), an act of "${child.alias}" or "all"`));
|
|
55029
|
-
}
|
|
55030
|
-
}
|
|
55031
|
-
return findings;
|
|
55032
|
-
}
|
|
55033
|
-
function checkAct(model, entity, act, at2) {
|
|
55034
|
-
const findings = [];
|
|
55035
|
-
const stages = new Set((milestonesOf(recordDisplay(model, entity.alias)) ?? []).map((stage) => stage.field));
|
|
55036
|
-
if (act.on === "view" && (act.requires ?? []).length > 0) {
|
|
55037
|
-
findings.push(error51(`${at2}.requires`, "runs over every row in view, and a field one row lacks would refuse them all \u2014 state a `when` the view is narrowed to, or run it on each record"));
|
|
55038
|
-
}
|
|
55039
|
-
const own = (name, path40) => {
|
|
55040
|
-
const found = fieldOf(entity, name);
|
|
55041
|
-
if (found === void 0) findings.push(error51(path40, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
55042
|
-
return found;
|
|
55043
|
-
};
|
|
55044
|
-
findings.push(...checkSelectsHold(entity, act.when, `${at2}.when`));
|
|
55045
|
-
for (const [index, name] of (act.requires ?? []).entries()) own(name, `${at2}.requires.${index}`);
|
|
55046
|
-
for (const [index, name] of (act.recommends ?? []).entries()) {
|
|
55047
|
-
own(name, `${at2}.recommends.${index}`);
|
|
55048
|
-
if ((act.requires ?? []).includes(name)) findings.push(error51(`${at2}.recommends.${index}`, `"${name}" is also in \`requires\` \u2014 the act runs without a field it recommends`));
|
|
55049
|
-
}
|
|
55050
|
-
const inputs = [];
|
|
55051
|
-
for (const [index, ask] of (act.asks ?? []).entries()) {
|
|
55052
|
-
if (isInput(ask)) {
|
|
55053
|
-
findings.push(...checkInput(model, entity, ask, inputs, `${at2}.asks.${index}`));
|
|
55054
|
-
inputs.push(ask);
|
|
55055
|
-
continue;
|
|
55056
|
-
}
|
|
55057
|
-
const field2 = own(ask, `${at2}.asks.${index}`);
|
|
55058
|
-
if (field2 !== void 0 && !isAsked(field2)) findings.push(error51(`${at2}.asks.${index}`, `asks "${ask}", a ${field2.type} field nobody writes`));
|
|
55059
|
-
}
|
|
55060
|
-
for (const [name, value] of Object.entries(act.set ?? {})) {
|
|
55061
|
-
const field2 = own(name, `${at2}.set.${name}`);
|
|
55062
|
-
if (field2 === void 0) continue;
|
|
55063
|
-
const written = actSetValue(field2, value, inputs);
|
|
55064
|
-
if ("refused" in written) findings.push(error51(`${at2}.set.${name}`, written.refused));
|
|
55065
|
-
else if (written.kind === "clear" && stages.has(name)) {
|
|
55066
|
-
findings.push(error51(`${at2}.set.${name}`, `clears the milestone "${name}" \u2014 a milestone is cleared by unticking it, which clears every later one`));
|
|
55067
|
-
}
|
|
55068
|
-
}
|
|
55069
|
-
if (act.template !== void 0) findings.push(...checkTemplate(model, act.template, `${at2}.template`));
|
|
55070
|
-
if (act.into !== void 0) {
|
|
55071
|
-
const into = own(act.into, `${at2}.into`);
|
|
55072
|
-
if (into !== void 0 && into.type !== "files") findings.push(error51(`${at2}.into`, `"${act.into}" is a ${into.type} field \u2014 a made document is kept in a files field`));
|
|
55073
|
-
if (act.template === void 0) findings.push(error51(`${at2}.into`, `keeps a document no template makes \u2014 name the \`template\``));
|
|
55074
|
-
if (act.on === "rows" || act.on === "view") findings.push(error51(`${at2}.into`, `keeps the one document made of many rows in each of them \u2014 over several rows the file is handed back, not kept`));
|
|
55075
|
-
}
|
|
55076
|
-
return findings;
|
|
55077
|
-
}
|
|
55078
|
-
function checkHistoryCopies(model, entity, act, at2) {
|
|
55079
|
-
const shape = historyShape(model, entity.alias);
|
|
55080
|
-
const status = model.records?.[entity.alias]?.status?.field;
|
|
55081
|
-
if (shape === void 0 || "refused" in shape || status === void 0 || act.set?.[status] === void 0) return [];
|
|
55082
|
-
const history = entityOf(model, shape.entity);
|
|
55083
|
-
if (history === void 0) return [];
|
|
55084
|
-
const taken = /* @__PURE__ */ new Set([shape.link, shape.status.field, shape.at.field, ...shape.by === void 0 ? [] : [shape.by]]);
|
|
55085
|
-
const optionsHeld = (into, options) => into.type === "select" && into.multi !== true && options.every((one) => into.options.some((option) => option.alias === one.alias));
|
|
55086
|
-
return (act.asks ?? []).flatMap((ask, index) => {
|
|
55087
|
-
const name = isInput(ask) ? ask.input : ask;
|
|
55088
|
-
const into = fieldOf(history, name);
|
|
55089
|
-
if (into === void 0 || taken.has(name)) return [];
|
|
55090
|
-
const holds = (() => {
|
|
55091
|
-
if (isInput(ask)) {
|
|
55092
|
-
switch (ask.type) {
|
|
55093
|
-
case "text":
|
|
55094
|
-
case "long_text":
|
|
55095
|
-
return into.type === "text";
|
|
55096
|
-
case "select":
|
|
55097
|
-
return optionsHeld(into, ask.options ?? []);
|
|
55098
|
-
case "link":
|
|
55099
|
-
return into.type === "select_record_link" && into.target_entity === ask.entity && into.cardinality === "one";
|
|
55100
|
-
case "member":
|
|
55101
|
-
return into.type === "select_member";
|
|
55102
|
-
default:
|
|
55103
|
-
return into.type === ask.type;
|
|
55104
|
-
}
|
|
55105
|
-
}
|
|
55106
|
-
const from = fieldOf(entity, ask);
|
|
55107
|
-
if (from === void 0 || from.type !== into.type) return false;
|
|
55108
|
-
if (from.type === "select") return from.multi !== true && optionsHeld(into, from.options);
|
|
55109
|
-
if (from.type === "select_record_link") return into.type === "select_record_link" && into.target_entity === from.target_entity && from.cardinality === "one" && into.cardinality === "one";
|
|
55110
|
-
return from.type !== "files";
|
|
55111
|
-
})();
|
|
55112
|
-
return holds ? [] : [error51(`${at2}.asks.${index}`, `asks "${name}", which the move copies onto "${history.alias}"'s "${name}", a ${into.type} field that cannot hold it \u2014 rename one, or make them one kind`)];
|
|
55113
|
-
});
|
|
55114
|
-
}
|
|
55115
|
-
function checkInput(model, entity, ask, before, at2) {
|
|
55116
|
-
const findings = [];
|
|
55117
|
-
if (before.some((one) => one.input === ask.input) || fieldOf(entity, ask.input) !== void 0) {
|
|
55118
|
-
findings.push(error51(`${at2}.input`, `"${ask.input}" names another ask or a field of "${entity.alias}" \u2014 an input's name is its own`));
|
|
55119
|
-
}
|
|
55120
|
-
if (ask.type === "select" !== (ask.options !== void 0)) {
|
|
55121
|
-
findings.push(error51(at2, ask.type === "select" ? `"${ask.input}" is a select with no \`options\` \u2014 a choice names what it offers` : `"${ask.input}" is a ${ask.type} and states \`options\`, which only a select offers`));
|
|
55122
|
-
}
|
|
55123
|
-
if (ask.type === "link" !== (ask.entity !== void 0)) {
|
|
55124
|
-
findings.push(error51(at2, ask.type === "link" ? `"${ask.input}" is a link with no \`entity\` \u2014 a link names the entity it picks a row of` : `"${ask.input}" is a ${ask.type} and states \`entity\`, which only a link picks from`));
|
|
55125
|
-
}
|
|
55126
|
-
if (ask.entity !== void 0 && entityOf(model, ask.entity) === void 0) {
|
|
55127
|
-
findings.push(error51(`${at2}.entity`, `names entity "${ask.entity}", which this model does not declare`));
|
|
55128
|
-
}
|
|
55129
|
-
if (ask.default !== void 0) findings.push(...checkInputDefault(model, entity, ask, `${at2}.default`));
|
|
55130
|
-
if (ask.input === RECORD_INPUT || ask.input === RECORDS_INPUT) {
|
|
55131
|
-
findings.push(error51(`${at2}.input`, `"${ask.input}" is the input the act's workflow takes its record in \u2014 name the input for what the reader states`));
|
|
55132
|
-
}
|
|
55133
|
-
return findings;
|
|
55134
|
-
}
|
|
55135
|
-
function checkInputDefault(model, entity, ask, at2) {
|
|
55136
|
-
const name = ask.default;
|
|
55137
|
-
if (name === void 0) return [];
|
|
55138
|
-
const field2 = fieldOf(entity, name);
|
|
55139
|
-
if (field2 === void 0) return [error51(at2, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
55140
|
-
const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
|
|
55141
|
-
const holds = (() => {
|
|
55142
|
-
switch (ask.type) {
|
|
55143
|
-
case "text":
|
|
55144
|
-
case "long_text":
|
|
55145
|
-
return type === "text";
|
|
55146
|
-
case "number":
|
|
55147
|
-
case "date":
|
|
55148
|
-
return type === ask.type;
|
|
55149
|
-
case "select":
|
|
55150
|
-
return field2.type === "select" && field2.multi !== true && field2.options.every((option) => (ask.options ?? []).some((one) => one.alias === option.alias));
|
|
55151
|
-
case "link":
|
|
55152
|
-
return field2.type === "select_record_link" && field2.cardinality === "one" && field2.target_entity === ask.entity;
|
|
55153
|
-
case "member":
|
|
55154
|
-
return field2.type === "select_member" && field2.multi !== true;
|
|
55155
|
-
}
|
|
55156
|
-
})();
|
|
55157
|
-
return holds ? [] : [error51(at2, `"${name}" is a ${type} field and "${ask.input}" asks a ${ask.type} \u2014 an input starts from a field holding what it states`)];
|
|
55158
|
-
}
|
|
55159
|
-
function offeredTogether2(one, other2) {
|
|
55160
|
-
return Object.entries(one.when ?? {}).every(([field2, options]) => {
|
|
55161
|
-
const theirs = other2.when?.[field2];
|
|
55162
|
-
return theirs === void 0 || options.some((option) => theirs.includes(option));
|
|
55163
|
-
});
|
|
55164
|
-
}
|
|
55165
|
-
function checkFootNeeds(model, entity, section, drawn, act, path40) {
|
|
55166
|
-
const asked = new Set((act.asks ?? []).filter((ask) => typeof ask === "string"));
|
|
55167
|
-
const writers = registerApps(model).flatMap((app) => (app.acts ?? []).filter((one) => actEntity(app, one) === entity.alias));
|
|
55168
|
-
const writtenBy = (one) => [...Object.keys(one.set ?? {}), ...(one.asks ?? []).filter((ask) => typeof ask === "string"), ...one.into === void 0 ? [] : [one.into]];
|
|
55169
|
-
const owned = new Set(writers.flatMap(writtenBy));
|
|
55170
|
-
return (act.requires ?? []).flatMap((name, index) => {
|
|
55171
|
-
const field2 = fieldOf(entity, name);
|
|
55172
|
-
if (asked.has(name) || field2 === void 0 || !isAsked(field2) && !owned.has(name)) return [];
|
|
55173
|
-
const at2 = `${path40}.requires.${index}`;
|
|
55174
|
-
if (!drawn.has(name)) {
|
|
55175
|
-
return [error51(at2, `"${act.alias}" needs "${name}", which "${section.title}" does not draw \u2014 its line would name a field the reader cannot see there; place it in the section, or have the act ask it`)];
|
|
55176
|
-
}
|
|
55177
|
-
if (!owned.has(name)) return [];
|
|
55178
|
-
if (writers.some((one) => one.alias !== act.alias && writtenBy(one).includes(name) && offeredTogether2(one, act))) return [];
|
|
55179
|
-
return [error51(at2, `"${act.alias}" needs "${name}", which no act offered together with it writes \u2014 it reads locked while "${act.alias}" waits; have "${act.alias}" ask it`)];
|
|
55180
|
-
});
|
|
55181
|
-
}
|
|
55182
|
-
|
|
55183
|
-
// ../shared/src/schemas/app_model_resolve.ts
|
|
55120
|
+
// ../shared/src/schemas/app_model_plan.ts
|
|
55184
55121
|
init_define_LOTICS_KIT_VERSIONS();
|
|
55185
55122
|
|
|
55186
|
-
// ../shared/src/
|
|
55123
|
+
// ../shared/src/schemas/app_model_resolved.ts
|
|
55187
55124
|
init_define_LOTICS_KIT_VERSIONS();
|
|
55188
|
-
|
|
55189
|
-
|
|
55190
|
-
if (!("type" in node) || node.type !== "PathExpression") return void 0;
|
|
55191
|
-
if ("data" in node && node.data === true) return void 0;
|
|
55192
|
-
if ("depth" in node && node.depth !== 0) return void 0;
|
|
55193
|
-
const parts = "parts" in node && Array.isArray(node.parts) ? node.parts : [];
|
|
55194
|
-
const [first2] = parts;
|
|
55195
|
-
return typeof first2 === "string" ? first2 : void 0;
|
|
55196
|
-
}
|
|
55197
|
-
function visit(node, into) {
|
|
55198
|
-
if (Array.isArray(node)) {
|
|
55199
|
-
for (const child of node) visit(child, into);
|
|
55200
|
-
return;
|
|
55201
|
-
}
|
|
55202
|
-
if (typeof node !== "object" || node === null || !("type" in node)) return;
|
|
55203
|
-
const own = rootName(node);
|
|
55204
|
-
if (own !== void 0) {
|
|
55205
|
-
into.add(own);
|
|
55206
|
-
return;
|
|
55207
|
-
}
|
|
55208
|
-
const params = "params" in node ? node.params : [];
|
|
55209
|
-
const hash2 = "hash" in node ? node.hash : void 0;
|
|
55210
|
-
if (node.type === "MustacheStatement" || node.type === "SubExpression") {
|
|
55211
|
-
const called = Array.isArray(params) && params.length > 0;
|
|
55212
|
-
if (!called && "path" in node) visit(node.path, into);
|
|
55213
|
-
visit(params, into);
|
|
55214
|
-
visit(hash2, into);
|
|
55215
|
-
return;
|
|
55216
|
-
}
|
|
55217
|
-
if (node.type === "BlockStatement") {
|
|
55218
|
-
const helper = "path" in node && typeof node.path === "object" && node.path !== null ? rootName(node.path) : void 0;
|
|
55219
|
-
visit(params, into);
|
|
55220
|
-
visit(hash2, into);
|
|
55221
|
-
if (helper !== "each" && helper !== "with") {
|
|
55222
|
-
if ("program" in node) visit(node.program, into);
|
|
55223
|
-
if ("inverse" in node) visit(node.inverse, into);
|
|
55224
|
-
}
|
|
55225
|
-
return;
|
|
55226
|
-
}
|
|
55227
|
-
for (const value of Object.values(node)) visit(value, into);
|
|
55125
|
+
function resolvedBlocks(record2) {
|
|
55126
|
+
return record2.sections.flatMap((section) => section.blocks);
|
|
55228
55127
|
}
|
|
55229
|
-
function
|
|
55230
|
-
|
|
55231
|
-
visit(import_handlebars.default.parse(content), into);
|
|
55232
|
-
return [...into];
|
|
55128
|
+
function isResolvedDashboard(app) {
|
|
55129
|
+
return "readings" in app;
|
|
55233
55130
|
}
|
|
55234
55131
|
|
|
55235
55132
|
// ../shared/src/schemas/app_model_resolve_readings.ts
|
|
@@ -55330,18 +55227,6 @@ function readingFields(reading) {
|
|
|
55330
55227
|
}
|
|
55331
55228
|
}
|
|
55332
55229
|
|
|
55333
|
-
// ../shared/src/schemas/app_model_plan.ts
|
|
55334
|
-
init_define_LOTICS_KIT_VERSIONS();
|
|
55335
|
-
|
|
55336
|
-
// ../shared/src/schemas/app_model_resolved.ts
|
|
55337
|
-
init_define_LOTICS_KIT_VERSIONS();
|
|
55338
|
-
function resolvedBlocks(record2) {
|
|
55339
|
-
return record2.sections.flatMap((section) => section.blocks);
|
|
55340
|
-
}
|
|
55341
|
-
function isResolvedDashboard(app) {
|
|
55342
|
-
return "readings" in app;
|
|
55343
|
-
}
|
|
55344
|
-
|
|
55345
55230
|
// ../shared/src/schemas/app_model_plan.ts
|
|
55346
55231
|
function recordHeaderFields(display) {
|
|
55347
55232
|
if (display === void 0) return [];
|
|
@@ -55359,15 +55244,25 @@ function historyOf(model, parent) {
|
|
|
55359
55244
|
return shape === void 0 || "refused" in shape ? void 0 : shape;
|
|
55360
55245
|
}
|
|
55361
55246
|
function defaultCreate(model, entity, excluded) {
|
|
55247
|
+
return createAsking(model, entity, excluded, []);
|
|
55248
|
+
}
|
|
55249
|
+
function defaultBlockCreate(model, child, excluded, columns) {
|
|
55250
|
+
const image = model.records?.[child.alias]?.image;
|
|
55251
|
+
const later = new Set((milestonesOf(recordDisplay(model, child.alias)) ?? []).slice(1).map((stage) => stage.field));
|
|
55252
|
+
return createAsking(model, child, excluded, [...image === void 0 ? [] : [image], ...columns].filter((name) => !later.has(name)));
|
|
55253
|
+
}
|
|
55254
|
+
function createAsking(model, entity, excluded, read2) {
|
|
55362
55255
|
const title = model.records?.[entity.alias]?.title;
|
|
55363
55256
|
const rules = model.write_rules?.[entity.alias];
|
|
55364
55257
|
const copied = new Set(Object.entries(rules?.fields ?? {}).flatMap(([field2, rule]) => rule.default_from === void 0 ? [] : [field2]));
|
|
55365
55258
|
const stamped = actSetFields(model, entity.alias);
|
|
55366
55259
|
const asked = (field2) => field2 !== void 0 && isAsked(field2) && !excluded.includes(field2.alias) && !copied.has(field2.alias) && !stamped.has(field2.alias);
|
|
55367
55260
|
const titleField = title === void 0 ? void 0 : fieldOf(entity, title);
|
|
55261
|
+
const shown = read2.map((name) => fieldOf(entity, name)).filter(asked);
|
|
55368
55262
|
const keyed = (rules?.natural_key ?? []).map((key) => fieldOf(entity, key)).filter(asked);
|
|
55369
55263
|
return unique([
|
|
55370
55264
|
...asked(titleField) ? [titleField.alias] : [],
|
|
55265
|
+
...shown.map((field2) => field2.alias),
|
|
55371
55266
|
...keyed.map((field2) => field2.alias),
|
|
55372
55267
|
...requiredOnCreate(model, entity, [...excluded, ...stamped])
|
|
55373
55268
|
]);
|
|
@@ -55380,9 +55275,9 @@ function appWritePlan(model, app) {
|
|
|
55380
55275
|
const entity = entityOf(model, app.entity);
|
|
55381
55276
|
const display = model.records?.[app.entity];
|
|
55382
55277
|
const owned = (alias2) => actOwnedFields(model, alias2);
|
|
55383
|
-
const added = (alias2, create) => {
|
|
55278
|
+
const added = (alias2, create, sent = []) => {
|
|
55384
55279
|
const stamped = actSetFields(model, alias2);
|
|
55385
|
-
return create.filter((name) => !stamped.has(name));
|
|
55280
|
+
return create.filter((name) => sent.includes(name) || !stamped.has(name));
|
|
55386
55281
|
};
|
|
55387
55282
|
if (entity !== void 0 && app.writes === "record" && peopleWrite(entity)) {
|
|
55388
55283
|
const placed2 = unique([
|
|
@@ -55401,13 +55296,24 @@ function appWritePlan(model, app) {
|
|
|
55401
55296
|
remove: true
|
|
55402
55297
|
});
|
|
55403
55298
|
}
|
|
55299
|
+
const saved = (child, fixed) => child.fields.filter((field2) => isAsked(field2) && !fixed.includes(field2.alias) && !owned(child.alias).has(field2.alias)).map((field2) => field2.alias);
|
|
55404
55300
|
for (const block2 of resolvedBlocks(app.record)) {
|
|
55405
|
-
if (block2.kind !== "rows" && block2.kind !== "timeline") continue;
|
|
55301
|
+
if (block2.kind !== "rows" && block2.kind !== "agenda" && block2.kind !== "timeline") continue;
|
|
55302
|
+
const under = block2.kind === "rows" ? block2.under : void 0;
|
|
55303
|
+
const document2 = under === void 0 ? void 0 : entityOf(model, under.entity);
|
|
55304
|
+
if (under !== void 0 && document2 !== void 0 && peopleWrite(document2) && !plan.has(document2.alias)) {
|
|
55305
|
+
const fixed = [under.via, under.back, ...under.where.map((one) => one.field)];
|
|
55306
|
+
plan.set(document2.alias, { ...under.create === void 0 ? {} : { create: added(document2.alias, under.create, fixed), parent: under.via }, stamps: [], update: saved(document2, fixed), remove: true });
|
|
55307
|
+
}
|
|
55406
55308
|
const child = entityOf(model, block2.entity);
|
|
55407
55309
|
if (child === void 0 || !peopleWrite(child) || plan.has(child.alias)) continue;
|
|
55408
|
-
if (block2.kind === "rows") {
|
|
55409
|
-
|
|
55410
|
-
|
|
55310
|
+
if (block2.kind === "rows" || block2.kind === "agenda") {
|
|
55311
|
+
plan.set(child.alias, {
|
|
55312
|
+
...block2.create === void 0 ? {} : { create: added(child.alias, block2.create), parent: block2.via },
|
|
55313
|
+
stamps: [],
|
|
55314
|
+
update: saved(child, [block2.via, ...under === void 0 ? [] : [under.field]]),
|
|
55315
|
+
remove: true
|
|
55316
|
+
});
|
|
55411
55317
|
continue;
|
|
55412
55318
|
}
|
|
55413
55319
|
if (block2.create === void 0) continue;
|
|
@@ -55460,6 +55366,7 @@ function appDrawnFields(model, app) {
|
|
|
55460
55366
|
...app.register.filters,
|
|
55461
55367
|
...app.register.search ?? [],
|
|
55462
55368
|
...app.register.sort === void 0 ? [] : [app.register.sort.field],
|
|
55369
|
+
...app.register.group === void 0 ? [] : [app.register.group],
|
|
55463
55370
|
...app.register.create ?? [],
|
|
55464
55371
|
...app.register.readings.flatMap(readingFields),
|
|
55465
55372
|
...app.record.sections.flatMap((section) => [...section.fields, ...section.when.map((gate) => gate.field)]),
|
|
@@ -55482,11 +55389,24 @@ function appDrawnFields(model, app) {
|
|
|
55482
55389
|
const written = child === void 0 ? [] : child.fields.filter((field2) => isAsked(field2) && field2.alias !== block2.via).map((field2) => field2.alias);
|
|
55483
55390
|
add(block2.entity, [...block2.columns, ...block2.create ?? [], ...block2.where.map((one) => one.field), ...written]);
|
|
55484
55391
|
add(app.entity, [...blockTotals(model, app.entity, block2.entity, block2.via, block2.columns, block2.where).values(), ...block2.of === void 0 ? [] : [block2.of]]);
|
|
55392
|
+
const under = block2.under;
|
|
55393
|
+
const document2 = under === void 0 ? void 0 : entityOf(model, under.entity);
|
|
55394
|
+
if (under !== void 0 && document2 !== void 0) {
|
|
55395
|
+
add(block2.entity, [under.field]);
|
|
55396
|
+
const writes = document2.fields.filter((field2) => isAsked(field2) && field2.alias !== under.via).map((field2) => field2.alias);
|
|
55397
|
+
add(document2.alias, [under.via, under.back, ...under.where.map((one) => one.field), ...under.create ?? [], ...writes]);
|
|
55398
|
+
}
|
|
55399
|
+
}
|
|
55400
|
+
if (block2.kind === "agenda") {
|
|
55401
|
+
const child = entityOf(model, block2.entity);
|
|
55402
|
+
const written = child === void 0 ? [] : child.fields.filter((field2) => isAsked(field2) && field2.alias !== block2.via).map((field2) => field2.alias);
|
|
55403
|
+
add(block2.entity, [...block2.columns, ...block2.create ?? [], block2.day, ...block2.time === void 0 ? [] : [block2.time], ...block2.part === void 0 ? [] : [block2.part], ...written]);
|
|
55404
|
+
if (block2.start !== void 0) add(app.entity, [block2.start]);
|
|
55485
55405
|
}
|
|
55486
55406
|
if (block2.kind === "timeline") add(block2.entity, [block2.at, ...block2.by === void 0 ? [] : [block2.by], ...block2.create ?? []]);
|
|
55487
55407
|
if (block2.kind === "reading") add(block2.reading.entity, [block2.via, ...readingFields(block2.reading)]);
|
|
55488
55408
|
}
|
|
55489
|
-
const moves = app.record.
|
|
55409
|
+
const moves = app.record.moves === void 0 ? void 0 : historyOf(model, app.entity);
|
|
55490
55410
|
if (moves !== void 0) add(moves.entity, [moves.at.field, ...moves.by === void 0 ? [] : [moves.by], moves.status.field]);
|
|
55491
55411
|
const plan = appWritePlan(model, app);
|
|
55492
55412
|
const moved = [app.entity, ...app.acts.flatMap((act) => act.of === void 0 ? [] : [act.of]), ...[...plan].flatMap(([alias2, writes]) => writes.create !== void 0 || writes.update.length > 0 ? [alias2] : [])];
|
|
@@ -55533,8 +55453,322 @@ function followLinks(model, drawn) {
|
|
|
55533
55453
|
return drawn;
|
|
55534
55454
|
}
|
|
55535
55455
|
|
|
55456
|
+
// ../shared/src/schemas/app_model_validate_visuals.ts
|
|
55457
|
+
function checkFirstClass(model, app, resolved, at2) {
|
|
55458
|
+
return [...checkFilesShown(model, app, at2), ...checkDocumentsPictured(model, resolved, at2), ...noteRegisterReadings(model, app, at2)];
|
|
55459
|
+
}
|
|
55460
|
+
function checkFilesShown(model, app, at2) {
|
|
55461
|
+
const sections = app.record?.sections;
|
|
55462
|
+
const entity = entityOf(model, app.entity);
|
|
55463
|
+
if (sections === void 0 || entity === void 0) return [];
|
|
55464
|
+
const display = recordDisplay(model, app.entity);
|
|
55465
|
+
const shown = /* @__PURE__ */ new Set([
|
|
55466
|
+
...app.register?.columns ?? [],
|
|
55467
|
+
...display?.image === void 0 ? [] : [display.image],
|
|
55468
|
+
...display?.subtitle ?? [],
|
|
55469
|
+
...sections.flatMap((section) => [...section.fields ?? [], ...(section.blocks ?? []).flatMap((block2) => "files" in block2 ? block2.files : [])])
|
|
55470
|
+
]);
|
|
55471
|
+
return entity.fields.filter((field2) => field2.type === "files" && !shown.has(field2.alias)).map(
|
|
55472
|
+
(field2) => error51(`${at2}.record`, `"${app.entity}" holds "${field2.alias}" files no screen shows \u2014 place it in a section's \`fields\` or a \`files\` block, or make it the \`image\``)
|
|
55473
|
+
);
|
|
55474
|
+
}
|
|
55475
|
+
function checkDocumentsPictured(model, resolved, at2) {
|
|
55476
|
+
const targets = /* @__PURE__ */ new Set();
|
|
55477
|
+
for (const [alias2, names] of appDrawnFields(model, resolved)) {
|
|
55478
|
+
const entity = entityOf(model, alias2);
|
|
55479
|
+
if (entity === void 0) continue;
|
|
55480
|
+
for (const name of names) {
|
|
55481
|
+
const field2 = fieldOf(entity, name);
|
|
55482
|
+
if (field2?.type === "select_record_link") targets.add(field2.target_entity);
|
|
55483
|
+
}
|
|
55484
|
+
}
|
|
55485
|
+
return [...targets].flatMap((target) => {
|
|
55486
|
+
const entity = entityOf(model, target);
|
|
55487
|
+
const [files, ...more] = entity?.fields.filter((field2) => field2.type === "files") ?? [];
|
|
55488
|
+
if (files === void 0 || more.length > 0 || recordDisplay(model, target)?.image !== void 0) return [];
|
|
55489
|
+
return [error51(at2, `links to "${target}" rows, documents holding "${files.alias}" files, and "${target}" has no \`image\` \u2014 a link to one previews nothing; declare \`records.${target}.image: "${files.alias}"\``)];
|
|
55490
|
+
});
|
|
55491
|
+
}
|
|
55492
|
+
function noteRegisterReadings(model, app, at2) {
|
|
55493
|
+
if (app.register?.readings !== void 0) return [];
|
|
55494
|
+
const entity = entityOf(model, app.entity);
|
|
55495
|
+
const display = recordDisplay(model, app.entity);
|
|
55496
|
+
if (entity === void 0) return [];
|
|
55497
|
+
const broken = display?.figure ?? display?.status?.field ?? entity.fields.find((field2) => isSingleSelect(field2))?.alias;
|
|
55498
|
+
if (broken === void 0) return [];
|
|
55499
|
+
return [note(`${at2}.register`, `states no \`readings\` over "${app.entity}", which has "${broken}" to read \u2014 a register reads faster with its readings`)];
|
|
55500
|
+
}
|
|
55501
|
+
|
|
55502
|
+
// ../shared/src/schemas/app_model_validate_acts.ts
|
|
55503
|
+
init_define_LOTICS_KIT_VERSIONS();
|
|
55504
|
+
|
|
55505
|
+
// ../shared/src/app_workflow_inputs.ts
|
|
55506
|
+
init_define_LOTICS_KIT_VERSIONS();
|
|
55507
|
+
var RECORD_INPUT = "record_id";
|
|
55508
|
+
var RECORDS_INPUT = "record_ids";
|
|
55509
|
+
var RECORDS_INPUT_MAX = 200;
|
|
55510
|
+
var ADDED_SUFFIX = "_added";
|
|
55511
|
+
var REMOVED_SUFFIX = "_removed";
|
|
55512
|
+
|
|
55513
|
+
// ../shared/src/schemas/app_model_validate_acts.ts
|
|
55514
|
+
function checkChildCheck(model, check2, children, acts, path40) {
|
|
55515
|
+
const child = check2.of === void 0 ? void 0 : entityOf(model, check2.of);
|
|
55516
|
+
if (child === void 0 || !children.has(child.alias)) {
|
|
55517
|
+
return [error51(`${path40}.of`, `names "${String(check2.of)}", which this record lists no rows of \u2014 a check of a child is of rows the record lists`)];
|
|
55518
|
+
}
|
|
55519
|
+
const field2 = fieldOf(child, check2.field);
|
|
55520
|
+
if (field2 === void 0) return [error51(`${path40}.field`, `names "${check2.field}", which is not a field of "${child.alias}"`)];
|
|
55521
|
+
const result = field2.type === "formula" ? resolvedFieldType(field2, child, model.entities) : void 0;
|
|
55522
|
+
const findings = [];
|
|
55523
|
+
if (result === void 0 || !["boolean", "text"].includes(result)) {
|
|
55524
|
+
findings.push(error51(`${path40}.field`, `"${check2.field}" is a ${field2.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
|
|
55525
|
+
}
|
|
55526
|
+
findings.push(...checkRestatesMeter(model, child, check2, path40));
|
|
55527
|
+
if (check2.blocks !== void 0 && check2.blocks !== "all") {
|
|
55528
|
+
for (const [index, target] of check2.blocks.entries()) {
|
|
55529
|
+
if (target === "edit" || target === "delete" || acts.get(target) === child.alias) continue;
|
|
55530
|
+
findings.push(error51(`${path40}.blocks.${index}`, `names "${target}" \u2014 a check of a child row refuses its saves ("edit"), its Delete ("delete"), an act of "${child.alias}" or "all"`));
|
|
55531
|
+
}
|
|
55532
|
+
}
|
|
55533
|
+
return findings;
|
|
55534
|
+
}
|
|
55535
|
+
function checkRestatesMeter(model, entity, check2, path40) {
|
|
55536
|
+
const display = model.records?.[entity.alias];
|
|
55537
|
+
const figure = display?.figure;
|
|
55538
|
+
const bound = figure === void 0 ? void 0 : display?.limits?.[figure];
|
|
55539
|
+
const gate = figure === void 0 ? void 0 : display?.gates?.[figure];
|
|
55540
|
+
if (check2.blocks !== void 0 || figure === void 0 || bound === void 0 || gate === void 0) return [];
|
|
55541
|
+
const reads = formulaReads(entity, check2.field);
|
|
55542
|
+
const marks = [bound, gate].filter((mark) => typeof mark === "string");
|
|
55543
|
+
if (!reads.has(figure) || !marks.some((mark) => reads.has(mark))) return [];
|
|
55544
|
+
const pass = typeof gate === "string" ? `"${gate}"` : String(gate);
|
|
55545
|
+
return [
|
|
55546
|
+
error51(
|
|
55547
|
+
`${path40}.field`,
|
|
55548
|
+
`"${check2.field}" reads "${figure}" against its pass mark ${pass}, which the figure's meter already draws \u2014 named under its track, amber until reached; drop the check`
|
|
55549
|
+
)
|
|
55550
|
+
];
|
|
55551
|
+
}
|
|
55552
|
+
function formulaReads(entity, alias2) {
|
|
55553
|
+
const reads = /* @__PURE__ */ new Set();
|
|
55554
|
+
const visit2 = (name) => {
|
|
55555
|
+
const field2 = fieldOf(entity, name);
|
|
55556
|
+
if (field2?.type !== "formula") return;
|
|
55557
|
+
for (const ref of contractFormulaFieldRefs(field2.formula.expression)) {
|
|
55558
|
+
if (reads.has(ref)) continue;
|
|
55559
|
+
reads.add(ref);
|
|
55560
|
+
visit2(ref);
|
|
55561
|
+
}
|
|
55562
|
+
};
|
|
55563
|
+
visit2(alias2);
|
|
55564
|
+
return reads;
|
|
55565
|
+
}
|
|
55566
|
+
function checkAct(model, entity, act, at2) {
|
|
55567
|
+
const findings = [];
|
|
55568
|
+
const stages = new Set((milestonesOf(recordDisplay(model, entity.alias)) ?? []).map((stage) => stage.field));
|
|
55569
|
+
if (act.on === "view" && (act.requires ?? []).length > 0) {
|
|
55570
|
+
findings.push(error51(`${at2}.requires`, "runs over every row in view, and a field one row lacks would refuse them all \u2014 state a `when` the view is narrowed to, or run it on each record"));
|
|
55571
|
+
}
|
|
55572
|
+
const own = (name, path40) => {
|
|
55573
|
+
const found = fieldOf(entity, name);
|
|
55574
|
+
if (found === void 0) findings.push(error51(path40, `names "${name}", which is not a field of "${entity.alias}"`));
|
|
55575
|
+
return found;
|
|
55576
|
+
};
|
|
55577
|
+
findings.push(...checkWhen(model.entities, entity, act.when, `${at2}.when`, "offer"));
|
|
55578
|
+
for (const [index, name] of (act.requires ?? []).entries()) own(name, `${at2}.requires.${index}`);
|
|
55579
|
+
for (const [index, name] of (act.recommends ?? []).entries()) {
|
|
55580
|
+
own(name, `${at2}.recommends.${index}`);
|
|
55581
|
+
if ((act.requires ?? []).includes(name)) findings.push(error51(`${at2}.recommends.${index}`, `"${name}" is also in \`requires\` \u2014 the act runs without a field it recommends`));
|
|
55582
|
+
}
|
|
55583
|
+
const inputs = [];
|
|
55584
|
+
for (const [index, ask] of (act.asks ?? []).entries()) {
|
|
55585
|
+
if (isInput(ask)) {
|
|
55586
|
+
findings.push(...checkInput(model, entity, ask, inputs, `${at2}.asks.${index}`));
|
|
55587
|
+
inputs.push(ask);
|
|
55588
|
+
continue;
|
|
55589
|
+
}
|
|
55590
|
+
const field2 = own(ask, `${at2}.asks.${index}`);
|
|
55591
|
+
if (field2 !== void 0 && !isAsked(field2)) findings.push(error51(`${at2}.asks.${index}`, `asks "${ask}", a ${field2.type} field nobody writes`));
|
|
55592
|
+
}
|
|
55593
|
+
for (const [name, value] of Object.entries(act.set ?? {})) {
|
|
55594
|
+
const field2 = own(name, `${at2}.set.${name}`);
|
|
55595
|
+
if (field2 === void 0) continue;
|
|
55596
|
+
const written = actSetValue(field2, value, inputs);
|
|
55597
|
+
if ("refused" in written) findings.push(error51(`${at2}.set.${name}`, written.refused));
|
|
55598
|
+
else if (written.kind === "clear" && stages.has(name)) {
|
|
55599
|
+
findings.push(error51(`${at2}.set.${name}`, `clears the milestone "${name}" \u2014 a milestone is cleared by unticking it, which clears every later one`));
|
|
55600
|
+
}
|
|
55601
|
+
}
|
|
55602
|
+
if (act.template !== void 0) findings.push(...checkTemplate(model, act.template, `${at2}.template`));
|
|
55603
|
+
if (act.into !== void 0) {
|
|
55604
|
+
const into = own(act.into, `${at2}.into`);
|
|
55605
|
+
if (into !== void 0 && into.type !== "files") findings.push(error51(`${at2}.into`, `"${act.into}" is a ${into.type} field \u2014 a made document is kept in a files field`));
|
|
55606
|
+
if (act.template === void 0) findings.push(error51(`${at2}.into`, `keeps a document no template makes \u2014 name the \`template\``));
|
|
55607
|
+
if (act.on === "rows" || act.on === "view") findings.push(error51(`${at2}.into`, `keeps the one document made of many rows in each of them \u2014 over several rows the file is handed back, not kept`));
|
|
55608
|
+
}
|
|
55609
|
+
return findings;
|
|
55610
|
+
}
|
|
55611
|
+
function checkHistoryCopies(model, entity, act, at2) {
|
|
55612
|
+
const shape = historyShape(model, entity.alias);
|
|
55613
|
+
const status = model.records?.[entity.alias]?.status?.field;
|
|
55614
|
+
if (shape === void 0 || "refused" in shape || status === void 0 || act.set?.[status] === void 0) return [];
|
|
55615
|
+
const history = entityOf(model, shape.entity);
|
|
55616
|
+
if (history === void 0) return [];
|
|
55617
|
+
const taken = /* @__PURE__ */ new Set([shape.link, shape.status.field, shape.at.field, ...shape.by === void 0 ? [] : [shape.by]]);
|
|
55618
|
+
const optionsHeld2 = (into, options) => into.type === "select" && into.multi !== true && options.every((one) => into.options.some((option) => option.alias === one.alias));
|
|
55619
|
+
return (act.asks ?? []).flatMap((ask, index) => {
|
|
55620
|
+
const name = isInput(ask) ? ask.input : ask;
|
|
55621
|
+
const into = fieldOf(history, name);
|
|
55622
|
+
if (into === void 0 || taken.has(name)) return [];
|
|
55623
|
+
const holds = (() => {
|
|
55624
|
+
if (isInput(ask)) {
|
|
55625
|
+
switch (ask.type) {
|
|
55626
|
+
case "text":
|
|
55627
|
+
case "long_text":
|
|
55628
|
+
return into.type === "text";
|
|
55629
|
+
case "select":
|
|
55630
|
+
return optionsHeld2(into, ask.options ?? []);
|
|
55631
|
+
case "link":
|
|
55632
|
+
return into.type === "select_record_link" && into.target_entity === ask.entity && into.cardinality === "one";
|
|
55633
|
+
case "member":
|
|
55634
|
+
return into.type === "select_member";
|
|
55635
|
+
default:
|
|
55636
|
+
return into.type === ask.type;
|
|
55637
|
+
}
|
|
55638
|
+
}
|
|
55639
|
+
const from = fieldOf(entity, ask);
|
|
55640
|
+
if (from === void 0 || from.type !== into.type) return false;
|
|
55641
|
+
if (from.type === "select") return from.multi !== true && optionsHeld2(into, from.options);
|
|
55642
|
+
if (from.type === "select_record_link") return into.type === "select_record_link" && into.target_entity === from.target_entity && from.cardinality === "one" && into.cardinality === "one";
|
|
55643
|
+
return from.type !== "files";
|
|
55644
|
+
})();
|
|
55645
|
+
return holds ? [] : [error51(`${at2}.asks.${index}`, `asks "${name}", which the move copies onto "${history.alias}"'s "${name}", a ${into.type} field that cannot hold it \u2014 rename one, or make them one kind`)];
|
|
55646
|
+
});
|
|
55647
|
+
}
|
|
55648
|
+
function checkInput(model, entity, ask, before, at2) {
|
|
55649
|
+
const findings = [];
|
|
55650
|
+
if (before.some((one) => one.input === ask.input) || fieldOf(entity, ask.input) !== void 0) {
|
|
55651
|
+
findings.push(error51(`${at2}.input`, `"${ask.input}" names another ask or a field of "${entity.alias}" \u2014 an input's name is its own`));
|
|
55652
|
+
}
|
|
55653
|
+
if (ask.type === "select" !== (ask.options !== void 0)) {
|
|
55654
|
+
findings.push(error51(at2, ask.type === "select" ? `"${ask.input}" is a select with no \`options\` \u2014 a choice names what it offers` : `"${ask.input}" is a ${ask.type} and states \`options\`, which only a select offers`));
|
|
55655
|
+
}
|
|
55656
|
+
if (ask.type === "link" !== (ask.entity !== void 0)) {
|
|
55657
|
+
findings.push(error51(at2, ask.type === "link" ? `"${ask.input}" is a link with no \`entity\` \u2014 a link names the entity it picks a row of` : `"${ask.input}" is a ${ask.type} and states \`entity\`, which only a link picks from`));
|
|
55658
|
+
}
|
|
55659
|
+
if (ask.entity !== void 0 && entityOf(model, ask.entity) === void 0) {
|
|
55660
|
+
findings.push(error51(`${at2}.entity`, `names entity "${ask.entity}", which this model does not declare`));
|
|
55661
|
+
}
|
|
55662
|
+
if (ask.default !== void 0) findings.push(...checkInputDefault(model, entity, ask, `${at2}.default`));
|
|
55663
|
+
if (ask.input === RECORD_INPUT || ask.input === RECORDS_INPUT) {
|
|
55664
|
+
findings.push(error51(`${at2}.input`, `"${ask.input}" is the input the act's workflow takes its record in \u2014 name the input for what the reader states`));
|
|
55665
|
+
}
|
|
55666
|
+
return findings;
|
|
55667
|
+
}
|
|
55668
|
+
function checkInputDefault(model, entity, ask, at2) {
|
|
55669
|
+
const name = ask.default;
|
|
55670
|
+
if (name === void 0) return [];
|
|
55671
|
+
const field2 = fieldOf(entity, name);
|
|
55672
|
+
if (field2 === void 0) return [error51(at2, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
55673
|
+
const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
|
|
55674
|
+
const holds = (() => {
|
|
55675
|
+
switch (ask.type) {
|
|
55676
|
+
case "text":
|
|
55677
|
+
case "long_text":
|
|
55678
|
+
return type === "text";
|
|
55679
|
+
case "number":
|
|
55680
|
+
case "date":
|
|
55681
|
+
return type === ask.type;
|
|
55682
|
+
case "select":
|
|
55683
|
+
return field2.type === "select" && field2.multi !== true && field2.options.every((option) => (ask.options ?? []).some((one) => one.alias === option.alias));
|
|
55684
|
+
case "link":
|
|
55685
|
+
return field2.type === "select_record_link" && field2.cardinality === "one" && field2.target_entity === ask.entity;
|
|
55686
|
+
case "member":
|
|
55687
|
+
return field2.type === "select_member" && field2.multi !== true;
|
|
55688
|
+
}
|
|
55689
|
+
})();
|
|
55690
|
+
return holds ? [] : [error51(at2, `"${name}" is a ${type} field and "${ask.input}" asks a ${ask.type} \u2014 an input starts from a field holding what it states`)];
|
|
55691
|
+
}
|
|
55692
|
+
function offeredTogether2(one, other2) {
|
|
55693
|
+
return Object.entries(one.when ?? {}).every(([field2, held2]) => {
|
|
55694
|
+
const theirs = other2.when?.[field2];
|
|
55695
|
+
if (theirs === void 0) return true;
|
|
55696
|
+
if (typeof held2 === "boolean" || typeof theirs === "boolean") return held2 === theirs;
|
|
55697
|
+
return held2.some((option) => theirs.includes(option));
|
|
55698
|
+
});
|
|
55699
|
+
}
|
|
55700
|
+
function checkFootNeeds(model, entity, section, drawn, act, path40) {
|
|
55701
|
+
const asked = new Set((act.asks ?? []).filter((ask) => typeof ask === "string"));
|
|
55702
|
+
const writers = registerApps(model).flatMap((app) => (app.acts ?? []).filter((one) => actEntity(app, one) === entity.alias));
|
|
55703
|
+
const writtenBy = (one) => [...Object.keys(one.set ?? {}), ...(one.asks ?? []).filter((ask) => typeof ask === "string"), ...one.into === void 0 ? [] : [one.into]];
|
|
55704
|
+
const owned = new Set(writers.flatMap(writtenBy));
|
|
55705
|
+
return (act.requires ?? []).flatMap((name, index) => {
|
|
55706
|
+
const field2 = fieldOf(entity, name);
|
|
55707
|
+
if (asked.has(name) || field2 === void 0 || !isAsked(field2) && !owned.has(name)) return [];
|
|
55708
|
+
const at2 = `${path40}.requires.${index}`;
|
|
55709
|
+
if (!drawn.has(name)) {
|
|
55710
|
+
return [error51(at2, `"${act.alias}" needs "${name}", which "${section.title}" does not draw \u2014 its line would name a field the reader cannot see there; place it in the section, or have the act ask it`)];
|
|
55711
|
+
}
|
|
55712
|
+
if (!owned.has(name)) return [];
|
|
55713
|
+
if (writers.some((one) => one.alias !== act.alias && writtenBy(one).includes(name) && offeredTogether2(one, act))) return [];
|
|
55714
|
+
return [error51(at2, `"${act.alias}" needs "${name}", which no act offered together with it writes \u2014 it reads locked while "${act.alias}" waits; have "${act.alias}" ask it`)];
|
|
55715
|
+
});
|
|
55716
|
+
}
|
|
55717
|
+
|
|
55718
|
+
// ../shared/src/schemas/app_model_resolve.ts
|
|
55719
|
+
init_define_LOTICS_KIT_VERSIONS();
|
|
55720
|
+
|
|
55721
|
+
// ../shared/src/template_fields.ts
|
|
55722
|
+
init_define_LOTICS_KIT_VERSIONS();
|
|
55723
|
+
var import_handlebars = __toESM(require_lib(), 1);
|
|
55724
|
+
function rootName(node) {
|
|
55725
|
+
if (!("type" in node) || node.type !== "PathExpression") return void 0;
|
|
55726
|
+
if ("data" in node && node.data === true) return void 0;
|
|
55727
|
+
if ("depth" in node && node.depth !== 0) return void 0;
|
|
55728
|
+
const parts = "parts" in node && Array.isArray(node.parts) ? node.parts : [];
|
|
55729
|
+
const [first2] = parts;
|
|
55730
|
+
return typeof first2 === "string" ? first2 : void 0;
|
|
55731
|
+
}
|
|
55732
|
+
function visit(node, into) {
|
|
55733
|
+
if (Array.isArray(node)) {
|
|
55734
|
+
for (const child of node) visit(child, into);
|
|
55735
|
+
return;
|
|
55736
|
+
}
|
|
55737
|
+
if (typeof node !== "object" || node === null || !("type" in node)) return;
|
|
55738
|
+
const own = rootName(node);
|
|
55739
|
+
if (own !== void 0) {
|
|
55740
|
+
into.add(own);
|
|
55741
|
+
return;
|
|
55742
|
+
}
|
|
55743
|
+
const params = "params" in node ? node.params : [];
|
|
55744
|
+
const hash2 = "hash" in node ? node.hash : void 0;
|
|
55745
|
+
if (node.type === "MustacheStatement" || node.type === "SubExpression") {
|
|
55746
|
+
const called = Array.isArray(params) && params.length > 0;
|
|
55747
|
+
if (!called && "path" in node) visit(node.path, into);
|
|
55748
|
+
visit(params, into);
|
|
55749
|
+
visit(hash2, into);
|
|
55750
|
+
return;
|
|
55751
|
+
}
|
|
55752
|
+
if (node.type === "BlockStatement") {
|
|
55753
|
+
const helper = "path" in node && typeof node.path === "object" && node.path !== null ? rootName(node.path) : void 0;
|
|
55754
|
+
visit(params, into);
|
|
55755
|
+
visit(hash2, into);
|
|
55756
|
+
if (helper !== "each" && helper !== "with") {
|
|
55757
|
+
if ("program" in node) visit(node.program, into);
|
|
55758
|
+
if ("inverse" in node) visit(node.inverse, into);
|
|
55759
|
+
}
|
|
55760
|
+
return;
|
|
55761
|
+
}
|
|
55762
|
+
for (const value of Object.values(node)) visit(value, into);
|
|
55763
|
+
}
|
|
55764
|
+
function templateFields(content) {
|
|
55765
|
+
const into = /* @__PURE__ */ new Set();
|
|
55766
|
+
visit(import_handlebars.default.parse(content), into);
|
|
55767
|
+
return [...into];
|
|
55768
|
+
}
|
|
55769
|
+
|
|
55536
55770
|
// ../shared/src/schemas/app_model_resolve.ts
|
|
55537
|
-
var
|
|
55771
|
+
var whereOf2 = (stated2) => Object.entries(stated2 ?? {}).map(([field2, options]) => ({ field: field2, options }));
|
|
55538
55772
|
function resolveModelApp(model, app) {
|
|
55539
55773
|
const entity = entityOf(model, app.entity);
|
|
55540
55774
|
if (entity === void 0) throw new Error(`resolveModelApp: "${app.entity}" is not an entity of this model \u2014 validate it first`);
|
|
@@ -55556,17 +55790,17 @@ function resolveModelApp(model, app) {
|
|
|
55556
55790
|
// A move into the section's stage from outside it is its way in, drawn first and alone while it waits.
|
|
55557
55791
|
...splitEntry(model, app, section.acts ?? [], at2),
|
|
55558
55792
|
actsLast: false,
|
|
55559
|
-
when:
|
|
55793
|
+
when: whenConditions(section.when),
|
|
55560
55794
|
...section.page === true ? { page: pageSegment(section.title) } : {}
|
|
55561
55795
|
};
|
|
55562
55796
|
});
|
|
55563
55797
|
if (statedSections === void 0) {
|
|
55564
55798
|
defaulted.push("record.sections");
|
|
55565
|
-
const
|
|
55799
|
+
const history = historyOf(model, entity.alias);
|
|
55566
55800
|
const header = /* @__PURE__ */ new Set([...recordHeaderFields(display), ...scope === void 0 ? [] : [scope.field]]);
|
|
55567
55801
|
const fields = entity.fields.flatMap((field2) => {
|
|
55568
55802
|
if (!isAsked(field2) || setBy.has(field2.alias) || header.has(field2.alias)) return [];
|
|
55569
|
-
if (field2.type === "select_record_link" && (field2.target_entity ===
|
|
55803
|
+
if (field2.type === "select_record_link" && (field2.target_entity === history?.entity || childList(model, entity, field2))) return [];
|
|
55570
55804
|
return [field2.alias];
|
|
55571
55805
|
});
|
|
55572
55806
|
const acts2 = (app.acts ?? []).flatMap((act) => act.of === void 0 && (act.on ?? "record") === "record" ? [act.alias] : []);
|
|
@@ -55597,7 +55831,7 @@ function resolveModelApp(model, app) {
|
|
|
55597
55831
|
const readings = figures.map((one) => one.reading);
|
|
55598
55832
|
defaulted.push(...figures.flatMap((one) => one.defaulted));
|
|
55599
55833
|
const stated2 = app.register?.export;
|
|
55600
|
-
const
|
|
55834
|
+
const moves = recordHistory(model, entity.alias);
|
|
55601
55835
|
const resolved = {
|
|
55602
55836
|
alias: app.alias,
|
|
55603
55837
|
name: app.name,
|
|
@@ -55613,12 +55847,13 @@ function resolveModelApp(model, app) {
|
|
|
55613
55847
|
filters: app.register?.filters ?? [],
|
|
55614
55848
|
...app.register?.search === void 0 ? {} : { search: app.register.search },
|
|
55615
55849
|
...sort === void 0 ? {} : { sort },
|
|
55850
|
+
...app.register?.group === void 0 ? {} : { group: app.register.group },
|
|
55616
55851
|
layout,
|
|
55617
55852
|
...create === void 0 || create.length === 0 ? {} : { create },
|
|
55618
55853
|
...stated2 === void 0 ? {} : { export: stated2 === true ? true : { template: stated2.template } },
|
|
55619
55854
|
readings
|
|
55620
55855
|
},
|
|
55621
|
-
record: { door, sections, comments: app.record?.comments === true,
|
|
55856
|
+
record: { door, sections, comments: app.record?.comments === true, history: app.record?.history === true, ...moves === void 0 ? {} : { moves } },
|
|
55622
55857
|
acts,
|
|
55623
55858
|
checks
|
|
55624
55859
|
};
|
|
@@ -55661,7 +55896,7 @@ function scopeOf(entity, field2) {
|
|
|
55661
55896
|
}
|
|
55662
55897
|
function resolveBlock(model, parent, block2, at2) {
|
|
55663
55898
|
const defaulted = [];
|
|
55664
|
-
const when =
|
|
55899
|
+
const when = whenConditions(block2.when);
|
|
55665
55900
|
const titled = (stated2, fallback) => {
|
|
55666
55901
|
if (stated2 !== void 0) return stated2;
|
|
55667
55902
|
defaulted.push(`${at2}.title`);
|
|
@@ -55677,6 +55912,31 @@ function resolveBlock(model, parent, block2, at2) {
|
|
|
55677
55912
|
if ("refused" in link) throw new Error(`resolveModelApp: block ${at2} ${link.refused}`);
|
|
55678
55913
|
return { block: { kind: "reading", reading, via: link.link, when }, defaulted };
|
|
55679
55914
|
}
|
|
55915
|
+
if (isAgendaBlock(block2)) {
|
|
55916
|
+
const child = entityOf(model, block2.agenda);
|
|
55917
|
+
if (child === void 0) throw new Error(`resolveModelApp: block ${at2} reads "${block2.agenda}", which is not an entity \u2014 validate it first`);
|
|
55918
|
+
const link = childLink(child, parent.alias, block2.via);
|
|
55919
|
+
if ("refused" in link) throw new Error(`resolveModelApp: block ${at2} ${link.refused}`);
|
|
55920
|
+
const axis = agendaAxis(model, child);
|
|
55921
|
+
if ("refused" in axis) throw new Error(`resolveModelApp: block ${at2} ${axis.refused}`);
|
|
55922
|
+
const stated2 = block2.create;
|
|
55923
|
+
const create = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] : defaultBlockCreate(model, child, [link.link], block2.columns ?? []);
|
|
55924
|
+
if (stated2 === void 0 && create.length > 0) defaulted.push(`${at2}.create`);
|
|
55925
|
+
return {
|
|
55926
|
+
block: {
|
|
55927
|
+
kind: "agenda",
|
|
55928
|
+
entity: child.alias,
|
|
55929
|
+
via: link.link,
|
|
55930
|
+
title: titled(block2.title, child.label),
|
|
55931
|
+
columns: block2.columns ?? [],
|
|
55932
|
+
...create.length === 0 ? {} : { create },
|
|
55933
|
+
...axis,
|
|
55934
|
+
...block2.start === void 0 ? {} : { start: block2.start },
|
|
55935
|
+
when
|
|
55936
|
+
},
|
|
55937
|
+
defaulted
|
|
55938
|
+
};
|
|
55939
|
+
}
|
|
55680
55940
|
if ("rows" in block2 || "timeline" in block2) {
|
|
55681
55941
|
const childAlias = "rows" in block2 ? block2.rows : block2.timeline;
|
|
55682
55942
|
const child = entityOf(model, childAlias);
|
|
@@ -55686,11 +55946,12 @@ function resolveBlock(model, parent, block2, at2) {
|
|
|
55686
55946
|
const title = titled(block2.title, child.label);
|
|
55687
55947
|
const stated2 = block2.create;
|
|
55688
55948
|
if ("rows" in block2) {
|
|
55689
|
-
const where =
|
|
55690
|
-
const stating = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] :
|
|
55949
|
+
const where = whereOf2(block2.where);
|
|
55950
|
+
const stating = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] : defaultBlockCreate(model, child, [link.link], block2.columns ?? []);
|
|
55691
55951
|
if (stated2 === void 0 && stating.length > 0) defaulted.push(`${at2}.create`);
|
|
55692
55952
|
const held2 = where.map((one) => one.field);
|
|
55693
55953
|
const create2 = stating.length === 0 ? [] : block2.expect === void 0 ? unique([...held2, ...stating]) : unique([block2.expect, ...held2, ...stating, ...expectedColumns(model, child, block2.columns ?? [])]);
|
|
55954
|
+
const under = block2.under === void 0 ? void 0 : resolveUnder(model, parent.alias, child, block2.under, where);
|
|
55694
55955
|
return {
|
|
55695
55956
|
block: {
|
|
55696
55957
|
kind: "rows",
|
|
@@ -55702,6 +55963,7 @@ function resolveBlock(model, parent, block2, at2) {
|
|
|
55702
55963
|
where,
|
|
55703
55964
|
...block2.expect === void 0 ? {} : { expect: block2.expect },
|
|
55704
55965
|
...block2.of === void 0 ? {} : { of: block2.of },
|
|
55966
|
+
...under === void 0 ? {} : { under },
|
|
55705
55967
|
when
|
|
55706
55968
|
},
|
|
55707
55969
|
defaulted
|
|
@@ -55739,7 +56001,7 @@ function blockHeld(model, entity, blocks) {
|
|
|
55739
56001
|
const own = blocks.flatMap((block2) => block2.kind === "files" ? block2.fields : block2.kind === "text" ? [block2.field] : []);
|
|
55740
56002
|
const links = new Set(
|
|
55741
56003
|
blocks.flatMap(
|
|
55742
|
-
(block2) => block2.kind === "rows" || block2.kind === "timeline" ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.entity && field2.paired_field_alias === block2.via ? [field2.alias] : []) : []
|
|
56004
|
+
(block2) => block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline" ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.entity && field2.paired_field_alias === block2.via ? [field2.alias] : []) : []
|
|
55743
56005
|
)
|
|
55744
56006
|
);
|
|
55745
56007
|
const held2 = /* @__PURE__ */ new Set([...own, ...links]);
|
|
@@ -55802,7 +56064,7 @@ function resolveAct(model, app, act, checks) {
|
|
|
55802
56064
|
label: act.label,
|
|
55803
56065
|
on: act.on ?? "record",
|
|
55804
56066
|
...act.of === void 0 ? {} : { of: act.of },
|
|
55805
|
-
when:
|
|
56067
|
+
when: whenConditions(act.when),
|
|
55806
56068
|
requires,
|
|
55807
56069
|
recommends: act.recommends ?? [],
|
|
55808
56070
|
asks,
|
|
@@ -55815,6 +56077,19 @@ function resolveAct(model, app, act, checks) {
|
|
|
55815
56077
|
blockedBy: checksBlocking(checks, act.alias, act.of)
|
|
55816
56078
|
};
|
|
55817
56079
|
}
|
|
56080
|
+
function resolveUnder(model, parent, child, field2, where) {
|
|
56081
|
+
const shape = documentUnder(model, parent, child, field2);
|
|
56082
|
+
if ("refused" in shape) throw new Error(`resolveModelApp: the lines of "${child.alias}" ${shape.refused} \u2014 validate it first`);
|
|
56083
|
+
const document2 = entityOf(model, shape.entity);
|
|
56084
|
+
if (document2 === void 0) throw new Error(`resolveModelApp: "${shape.entity}" is not an entity \u2014 validate it first`);
|
|
56085
|
+
const held2 = where.filter((one) => {
|
|
56086
|
+
const own = fieldOf(document2, one.field);
|
|
56087
|
+
return own !== void 0 && isSingleSelect(own) && one.options.every((option) => own.options.some((declared) => declared.alias === option));
|
|
56088
|
+
});
|
|
56089
|
+
const sent = [shape.back, ...held2.map((one) => one.field)];
|
|
56090
|
+
const create = peopleWrite(document2) ? unique([...sent, ...defaultBlockCreate(model, document2, [shape.via, ...sent], [])]) : [];
|
|
56091
|
+
return { ...shape, where: held2, ...create.length === 0 ? {} : { create } };
|
|
56092
|
+
}
|
|
55818
56093
|
function expectedColumns(model, child, columns) {
|
|
55819
56094
|
const owned = actOwnedFields(model, child.alias);
|
|
55820
56095
|
return columns.filter((name) => {
|
|
@@ -55826,6 +56101,7 @@ function expectedColumns(model, child, columns) {
|
|
|
55826
56101
|
// ../shared/src/schemas/app_model_validate.ts
|
|
55827
56102
|
var NAMING_TYPES = /* @__PURE__ */ new Set(["text", "autonumber", "select_record_link", "select_member", "select"]);
|
|
55828
56103
|
var FILTER_TYPES = /* @__PURE__ */ new Set(["select", "select_member", "select_record_link", "date", "boolean"]);
|
|
56104
|
+
var GROUP_TYPES = /* @__PURE__ */ new Set([...FILTER_TYPES, "text"]);
|
|
55829
56105
|
var SEARCH_TYPES = /* @__PURE__ */ new Set(["text", "autonumber", "select_record_link"]);
|
|
55830
56106
|
function partsOf(display) {
|
|
55831
56107
|
if (display === void 0) return /* @__PURE__ */ new Map();
|
|
@@ -55867,6 +56143,8 @@ function validateModelApps(model) {
|
|
|
55867
56143
|
if (own.length > 0) continue;
|
|
55868
56144
|
const resolved = resolveModelApp(model, app);
|
|
55869
56145
|
findings.push(...checkCreates(model, resolved, at2));
|
|
56146
|
+
findings.push(...noteChildLists(model, app, at2));
|
|
56147
|
+
findings.push(...checkFirstClass(model, app, resolved, at2));
|
|
55870
56148
|
findings.push(...checkReadsRows(model, app.reads, resolved.entities, at2));
|
|
55871
56149
|
}
|
|
55872
56150
|
return findings;
|
|
@@ -55886,8 +56164,8 @@ function checkCreates(model, resolved, at2) {
|
|
|
55886
56164
|
const lists = [
|
|
55887
56165
|
...resolved.register.create === void 0 ? [] : [{ entity: resolved.entity, asked: resolved.register.create, fills: resolved.scope === void 0 ? [] : [resolved.scope.field], path: `${at2}.register.create` }],
|
|
55888
56166
|
...resolved.record.sections.flatMap(
|
|
55889
|
-
(section, index) => section.blocks.flatMap(
|
|
55890
|
-
(block2
|
|
56167
|
+
(section, index) => section.blocks.flatMap((block2, order) => [
|
|
56168
|
+
...(block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline") && block2.create !== void 0 ? [
|
|
55891
56169
|
{
|
|
55892
56170
|
entity: block2.entity,
|
|
55893
56171
|
asked: block2.create,
|
|
@@ -55895,8 +56173,18 @@ function checkCreates(model, resolved, at2) {
|
|
|
55895
56173
|
fills: [block2.via, ...block2.kind === "timeline" ? [block2.at, ...block2.by === void 0 ? [] : [block2.by]] : []],
|
|
55896
56174
|
path: `${at2}.record.sections.${index}.blocks.${order}.create`
|
|
55897
56175
|
}
|
|
56176
|
+
] : [],
|
|
56177
|
+
// A document made from its lines is filed under the record by its own link, over the lines picked and
|
|
56178
|
+
// the options the block's lines hold: sent by the make, never asked.
|
|
56179
|
+
...block2.kind === "rows" && block2.under?.create !== void 0 ? [
|
|
56180
|
+
{
|
|
56181
|
+
entity: block2.under.entity,
|
|
56182
|
+
asked: block2.under.create,
|
|
56183
|
+
fills: [block2.under.via, block2.under.back, ...block2.under.where.map((one) => one.field)],
|
|
56184
|
+
path: `${at2}.record.sections.${index}.blocks.${order}.under`
|
|
56185
|
+
}
|
|
55898
56186
|
] : []
|
|
55899
|
-
)
|
|
56187
|
+
])
|
|
55900
56188
|
)
|
|
55901
56189
|
];
|
|
55902
56190
|
return lists.flatMap(({ entity, asked, fills, path: path40 }) => {
|
|
@@ -55909,8 +56197,9 @@ function checkCreates(model, resolved, at2) {
|
|
|
55909
56197
|
};
|
|
55910
56198
|
return [
|
|
55911
56199
|
...checkCreateMilestones(model, entity, asked, path40),
|
|
55912
|
-
// An act's outcome is the act's to write: an add stating it would decide what only the act decides.
|
|
55913
|
-
|
|
56200
|
+
// An act's outcome is the act's to write: an add stating it would decide what only the act decides. What the
|
|
56201
|
+
// add fills itself (the record, a document's lines) is no reader's statement.
|
|
56202
|
+
...asked.filter((name) => !fills.includes(name)).flatMap((name) => {
|
|
55914
56203
|
const act = setter(name);
|
|
55915
56204
|
return act === void 0 ? [] : [error51(path40, `asks "${name}", which ${act} sets \u2014 the act writes it, so an add never states it; drop it here`)];
|
|
55916
56205
|
}),
|
|
@@ -55982,6 +56271,7 @@ function checkApp(model, app, at2) {
|
|
|
55982
56271
|
scoped(name, path40, "it lists");
|
|
55983
56272
|
}
|
|
55984
56273
|
if (app.register?.sort !== void 0) own(app.register.sort.field, `${at2}.register.sort.field`);
|
|
56274
|
+
if (app.register?.group !== void 0) findings.push(...checkGroup(model, entity, display, app, app.register.group, `${at2}.register.group`));
|
|
55985
56275
|
const create = app.register?.create;
|
|
55986
56276
|
if (Array.isArray(create)) {
|
|
55987
56277
|
if (app.writes === "children") {
|
|
@@ -56026,10 +56316,13 @@ function checkApp(model, app, at2) {
|
|
|
56026
56316
|
if (scope !== void 0 && !placed2.has(scope)) place(scope, "the header's folder", `${at2}.record`);
|
|
56027
56317
|
const sections = app.record?.sections ?? [];
|
|
56028
56318
|
findings.push(...checkPages(app.record?.door, sections, `${at2}.record`));
|
|
56319
|
+
if (app.record?.history === true && display.status?.history === void 0) {
|
|
56320
|
+
findings.push(error51(`${at2}.record.history`, `draws the status history of "${entity.alias}", which records none \u2014 name the history entity in \`records.${entity.alias}.status.history\`, or drop it`));
|
|
56321
|
+
}
|
|
56029
56322
|
const inFacts = /* @__PURE__ */ new Map();
|
|
56030
56323
|
for (const [index, section] of sections.entries()) {
|
|
56031
56324
|
const path40 = `${at2}.record.sections.${index}`;
|
|
56032
|
-
findings.push(...
|
|
56325
|
+
findings.push(...checkWhen(model.entities, entity, section.when, `${path40}.when`, "offer"));
|
|
56033
56326
|
if (section.fields === void 0 && section.blocks === void 0 && section.acts === void 0) {
|
|
56034
56327
|
findings.push(error51(path40, `"${section.title}" holds no fields, blocks or acts \u2014 a section is where the reader fills, reads or does something; give it one, or drop it`));
|
|
56035
56328
|
}
|
|
@@ -56069,9 +56362,10 @@ function checkApp(model, app, at2) {
|
|
|
56069
56362
|
for (const [index, section] of sections.entries()) {
|
|
56070
56363
|
for (const [order, block2] of (section.blocks ?? []).entries()) {
|
|
56071
56364
|
const path40 = `${at2}.record.sections.${index}.blocks.${order}`;
|
|
56072
|
-
findings.push(...
|
|
56073
|
-
|
|
56074
|
-
|
|
56365
|
+
findings.push(...checkWhen(model.entities, entity, block2.when, `${path40}.when`, "offer"));
|
|
56366
|
+
const listed = "timeline" in block2 ? block2.timeline : isRowsBlock(block2) ? block2.rows : isAgendaBlock(block2) ? block2.agenda : void 0;
|
|
56367
|
+
if (listed !== void 0 && listed === display.status?.history) {
|
|
56368
|
+
findings.push(error51(path40, `reads "${listed}", the record's status history \u2014 never a section: \`record.history\` draws it beside the record; drop the block`));
|
|
56075
56369
|
}
|
|
56076
56370
|
const identity = blockIdentity(block2);
|
|
56077
56371
|
const first2 = identity === void 0 ? void 0 : blocksPlaced.get(identity);
|
|
@@ -56089,14 +56383,15 @@ function checkApp(model, app, at2) {
|
|
|
56089
56383
|
}
|
|
56090
56384
|
}
|
|
56091
56385
|
}
|
|
56092
|
-
|
|
56386
|
+
findings.push(...checkDocumentsListed(model, entity, sections, at2));
|
|
56387
|
+
const children = new Set(recordChildren(model, entity.alias, recordBlocks(app.record)));
|
|
56093
56388
|
const acts = /* @__PURE__ */ new Map();
|
|
56094
56389
|
for (const [index, act] of (app.acts ?? []).entries()) {
|
|
56095
56390
|
const path40 = `${at2}.acts.${index}`;
|
|
56096
56391
|
if (acts.has(act.alias)) findings.push(error51(`${path40}.alias`, `"${act.alias}" names two acts of this app \u2014 an act alias is unique`));
|
|
56097
56392
|
acts.set(act.alias, act.of);
|
|
56098
56393
|
if (act.of !== void 0 && !children.has(act.of)) {
|
|
56099
|
-
findings.push(error51(`${path40}.of`, `names "${act.of}", which no rows
|
|
56394
|
+
findings.push(error51(`${path40}.of`, `names "${act.of}", which this record lists no rows of \u2014 an act of a child works on rows the record lists`));
|
|
56100
56395
|
continue;
|
|
56101
56396
|
}
|
|
56102
56397
|
if (act.of !== void 0 && act.on !== void 0 && act.on !== "record") {
|
|
@@ -56123,7 +56418,7 @@ function checkApp(model, app, at2) {
|
|
|
56123
56418
|
const drawnBy = (section) => [
|
|
56124
56419
|
...section.fields ?? [],
|
|
56125
56420
|
...(section.blocks ?? []).flatMap(
|
|
56126
|
-
(block2) => "files" in block2 ? block2.files : "text" in block2 ? [block2.text] : isRowsBlock(block2) ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.rows ? [field2.alias] : []) : []
|
|
56421
|
+
(block2) => "files" in block2 ? block2.files : "text" in block2 ? [block2.text] : isRowsBlock(block2) || isAgendaBlock(block2) ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === (isRowsBlock(block2) ? block2.rows : block2.agenda) ? [field2.alias] : []) : []
|
|
56127
56422
|
)
|
|
56128
56423
|
];
|
|
56129
56424
|
const unstaged = sections.filter((section) => section.at === void 0).flatMap(drawnBy);
|
|
@@ -56173,6 +56468,7 @@ function checkApp(model, app, at2) {
|
|
|
56173
56468
|
if (drawn !== void 0) {
|
|
56174
56469
|
findings.push(error51(`${path40}.field`, `"${check2.field}" is a check, whose line under the header says it, and it is also in ${drawn} \u2014 drop it there`));
|
|
56175
56470
|
}
|
|
56471
|
+
findings.push(...checkRestatesMeter(model, entity, check2, path40));
|
|
56176
56472
|
if (check2.blocks === void 0 || check2.blocks === "all") continue;
|
|
56177
56473
|
for (const [at22, target] of check2.blocks.entries()) {
|
|
56178
56474
|
if (target === "edit" || target === "delete") continue;
|
|
@@ -56182,12 +56478,73 @@ function checkApp(model, app, at2) {
|
|
|
56182
56478
|
}
|
|
56183
56479
|
return findings;
|
|
56184
56480
|
}
|
|
56481
|
+
function noteChildLists(model, app, at2) {
|
|
56482
|
+
const placed2 = (app.record?.sections ?? []).flatMap(
|
|
56483
|
+
(section, index) => (section.blocks ?? []).flatMap((block2, order) => isRowsBlock(block2) ? [{ block: block2, path: `${at2}.record.sections.${index}.blocks.${order}` }] : [])
|
|
56484
|
+
);
|
|
56485
|
+
const planned = placed2.flatMap(({ block: block2, path: path40 }) => {
|
|
56486
|
+
const child = entityOf(model, block2.rows);
|
|
56487
|
+
const axis = child === void 0 ? void 0 : agendaAxis(model, child);
|
|
56488
|
+
if (axis === void 0 || "refused" in axis || axis.time === void 0 && axis.part === void 0) return [];
|
|
56489
|
+
const within2 = axis.time === void 0 ? `in a "${axis.part ?? ""}"` : "at an hour";
|
|
56490
|
+
return [note(path40, `lists "${block2.rows}" rows, each on a day ("${axis.day}") ${within2} \u2014 a table reads them as rows; an \`agenda\` draws them by day`)];
|
|
56491
|
+
});
|
|
56492
|
+
const covering = placed2.flatMap(({ block: block2, path: path40 }) => {
|
|
56493
|
+
const covers = placed2.find((other2) => {
|
|
56494
|
+
if (other2.block === block2 || other2.block.under !== void 0) return false;
|
|
56495
|
+
const lines = entityOf(model, other2.block.rows);
|
|
56496
|
+
const title = model.records?.[other2.block.rows]?.title;
|
|
56497
|
+
return lines?.fields.some((field2) => {
|
|
56498
|
+
if (field2.type !== "select_record_link" || field2.cardinality !== "one" || field2.required === true || field2.alias === title) return false;
|
|
56499
|
+
const shape = documentUnder(model, app.entity, lines, field2.alias);
|
|
56500
|
+
return !("refused" in shape) && shape.entity === block2.rows;
|
|
56501
|
+
});
|
|
56502
|
+
});
|
|
56503
|
+
return covers === void 0 ? [] : [note(path40, `"${block2.rows}" rows cover "${covers.block.rows}" lines listed in ${covers.path} \u2014 draw them over the lines they cover: \`"under"\` on that block`)];
|
|
56504
|
+
});
|
|
56505
|
+
return [...planned, ...covering];
|
|
56506
|
+
}
|
|
56507
|
+
function checkDocumentsListed(model, entity, sections, at2) {
|
|
56508
|
+
const placed2 = sections.flatMap((section, index) => (section.blocks ?? []).map((block2, order) => ({ block: block2, path: `${at2}.record.sections.${index}.blocks.${order}` })));
|
|
56509
|
+
const over = new Map(
|
|
56510
|
+
placed2.flatMap(({ block: block2 }) => {
|
|
56511
|
+
if (!isRowsBlock(block2) || block2.under === void 0) return [];
|
|
56512
|
+
const child = entityOf(model, block2.rows);
|
|
56513
|
+
const shape = child === void 0 ? void 0 : documentUnder(model, entity.alias, child, block2.under);
|
|
56514
|
+
return shape === void 0 || "refused" in shape ? [] : [[shape.entity, block2.title ?? child?.label ?? block2.rows]];
|
|
56515
|
+
})
|
|
56516
|
+
);
|
|
56517
|
+
return placed2.flatMap(({ block: block2, path: path40 }) => {
|
|
56518
|
+
if (!isRowsBlock(block2)) return [];
|
|
56519
|
+
const lines = over.get(block2.rows);
|
|
56520
|
+
return lines === void 0 ? [] : [error51(path40, `lists "${block2.rows}" rows, which stand over their lines in "${lines}" (\`under\`) \u2014 a second list says them twice; drop it`)];
|
|
56521
|
+
});
|
|
56522
|
+
}
|
|
56185
56523
|
function hiddenWhenOffered(shown, offered) {
|
|
56186
56524
|
return Object.entries(shown ?? {}).some(([field2, options]) => {
|
|
56187
56525
|
const held2 = offered?.[field2];
|
|
56188
|
-
|
|
56526
|
+
if (held2 === void 0) return false;
|
|
56527
|
+
if (typeof held2 === "boolean" || typeof options === "boolean") return held2 !== options;
|
|
56528
|
+
return !held2.some((option) => options.includes(option));
|
|
56189
56529
|
});
|
|
56190
56530
|
}
|
|
56531
|
+
function checkGroup(model, entity, display, app, name, path40) {
|
|
56532
|
+
const field2 = fieldOf(entity, name);
|
|
56533
|
+
if (field2 === void 0) return [error51(path40, `names "${name}", which is not a field of "${entity.alias}"`)];
|
|
56534
|
+
const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
|
|
56535
|
+
const several = (field2.type === "select" || field2.type === "select_member") && field2.multi === true;
|
|
56536
|
+
const many = field2.type === "select_record_link" && field2.cardinality !== "one";
|
|
56537
|
+
if (several || many || !GROUP_TYPES.has(type)) {
|
|
56538
|
+
return [error51(path40, `"${name}" is a ${several || many ? `${field2.type} field holding several values` : `${field2.type} field`} \u2014 a row stands in one group: a single select, a one-row link, one member, a date, a yes/no or text`)];
|
|
56539
|
+
}
|
|
56540
|
+
if (name === display.status?.field) return [error51(path40, `"${name}" is the status, whose chips already split the rows \u2014 group by another field`)];
|
|
56541
|
+
if (name === app.scope) return [error51(path40, `"${name}" is the folder the app is inside, one group of every row \u2014 group by another field`)];
|
|
56542
|
+
if ((app.register?.layout ?? "table") !== "table") return [error51(path40, `groups a ${app.register?.layout ?? ""} register \u2014 rows are grouped in a table`)];
|
|
56543
|
+
if (name === display.title && (display.subtitle ?? []).length === 0) {
|
|
56544
|
+
return [error51(path40, `groups by "${name}", the row's title, so each row leads with its first subtitle \u2014 state \`records.${entity.alias}.subtitle\` (the person, the party)`)];
|
|
56545
|
+
}
|
|
56546
|
+
return [];
|
|
56547
|
+
}
|
|
56191
56548
|
function misplacedMove(act, title, entry) {
|
|
56192
56549
|
return `"${act.alias}" moves the status ${entry ? "into" : "on from"} "${title}", so it stands at that section's foot \u2014 ${entry ? "its way in" : "the step it finishes"}; name it there`;
|
|
56193
56550
|
}
|
|
@@ -56267,7 +56624,7 @@ function checkStagedAct(display, section, act, path40) {
|
|
|
56267
56624
|
}
|
|
56268
56625
|
const field2 = status.field;
|
|
56269
56626
|
if (field2 === void 0) return [];
|
|
56270
|
-
const held2 = act.when
|
|
56627
|
+
const held2 = heldOptions(act.when, field2);
|
|
56271
56628
|
if (held2 === void 0) {
|
|
56272
56629
|
return [error51(path40, `"${act.alias}" is offered whatever "${field2}" holds, and "${section.title}" is the work only while it is ${at2.join(" or ")} \u2014 narrow its \`when\` to that, or ${elsewhere}`)];
|
|
56273
56630
|
}
|
|
@@ -56280,6 +56637,7 @@ function blockIdentity(block2) {
|
|
|
56280
56637
|
return `rows:${block2.rows}:${block2.via ?? ""}:${where}`;
|
|
56281
56638
|
}
|
|
56282
56639
|
if ("timeline" in block2) return `timeline:${block2.timeline}:${block2.via ?? ""}`;
|
|
56640
|
+
if (isAgendaBlock(block2)) return `agenda:${block2.agenda}:${block2.via ?? ""}`;
|
|
56283
56641
|
if ("component" in block2) return `component:${block2.component}`;
|
|
56284
56642
|
if (isReadingBlock(block2)) return `reading:${JSON.stringify(block2)}`;
|
|
56285
56643
|
return void 0;
|
|
@@ -56303,6 +56661,64 @@ function checkWhere(child, block2, at2) {
|
|
|
56303
56661
|
];
|
|
56304
56662
|
});
|
|
56305
56663
|
}
|
|
56664
|
+
function checkChildColumns(model, child, names, back, expecting, at2) {
|
|
56665
|
+
const findings = [];
|
|
56666
|
+
const display = model.records?.[child.alias];
|
|
56667
|
+
const rowParts = partsOf(display);
|
|
56668
|
+
const bounds = meterBounds(display, names);
|
|
56669
|
+
const columns = /* @__PURE__ */ new Map();
|
|
56670
|
+
for (const [index, name] of names.entries()) {
|
|
56671
|
+
const path40 = `${at2}.columns.${index}`;
|
|
56672
|
+
if (fieldOf(child, name) === void 0) {
|
|
56673
|
+
findings.push(error51(path40, `names "${name}", which is not a field of "${child.alias}"`));
|
|
56674
|
+
continue;
|
|
56675
|
+
}
|
|
56676
|
+
const part = rowParts.get(name);
|
|
56677
|
+
const inPlace = expecting && name === display?.figure && expectedColumns(model, child, [name]).length > 0;
|
|
56678
|
+
if (part !== void 0 && !inPlace) findings.push(error51(path40, `"${name}" is already drawn by the "${child.alias}" row itself, as ${part} \u2014 drop the column`));
|
|
56679
|
+
const meter = bounds.get(name);
|
|
56680
|
+
if (meter !== void 0) findings.push(error51(path40, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
|
|
56681
|
+
if (name === back) findings.push(error51(path40, `"${name}" is the link back to this record, the same on every row of the block \u2014 drop the column`));
|
|
56682
|
+
const first2 = columns.get(name);
|
|
56683
|
+
if (first2 !== void 0) findings.push(error51(path40, `"${name}" is already column ${first2} \u2014 a field is one column`));
|
|
56684
|
+
else columns.set(name, index);
|
|
56685
|
+
}
|
|
56686
|
+
return findings;
|
|
56687
|
+
}
|
|
56688
|
+
function checkChildCreate(child, create, via, at2) {
|
|
56689
|
+
if (!Array.isArray(create)) return [];
|
|
56690
|
+
const findings = [];
|
|
56691
|
+
if (!peopleWrite(child)) findings.push(error51(at2, `adds rows to "${child.alias}", which no person writes`));
|
|
56692
|
+
for (const [index, name] of create.entries()) {
|
|
56693
|
+
const field2 = fieldOf(child, name);
|
|
56694
|
+
if (field2 === void 0) findings.push(error51(`${at2}.${index}`, `names "${name}", which is not a field of "${child.alias}"`));
|
|
56695
|
+
else if (name === via) findings.push(error51(`${at2}.${index}`, `asks "${name}", the link to this record, which an add sets itself`));
|
|
56696
|
+
else if (!isAsked(field2)) findings.push(error51(`${at2}.${index}`, `asks "${name}", a ${field2.type} field nobody writes`));
|
|
56697
|
+
}
|
|
56698
|
+
return findings;
|
|
56699
|
+
}
|
|
56700
|
+
function checkUnder(model, entity, child, block2, path40) {
|
|
56701
|
+
if (block2.under === void 0) return [];
|
|
56702
|
+
const at2 = `${path40}.under`;
|
|
56703
|
+
const shape = documentUnder(model, entity.alias, child, block2.under);
|
|
56704
|
+
if ("refused" in shape) return [error51(at2, shape.refused)];
|
|
56705
|
+
const findings = [];
|
|
56706
|
+
const line = fieldOf(child, block2.under);
|
|
56707
|
+
if (line !== void 0 && !isAsked(line)) findings.push(error51(at2, `"${block2.under}" is a field nobody writes \u2014 making a document links its lines`));
|
|
56708
|
+
if (model.records?.[shape.entity] === void 0) {
|
|
56709
|
+
findings.push(error51(at2, `stands the lines under "${shape.entity}", which has no \`records\` entry \u2014 say how a document is recognised (its title at least)`));
|
|
56710
|
+
}
|
|
56711
|
+
for (const [index, name] of (block2.columns ?? []).entries()) {
|
|
56712
|
+
const column = fieldOf(child, name);
|
|
56713
|
+
if (column?.type === "text" && column.format === "markdown") {
|
|
56714
|
+
findings.push(error51(`${path40}.columns.${index}`, `"${name}" is long text, drawn whole on a card per row, and lines under a document are a table of short facts \u2014 drop the column or \`under\``));
|
|
56715
|
+
continue;
|
|
56716
|
+
}
|
|
56717
|
+
if (name !== block2.under && !(column?.type === "lookup" && column.source_field_alias === block2.under)) continue;
|
|
56718
|
+
findings.push(error51(`${path40}.columns.${index}`, `"${name}" reads the "${shape.entity}" each line stands under, which its heading already reads \u2014 drop the column`));
|
|
56719
|
+
}
|
|
56720
|
+
return findings;
|
|
56721
|
+
}
|
|
56306
56722
|
function checkBlock(model, entity, block2, at2, place, scoped) {
|
|
56307
56723
|
const findings = [];
|
|
56308
56724
|
const unruled = (child, through) => scoped && entity.read_scope !== void 0 && child.read_scope === void 0 && model.records?.[entity.alias]?.status?.history !== child.alias ? [
|
|
@@ -56319,6 +56735,24 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
|
|
|
56319
56735
|
if ("refused" in link) return [error51(at2, link.refused)];
|
|
56320
56736
|
return [...unruled(child, link.link), ...checkReading(model, block2, at2)];
|
|
56321
56737
|
}
|
|
56738
|
+
if (isAgendaBlock(block2)) {
|
|
56739
|
+
const child = entityOf(model, block2.agenda);
|
|
56740
|
+
if (child === void 0) return [error51(at2, `reads "${block2.agenda}", which this model does not declare`)];
|
|
56741
|
+
const link = childLink(child, entity.alias, block2.via);
|
|
56742
|
+
if ("refused" in link) findings.push(error51(at2, link.refused));
|
|
56743
|
+
findings.push(...unruled(child, "link" in link ? link.link : block2.via ?? entity.alias));
|
|
56744
|
+
const axis = agendaAxis(model, child);
|
|
56745
|
+
if ("refused" in axis) findings.push(error51(at2, axis.refused));
|
|
56746
|
+
const back = "link" in link ? link.link : block2.via;
|
|
56747
|
+
findings.push(...checkChildColumns(model, child, block2.columns ?? [], back, false, at2));
|
|
56748
|
+
findings.push(...checkChildCreate(child, block2.create, back, `${at2}.create`));
|
|
56749
|
+
if (block2.start !== void 0) {
|
|
56750
|
+
const start = fieldOf(entity, block2.start);
|
|
56751
|
+
if (start === void 0) findings.push(error51(`${at2}.start`, `names "${block2.start}", which is not a field of "${entity.alias}"`));
|
|
56752
|
+
else if (resolvedFieldType(start, entity, model.entities) !== "date") findings.push(error51(`${at2}.start`, `"${block2.start}" is a ${start.type} field \u2014 the first day is a date of the record`));
|
|
56753
|
+
}
|
|
56754
|
+
return findings;
|
|
56755
|
+
}
|
|
56322
56756
|
if ("rows" in block2 || "timeline" in block2) {
|
|
56323
56757
|
const childAlias = "rows" in block2 ? block2.rows : block2.timeline;
|
|
56324
56758
|
const child = entityOf(model, childAlias);
|
|
@@ -56328,7 +56762,6 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
|
|
|
56328
56762
|
findings.push(...unruled(child, "link" in link ? link.link : block2.via ?? entity.alias));
|
|
56329
56763
|
if ("rows" in block2) {
|
|
56330
56764
|
const childDisplay = model.records?.[child.alias];
|
|
56331
|
-
const rowParts = partsOf(childDisplay);
|
|
56332
56765
|
const back = "link" in link ? link.link : block2.via;
|
|
56333
56766
|
if (childDisplay !== void 0 && back !== void 0 && childDisplay.title === back) {
|
|
56334
56767
|
const next = (childDisplay.subtitle ?? []).find((name) => name !== back);
|
|
@@ -56343,40 +56776,15 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
|
|
|
56343
56776
|
);
|
|
56344
56777
|
}
|
|
56345
56778
|
}
|
|
56346
|
-
|
|
56347
|
-
const blockBounds = meterBounds(childDisplay, block2.columns ?? []);
|
|
56348
|
-
for (const [index, name] of (block2.columns ?? []).entries()) {
|
|
56349
|
-
const path40 = `${at2}.columns.${index}`;
|
|
56350
|
-
if (fieldOf(child, name) === void 0) {
|
|
56351
|
-
findings.push(error51(path40, `names "${name}", which is not a field of "${child.alias}"`));
|
|
56352
|
-
continue;
|
|
56353
|
-
}
|
|
56354
|
-
const part = rowParts.get(name);
|
|
56355
|
-
const inPlace = block2.expect !== void 0 && name === childDisplay?.figure && expectedColumns(model, child, [name]).length > 0;
|
|
56356
|
-
if (part !== void 0 && !inPlace) findings.push(error51(path40, `"${name}" is already drawn by the "${child.alias}" row itself, as ${part} \u2014 drop the column`));
|
|
56357
|
-
const meter = blockBounds.get(name);
|
|
56358
|
-
if (meter !== void 0) findings.push(error51(path40, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
|
|
56359
|
-
if (name === back) findings.push(error51(path40, `"${name}" is the link back to this record, the same on every row of the block \u2014 drop the column`));
|
|
56360
|
-
const first2 = columns.get(name);
|
|
56361
|
-
if (first2 !== void 0) findings.push(error51(path40, `"${name}" is already column ${first2} \u2014 a field is one column`));
|
|
56362
|
-
else columns.set(name, index);
|
|
56363
|
-
}
|
|
56779
|
+
findings.push(...checkChildColumns(model, child, block2.columns ?? [], back, block2.expect !== void 0, at2));
|
|
56364
56780
|
findings.push(...checkWhere(child, block2, at2));
|
|
56365
56781
|
if (block2.expect !== void 0) findings.push(...checkExpect(model, child, block2, back, `${at2}.expect`));
|
|
56366
56782
|
if (block2.of !== void 0) findings.push(...checkExpectOf(entity, child, block2, `${at2}.of`));
|
|
56783
|
+
if (block2.under !== void 0) findings.push(...checkUnder(model, entity, child, block2, at2));
|
|
56367
56784
|
} else if (!child.fields.some((field2) => resolvedFieldType(field2, child, model.entities) === "date")) {
|
|
56368
56785
|
findings.push(error51(at2, `reads "${child.alias}" as a timeline, and it has no date to place its entries by`));
|
|
56369
56786
|
}
|
|
56370
|
-
|
|
56371
|
-
if (!peopleWrite(child)) findings.push(error51(`${at2}.create`, `adds rows to "${child.alias}", which no person writes`));
|
|
56372
|
-
const via = "link" in link ? link.link : block2.via;
|
|
56373
|
-
for (const [index, name] of block2.create.entries()) {
|
|
56374
|
-
const field2 = fieldOf(child, name);
|
|
56375
|
-
if (field2 === void 0) findings.push(error51(`${at2}.create.${index}`, `names "${name}", which is not a field of "${child.alias}"`));
|
|
56376
|
-
else if (name === via) findings.push(error51(`${at2}.create.${index}`, `asks "${name}", the link to this record, which an add sets itself`));
|
|
56377
|
-
else if (!isAsked(field2)) findings.push(error51(`${at2}.create.${index}`, `asks "${name}", a ${field2.type} field nobody writes`));
|
|
56378
|
-
}
|
|
56379
|
-
}
|
|
56787
|
+
findings.push(...checkChildCreate(child, block2.create, "link" in link ? link.link : block2.via, `${at2}.create`));
|
|
56380
56788
|
return findings;
|
|
56381
56789
|
}
|
|
56382
56790
|
if ("files" in block2) {
|
|
@@ -57568,7 +57976,7 @@ function resultSideEffects(result) {
|
|
|
57568
57976
|
|
|
57569
57977
|
// src/version.ts
|
|
57570
57978
|
init_define_LOTICS_KIT_VERSIONS();
|
|
57571
|
-
var VERSION = "0.
|
|
57979
|
+
var VERSION = "0.254.0";
|
|
57572
57980
|
|
|
57573
57981
|
// src/timezone.ts
|
|
57574
57982
|
init_define_LOTICS_KIT_VERSIONS();
|
|
@@ -57591,7 +57999,7 @@ import path6 from "node:path";
|
|
|
57591
57999
|
import { fileURLToPath } from "node:url";
|
|
57592
58000
|
|
|
57593
58001
|
// src/model_reference.md
|
|
57594
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. The\nfull form spells it out; the `from` form names a published preset and carries\nonly what this business differs by (\xA7 Starting from a preset \u2014 prefer it whenever\na preset fits the trade). Each \xA7 is `lotics docs model/<section>`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics scaffold check model.json` \u2014 offline, every problem in one run, and each app\n printed as its reader will see it.\n5. `lotics app preview model.json#<app> --shots <dir>` \u2014 each app drawn from `rows`, nothing created.\n6. `lotics workspace build model.json --deploy` \u2014 the tables, then every app, live\n (`lotics setup model.json --email you@company.com` first where no account exists).\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. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference.\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- **`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\nEvery table of keys on this page is generated from the schema `scaffold check`\nparses the file with, so it is the whole of what a key may hold. The first is the\nfull form; the second names a preset instead of restating one (\xA7 Starting from a\npreset).\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates whose content travels inline (html or email) |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `preset` | [Preset](#preset) | no | The trade\'s branches, for a model published to be read; `setup` and `apply` scaffold the base alone |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n#### From file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | text | yes | The preset this model starts from, by its slug |\n| `variants` | list of text | no | The preset\'s variant slugs to merge onto its base, in order |\n| `rename` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 what this business calls that table and its fields |\n| `entities` | list of [Entity](#entity) | no | Tables this business has that the preset does not declare |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How rows of the preset\'s entities and this business\'s own are recognised, entity by entity over the preset\'s |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Create-time clauses on the preset\'s entities and this business\'s own, over whatever the preset declares |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n<!-- generated:end top-level -->\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 what an agent states to make an app,\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<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | Display label used as the table name at scaffold time |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does, and `scaffold\ncheck` prints it under *Who writes what* as `unique: Bed + Sown on`.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `scaffold check` prints it beside the table\nunder *Who writes what* and REFUSES a model where a table some create opens \u2014 a\nregister\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create, no update and no `lotics.writes` entry for it. `scaffold\ncheck` prints it under *Who writes what* as `written by automations` and never\nnotes it as created nowhere. A `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\nscaffold into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. Scaffold writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`; `scaffold check` prints it on the app\'s line and\nnames the app beside the rule it is exempt from.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | Display label used as the field name at scaffold time |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Scaffold writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the channel it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\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\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nlookup of the select on another entity: the channel it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`), or a glyph from the kit\'s curated Lucide set\n(`{"kind": "icon", "name": "wrench"}`; a name outside it is refused naming the\nnearest ones). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it.\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\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. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Scaffold creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\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.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows).\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics scaffold export` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso `check` refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\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<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \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## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the starter is copied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. `{{name}}` in the content is filled from the workflow\'s data.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the template content \u2014 inline: the utf-8 content string; file-backed: the bytes_ref bytes. The modified-detection reference. Optional in the schema so contracts published before template sha still parse; REQUIRED at publish (validatePackageContract). |\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n\n<!-- generated:end templates -->\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<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\nA `ref` is lowercase letters, digits and underscores, and 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, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-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**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\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- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\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## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows. A link names the row by the linked row (a job read by its customer), never a code or an autonumber |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines: each counts down and turns overdue |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 list of alias (at least one) | yes | The stage applies only while each named select holds one of these options; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n- **The title names the row.** A link names it by the linked row (a job read by its\n customer); a code or an autonumber is refused as a title \u2014 the code is the subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias \u2014 this file\'s own:\n`check` proves it and the workspace never sees it. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity. The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip) |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row\'s span starts on |\n| `to` | alias | yes | The date a row\'s span ends on |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key of one `text` field declares `unique: true`\n on the field itself \u2014 two rows sharing it would make find-or-create pick\n whichever the read answered first. An add asks the key by default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` link is `required`, because there has to be a row to read, and\n the two field types must match. The copied field is not asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A save moving what\n the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is a\n`component` block or an act\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] },\n { "title": "History", "blocks": [{ "timeline": "visit_history" }] }\n ],\n "comments": true\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record itself |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections and its comment thread |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time. A `list` reading lists rows, each opening in its `app`.\n\n```jsonc\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "metric": "visit", "where": { "stage": ["in_yard"] }, "label": "In the yard" },\n { "trend": "visit", "over": "arrived_on", "label": "Gate-ins" },\n { "breakdown": "visit", "by": "line", "value": "fees" },\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title; the status and the figure are placed by the runtime |\n| `filters` | list of alias (at most 3) | no | Fields the reader narrows the rows by (a select, a member, a link, a date, a yes/no), at most 3 \u2014 the ones narrowed by daily, in that order, each on the register\'s line. The status is the chips, never one of these; a column\'s header sorts by a day or an amount |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `sort` | [Register order](#register-order) | no | The order rows open in; absent, the first deadline soonest first, else the newest first |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` | no | `cards` for rows read by their picture, `calendar` for rows read by their day; absent, a table |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title and every required field; false, rows are not added here |\n| `export` | `true` \\| [Register export](#register-export) | no | The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them |\n\n#### Register order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register export\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template made into one file of every row in view, the rows listed under the entity\'s alias |\n\n<!-- generated:end apps-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed \u2014 never one of `filters`. `filters` are at most\nthree, in order of use, each on the line with the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A\ncolumn the row already draws is refused. Absent `sort`, rows open soonest\ndeadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export: true` saves the rows in view as a spreadsheet of the columns; `export: {\n"template" }` makes one document of them, the rows listed under the entity\'s alias.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one. One line states the metrics; the pictures\nwait behind its Charts press, as each reader last left them. Pressing a bar, a cell or\na metric with a `where` adds its condition as a filter.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n```\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no. `over` is a stored date: a trend\'s periods, a metric\'s recent\nperiods beside its number, and what a dashboard\'s period windows. `where` keeps rows\nwhose select holds one of the options, or whose yes/no is the value, every entry ANDed.\n`target` reads a metric against a number where no `limits` bounds its value. A register\nreads only its own entity; a `list` stands only on a dashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ],\n "comments": true\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` | no | A page for work read at length; a drawer for rows worked one after another from the register. Either stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page` or the record has more than three sections, else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread: who wrote what and when, with a composer, beside the sections |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one and it stands before the section that is the work now, and waiting otherwise; the item of a section with no `at` is its title alone, and a record with no `at` marks no item. A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act on the record is named by one section. An act moving the status stands in the section of the step it is an outcome of \u2014 the one stage its `when` holds \u2014 unless it enters a detour or an exit the work reaches no other way, whose section it opens. A destructive act that is not one of its step\'s outcomes is a correction: it waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), the section stands even holding\nnothing yet, and a page opened from the register opens scrolled to it \u2014 it never hides a\nfield, locks it or gates an act. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status stands in the section of the step it is an outcome of (its `when`);\none entering a detour or an exit \u2014 a step the work reaches no other way \u2014 stands in that\nsection, which draws it and its gaps alone while it waits. A destructive act offered\nbeside another move of the same step (T\u1EEB ch\u1ED1i beside Duy\u1EC7t) is a button of that step; any\nother destructive act corrects what is done and waits in the \u22EF of what it acts on: its\nsection\'s heading, its row. `comments: true` puts the record\'s comment thread beside the\nsections, and a status `history` is read there too, never as a section.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused, and so is a section holding nothing, an `at` on a record\nwith no status, and a section\'s act that is a child\'s or runs over the register\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"] },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" },\n { "component": "YardMap" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as dated entries, newest first, with a composer where the app writes it \u2014 never the record\'s status history, which the record\'s companion reads beside its comments |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field; false, rows are not added here |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Component block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `component` | text | yes | A component of the app (src/components) that draws this block |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n<!-- generated:end apps-blocks -->\n\nA `rows` or `timeline` block reads a child entity through its link to the record \u2014\n`via` names it where the child links more than once. A rows block adds rows (asking\nthe child\'s title and required fields) and opens each in a drawer; a timeline reads\nits child\'s first date as the moment and its member field as who, with a composer\nwhere the app writes it. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row, the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one. A `component` is\nthe app\'s own, exported from `src/components`.\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row, its conditions and its write that row\'s |\n| `when` | map of alias \u2192 list of alias (at least one) | no | The act works only while each named select holds one of these options |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first; a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null`) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `into` | alias | no | The files field of the one record the made document is kept in |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n<!-- generated:end apps-acts -->\n\nAn act whose `when` does not hold is not offered; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. A field an act writes is\nread-only everywhere else. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too.\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands, and a text formula\'s words are what the reader sees |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts.\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": { "company": { "label": "Customers", "fields": { "name": "Company name" } } },\n "no_sample_data": true\n }\n]\n```\n\n<!-- generated:start apply -->\n\n#### Package entry\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `package` | text | yes | The package id to copy in |\n| `bind` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 the labels this workspace calls that entity and its fields |\n| `no_sample_data` | boolean | no | Decline the package\'s sample records |\n\n#### Binding\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | no | The workspace\'s own table name for this entity. |\n| `fields` | map of alias \u2192 text | no | Field alias \u2192 the workspace\'s own field name for it. |\n\n<!-- generated:end apply -->\n\n`bind` holds the LABELS this workspace 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### What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\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?"],\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\n<!-- generated:start presets -->\n\n#### Preset\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `name` | text | yes | The trade, as the shelf lists it |\n| `description` | text | yes | What the trade\'s workspace keeps, in a sentence |\n| `questions` | list of text (at most 2) | yes | What to ask before choosing a variant \u2014 at most two |\n| `variants` | map of alias \u2192 [Variant](#variant) | yes | Slug \u2192 a branch of the model, taken where its `when` describes the business |\n\n#### Variant\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `when` | text | yes | The condition, in the words a person would use, that selects this variant |\n| `entities` | list of [Entity](#entity) | no | Tables this variant adds |\n| `fields` | map of alias \u2192 list of [Field](#field) | no | Entity alias \u2192 the fields this variant adds to that entity |\n\n<!-- generated:end presets -->\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 "records": { "job": { "title": "code" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the apps this business 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`**, **`records`**, **`write_rules`**, **`apps`** and **`apply`** mean\n exactly what they mean in the full form \u2014 `"rows"` are this business\'s real\n first records, `"records"` and `"write_rules"` may name the preset\'s entities\n as well as its own (an entity this file names replaces the preset\'s whole entry\n for it), `"apps"` the apps 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 "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\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 "singular": "Order",\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 { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "figure": "total_ordered" },\n "order": { "title": "code", "subtitle": ["customer", "placed_on"], "figure": "amount" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": { "columns": ["tier"], "filters": ["tier"] },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"] }] }\n ],\n "comments": true\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": { "filters": ["customer"] },\n "record": { "door": "drawer" }\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 prints the counts, then each app as its\nreader will see it:\n\n```\nOK \u2014 2 tables, 11 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps.\n\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters Tier\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no. (default)\n thread the record\'s comments, beside the sections\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Order no. \xB7 under it Customer, Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n add Order no. (default)\n Record \u2014 a drawer\n header title Order no. \xB7 subtitle Customer, Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n (default) marks what the model left to the system. `lotics app preview <model.json>#<app>` draws it.\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
|
|
58002
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. The\nfull form spells it out; the `from` form names a published preset and carries\nonly what this business differs by (\xA7 Starting from a preset \u2014 prefer it whenever\na preset fits the trade). Each \xA7 is `lotics docs model/<section>`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics scaffold check model.json` \u2014 offline, every problem in one run, and each app\n printed as its reader will see it.\n5. `lotics app preview model.json#<app> --shots <dir>` \u2014 each app drawn from `rows`, nothing created.\n6. `lotics workspace build model.json --deploy` \u2014 the tables, then every app, live\n (`lotics setup model.json --email you@company.com` first where no account exists).\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. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference.\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- **`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\nEvery table of keys on this page is generated from the schema `scaffold check`\nparses the file with, so it is the whole of what a key may hold. The first is the\nfull form; the second names a preset instead of restating one (\xA7 Starting from a\npreset).\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates whose content travels inline (html or email) |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `preset` | [Preset](#preset) | no | The trade\'s branches, for a model published to be read; `setup` and `apply` scaffold the base alone |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n#### From file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | text | yes | The preset this model starts from, by its slug |\n| `variants` | list of text | no | The preset\'s variant slugs to merge onto its base, in order |\n| `rename` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 what this business calls that table and its fields |\n| `entities` | list of [Entity](#entity) | no | Tables this business has that the preset does not declare |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How rows of the preset\'s entities and this business\'s own are recognised, entity by entity over the preset\'s |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Create-time clauses on the preset\'s entities and this business\'s own, over whatever the preset declares |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n<!-- generated:end top-level -->\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 what an agent states to make an app,\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<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | Display label used as the table name at scaffold time |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does, and `scaffold\ncheck` prints it under *Who writes what* as `unique: Bed + Sown on`.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `scaffold check` prints it beside the table\nunder *Who writes what* and REFUSES a model where a table some create opens \u2014 a\nregister\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create, no update and no `lotics.writes` entry for it. `scaffold\ncheck` prints it under *Who writes what* as `written by automations` and never\nnotes it as created nowhere. A `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\nscaffold into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. Scaffold writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`; `scaffold check` prints it on the app\'s line and\nnames the app beside the rule it is exempt from.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | Display label used as the field name at scaffold time |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Scaffold writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the channel it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\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\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nlookup of the select on another entity: the channel it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`), or a glyph from the kit\'s curated Lucide set\n(`{"kind": "icon", "name": "wrench"}`; a name outside it is refused naming the\nnearest ones). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it.\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\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. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Scaffold creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\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.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows).\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics scaffold export` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso `check` refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\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<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \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## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the starter is copied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. `{{name}}` in the content is filled from the workflow\'s data.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the template content \u2014 inline: the utf-8 content string; file-backed: the bytes_ref bytes. The modified-detection reference. Optional in the schema so contracts published before template sha still parse; REQUIRED at publish (validatePackageContract). |\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n\n<!-- generated:end templates -->\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<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\nA `ref` is lowercase letters, digits and underscores, and 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, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-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**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\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- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\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## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows. A link names the row by the linked row (a job read by its customer), never a code or an autonumber |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines: each counts down and turns overdue |\n| `starts` | map of alias \u2192 `"today"` \\| `"me"` | no | What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default` |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | yes | The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n- **The title names the row.** A link names it by the linked row (a job read by its\n customer); a code or an autonumber is refused as a title \u2014 the code is the subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options and its stored yes/no that\n value; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n- **`starts`** is where an add\'s field begins, stated only where that is certain \u2014 a\n log\'s own day at `today`, a request\'s requester at `me`. Nothing else starts a field\n but its declared `default` and what the reader narrowed the register to.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias \u2014 this file\'s own:\n`check` proves it and the workspace never sees it. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity. The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip) |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row\'s span starts on |\n| `to` | alias | yes | The date a row\'s span ends on |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key of one `text` field declares `unique: true`\n on the field itself \u2014 two rows sharing it would make find-or-create pick\n whichever the read answered first. An add asks the key by default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` link is `required`, because there has to be a row to read, and\n the two field types must match. The copied field is not asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A save moving what\n the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is a\n`component` block or an act\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] }\n ],\n "comments": true,\n "history": true\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record itself |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections, and beside them its comment thread and its status history where stated |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time. A `list` reading lists rows, each opening in its `app`.\n\n```jsonc\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "metric": "visit", "where": { "stage": ["in_yard"] }, "label": "In the yard" },\n { "trend": "visit", "over": "arrived_on", "label": "Gate-ins" },\n { "breakdown": "visit", "by": "line", "value": "fees" },\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title; the status and the figure are placed by the runtime |\n| `filters` | list of alias (at most 3) | no | Fields the reader narrows the rows by (a select, a member, a link, a date, a yes/no), at most 3 \u2014 the ones narrowed by daily, in that order, each on the register\'s line. The status is the chips, never one of these; a column\'s header sorts by a day or an amount |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `group` | alias | no | The field the rows open grouped by, the reader\'s grouping starting there \u2014 a period\'s rows read under each period, one per person; absent, ungrouped |\n| `sort` | [Register order](#register-order) | no | The order rows open in; absent, the first deadline soonest first, else the newest first |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` | no | `cards` for rows read by their picture, `calendar` for rows read by their day; absent, a table |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title and every required field; false, rows are not added here |\n| `export` | `true` \\| [Register export](#register-export) | no | The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them |\n\n#### Register order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register export\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template made into one file of every row in view, the rows listed under the entity\'s alias |\n\n<!-- generated:end apps-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed \u2014 never one of `filters`. `filters` are at most\nthree, in order of use, each on the line with the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A\ncolumn the row already draws is refused. Absent `sort`, rows open soonest\ndeadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export: true` saves the rows in view as a spreadsheet of the columns; `export: {\n"template" }` makes one document of them, the rows listed under the entity\'s alias.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one. One line states the metrics; the pictures\nwait behind its Charts press, as each reader last left them. Pressing a bar, a cell or\na metric with a `where` adds its condition as a filter.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n```\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no. `over` is a stored date: a trend\'s periods, a metric\'s recent\nperiods beside its number, and what a dashboard\'s period windows. `where` keeps rows\nwhose select holds one of the options, or whose yes/no is the value, every entry ANDed.\n`target` reads a metric against a number where no `limits` bounds its value. A register\nreads only its own entity; a `list` stands only on a dashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ],\n "comments": true\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` | no | A page for work read at length; a drawer for rows worked one after another from the register. Either stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page` or the record has more than three sections, else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread: who wrote what and when, with a composer, beside the sections |\n| `history` | `true` | no | The record\'s status history \u2014 each move, when and who \u2014 beside the sections, for a lifecycle whose past steps are looked up (a shipment, a container\'s gate moves, an order); the entity\'s `status.history` is written either way. Absent, it is drawn nowhere |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one and it stands before the section that is the work now, and waiting otherwise; the item of a section with no `at` is its title alone, and a record with no `at` marks no item. A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act on the record is named by one section. An act moving the status stands in the section of the step it is an outcome of \u2014 the one stage its `when` holds \u2014 unless it enters a detour or an exit the work reaches no other way, whose section it opens. A destructive act that is not one of its step\'s outcomes is a correction: it waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), and the section stands even holding\nnothing yet \u2014 it never hides a field, locks it or gates an act, and a record opens at its\nhead. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status stands in the section of the step it is an outcome of (its `when`);\none entering a detour or an exit \u2014 a step the work reaches no other way \u2014 stands in that\nsection, which draws it and its gaps alone while it waits. A destructive act offered\nbeside another move of the same step (T\u1EEB ch\u1ED1i beside Duy\u1EC7t) is a button of that step; any\nother destructive act corrects what is done and waits in the \u22EF of what it acts on: its\nsection\'s heading, its row. `comments: true` puts the record\'s comment thread beside the\nsections, and `history: true` its status history \u2014 each only where stated, and the\nhistory never as a section: for a lifecycle whose past steps are looked up (a shipment,\na container\'s gate moves, an order), not every record with a status.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused, and so is a section holding nothing, an `at` on a record\nwith no status, and a section\'s act that is a child\'s or runs over the register\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"], "under": "invoice" },\n { "agenda": "booking", "columns": ["guide"], "start": "arrival" },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" },\n { "component": "YardMap" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the block\'s columns and every required field a person writes; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `under` | alias | no | The child\'s one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers, the lines no document covers first under their own heading, where the document\'s add makes one from the lines picked. Never also a rows block of the document |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as a log of dated entries, newest first, with a composer where the app writes it (entries planned ahead are an `agenda`) \u2014 never the record\'s status history, which `record.history` draws beside the record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field a person writes; false, rows are not added here |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Agenda block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `agenda` | alias | yes | A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child\'s row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day\'s order |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s facts read on an entry\'s line after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the block\'s columns and every required field a person writes; false, rows are not added here |\n| `start` | alias | no | The record\'s date that is the first day: each day\'s heading counts from it (Day 1, Day 2); absent, the weekday and the date alone |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Component block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `component` | text | yes | A component of the app (src/components) that draws this block |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n<!-- generated:end apps-blocks -->\n\nA `rows`, `agenda` or `timeline` block reads a child entity through its link to the\nrecord \u2014 `via` names it where the child links more than once. A rows block adds rows\n(asking the child\'s title, its picture, the block\'s columns and its required fields)\nand opens each in a drawer. An `agenda` draws planned entries by day, soonest first:\nthe first date on the child\'s row line is the day, a datetime there or the first single\nselect after the date orders a day, and `start` \u2014 the record\'s date \u2014 is day 1. `under`\nnames the child\'s one-link to a document covering its lines (an invoice over its fees,\nits many-link paired back, linking to the record): each document heads the lines it\ncovers, the uncovered lines first, where a document is made from the lines picked \u2014 the\nrecord never lists the documents again as a block of their own. A timeline reads\nits child\'s first date as the moment and its member field as who, with a composer\nwhere the app writes it. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row, the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one. A `component` is\nthe app\'s own, exported from `src/components`.\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row, its conditions and its write that row\'s |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first; a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null`) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `into` | alias | no | The files field of the one record the made document is kept in |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n<!-- generated:end apps-acts -->\n\nAn act whose `when` does not hold is not offered \u2014 each select named holding one of its\noptions, each yes/no named (stored, or a formula: a period that has ended) its `true` or\n`false`; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. A field an act writes is\nread-only everywhere else. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too.\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands, and a text formula\'s words are what the reader sees |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts. A warning reading the figure of a meter that\n`gates` marks against its bound or its pass mark is refused: the meter already draws it.\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": { "company": { "label": "Customers", "fields": { "name": "Company name" } } },\n "no_sample_data": true\n }\n]\n```\n\n<!-- generated:start apply -->\n\n#### Package entry\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `package` | text | yes | The package id to copy in |\n| `bind` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 the labels this workspace calls that entity and its fields |\n| `no_sample_data` | boolean | no | Decline the package\'s sample records |\n\n#### Binding\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | no | The workspace\'s own table name for this entity. |\n| `fields` | map of alias \u2192 text | no | Field alias \u2192 the workspace\'s own field name for it. |\n\n<!-- generated:end apply -->\n\n`bind` holds the LABELS this workspace 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### What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\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?"],\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\n<!-- generated:start presets -->\n\n#### Preset\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `name` | text | yes | The trade, as the shelf lists it |\n| `description` | text | yes | What the trade\'s workspace keeps, in a sentence |\n| `questions` | list of text (at most 2) | yes | What to ask before choosing a variant \u2014 at most two |\n| `variants` | map of alias \u2192 [Variant](#variant) | yes | Slug \u2192 a branch of the model, taken where its `when` describes the business |\n\n#### Variant\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `when` | text | yes | The condition, in the words a person would use, that selects this variant |\n| `entities` | list of [Entity](#entity) | no | Tables this variant adds |\n| `fields` | map of alias \u2192 list of [Field](#field) | no | Entity alias \u2192 the fields this variant adds to that entity |\n\n<!-- generated:end presets -->\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 "records": { "job": { "title": "code" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the apps this business 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`**, **`records`**, **`write_rules`**, **`apps`** and **`apply`** mean\n exactly what they mean in the full form \u2014 `"rows"` are this business\'s real\n first records, `"records"` and `"write_rules"` may name the preset\'s entities\n as well as its own (an entity this file names replaces the preset\'s whole entry\n for it), `"apps"` the apps 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 "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\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 "singular": "Order",\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 { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "figure": "total_ordered" },\n "order": { "title": "code", "subtitle": ["customer", "placed_on"], "figure": "amount", "starts": { "placed_on": "today" } }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": {\n "columns": ["tier"],\n "filters": ["tier"],\n "readings": [{ "breakdown": "customer", "by": "tier", "value": "total_ordered" }]\n },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"] }] }\n ],\n "comments": true\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": {\n "filters": ["customer"],\n "create": ["code", "customer", "placed_on"],\n "readings": [{ "trend": "order", "over": "placed_on", "value": "amount" }]\n },\n "record": { "door": "drawer" }\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 prints the counts, then each app as its\nreader will see it:\n\n```\nOK \u2014 2 tables, 11 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps.\n\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters Tier\n readings "Tier" \u2014 Total ordered summed per Tier, over the rows in view (default)\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no. (default)\n thread the record\'s comments, beside the sections\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Order no. \xB7 under it Customer, Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n readings "Amount" \u2014 Amount summed per period by Placed on, over the rows in view (default)\n add Order no., Customer, Placed on \u2014 starting Placed on at today\n Record \u2014 a drawer\n header title Order no. \xB7 subtitle Customer, Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n (default) marks what the model left to the system. `lotics app preview <model.json>#<app>` draws it.\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
|
|
57595
58003
|
|
|
57596
58004
|
// src/docs_command.ts
|
|
57597
58005
|
var READING_ORDER = ["@lotics/app-runtime", "@lotics/ui", "@lotics/cli"];
|
|
@@ -58733,7 +59141,7 @@ function setMachineOutput(on) {
|
|
|
58733
59141
|
function isMachineOutput() {
|
|
58734
59142
|
return machineMode;
|
|
58735
59143
|
}
|
|
58736
|
-
function
|
|
59144
|
+
function note2(text) {
|
|
58737
59145
|
if (machineMode) return;
|
|
58738
59146
|
console.error(text);
|
|
58739
59147
|
}
|
|
@@ -58775,7 +59183,7 @@ async function offerSignInLink(client, redirect_path) {
|
|
|
58775
59183
|
return null;
|
|
58776
59184
|
}
|
|
58777
59185
|
if (url2 !== null) {
|
|
58778
|
-
|
|
59186
|
+
note2(`
|
|
58779
59187
|
Open it \u2014 one-time sign-in link, expires in 15 minutes:
|
|
58780
59188
|
${url2}`);
|
|
58781
59189
|
}
|
|
@@ -58878,7 +59286,7 @@ function printPresetRows(rows) {
|
|
|
58878
59286
|
}
|
|
58879
59287
|
function noteCopiedContent(templates, knowledgeDocs) {
|
|
58880
59288
|
if (templates.length > 0) {
|
|
58881
|
-
|
|
59289
|
+
note2(
|
|
58882
59290
|
`
|
|
58883
59291
|
${templates.length} document template${templates.length === 1 ? "" : "s"}: ${templates.join(", ")}
|
|
58884
59292
|
These carry the publisher's wording and details, not yours \u2014 decide which should
|
|
@@ -58886,7 +59294,7 @@ function noteCopiedContent(templates, knowledgeDocs) {
|
|
|
58886
59294
|
);
|
|
58887
59295
|
}
|
|
58888
59296
|
if (knowledgeDocs.length > 0) {
|
|
58889
|
-
|
|
59297
|
+
note2(
|
|
58890
59298
|
`
|
|
58891
59299
|
${knowledgeDocs.length} knowledge doc${knowledgeDocs.length === 1 ? "" : "s"}: ${knowledgeDocs.join(", ")}
|
|
58892
59300
|
These describe how the publisher works, and the app's agents read them.
|
|
@@ -59109,7 +59517,7 @@ function reportKnowledgeWarnings(warnings) {
|
|
|
59109
59517
|
}
|
|
59110
59518
|
async function copyPackage(client, args) {
|
|
59111
59519
|
const pkg = await client.getStarter(args.starter_id);
|
|
59112
|
-
|
|
59520
|
+
note2(
|
|
59113
59521
|
`Copying ${pkg.name}${pkg.is_official ? " (official)" : ""} into this workspace\u2026`
|
|
59114
59522
|
);
|
|
59115
59523
|
const result = await client.instantiateStarter(args.starter_id, {
|
|
@@ -59130,7 +59538,7 @@ async function copyPackage(client, args) {
|
|
|
59130
59538
|
version_number: app.deployed?.version_number ?? null,
|
|
59131
59539
|
error: app.error
|
|
59132
59540
|
}));
|
|
59133
|
-
|
|
59541
|
+
note2(
|
|
59134
59542
|
` Created ${tables.length} table${tables.length === 1 ? "" : "s"}, ${templates.length} template${templates.length === 1 ? "" : "s"}, ${knowledgeDocs.length} knowledge doc${knowledgeDocs.length === 1 ? "" : "s"}, ${records} sample record${records === 1 ? "" : "s"}, ${apps.length} app${apps.length === 1 ? "" : "s"}.`
|
|
59135
59543
|
);
|
|
59136
59544
|
reportKnowledgeWarnings(result.knowledge_warnings);
|
|
@@ -59142,7 +59550,7 @@ ${pkg.name} is the publisher's code \u2014 its apps, workflows and agents \u2014
|
|
|
59142
59550
|
);
|
|
59143
59551
|
}
|
|
59144
59552
|
for (const app of apps) {
|
|
59145
|
-
|
|
59553
|
+
note2(
|
|
59146
59554
|
app.error === null ? ` \u2713 ${app.name} (${app.app_id}, v${app.version_number})` : ` \u2717 ${app.name} (${app.app_id}): ${app.error.split("\n")[0]}`
|
|
59147
59555
|
);
|
|
59148
59556
|
}
|
|
@@ -59156,7 +59564,7 @@ ${failed.length} app${failed.length === 1 ? "" : "s"} landed without a version:
|
|
|
59156
59564
|
);
|
|
59157
59565
|
process.exitCode = 1;
|
|
59158
59566
|
}
|
|
59159
|
-
|
|
59567
|
+
note2(
|
|
59160
59568
|
(apps.length > 0 && failed.length === apps.length ? `
|
|
59161
59569
|
${pkg.name} landed, but none of its ${apps.length} app${apps.length === 1 ? "" : "s"} is live.
|
|
59162
59570
|
` : `
|
|
@@ -59169,7 +59577,7 @@ Done \u2014 ${pkg.name} is live and yours.
|
|
|
59169
59577
|
How to change it: lotics docs building_an_app (then \`lotics docs\` for the rest)`
|
|
59170
59578
|
);
|
|
59171
59579
|
if (records > 0) {
|
|
59172
|
-
|
|
59580
|
+
note2(
|
|
59173
59581
|
`
|
|
59174
59582
|
${records} sample record${records === 1 ? "" : "s"} came with it. They are ordinary records \u2014
|
|
59175
59583
|
delete them whenever you like, or re-run with --no-sample-data next time.`
|
|
@@ -59360,8 +59768,18 @@ function orderWords(model, entity, field2, desc) {
|
|
|
59360
59768
|
return desc ? "Z\u2192A" : "A\u2192Z";
|
|
59361
59769
|
}
|
|
59362
59770
|
var marked = (app, path40) => app.defaulted.includes(path40) ? DEFAULT : "";
|
|
59771
|
+
function startWords(model, entity, asked) {
|
|
59772
|
+
const starts = model.records?.[entity]?.starts ?? {};
|
|
59773
|
+
const words = asked.flatMap((name) => {
|
|
59774
|
+
const start = starts[name];
|
|
59775
|
+
return start === void 0 ? [] : [`${label(model, entity, name)} ${start === "today" ? "at today" : "at the reader"}`];
|
|
59776
|
+
});
|
|
59777
|
+
return words.length === 0 ? "" : ` \u2014 starting ${words.join(", ")}`;
|
|
59778
|
+
}
|
|
59363
59779
|
function whileWords(model, entity, when) {
|
|
59364
|
-
return when.map(
|
|
59780
|
+
return when.map(
|
|
59781
|
+
(gate) => `while ${label(model, entity, gate.field)} is ${"value" in gate ? gate.value ? "yes" : "no" : gate.options.map((option) => optionLabel(model, entity, gate.field, option)).join(" or ")}`
|
|
59782
|
+
).join(", ");
|
|
59365
59783
|
}
|
|
59366
59784
|
function describeApp(model, app) {
|
|
59367
59785
|
const entity = entityOf2(model, app.entity);
|
|
@@ -59394,11 +59812,18 @@ function describeApp(model, app) {
|
|
|
59394
59812
|
const closed = (option) => stages === void 0 || status?.field !== void 0 ? optionLabel(model, app.entity, status?.field ?? "", option) : label(model, app.entity, option);
|
|
59395
59813
|
const statusChips = stageWord === void 0 ? [] : [`${stageWord} (opens on ${status?.closed === void 0 ? "every row" : `all but ${status.closed.map(closed).join(", ")}`})`];
|
|
59396
59814
|
lines.push(` filters ${[...statusChips, ...app.register.filters.map((field2) => label(model, app.entity, field2))].join(" \xB7 ") || "\u2014"}`);
|
|
59815
|
+
if (app.register.group !== void 0) {
|
|
59816
|
+
const lead = app.register.group === display?.title ? display.subtitle?.[0] : void 0;
|
|
59817
|
+
lines.push(
|
|
59818
|
+
` grouped by ${label(model, app.entity, app.register.group)}, the reader's to change \u2014 each group's head says it once${lead === void 0 ? "" : `, and each row leads with ${label(model, app.entity, lead)}`}`
|
|
59819
|
+
);
|
|
59820
|
+
}
|
|
59397
59821
|
if (app.register.search !== void 0) {
|
|
59398
59822
|
lines.push(` search ${labels(model, app.entity, app.register.search)} \u2014 a pasted list searches each line and names the lines with no row`);
|
|
59399
59823
|
}
|
|
59400
59824
|
if (app.register.readings.length > 0) lines.push(...block("readings", app.register.readings.map((reading, index) => `${describeReading(model, reading, "over the rows in view")}${marked(app, `register.readings.${index}.label`)}`)));
|
|
59401
|
-
|
|
59825
|
+
const create = app.register.create;
|
|
59826
|
+
lines.push(` add ${create === void 0 ? "\u2014 rows are not added here" : labels(model, app.entity, create)}${marked(app, "register.create")}${create === void 0 ? "" : startWords(model, app.entity, create)}`);
|
|
59402
59827
|
const exported = app.register.export;
|
|
59403
59828
|
if (exported === true) lines.push(` export the register's columns, as a spreadsheet`);
|
|
59404
59829
|
else if (exported !== void 0) lines.push(` export ${exported.template}, one document of every row in view`);
|
|
@@ -59437,7 +59862,7 @@ function describeApp(model, app) {
|
|
|
59437
59862
|
...block(
|
|
59438
59863
|
"stages",
|
|
59439
59864
|
stages.map((stage, index) => {
|
|
59440
|
-
const when =
|
|
59865
|
+
const when = whenConditions(stage.when);
|
|
59441
59866
|
return `${index + 1}. ${label(model, app.entity, stage.field)}${when.length === 0 ? "" : ` (${whileWords(model, app.entity, when)})`} \u2014 ticking stamps today, clearing clears every later one`;
|
|
59442
59867
|
})
|
|
59443
59868
|
)
|
|
@@ -59445,9 +59870,9 @@ function describeApp(model, app) {
|
|
|
59445
59870
|
}
|
|
59446
59871
|
lines.push(...describeSections(model, app));
|
|
59447
59872
|
lines.push(` thread ${app.record.comments ? "the record's comments, beside the sections" : "\u2014"}`);
|
|
59448
|
-
const
|
|
59873
|
+
const moves = app.record.moves;
|
|
59449
59874
|
lines.push(
|
|
59450
|
-
` history ${
|
|
59875
|
+
` history ${moves === void 0 || !app.record.history ? "\u2014" : `${moves.title} \u2014 each move of the status by ${label(model, moves.entity, moves.at)}${moves.by === void 0 ? "" : `, who ${label(model, moves.entity, moves.by)}`}, beside the sections`}`
|
|
59451
59876
|
);
|
|
59452
59877
|
lines.push(...block("acts", app.acts.map((act) => describeAct(model, app, act))));
|
|
59453
59878
|
lines.push(` Reads ${app.entities.map((alias2) => entityOf2(model, alias2)?.label ?? alias2).join(", ")}`);
|
|
@@ -59535,7 +59960,7 @@ function describeSections(model, app) {
|
|
|
59535
59960
|
}
|
|
59536
59961
|
function describeBlock(model, app, one, path40) {
|
|
59537
59962
|
const titled = (title) => `${title}${marked(app, `${path40}.title`)}`;
|
|
59538
|
-
const add = (create, entity) => create === void 0 ? "; rows are not added here" : `; add asks ${labels(model, entity, create)}${marked(app, `${path40}.create`)}`;
|
|
59963
|
+
const add = (create, entity) => create === void 0 ? "; rows are not added here" : `; add asks ${labels(model, entity, create)}${marked(app, `${path40}.create`)}${startWords(model, entity, create)}`;
|
|
59539
59964
|
const shown = one.when.length === 0 ? "" : ` (${whileWords(model, app.entity, one.when)})`;
|
|
59540
59965
|
const line = (() => {
|
|
59541
59966
|
switch (one.kind) {
|
|
@@ -59543,7 +59968,12 @@ function describeBlock(model, app, one, path40) {
|
|
|
59543
59968
|
const where = one.where.length === 0 ? "" : ` ${whileWords(model, one.entity, one.where).replace(/^while /, "where ").replaceAll(", while ", " and ")}`;
|
|
59544
59969
|
const set2 = new Set(one.where.flatMap((gate) => gate.options.length === 1 ? [gate.field] : []));
|
|
59545
59970
|
const asked = one.create?.filter((field2) => !set2.has(field2));
|
|
59546
|
-
return `${titled(one.title)} \u2014 ${one.expect === void 0 ? "rows of" : `one line per ${label(model, one.entity, one.expect)} of`} ${entityOf2(model, one.entity)?.label ?? one.entity}${where} through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(asked, one.entity)}`;
|
|
59971
|
+
return `${titled(one.title)} \u2014 ${one.expect === void 0 ? "rows of" : `one line per ${label(model, one.entity, one.expect)} of`} ${entityOf2(model, one.entity)?.label ?? one.entity}${where} through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(asked, one.entity)}${one.under === void 0 ? "" : describeUnder(model, one.under)}`;
|
|
59972
|
+
}
|
|
59973
|
+
case "agenda": {
|
|
59974
|
+
const within2 = one.time !== void 0 ? `at ${label(model, one.entity, one.time)}` : one.part !== void 0 ? `in ${label(model, one.entity, one.part)}'s order` : "in the order added";
|
|
59975
|
+
const first2 = one.start === void 0 ? "" : `, day 1 on ${label(model, app.entity, one.start)}`;
|
|
59976
|
+
return `${titled(one.title)} \u2014 ${entityOf2(model, one.entity)?.label ?? one.entity} planned by ${label(model, one.entity, one.day)}, soonest first, ${within2}${first2}, through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(one.create, one.entity)}`;
|
|
59547
59977
|
}
|
|
59548
59978
|
case "timeline":
|
|
59549
59979
|
return `${titled(one.title)} \u2014 entries of ${entityOf2(model, one.entity)?.label ?? one.entity} by ${label(model, one.entity, one.at)}${one.by === void 0 ? "" : `, who ${label(model, one.entity, one.by)}`}${add(one.create, one.entity)}`;
|
|
@@ -59559,6 +59989,14 @@ function describeBlock(model, app, one, path40) {
|
|
|
59559
59989
|
})();
|
|
59560
59990
|
return `${line}${shown}`;
|
|
59561
59991
|
}
|
|
59992
|
+
function describeUnder(model, under) {
|
|
59993
|
+
const document2 = entityOf2(model, under.entity);
|
|
59994
|
+
const named = document2?.singular ?? document2?.label ?? under.entity;
|
|
59995
|
+
const sent = /* @__PURE__ */ new Set([under.back, ...under.where.map((one) => one.field)]);
|
|
59996
|
+
const asked = under.create?.filter((field2) => !sent.has(field2)) ?? [];
|
|
59997
|
+
const make = under.create === void 0 ? "; documents are not made here" : `; a ${named} is made over the lines picked, asking ${asked.length === 0 ? "nothing more" : labels(model, under.entity, asked)}`;
|
|
59998
|
+
return `; each under its ${named} (${label(model, under.entity, under.back)}), the lines with none first${make}`;
|
|
59999
|
+
}
|
|
59562
60000
|
function describeAct(model, app, act) {
|
|
59563
60001
|
const at2 = act.of ?? app.entity;
|
|
59564
60002
|
const parts = [
|
|
@@ -59617,6 +60055,40 @@ function placed(model, app, act) {
|
|
|
59617
60055
|
function count(n, noun) {
|
|
59618
60056
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
59619
60057
|
}
|
|
60058
|
+
function cellWords(value) {
|
|
60059
|
+
if (value === void 0 || value === null) return "";
|
|
60060
|
+
if (Array.isArray(value)) return value.map(cellWords).join(", ");
|
|
60061
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
60062
|
+
}
|
|
60063
|
+
function indistinctRows(records, rows, apps) {
|
|
60064
|
+
const registered = new Set(apps.flatMap((app) => isDashboardApp(app) ? [] : [app.entity]));
|
|
60065
|
+
return Object.entries(rows).flatMap(([entity, list3]) => {
|
|
60066
|
+
const display = records[entity];
|
|
60067
|
+
if (display === void 0 || !registered.has(entity)) return [];
|
|
60068
|
+
const read2 = [display.title, ...display.subtitle ?? []];
|
|
60069
|
+
const status = display.status?.field;
|
|
60070
|
+
const closed = new Set(status === void 0 ? [] : display.status?.closed ?? []);
|
|
60071
|
+
const alike = /* @__PURE__ */ new Map();
|
|
60072
|
+
for (const [index, row] of list3.entries()) {
|
|
60073
|
+
const held2 = status === void 0 ? void 0 : row.fields[status];
|
|
60074
|
+
if (row.fields[display.title] === void 0 || typeof held2 === "string" && closed.has(held2)) continue;
|
|
60075
|
+
const key = JSON.stringify(read2.map((field2) => row.fields[field2] ?? null));
|
|
60076
|
+
alike.set(key, [...alike.get(key) ?? [], index]);
|
|
60077
|
+
}
|
|
60078
|
+
return [...alike.values()].flatMap((indexes) => {
|
|
60079
|
+
const [first2] = indexes;
|
|
60080
|
+
if (first2 === void 0 || indexes.length < 2) return [];
|
|
60081
|
+
const line = read2.map((field2) => cellWords(list3[first2]?.fields[field2])).filter((words) => words !== "").join(" \xB7 ");
|
|
60082
|
+
return [
|
|
60083
|
+
{
|
|
60084
|
+
severity: "error",
|
|
60085
|
+
path: `rows.${entity}.${first2}`,
|
|
60086
|
+
message: `${indexes.length} open rows read "${line}" (rows ${indexes.join(", ")}) \u2014 the register cannot tell them apart; give each its own title, or a subtitle that differs`
|
|
60087
|
+
}
|
|
60088
|
+
];
|
|
60089
|
+
});
|
|
60090
|
+
});
|
|
60091
|
+
}
|
|
59620
60092
|
function checkWorkspaceModel(raw, options = {}) {
|
|
59621
60093
|
const read2 = parseModelFile(raw);
|
|
59622
60094
|
if (read2.type === "invalid") return { errors: read2.findings, notes: [], model: null };
|
|
@@ -59633,6 +60105,8 @@ function checkWorkspaceModel(raw, options = {}) {
|
|
|
59633
60105
|
// The model's language — a judgement about an authored file, so not in the validator the server
|
|
59634
60106
|
// also runs over stored copies.
|
|
59635
60107
|
...checkModelLanguage(contract.entities),
|
|
60108
|
+
// Rows are the first a register draws: two it cannot tell apart are a judgement of the authored file too.
|
|
60109
|
+
...indistinctRows(records, rows, apps),
|
|
59636
60110
|
// Every preset branch, applied and checked as a model of its own.
|
|
59637
60111
|
...preset === void 0 ? [] : validateModelPreset(contract, preset, rows),
|
|
59638
60112
|
// A document named by path must be beside the file, or `apply` fails after the tables exist.
|
|
@@ -59683,10 +60157,10 @@ async function uploadDocumentPaths(client, paths, baseDir, known) {
|
|
|
59683
60157
|
const idByPath = new Map(Object.entries(known).filter(([relative3]) => paths.includes(relative3)));
|
|
59684
60158
|
const fresh = paths.filter((relative3) => !idByPath.has(relative3));
|
|
59685
60159
|
if (fresh.length === 0) {
|
|
59686
|
-
|
|
60160
|
+
note2(`${count(paths.length, "document")} already uploaded \u2014 reusing the ids the last run recorded.`);
|
|
59687
60161
|
return idByPath;
|
|
59688
60162
|
}
|
|
59689
|
-
|
|
60163
|
+
note2(`Uploading ${count(fresh.length, "document")} the rows attach\u2026`);
|
|
59690
60164
|
for (const relative3 of fresh) {
|
|
59691
60165
|
const uploaded = await client.uploadFiles([path9.resolve(baseDir, relative3)]);
|
|
59692
60166
|
const error52 = uploaded.errors[0];
|
|
@@ -59696,7 +60170,7 @@ async function uploadDocumentPaths(client, paths, baseDir, known) {
|
|
|
59696
60170
|
const id = uploaded.files[0]?.id;
|
|
59697
60171
|
if (id === void 0) throw new Error(`The upload returned no file for "${relative3}".`);
|
|
59698
60172
|
idByPath.set(relative3, id);
|
|
59699
|
-
|
|
60173
|
+
note2(` uploaded ${relative3} ${id}`);
|
|
59700
60174
|
}
|
|
59701
60175
|
return idByPath;
|
|
59702
60176
|
}
|
|
@@ -59794,7 +60268,7 @@ async function resolveOverlay(file2, raw, overlay) {
|
|
|
59794
60268
|
if (model === null || dropped.length > 0) {
|
|
59795
60269
|
return { kind: "error", findings: [...dropped, ...errors], notes };
|
|
59796
60270
|
}
|
|
59797
|
-
|
|
60271
|
+
note2(`${file2} starts from ${read2.name} (${overlay.from}).`);
|
|
59798
60272
|
return { kind: "ok", model, notes };
|
|
59799
60273
|
}
|
|
59800
60274
|
async function knownSlugs(named) {
|
|
@@ -60091,7 +60565,7 @@ async function applyModelDocuments(client, model, options) {
|
|
|
60091
60565
|
(document2) => document2.kind === "path"
|
|
60092
60566
|
);
|
|
60093
60567
|
if (documents.length === 0) {
|
|
60094
|
-
|
|
60568
|
+
note2(`No row in ${options.file} attaches a document by path \u2014 nothing to write.`);
|
|
60095
60569
|
return { documents: {}, attached: 0 };
|
|
60096
60570
|
}
|
|
60097
60571
|
const binding = await client.getModelBinding();
|
|
@@ -60158,7 +60632,7 @@ async function applyModelDocuments(client, model, options) {
|
|
|
60158
60632
|
}
|
|
60159
60633
|
}
|
|
60160
60634
|
const written = Object.fromEntries(idByPath);
|
|
60161
|
-
|
|
60635
|
+
note2(
|
|
60162
60636
|
`
|
|
60163
60637
|
Attached ${count(attached, "document")} across ${count(entities.length, "table")}. Running this again attaches nothing \u2014 this workspace holds the id each path was uploaded as.`
|
|
60164
60638
|
);
|
|
@@ -60182,7 +60656,7 @@ function modelSignInPath(run) {
|
|
|
60182
60656
|
async function applyModelPackages(client, entries2) {
|
|
60183
60657
|
const applied = [];
|
|
60184
60658
|
for (const entry of entries2) {
|
|
60185
|
-
|
|
60659
|
+
note2(`
|
|
60186
60660
|
Applying ${entry.package}\u2026`);
|
|
60187
60661
|
try {
|
|
60188
60662
|
const copy = await copyPackage(client, {
|
|
@@ -60218,7 +60692,7 @@ function boundByNote(boundBy) {
|
|
|
60218
60692
|
async function scaffoldUnbind(client, kind, alias2) {
|
|
60219
60693
|
const { forgotten } = await client.deleteModelBinding(kind, alias2);
|
|
60220
60694
|
const next = kind === "app" ? `An app regenerated from now on draws its sibling act naming "${alias2}" disabled until \`lotics app create --from <model.json>#${alias2}\` binds a new one.` : `The next apply binds it by label, and creates one where no label matches.`;
|
|
60221
|
-
|
|
60695
|
+
note2(`Forgot ${count(forgotten, "binding")} under ${kind} "${alias2}". ${next}`);
|
|
60222
60696
|
return { forgotten };
|
|
60223
60697
|
}
|
|
60224
60698
|
async function scaffoldApply(client, model, options) {
|
|
@@ -60226,7 +60700,7 @@ async function scaffoldApply(client, model, options) {
|
|
|
60226
60700
|
}
|
|
60227
60701
|
async function applyModel(client, model, options) {
|
|
60228
60702
|
const labels2 = new Map(model.contract.entities.map((entity) => [entity.alias, entity.label]));
|
|
60229
|
-
|
|
60703
|
+
note2(`Applying the model \u2014 ${summarizeModel(model)}\u2026`);
|
|
60230
60704
|
const attaches = modelRowDocuments(model.contract, model.rows).some((document2) => document2.kind === "path");
|
|
60231
60705
|
const known = attaches ? (await client.getModelBinding()).documents : {};
|
|
60232
60706
|
const uploaded = await uploadModelDocuments(client, model, path9.dirname(path9.resolve(options.file)), known);
|
|
@@ -60236,16 +60710,16 @@ async function applyModel(client, model, options) {
|
|
|
60236
60710
|
const result = await postModel(client, { ...model, rows: uploaded.rows }, options);
|
|
60237
60711
|
const width = Math.max(...result.entities.map((entity) => entity.alias.length), 0);
|
|
60238
60712
|
for (const entity of result.entities) {
|
|
60239
|
-
|
|
60713
|
+
note2(
|
|
60240
60714
|
` ${entity.created ? "created" : "adopted"} ${entity.alias.padEnd(width)} ` + `${entity.table_id} ${labels2.get(entity.alias) ?? ""}`.trimEnd() + scaffoldDelta(entity) + boundByNote(entity.bound_by)
|
|
60241
60715
|
);
|
|
60242
60716
|
}
|
|
60243
60717
|
for (const role of result.roles) {
|
|
60244
|
-
|
|
60718
|
+
note2(` ${role.created ? "created" : "adopted"} role ${role.alias.padEnd(width)} ${role.group_id}`);
|
|
60245
60719
|
}
|
|
60246
60720
|
for (const [alias2, ids] of Object.entries(result.record_ids)) {
|
|
60247
60721
|
if (ids.length === 0) continue;
|
|
60248
|
-
|
|
60722
|
+
note2(` rows ${alias2.padEnd(width)} ${ids.length}`);
|
|
60249
60723
|
}
|
|
60250
60724
|
for (const alias2 of result.carried_rules ?? []) {
|
|
60251
60725
|
warn(` kept ${labels2.get(alias2) ?? alias2}'s own row rule (differs from the model) \u2014 every door serves the table's, not this file's`);
|
|
@@ -60291,7 +60765,7 @@ ${count(drifted.length, "name")} ${drifted.length === 1 ? "has" : "have"} moved
|
|
|
60291
60765
|
const applied = await applyModelPackages(client, model.apply);
|
|
60292
60766
|
const created = result.entities.filter((entity) => entity.created).length;
|
|
60293
60767
|
const apps = applied.flatMap((entry) => entry.apps);
|
|
60294
|
-
|
|
60768
|
+
note2(
|
|
60295
60769
|
`
|
|
60296
60770
|
${created === result.entities.length ? "Done" : `Done \u2014 ${created} of ${result.entities.length} tables created, the rest adopted`}.
|
|
60297
60771
|
Everything here is ordinary: edit the tables, add fields, delete what you do not want.
|
|
@@ -60359,7 +60833,7 @@ function aggregateRows(rows, aggregate) {
|
|
|
60359
60833
|
}
|
|
60360
60834
|
|
|
60361
60835
|
// ../shared/src/schemas/app_spec.ts
|
|
60362
|
-
var APP_SPEC_VERSION =
|
|
60836
|
+
var APP_SPEC_VERSION = 4;
|
|
60363
60837
|
var specTableIdSchema = zod_default.string().refine(isTableId, "a table must be a live table id (`tbl_\u2026`, or an older table's UUID), not a model alias");
|
|
60364
60838
|
var specFieldIdSchema = zod_default.string().refine(isStoredKey, "a field must be a live field key, not a label");
|
|
60365
60839
|
var specOptionIdSchema = zod_default.string().refine((id) => id.startsWith("opt_") && isStoredKey(id), "an option must be a live `opt_` key, not a model alias");
|
|
@@ -60368,7 +60842,9 @@ var specReadSchema = zod_default.object({
|
|
|
60368
60842
|
query: zod_default.string().min(1),
|
|
60369
60843
|
param: zod_default.string().min(1).optional()
|
|
60370
60844
|
}).strict();
|
|
60371
|
-
var
|
|
60845
|
+
var specOptionsHeldSchema = zod_default.object({ field: specColumnAliasSchema, options: zod_default.array(specOptionIdSchema).min(1) }).strict();
|
|
60846
|
+
var specConditionSchema = zod_default.union([specOptionsHeldSchema, zod_default.object({ field: specColumnAliasSchema, value: zod_default.boolean() }).strict()]);
|
|
60847
|
+
var specWhenSchema = zod_default.array(specConditionSchema);
|
|
60372
60848
|
var specFieldTypeSchema = zod_default.enum([
|
|
60373
60849
|
"text",
|
|
60374
60850
|
"number",
|
|
@@ -60411,6 +60887,9 @@ var specFieldSchema = zod_default.object({
|
|
|
60411
60887
|
/** A rollup of the model sums it: an amount that adds up, so a column of it totals at the foot — a
|
|
60412
60888
|
* price or a rate, which nothing sums, has none. */
|
|
60413
60889
|
summed: zod_default.literal(true).optional(),
|
|
60890
|
+
/** What an add starts at where the model says it is certain: a date at today (a moment at now), a
|
|
60891
|
+
* member at the reader adding the row. */
|
|
60892
|
+
starts: zod_default.enum(["today", "me"]).optional(),
|
|
60414
60893
|
/** What an add starts from; the workspace fills it where a create leaves the field out. A select's
|
|
60415
60894
|
* is its option ids. */
|
|
60416
60895
|
default: zod_default.union([zod_default.string(), zod_default.number(), zod_default.boolean(), zod_default.array(specOptionIdSchema)]).optional(),
|
|
@@ -60420,6 +60899,10 @@ var specFieldSchema = zod_default.object({
|
|
|
60420
60899
|
link: contractAliasSchema.optional(),
|
|
60421
60900
|
/** A single link to a pictured entity: the column its linked row's picture is read under. */
|
|
60422
60901
|
image: specColumnAliasSchema.optional(),
|
|
60902
|
+
/** A link to several rows of a pictured entity (`link`): the read their pictures come through, apart from the
|
|
60903
|
+
* link — a read beside it reaches only its first row. Every row in view hands its linked ids in `param` at
|
|
60904
|
+
* once, and each id answers under `link`'s own `image` and `title`. */
|
|
60905
|
+
pictures: specReadSchema.extend({ param: zod_default.string().min(1) }).strict().optional(),
|
|
60423
60906
|
/** A single link: the linked row's own fields read beside it — its line, its figure and that figure's
|
|
60424
60907
|
* bound, and each value a field of this row starts from — by the linked entity's field alias, each to
|
|
60425
60908
|
* the column it is read under. */
|
|
@@ -60472,10 +60955,6 @@ var specEntitySchema = zod_default.object({
|
|
|
60472
60955
|
ctx.issues.push({ code: "custom", input: ctx.value, path: ["milestones"], message: "a row's stage is its status select or its milestones, never both" });
|
|
60473
60956
|
}
|
|
60474
60957
|
});
|
|
60475
|
-
var specReadingWhereSchema = zod_default.union([
|
|
60476
|
-
zod_default.object({ field: specColumnAliasSchema, options: zod_default.array(specOptionIdSchema).min(1) }).strict(),
|
|
60477
|
-
zod_default.object({ field: specColumnAliasSchema, value: zod_default.boolean() }).strict()
|
|
60478
|
-
]);
|
|
60479
60958
|
var specAggregateSchema = zod_default.object({
|
|
60480
60959
|
by: zod_default.array(specColumnAliasSchema).min(1).max(AGGREGATE_MAX_BY).optional(),
|
|
60481
60960
|
day: specColumnAliasSchema.optional(),
|
|
@@ -60494,7 +60973,7 @@ var readingBase = {
|
|
|
60494
60973
|
*/
|
|
60495
60974
|
read: specReadSchema.extend({ aggregate: specAggregateSchema.optional() }).strict(),
|
|
60496
60975
|
/** The conditions it keeps rows by — pressed, each is the filter it adds. */
|
|
60497
|
-
where:
|
|
60976
|
+
where: specWhenSchema.optional()
|
|
60498
60977
|
};
|
|
60499
60978
|
var specReadingSchema = zod_default.discriminatedUnion("kind", [
|
|
60500
60979
|
zod_default.object({
|
|
@@ -60542,6 +61021,8 @@ var specRegisterSchema = zod_default.object({
|
|
|
60542
61021
|
/** The columns the search box matches, a link by its display — each line of a pasted list on its own. Absent, the list read's `search` param. */
|
|
60543
61022
|
search: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
60544
61023
|
sort: zod_default.object({ field: specColumnAliasSchema, desc: zod_default.literal(true).optional() }).strict().optional(),
|
|
61024
|
+
/** The column the rows open grouped by — the reader's grouping starts at it and may change it. */
|
|
61025
|
+
group: specColumnAliasSchema.optional(),
|
|
60545
61026
|
layout: zod_default.enum(["table", "cards", "calendar"]),
|
|
60546
61027
|
/** What an add from the register asks; absent, rows are not added here. */
|
|
60547
61028
|
create: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
@@ -60593,7 +61074,46 @@ var specBlockSchema = zod_default.discriminatedUnion("kind", [
|
|
|
60593
61074
|
of: specColumnAliasSchema.optional(),
|
|
60594
61075
|
/** The child's selects each row here holds one of these options of: its read keeps only them, and a
|
|
60595
61076
|
* row added here is written holding them. */
|
|
60596
|
-
where:
|
|
61077
|
+
where: zod_default.array(specOptionsHeldSchema).optional(),
|
|
61078
|
+
/** The document the lines stand under, each a heading over the lines it covers. A document is made here
|
|
61079
|
+
* where `entities[entity].create` is: the lines picked are sent as `back`'s pieces added, the record in
|
|
61080
|
+
* `via`, and `where` as it states — its other fields are the form's. */
|
|
61081
|
+
under: zod_default.object({
|
|
61082
|
+
/** The line's one-link to the document. */
|
|
61083
|
+
field: specColumnAliasSchema,
|
|
61084
|
+
/** The document's entity — a key of `entities`. */
|
|
61085
|
+
entity: contractAliasSchema,
|
|
61086
|
+
/** The document's one-link to the record. */
|
|
61087
|
+
via: specColumnAliasSchema,
|
|
61088
|
+
/** The document's many-link to the lines it covers. */
|
|
61089
|
+
back: specColumnAliasSchema,
|
|
61090
|
+
/** The documents under one record; `param` takes the record's id. */
|
|
61091
|
+
read: specReadSchema,
|
|
61092
|
+
/** The document's selects a document made here holds, as its lines hold them. */
|
|
61093
|
+
where: zod_default.array(specOptionsHeldSchema).optional()
|
|
61094
|
+
}).strict().optional(),
|
|
61095
|
+
when: specWhenSchema.optional()
|
|
61096
|
+
}).strict(),
|
|
61097
|
+
/** Planned entries of a child entity under the record, by day, soonest first. */
|
|
61098
|
+
zod_default.object({
|
|
61099
|
+
kind: zod_default.literal("agenda"),
|
|
61100
|
+
entity: contractAliasSchema,
|
|
61101
|
+
via: specColumnAliasSchema,
|
|
61102
|
+
/** The child's rows under one record; `param` takes the record's id. */
|
|
61103
|
+
read: specReadSchema,
|
|
61104
|
+
title: zod_default.string().min(1),
|
|
61105
|
+
/** The facts on an entry's line, after its title. */
|
|
61106
|
+
columns: zod_default.array(specColumnAliasSchema),
|
|
61107
|
+
/** The child's date an entry stands on. */
|
|
61108
|
+
day: specColumnAliasSchema,
|
|
61109
|
+
/** A datetime ordering a day's entries — the day itself where it holds the hour. */
|
|
61110
|
+
time: specColumnAliasSchema.optional(),
|
|
61111
|
+
/** Else a single select ordering them: its options in the day's order. */
|
|
61112
|
+
part: specColumnAliasSchema.optional(),
|
|
61113
|
+
/** The record's date that is the first day: each day's heading counts from it. */
|
|
61114
|
+
start: specColumnAliasSchema.optional(),
|
|
61115
|
+
/** What an add asks, the link to the record set for it; absent, entries are not added here. */
|
|
61116
|
+
create: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
60597
61117
|
when: specWhenSchema.optional()
|
|
60598
61118
|
}).strict(),
|
|
60599
61119
|
specTimelineBlockSchema,
|
|
@@ -60631,10 +61151,16 @@ var specRecordSchema = zod_default.object({
|
|
|
60631
61151
|
sections: zod_default.array(specSectionSchema),
|
|
60632
61152
|
/** The record's comment thread stands beside its sections. */
|
|
60633
61153
|
comments: zod_default.literal(true).optional(),
|
|
60634
|
-
/** The record's status history
|
|
60635
|
-
history:
|
|
61154
|
+
/** The record's status history is drawn beside its sections with the thread — never one of them. */
|
|
61155
|
+
history: zod_default.literal(true).optional(),
|
|
61156
|
+
/** The moves of the record's status history, read wherever the entity has one — the steps the record went
|
|
61157
|
+
* through and how long its status has held, drawn or not; `history` draws them. */
|
|
61158
|
+
moves: specTimelineBlockSchema.optional()
|
|
60636
61159
|
}).strict().check((ctx) => {
|
|
60637
|
-
const { door, sections } = ctx.value;
|
|
61160
|
+
const { door, sections, history, moves } = ctx.value;
|
|
61161
|
+
if (history === true && moves === void 0) {
|
|
61162
|
+
ctx.issues.push({ code: "custom", input: history, path: ["history"], message: "draws a status history it does not read \u2014 `moves` is the read" });
|
|
61163
|
+
}
|
|
60638
61164
|
const seen = /* @__PURE__ */ new Set();
|
|
60639
61165
|
sections.forEach((section, index) => {
|
|
60640
61166
|
const page = section.page;
|
|
@@ -60737,12 +61263,15 @@ function specQueryAliases(file2) {
|
|
|
60737
61263
|
...Object.values(file2.entities).flatMap((entity) => [
|
|
60738
61264
|
entity.list.query,
|
|
60739
61265
|
...entity.one === void 0 ? [] : [entity.one.query],
|
|
60740
|
-
...Object.values(entity.fields).flatMap((field2) => field2.pick === void 0 ? [] : [field2.pick.query])
|
|
61266
|
+
...Object.values(entity.fields).flatMap((field2) => [...field2.pick === void 0 ? [] : [field2.pick.query], ...field2.pictures === void 0 ? [] : [field2.pictures.query]])
|
|
60741
61267
|
]),
|
|
60742
61268
|
...specReadings(file2).map((reading) => reading.read.query),
|
|
60743
61269
|
...isDashboardSpec(file2) ? [] : [
|
|
60744
|
-
...specBlocks(file2).flatMap((block2) =>
|
|
60745
|
-
|
|
61270
|
+
...specBlocks(file2).flatMap((block2) => [
|
|
61271
|
+
...block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline" ? [block2.read.query] : [],
|
|
61272
|
+
...block2.kind === "rows" && block2.under !== void 0 ? [block2.under.read.query] : []
|
|
61273
|
+
]),
|
|
61274
|
+
...file2.record.moves === void 0 ? [] : [file2.record.moves.read.query],
|
|
60746
61275
|
...file2.scope === void 0 ? [] : [file2.scope.counts.query]
|
|
60747
61276
|
]
|
|
60748
61277
|
];
|
|
@@ -61969,7 +62498,7 @@ async function runAppTypecheck(projectDir) {
|
|
|
61969
62498
|
warn("package.json has no `typecheck` script \u2014 the app's types were not checked.");
|
|
61970
62499
|
return;
|
|
61971
62500
|
}
|
|
61972
|
-
|
|
62501
|
+
note2("Typechecking...");
|
|
61973
62502
|
await runNpm(["run", "typecheck"], projectDir);
|
|
61974
62503
|
}
|
|
61975
62504
|
function npmInstallArgs(hasLockfile, specs = []) {
|
|
@@ -61978,7 +62507,7 @@ function npmInstallArgs(hasLockfile, specs = []) {
|
|
|
61978
62507
|
}
|
|
61979
62508
|
function runNpmInstall(cwd, specs = []) {
|
|
61980
62509
|
const hasLockfile = fs11.existsSync(path12.join(cwd, "package-lock.json"));
|
|
61981
|
-
|
|
62510
|
+
note2(
|
|
61982
62511
|
specs.length > 0 ? `Installing ${specs.join(" ")} (resolving the rest of the tree, no install scripts)...` : hasLockfile ? "Installing npm dependencies (npm ci \u2014 the lockfile's exact tree, no install scripts)..." : "Installing npm dependencies (no lockfile yet \u2014 resolving ranges, no install scripts)..."
|
|
61983
62512
|
);
|
|
61984
62513
|
return runNpm(npmInstallArgs(hasLockfile, specs), cwd);
|
|
@@ -65354,7 +65883,7 @@ function stateRanges(projectDir, ranges) {
|
|
|
65354
65883
|
}
|
|
65355
65884
|
function noteMigration(migrated) {
|
|
65356
65885
|
if (migrated === null) return;
|
|
65357
|
-
|
|
65886
|
+
note2(
|
|
65358
65887
|
`Migrated from ${FOLDED_SDK_MOVE}: dropped the dependency` + (migrated.length === 0 ? "; no file named it." : ` and rewrote ${migrated.join(", ")}.`)
|
|
65359
65888
|
);
|
|
65360
65889
|
}
|
|
@@ -65422,14 +65951,14 @@ async function appKit(args) {
|
|
|
65422
65951
|
const prior = record2?.replaced?.runtime ?? null;
|
|
65423
65952
|
const listedUi = dependenciesOf(readManifest2(projectDir))[KIT_UI] ?? null;
|
|
65424
65953
|
const ranges = prior === null ? await requireKitRanges() : { runtime: prior, ui: record2?.replaced?.ui ?? listedUi };
|
|
65425
|
-
|
|
65954
|
+
note2(
|
|
65426
65955
|
prior !== null ? `Restoring ${KIT_PACKAGE} to ${prior}, the range this app listed before the checkout went in\u2026` : record2 === void 0 ? `Moving ${KIT_PACKAGE} to the published ${ranges.runtime}\u2026` : `Restoring ${KIT_PACKAGE} to the published ${ranges.runtime} \u2014 the checkout's record names no range this app listed before it\u2026`
|
|
65427
65956
|
);
|
|
65428
65957
|
unpinLocalKit(projectDir, KIT_PACKAGE, ranges.runtime);
|
|
65429
65958
|
const migrated2 = stateRanges(projectDir, ranges);
|
|
65430
65959
|
await runNpmInstall(projectDir, [`${KIT_PACKAGE}@${ranges.runtime}`]);
|
|
65431
65960
|
noteMigration(migrated2);
|
|
65432
|
-
|
|
65961
|
+
note2(`${KIT_PACKAGE} is ${ranges.runtime}.`);
|
|
65433
65962
|
return;
|
|
65434
65963
|
}
|
|
65435
65964
|
const packageDir = path14.resolve(args.packagePath);
|
|
@@ -65463,7 +65992,7 @@ async function appKit(args) {
|
|
|
65463
65992
|
}
|
|
65464
65993
|
});
|
|
65465
65994
|
noteMigration(migrated);
|
|
65466
|
-
|
|
65995
|
+
note2(
|
|
65467
65996
|
`${name}@${packed.version} installed from ${packageDir} (${witness} matches).
|
|
65468
65997
|
'lotics app deploy' refuses this app until the kit is published, or takes --allow-local-kit.`
|
|
65469
65998
|
);
|
|
@@ -65471,7 +66000,7 @@ async function appKit(args) {
|
|
|
65471
66000
|
async function packCheckout(packageDir, kitDir, accepted = [KIT_PACKAGE]) {
|
|
65472
66001
|
const { name, version: version2, files, builds } = kitPackageAt(packageDir, accepted);
|
|
65473
66002
|
if (builds) {
|
|
65474
|
-
|
|
66003
|
+
note2(`Building ${name} in ${packageDir}\u2026`);
|
|
65475
66004
|
try {
|
|
65476
66005
|
await runNpm(["run", "build"], packageDir);
|
|
65477
66006
|
} catch (error52) {
|
|
@@ -65487,7 +66016,7 @@ async function packCheckout(packageDir, kitDir, accepted = [KIT_PACKAGE]) {
|
|
|
65487
66016
|
for (const entry of fs13.readdirSync(kitDir)) {
|
|
65488
66017
|
if (entry.startsWith(prefix) && entry.endsWith(".tgz")) fs13.rmSync(path14.join(kitDir, entry), { force: true });
|
|
65489
66018
|
}
|
|
65490
|
-
|
|
66019
|
+
note2(`Packing ${name}@${version2}\u2026`);
|
|
65491
66020
|
const listing = packListingSchema.parse(JSON.parse(await npmOutput(["pack", "--json", "--ignore-scripts", "--pack-destination", kitDir], packageDir)));
|
|
65492
66021
|
const tarball = tarballName(name, version2);
|
|
65493
66022
|
if (!fs13.existsSync(path14.join(kitDir, tarball))) {
|
|
@@ -74222,7 +74751,7 @@ async function writeGeneratedAppFields(client, projectDir, app, manifest) {
|
|
|
74222
74751
|
}
|
|
74223
74752
|
const groups = await client.getMemberGroups();
|
|
74224
74753
|
const fieldsPath = writeAppFields(projectDir, tables, groups);
|
|
74225
|
-
|
|
74754
|
+
note2(
|
|
74226
74755
|
`Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"}, ${groups.length} group${groups.length === 1 ? "" : "s"})`
|
|
74227
74756
|
);
|
|
74228
74757
|
} catch (err) {
|
|
@@ -74247,7 +74776,7 @@ async function writeAppSiblings(client, projectDir) {
|
|
|
74247
74776
|
}
|
|
74248
74777
|
fs16.mkdirSync(path17.dirname(file2), { recursive: true });
|
|
74249
74778
|
fs16.writeFileSync(file2, generateAppSiblings(apps));
|
|
74250
|
-
|
|
74779
|
+
note2(`Wrote ${file2} (${apps.size} app${apps.size === 1 ? "" : "s"} of this workspace's model)`);
|
|
74251
74780
|
}
|
|
74252
74781
|
function warnAppFieldsUnwritten(projectDir, err) {
|
|
74253
74782
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -74443,7 +74972,7 @@ async function hydrateAppProject(client, targetPath, app, opts) {
|
|
|
74443
74972
|
);
|
|
74444
74973
|
opts.kept.push(...kept);
|
|
74445
74974
|
if (written.length > 0) {
|
|
74446
|
-
|
|
74975
|
+
note2(
|
|
74447
74976
|
`Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`
|
|
74448
74977
|
);
|
|
74449
74978
|
}
|
|
@@ -74476,7 +75005,7 @@ async function hydrateAppProject(client, targetPath, app, opts) {
|
|
|
74476
75005
|
unseenLiveProse.push({ alias: alias2, parked: parkLiveAgentProse(targetPath, alias2, instructions) });
|
|
74477
75006
|
}
|
|
74478
75007
|
if (written.length > 0) {
|
|
74479
|
-
|
|
75008
|
+
note2(
|
|
74480
75009
|
`Wrote ${written.length} agent instruction ${written.length === 1 ? "file" : "files"} to ${AGENTS_DIR}/ (${written.join(", ")})`
|
|
74481
75010
|
);
|
|
74482
75011
|
}
|
|
@@ -77258,7 +77787,7 @@ async function appDeploy(client, args) {
|
|
|
77258
77787
|
);
|
|
77259
77788
|
}
|
|
77260
77789
|
await runAppTypecheck(projectDir);
|
|
77261
|
-
|
|
77790
|
+
note2("Building...");
|
|
77262
77791
|
await runNpm(["run", "build"], projectDir);
|
|
77263
77792
|
const distDir = path23.join(projectDir, "dist");
|
|
77264
77793
|
if (!fs23.existsSync(distDir)) {
|
|
@@ -77269,15 +77798,15 @@ async function appDeploy(client, args) {
|
|
|
77269
77798
|
const tmpSource = path23.join(tmpdir2(), `lotics-source-${Date.now()}.tar.gz`);
|
|
77270
77799
|
const tmpDist = path23.join(tmpdir2(), `lotics-dist-${Date.now()}.tar.gz`);
|
|
77271
77800
|
try {
|
|
77272
|
-
|
|
77801
|
+
note2("Packaging source...");
|
|
77273
77802
|
await runTar(
|
|
77274
77803
|
["-czf", tmpSource, ...ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`), "."],
|
|
77275
77804
|
projectDir
|
|
77276
77805
|
);
|
|
77277
|
-
|
|
77806
|
+
note2("Packaging dist...");
|
|
77278
77807
|
await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
|
|
77279
77808
|
const liveQueries = await readLiveQueries(client, meta3.app_id);
|
|
77280
|
-
|
|
77809
|
+
note2("Uploading...");
|
|
77281
77810
|
try {
|
|
77282
77811
|
const result = await client.deployAppVersion({
|
|
77283
77812
|
queries: liveQueries,
|
|
@@ -77320,15 +77849,15 @@ async function appDeploy(client, args) {
|
|
|
77320
77849
|
// below. Omitting it preserves the previous set, which is what keeps an
|
|
77321
77850
|
// unresolved orphan reportable on the next run.
|
|
77322
77851
|
});
|
|
77323
|
-
|
|
77324
|
-
|
|
77325
|
-
if (result.origin !== void 0)
|
|
77852
|
+
note2(`Deployed v${result.version_number} (${result.version_id})`);
|
|
77853
|
+
note2(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
|
|
77854
|
+
if (result.origin !== void 0) note2(`Address: ${result.origin}`);
|
|
77326
77855
|
try {
|
|
77327
77856
|
const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
|
|
77328
77857
|
for (const { alias: alias2, declaration, hadTypes } of drifted) {
|
|
77329
77858
|
const refreshed = await refreshWorkflowTypes(client, projectDir, meta3.app_id, alias2, declaration);
|
|
77330
77859
|
if (refreshed.kind !== "refreshed") continue;
|
|
77331
|
-
|
|
77860
|
+
note2(
|
|
77332
77861
|
hadTypes ? `Refreshed workflow types for ${alias2} \u2014 this deploy moved its declaration.` : `Wrote workflow types for ${alias2} \u2014 this project had none.`
|
|
77333
77862
|
);
|
|
77334
77863
|
}
|
|
@@ -77389,7 +77918,7 @@ Unbinding ${retired.queries.length + retired.workflows.length} binding(s) the pl
|
|
|
77389
77918
|
let outstanding = orphans;
|
|
77390
77919
|
if (args.prune) {
|
|
77391
77920
|
if (!meta3.bundle_calls && nothingOrphaned(undeclared2)) {
|
|
77392
|
-
|
|
77921
|
+
note2(
|
|
77393
77922
|
"Nothing to prune yet: this project has no record of what the previous bundle called, so there is no way to tell a retired binding from an agent-facing one. This deploy records it; the next can compare."
|
|
77394
77923
|
);
|
|
77395
77924
|
}
|
|
@@ -77682,6 +78211,9 @@ function pickQueryAlias(entity, field2) {
|
|
|
77682
78211
|
function readingQueryAlias(place, index) {
|
|
77683
78212
|
return `${place}_reading_${index}`;
|
|
77684
78213
|
}
|
|
78214
|
+
function picturesQueryAlias(entity) {
|
|
78215
|
+
return `${entity}_pictures`;
|
|
78216
|
+
}
|
|
77685
78217
|
function folderCountsQueryAlias(entity, scope) {
|
|
77686
78218
|
return `${entity}_count_by_${scope}`;
|
|
77687
78219
|
}
|
|
@@ -77695,6 +78227,9 @@ var SEARCH_PARAM = "search";
|
|
|
77695
78227
|
function recordParam(entity) {
|
|
77696
78228
|
return `${entity}_id`;
|
|
77697
78229
|
}
|
|
78230
|
+
function recordsParam(entity) {
|
|
78231
|
+
return `${entity}_ids`;
|
|
78232
|
+
}
|
|
77698
78233
|
function createAlias(entity) {
|
|
77699
78234
|
return `create_${entity}`;
|
|
77700
78235
|
}
|
|
@@ -78089,7 +78624,7 @@ function appendHistory(history, record2, moved, copies = []) {
|
|
|
78089
78624
|
];
|
|
78090
78625
|
}
|
|
78091
78626
|
function milestoneOrder(stages, touches, left, words, landing) {
|
|
78092
|
-
const applies = (stage) => stage.when.map((gate) => `includes([${gate.options.map(str).join(", ")}], ${left(gate.field)})`);
|
|
78627
|
+
const applies = (stage) => stage.when.map((gate) => "value" in gate ? `${left(gate.field)} ${gate.value ? "==" : "!="} true` : `includes([${gate.options.map(str).join(", ")}], ${left(gate.field)})`);
|
|
78093
78628
|
const dated = (stage) => left(stage.field) === "now()" ? "true" : `!(${heldEmpty(stage.field, left(stage.field))})`;
|
|
78094
78629
|
return validateBlock(
|
|
78095
78630
|
stages.flatMap(
|
|
@@ -78197,7 +78732,7 @@ function actGates(plan) {
|
|
|
78197
78732
|
...scopeGuard(plan.table, "row", words),
|
|
78198
78733
|
...validateBlock([
|
|
78199
78734
|
...plan.when.map(
|
|
78200
|
-
(gate) => checkLine(
|
|
78735
|
+
(gate) => "value" in gate ? checkLine(`row.data[${str(gate.field.id)}] ${gate.value ? "!=" : "=="} true`, void 0, str(words.refusals.notWhile(plan.label, gate.label, gate.value))) : checkLine(
|
|
78201
78736
|
`!includes([${gate.options.map(str).join(", ")}], row.data[${str(gate.field.id)}])`,
|
|
78202
78737
|
void 0,
|
|
78203
78738
|
str(words.refusals.notNow(plan.label, gate.label, gate.names))
|
|
@@ -78264,7 +78799,8 @@ function actHistory(plan, record2) {
|
|
|
78264
78799
|
}
|
|
78265
78800
|
function actRuled(plan) {
|
|
78266
78801
|
const { rules } = plan;
|
|
78267
|
-
|
|
78802
|
+
const cleared = new Set(plan.set.filter((one) => one.value === "null").map((one) => one.field.id));
|
|
78803
|
+
return rules.written.some((one) => !cleared.has(one.field.id) && (one.min?.kind === "value" || one.max?.kind === "value" || (one.link?.same.length ?? 0) > 0)) || rules.bounded.length > 0 || rules.held.length > 0 || rules.kept.length > 0 || rules.strays.length > 0 || rules.overlap !== void 0;
|
|
78268
78804
|
}
|
|
78269
78805
|
function actLeaves(plan) {
|
|
78270
78806
|
if (!actRuled(plan)) return [];
|
|
@@ -78407,6 +78943,32 @@ function createValue(field2) {
|
|
|
78407
78943
|
if (field2.field.type === "select_record_link") return editedByDelta(field2.field) ? `isEmpty(${handed}) ? null : ${handed}` : `isNull(${handed}) ? null : [${handed}]`;
|
|
78408
78944
|
return handed;
|
|
78409
78945
|
}
|
|
78946
|
+
function optionAsOwn(theirs, own, value) {
|
|
78947
|
+
const pairs = [...theirs.options ?? []].flatMap(([alias2, id]) => {
|
|
78948
|
+
const to = own.options?.get(alias2);
|
|
78949
|
+
return to === void 0 ? [] : [`${value} == ${str(id)} ? ${str(to)} : `];
|
|
78950
|
+
});
|
|
78951
|
+
return `(${pairs.join("")}null)`;
|
|
78952
|
+
}
|
|
78953
|
+
function coversGuard(covers, record2, words) {
|
|
78954
|
+
const sent = deltaOf(covers.back).added;
|
|
78955
|
+
const cell = (field2) => `coveredLine.data[${str(field2.id)}]`;
|
|
78956
|
+
return [
|
|
78957
|
+
...validateBlock([checkLine(`isEmpty(i.${sent})`, sent, str(words.refusals.required(covers.back.field.label)))]),
|
|
78958
|
+
`for (const picked of i.${sent}) {`,
|
|
78959
|
+
...indent([
|
|
78960
|
+
`const coveredLine = await get_record({ table_id: ${str(covers.table.id)}, record_id: picked });`,
|
|
78961
|
+
...scopeGuard(covers.table, "coveredLine", words),
|
|
78962
|
+
...validateBlock([
|
|
78963
|
+
checkLine(`!includes(coalesce(${cell(covers.via)}, []), ${record2})`, sent, str(words.refusals.notUnder(covers.line, covers.record))),
|
|
78964
|
+
checkLine(`!isEmpty(${cell(covers.under)})`, sent, str(words.refusals.covered(covers.line, covers.document))),
|
|
78965
|
+
...covers.held.map((pair) => checkLine(`${optionAsOwn(pair.theirs, pair.own, cell(pair.theirs))} != i.${pair.own.alias}`, pair.own.alias, str(words.refusals.mismatched(covers.line, pair.own.field.label))))
|
|
78966
|
+
]),
|
|
78967
|
+
...checksGuard(covers.checks, "coveredLine")
|
|
78968
|
+
]),
|
|
78969
|
+
`}`
|
|
78970
|
+
];
|
|
78971
|
+
}
|
|
78410
78972
|
function createSource(plan) {
|
|
78411
78973
|
const { words } = plan;
|
|
78412
78974
|
const handedFields = [...plan.fields.map((one) => one.field), ...plan.parent === void 0 ? [] : [plan.parent.field]];
|
|
@@ -78461,6 +79023,7 @@ function createSource(plan) {
|
|
|
78461
79023
|
);
|
|
78462
79024
|
}
|
|
78463
79025
|
lines.push(...linkChecks(plan.fields, words, (field2) => createdInput(field2)));
|
|
79026
|
+
if (plan.covers !== void 0 && plan.parent !== void 0) lines.push(...coversGuard(plan.covers, `i.${plan.parent.field.alias}`, words));
|
|
78464
79027
|
const copied = /* @__PURE__ */ new Map();
|
|
78465
79028
|
for (const copy of plan.copies) {
|
|
78466
79029
|
const local = `${camel(copy.link.alias)}Source`;
|
|
@@ -78685,6 +79248,36 @@ function linkImage(ctx, entity, alias2) {
|
|
|
78685
79248
|
if (image === void 0 || !ctx.drawn.has(target)) return void 0;
|
|
78686
79249
|
return { column: linkImageColumn(alias2), link: link.id, image: fieldOf2(ctx, target, image).id };
|
|
78687
79250
|
}
|
|
79251
|
+
function picturedTarget(ctx, target) {
|
|
79252
|
+
return recordDisplay(ctx.model, target)?.image !== void 0 && ctx.drawn.has(target);
|
|
79253
|
+
}
|
|
79254
|
+
function linkPictures(ctx, entity, alias2) {
|
|
79255
|
+
const link = fieldOf2(ctx, entity, alias2);
|
|
79256
|
+
if (link.field.type !== "select_record_link" || !fieldHoldsSeveral(link.field)) return void 0;
|
|
79257
|
+
const target = link.field.target_entity;
|
|
79258
|
+
return picturedTarget(ctx, target) ? { query: picturesQueryAlias(target), param: recordsParam(target) } : void 0;
|
|
79259
|
+
}
|
|
79260
|
+
function picturesRead(ctx, target) {
|
|
79261
|
+
const query = picturesQueryAlias(target);
|
|
79262
|
+
const linked = [...ctx.drawn].some(([entity, fields]) => fields.some((alias2) => linkPictures(ctx, entity, alias2)?.query === query));
|
|
79263
|
+
const display = recordDisplay(ctx.model, target);
|
|
79264
|
+
if (!linked || display?.image === void 0) return {};
|
|
79265
|
+
const bent = entityOf3(ctx, target);
|
|
79266
|
+
const param = recordsParam(target);
|
|
79267
|
+
const ids = { node_type: "condition", type: "record_id", operator: "is_any_of", value: `{{params.${param}}}` };
|
|
79268
|
+
const filter2 = withScope(ctx, target, ids);
|
|
79269
|
+
return {
|
|
79270
|
+
[query]: {
|
|
79271
|
+
ast: {
|
|
79272
|
+
kind: "project",
|
|
79273
|
+
from: { kind: "from_table", table_id: bent.table, ...filter2 === void 0 ? {} : { filter: filter2 } },
|
|
79274
|
+
columns: [.../* @__PURE__ */ new Set([display.title, display.image])].map((alias2) => ({ source: fieldOf2(ctx, target, alias2).id, output: alias2 }))
|
|
79275
|
+
},
|
|
79276
|
+
params: { [param]: { type: "array", items: { type: "record_link", table_id: bent.table }, required: true } },
|
|
79277
|
+
description: ctx.words.queries.pictures(bent.entity.label)
|
|
79278
|
+
}
|
|
79279
|
+
};
|
|
79280
|
+
}
|
|
78688
79281
|
var FACE_TYPES = { text: "text", autonumber: "text", number: "number", date: "date", boolean: "boolean", select: "select" };
|
|
78689
79282
|
function linkFace(ctx, entity, alias2) {
|
|
78690
79283
|
const link = fieldOf2(ctx, entity, alias2);
|
|
@@ -78797,7 +79390,8 @@ function entityReads(ctx, alias2) {
|
|
|
78797
79390
|
ast: { kind: "project", from: { kind: "from_table", table_id: bent.table, ...oneFilter === void 0 ? {} : { filter: oneFilter } }, columns },
|
|
78798
79391
|
params: { [recordParam(alias2)]: { type: "record_link", table_id: bent.table, required: true } },
|
|
78799
79392
|
description: ctx.words.queries.one(bent.entity.label)
|
|
78800
|
-
}
|
|
79393
|
+
},
|
|
79394
|
+
...picturesRead(ctx, alias2)
|
|
78801
79395
|
};
|
|
78802
79396
|
}
|
|
78803
79397
|
function folderCounts(ctx) {
|
|
@@ -79114,7 +79708,14 @@ function parentLocks(ctx, alias2) {
|
|
|
79114
79708
|
if (alias2 === ctx.app.entity) return [];
|
|
79115
79709
|
const checks = bodyChecks(ctx, "edit");
|
|
79116
79710
|
if (checks.length === 0) return [];
|
|
79117
|
-
const vias = [
|
|
79711
|
+
const vias = [
|
|
79712
|
+
...new Set(
|
|
79713
|
+
resolvedBlocks(ctx.app.record).flatMap((block2) => [
|
|
79714
|
+
...(block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline") && block2.entity === alias2 ? [block2.via] : [],
|
|
79715
|
+
...block2.kind === "rows" && block2.under?.entity === alias2 ? [block2.under.via] : []
|
|
79716
|
+
])
|
|
79717
|
+
)
|
|
79718
|
+
];
|
|
79118
79719
|
const label2 = entityOf3(ctx, ctx.app.entity).entity.label;
|
|
79119
79720
|
return vias.map((via) => ({ link: fieldOf2(ctx, alias2, via), label: label2, checks }));
|
|
79120
79721
|
}
|
|
@@ -79124,9 +79725,9 @@ function bodyMilestones(ctx, entity) {
|
|
|
79124
79725
|
return {
|
|
79125
79726
|
field: field2,
|
|
79126
79727
|
label: field2.field.label,
|
|
79127
|
-
when:
|
|
79128
|
-
const
|
|
79129
|
-
return { field:
|
|
79728
|
+
when: whenConditions(stage.when).map((gate) => {
|
|
79729
|
+
const bound = fieldOf2(ctx, entity, gate.field);
|
|
79730
|
+
return "value" in gate ? { field: bound, value: gate.value } : { field: bound, options: gate.options.flatMap((option) => bound.options?.get(option) ?? []) };
|
|
79130
79731
|
})
|
|
79131
79732
|
};
|
|
79132
79733
|
});
|
|
@@ -79162,6 +79763,25 @@ function boundHistory(ctx, entity, shape) {
|
|
|
79162
79763
|
function historyFields(history) {
|
|
79163
79764
|
return [history.link, history.status, ...history.at === void 0 ? [] : [history.at], ...history.by === void 0 ? [] : [history.by]];
|
|
79164
79765
|
}
|
|
79766
|
+
function bodyCovers(ctx, alias2) {
|
|
79767
|
+
const blocks = resolvedBlocks(ctx.app.record).flatMap((block3) => block3.kind === "rows" && block3.under?.entity === alias2 ? [{ block: block3, under: block3.under }] : []);
|
|
79768
|
+
const [first2] = blocks;
|
|
79769
|
+
if (first2 === void 0) return void 0;
|
|
79770
|
+
const { block: block2, under } = first2;
|
|
79771
|
+
const named = (entity) => modelEntity(ctx, entity).singular ?? modelEntity(ctx, entity).label;
|
|
79772
|
+
const held2 = [...new Set(blocks.flatMap((one) => one.under.where.map((where) => where.field)))];
|
|
79773
|
+
return {
|
|
79774
|
+
back: fieldOf2(ctx, alias2, under.back),
|
|
79775
|
+
table: bodyTable(ctx, block2.entity),
|
|
79776
|
+
line: named(block2.entity),
|
|
79777
|
+
record: named(ctx.app.entity),
|
|
79778
|
+
document: named(alias2),
|
|
79779
|
+
via: fieldOf2(ctx, block2.entity, block2.via),
|
|
79780
|
+
under: fieldOf2(ctx, block2.entity, under.field),
|
|
79781
|
+
held: held2.map((name) => ({ own: fieldOf2(ctx, alias2, name), theirs: fieldOf2(ctx, block2.entity, name) })),
|
|
79782
|
+
checks: bodyChecks(ctx, "edit", block2.entity)
|
|
79783
|
+
};
|
|
79784
|
+
}
|
|
79165
79785
|
function createRequired(field2) {
|
|
79166
79786
|
return field2.required === true && !hasDefault(field2);
|
|
79167
79787
|
}
|
|
@@ -79189,6 +79809,7 @@ function createPlan(ctx, alias2, writes) {
|
|
|
79189
79809
|
const unique3 = fields.flatMap((one) => one.field.field.type === "text" && one.field.field.unique === true && !keyed.has(one.field.alias) ? [one.field] : []);
|
|
79190
79810
|
const history = bodyHistory(ctx, alias2);
|
|
79191
79811
|
const overlap = bodyOverlap(ctx, alias2);
|
|
79812
|
+
const covers = bodyCovers(ctx, alias2);
|
|
79192
79813
|
return {
|
|
79193
79814
|
table: bodyTable(ctx, alias2),
|
|
79194
79815
|
fields,
|
|
@@ -79201,6 +79822,7 @@ function createPlan(ctx, alias2, writes) {
|
|
|
79201
79822
|
expects: bodyExpects(ctx, alias2),
|
|
79202
79823
|
...history === void 0 ? {} : { history },
|
|
79203
79824
|
milestones: bodyMilestones(ctx, alias2),
|
|
79825
|
+
...covers === void 0 || !asked.has(covers.back.alias) ? {} : { covers },
|
|
79204
79826
|
words: ctx.words
|
|
79205
79827
|
};
|
|
79206
79828
|
}
|
|
@@ -79298,6 +79920,7 @@ function actPlan(ctx, act) {
|
|
|
79298
79920
|
param: act.on === "record" ? RECORD_INPUT : RECORDS_INPUT,
|
|
79299
79921
|
when: act.when.map((gate) => {
|
|
79300
79922
|
const bf = fieldOf2(ctx, entity, gate.field);
|
|
79923
|
+
if ("value" in gate) return { field: bf, label: bf.field.label, value: gate.value, names: [] };
|
|
79301
79924
|
const declared = bf.field.type === "select" ? bf.field.options : [];
|
|
79302
79925
|
return {
|
|
79303
79926
|
field: bf,
|
|
@@ -79366,9 +79989,10 @@ function createInputs(ctx, alias2, writes) {
|
|
|
79366
79989
|
if (via.field.type !== "select_record_link") fail(`"${alias2}.${via.alias}" files a row under its record, so it must be a link`);
|
|
79367
79990
|
inputs[via.alias] = { type: "record_link", table_id: entityOf3(ctx, via.field.target_entity).table, required: true };
|
|
79368
79991
|
}
|
|
79992
|
+
const lines = bodyCovers(ctx, alias2)?.back.alias;
|
|
79369
79993
|
for (const name of writes.create ?? []) {
|
|
79370
79994
|
const bf = fieldOf2(ctx, alias2, name);
|
|
79371
|
-
const required2 = createRequired(bf.field);
|
|
79995
|
+
const required2 = createRequired(bf.field) || name === lines;
|
|
79372
79996
|
if (editedByDelta(bf.field)) inputs[deltaOf(bf).added] = { ...fieldInput(ctx, bf, required2), multi: true };
|
|
79373
79997
|
else inputs[name] = fieldInput(ctx, bf, required2);
|
|
79374
79998
|
}
|
|
@@ -79484,6 +80108,7 @@ function specField(ctx, entity, alias2, pick3) {
|
|
|
79484
80108
|
const readonly2 = isPlatformWrittenField(field2) || !isAsked(field2) || setBy;
|
|
79485
80109
|
const stated2 = specDefault(field2, bf);
|
|
79486
80110
|
const face = linkImage(ctx, entity, alias2);
|
|
80111
|
+
const pictures = linkPictures(ctx, entity, alias2);
|
|
79487
80112
|
const linked = linkFace(ctx, entity, alias2);
|
|
79488
80113
|
const offered = defaultFrom(ctx, entity, alias2);
|
|
79489
80114
|
return {
|
|
@@ -79503,11 +80128,13 @@ function specField(ctx, entity, alias2, pick3) {
|
|
|
79503
80128
|
...gate === void 0 ? {} : { gate: typeof gate === "number" ? { value: gate } : { field: gate } },
|
|
79504
80129
|
...tolerance === void 0 ? {} : { tolerance: tolerance / 100 },
|
|
79505
80130
|
...(display?.due ?? []).includes(alias2) ? { due: true } : {},
|
|
80131
|
+
...display?.starts?.[alias2] === void 0 ? {} : { starts: display.starts[alias2] },
|
|
79506
80132
|
...isSummed(ctx.model, entity, alias2) ? { summed: true } : {},
|
|
79507
80133
|
...stated2 === void 0 ? {} : { default: stated2 },
|
|
79508
80134
|
...options === void 0 ? {} : { options },
|
|
79509
80135
|
...field2.type === "select_record_link" && ctx.drawn.has(field2.target_entity) ? { link: field2.target_entity } : {},
|
|
79510
80136
|
...face === void 0 ? {} : { image: face.column },
|
|
80137
|
+
...pictures === void 0 ? {} : { pictures },
|
|
79511
80138
|
...linked === void 0 ? {} : { face: Object.fromEntries(linked.columns.map((one) => [one.field, one.column])) },
|
|
79512
80139
|
...offered === void 0 ? {} : { default_from: offered },
|
|
79513
80140
|
...pick3 === void 0 ? {} : { pick: { query: pick3.query, param: SEARCH_PARAM, ...pick3.same.length === 0 ? {} : { same: [...pick3.same] } } }
|
|
@@ -79522,7 +80149,13 @@ function defaultFrom(ctx, entity, alias2) {
|
|
|
79522
80149
|
}
|
|
79523
80150
|
function specWhen(ctx, entity, when) {
|
|
79524
80151
|
if (when.length === 0) return void 0;
|
|
79525
|
-
return when.map((gate) =>
|
|
80152
|
+
return when.map((gate) => "value" in gate ? { field: gate.field, value: gate.value } : optionsHeld(ctx, entity, gate));
|
|
80153
|
+
}
|
|
80154
|
+
function specWhere(ctx, entity, where) {
|
|
80155
|
+
return where.length === 0 ? void 0 : where.map((gate) => optionsHeld(ctx, entity, gate));
|
|
80156
|
+
}
|
|
80157
|
+
function optionsHeld(ctx, entity, gate) {
|
|
80158
|
+
return { field: gate.field, options: gate.options.flatMap((option) => fieldOf2(ctx, entity, gate.field).options?.get(option) ?? []) };
|
|
79526
80159
|
}
|
|
79527
80160
|
function specAt(ctx, entity, at2) {
|
|
79528
80161
|
if (at2.length === 0) return void 0;
|
|
@@ -79559,11 +80192,7 @@ function specEntity(ctx, alias2, picks) {
|
|
|
79559
80192
|
...stages === void 0 ? {} : {
|
|
79560
80193
|
milestones: {
|
|
79561
80194
|
stages: stages.map((stage) => {
|
|
79562
|
-
const when = specWhen(
|
|
79563
|
-
ctx,
|
|
79564
|
-
alias2,
|
|
79565
|
-
Object.entries(stage.when).map(([field2, options]) => ({ field: field2, options }))
|
|
79566
|
-
);
|
|
80195
|
+
const when = specWhen(ctx, alias2, whenConditions(stage.when));
|
|
79567
80196
|
return { field: stage.field, ...when === void 0 ? {} : { when } };
|
|
79568
80197
|
}),
|
|
79569
80198
|
...display.status?.closed === void 0 ? {} : { closed: [...display.status.closed] }
|
|
@@ -79618,7 +80247,6 @@ function specBlock(ctx, block2, index, entities) {
|
|
|
79618
80247
|
return { spec: { kind: "reading", reading: spec, via: block2.via, ...when }, queries: queries2 };
|
|
79619
80248
|
}
|
|
79620
80249
|
const parent = ctx.app.entity;
|
|
79621
|
-
const child = entityOf3(ctx, block2.entity);
|
|
79622
80250
|
const via = fieldOf2(ctx, block2.entity, block2.via);
|
|
79623
80251
|
const where = block2.kind === "rows" ? block2.where : [];
|
|
79624
80252
|
const query = childrenQueryAlias(block2.entity, block2.via, where);
|
|
@@ -79626,23 +80254,33 @@ function specBlock(ctx, block2, index, entities) {
|
|
|
79626
80254
|
const own = { node_type: "condition", type: "select_record_link", field_key: via.id, operator: "has_any_of", value: [`{{params.${param}}}`] };
|
|
79627
80255
|
const filter2 = withScope(ctx, block2.entity, allOf([own, ...whereConditions(ctx, block2.entity, where)]));
|
|
79628
80256
|
const sort = block2.kind === "timeline" ? [{ field_key: fieldOf2(ctx, block2.entity, block2.at).id, order: "desc" }] : rowOrder(ctx, block2.entity);
|
|
79629
|
-
const queries = {
|
|
79630
|
-
[query]: {
|
|
79631
|
-
ast: {
|
|
79632
|
-
kind: "project",
|
|
79633
|
-
from: { kind: "from_table", table_id: child.table, ...sort === void 0 ? {} : { sort }, ...filter2 === void 0 ? {} : { filter: filter2 } },
|
|
79634
|
-
columns: projection(ctx, block2.entity)
|
|
79635
|
-
},
|
|
79636
|
-
params: { [param]: { type: "record_link", table_id: entityOf3(ctx, parent).table, required: true } },
|
|
79637
|
-
description: ctx.words.queries.children(child.entity.label, entityOf3(ctx, parent).entity.label, via.field.label)
|
|
79638
|
-
}
|
|
79639
|
-
};
|
|
80257
|
+
const queries = { [query]: childrenQuery(ctx, block2.entity, block2.via, filter2, sort) };
|
|
79640
80258
|
const create = entities[block2.entity]?.create !== void 0 && ctx.plan.get(block2.entity)?.parent === block2.via ? block2.create : void 0;
|
|
80259
|
+
if (block2.kind === "agenda") {
|
|
80260
|
+
return {
|
|
80261
|
+
spec: {
|
|
80262
|
+
kind: "agenda",
|
|
80263
|
+
entity: block2.entity,
|
|
80264
|
+
via: block2.via,
|
|
80265
|
+
read: { query, param },
|
|
80266
|
+
title: block2.title,
|
|
80267
|
+
columns: [...block2.columns],
|
|
80268
|
+
day: block2.day,
|
|
80269
|
+
...block2.time === void 0 ? {} : { time: block2.time },
|
|
80270
|
+
...block2.part === void 0 ? {} : { part: block2.part },
|
|
80271
|
+
...block2.start === void 0 ? {} : { start: block2.start },
|
|
80272
|
+
...create === void 0 ? {} : { create: [...create] },
|
|
80273
|
+
...when
|
|
80274
|
+
},
|
|
80275
|
+
queries
|
|
80276
|
+
};
|
|
80277
|
+
}
|
|
79641
80278
|
if (block2.kind === "rows") {
|
|
80279
|
+
const under = block2.under === void 0 ? void 0 : specUnder(ctx, block2.under);
|
|
79642
80280
|
const totals = blockTotals(ctx.model, parent, block2.entity, block2.via, block2.columns, block2.where);
|
|
79643
80281
|
const narrower = narrowerTotals(ctx.model, parent, totals, block2.where);
|
|
79644
80282
|
const soonest = blockSoonest(ctx.model, parent, block2.entity, block2.via);
|
|
79645
|
-
const narrowed =
|
|
80283
|
+
const narrowed = specWhere(ctx, block2.entity, block2.where);
|
|
79646
80284
|
return {
|
|
79647
80285
|
spec: {
|
|
79648
80286
|
kind: "rows",
|
|
@@ -79658,9 +80296,10 @@ function specBlock(ctx, block2, index, entities) {
|
|
|
79658
80296
|
...block2.expect === void 0 ? {} : { expect: block2.expect },
|
|
79659
80297
|
...block2.of === void 0 ? {} : { of: block2.of },
|
|
79660
80298
|
...narrowed === void 0 ? {} : { where: narrowed },
|
|
80299
|
+
...under === void 0 ? {} : { under: under.spec },
|
|
79661
80300
|
...when
|
|
79662
80301
|
},
|
|
79663
|
-
queries
|
|
80302
|
+
queries: { ...queries, ...under?.queries }
|
|
79664
80303
|
};
|
|
79665
80304
|
}
|
|
79666
80305
|
return {
|
|
@@ -79678,6 +80317,28 @@ function specBlock(ctx, block2, index, entities) {
|
|
|
79678
80317
|
queries
|
|
79679
80318
|
};
|
|
79680
80319
|
}
|
|
80320
|
+
function childrenQuery(ctx, entity, via, filter2, sort) {
|
|
80321
|
+
const parent = entityOf3(ctx, ctx.app.entity);
|
|
80322
|
+
return {
|
|
80323
|
+
ast: {
|
|
80324
|
+
kind: "project",
|
|
80325
|
+
from: { kind: "from_table", table_id: entityOf3(ctx, entity).table, ...sort === void 0 ? {} : { sort }, ...filter2 === void 0 ? {} : { filter: filter2 } },
|
|
80326
|
+
columns: projection(ctx, entity)
|
|
80327
|
+
},
|
|
80328
|
+
params: { [recordParam(ctx.app.entity)]: { type: "record_link", table_id: parent.table, required: true } },
|
|
80329
|
+
description: ctx.words.queries.children(entityOf3(ctx, entity).entity.label, parent.entity.label, fieldOf2(ctx, entity, via).field.label)
|
|
80330
|
+
};
|
|
80331
|
+
}
|
|
80332
|
+
function specUnder(ctx, under) {
|
|
80333
|
+
const query = childrenQueryAlias(under.entity, under.via, []);
|
|
80334
|
+
const param = recordParam(ctx.app.entity);
|
|
80335
|
+
const own = { node_type: "condition", type: "select_record_link", field_key: fieldOf2(ctx, under.entity, under.via).id, operator: "has_any_of", value: [`{{params.${param}}}`] };
|
|
80336
|
+
const where = specWhere(ctx, under.entity, under.where);
|
|
80337
|
+
return {
|
|
80338
|
+
spec: { field: under.field, entity: under.entity, via: under.via, back: under.back, read: { query, param }, ...where === void 0 ? {} : { where } },
|
|
80339
|
+
queries: { [query]: childrenQuery(ctx, under.entity, under.via, withScope(ctx, under.entity, own), rowOrder(ctx, under.entity)) }
|
|
80340
|
+
};
|
|
80341
|
+
}
|
|
79681
80342
|
function specHistory(ctx, history, entities) {
|
|
79682
80343
|
const { spec, queries } = specBlock(ctx, { kind: "timeline", ...history, when: [] }, 0, entities);
|
|
79683
80344
|
if (spec.kind !== "timeline") return fail(`compileApp: the status history of "${ctx.app.entity}" compiled to a ${spec.kind} block.`);
|
|
@@ -79730,10 +80391,13 @@ var PLAIN = {
|
|
|
79730
80391
|
offered: (target) => `Not one of the ${target.toLowerCase()} this may point at.`,
|
|
79731
80392
|
mismatched: (rows, field2) => `A ${rows.toLowerCase()} linked with this row names another ${field2.toLowerCase()} \u2014 change it first.`,
|
|
79732
80393
|
notNow: (act, field2, options) => `${act} works only while ${field2} is ${options.join(" or ")}.`,
|
|
80394
|
+
notWhile: (act, field2, value) => `${act} works only while ${field2} is ${value ? "yes" : "no"}.`,
|
|
79733
80395
|
unreadable: (record2) => `The ${record2.toLowerCase()} this row belongs to cannot be read, so it cannot be changed.`,
|
|
79734
80396
|
milestoneOrder: (stage, before) => `${stage} comes after ${before} \u2014 fill that first.`,
|
|
79735
80397
|
milestoneBefore: (stage, before) => `${stage} is dated before ${before} \u2014 a later step is reached on or after the day of the one before it.`,
|
|
79736
|
-
stepDone: (act, step) => `${act} is done: ${step} is already dated
|
|
80398
|
+
stepDone: (act, step) => `${act} is done: ${step} is already dated.`,
|
|
80399
|
+
notUnder: (line, record2) => `A ${line.toLowerCase()} picked is not one of this ${record2.toLowerCase()}'s.`,
|
|
80400
|
+
covered: (line, document2) => `A ${line.toLowerCase()} picked already has its ${document2.toLowerCase()}.`
|
|
79737
80401
|
},
|
|
79738
80402
|
done: {
|
|
79739
80403
|
opened: (entity) => `Opened one row of ${entity}.`,
|
|
@@ -79749,7 +80413,8 @@ var PLAIN = {
|
|
|
79749
80413
|
children: (child, parent, link) => `The ${child} rows under one ${parent}, through "${link}".`,
|
|
79750
80414
|
pick: (target, link) => `The ${target} rows "${link}" may point at, narrowed by what the reader typed.`,
|
|
79751
80415
|
reading: (label2, entity) => `${label2}: the ${entity} rows counted, or summed, per value.`,
|
|
79752
|
-
folders: (entity, folder) => `How many ${entity} rows each ${folder} holds
|
|
80416
|
+
folders: (entity, folder) => `How many ${entity} rows each ${folder} holds.`,
|
|
80417
|
+
pictures: (entity) => `The picture and name of each ${entity} row among the ids handed in.`
|
|
79753
80418
|
},
|
|
79754
80419
|
workflows: {
|
|
79755
80420
|
create: (entity) => `Opens one row of ${entity}.`,
|
|
@@ -79776,10 +80441,13 @@ var DIACRITICS = {
|
|
|
79776
80441
|
offered: (target) => `D\xF2ng ${target.toLowerCase()} n\xE0y kh\xF4ng n\u1EB1m trong danh s\xE1ch \u0111\u01B0\u1EE3c ch\u1ECDn.`,
|
|
79777
80442
|
mismatched: (rows, field2) => `C\xF3 ${rows.toLowerCase()} g\u1EAFn v\u1EDBi d\xF2ng n\xE0y mang ${field2.toLowerCase()} kh\xE1c \u2014 h\xE3y s\u1EEDa tr\u01B0\u1EDBc.`,
|
|
79778
80443
|
notNow: (act, field2, options) => `Ch\u1EC9 ${act.toLowerCase()} \u0111\u01B0\u1EE3c khi ${field2.toLowerCase()} l\xE0 ${options.join(" ho\u1EB7c ")}.`,
|
|
80444
|
+
notWhile: (act, field2, value) => `Ch\u1EC9 ${act.toLowerCase()} \u0111\u01B0\u1EE3c khi ${value ? "" : "kh\xF4ng "}${field2.toLowerCase()}.`,
|
|
79779
80445
|
unreadable: (record2) => `Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c ${record2.toLowerCase()} ch\u1EE9a d\xF2ng n\xE0y, n\xEAn kh\xF4ng th\u1EC3 thay \u0111\u1ED5i n\xF3.`,
|
|
79780
80446
|
milestoneOrder: (stage, before) => `${stage} \u0111\u1EBFn sau ${before.toLowerCase()} \u2014 h\xE3y \u0111i\u1EC1n m\u1ED1c \u0111\xF3 tr\u01B0\u1EDBc.`,
|
|
79781
80447
|
milestoneBefore: (stage, before) => `Ng\xE0y ${stage.toLowerCase()} s\u1EDBm h\u01A1n ng\xE0y ${before.toLowerCase()} \u2014 m\u1ED1c sau \u0111\u1EBFn c\xF9ng ng\xE0y ho\u1EB7c sau m\u1ED1c tr\u01B0\u1EDBc n\xF3.`,
|
|
79782
|
-
stepDone: (act, step) => `${act} \u0111\xE3 xong: ${step.toLowerCase()} \u0111\xE3 c\xF3 ng\xE0y
|
|
80448
|
+
stepDone: (act, step) => `${act} \u0111\xE3 xong: ${step.toLowerCase()} \u0111\xE3 c\xF3 ng\xE0y.`,
|
|
80449
|
+
notUnder: (line, record2) => `C\xF3 ${line.toLowerCase()} \u0111\u01B0\u1EE3c ch\u1ECDn kh\xF4ng thu\u1ED9c ${record2.toLowerCase()} n\xE0y.`,
|
|
80450
|
+
covered: (line, document2) => `C\xF3 ${line.toLowerCase()} \u0111\u01B0\u1EE3c ch\u1ECDn \u0111\xE3 c\xF3 ${document2.toLowerCase()}.`
|
|
79783
80451
|
},
|
|
79784
80452
|
done: {
|
|
79785
80453
|
opened: (entity) => `\u0110\xE3 m\u1EDF m\u1ED9t d\xF2ng ${entity}.`,
|
|
@@ -79795,7 +80463,8 @@ var DIACRITICS = {
|
|
|
79795
80463
|
children: (child, parent, link) => `C\xE1c d\xF2ng ${child} thu\u1ED9c m\u1ED9t ${parent}, qua "${link}".`,
|
|
79796
80464
|
pick: (target, link) => `C\xE1c d\xF2ng ${target} m\xE0 "${link}" \u0111\u01B0\u1EE3c ch\u1ECDn, l\u1ECDc theo ch\u1EEF ng\u01B0\u1EDDi \u0111\u1ECDc g\xF5.`,
|
|
79797
80465
|
reading: (label2, entity) => `${label2}: c\xE1c d\xF2ng ${entity} \u0111\u01B0\u1EE3c \u0111\u1EBFm, ho\u1EB7c c\u1ED9ng, theo t\u1EEBng gi\xE1 tr\u1ECB.`,
|
|
79798
|
-
folders: (entity, folder) => `S\u1ED1 d\xF2ng ${entity} trong m\u1ED7i ${folder}
|
|
80466
|
+
folders: (entity, folder) => `S\u1ED1 d\xF2ng ${entity} trong m\u1ED7i ${folder}.`,
|
|
80467
|
+
pictures: (entity) => `\u1EA2nh v\xE0 t\xEAn c\u1EE7a t\u1EEBng d\xF2ng ${entity} trong c\xE1c m\xE3 \u0111\u01B0\u1EE3c giao.`
|
|
79799
80468
|
},
|
|
79800
80469
|
workflows: {
|
|
79801
80470
|
create: (entity) => `M\u1EDF m\u1ED9t d\xF2ng ${entity}.`,
|
|
@@ -79858,7 +80527,7 @@ function compileApp(model, app, bound, tables) {
|
|
|
79858
80527
|
Object.assign(queries, entityReads(ctx, alias2), ...picks.map((pick3) => pick3.query));
|
|
79859
80528
|
const spec2 = specEntity(ctx, alias2, new Map(picks.map((pick3) => [pick3.field, { query: pick3.alias, same: pick3.same }])));
|
|
79860
80529
|
const table = bodyTable(ctx, alias2);
|
|
79861
|
-
const
|
|
80530
|
+
const history = bodyHistory(ctx, alias2);
|
|
79862
80531
|
if (writes?.create !== void 0) {
|
|
79863
80532
|
const workflow = createAlias(alias2);
|
|
79864
80533
|
const create = createPlan(ctx, alias2, writes);
|
|
@@ -79871,14 +80540,14 @@ function compileApp(model, app, bound, tables) {
|
|
|
79871
80540
|
...create.copies.map((copy) => copy.into),
|
|
79872
80541
|
...create.stamps.map((stamp) => stamp.field)
|
|
79873
80542
|
]);
|
|
79874
|
-
if (
|
|
80543
|
+
if (history !== void 0) wrote(history.table, historyFields(history));
|
|
79875
80544
|
spec2.create = { workflow, fields: [...writes.create] };
|
|
79876
80545
|
}
|
|
79877
80546
|
if (writes !== void 0 && writes.update.length > 0) {
|
|
79878
80547
|
const workflow = updateAlias(alias2);
|
|
79879
80548
|
const fields = writes.update.map((name) => bodyField(ctx, alias2, name));
|
|
79880
80549
|
const checks = bodyChecks(ctx, "edit", alias2);
|
|
79881
|
-
const
|
|
80550
|
+
const moves2 = history !== void 0 && fields.some((one) => one.field.id === history.source.id) ? history : void 0;
|
|
79882
80551
|
const overlap = bodyOverlap(ctx, alias2);
|
|
79883
80552
|
const stages = bodyMilestones(ctx, alias2);
|
|
79884
80553
|
const milestones = touchesMilestones(stages, new Set(fields.map((one) => one.field.alias))) ? stages : [];
|
|
@@ -79893,7 +80562,7 @@ function compileApp(model, app, bound, tables) {
|
|
|
79893
80562
|
strays: bodyStrays(ctx, alias2, fields),
|
|
79894
80563
|
...overlap === void 0 ? {} : { overlap },
|
|
79895
80564
|
expects: bodyExpects(ctx, alias2, new Set(fields.map((one) => one.field.alias))),
|
|
79896
|
-
...
|
|
80565
|
+
...moves2 === void 0 ? {} : { history: moves2 },
|
|
79897
80566
|
locks: parentLocks(ctx, alias2),
|
|
79898
80567
|
milestones,
|
|
79899
80568
|
words
|
|
@@ -79901,7 +80570,7 @@ function compileApp(model, app, bound, tables) {
|
|
|
79901
80570
|
bodies[workflow] = body.source;
|
|
79902
80571
|
workflows[workflow] = { inputs: updateInputs(ctx, alias2, fields), outputs: body.outputs, description: described(words.workflows.update(bent.entity.label), bent.entity) };
|
|
79903
80572
|
wrote(bent.table, [...fields.map((one) => one.field), ...milestones.map((stage) => stage.field)]);
|
|
79904
|
-
if (
|
|
80573
|
+
if (moves2 !== void 0) wrote(moves2.table, historyFields(moves2));
|
|
79905
80574
|
spec2.update = { workflow };
|
|
79906
80575
|
}
|
|
79907
80576
|
if (writes?.remove === true) {
|
|
@@ -79958,8 +80627,8 @@ function compileApp(model, app, bound, tables) {
|
|
|
79958
80627
|
const readings = app.register.readings.map((reading) => specReading(ctx, reading, { kind: "register" }));
|
|
79959
80628
|
const before = (at2) => app.record.sections.slice(0, at2).reduce((count2, section) => count2 + section.blocks.length, 0);
|
|
79960
80629
|
const sections = app.record.sections.map((section, at2) => ({ section, blocks: section.blocks.map((block2, index) => specBlock(ctx, block2, before(at2) + index, entities)) }));
|
|
79961
|
-
const
|
|
79962
|
-
for (const read2 of [...readings, ...sections.flatMap((one) => one.blocks), ...
|
|
80630
|
+
const moves = app.record.moves === void 0 ? void 0 : specHistory(ctx, app.record.moves, entities);
|
|
80631
|
+
for (const read2 of [...readings, ...sections.flatMap((one) => one.blocks), ...moves === void 0 ? [] : [moves]]) Object.assign(queries, read2.queries);
|
|
79963
80632
|
const spec = {
|
|
79964
80633
|
version: APP_SPEC_VERSION,
|
|
79965
80634
|
app: {
|
|
@@ -79976,6 +80645,7 @@ function compileApp(model, app, bound, tables) {
|
|
|
79976
80645
|
filters: [...app.register.filters],
|
|
79977
80646
|
...app.register.search === void 0 ? {} : { search: [...app.register.search] },
|
|
79978
80647
|
...app.register.sort === void 0 ? {} : { sort: app.register.sort },
|
|
80648
|
+
...app.register.group === void 0 ? {} : { group: app.register.group },
|
|
79979
80649
|
layout: app.register.layout,
|
|
79980
80650
|
...entities[app.entity]?.create === void 0 || app.register.create === void 0 ? {} : { create: [...app.register.create] },
|
|
79981
80651
|
...exportSpec === void 0 ? {} : { export: exportSpec },
|
|
@@ -80000,7 +80670,8 @@ function compileApp(model, app, bound, tables) {
|
|
|
80000
80670
|
};
|
|
80001
80671
|
}),
|
|
80002
80672
|
...app.record.comments ? { comments: true } : {},
|
|
80003
|
-
...history
|
|
80673
|
+
...app.record.history && moves !== void 0 ? { history: true } : {},
|
|
80674
|
+
...moves === void 0 ? {} : { moves: moves.spec }
|
|
80004
80675
|
},
|
|
80005
80676
|
acts,
|
|
80006
80677
|
checks: app.checks.map((check2) => ({
|
|
@@ -80166,8 +80837,10 @@ function columnRefusals(spec) {
|
|
|
80166
80837
|
...main3 === void 0 ? [] : refusalsOf(main3, spec.register.columns, "register.columns", void 0),
|
|
80167
80838
|
...spec.record.sections.flatMap(
|
|
80168
80839
|
(section, index) => section.blocks.flatMap((block2, order) => {
|
|
80169
|
-
|
|
80170
|
-
|
|
80840
|
+
if (block2.kind !== "rows" && block2.kind !== "agenda") return [];
|
|
80841
|
+
const child = spec.entities[block2.entity];
|
|
80842
|
+
const expected = block2.kind === "rows" && block2.expect !== void 0;
|
|
80843
|
+
return child === void 0 ? [] : refusalsOf(child, block2.columns, `record.sections.${index}.blocks.${order}.columns`, block2.via, expected);
|
|
80171
80844
|
})
|
|
80172
80845
|
)
|
|
80173
80846
|
];
|
|
@@ -80213,7 +80886,7 @@ function checkSection(spec, section, at2) {
|
|
|
80213
80886
|
})
|
|
80214
80887
|
];
|
|
80215
80888
|
}
|
|
80216
|
-
function checkRead(at2, read2, entity, manifest) {
|
|
80889
|
+
function checkRead(at2, read2, entity, manifest, drawn = Object.values(entity.fields)) {
|
|
80217
80890
|
const declaration = manifest.queries?.[read2.query];
|
|
80218
80891
|
if (declaration === void 0) return [{ at: at2, message: `reads "${read2.query}", which package.json#lotics.queries does not declare` }];
|
|
80219
80892
|
const findings = [];
|
|
@@ -80225,7 +80898,7 @@ function checkRead(at2, read2, entity, manifest) {
|
|
|
80225
80898
|
return [...findings, { at: at2, message: `reads "${read2.query}", which is not a projection over one table \u2014 a row is read by the columns its query names` }];
|
|
80226
80899
|
}
|
|
80227
80900
|
if (projection2.table !== entity.table) findings.push({ at: at2, message: `reads ${entity.label} (${entity.table}) through "${read2.query}", which reads ${projection2.table}` });
|
|
80228
|
-
for (const field2 of
|
|
80901
|
+
for (const field2 of drawn) {
|
|
80229
80902
|
if (!projection2.outputs.has(field2.alias)) findings.push({ at: at2, message: `draws "${field2.alias}" (${field2.label}), which "${read2.query}" does not project` });
|
|
80230
80903
|
const key = projection2.keys.get(field2.alias);
|
|
80231
80904
|
if (key !== void 0 && key !== field2.id) findings.push({ at: at2, message: `reads "${field2.alias}" as field \`${field2.id}\`, and "${read2.query}" projects it from \`${key}\`` });
|
|
@@ -80235,6 +80908,11 @@ function checkRead(at2, read2, entity, manifest) {
|
|
|
80235
80908
|
}
|
|
80236
80909
|
return findings;
|
|
80237
80910
|
}
|
|
80911
|
+
function checkPictures(at2, read2, target, manifest) {
|
|
80912
|
+
if (target.image === void 0) return [{ at: at2, message: `reads the pictures of ${target.label}, whose rows name no \`image\`` }];
|
|
80913
|
+
const drawn = [target.title, target.image].flatMap((alias2) => target.fields[alias2] ?? []);
|
|
80914
|
+
return checkRead(at2, read2, target, manifest, drawn);
|
|
80915
|
+
}
|
|
80238
80916
|
function checkReadingRead(at2, reading, manifest) {
|
|
80239
80917
|
const { query, param, aggregate } = reading.read;
|
|
80240
80918
|
const declaration = manifest.queries?.[query];
|
|
@@ -80275,6 +80953,7 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
80275
80953
|
}
|
|
80276
80954
|
const target = field2.link === void 0 ? void 0 : spec.entities[field2.link];
|
|
80277
80955
|
if (field2.pick !== void 0 && target !== void 0) findings.push(...checkRead(`${at2}.fields.${field2.alias}.pick`, field2.pick, target, manifest));
|
|
80956
|
+
if (field2.pictures !== void 0 && target !== void 0) findings.push(...checkPictures(`${at2}.fields.${field2.alias}.pictures`, field2.pictures, target, manifest));
|
|
80278
80957
|
}
|
|
80279
80958
|
if (entity.create !== void 0) findings.push(...checkWorkflow(`${at2}.create`, entity.create.workflow, entity.create.fields.map((field2) => createInput(entity, field2)), manifest));
|
|
80280
80959
|
if (entity.update !== void 0) findings.push(...checkWorkflow(`${at2}.update`, entity.update.workflow, [], manifest));
|
|
@@ -80301,14 +80980,14 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
80301
80980
|
for (const [index, section] of spec.record.sections.entries()) findings.push(...checkSection(spec, section, `record.sections.${index}`));
|
|
80302
80981
|
const blocks = [
|
|
80303
80982
|
...spec.record.sections.flatMap((section, index) => section.blocks.map((one, order) => ({ at: `record.sections.${index}.blocks.${order}`, one }))),
|
|
80304
|
-
...spec.record.
|
|
80983
|
+
...spec.record.moves === void 0 ? [] : [{ at: "record.moves", one: spec.record.moves }]
|
|
80305
80984
|
];
|
|
80306
80985
|
for (const { at: at2, one } of blocks) {
|
|
80307
80986
|
if (one.kind === "reading") {
|
|
80308
80987
|
findings.push(...checkReadingRead(at2, one.reading, manifest));
|
|
80309
80988
|
continue;
|
|
80310
80989
|
}
|
|
80311
|
-
if (one.kind !== "rows" && one.kind !== "timeline") continue;
|
|
80990
|
+
if (one.kind !== "rows" && one.kind !== "agenda" && one.kind !== "timeline") continue;
|
|
80312
80991
|
const child = spec.entities[one.entity];
|
|
80313
80992
|
if (child === void 0) {
|
|
80314
80993
|
findings.push({ at: at2, message: `reads "${one.entity}", which \`entities\` does not carry` });
|
|
@@ -80321,6 +81000,10 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
80321
81000
|
if (one.kind === "rows" && one.expect !== void 0 && one.expect !== child.title) {
|
|
80322
81001
|
findings.push({ at: `${at2}.expect`, message: `expects a line per "${one.expect}", which is not the title of "${one.entity}" \u2014 each line is named by its key` });
|
|
80323
81002
|
}
|
|
81003
|
+
const under = one.kind === "rows" ? one.under : void 0;
|
|
81004
|
+
const document2 = under === void 0 ? void 0 : spec.entities[under.entity];
|
|
81005
|
+
if (under !== void 0 && document2 === void 0) findings.push({ at: `${at2}.under`, message: `stands the lines under "${under.entity}", which \`entities\` does not carry` });
|
|
81006
|
+
if (under !== void 0 && document2 !== void 0) findings.push(...checkRead(`${at2}.under`, under.read, document2, manifest));
|
|
80324
81007
|
}
|
|
80325
81008
|
for (const [index, act] of spec.acts.entries()) {
|
|
80326
81009
|
const on = spec.entities[act.of ?? spec.entity];
|
|
@@ -82434,19 +83117,22 @@ function answerQuery(data, call) {
|
|
|
82434
83117
|
}
|
|
82435
83118
|
const params = call.params ?? {};
|
|
82436
83119
|
const term = typeof params.search === "string" ? params.search.trim().toLowerCase() : "";
|
|
82437
|
-
const given = Object.entries(params).flatMap(([name, value]) =>
|
|
83120
|
+
const given = Object.entries(params).flatMap(([name, value]) => {
|
|
83121
|
+
const values2 = asList2(value).filter((one) => typeof one === "string" && one !== "");
|
|
83122
|
+
return values2.length === 0 ? [] : [[name, values2]];
|
|
83123
|
+
});
|
|
82438
83124
|
const read2 = declared.rows.filter((row) => {
|
|
82439
83125
|
if (term !== "" && declared.search && !matchesTerm(row, term)) return false;
|
|
82440
83126
|
const conditions = declared.narrow[String(row.__source_table_id)] ?? {};
|
|
82441
83127
|
const id = String(row.__source_record_id);
|
|
82442
83128
|
return given.every(
|
|
82443
83129
|
([name, wanted2]) => (conditions[name] ?? []).every((by) => {
|
|
82444
|
-
if ("own" in by) return
|
|
83130
|
+
if ("own" in by) return wanted2.includes(id) === (by.own === "is");
|
|
82445
83131
|
if ("day" in by) {
|
|
82446
83132
|
const day = by.day[id];
|
|
82447
|
-
return day !== void 0 && (by.side === "from" ? day >=
|
|
83133
|
+
return day !== void 0 && wanted2.some((bound) => by.side === "from" ? day >= bound : day <= bound);
|
|
82448
83134
|
}
|
|
82449
|
-
return (by.holds[id] ?? []).includes(
|
|
83135
|
+
return (by.holds[id] ?? []).some((held2) => wanted2.includes(held2));
|
|
82450
83136
|
})
|
|
82451
83137
|
);
|
|
82452
83138
|
});
|
|
@@ -85486,7 +86172,7 @@ async function holdRenderProject(project2, app, pollMs = 1e3) {
|
|
|
85486
86172
|
}
|
|
85487
86173
|
}
|
|
85488
86174
|
if (!told) {
|
|
85489
|
-
|
|
86175
|
+
note2(
|
|
85490
86176
|
holder.success ? `Waiting for the render of "${holder.data.app}" (pid ${holder.data.pid}), which holds ${project2.dir}\u2026` : `Waiting on ${lock}, whose holder has not yet said who it is\u2026`
|
|
85491
86177
|
);
|
|
85492
86178
|
told = true;
|
|
@@ -86135,11 +86821,11 @@ async function appApiPublish(client, args) {
|
|
|
86135
86821
|
emitJson(result);
|
|
86136
86822
|
return;
|
|
86137
86823
|
}
|
|
86138
|
-
|
|
86824
|
+
note2(
|
|
86139
86825
|
`Published the API of ${appId} \u2014 contract version ${result.contract_version}, ${result.published_at}.`
|
|
86140
86826
|
);
|
|
86141
|
-
for (const warning of result.warnings)
|
|
86142
|
-
|
|
86827
|
+
for (const warning of result.warnings) note2(` \u26A0 ${warning}`);
|
|
86828
|
+
note2(` The document its callers read: lotics app api spec ${appId}`);
|
|
86143
86829
|
}
|
|
86144
86830
|
async function appApiUnpublish(client, args) {
|
|
86145
86831
|
const appId = apiTargetApp(args.app_id);
|
|
@@ -86148,7 +86834,7 @@ async function appApiUnpublish(client, args) {
|
|
|
86148
86834
|
emitJson(result);
|
|
86149
86835
|
return;
|
|
86150
86836
|
}
|
|
86151
|
-
|
|
86837
|
+
note2(
|
|
86152
86838
|
result.unpublished ? `Stopped publishing the API of ${appId}.` : `${appId} was publishing no API \u2014 nothing changed.`
|
|
86153
86839
|
);
|
|
86154
86840
|
}
|
|
@@ -86173,7 +86859,7 @@ async function appApiSpec(client, args) {
|
|
|
86173
86859
|
return;
|
|
86174
86860
|
}
|
|
86175
86861
|
fs28.writeFileSync(args.outputPath, text);
|
|
86176
|
-
|
|
86862
|
+
note2(`Wrote the OpenAPI document of ${appId} to ${args.outputPath}`);
|
|
86177
86863
|
}
|
|
86178
86864
|
|
|
86179
86865
|
// src/app_check.ts
|
|
@@ -87451,7 +88137,7 @@ async function workspaceBuild(client, args, actions = REAL_ACTIONS) {
|
|
|
87451
88137
|
);
|
|
87452
88138
|
}
|
|
87453
88139
|
const differences = diffModelAgainstWorkspace(model.contract.entities, live.data, binding);
|
|
87454
|
-
for (const line of describeDiff(differences))
|
|
88140
|
+
for (const line of describeDiff(differences)) note2(line);
|
|
87455
88141
|
let scaffold = "unchanged";
|
|
87456
88142
|
if (differences.length > 0) {
|
|
87457
88143
|
if (dryRun) scaffold = "not run";
|
|
@@ -87488,7 +88174,7 @@ async function buildOneApp(client, actions, context) {
|
|
|
87488
88174
|
};
|
|
87489
88175
|
if (dryRun) return outcome;
|
|
87490
88176
|
try {
|
|
87491
|
-
|
|
88177
|
+
note2(`
|
|
87492
88178
|
${app.alias} \u2014 ${exists ? "regenerating" : "creating"} ${dir}`);
|
|
87493
88179
|
const from = `${file2}#${app.alias}`;
|
|
87494
88180
|
if (exists) {
|
|
@@ -87657,10 +88343,10 @@ async function fieldRename(client, args) {
|
|
|
87657
88343
|
{ format: "json" }
|
|
87658
88344
|
);
|
|
87659
88345
|
if (written.error) throw new Error(`Could not rename ${table.name}.${field2.name}: ${written.error}`);
|
|
87660
|
-
|
|
88346
|
+
note2(`Renamed ${table.name}.${field2.name} \u2192 ${args.newLabel} (${field2.id})`);
|
|
87661
88347
|
const after = await readTableSchema(client, table.id);
|
|
87662
88348
|
const moves = aliasMoves(before, after);
|
|
87663
|
-
for (const move of moves)
|
|
88349
|
+
for (const move of moves) note2(` alias ${move.table}.${move.from} \u2192 ${move.table}.${move.to}`);
|
|
87664
88350
|
if (args.model !== void 0) {
|
|
87665
88351
|
const raw = JSON.parse(fs34.readFileSync(args.model, "utf-8"));
|
|
87666
88352
|
const { changed, file: file2 } = renameInModel(raw, {
|
|
@@ -87671,9 +88357,9 @@ async function fieldRename(client, args) {
|
|
|
87671
88357
|
if (changed) {
|
|
87672
88358
|
fs34.writeFileSync(args.model, `${JSON.stringify(file2, null, 2)}
|
|
87673
88359
|
`);
|
|
87674
|
-
|
|
88360
|
+
note2(` model ${args.model}`);
|
|
87675
88361
|
} else {
|
|
87676
|
-
|
|
88362
|
+
note2(
|
|
87677
88363
|
` model ${args.model} declares no "${table.name}.${field2.name}" \u2014 nothing moved there. lotics scaffold diff ${args.model} says how far apart they are.`
|
|
87678
88364
|
);
|
|
87679
88365
|
}
|
|
@@ -87687,7 +88373,7 @@ async function fieldRename(client, args) {
|
|
|
87687
88373
|
fs34.writeFileSync(file2, next);
|
|
87688
88374
|
rewritten.push(path37.relative(resolved, file2));
|
|
87689
88375
|
}
|
|
87690
|
-
|
|
88376
|
+
note2(
|
|
87691
88377
|
` app ${resolved} \u2014 ${rewritten.length === 0 ? "no call site named it" : rewritten.join(", ")}`
|
|
87692
88378
|
);
|
|
87693
88379
|
await appCodegen({ projectDir: resolved, client });
|
|
@@ -89005,13 +89691,13 @@ async function resolveContextOrClaim(flags, options) {
|
|
|
89005
89691
|
const claim = await claimPendingLogin();
|
|
89006
89692
|
if (claim.status === "signed_in") {
|
|
89007
89693
|
const { account } = claim;
|
|
89008
|
-
|
|
89694
|
+
note2(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
89009
89695
|
return { ctx: resolveContext(flags, appWorkspaceId), claimed: account };
|
|
89010
89696
|
}
|
|
89011
89697
|
const resolved = resolveContext(flags, appWorkspaceId);
|
|
89012
89698
|
if (claim.status === "none") return { ctx: resolved, claimed: null };
|
|
89013
89699
|
if (resolved) {
|
|
89014
|
-
|
|
89700
|
+
note2(claim.message);
|
|
89015
89701
|
return { ctx: resolved, claimed: null };
|
|
89016
89702
|
}
|
|
89017
89703
|
return { ctx: null, claimed: null, refusal: claim.message };
|
|
@@ -89249,10 +89935,10 @@ async function main2(argv2) {
|
|
|
89249
89935
|
process.exit(1);
|
|
89250
89936
|
}
|
|
89251
89937
|
const account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
|
|
89252
|
-
|
|
89938
|
+
note2(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
89253
89939
|
} else {
|
|
89254
89940
|
const { account } = outcome;
|
|
89255
|
-
|
|
89941
|
+
note2(`Account created for ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
|
|
89256
89942
|
}
|
|
89257
89943
|
} catch (error52) {
|
|
89258
89944
|
console.error(error52 instanceof Error ? error52.message : String(error52));
|