@dforge-core/dforge-mcp 0.1.9 → 0.1.11
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/CHANGELOG.md +106 -0
- package/dist/server.js +3 -1
- package/package.json +2 -2
- package/resources/docs/conventions.md +1 -1
- package/resources/docs/dsl-reference.md +55 -1
- package/resources/schemas/entity.schema.json +3 -3
- package/skills/dforge-mcp-author/SKILL.md +4 -4
- package/skills/dforge-mcp-author/references/action-dsl.md +104 -5
- package/skills/dforge-mcp-author/references/column-types.md +29 -19
- package/skills/dforge-mcp-author/references/field-types.md +6 -2
- package/skills/dforge-mcp-author/references/formulas.md +30 -12
- package/skills/dforge-mcp-author/references/jobs.md +2 -2
- package/skills/dforge-mcp-author/references/validation-checklist.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,112 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
|
|
|
4
4
|
`0.1.0-rc.N` pre-release tags; the published version is set at publish time via
|
|
5
5
|
the release workflow, so committed `package.json` versions are placeholders.
|
|
6
6
|
|
|
7
|
+
## 0.1.11
|
|
8
|
+
|
|
9
|
+
> Release prerequisite: publish `@dforge-core/metadata@0.0.9` first (adds the
|
|
10
|
+
> `avatar`/`markdown` field types to the runtime registry — until then the field
|
|
11
|
+
> tools reject them), then `pnpm install` + `sync-schemas` here.
|
|
12
|
+
|
|
13
|
+
### Skill
|
|
14
|
+
|
|
15
|
+
- **Action DSL: `select()` / `update()` / `delete()` documented.**
|
|
16
|
+
`select('module.entity', {columns, filter, orderBy, limit, offset})` is the
|
|
17
|
+
structured multi-row read: canonical `{c,o,v}`/`{g,i}` filter JSON (same
|
|
18
|
+
operators as data views), one-hop nav paths `'ref.target as alias'` that emit
|
|
19
|
+
LEFT JOINs, **fail-loud** semantics (unknown column/operator/nav path throws —
|
|
20
|
+
unlike UI filters, which drop the condition), and no folder row-filter/column
|
|
21
|
+
security (it hits the table directly; filter nav paths instead of `R`
|
|
22
|
+
columns). `update()`/`delete()` take a scalar PK **or** a `{col: val}` object
|
|
23
|
+
key (string ids coerced to the PK storage type; array and null keys rejected).
|
|
24
|
+
`query()` is repositioned as the escape hatch (aggregates, multi-hop joins,
|
|
25
|
+
CTEs, `FOR UPDATE`). New real example: `comm.dispatch_messages` (cron-scan
|
|
26
|
+
`select()` + scalar-key `update()`). Also documents `exit(message?, level?)` —
|
|
27
|
+
the DSL's early-return — and the rule to always qualify entity codes
|
|
28
|
+
(`update('comm.message', …)`) (`action-dsl.md`, `jobs.md`, `SKILL.md`).
|
|
29
|
+
- **Formula date family completed + SQL-time parity** (`formulas.md`). Adds the
|
|
30
|
+
missing `WEEKDAY`, `DATE`, and period-boundary functions (`STARTMONTH`,
|
|
31
|
+
`ENDMONTH`, `STARTQUARTER`, `ENDQUARTER`, `STARTYEAR`, `ENDYEAR`,
|
|
32
|
+
`STARTNEXTMONTH` — optional arg defaults to today) and corrects
|
|
33
|
+
`DATEADD`/`DATEDIFF` signatures (string-literal units `'DAY'`/`'HOUR'`/
|
|
34
|
+
`'MINUTE'`/`'SECOND'`/`'MONTH'`/`'YEAR'`; `DATEDIFF(d1, d2, unit)` = `d2 − d1`).
|
|
35
|
+
The whole family now evaluates identically client-side and SQL-time (reports,
|
|
36
|
+
filters, sorts) — the server SQL translator gained the full set, so
|
|
37
|
+
`DATEDIFF([due_date], TODAY(), 'DAY')` no longer comes back NULL in reports.
|
|
38
|
+
- **Field types** (`field-types.md`): added `avatar` (initials circle +
|
|
39
|
+
optional photo via `params.imageColumn`), `markdown` (Write/Preview editor,
|
|
40
|
+
distinct from `richtext` WYSIWYG — the alias table previously mapped
|
|
41
|
+
`markdown` → `richtext`, now wrong), and `list` (card-list rendering of a 1:N
|
|
42
|
+
set, sibling of `grid`).
|
|
43
|
+
|
|
44
|
+
### Resources
|
|
45
|
+
|
|
46
|
+
- **`dforge://reference/dsl` (`resources/docs/dsl-reference.md`) caught up with the
|
|
47
|
+
engine**: documents `select()`, `update()`, `delete()` (previously absent — the
|
|
48
|
+
reference stopped at `query()`/`insert()`), plus `entityLink()`, the
|
|
49
|
+
qualify-entity-codes rule, and scalar-vs-object key semantics.
|
|
50
|
+
|
|
51
|
+
### Schema
|
|
52
|
+
|
|
53
|
+
- **`entity.schema.json` re-vendored** (from `@dforge-core/metadata@0.0.9`):
|
|
54
|
+
`fieldTypeCd` enum gains `avatar`, `markdown`, `list`; `refFilter` description
|
|
55
|
+
now documents `@[field]` dynamic placeholders (resolved against the current
|
|
56
|
+
record at option load; an unset placeholder drops the condition, and lookups
|
|
57
|
+
re-resolve options on every open).
|
|
58
|
+
|
|
59
|
+
## 0.1.10
|
|
60
|
+
|
|
61
|
+
### Schema
|
|
62
|
+
- **`entity.schema.json`: `references` gains `onDelete` / `onUpdate`.** Both
|
|
63
|
+
accept `cascade` | `setNull` | `restrict` | `noAction` (omitted = `noAction`,
|
|
64
|
+
a plain FK). `setNull` requires a nullable FK column; `onUpdate` is a no-op for
|
|
65
|
+
immutable `cuid` PKs but matters for entities keyed on a natural/mutable PK.
|
|
66
|
+
The keys were already documented in the skill (0.1.6) but rejected by the
|
|
67
|
+
vendored schema — `dforge_module_validate` now accepts them.
|
|
68
|
+
- **`triggers.schema.json`: clarified `async` semantics.** The description now
|
|
69
|
+
spells out that `async: false` runs the action *synchronously inside the
|
|
70
|
+
triggering transaction* (an `error()` rolls the whole mutation back and
|
|
71
|
+
surfaces to the caller), while `async: true` commits first and only logs
|
|
72
|
+
failures — so `async: false` actions must stay fast.
|
|
73
|
+
|
|
74
|
+
### Skill
|
|
75
|
+
- `dforge-mcp-author`: **corrected roll-up totals — use a Generated (`G`) column,
|
|
76
|
+
not a Formula (`F`) column.** Previous guidance (0.1.x) told authors to put
|
|
77
|
+
`SUM([set].[field])` in an `F` column; the formula runtime has no
|
|
78
|
+
`SUM`/`COUNT`/`AVG` and its nav resolution only walks single-hop N:1
|
|
79
|
+
references, so an `F` set-aggregate **silently renders empty** with no error.
|
|
80
|
+
The correct shape is a `G` column with `dbDatatype` + `formula` (no `link` /
|
|
81
|
+
`baseDatatypeCd`), which the installer maintains with a DB trigger on the child
|
|
82
|
+
table — stored, so it is filterable and sortable. The pre-existing rule still
|
|
83
|
+
holds: aggregate only a **physical** child column (`D`, or a same-row `G`);
|
|
84
|
+
a virtual `F`/`R`/`S` child fails install with
|
|
85
|
+
`db_error: column old.<field> does not exist`. Also documents the supported
|
|
86
|
+
aggregates (`SUM`/`COUNT`/`AVG`/`MIN`/`MAX`), empty-set results (`SUM`/`COUNT`
|
|
87
|
+
→ `0`; `MIN`/`MAX`/`AVG` → `NULL`), the same-set restriction, and that
|
|
88
|
+
`COUNT(*)` is rejected in favour of `COUNT([lines])`
|
|
89
|
+
(`column-types.md`, `formulas.md`, `SKILL.md`, `validation-checklist.md`).
|
|
90
|
+
|
|
91
|
+
## 0.1.9
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
- Bumped `@dforge-core/metadata` to `^0.0.8` (re-vendored schemas).
|
|
95
|
+
|
|
96
|
+
### Skill
|
|
97
|
+
- `dforge-mcp-author`: documentation updates to the action-DSL and field-type
|
|
98
|
+
references (no tool/behavior changes).
|
|
99
|
+
- **`getRecord` throws; `getRecordOrNull` is the nullable variant.** Corrected
|
|
100
|
+
the docs: `getRecord(entityCd, key)` raises a localized "not found" error
|
|
101
|
+
instead of returning `null`, so the old `if (rec == null)` guard was dead
|
|
102
|
+
code — use `getRecordOrNull(entityCd, key)` when absence is an expected
|
|
103
|
+
outcome (optional lookup, upsert probe). Also documents compound keys
|
|
104
|
+
(`getRecord('gl.tag', { tag_group: 'REGION', tag_code: 'EU' })`), dot /
|
|
105
|
+
`.get()` field access, and that the returned snapshot is **read-only**
|
|
106
|
+
(`dsl-reference.md`, `action-dsl.md`).
|
|
107
|
+
- **`file` / `image` columns are stored as `jsonb`, not `bytea`.** Despite the
|
|
108
|
+
`binary` base datatype, the column holds a JSON metadata reference
|
|
109
|
+
(`{ storagePath, fileName, … }`) while the bytes live in file storage — the
|
|
110
|
+
field tools derive `jsonb`, so never set `dbDatatype: "bytea"`
|
|
111
|
+
(`field-types.md`).
|
|
112
|
+
|
|
7
113
|
## 0.1.8
|
|
8
114
|
|
|
9
115
|
### Added
|
package/dist/server.js
CHANGED
|
@@ -31180,6 +31180,7 @@ var AGG_TYPE_LIST = Object.values(AggType);
|
|
|
31180
31180
|
// node_modules/@dforge-core/metadata/dist/field-types.js
|
|
31181
31181
|
var fieldTypes = [
|
|
31182
31182
|
{ cd: "text", name: "Text", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31183
|
+
{ cd: "avatar", name: "Avatar Name", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31183
31184
|
{ cd: "email", name: "Email", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31184
31185
|
{ cd: "phone", name: "Phone", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31185
31186
|
{ cd: "url", name: "URL", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
@@ -31198,6 +31199,7 @@ var fieldTypes = [
|
|
|
31198
31199
|
{ cd: "color", name: "Color Picker", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: 20, precision: null, useStringInFilter: false },
|
|
31199
31200
|
{ cd: "image", name: "Image", columnType: "D", baseDatatypeCd: "binary", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: false },
|
|
31200
31201
|
{ cd: "file", name: "File", columnType: "D", baseDatatypeCd: "binary", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: false },
|
|
31202
|
+
{ cd: "markdown", name: "Markdown", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31201
31203
|
{ cd: "richtext", name: "Rich Text Editor", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31202
31204
|
{ cd: "lookup", name: "Lookup", columnType: "R", baseDatatypeCd: "guid", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: true },
|
|
31203
31205
|
{ cd: "user", name: "User Picker", columnType: "D", baseDatatypeCd: "cuid", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: false },
|
|
@@ -31355,7 +31357,7 @@ function expandTraits(traitCds, entityName) {
|
|
|
31355
31357
|
|
|
31356
31358
|
// node_modules/@dforge-core/metadata/dist/derive.js
|
|
31357
31359
|
var NUMERIC_TOTAL = 18;
|
|
31358
|
-
var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "richtext", "code"]);
|
|
31360
|
+
var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "markdown", "richtext", "code"]);
|
|
31359
31361
|
var DB_DATATYPE_OVERRIDE = {
|
|
31360
31362
|
file: "jsonb",
|
|
31361
31363
|
image: "jsonb"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes scaffold/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/iash44/dForge-core",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@dforge-core/dforge-cli": "^0.2.7",
|
|
35
|
-
"@dforge-core/metadata": "^0.0.
|
|
35
|
+
"@dforge-core/metadata": "^0.0.9",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
37
|
"zod": "^4.4.3"
|
|
38
38
|
},
|
|
@@ -562,7 +562,7 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
|
|
|
562
562
|
- `schedule` — five fields, minute granularity (sub-minute schedules are rejected at install)
|
|
563
563
|
- `timeout` — required, in seconds, range `(0, 3600]`
|
|
564
564
|
- `timeout > 300` requires `"class": "long_running"` (the standard pool is capped at 300s)
|
|
565
|
-
- Referenced action must NOT use `[field]` or `for x in records` — scheduled fires have no record context. Install fails fast if it does. Use `
|
|
565
|
+
- Referenced action must NOT use `[field]` or `for x in records` — scheduled fires have no record context. Install fails fast if it does. Use `select()` / `insert()` / `update()` for table-level work.
|
|
566
566
|
- Max 50 jobs per module
|
|
567
567
|
- Optional `timeZone` (IANA name) overrides `auth.tenant.time_zone` for that job
|
|
568
568
|
- Use `paused: true` to freeze a job during incidents — admin "Run now" still works while the cron path is silent
|
|
@@ -130,10 +130,13 @@ execute:
|
|
|
130
130
|
|
|
131
131
|
---
|
|
132
132
|
|
|
133
|
-
## Built-in functions
|
|
133
|
+
## Built-in functions
|
|
134
134
|
|
|
135
135
|
All exposed by `ActionScriptEngine.cs` as host globals — no `import` needed.
|
|
136
136
|
|
|
137
|
+
> **Entity codes: always qualify** (`'comm.message'`, not `'message'`) in every data
|
|
138
|
+
> built-in, even inside the owning module.
|
|
139
|
+
|
|
137
140
|
### Database & data
|
|
138
141
|
|
|
139
142
|
#### `query(sql, params?) → array`
|
|
@@ -168,6 +171,46 @@ var po = insert('purchase_order', { supplier_id: params[supplier].supplier_id })
|
|
|
168
171
|
insert('po_line', { po_id: po.purchase_order_id, product_id: [product_id], qty: 1 })
|
|
169
172
|
```
|
|
170
173
|
|
|
174
|
+
#### `select(entityCd, opts?) → array`
|
|
175
|
+
Structured multi-row read — the counterpart to `insert()`/`update()`/`delete()`; prefer
|
|
176
|
+
it over `query()` for row-shaped reads. `opts` (all optional): `columns` (paths; a
|
|
177
|
+
one-hop nav path `'ref.target as alias'` LEFT JOINs the referenced entity; formula
|
|
178
|
+
columns translate to SQL; default = all physical columns), `filter` (canonical
|
|
179
|
+
`{c,o,v}`/`{g,i}` filter JSON, nav paths allowed), `orderBy` (`'-'` prefix =
|
|
180
|
+
descending; default = PK), `limit`, `offset`. **Fail-loud:** an unknown column,
|
|
181
|
+
operator, or nav path throws instead of silently dropping the condition. Hits the
|
|
182
|
+
table directly (no folder row-filter / column security); a plain reference (`R`)
|
|
183
|
+
column can't be selected or string-filtered — use the FK column or a nav path.
|
|
184
|
+
`query()` remains the escape hatch (aggregates, multi-hop joins, CTEs, `FOR UPDATE`).
|
|
185
|
+
|
|
186
|
+
```dsl
|
|
187
|
+
var queued = select('comm.message', {
|
|
188
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject'],
|
|
189
|
+
filter: { g: 'and', i: [
|
|
190
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
191
|
+
{ c: 'channel', o: '=', v: 'email' }
|
|
192
|
+
] },
|
|
193
|
+
orderBy: ['message_id'],
|
|
194
|
+
limit: 10
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### `update(entityCd, key, fields) → int`
|
|
199
|
+
Update the row(s) matched by `key`; returns rows affected. Refreshes update-phase
|
|
200
|
+
server defaults (audit `last_updated` / `last_updated_by`) and coerces values like
|
|
201
|
+
`insert()`. `key` is a scalar PK (`update('task', 42, {…})`) or an object
|
|
202
|
+
(`{ task_id: 42 }`; multiple entries for a compound key). Scalar string ids are
|
|
203
|
+
coerced to the PK storage type (snowflake-as-string binds `int8`). A null key
|
|
204
|
+
**throws**; array keys are rejected — loop and mutate per id.
|
|
205
|
+
|
|
206
|
+
```dsl
|
|
207
|
+
update('comm.message', msg.message_id, { status: 'sent', sent_at: now() })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `delete(entityCd, key) → int`
|
|
211
|
+
Delete the row(s) matched by `key`; returns rows affected. Same key shapes and
|
|
212
|
+
rules as `update()`.
|
|
213
|
+
|
|
171
214
|
#### `getRecord(entityCd, key) → record`
|
|
172
215
|
Fetch a single record by key. **Throws** a localized "not found" error if no row
|
|
173
216
|
matches — the safe default for a lookup that should always resolve (e.g. an FK you
|
|
@@ -376,6 +419,17 @@ if (!result.isValid) { error('Validation failed: ' + result.errors[0]) }
|
|
|
376
419
|
|
|
377
420
|
### Utility
|
|
378
421
|
|
|
422
|
+
#### `entityLink(entityCd, record, description?) → link value`
|
|
423
|
+
Build a clickable link to any entity record for storing in an `entitylink` (jsonb)
|
|
424
|
+
column. `record` is a row object (e.g. from `insert()` / `getRecord()`); its PK
|
|
425
|
+
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact. Optional
|
|
426
|
+
`description` sets the display text.
|
|
427
|
+
|
|
428
|
+
```dsl
|
|
429
|
+
var task = insert('workspace.task', { title: 'Follow up' })
|
|
430
|
+
insert('workspace.task_note', { link: entityLink('workspace.task', task, task.title) })
|
|
431
|
+
```
|
|
432
|
+
|
|
379
433
|
#### `flush()`
|
|
380
434
|
Commit pending changes mid-script AND broadcast SSE updates to connected clients. **Async / background actions only** — in sync actions, everything commits at the end automatically.
|
|
381
435
|
|
|
@@ -120,10 +120,10 @@
|
|
|
120
120
|
"fieldTypeCd": {
|
|
121
121
|
"type": "string",
|
|
122
122
|
"enum": [
|
|
123
|
-
"text", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
123
|
+
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
124
124
|
"checkbox", "date", "datetime", "time",
|
|
125
125
|
"dropdown", "flags", "tags", "json", "hidden", "color", "image", "file",
|
|
126
|
-
"richtext", "lookup", "user", "grid", "entitylink", "code"
|
|
126
|
+
"markdown", "richtext", "lookup", "user", "grid", "list", "entitylink", "code"
|
|
127
127
|
],
|
|
128
128
|
"description": "UI field type code from field_type table"
|
|
129
129
|
},
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"description": "On a self-referencing reference column (R column whose link.entity is the owning entity), set true to ALLOW parent/child cycles. When absent the self-reference is treated as an acyclic hierarchy and DB-level guards reject loops. Ignored on non-self-referencing columns."
|
|
211
211
|
},
|
|
212
212
|
"refFilter": {
|
|
213
|
-
"description": "Filter criteria for reference lookups"
|
|
213
|
+
"description": "Filter criteria for reference lookups. Condition values support @[field] placeholders resolved against the current record at lookup time (e.g. {\"c\":\"channel\",\"o\":\"eq\",\"v\":\"@[channel]\"} narrows options to rows matching the record's own channel); a condition whose placeholder is unset is dropped rather than matching nothing."
|
|
214
214
|
}
|
|
215
215
|
},
|
|
216
216
|
"oneOf": [
|
|
@@ -110,7 +110,7 @@ Always-on cheat-sheet — enough to author inline; load the linked `references/*
|
|
|
110
110
|
- **Flags** = letters from `V I E M H` only (no `U`/`S`/`P`): `VEM` required+visible, `VE` optional+visible, `V` read-only, `EM` hidden FK, `I` trait-provided. → `flags.md`
|
|
111
111
|
- **Field types:** `fieldTypeCd` = UI control, `dbDatatype` = SQL type. **Omit `dbDatatype` on plain data columns — it's derived from `fieldTypeCd`** (`currency` → `numeric(18,2)`, `text` → `varchar`). Only set it for a **hidden FK** (no `fieldTypeCd`, so use the target PK type `cuid`) or to override size/precision. When you do set it: never the same as `fieldTypeCd`, and never `"number"` (use `int`/`bigint`/`numeric`). Common `fieldTypeCd` fixes: `number` not `integer`/`float`, `phone` not `phoneNumber`, `date` not `datePicker`. Common `dbDatatype` fixes: `timestamptz` not `datetime`/`timestamp`, `bool` not `boolean`, `varchar`/`text` not `string`. (Invalid `fieldTypeCd`/`columnType` are now rejected at authoring time.) → `field-types.md`
|
|
112
112
|
- **Formula columns** (`columnType: "F"`): `baseDatatypeCd` required, no `dbDatatype`, `flags: "V"`. → `formulas.md`
|
|
113
|
-
- **Roll-up totals** over a child set →
|
|
113
|
+
- **Roll-up totals** over a child set → Generated `G` column with `SUM([set].[field])` (`dbDatatype` + `formula`, no `link`/`baseDatatypeCd`); the installer maintains it with a DB trigger. **Not** a Formula `F` — an `F` set-aggregate is unsupported and silently renders empty. Aggregate only a **physical** child column (`D` or same-row `G`); a virtual `F`/`R`/`S` child fails install (`column old.<field> does not exist`). → `column-types.md`
|
|
114
114
|
- **Column defaults:** entity *data* columns have **no** `defaultValue`/`default` key. Set a default via a formula (`"formula": "TODAY()"`, `"formula": "'draft'"`) or in action/trigger logic. `defaultValue` is **settings-only**. → `field-types.md`
|
|
115
115
|
- **Traits:** default `["identity", "audit"]`; `audit-full` (when the user needs *who*-tracking) adds required `created_by`/`last_updated_by` with no default — if such an entity is **seeded**, set both to the System user `0` in every record, or don't seed it. → `traits.md`
|
|
116
116
|
- **`toString`:** every entity needs one, `{column}` braces, e.g. `"{first_name} {last_name}"`.
|
|
@@ -209,7 +209,7 @@ Push back on verb-less answers ("admins and users" → "What does an admin do th
|
|
|
209
209
|
2. Every action's `canExecute` guard references a status value that exists in that entity's options list.
|
|
210
210
|
3. Every seed record's FK references a parent entity that also has seed data (referential integrity in load order).
|
|
211
211
|
4. Every formula column uses only fields that exist on the same entity or a directly referenced entity (exactly 1 FK hop). Transitive references (2+ hops) are async and must have been flagged in the Phase 0c gap scan.
|
|
212
|
-
5. Any `SUM
|
|
212
|
+
5. Any set aggregate (`SUM`/`COUNT`/`AVG`/`MIN`/`MAX` over `[set].[field]`) is a Generated (`G`) column — never a Formula (`F`) column (an `F` set-aggregate silently renders empty) — and aggregates only a **physical** child column (`D` or same-row `G`), never a virtual `F`/`R`/`S` child.
|
|
213
213
|
6. Every seeded entity handles audit traits: if it uses `audit-full`, every seed record sets `created_by`/`last_updated_by` to the System user `0` (else use `audit` / don't seed it) — `audit-full`'s required user columns otherwise fail seed install.
|
|
214
214
|
|
|
215
215
|
Once all checks pass, present a brief summary (entity count, action count) and ask for final confirmation before calling `dforge_module_create`.
|
|
@@ -274,7 +274,7 @@ For each trigger, propose: entity + event + (optional) condition formula + targe
|
|
|
274
274
|
Load the "scheduled job" row from the Loading policy table (`dforge://reference/jobs` + `dforge://schema/jobs`).
|
|
275
275
|
|
|
276
276
|
Constraints baked into the tool:
|
|
277
|
-
- Action MUST NOT use record-context (`[field]`) syntax — jobs run as system user with NO current record. Wrap any record-context action in a thin job-friendly action that uses `query()` to fetch the records it needs.
|
|
277
|
+
- Action MUST NOT use record-context (`[field]`) syntax — jobs run as system user with NO current record. Wrap any record-context action in a thin job-friendly action that uses `select()` (or `query()`) to fetch the records it needs.
|
|
278
278
|
- `timeout` is required, ≤ 3600s.
|
|
279
279
|
- If `timeout > 300`, you MUST set `jobClass: 'long_running'`.
|
|
280
280
|
|
|
@@ -380,7 +380,7 @@ Load `dforge://reference/validation-checklist`. Run through **every section** in
|
|
|
380
380
|
**Top install-blockers — scan these first** (each is a documented real install failure the platform validator rejects):
|
|
381
381
|
|
|
382
382
|
1. **DSL dates:** `execute:` blocks use lowercase `now()`; never `TODAY()`/`NOW()` (formula-only → `'TODAY' is not defined`).
|
|
383
|
-
2. **Roll-ups:** a total over a child set is a
|
|
383
|
+
2. **Roll-ups:** a total over a child set is a Generated (`G`) column with `SUM([set].[field])` (`dbDatatype` + `formula`) — never a Formula (`F`) column (an `F` set-aggregate silently renders empty). Aggregate only a **physical** child column (`D` or same-row `G`); a virtual `F`/`R`/`S` child fails install (→ `db_error: column old.<field> does not exist`).
|
|
384
384
|
3. **Rights keys:** actions/reports/folders use a **colon** (`action:x`, `report:x`, `folder:x`); entities are bare or cross-module-dotted; deny by omitting the key, never `""`.
|
|
385
385
|
4. **Manifest:** no `translations` key (translation files auto-discovered; non-English locales → `supportedLocales`).
|
|
386
386
|
5. **Column defaults:** set via `formula` / `numberSequence` / DSL — never a `defaultValue` key on an entity field.
|
|
@@ -106,11 +106,68 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
106
106
|
| Function | Returns | Description |
|
|
107
107
|
|---|---|---|
|
|
108
108
|
| `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
109
|
+
| `select('entity', opts?)` | Array of row objects | Structured multi-row read — see "select()" below. `opts` = `{columns, filter, orderBy, limit, offset}`, all optional. Prefer over `query()` for row-shaped reads. |
|
|
110
|
+
| `update('entity', key, { field: value, … })` | Rows affected (number) | Update the row(s) matched by `key`. Refreshes update-phase server defaults (audit `last_updated` / `last_updated_by`) and coerces values like `insert()`. |
|
|
111
|
+
| `delete('entity', key)` | Rows affected (number) | Delete the row(s) matched by `key`. |
|
|
112
|
+
| `query('SQL', { arg: value })` | Array of row objects | Execute parameterized SQL. Use `@argName` placeholders. Schema-qualified table names (`crm.contact`). Access row fields with `row.field_name` or `row['field_name']`. **Escape hatch** — prefer `select()`/`update()`/`delete()` when the structured shape fits. |
|
|
113
|
+
| `getRecord('entity', key)` | Row object | Fetch a single record by key. Access fields with `rec.field_name` (or `rec.get('field_name')`). **Throws** a localized "not found" error if absent. |
|
|
111
114
|
| `getRecordOrNull('entity', key)` | Row object or `null` | Like `getRecord`, but returns `null` instead of throwing when the row is absent — use for an expected-absence lookup, guarded with `if (rec == null)`. |
|
|
112
115
|
| `preloadRef('fk_column')` | Row object | Load the referenced record for a FK column. Access its fields with `ref.get('field')`. |
|
|
113
116
|
|
|
117
|
+
> **Entity codes: always qualify.** Every data built-in (`select`, `insert`, `update`, `delete`,
|
|
118
|
+
> `getRecord`, `nextNumber`) accepts a qualified `module.entity` code — write `update('comm.message', …)`,
|
|
119
|
+
> not `update('message', …)`, even inside the owning module. It reads unambiguously and keeps the
|
|
120
|
+
> script correct if it's ever moved to a bridge module.
|
|
121
|
+
|
|
122
|
+
> **Keys: scalar or object.** In `update()` / `delete()` / `getRecord()` / `getRecordOrNull()`,
|
|
123
|
+
> `key` is either a scalar PK value (`update('task', 42, {…})` — single-column PK only) or an
|
|
124
|
+
> object (`{ task_id: 42 }`; multiple entries for compound keys). Scalar **string** ids are coerced
|
|
125
|
+
> to the PK's storage type, so a snowflake id carried as a string binds as `int8`, not text. A
|
|
126
|
+
> `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
|
|
127
|
+
> loop and mutate per id instead.
|
|
128
|
+
|
|
129
|
+
### select() — structured multi-row reads
|
|
130
|
+
|
|
131
|
+
The read counterpart to `insert()`/`update()`/`delete()`. Entity codes are metadata-validated,
|
|
132
|
+
values are parameterized and type-coerced per column, and reference navigation replaces
|
|
133
|
+
hand-written LEFT JOINs. A bare `select('entity')` reads every physical column of every row,
|
|
134
|
+
ordered by PK.
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
var queued = select('comm.message', {
|
|
138
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject'],
|
|
139
|
+
filter: { g: 'and', i: [
|
|
140
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
141
|
+
{ c: 'channel', o: '=', v: 'email' },
|
|
142
|
+
{ g: 'or', i: [
|
|
143
|
+
{ c: 'to_addr', o: '!null' },
|
|
144
|
+
{ c: 'to_point.value', o: '!null' }
|
|
145
|
+
] }
|
|
146
|
+
] },
|
|
147
|
+
orderBy: ['message_id'],
|
|
148
|
+
limit: 10
|
|
149
|
+
})
|
|
150
|
+
// queued = [{ message_id: 123, to_addr: null, point_value: 'a@b.c', subject: '…' }, …]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
| Option | Shape | Notes |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `columns` | array of strings | Column paths. A one-hop nav path `'ref.target'` LEFT JOINs the referenced entity; `' as alias'` renames the key in the returned rows (avoids bracket-indexed `row['ref.target']` access). Formula columns are translated to SQL. Defaults to all physical columns. |
|
|
156
|
+
| `filter` | object | The platform's canonical filter JSON — `{c, o, v}` conditions inside `{g, i}` groups, same operators as data views (`=`, `!=`, `>`, `in`, `start`, `contains`, `null`, `!null`, `between`, …; see `filters.md`). Nav paths work here too. |
|
|
157
|
+
| `orderBy` | array of strings | Column paths; `'-'` prefix for descending (`['-message_id']`). Defaults to the PK for deterministic results. |
|
|
158
|
+
| `limit` / `offset` | int | Positive / non-negative. |
|
|
159
|
+
|
|
160
|
+
- **Fail-loud:** unlike UI filters (which drop untranslatable conditions with a warning),
|
|
161
|
+
`select()` **throws** on an unknown column, unknown operator, or unresolvable nav path — a
|
|
162
|
+
silently narrowed filter in an action means processing the wrong rows.
|
|
163
|
+
- **Low-level:** like the other data built-ins, `select()` hits the table directly — no folder
|
|
164
|
+
row-filter, column security, or reference display machinery. A plain reference (`R`) column has
|
|
165
|
+
no value here; select its FK column or a nav path (the error message says which). String
|
|
166
|
+
operators on a reference column are rejected for the same reason — filter a nav path like
|
|
167
|
+
`'customer.company_name'` instead.
|
|
168
|
+
- `query()` remains the escape hatch for anything this shape can't express: aggregates, multi-hop
|
|
169
|
+
joins, CTEs, `FOR UPDATE`.
|
|
170
|
+
|
|
114
171
|
### Messaging
|
|
115
172
|
|
|
116
173
|
| Function | Description |
|
|
@@ -118,6 +175,7 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
118
175
|
| `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
|
|
119
176
|
| `warn('message')` | Show a warning but continue execution. |
|
|
120
177
|
| `info('message')` | Show an informational message. |
|
|
178
|
+
| `exit('message'?, 'level'?)` | **Stop the script early without error** — changes made so far are kept. Level `'info'` (default) or `'warn'` styles the message. This is the DSL's early-return: a bare `return` is not valid at the top level of `execute:`. |
|
|
121
179
|
| `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
|
|
122
180
|
|
|
123
181
|
### Email
|
|
@@ -315,6 +373,42 @@ execute:
|
|
|
315
373
|
info('Received ' + poLines.length + ' line(s) from PO ' + [po_number])
|
|
316
374
|
```
|
|
317
375
|
|
|
376
|
+
> The two `query()` reads and the raw `query('UPDATE …')` above predate `select()`/`update()` —
|
|
377
|
+
> today each would be a structured call (`select('wms.stock', { filter: … })`,
|
|
378
|
+
> `update('wms.stock', stockId, { quantity: newQty })`).
|
|
379
|
+
|
|
380
|
+
### select() + update() with a scalar key (cron-scan dispatch)
|
|
381
|
+
|
|
382
|
+
From `modules/comm/logic/actions/dispatch_messages.dsl` (a scheduled-job action — no record context):
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
execute:
|
|
386
|
+
var queued = select('comm.message', {
|
|
387
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject', 'body_html', 'body_text', 'attempt_count'],
|
|
388
|
+
filter: { g: 'and', i: [
|
|
389
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
390
|
+
{ c: 'channel', o: '=', v: 'email' }
|
|
391
|
+
] },
|
|
392
|
+
orderBy: ['message_id'],
|
|
393
|
+
limit: 10
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
if (queued.length == 0) {
|
|
397
|
+
exit('No queued messages', 'info')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (var i = 0; i < queued.length; i++) {
|
|
401
|
+
var msg = queued[i]
|
|
402
|
+
sendEmail(msg.to_addr || msg.point_value, msg.subject, msg.body_html || msg.body_text || '')
|
|
403
|
+
|
|
404
|
+
update('comm.message', msg.message_id, {
|
|
405
|
+
status: 'sent',
|
|
406
|
+
sent_at: now(),
|
|
407
|
+
attempt_count: (msg.attempt_count || 0) + 1
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
318
412
|
## Execution modes
|
|
319
413
|
|
|
320
414
|
Actions run in one of two modes, declared in `ui/actions.json`:
|
|
@@ -376,10 +470,12 @@ In `ui/actions.json`:
|
|
|
376
470
|
|
|
377
471
|
## SQL in query() — important notes
|
|
378
472
|
|
|
379
|
-
|
|
473
|
+
`query()` is the **escape hatch** — reach for it only when the structured built-ins can't express the read/write (aggregates, multi-hop joins, CTEs, `FOR UPDATE`). For row-shaped work prefer `select()` / `insert()` / `update()` / `delete()`: they validate entity codes against metadata, coerce values per column, and (for `insert`/`update`) maintain auto-fill and audit columns.
|
|
474
|
+
|
|
475
|
+
1. **Table names are schema-qualified** within the module: `crm.contact`, `wms.stock_movement`, `fin.invoice`. Use the module code as the schema prefix. (Module codes with a hyphen, e.g. `crm-fin`, cannot be schema-qualified in raw SQL — another reason to use the structured built-ins, which accept the qualified `module.entity` code.)
|
|
380
476
|
2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
|
|
381
477
|
3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
|
|
382
|
-
4. **`insert()` is preferred over `query('INSERT ...')
|
|
478
|
+
4. **`insert()` is preferred over `query('INSERT ...')`**, `update()`/`delete()` over `query('UPDATE/DELETE ...')`, and `select()` over `query('SELECT ...')` for module entities — raw SQL skips auto-fill (PKs, audit, number sequences) and metadata validation.
|
|
383
479
|
|
|
384
480
|
## Common mistakes
|
|
385
481
|
|
|
@@ -387,7 +483,10 @@ In `ui/actions.json`:
|
|
|
387
483
|
- Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
|
|
388
484
|
- Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
|
|
389
485
|
- Writing `insert('entity', field1, value1)` (positional args) — **wrong**. Use `insert('entity', { field: value })` (object).
|
|
390
|
-
- Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders.
|
|
486
|
+
- Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders. A structured read is `select('entity', { filter: … })`.
|
|
487
|
+
- Passing an **array of ids** as the `key` of `update()`/`delete()` — **rejected**. Loop over the ids and mutate one at a time.
|
|
488
|
+
- Selecting or string-filtering a plain reference (`R`) column in `select()` — **throws**. Use the FK column or a nav path (`'customer.company_name'`).
|
|
489
|
+
- Unqualified entity codes (`update('message', …)`) — works inside the owning module but **always prefer the qualified form** (`update('comm.message', …)`).
|
|
391
490
|
- Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
|
|
392
491
|
- Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
|
|
393
492
|
- Calling `TODAY()` or `NOW()` (uppercase) inside `execute:` — **wrong**, they're undefined there and install fails with `'TODAY' is not defined`. Use lowercase **`now()`** in `execute:`; `TODAY()`/`NOW()` are formula-only (`canExecute:`, formula columns).
|
|
@@ -10,9 +10,9 @@ Every column has a `columnType` (optional for the default physical type). Seven
|
|
|
10
10
|
| `"F"` | No | **Formula column** — virtual computed value. Evaluated by the formula engine. |
|
|
11
11
|
| `"A"` | Yes | **Accumulation register column** — stores posted state for accumulation registers. Advanced (accounting modules). |
|
|
12
12
|
| `"L"` | Yes | **Ledger register column** — stores posted state for double-entry bookkeeping. Advanced (accounting modules). |
|
|
13
|
-
| `"G"` | Yes | **Generated column** — DB-level computed
|
|
13
|
+
| `"G"` | Yes | **Generated column** — DB-level computed value. Two strategies, auto-detected from the formula: a **trigger-based aggregate over a child set** (`SUM([lines].[amount])`), or a same-row PostgreSQL `GENERATED ALWAYS AS` (`[qty] * [price]`). This is the correct type for **roll-up totals over child rows** (see below). The aggregated child column **must be physical** (a `D` column, or a same-row `G`) — never a virtual `F`/`R`/`S` column. |
|
|
14
14
|
|
|
15
|
-
**For most module development, you'll
|
|
15
|
+
**For most module development, you'll use D, R, S, and F.** Add `G` when you need a **roll-up total over a child set** (`SUM`/`COUNT`/`AVG`/`MIN`/`MAX`) — see below. Types A and L are for advanced accounting/registry modules that use the `postable`, `accumulation`, or `ledger` traits.
|
|
16
16
|
|
|
17
17
|
## Data columns (`columnType` omitted or `"D"`)
|
|
18
18
|
|
|
@@ -147,33 +147,43 @@ Do **not** include `dbDatatype` on formula columns (there's no physical storage)
|
|
|
147
147
|
|
|
148
148
|
See `formulas.md` for syntax.
|
|
149
149
|
|
|
150
|
-
## Roll-up totals over child rows — use `
|
|
150
|
+
## Roll-up totals over child rows — use `G`, not `F`
|
|
151
151
|
|
|
152
152
|
To total a child set on a header (line items → order total, movements → quantity on hand),
|
|
153
|
-
**use a
|
|
154
|
-
|
|
153
|
+
**use a Generated column (`"G"`) with `SUM([set].[field])`**. The installer detects the set
|
|
154
|
+
navigation and generates a database trigger on the child table that keeps the parent value current
|
|
155
|
+
on every child insert/update/delete. Because the value is **physically stored**, it displays
|
|
156
|
+
everywhere (grid, form, reports) and is **filterable and sortable** in queries.
|
|
155
157
|
|
|
156
158
|
```json
|
|
157
|
-
// purchase_order.total_amount —
|
|
159
|
+
// purchase_order.total_amount — trigger-maintained roll-up
|
|
158
160
|
"total_amount": {
|
|
159
|
-
"columnType": "
|
|
160
|
-
"
|
|
161
|
-
"
|
|
161
|
+
"columnType": "G",
|
|
162
|
+
"dbDatatype": "numeric(18,2)",
|
|
163
|
+
"formula": "SUM([lines].[amount])",
|
|
162
164
|
"flags": "V",
|
|
163
165
|
"orderNum": 90,
|
|
164
|
-
"formula": "SUM([lines].[line_total])", // line_total may itself be a formula column
|
|
165
166
|
"description": "Total Amount"
|
|
166
167
|
}
|
|
167
168
|
```
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
>
|
|
176
|
-
>
|
|
170
|
+
A `"G"` roll-up requires `dbDatatype` and `formula`; it must **not** have `link` or
|
|
171
|
+
`baseDatatypeCd`. Supported aggregate functions: `SUM`, `COUNT`, `AVG`, `MIN`, `MAX` (`SUM`/`COUNT`
|
|
172
|
+
default to `0` when there are no children; `MIN`/`MAX`/`AVG` return `NULL`). All set references
|
|
173
|
+
inside one aggregate must point at the **same** set column, and `COUNT(*)` is rejected — write
|
|
174
|
+
`COUNT([lines])`.
|
|
175
|
+
|
|
176
|
+
> ⛔ **Do not use a Formula (`F`) column for set aggregation.** `SUM([set].[field])` in an `F`
|
|
177
|
+
> column is **not supported** by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and
|
|
178
|
+
> nav resolution only walks single-hop N:1 references, never a 1:N set. The column silently renders
|
|
179
|
+
> **empty** on the form, grid, and reports (no error). `F` is for same-row expressions
|
|
180
|
+
> (`[qty] * [price]`) and single-hop reference navigation (`[account].[name]`) only.
|
|
181
|
+
|
|
182
|
+
> ⛔ **The aggregated child column must be physical.** A `G` trigger reads the child's `OLD`/`NEW`
|
|
183
|
+
> physical values, so aggregating a **virtual** child column (`F`/`R`/`S`) fails at install with
|
|
184
|
+
> `db_error: column old.<field> does not exist`. To roll up a *derived* quantity (e.g. a signed
|
|
185
|
+
> movement amount), make that child column physical first — a plain `D` column written by action
|
|
186
|
+
> logic, or a same-row `G`/`GENERATED ALWAYS AS` column with a `dbDatatype` — then aggregate that.
|
|
177
187
|
|
|
178
188
|
## Quick reference: which columnType do I use?
|
|
179
189
|
|
|
@@ -183,6 +193,6 @@ can reference **any** child column — including the child's own formula columns
|
|
|
183
193
|
- **"Compute full_name from first_name + last_name"** → `"F"`
|
|
184
194
|
- **"User picker" (points to admin.user)** → omit `columnType`, use `fieldTypeCd: "user"` (writes user ID directly, no paired FK)
|
|
185
195
|
- **"Polymorphic link to any entity"** → omit `columnType`, use `fieldTypeCd: "entitylink"` (stored as JSON)
|
|
186
|
-
- **"Roll-up total over child rows"** (line items → header total, movements → quantity on hand) → `"
|
|
196
|
+
- **"Roll-up total over child rows"** (line items → header total, movements → quantity on hand) → `"G"` with `SUM([set].[field])` (trigger-maintained, stored; see "Roll-up totals" above). Aggregate only a **physical** child column — never an `F`/`R`/`S` column. Do **not** use `"F"` for set aggregation — it silently renders empty.
|
|
187
197
|
- **"Accumulation register state"** → `"A"` (advanced accounting — requires `postable` + `accumulation` traits)
|
|
188
198
|
- **"Double-entry ledger state"** → `"L"` (advanced accounting — requires `postable` + `ledger` traits)
|
|
@@ -15,11 +15,13 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
15
15
|
| `fieldTypeCd` | `baseDatatypeCd` | Description | Common `dbDatatype` | Params |
|
|
16
16
|
|---|---|---|---|---|
|
|
17
17
|
| `text` | `string` | Single-line text | `varchar` (with `maxLen`) | `maxLen` |
|
|
18
|
+
| `avatar` | `string` | Person/company name rendered as an initials circle; optional photo. | `varchar` | `imageColumn` (an `image` column on the same entity to use as the photo) |
|
|
18
19
|
| `email` | `string` | Email address. Client-side format validation. | `varchar` 250 | — |
|
|
19
20
|
| `phone` | `string` | Phone/telephone. | `varchar` 50 | — |
|
|
20
21
|
| `url` | `string` | Web URL. | `varchar` 500 | — |
|
|
21
22
|
| `textarea` | `string` | Multi-line plain text. | `text` | `defaultLines`, `maxLines` |
|
|
22
|
-
| `
|
|
23
|
+
| `markdown` | `string` | Markdown editor (Write/Preview tabs), rendered sanitized. | `text` | — |
|
|
24
|
+
| `richtext` | `string` | Rich text WYSIWYG editor, stores sanitized HTML. | `text` | — |
|
|
23
25
|
| `code` | `string` | Code editor with syntax highlighting. | `text` | `language` |
|
|
24
26
|
| `dropdown` | `string` | Fixed list of options. | `varchar` | `options: ["a","b",…]` or `[{value,label,icon,color}]` |
|
|
25
27
|
| `flags` | `string` | Multiple checkboxes with letter flags. | `varchar` | `optionSets`, `style: "buttons"` |
|
|
@@ -67,6 +69,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
67
69
|
| `lookup` | `guid` | `R` | Reference to another entity (always paired with a hidden FK column — see SKILL.md "FK+Reference pattern") |
|
|
68
70
|
| `user` | `cuid` | `D` | User picker. Writes a user ID directly, no paired FK needed. |
|
|
69
71
|
| `grid` | `set` | `S` | Detail grid of related records (1:N backwards reference). Used with `link` declaring the relation. |
|
|
72
|
+
| `list` | `set` | `S` | Detail list — same 1:N set as `grid`, rendered as a card list instead of a table. |
|
|
70
73
|
| `entitylink` | `json` | `D` | Polymorphic link to any entity (stores `{entity, id}` pair). Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in (see `action-dsl.md`). |
|
|
71
74
|
|
|
72
75
|
## JSON
|
|
@@ -137,7 +140,8 @@ These are the field type names LLMs tend to invent. **They are all wrong.**
|
|
|
137
140
|
| `picture`, `photo` | `image` |
|
|
138
141
|
| `attachment` | `file` |
|
|
139
142
|
| `multiline` | `textarea` |
|
|
140
|
-
| `
|
|
143
|
+
| `md` | `markdown` |
|
|
144
|
+
| `richText`, `wysiwyg`, `html` | `richtext` |
|
|
141
145
|
| `userPicker` | `user` |
|
|
142
146
|
| `phoneNumber` | `phone` |
|
|
143
147
|
|
|
@@ -70,10 +70,15 @@ FORMAT([created_date], "yyyy-MM-dd")
|
|
|
70
70
|
|
|
71
71
|
- `TODAY()` — current date
|
|
72
72
|
- `NOW()` — current datetime with timezone
|
|
73
|
-
- `YEAR(d)`, `MONTH(d)`, `DAY(d)` — date parts
|
|
73
|
+
- `YEAR(d)`, `MONTH(d)`, `DAY(d)` — date parts (`MONTH` is 1-based)
|
|
74
74
|
- `HOUR(dt)`, `MINUTE(dt)`, `SECOND(dt)` — time parts
|
|
75
|
-
- `
|
|
76
|
-
- `
|
|
75
|
+
- `WEEKDAY(d)` — day of week, `0` = Sunday
|
|
76
|
+
- `DATE(y, m, d)` — build a date from parts; `DATE(v)` casts a value to a date
|
|
77
|
+
- `DATEADD(d, count, unit)` — add time; unit is a **string literal**: `'DAY'`, `'HOUR'`, `'MINUTE'`, `'SECOND'`, `'MONTH'`, `'YEAR'` (plural forms accepted)
|
|
78
|
+
- `DATEDIFF(d1, d2, unit)` — difference `d2 − d1` in the given unit (same unit list). `DAY`/`HOUR`/`MINUTE`/`SECOND` floor the elapsed time; `MONTH`/`YEAR` are calendar-component deltas. E.g. days overdue: `DATEDIFF([due_date], TODAY(), 'DAY')`
|
|
79
|
+
- `STARTMONTH(d?)`, `ENDMONTH(d?)`, `STARTQUARTER(d?)`, `ENDQUARTER(d?)`, `STARTYEAR(d?)`, `ENDYEAR(d?)`, `STARTNEXTMONTH(d?)` — period boundaries; the argument is optional and defaults to today
|
|
80
|
+
|
|
81
|
+
The whole date family evaluates identically **client-side and SQL-time** (reports, filters, sorts on formula columns) — the SQL translator mirrors the client runtime, including `MONTH` 1-based and `WEEKDAY` 0=Sunday. Unit arguments must be string literals; an unknown unit fails loud with the accepted list.
|
|
77
82
|
|
|
78
83
|
### Logical functions
|
|
79
84
|
|
|
@@ -83,19 +88,32 @@ FORMAT([created_date], "yyyy-MM-dd")
|
|
|
83
88
|
- `NULLIF(a, b)` — null if equal, else a
|
|
84
89
|
- `ISNULL(x)` — boolean null check
|
|
85
90
|
|
|
86
|
-
### Aggregation
|
|
91
|
+
### Aggregation over a child set — use a Generated (`G`) column, not `F`
|
|
87
92
|
|
|
88
|
-
- `
|
|
89
|
-
- `
|
|
93
|
+
- `SUM([set].[field])` — sum a child field over a set
|
|
94
|
+
- `COUNT([set])` — count related rows (`COUNT(*)` is rejected)
|
|
90
95
|
- `AVG`, `MIN`, `MAX` similarly
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
> ⚠️ **Set aggregation is NOT a Formula (`F`) feature.** `SUM([set].[field])` in an `F` column is
|
|
98
|
+
> unsupported by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and nav resolution
|
|
99
|
+
> only walks single-hop N:1 references, never a 1:N set. It silently renders **empty** (no error).
|
|
100
|
+
|
|
101
|
+
Put set aggregations in a **Generated (`G`) column** instead. The installer detects the set
|
|
102
|
+
navigation and maintains the value with a database trigger on the child table. The aggregated
|
|
103
|
+
child column must be **physical** (a `D` column, or a same-row `G`) — never a virtual `F`/`R`/`S`
|
|
104
|
+
column, which fails at install with `db_error: column old.<field> does not exist`. Full details
|
|
105
|
+
and the JSON shape: `column-types.md` → "Roll-up totals over child rows — use `G`, not `F`".
|
|
94
106
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
```json
|
|
108
|
+
"total_amount": {
|
|
109
|
+
"columnType": "G",
|
|
110
|
+
"dbDatatype": "numeric(18,2)",
|
|
111
|
+
"formula": "SUM([lines].[amount])",
|
|
112
|
+
"flags": "V",
|
|
113
|
+
"orderNum": 90,
|
|
114
|
+
"description": "Total Amount"
|
|
115
|
+
}
|
|
116
|
+
```
|
|
99
117
|
|
|
100
118
|
## Navigation (dot notation)
|
|
101
119
|
|
|
@@ -62,7 +62,7 @@ Scheduled actions run with **no record bound**. Specifically:
|
|
|
62
62
|
- `notify()` writes to inbox only (no live SSE in Phase 1)
|
|
63
63
|
- `sendEmail()` raw mode only in Phase 1
|
|
64
64
|
|
|
65
|
-
**Record context is forbidden.** The install pipeline rejects any action whose compiled DSL references `[field]` or `for x in records { … }` — those compile to `__r.` / `__records.` which would `ReferenceError` at fire time. Refactor to operate via `
|
|
65
|
+
**Record context is forbidden.** The install pipeline rejects any action whose compiled DSL references `[field]` or `for x in records { … }` — those compile to `__r.` / `__records.` which would `ReferenceError` at fire time. Refactor to operate via `select()` / `insert()` / `update()`:
|
|
66
66
|
|
|
67
67
|
```
|
|
68
68
|
execute:
|
|
@@ -132,7 +132,7 @@ Full source: [`modules/chore/`](../../../modules/chore/).
|
|
|
132
132
|
## Common mistakes
|
|
133
133
|
|
|
134
134
|
- **Cross-module action reference.** A job in `crm` cannot reference an action in `fin`. Declare a thin `crm.run_fin_thing` action in your own module and bind the job to that.
|
|
135
|
-
- **Record-bound action.** Any `[field]` or `for x in records` in the DSL fails install — the action has no record at fire time. Refactor to `
|
|
135
|
+
- **Record-bound action.** Any `[field]` or `for x in records` in the DSL fails install — the action has no record at fire time. Refactor to `select()`/`insert()`/`update()`.
|
|
136
136
|
- **Missing `timeout`.** Required, no default. Pick 30 s if you don't know; bump when you have evidence.
|
|
137
137
|
- **`timeout > 300` without `class: "long_running"`.** Install fails. Heavy jobs are an explicit opt-in.
|
|
138
138
|
- **Sub-minute cron.** The scheduler ticks every 60 s. Anything finer is dishonest — declare a minute cron.
|
|
@@ -7,7 +7,7 @@ Run through this checklist **before** declaring a module complete. Always start
|
|
|
7
7
|
These five have each caused a real install failure. Check them before the section-by-section pass:
|
|
8
8
|
|
|
9
9
|
- [ ] **DSL dates** — `execute:` blocks use lowercase `now()`, never `TODAY()`/`NOW()` (formula-only; otherwise install fails `'TODAY' is not defined`)
|
|
10
|
-
- [ ] **Roll-up totals** — a sum over a child set is a
|
|
10
|
+
- [ ] **Roll-up totals** — a sum over a child set is a Generated (`G`) column with `SUM([set].[field])` (`dbDatatype` + `formula`, no `link`/`baseDatatypeCd`), **not** a Formula (`F`) column (an `F` set-aggregate silently renders empty). The aggregated child column must be physical (`D` or same-row `G`) — never a virtual `F`/`R`/`S` child (otherwise `db_error: column old.<field> does not exist`)
|
|
11
11
|
- [ ] **Rights keys** — actions/reports/folders use a **colon** (`action:x`, `report:x`, `folder:x`); entities bare or cross-module-dotted; deny by omitting the key, never `""`
|
|
12
12
|
- [ ] **Manifest** — no `translations` key (auto-discovered; non-English locales go in `supportedLocales`)
|
|
13
13
|
- [ ] **Column defaults** — set via `formula` / `numberSequence` / DSL, never a `defaultValue` key on an entity field
|