@dforge-core/dforge-mcp 0.2.1 → 0.2.3
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 +225 -0
- package/dist/server.js +79 -29
- package/package.json +3 -3
- package/resources/docs/conventions.md +12 -9
- package/resources/docs/dsl-reference.md +93 -6
- package/resources/schemas/entity.schema.json +2 -2
- package/resources/schemas/triggers.schema.json +1 -1
- package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +1 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +78 -3
- package/skills/dforge-mcp-author/references/column-types.md +11 -4
- package/skills/dforge-mcp-author/references/conventions.md +4 -2
- package/skills/dforge-mcp-author/references/data-views.md +2 -2
- package/skills/dforge-mcp-author/references/field-types.md +1 -1
- package/skills/dforge-mcp-author/references/flags.md +37 -6
- package/skills/dforge-mcp-author/references/formulas.md +22 -4
- package/skills/dforge-mcp-author/references/schema-import.md +4 -2
- package/skills/dforge-mcp-author/references/validation-checklist.md +3 -2
- package/skills/dforge-module-build/SKILL.md +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,231 @@ 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.2.3
|
|
8
|
+
|
|
9
|
+
Catches up with the platform's current-user work: `currentUserId()` exists now,
|
|
10
|
+
and the three ways an author can get this wrong each get their own diagnostic
|
|
11
|
+
instead of a generic "unknown function".
|
|
12
|
+
|
|
13
|
+
### Fixed — `dforge_action_check` flagged real built-ins as unknown
|
|
14
|
+
|
|
15
|
+
`DSL_BUILTINS` is a hand-maintained mirror of `DslBuiltins.FunctionNames` in
|
|
16
|
+
dForge-core, and it had drifted: `addMinutes`, `applyProfile` and
|
|
17
|
+
`getFileBase64` were missing, so a module using them was told its correct code
|
|
18
|
+
was wrong. Nothing compared the two lists, which is why it went unnoticed.
|
|
19
|
+
`test/dsl-builtins-drift.test.ts` now asserts they match in both directions,
|
|
20
|
+
parsing the authoritative list out of a sibling dForge-core checkout.
|
|
21
|
+
|
|
22
|
+
### Added — `userId()` and `CURRENT_USER_ID()` diagnostics
|
|
23
|
+
|
|
24
|
+
- **`userId()`** is now an `error` (`user-id-called-as-function`) carrying the
|
|
25
|
+
platform's own sentence: *'userId' is a value, not a function — write 'userId'
|
|
26
|
+
without parentheses, or 'currentUserId()'*. It used to draw only a generic
|
|
27
|
+
`unknown-builtin` warning that called `userId` "not a DSL host function",
|
|
28
|
+
which is misleading — it is a host value, just not callable. The platform
|
|
29
|
+
rejects this at compile time now, so the checker matches its severity.
|
|
30
|
+
- **`CURRENT_USER_ID()`** in `execute:` joins `TODAY()`/`NOW()` as formula-only.
|
|
31
|
+
The message is per-function rather than date-specific, so it points at
|
|
32
|
+
`currentUserId()` here and `now()` there.
|
|
33
|
+
- **`currentUserId()`** and bare **`userId`** both check clean.
|
|
34
|
+
|
|
35
|
+
### Changed — reference docs
|
|
36
|
+
|
|
37
|
+
`dsl-reference.md` and the skill's `action-dsl.md` gain the full built-in list,
|
|
38
|
+
the formula-vs-`execute:` split for `CURRENT_USER_ID()`, and the 64-bit id
|
|
39
|
+
contract: ids are BigInt inside `execute:` and stay exact, arithmetic mixing
|
|
40
|
+
BigInt and Number throws, and a large id written as a bare numeric literal
|
|
41
|
+
rounds before the comparison runs. The skill reference also drops
|
|
42
|
+
`IF(condition, trueVal, falseVal)`, which it listed as an `execute:` ternary
|
|
43
|
+
helper — it is formula-only and the compiler rejects it.
|
|
44
|
+
|
|
45
|
+
## 0.2.2
|
|
46
|
+
|
|
47
|
+
Mirrors the platform change that gives the `M` column flag its meaning — in the
|
|
48
|
+
docs, in the schemas, and in the tools that emit flags — and catches up the
|
|
49
|
+
formula and `entityLink()` references with the platform work since 0.2.1.
|
|
50
|
+
|
|
51
|
+
### Fixed — the relation tools emitted `NOT NULL` for every optional relation
|
|
52
|
+
|
|
53
|
+
The `M` change turned an inert flag into a physical constraint, which made three
|
|
54
|
+
places that hard-coded it actively wrong. All three now take `M` from whether the
|
|
55
|
+
relation is actually required, and put it on **both** halves in step:
|
|
56
|
+
|
|
57
|
+
- **`dforge_entity_reference_add`** emitted the hidden FK as `flags: "EM"`
|
|
58
|
+
unconditionally, whatever `required` said. Post-change that made every optional
|
|
59
|
+
relation's FK column `NOT NULL` while its own Reference half said `"VE"` — a pair
|
|
60
|
+
that contradicts itself. It now emits FK `"EM"` + Reference `"VEM"` when
|
|
61
|
+
`required`, and FK `"E"` + Reference `"VE"` when not. Reusing a half-built FK
|
|
62
|
+
normalizes to the same shape, and drops a lingering `"isNullable": true` when the
|
|
63
|
+
relation is required rather than emitting a pair the platform rejects at pack.
|
|
64
|
+
- **`dforge_dbml_import` / `dforge_module_import`** did the same for every imported
|
|
65
|
+
FK. They now read the source column's nullability — a DBML `[not null]` on the FK
|
|
66
|
+
is carried onto the reference (the FK column itself is dropped from `columns`, so
|
|
67
|
+
it was being lost) and `required` is accepted explicitly on a table-spec reference.
|
|
68
|
+
- **`isHiddenFk()`** required `M` to recognize the hidden half of a pair, so an
|
|
69
|
+
optional FK (`"E"`) became invisible to every rule keyed off it. `M` is not part
|
|
70
|
+
of the shape — it marks the relation required.
|
|
71
|
+
- **Set columns** are emitted `"VE"`, not `"VEM"`: `M` is inert on a virtual column,
|
|
72
|
+
but it reads as "this grid must be non-empty", which nothing enforces.
|
|
73
|
+
|
|
74
|
+
### Added — the validator catches the `M` / `isNullable` contradiction
|
|
75
|
+
|
|
76
|
+
`dforge_module_validate` (and the authoring-time field schema) now error on a field
|
|
77
|
+
declaring `M` together with `"isNullable": true`, naming the field. The platform
|
|
78
|
+
rejects this at pack time; catching it here says which column while you can still
|
|
79
|
+
see it. On a virtual column the message says so instead of offering both fixes.
|
|
80
|
+
|
|
81
|
+
### Changed — the `M` column flag now means "required" (platform mirror)
|
|
82
|
+
|
|
83
|
+
- **`M` resolves to `isNullable: false` at install.** The flag was documented as
|
|
84
|
+
"mandatory" but was inert — nothing on the server or the client read it, so a
|
|
85
|
+
column declared `"flags": "VEM"` rendered without a red asterisk and saved
|
|
86
|
+
empty. It is now folded into `isNullable`, the platform's single notion of
|
|
87
|
+
required, and everything downstream keys off that: `NOT NULL` in generated
|
|
88
|
+
DDL, the `data.insert` required-column check, the client asterisk.
|
|
89
|
+
- **Declaring `M` together with `"isNullable": true` is now a hard error** at
|
|
90
|
+
pack time, naming the entity and every offending field — it is a contradiction
|
|
91
|
+
only the author can resolve. **This repo shipped exactly that combination** in
|
|
92
|
+
the extension example in `resources/docs/conventions.md` (`customer_id`, `"flags":
|
|
93
|
+
"EM"` with `"isNullable": true`), so a module authored from that pattern could
|
|
94
|
+
not pack. Corrected to `"flags": "E"`, matching the real `crm-fin` module.
|
|
95
|
+
- **The FK+Reference rule no longer prescribes `M` by default.** A hidden FK is
|
|
96
|
+
`flags: "E"`, or `"EM"` when the relationship is genuinely required; omitting
|
|
97
|
+
both leaves the column nullable, which is the default — so the examples no
|
|
98
|
+
longer spell out `"isNullable": true` either. Keep the visible Reference in
|
|
99
|
+
step (`VE` / `VEM`): `M` is inert on a virtual column, so `VEM` over an
|
|
100
|
+
optional FK changes nothing but reads as a required field to the next author,
|
|
101
|
+
which is precisely how the broken example above went unnoticed.
|
|
102
|
+
- **An upgrade now reconciles nullability in both directions.** Previously
|
|
103
|
+
neither direction reached an existing table: `ADD COLUMN IF NOT EXISTS` no-ops
|
|
104
|
+
on a column that is already there, and the only nullability statement emitted
|
|
105
|
+
was `DROP NOT NULL`, and that only for an *explicit* `isNullable: true`. So
|
|
106
|
+
adding `M` to a shipped field silently did nothing on upgraded tenants (it
|
|
107
|
+
worked on a fresh install, which goes through `CREATE TABLE`), and merely
|
|
108
|
+
deleting `M` left the constraint standing, failing later inserts with a raw
|
|
109
|
+
Postgres 23502.
|
|
110
|
+
- **Authoring consequence:** adding `M` to a field that existing tenant rows
|
|
111
|
+
leave empty now **aborts the upgrade**, rolled back, naming the column and its
|
|
112
|
+
NULL row count — rather than committing a tenant whose API enforces a rule its
|
|
113
|
+
data already breaks. Give the column a `params.serverDefault` if the install
|
|
114
|
+
should backfill it. Removing `M` is always safe.
|
|
115
|
+
- **`params.serverDefault` now works on a required column.** The platform's
|
|
116
|
+
required-for-insert check excluded `formula` and `numberSequence` but not
|
|
117
|
+
`serverDefault`, so an insert omitting such a column was rejected as missing
|
|
118
|
+
even though the INSERT would have supplied the value. An `on: "update"`
|
|
119
|
+
default stays required — it does not fire on insert.
|
|
120
|
+
- **Also corrected in `conventions.md`:** the hidden-FK examples carried
|
|
121
|
+
`"fieldTypeCd": "hidden"`, which makes install warn that a `cuid` column
|
|
122
|
+
(group *number*) is bound to a *text* field type and can misbind filter/DML
|
|
123
|
+
values at runtime (`operator does not exist: bigint = text`). A hidden FK is
|
|
124
|
+
`dbDatatype: "cuid"` + `flags` — no `fieldTypeCd`.
|
|
125
|
+
|
|
126
|
+
### Changed — formula navigation at SQL time is no longer single-hop
|
|
127
|
+
|
|
128
|
+
`formulas.md` and `column-types.md` still described the report/query translator as
|
|
129
|
+
single-hop-only, and told authors to keep report-bound formulas to one hop. That
|
|
130
|
+
stopped being true upstream:
|
|
131
|
+
|
|
132
|
+
- **Multi-hop `[a].[b].[c]` translates**, one `LEFT JOIN` per hop, each with the
|
|
133
|
+
same soft-deny rights check as a reference display column. A nav path may land on
|
|
134
|
+
a *formula* column of the referenced entity, whose own hops are joined off that
|
|
135
|
+
alias — which is what makes a two-level formula reachable through a reference.
|
|
136
|
+
- **Hops are counted from the query's root** and capped at `MaxNavigationDepth`, so
|
|
137
|
+
the hops spent reaching a formula reduce the budget left for its own navigation.
|
|
138
|
+
- **What is still unsupported changed shape:** `$[Setting]` references, paths past
|
|
139
|
+
the cap, **extension-table columns** (they live in a separate 1:1 table needing
|
|
140
|
+
its own JOIN), and misauthored paths. All come back NULL with a response warning.
|
|
141
|
+
- **Warning wording follows the clause** — a SELECT slot goes empty, an ORDER BY
|
|
142
|
+
term is dropped so rows come back in a different order, a WHERE condition is
|
|
143
|
+
dropped so *more* rows come back than were asked for. Documented, because the
|
|
144
|
+
third one is the only failure that silently widens a result set.
|
|
145
|
+
- **Comparison is null-safe and two-valued** in every context: `null = null` is
|
|
146
|
+
true, `[x] != null` is true for any non-null `x`, and SQL-time `=`/`!=` translate
|
|
147
|
+
to `IS NOT DISTINCT FROM` / `IS DISTINCT FROM` so a card and a report agree.
|
|
148
|
+
- Corrected in both files: nav resolution walks N:1 references only, **never a 1:N
|
|
149
|
+
set**, however many hops it is given — the reason `SUM([set].[field])` in an `F`
|
|
150
|
+
column renders empty. That was previously attributed to the single-hop limit.
|
|
151
|
+
|
|
152
|
+
### Changed — `entityLink()` stores the entity id and captions from `toString`
|
|
153
|
+
|
|
154
|
+
`entity_cd` is only unique per module, so storing the bare code meant a link built
|
|
155
|
+
as `entityLink('fin.invoice', …)` re-resolved at click time against the *reader's*
|
|
156
|
+
folder module and could land on another module's `invoice`. It now stores the
|
|
157
|
+
resolved entity id. The display caption comes from the entity's `toString`
|
|
158
|
+
evaluated against the record (an explicit `description` still wins) — previously
|
|
159
|
+
the entity's own description, which names the table, not the record. Documented in
|
|
160
|
+
`action-dsl.md`, `field-types.md` and the `dforge://reference/dsl` resource, with
|
|
161
|
+
the reminder to pass a qualified code.
|
|
162
|
+
|
|
163
|
+
### Changed — dependencies
|
|
164
|
+
|
|
165
|
+
- **`@dforge-core/metadata` `^0.0.10` → `^0.0.12`**, and the vendored schemas
|
|
166
|
+
re-generated from it. `entity.schema.json` and `triggers.schema.json` were stale:
|
|
167
|
+
they still described `M` as plain "Mandatory" with no mention of `isNullable`, and
|
|
168
|
+
omitted that DSL `insert()`/`update()`/`delete()`/`query()` writes to *other*
|
|
169
|
+
records raise no trigger events.
|
|
170
|
+
- **`@dforge-core/dforge-cli` `^0.2.7` → `^0.2.10`.**
|
|
171
|
+
|
|
172
|
+
## 0.2.1
|
|
173
|
+
|
|
174
|
+
Adds the column-domain schema that `dforge_module_inspect` already reported on,
|
|
175
|
+
and corrects three areas where the schemas and reference docs described
|
|
176
|
+
behavior the platform doesn't have.
|
|
177
|
+
|
|
178
|
+
### Added — column domains
|
|
179
|
+
|
|
180
|
+
- **`resources/schemas/domains.schema.json`** — `domains.json` had no schema, so
|
|
181
|
+
the one file that defines reusable semantic types was the only module file
|
|
182
|
+
authored blind. A domain bundles a base datatype, a control, sizing and a
|
|
183
|
+
shared option list under one code. Structural fields (`dbDatatype`,
|
|
184
|
+
`baseDatatypeCd`, `fieldTypeCd`, `maxLen`, `precision`) are materialized onto
|
|
185
|
+
each consuming column at install; `params` — most importantly `options` —
|
|
186
|
+
stays on the domain and resolves at metadata-load time, which is what lets a
|
|
187
|
+
shared list be authored and translated exactly once.
|
|
188
|
+
- **`entity.schema.json` gains `domain`** plus a *Domain-backed column* branch in
|
|
189
|
+
the column `oneOf`. Referencing a domain and restating what it owns is
|
|
190
|
+
rejected at install, so the schema now refuses the same combination rather
|
|
191
|
+
than letting it through to a failed install.
|
|
192
|
+
|
|
193
|
+
### Fixed — trigger semantics were documented wrong
|
|
194
|
+
|
|
195
|
+
`triggers.schema.json` and `dforge_trigger_add` both described an invocation
|
|
196
|
+
contract the runtime doesn't implement, and agents wrote actions against it:
|
|
197
|
+
|
|
198
|
+
- **There is no injected `record_id` param.** The trigger binds the affected
|
|
199
|
+
record to the action as *record context* — the action reads it with `[field]`
|
|
200
|
+
and its PK with `[pk_column]`, exactly like a UI-invoked action, with
|
|
201
|
+
pre-change values as `old[field]` on update/delete/status_change. `params` is
|
|
202
|
+
static-only: the literal values written in the trigger, nothing else.
|
|
203
|
+
- **The action's `entityCode` must equal the trigger's `entity`**, and its
|
|
204
|
+
`executionMode` must not be `batch` — both enforced at pack time. The old
|
|
205
|
+
description said the opposite, warning the action must *not* rely on the
|
|
206
|
+
triggering record.
|
|
207
|
+
- **`condition` rejects reference navigation.** The event-time evaluator reads
|
|
208
|
+
only columns of the changed row, so `[ref].[target]` resolves to null and the
|
|
209
|
+
trigger misfires. Navigate inside the action, or denormalise onto the watched
|
|
210
|
+
entity.
|
|
211
|
+
- **`async` defaults to `true`**, matching the platform. The tool defaulted it to
|
|
212
|
+
`false`, so every trigger added without an explicit `async` ran synchronously
|
|
213
|
+
inside the user's transaction — where a failing action rolls back their write.
|
|
214
|
+
|
|
215
|
+
### Fixed — actions are not reliably atomic
|
|
216
|
+
|
|
217
|
+
- **`isTransacted` does not open a transaction.** It only decides whether the
|
|
218
|
+
first failing record aborts the run or the loop continues. Whether writes made
|
|
219
|
+
before a failure survive depends on the execution path: `auditHistory` on the
|
|
220
|
+
entity, `single` vs `batch`, sync vs background. `action-dsl.md` now tabulates
|
|
221
|
+
all six combinations, and `dsl-reference.md` no longer claims `error()` rolls
|
|
222
|
+
back the whole transaction. This is a platform defect rather than a contract —
|
|
223
|
+
actions are intended to be atomic — so both documents say to validate before
|
|
224
|
+
writing anything, and not to design around either outcome.
|
|
225
|
+
- **`query()` runs on a pooled, shared connection**, which nothing checks
|
|
226
|
+
statically and neither document mentioned: `SET LOCAL` not `SET`,
|
|
227
|
+
`pg_advisory_xact_lock()` not `pg_advisory_lock()`, `ON COMMIT DROP` on temp
|
|
228
|
+
tables.
|
|
229
|
+
- Documented `old[fieldName]`, and the single-hop limit on `[ref].[target]` —
|
|
230
|
+
`[a].[b].[c]` is a compile error, not a slow path.
|
|
231
|
+
|
|
7
232
|
## 0.2.0
|
|
8
233
|
|
|
9
234
|
Tools go from 27 → 34; the single wizard skill splits into a router plus three
|
package/dist/server.js
CHANGED
|
@@ -31299,7 +31299,7 @@ var traits = [
|
|
|
31299
31299
|
cd: "sorting",
|
|
31300
31300
|
description: "Display order",
|
|
31301
31301
|
fields: {
|
|
31302
|
-
order_num: { dbDatatype: "int4", fieldTypeCd: "number", flags: "
|
|
31302
|
+
order_num: { dbDatatype: "int4", fieldTypeCd: "number", flags: "VE", orderNum: 9920, description: "Order" }
|
|
31303
31303
|
}
|
|
31304
31304
|
},
|
|
31305
31305
|
{ cd: "postable", description: "Document posting support (marker trait \u2014 actual state managed by A/L columns)", fields: {} },
|
|
@@ -31309,11 +31309,11 @@ var traits = [
|
|
|
31309
31309
|
cd: "period",
|
|
31310
31310
|
description: "Period entity with key format, closed flag, date range",
|
|
31311
31311
|
fields: {
|
|
31312
|
-
period_key: { dbDatatype: "varchar", fieldTypeCd: "text", flags: "
|
|
31313
|
-
description: { dbDatatype: "varchar", fieldTypeCd: "text", flags: "
|
|
31314
|
-
closed: { dbDatatype: "bool", fieldTypeCd: "checkbox", flags: "
|
|
31315
|
-
start_date: { dbDatatype: "date", fieldTypeCd: "date", flags: "
|
|
31316
|
-
end_date: { dbDatatype: "date", fieldTypeCd: "date", flags: "
|
|
31312
|
+
period_key: { dbDatatype: "varchar", fieldTypeCd: "text", flags: "VE", isPk: true, isNullable: false, maxLen: 20, orderNum: 1, description: "Period Key" },
|
|
31313
|
+
description: { dbDatatype: "varchar", fieldTypeCd: "text", flags: "VE", maxLen: 100, orderNum: 30, description: "Description" },
|
|
31314
|
+
closed: { dbDatatype: "bool", fieldTypeCd: "checkbox", flags: "VE", isNullable: false, formula: "false", orderNum: 40, description: "Closed" },
|
|
31315
|
+
start_date: { dbDatatype: "date", fieldTypeCd: "date", flags: "VE", orderNum: 50, description: "Start Date" },
|
|
31316
|
+
end_date: { dbDatatype: "date", fieldTypeCd: "date", flags: "VE", orderNum: 60, description: "End Date" }
|
|
31317
31317
|
}
|
|
31318
31318
|
}
|
|
31319
31319
|
];
|
|
@@ -32652,7 +32652,10 @@ var tableSpec = external_exports.object({
|
|
|
32652
32652
|
external_exports.object({
|
|
32653
32653
|
column: codeRe.describe("FK column on THIS table (a hidden cuid column is generated for it)."),
|
|
32654
32654
|
toTable: codeRe.describe("Target entity code."),
|
|
32655
|
-
toColumn: codeRe.optional().describe("Target PK column; defaults to {toTable}_id.")
|
|
32655
|
+
toColumn: codeRe.optional().describe("Target PK column; defaults to {toTable}_id."),
|
|
32656
|
+
required: external_exports.boolean().optional().describe(
|
|
32657
|
+
"Whether the relation is required \u2014 puts 'M' on both halves (FK 'EM', Reference 'VEM'), which is what makes the FK column NOT NULL. Default: nullable."
|
|
32658
|
+
)
|
|
32656
32659
|
})
|
|
32657
32660
|
).optional()
|
|
32658
32661
|
});
|
|
@@ -32764,13 +32767,19 @@ function buildImportedEntity(table) {
|
|
|
32764
32767
|
const references = {};
|
|
32765
32768
|
for (const ref of table.references ?? []) {
|
|
32766
32769
|
const otherKey = ref.toColumn ?? `${ref.toTable}_id`;
|
|
32767
|
-
|
|
32770
|
+
const required2 = ref.required === true || table.columns.find((c) => c.name === ref.column)?.required === true;
|
|
32771
|
+
fields[ref.column] = {
|
|
32772
|
+
dbDatatype: "cuid",
|
|
32773
|
+
flags: required2 ? "EM" : "E",
|
|
32774
|
+
orderNum: order,
|
|
32775
|
+
description: titleCase(ref.column)
|
|
32776
|
+
};
|
|
32768
32777
|
order += 10;
|
|
32769
32778
|
const refName = ref.column.endsWith("_id") ? ref.column.slice(0, -3) : `${ref.column}_ref`;
|
|
32770
32779
|
fields[refName] = {
|
|
32771
32780
|
columnType: "R",
|
|
32772
32781
|
fieldTypeCd: "lookup",
|
|
32773
|
-
flags: "VEM",
|
|
32782
|
+
flags: required2 ? "VEM" : "VE",
|
|
32774
32783
|
orderNum: order,
|
|
32775
32784
|
description: titleCase(refName),
|
|
32776
32785
|
link: { entity: ref.toTable, thisKey: ref.column, otherKey }
|
|
@@ -32864,7 +32873,13 @@ function parseDbml(dbml) {
|
|
|
32864
32873
|
const references = t.refs.map((r) => {
|
|
32865
32874
|
const targetPk = byName.get(r.toTable)?.pk;
|
|
32866
32875
|
const toColumn = targetPk && r.toColumn === targetPk ? void 0 : r.toColumn;
|
|
32867
|
-
|
|
32876
|
+
const required2 = t.columns.find((c) => c.name === r.column)?.required === true;
|
|
32877
|
+
return {
|
|
32878
|
+
column: r.column,
|
|
32879
|
+
toTable: r.toTable,
|
|
32880
|
+
...toColumn ? { toColumn } : {},
|
|
32881
|
+
...required2 ? { required: true } : {}
|
|
32882
|
+
};
|
|
32868
32883
|
});
|
|
32869
32884
|
const columns = t.columns.filter((c) => !refColNames.has(c.name));
|
|
32870
32885
|
const spec = { name: t.name, columns: columns.length ? columns : [{ name: "name", sqlType: "varchar" }] };
|
|
@@ -33094,7 +33109,13 @@ function checkFieldSpec(where, field) {
|
|
|
33094
33109
|
}
|
|
33095
33110
|
if (typeof v.flags === "string" && v.flags.length > 0 && !/^[VIEMH]+$/.test(v.flags)) {
|
|
33096
33111
|
err(
|
|
33097
|
-
`flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M/H (e.g. VEM, VE, V,
|
|
33112
|
+
`flags '${v.flags}' contains invalid letters \u2014 use only V/I/E/M/H (e.g. VEM, VE, V, E). U/S/P are not flag letters.`
|
|
33113
|
+
);
|
|
33114
|
+
}
|
|
33115
|
+
if (typeof v.flags === "string" && v.flags.includes("M") && v.isNullable === true) {
|
|
33116
|
+
const virtual = typeof v.columnType === "string" && ["R", "S", "F"].includes(v.columnType);
|
|
33117
|
+
err(
|
|
33118
|
+
`flags '${v.flags}' includes 'M' (mandatory) while "isNullable": true \u2014 a contradiction that fails at pack time. 'M' is resolved into isNullable:false at install. Drop 'M' if the column is optional, or remove "isNullable": true if it is required.${virtual ? " (On a virtual column 'M' is inert anyway \u2014 drop it.)" : ""}`
|
|
33098
33119
|
);
|
|
33099
33120
|
}
|
|
33100
33121
|
if (typeof v.fieldTypeCd === "string" && v.fieldTypeCd.length > 0 && !isFieldTypeCd(v.fieldTypeCd)) {
|
|
@@ -33179,7 +33200,7 @@ function checkFieldSpec(where, field) {
|
|
|
33179
33200
|
}
|
|
33180
33201
|
if (v.dbDatatype !== void 0) {
|
|
33181
33202
|
err(
|
|
33182
|
-
"reference column (columnType 'R') must NOT set 'dbDatatype' \u2014 the physical column is the paired hidden FK (flags 'EM'), not this one."
|
|
33203
|
+
"reference column (columnType 'R') must NOT set 'dbDatatype' \u2014 the physical column is the paired hidden FK (flags 'EM' when required, else 'E'), not this one."
|
|
33183
33204
|
);
|
|
33184
33205
|
}
|
|
33185
33206
|
}
|
|
@@ -33227,9 +33248,10 @@ var fieldSchema = external_exports.object({
|
|
|
33227
33248
|
}
|
|
33228
33249
|
}).describe(
|
|
33229
33250
|
`Field spec. RULES (load dforge://reference/flags, /field-types, /column-types first):
|
|
33230
|
-
\u2022 flags = a subset of V/E/M only. NEVER combine I or H with them. VEM = required+visible; VE = optional+visible; V = read-only/formula; EM = hidden FK. 'VEMHI' is INVALID.
|
|
33251
|
+
\u2022 flags = a subset of V/E/M only. NEVER combine I or H with them. VEM = required+visible; VE = optional+visible; V = read-only/formula; EM/E = hidden FK (required/optional). 'VEMHI' is INVALID.
|
|
33252
|
+
\u2022 'M' means required: it is resolved into isNullable:false at install, so it makes the column NOT NULL. Declaring 'M' AND "isNullable": true fails the pack. 'M' is inert on virtual (R/S/F) and identity columns \u2014 don't put it there.
|
|
33231
33253
|
\u2022 dbDatatype is AUTO-DERIVED from fieldTypeCd when omitted (e.g. currency \u2192 numeric(18,2), text \u2192 varchar) \u2014 only set it to override. Values: bool, varchar, text, int, bigint, numeric, timestamptz, date, time, cuid, json. NOT boolean/string/datetime/integer/timestamp/number \u2014 'number' is a fieldTypeCd, not a dbDatatype.
|
|
33232
|
-
\u2022 A relation is TWO fields: hidden FK (dbDatatype:'cuid', flags:'EM', NO fieldTypeCd) + a Reference (columnType:'R', fieldTypeCd:'lookup', flags:'VEM', link:{entity,thisKey,otherKey}). otherKey = the target entity's PK ('{entity}_id'), never 'id'.
|
|
33254
|
+
\u2022 A relation is TWO fields: hidden FK (dbDatatype:'cuid', flags:'EM' if required else 'E', NO fieldTypeCd) + a Reference (columnType:'R', fieldTypeCd:'lookup', flags:'VEM'/'VE' to match, link:{entity,thisKey,otherKey}). Keep the two halves in step. otherKey = the target entity's PK ('{entity}_id'), never 'id'. Prefer dforge_entity_reference_add, which emits both.
|
|
33233
33255
|
\u2022 Formula column: columnType:'F', baseDatatypeCd set, NO dbDatatype, flags:'V'.
|
|
33234
33256
|
\u2022 Column DEFAULTS use 'formula' (e.g. "'draft'" or "TODAY()"), NOT 'defaultValue' (settings-only).
|
|
33235
33257
|
\u2022 dropdown options go under params.options = [{value,label}] objects, never at the field root and never bare strings.`
|
|
@@ -33324,7 +33346,9 @@ var entityReferenceAddSchema = {
|
|
|
33324
33346
|
),
|
|
33325
33347
|
fkField: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).optional().describe("Override the hidden FK column name. Default: '<name>_id'."),
|
|
33326
33348
|
label: external_exports.string().optional().describe("Display label for the reference column. Default: derived from name."),
|
|
33327
|
-
required: external_exports.boolean().default(true).describe(
|
|
33349
|
+
required: external_exports.boolean().default(true).describe(
|
|
33350
|
+
"Whether the relation is required. true \u2192 FK 'EM' + Reference 'VEM'; false \u2192 FK 'E' + Reference 'VE'. 'M' resolves to isNullable:false at install, so this is what makes the FK column NOT NULL."
|
|
33351
|
+
),
|
|
33328
33352
|
targetPk: external_exports.string().regex(/^[a-z][a-z0-9_]*$/).optional().describe(
|
|
33329
33353
|
"The parent's PK column. Default '{targetEntity}_id' (what the identity trait provides). Never 'id'."
|
|
33330
33354
|
),
|
|
@@ -33352,16 +33376,23 @@ function entityReferenceAdd(args) {
|
|
|
33352
33376
|
);
|
|
33353
33377
|
}
|
|
33354
33378
|
const reusedFk = Boolean(existingFk);
|
|
33379
|
+
const fkFlags = args.required ? "EM" : "E";
|
|
33380
|
+
const refFlags = args.required ? "VEM" : "VE";
|
|
33355
33381
|
const fkFixes = [];
|
|
33356
33382
|
if (existingFk) {
|
|
33357
|
-
const
|
|
33383
|
+
const flags = typeof existingFk.flags === "string" ? existingFk.flags : "";
|
|
33358
33384
|
if (existingFk.dbDatatype !== "cuid") {
|
|
33359
33385
|
fkFixes.push(
|
|
33360
33386
|
`dbDatatype ${existingFk.dbDatatype === void 0 ? "(unset)" : `'${String(existingFk.dbDatatype)}'`} \u2192 'cuid' (the target PK type)`
|
|
33361
33387
|
);
|
|
33362
33388
|
}
|
|
33363
|
-
if (
|
|
33364
|
-
fkFixes.push(
|
|
33389
|
+
if (flags !== fkFlags) {
|
|
33390
|
+
fkFixes.push(
|
|
33391
|
+
`flags '${flags || "(unset)"}' \u2192 '${fkFlags}' (hidden, not user-visible; ${args.required ? "'M' because the relation is required" : "no 'M' because the relation is optional"})`
|
|
33392
|
+
);
|
|
33393
|
+
}
|
|
33394
|
+
if (args.required && existingFk.isNullable === true) {
|
|
33395
|
+
fkFixes.push(`dropped "isNullable": true (contradicts the 'M' flag on a required relation; pack rejects the pair)`);
|
|
33365
33396
|
}
|
|
33366
33397
|
if (existingFk.fieldTypeCd !== void 0) {
|
|
33367
33398
|
fkFixes.push(
|
|
@@ -33380,21 +33411,21 @@ function entityReferenceAdd(args) {
|
|
|
33380
33411
|
}
|
|
33381
33412
|
}
|
|
33382
33413
|
const label = args.label ?? pascal(args.name).replace(/([a-z])([A-Z])/g, "$1 $2");
|
|
33383
|
-
const flags = args.required ? "VEM" : "VE";
|
|
33384
33414
|
const base = args.orderNum ?? nextOrderNum(fields);
|
|
33385
33415
|
if (existingFk) {
|
|
33386
|
-
const { fieldTypeCd: _ft, columnType: _ct, ...
|
|
33416
|
+
const { fieldTypeCd: _ft, columnType: _ct, isNullable: _in, ...rest } = existingFk;
|
|
33417
|
+
const keep = args.required ? rest : { ...rest, ..._in === void 0 ? {} : { isNullable: _in } };
|
|
33387
33418
|
fields[fkField] = {
|
|
33388
33419
|
...keep,
|
|
33389
33420
|
dbDatatype: "cuid",
|
|
33390
|
-
flags:
|
|
33421
|
+
flags: fkFlags,
|
|
33391
33422
|
orderNum: typeof existingFk.orderNum === "number" ? existingFk.orderNum : base,
|
|
33392
33423
|
description: typeof existingFk.description === "string" ? existingFk.description : `${label} ID`
|
|
33393
33424
|
};
|
|
33394
33425
|
} else {
|
|
33395
33426
|
fields[fkField] = {
|
|
33396
33427
|
dbDatatype: "cuid",
|
|
33397
|
-
flags:
|
|
33428
|
+
flags: fkFlags,
|
|
33398
33429
|
orderNum: base,
|
|
33399
33430
|
description: `${label} ID`
|
|
33400
33431
|
};
|
|
@@ -33402,7 +33433,7 @@ function entityReferenceAdd(args) {
|
|
|
33402
33433
|
fields[args.name] = {
|
|
33403
33434
|
columnType: "R",
|
|
33404
33435
|
fieldTypeCd: "lookup",
|
|
33405
|
-
flags,
|
|
33436
|
+
flags: refFlags,
|
|
33406
33437
|
orderNum: base + 5,
|
|
33407
33438
|
description: label,
|
|
33408
33439
|
link: { entity: args.targetEntity, thisKey: fkField, otherKey: targetPk }
|
|
@@ -33499,7 +33530,9 @@ function entityRollupAdd(args) {
|
|
|
33499
33530
|
fields[setField] = {
|
|
33500
33531
|
columnType: "S",
|
|
33501
33532
|
fieldTypeCd: "grid",
|
|
33502
|
-
|
|
33533
|
+
// No 'M': a Set is virtual, so 'M' is inert on it — but it would read as
|
|
33534
|
+
// "this grid must be non-empty", which nothing enforces.
|
|
33535
|
+
flags: "VE",
|
|
33503
33536
|
orderNum: nextOrderNum(fields),
|
|
33504
33537
|
description: pascal(setField).replace(/([a-z])([A-Z])/g, "$1 $2"),
|
|
33505
33538
|
link: { entity: args.childEntity, thisKey: pkOf(args.entity), otherKey: childFk }
|
|
@@ -34144,10 +34177,13 @@ var path9 = __toESM(require("path"));
|
|
|
34144
34177
|
var BLOCKS = ["params", "canExecute", "onBeforeStart", "execute"];
|
|
34145
34178
|
var DSL_BUILTINS = /* @__PURE__ */ new Set([
|
|
34146
34179
|
"addDays",
|
|
34180
|
+
"addMinutes",
|
|
34147
34181
|
"addSeconds",
|
|
34182
|
+
"applyProfile",
|
|
34148
34183
|
"callApi",
|
|
34149
34184
|
"callProc",
|
|
34150
34185
|
"callService",
|
|
34186
|
+
"currentUserId",
|
|
34151
34187
|
"delete",
|
|
34152
34188
|
"detectDocument",
|
|
34153
34189
|
"download",
|
|
@@ -34155,6 +34191,7 @@ var DSL_BUILTINS = /* @__PURE__ */ new Set([
|
|
|
34155
34191
|
"error",
|
|
34156
34192
|
"exit",
|
|
34157
34193
|
"flush",
|
|
34194
|
+
"getFileBase64",
|
|
34158
34195
|
"getFileInfo",
|
|
34159
34196
|
"getFileUrl",
|
|
34160
34197
|
"getRecord",
|
|
@@ -34208,7 +34245,12 @@ var JS_RESERVED = /* @__PURE__ */ new Set([
|
|
|
34208
34245
|
"RegExp",
|
|
34209
34246
|
"Error"
|
|
34210
34247
|
]);
|
|
34211
|
-
var
|
|
34248
|
+
var VALUE_MEMBERS = /* @__PURE__ */ new Set(["userId"]);
|
|
34249
|
+
var FORMULA_ONLY = {
|
|
34250
|
+
TODAY: "now()",
|
|
34251
|
+
NOW: "now()",
|
|
34252
|
+
CURRENT_USER_ID: "currentUserId()"
|
|
34253
|
+
};
|
|
34212
34254
|
function blank(src) {
|
|
34213
34255
|
let out = "";
|
|
34214
34256
|
let i = 0;
|
|
@@ -34337,17 +34379,25 @@ function checkDsl(src, opts = {}) {
|
|
|
34337
34379
|
const execCode = execBlock ? code2.slice(execBlock.start, execBlock.end) : "";
|
|
34338
34380
|
const execOffset = execBlock ? execBlock.start : 0;
|
|
34339
34381
|
if (execBlock) {
|
|
34340
|
-
for (const fn of FORMULA_ONLY) {
|
|
34382
|
+
for (const [fn, replacement] of Object.entries(FORMULA_ONLY)) {
|
|
34341
34383
|
const re = new RegExp(`\\b${fn}\\s*\\(\\s*\\)`, "g");
|
|
34342
34384
|
for (const m of execCode.matchAll(re)) {
|
|
34343
34385
|
add(
|
|
34344
34386
|
"error",
|
|
34345
34387
|
"execute-formula-date",
|
|
34346
|
-
`the execute: block calls ${fn}(), which is undefined at runtime \u2014 install fails with "'${fn}' is not defined". Use
|
|
34388
|
+
`the execute: block calls ${fn}(), which is undefined at runtime \u2014 install fails with "'${fn}' is not defined". Use ${replacement} in execute:. ${fn}() is formula-only (canExecute:, formula columns).`,
|
|
34347
34389
|
lineOf(src, execOffset + (m.index ?? 0))
|
|
34348
34390
|
);
|
|
34349
34391
|
}
|
|
34350
34392
|
}
|
|
34393
|
+
for (const m of execCode.matchAll(/\buserId\s*\(/g)) {
|
|
34394
|
+
add(
|
|
34395
|
+
"error",
|
|
34396
|
+
"user-id-called-as-function",
|
|
34397
|
+
"'userId' is a value, not a function \u2014 write 'userId' without parentheses, or 'currentUserId()'.",
|
|
34398
|
+
lineOf(src, execOffset + (m.index ?? 0))
|
|
34399
|
+
);
|
|
34400
|
+
}
|
|
34351
34401
|
}
|
|
34352
34402
|
const recordRefs = [...code2.matchAll(/(?<![\w\]$])\[([a-z][a-z0-9_]*)\]/g)];
|
|
34353
34403
|
const mode = (opts.executionMode ?? "").toLowerCase();
|
|
@@ -34411,7 +34461,7 @@ function checkDsl(src, opts = {}) {
|
|
|
34411
34461
|
const reported = /* @__PURE__ */ new Set();
|
|
34412
34462
|
for (const m of code2.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
34413
34463
|
const fn = m[1];
|
|
34414
|
-
if (DSL_BUILTINS.has(fn) || JS_RESERVED.has(fn) || local.has(fn) || reported.has(fn) || FORMULA_ONLY.
|
|
34464
|
+
if (DSL_BUILTINS.has(fn) || JS_RESERVED.has(fn) || local.has(fn) || reported.has(fn) || fn in FORMULA_ONLY || VALUE_MEMBERS.has(fn)) {
|
|
34415
34465
|
continue;
|
|
34416
34466
|
}
|
|
34417
34467
|
reported.add(fn);
|
|
@@ -36505,7 +36555,7 @@ server.registerTool(
|
|
|
36505
36555
|
"dforge_entity_reference_add",
|
|
36506
36556
|
{
|
|
36507
36557
|
title: "Add a relation (FK + Reference)",
|
|
36508
|
-
description: "PHASE 1: Add a relation between two entities in ONE call. Emits the complete FK+Reference pattern \u2014 the documented #1 source of broken modules \u2014 as all three of its parts: the hidden FK column (dbDatatype 'cuid', flags 'EM', no fieldTypeCd), the visible Reference column (columnType 'R', fieldTypeCd 'lookup', flags VEM/VE, link{entity,thisKey,otherKey}), and the `references` block entry. Use this instead of two dforge_entity_field_add calls \u2014 it can't emit the one-column form that fails install.",
|
|
36558
|
+
description: "PHASE 1: Add a relation between two entities in ONE call. Emits the complete FK+Reference pattern \u2014 the documented #1 source of broken modules \u2014 as all three of its parts: the hidden FK column (dbDatatype 'cuid', flags 'EM'/'E', no fieldTypeCd), the visible Reference column (columnType 'R', fieldTypeCd 'lookup', flags 'VEM'/'VE', link{entity,thisKey,otherKey}), and the `references` block entry. `required` sets 'M' on both halves in step \u2014 'M' resolves to isNullable:false at install, so it is what makes the FK NOT NULL. Use this instead of two dforge_entity_field_add calls \u2014 it can't emit the one-column form that fails install.",
|
|
36509
36559
|
inputSchema: { ...entityReferenceAddSchema, ...applyInput }
|
|
36510
36560
|
},
|
|
36511
36561
|
envelope(entityReferenceAdd)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes 34 scaffold/patch/validate/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",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"install-skills": "node scripts/install-skills.mjs"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@dforge-core/dforge-cli": "^0.2.
|
|
38
|
-
"@dforge-core/metadata": "^0.0.
|
|
37
|
+
"@dforge-core/dforge-cli": "^0.2.10",
|
|
38
|
+
"@dforge-core/metadata": "^0.0.12",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
40
40
|
"zod": "^4.4.3"
|
|
41
41
|
},
|
|
@@ -135,15 +135,14 @@ The entity file uses the same `fields` schema as regular entities, plus an `exte
|
|
|
135
135
|
"fields": {
|
|
136
136
|
"customer_id": {
|
|
137
137
|
"dbDatatype": "int8",
|
|
138
|
-
"
|
|
139
|
-
"flags": "EM",
|
|
138
|
+
"flags": "E",
|
|
140
139
|
"orderNum": 34,
|
|
141
140
|
"description": "Customer FK"
|
|
142
141
|
},
|
|
143
142
|
"customer": {
|
|
144
143
|
"columnType": "R",
|
|
145
144
|
"fieldTypeCd": "lookup",
|
|
146
|
-
"flags": "
|
|
145
|
+
"flags": "VE",
|
|
147
146
|
"orderNum": 35,
|
|
148
147
|
"description": "Customer",
|
|
149
148
|
"link": {
|
|
@@ -641,7 +640,6 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
|
|
|
641
640
|
},
|
|
642
641
|
"department_id": {
|
|
643
642
|
"dbDatatype": "cuid",
|
|
644
|
-
"fieldTypeCd": "hidden",
|
|
645
643
|
"flags": "EM",
|
|
646
644
|
"orderNum": 30,
|
|
647
645
|
"description": "Department ID"
|
|
@@ -715,8 +713,7 @@ For every foreign key relationship, create TWO columns:
|
|
|
715
713
|
```json
|
|
716
714
|
"department_id": {
|
|
717
715
|
"dbDatatype": "cuid",
|
|
718
|
-
"
|
|
719
|
-
"flags": "EM",
|
|
716
|
+
"flags": "E",
|
|
720
717
|
"orderNum": 30,
|
|
721
718
|
"description": "Department ID"
|
|
722
719
|
}
|
|
@@ -727,7 +724,7 @@ For every foreign key relationship, create TWO columns:
|
|
|
727
724
|
"department": {
|
|
728
725
|
"columnType": "R",
|
|
729
726
|
"fieldTypeCd": "lookup",
|
|
730
|
-
"flags": "
|
|
727
|
+
"flags": "VE",
|
|
731
728
|
"orderNum": 35,
|
|
732
729
|
"description": "Department",
|
|
733
730
|
"link": {
|
|
@@ -752,8 +749,14 @@ For every foreign key relationship, create TWO columns:
|
|
|
752
749
|
- Reference columns use `"link"` (not `"params"`) for entity binding
|
|
753
750
|
- `link.entity` = target entity code, `link.thisKey` = local FK field, `link.otherKey` = remote PK field
|
|
754
751
|
- `params` is used for other purposes (e.g., dropdown `options`)
|
|
755
|
-
- Hidden FK column: `flags: "
|
|
756
|
-
|
|
752
|
+
- Hidden FK column: `flags: "E"` (no `V` = hidden from UI), `flags: "EM"` when the
|
|
753
|
+
relationship is genuinely required. `M` resolves to `isNullable: false` at install, so it
|
|
754
|
+
makes the FK `NOT NULL`; omitting both leaves the column nullable, which is the default.
|
|
755
|
+
Declaring `M` alongside `"isNullable": true` fails at pack time.
|
|
756
|
+
- Visible reference column: `columnType: "R"`, `flags: "VE"` — or `"VEM"` when the FK is
|
|
757
|
+
required. **Keep the two in step.** `M` is inert on a virtual column (a Reference is
|
|
758
|
+
required exactly when its hidden FK is), so `VEM` over an optional FK changes nothing but
|
|
759
|
+
reads as a required field to the next author.
|
|
757
760
|
- The FK column's `dbDatatype` **MUST match the referenced PK's type** — use `cuid` for `identity`-trait PKs (`cuid` is physically `int8`, **not** a UUID). A mismatch (e.g. FK `uuid` → PK `cuid`) fails install with *"foreign key constraint … cannot be implemented"*.
|
|
758
761
|
|
|
759
762
|
---
|
|
@@ -149,7 +149,10 @@ var open = query(
|
|
|
149
149
|
{ s: 'open', cid: [customer_id] }
|
|
150
150
|
)
|
|
151
151
|
if (open.length == 0) { exit('Nothing open', 'info') }
|
|
152
|
-
|
|
152
|
+
for (var i = 0; i < open.length; i++) {
|
|
153
|
+
update('fin.invoice', open[i].invoice_id, { status: 'reminded' })
|
|
154
|
+
}
|
|
155
|
+
info('Reminded ' + open.length + ' open invoice(s)')
|
|
153
156
|
```
|
|
154
157
|
|
|
155
158
|
> **Pooled connection.** The connection is handed to another request once your
|
|
@@ -228,7 +231,8 @@ The result is a **read-only** snapshot — writing to it (`rec.set(...)`) throws
|
|
|
228
231
|
|
|
229
232
|
```dsl
|
|
230
233
|
var rec = getRecord('crm.customer', [customer_id])
|
|
231
|
-
|
|
234
|
+
if (rec.credit_limit == null) { error('Customer has no credit limit set') }
|
|
235
|
+
[customer_name] = rec.name
|
|
232
236
|
```
|
|
233
237
|
|
|
234
238
|
#### `getRecordOrNull(entityCd, key) → record | null`
|
|
@@ -245,7 +249,7 @@ Call a stored procedure with named args. Returns the proc's result row (or rows
|
|
|
245
249
|
|
|
246
250
|
```dsl
|
|
247
251
|
var summary = callProc('fin.compute_balance', { account_id: [account_id], as_of: now() })
|
|
248
|
-
|
|
252
|
+
[closing_balance] = summary.balance
|
|
249
253
|
```
|
|
250
254
|
|
|
251
255
|
#### `preloadRef(fkField)`
|
|
@@ -284,11 +288,75 @@ Fractional-unit arithmetic.
|
|
|
284
288
|
[token_expires_at] = addMinutes(now(), 15)
|
|
285
289
|
```
|
|
286
290
|
|
|
291
|
+
### Current user
|
|
292
|
+
|
|
293
|
+
#### `currentUserId() → long`, `userId → long`
|
|
294
|
+
The acting user's tenant user ID. Two spellings for the same value — `userId` is
|
|
295
|
+
a **bare identifier**, `currentUserId()` is the call form. Both are always
|
|
296
|
+
available; use whichever reads better.
|
|
297
|
+
|
|
298
|
+
```dsl
|
|
299
|
+
[approved_by_id] = userId
|
|
300
|
+
[created_by_id] = currentUserId()
|
|
301
|
+
notify(userId, 'Done')
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`userId()` — the value spelled as a call — is **rejected at compile time**:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
'userId' is a value, not a function — write 'userId' without parentheses, or 'currentUserId()'
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`CURRENT_USER_ID()` is **not** available in `execute:` — it is a *formula* function,
|
|
311
|
+
valid in `canExecute:`, formula columns, filters and reports, exactly like
|
|
312
|
+
`TODAY()`/`NOW()` vs `now()`. Same split, same reason: `execute:` is JavaScript,
|
|
313
|
+
the rest is the formula engine.
|
|
314
|
+
|
|
315
|
+
#### 64-bit ids are BigInt
|
|
316
|
+
|
|
317
|
+
JS numbers are doubles (53 mantissa bits); dForge ids are 58-bit snowflakes. Any
|
|
318
|
+
`long` outside ±2^53 — user ids, PKs, FKs — reaches the script as a JS **BigInt**,
|
|
319
|
+
so ids stay exact through reads, writes and comparisons. Smaller `int8` values
|
|
320
|
+
(counters, quantities) stay ordinary numbers, so arithmetic on them is unchanged.
|
|
321
|
+
|
|
322
|
+
```dsl
|
|
323
|
+
[copy] = [owner_id] # exact round-trip
|
|
324
|
+
[is_mine] = currentUserId() == [last_updated_by] # exact — both sides BigInt
|
|
325
|
+
[label] = '' + [owner_id] # "200650220910936067"
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Two operations throw instead of rounding silently:
|
|
329
|
+
|
|
330
|
+
```dsl
|
|
331
|
+
[x] = [owner_id] + 1 # Cannot mix BigInt and other types
|
|
332
|
+
[x] = Number([owner_id]) + 1 # explicit — and accepts the rounding
|
|
333
|
+
|
|
334
|
+
[x] = JSON.stringify([owner_id]) # Do not know how to serialize a BigInt
|
|
335
|
+
[payload] = { id: [owner_id] } # assign the object; jsonb writes the number
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Never write a large id as a bare numeric literal — the literal itself rounds
|
|
339
|
+
before the comparison runs:
|
|
340
|
+
|
|
341
|
+
| Comparison | Result |
|
|
342
|
+
|---|---|
|
|
343
|
+
| `[owner_id] == 200650220910936067` | ❌ literal rounded to `…064` |
|
|
344
|
+
| `[owner_id] == '200650220910936067'` | ✅ string coerces to BigInt |
|
|
345
|
+
| `[owner_id] == 200650220910936067n` | ✅ BigInt literal |
|
|
346
|
+
| `[owner_id] === '200650220910936067'` | ❌ strict equality, different types |
|
|
347
|
+
|
|
348
|
+
For "who did this", prefer the platform audit columns (`created_by` /
|
|
349
|
+
`last_updated_by`) — filled server-side, no script involved. For ownership
|
|
350
|
+
predicates in `canExecute:` or a formula column, use `CURRENT_USER_ID()`, which
|
|
351
|
+
the server translates to an exact `::bigint` SQL literal.
|
|
352
|
+
|
|
287
353
|
### Messaging & notifications
|
|
288
354
|
|
|
289
355
|
#### `info(message, opts?)`, `warn(message, opts?)`
|
|
290
356
|
Queue user-facing toast. `opts.links: [{entity, id}]` adds "open record" buttons.
|
|
291
357
|
|
|
358
|
+
A message is a **receipt for work the action did**, not a way to publish a computed value. An action whose only output is `info('<some computed number>')` stores nothing — it can't be audited, re-read or reported on, and the value arrives unformatted. Build a report, a formula column, or an action that writes a result record instead. See `dforge://reference/action-dsl`, *When an action is the wrong tool*.
|
|
359
|
+
|
|
292
360
|
```dsl
|
|
293
361
|
info('Created PO ' + po.po_number, {
|
|
294
362
|
links: [{ entity: 'purchase_order', id: po.purchase_order_id }]
|
|
@@ -453,17 +521,36 @@ var det = detectDocument(data.raw_text) // { profile: 'proc.bill_of_lading',
|
|
|
453
521
|
if (det != null) { var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), null, { profile: det.profile }) }
|
|
454
522
|
```
|
|
455
523
|
|
|
524
|
+
#### `applyProfile(json, profile) → object`
|
|
525
|
+
Map an already-extracted JSON payload through a registered extraction profile's
|
|
526
|
+
field schema, without re-running OCR. Use when the raw JSON came from somewhere
|
|
527
|
+
other than `ocrExtract()` — a webhook payload, a stored response, a re-process of
|
|
528
|
+
an earlier extraction.
|
|
529
|
+
|
|
530
|
+
```dsl
|
|
531
|
+
var mapped = applyProfile(tryParseJson([raw_payload]), 'proc.bill_of_lading')
|
|
532
|
+
```
|
|
533
|
+
|
|
456
534
|
### Utility
|
|
457
535
|
|
|
458
536
|
#### `entityLink(entityCd, record, description?) → link value`
|
|
459
537
|
Build a clickable link to any entity record for storing in an `entitylink` (jsonb)
|
|
460
538
|
column. `record` is a row object (e.g. from `insert()` / `getRecord()`); its PK
|
|
461
|
-
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact.
|
|
462
|
-
|
|
539
|
+
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact.
|
|
540
|
+
|
|
541
|
+
The target entity is resolved at build time and stored **by id**, not by code:
|
|
542
|
+
`entity_cd` is only unique per module, so a bare `'invoice'` would re-resolve at click
|
|
543
|
+
time against the *reader's* folder module and could land on another module's entity.
|
|
544
|
+
Always qualify the code you pass (`'fin.invoice'`).
|
|
545
|
+
|
|
546
|
+
`description` is optional — omit it and the link is captioned with the entity's
|
|
547
|
+
`toString` evaluated against `record`, the way records are captioned everywhere else.
|
|
548
|
+
An explicit `description` still wins.
|
|
463
549
|
|
|
464
550
|
```dsl
|
|
465
551
|
var task = insert('workspace.task', { title: 'Follow up' })
|
|
466
|
-
|
|
552
|
+
# Captioned by workspace.task's toString — no third argument needed.
|
|
553
|
+
insert('workspace.task_note', { link: entityLink('workspace.task', task) })
|
|
467
554
|
```
|
|
468
555
|
|
|
469
556
|
#### `flush()`
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
"flags": {
|
|
131
131
|
"type": "string",
|
|
132
132
|
"pattern": "^[VIEMH]*$",
|
|
133
|
-
"description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden"
|
|
133
|
+
"description": "Display flags: V=Visible, I=Identity, E=Editable, M=Mandatory, H=Hidden. M is resolved into isNullable:false at install, so it makes the column NOT NULL; it is inert on virtual (R/S/F) and identity columns. Declaring M together with isNullable:true is rejected at pack time."
|
|
134
134
|
},
|
|
135
135
|
"isPk": {
|
|
136
136
|
"type": "boolean",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
},
|
|
139
139
|
"isNullable": {
|
|
140
140
|
"type": "boolean",
|
|
141
|
-
"description": "Whether the column allows NULL values"
|
|
141
|
+
"description": "Whether the column allows NULL values. Omitted means true (nullable) — only an explicit false, or the M flag which resolves to it, makes the column NOT NULL. Adding it to a column that existing tenant rows leave empty aborts the upgrade."
|
|
142
142
|
},
|
|
143
143
|
"isIdentity": {
|
|
144
144
|
"type": "boolean",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"event": {
|
|
34
34
|
"type": "string",
|
|
35
35
|
"enum": ["insert", "update", "delete", "status_change", "any"],
|
|
36
|
-
"description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete."
|
|
36
|
+
"description": "DB event that fires this trigger. 'status_change' only fires when the entity's status field actually changes value. 'any' fires for all insert/update/delete. Events are raised by the row change itself, whichever path wrote it \u2014 a user edit and an action's `[field] = value` both count \u2014 except for the DSL's insert()/update()/delete()/query() builtins writing OTHER records, which raise nothing."
|
|
37
37
|
},
|
|
38
38
|
"condition": {
|
|
39
39
|
"type": "string",
|
|
@@ -5,6 +5,26 @@ Actions are dForge's business logic. They are scripts with three declarative blo
|
|
|
5
5
|
Lives in: `logic/actions/<action_name>.dsl`
|
|
6
6
|
Registered in: `ui/actions.json`
|
|
7
7
|
|
|
8
|
+
## When an action is the wrong tool
|
|
9
|
+
|
|
10
|
+
An action exists to **change something**. Its message is a receipt for work that was done — not the output itself.
|
|
11
|
+
|
|
12
|
+
Do **not** write an action whose `execute:` block only reads data, computes a value and reports it with `info()`. The result is a line of text that is never stored: it can't be audited, referenced, re-read or reported on, and every reader has to re-run the action to see it again. The value also reaches the user unformatted — a raw `query()` result carries no column metadata, so money renders as `1000.0099999999999` instead of `1,000.01`.
|
|
13
|
+
|
|
14
|
+
If you are about to write a `check_…`, `calculate_…` or `show_…` action, build one of these instead:
|
|
15
|
+
|
|
16
|
+
| What's actually needed | Build this |
|
|
17
|
+
|---|---|
|
|
18
|
+
| The user needs to **see** current numbers | A **report** or a data view — both are metadata-driven, so values format correctly, and the user can return to them any time without re-running anything. |
|
|
19
|
+
| A derived value **belongs to the record** | A **formula column** (`columnType: "F"`) or a roll-up — computed on read, visible on the card and in grids, filterable and sortable. |
|
|
20
|
+
| The **verdict itself matters** (approvals, credit checks, compliance) | An action that **writes a result record** — e.g. `credit_check(customer, checked_at, limit, outstanding, projected, verdict, checked_by)`. Now it's auditable and reportable, and `info()` becomes a legitimate receipt with a link to the new row. |
|
|
21
|
+
| The check must **block** something | A **trigger** with `async: false`, or a `canExecute:` guard on the action being gated — enforcement, not narration. |
|
|
22
|
+
|
|
23
|
+
Messages stay correct for exactly two things, and both are short by nature:
|
|
24
|
+
|
|
25
|
+
- a **receipt** for a mutation that just happened — `info('Created PO ' + po.po_number, { links: [...] })`
|
|
26
|
+
- a **refusal** that prevented one — `error('Period is closed')`
|
|
27
|
+
|
|
8
28
|
## Structure
|
|
9
29
|
|
|
10
30
|
A DSL file has up to three blocks. All are optional (but `execute:` is needed to actually do something).
|
|
@@ -126,6 +146,17 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
126
146
|
> `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
|
|
127
147
|
> loop and mutate per id instead.
|
|
128
148
|
|
|
149
|
+
> **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
|
|
150
|
+
> write layer: by design they skip folder row-filters, closed-period checks and registry state, and
|
|
151
|
+
> they raise no trigger or webhook events either. Only the **acted-on record's own**
|
|
152
|
+
> `[field] = value` raises them — that one is indistinguishable from a user editing the same field,
|
|
153
|
+
> and fires the entity's `update` / `status_change` triggers exactly as the grid does.
|
|
154
|
+
>
|
|
155
|
+
> So a trigger on `b` will not see `update('b', …)` from an action bound to `a`. If `b` must react,
|
|
156
|
+
> put the trigger on `a` — the entity the action is bound to — or make the change through `b`'s own
|
|
157
|
+
> action. The symptom when you get this wrong is a trigger that simply never fires, with nothing in
|
|
158
|
+
> the log to say why.
|
|
159
|
+
|
|
129
160
|
### select() — structured multi-row reads
|
|
130
161
|
|
|
131
162
|
The read counterpart to `insert()`/`update()`/`delete()`. Entity codes are metadata-validated,
|
|
@@ -174,7 +205,7 @@ var queued = select('comm.message', {
|
|
|
174
205
|
|---|---|
|
|
175
206
|
| `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
|
|
176
207
|
| `warn('message')` | Show a warning but continue execution. |
|
|
177
|
-
| `info('message')` | Show an informational message. |
|
|
208
|
+
| `info('message')` | Show an informational message. A **receipt for work the action did** — not a way to publish a computed value; see *When an action is the wrong tool*. |
|
|
178
209
|
| `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:`. |
|
|
179
210
|
| `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
|
|
180
211
|
|
|
@@ -193,17 +224,34 @@ var queued = select('comm.message', {
|
|
|
193
224
|
| `getFileBase64(fileFieldValue)` | String | Read a file from storage and return its content as a base64-encoded string. Use with `callApi()` to send files to AI APIs. Max 10 MB. Example: `getFileBase64([document_file])`. |
|
|
194
225
|
| `getFileUrl(fileFieldValue)` | String | Generate a temporary signed download URL (relative path) for a file field. Use in email templates or notifications — not for `callApi()`. |
|
|
195
226
|
| `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
|
|
227
|
+
| `getFileInfo(fileFieldValue)` | Object \| null | File metadata for a file field. |
|
|
228
|
+
| `download(url, fileName?)` | — | Fetch a URL into tenant storage. |
|
|
229
|
+
| `callService('name', { args })` | Any | Invoke a registered host service. |
|
|
230
|
+
|
|
231
|
+
### Files, settings, document extraction
|
|
232
|
+
|
|
233
|
+
| Function | Returns | Description |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| `getSetting('setting_cd')` | Any | Read a module setting, resolved through the folder chain (current folder → parents → module default). |
|
|
236
|
+
| `ocrExtract(fileField, endpointBaseUrl, schema?, opts?)` | Object | Run OCR extraction against a document. `opts.profile` selects a registered extraction profile, e.g. `{ profile: 'proc.bill_of_lading' }`. |
|
|
237
|
+
| `detectDocument(rawText)` | Object \| null | Score text against reachable extraction profiles; returns the best match `{ profile, docType, score }` or `null`. |
|
|
238
|
+
| `applyProfile(json, 'module.profile_cd')` | Object | Map already-extracted JSON through a profile's field schema, without re-running OCR. |
|
|
196
239
|
|
|
197
240
|
### Utilities
|
|
198
241
|
|
|
199
242
|
| Function | Returns | Description |
|
|
200
243
|
|---|---|---|
|
|
201
244
|
| `now()` | DateTime | Current date/time. Use for `date`, `datetime`, and `timestamp` fields. **Lowercase** — this is the execute-block date function. |
|
|
202
|
-
| `IF(condition, trueVal, falseVal)` | Any | Ternary helper. |
|
|
203
245
|
| `addDays(date, n)` | Date | Add `n` days to a date, e.g. `addDays(now(), 30)`. |
|
|
246
|
+
| `addMinutes(date, n)` | DateTime | Add `n` minutes, e.g. `addMinutes(now(), 15)`. |
|
|
247
|
+
| `addSeconds(date, n)` | DateTime | Add `n` seconds. |
|
|
248
|
+
| `currentUserId()` | Long | The acting user's tenant user ID. |
|
|
249
|
+
| `userId` | Long | Same value as `currentUserId()`, as a **bare identifier — no parentheses**. `userId()` is a compile error; there is no `current_user_id()`. |
|
|
250
|
+
| `flush()` | — | Flush pending writes so a following read sees them. |
|
|
251
|
+
| `tryParseJson(value)` | Any \| null | Parse JSON, returning `null` on failure instead of throwing. |
|
|
204
252
|
| `nextNumber('entity')` | String | Generate next value from a number sequence. Usually not needed — platform auto-fills on `insert()`. Use for pre-generating numbers. |
|
|
205
253
|
| `callProc('proc_name', { args })` | Result | Call a stored procedure. Args are passed as named parameters. |
|
|
206
|
-
| `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact.
|
|
254
|
+
| `entityLink('entityCd', record, description?)` | Link value | Build a clickable link to any entity record, for storing in an `entitylink` (jsonb) column. `record` is a row object (e.g. the result of `insert()` or `getRecord()`); its PK columns are read and stored as **strings**, so snowflake/cuid ids (> 2^53) stay exact. The entity is resolved at build time and stored **by id** — `entity_cd` is only unique per module, so always pass a qualified code (`'fin.invoice'`). Omit `description` and the link is captioned with the entity's `toString` against `record`; an explicit `description` wins. |
|
|
207
255
|
|
|
208
256
|
> **Dates: `now()` in `execute:`, `TODAY()`/`NOW()` in formulas.** The `execute:` block runs as
|
|
209
257
|
> JavaScript (Jint) and exposes **lowercase `now()`** only — `TODAY()` and `NOW()` (uppercase) are
|
|
@@ -212,6 +260,32 @@ var queued = select('comm.message', {
|
|
|
212
260
|
> columns, and other formula contexts; use `now()` everywhere inside `execute:`. (Uppercase `NOW()`
|
|
213
261
|
> is also fine *inside a raw SQL string* passed to `query()`, because that's SQL, not JS.)
|
|
214
262
|
|
|
263
|
+
> **`IF()` is formula-only too.** It was previously listed here as a ternary helper — that was
|
|
264
|
+
> wrong. `IF(cond, a, b)` is a **formula-engine** function, valid in `canExecute:` and formula
|
|
265
|
+
> columns, and **undefined in `execute:`** (install fails with `'IF' is not defined`). Inside
|
|
266
|
+
> `execute:` use the JavaScript conditional operator: `var x = cond ? a : b`.
|
|
267
|
+
|
|
268
|
+
> **64-bit ids are BigInt in `execute:`.** JS numbers are doubles (53 mantissa bits) and dForge
|
|
269
|
+
> ids are 58-bit snowflakes, so any `long` outside ±2^53 — user ids, PKs, FKs — reaches the script
|
|
270
|
+
> as a JS **BigInt**. Ids stay exact through reads, writes and comparisons, including
|
|
271
|
+
> `currentUserId() == [last_updated_by]`. Smaller `int8` values (counters, quantities) stay
|
|
272
|
+
> ordinary numbers, so arithmetic on them is unchanged.
|
|
273
|
+
>
|
|
274
|
+
> Two operations throw rather than round silently, each with a working form:
|
|
275
|
+
> `[owner_id] + 1` → use `Number([owner_id]) + 1` (explicit, and accepts the rounding);
|
|
276
|
+
> `JSON.stringify([owner_id])` → assign the object instead (`[payload] = { id: [owner_id] }`),
|
|
277
|
+
> which serializes correctly on the write path.
|
|
278
|
+
>
|
|
279
|
+
> Never write a large id as a bare numeric literal — the literal itself rounds before the
|
|
280
|
+
> comparison runs. `[owner_id] == 2006…067` is **false**; quote it (`== '2006…067'`) or suffix
|
|
281
|
+
> it with `n` (`== 2006…067n`). Comparing two id columns needs neither.
|
|
282
|
+
>
|
|
283
|
+
> For "who did this", the platform audit columns (`created_by` / `last_updated_by`) remain the
|
|
284
|
+
> better answer — filled server-side, no script involved. For ownership predicates use
|
|
285
|
+
> **`CURRENT_USER_ID()`** in `canExecute:` or a formula column — a *formula-engine* function
|
|
286
|
+
> (undefined in `execute:`) that the server translates to an exact `::bigint` SQL literal.
|
|
287
|
+
|
|
288
|
+
|
|
215
289
|
## Real examples from the codebase
|
|
216
290
|
|
|
217
291
|
### Simple field update (status transition)
|
|
@@ -514,6 +588,7 @@ way.
|
|
|
514
588
|
|
|
515
589
|
## Common mistakes
|
|
516
590
|
|
|
591
|
+
- Writing an action that only reads, computes and prints (`info('Balance: ' + total)`) — **wrong shape**, not just wrong formatting. Nothing is stored, so nobody can re-read the answer. Use a report, a formula column, or write a result record. → *When an action is the wrong tool* (top of this file)
|
|
517
592
|
- Writing `params.note` instead of `params[note]` — **wrong**. Use bracket syntax for params.
|
|
518
593
|
- Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
|
|
519
594
|
- Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
|
|
@@ -33,8 +33,14 @@ Most columns. They map 1:1 to a physical SQL column.
|
|
|
33
33
|
|
|
34
34
|
Virtual N:1 lookup. No physical column. Always **paired with a hidden FK column** that does hold the physical data. Together they form the **FK+Reference pattern**.
|
|
35
35
|
|
|
36
|
+
The example below is a **required** relation, so both halves carry `M`. For an optional
|
|
37
|
+
one, drop it from both: FK `"E"`, Reference `"VE"`. `M` resolves to `isNullable: false`
|
|
38
|
+
at install, so it is what makes the FK column `NOT NULL` — and while `M` is inert on the
|
|
39
|
+
virtual Reference half, leaving it there over an optional FK reads as a required field to
|
|
40
|
+
the next author. `dforge_entity_reference_add({ required })` emits both halves in step.
|
|
41
|
+
|
|
36
42
|
```json
|
|
37
|
-
// Hidden FK column — physical, hidden
|
|
43
|
+
// Hidden FK column — physical, hidden. "E" instead of "EM" if the relation is optional.
|
|
38
44
|
"account_id": {
|
|
39
45
|
"dbDatatype": "cuid",
|
|
40
46
|
"flags": "EM",
|
|
@@ -175,9 +181,10 @@ inside one aggregate must point at the **same** set column, and `COUNT(*)` is re
|
|
|
175
181
|
|
|
176
182
|
> ⛔ **Do not use a Formula (`F`) column for set aggregation.** `SUM([set].[field])` in an `F`
|
|
177
183
|
> column is **not supported** by the engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and
|
|
178
|
-
> nav resolution
|
|
179
|
-
> **empty** on the form, grid, and reports (no error). `F` is for same-row
|
|
180
|
-
> (`[qty] * [price]`) and
|
|
184
|
+
> nav resolution walks N:1 references only, never a 1:N set (however many hops it is given). The
|
|
185
|
+
> column silently renders **empty** on the form, grid, and reports (no error). `F` is for same-row
|
|
186
|
+
> expressions (`[qty] * [price]`) and N:1 reference navigation (`[account].[name]`, and multi-hop
|
|
187
|
+
> chains — see `dforge://reference/formulas`).
|
|
181
188
|
|
|
182
189
|
> ⛔ **The aggregated child column must be physical.** A `G` trigger reads the child's `OLD`/`NEW`
|
|
183
190
|
> physical values, so aggregating a **virtual** child column (`F`/`R`/`S`) fails at install with
|
|
@@ -64,8 +64,10 @@ Only include what your module actually uses — not every module needs `print_te
|
|
|
64
64
|
|
|
65
65
|
**Every reference to another entity is two columns**:
|
|
66
66
|
|
|
67
|
-
1. Hidden FK column with `flags: "EM"` and `dbDatatype` matching the target PK type
|
|
68
|
-
2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VEM"
|
|
67
|
+
1. Hidden FK column with `flags: "E"` — `"EM"` when the relation is genuinely required — and `dbDatatype` matching the target PK type
|
|
68
|
+
2. Visible Reference column with `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags: "VE"` / `"VEM"` **matching the FK**, and a `link` object pointing at the target
|
|
69
|
+
|
|
70
|
+
`M` resolves to `isNullable: false` at install, so it is what makes the FK column `NOT NULL` — keep the two halves in step. `dforge_entity_reference_add({ required })` emits both correctly.
|
|
69
71
|
|
|
70
72
|
Plus the actual FK constraint declared in the entity's `references` block.
|
|
71
73
|
|
|
@@ -67,8 +67,8 @@ If none of an entity's fields are visible (or only its set/child-collection colu
|
|
|
67
67
|
```jsonc
|
|
68
68
|
// WRONG — grid over an entity whose fields are all hidden (no "V")
|
|
69
69
|
"fields": {
|
|
70
|
-
"code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M
|
|
71
|
-
"lines": { "columnType": "S", "flags": "
|
|
70
|
+
"code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=required, but no V
|
|
71
|
+
"lines": { "columnType": "S", "flags": "VE" } // visible, but a SET — doesn't count for a grid
|
|
72
72
|
}
|
|
73
73
|
// RIGHT — at least one visible scalar field
|
|
74
74
|
"fields": {
|
|
@@ -72,7 +72,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
72
72
|
| `user` | `cuid` | `D` | User picker. Writes a user ID directly, no paired FK needed. |
|
|
73
73
|
| `grid` | `set` | `S` | Detail grid of related records (1:N backwards reference). Used with `link` declaring the relation. |
|
|
74
74
|
| `list` | `set` | `S` | Detail list — same 1:N set as `grid`, rendered as a card list instead of a table. |
|
|
75
|
-
| `entitylink` | `json` | `D` | Polymorphic link to any entity
|
|
75
|
+
| `entitylink` | `json` | `D` | Polymorphic link to any entity — stores the **resolved entity id** plus the record's PK (as strings) and a display caption. Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in; pass a qualified entity code (see `action-dsl.md`). |
|
|
76
76
|
|
|
77
77
|
## JSON
|
|
78
78
|
|
|
@@ -11,26 +11,54 @@ Valid flags regex: `^[VIEMH]*$`
|
|
|
11
11
|
| `V` | Visible | Column appears in grid / form / card views. Omit to hide from standard UI. |
|
|
12
12
|
| `I` | Internal | Auto-managed by the platform. Used on PK columns, audit timestamps, system fields. Not shown in UI, not directly editable by users. |
|
|
13
13
|
| `E` | Editable | User can modify the value in the UI. Omit for read-only. |
|
|
14
|
-
| `M` | Mandatory | Required
|
|
14
|
+
| `M` | Mandatory | Required. Resolved into `isNullable: false` at install — see below. |
|
|
15
15
|
| `H` | Hidden | Permanently hidden — never shown, not even in admin metadata views. Stronger than omitting `V`. |
|
|
16
16
|
|
|
17
17
|
**Only these 5 letters are valid.** The platform rejects any other characters.
|
|
18
18
|
|
|
19
|
+
## `M` is the platform's one notion of "required"
|
|
20
|
+
|
|
21
|
+
At install, `MandatoryFlagNormalizer` folds `M` into `isNullable: false` before DDL
|
|
22
|
+
generation. Everything downstream reads only `isNullable`: the `NOT NULL` in the
|
|
23
|
+
generated DDL, `data.insert`'s required-column check, and the red asterisk on the
|
|
24
|
+
client. There is no second signal.
|
|
25
|
+
|
|
26
|
+
Four consequences worth knowing before you type a flag:
|
|
27
|
+
|
|
28
|
+
- **`M` makes the column `NOT NULL`.** It is not decoration and not a UI hint. Put it
|
|
29
|
+
on a column only when a row is genuinely meaningless without the value.
|
|
30
|
+
- **`M` together with `"isNullable": true` is a hard error at pack time**, naming the
|
|
31
|
+
entity and every offending field. It's a contradiction only you can resolve: drop
|
|
32
|
+
`M` if the column is optional, or drop `isNullable` if it is required.
|
|
33
|
+
- **`M` is inert on virtual (`R`/`S`/`F`) and identity columns** — nullability is
|
|
34
|
+
meaningless where there is no physical column. It won't fail, but `VEM` on a
|
|
35
|
+
Reference over an *optional* FK reads as a required field to the next author, which
|
|
36
|
+
is exactly how these get out of step. Match the Reference to its FK.
|
|
37
|
+
- **Adding `M` to a shipped field aborts the upgrade** on any tenant whose existing
|
|
38
|
+
rows leave it empty — rolled back, naming the column and its NULL row count. Give
|
|
39
|
+
the column a `params.serverDefault` if the install should backfill it. Removing `M`
|
|
40
|
+
is always safe.
|
|
41
|
+
|
|
42
|
+
Omitting both `M` and `isNullable` leaves the column nullable — that is the default.
|
|
43
|
+
|
|
19
44
|
## Common column flag combinations
|
|
20
45
|
|
|
21
46
|
| Flags | Meaning | Use case |
|
|
22
47
|
|---|---|---|
|
|
23
|
-
| `"VEM"` | Visible, Editable, Mandatory | Required field shown in UI
|
|
24
|
-
| `"VE"` | Visible, Editable, optional | Nullable field |
|
|
48
|
+
| `"VEM"` | Visible, Editable, Mandatory | Required field shown in UI |
|
|
49
|
+
| `"VE"` | Visible, Editable, optional | Nullable field — the common case for most business fields |
|
|
25
50
|
| `"V"` | Visible, read-only | Display-only, like formula columns or computed values |
|
|
26
|
-
| `"
|
|
51
|
+
| `"E"` | Editable, **not visible** | Hidden FK column of an *optional* relation |
|
|
52
|
+
| `"EM"` | Editable, Mandatory, **not visible** | Hidden FK column of a *required* relation |
|
|
27
53
|
| `"I"` | Internal | Trait-provided columns (PK, audit timestamps). Platform-managed. |
|
|
28
54
|
| `""` (empty) | No flags | Rare — column exists but is invisible and not editable |
|
|
29
55
|
|
|
30
56
|
## Rules
|
|
31
57
|
|
|
32
|
-
1. **Hidden FK columns**:
|
|
33
|
-
|
|
58
|
+
1. **Hidden FK columns**: `"E"`, or `"EM"` when the relationship is genuinely required.
|
|
59
|
+
Writeable (E), never shown (no V). `M` here is what makes the FK `NOT NULL`.
|
|
60
|
+
2. **Visible Reference columns**: `"VE"`, or `"VEM"` when the FK is required — **keep the
|
|
61
|
+
two halves in step**. `dforge_entity_reference_add({ required })` sets both for you.
|
|
34
62
|
3. **Formula columns**: usually `"V"` (visible, read-only). The user never edits them.
|
|
35
63
|
4. **Trait columns** (PK, audit): use `"I"` (internal). These are set automatically by `traits: ["identity", "audit"]`.
|
|
36
64
|
5. **Don't use `I` on your own columns** — it's reserved for trait-provided and platform-managed columns.
|
|
@@ -68,6 +96,9 @@ These are **NOT** flag letters. They're separate properties on the column defini
|
|
|
68
96
|
- Using `"U"` for unique — **not a valid flag**. Use `"isUnique": true` or indexes.
|
|
69
97
|
- Using `"S"` for searchable — **not a valid flag**.
|
|
70
98
|
- Using `"P"` for primary key — **not a valid flag**. Use `"isPk": true` or the `identity` trait.
|
|
99
|
+
- Declaring `M` **and** `"isNullable": true` on the same field — a contradiction that fails the pack.
|
|
100
|
+
- Reaching for `"VEM"` by default — `M` now means `NOT NULL`. Most business fields are `"VE"`.
|
|
101
|
+
- Putting `M` on a Reference whose hidden FK is optional — inert, but it misreports the field as required.
|
|
71
102
|
- Using `"VEM"` on trait-provided columns (PK, audit) — **don't**. Traits set their own flags (`"I"`).
|
|
72
103
|
- Using `"I"` on business columns — **don't**. `I` means "platform-managed"; regular business columns use `V`/`E`/`M`.
|
|
73
104
|
- Using long names like `"Visible,Editable"` — **wrong**. Single letters concatenated: `"VE"`.
|
|
@@ -26,6 +26,12 @@ The same grammar applies everywhere.
|
|
|
26
26
|
`CONTAINS` / `STARTS_WITH` / `ENDS_WITH` are infix operators, not functions — write
|
|
27
27
|
`[note] CONTAINS 'urgent'`, never `CONTAINS([note], 'urgent')`.
|
|
28
28
|
|
|
29
|
+
**Comparison is null-safe and two-valued in every context.** `null = null` is true, and
|
|
30
|
+
`[x] != null` is true for any non-null `x`. There is no SQL-style third "unknown" answer,
|
|
31
|
+
so `IF([completed_date] != null, 100, 0)` gives the same result on the record card and in
|
|
32
|
+
a report. At SQL time `=` / `!=` translate to `IS NOT DISTINCT FROM` / `IS DISTINCT FROM`
|
|
33
|
+
to preserve that.
|
|
34
|
+
|
|
29
35
|
## Examples
|
|
30
36
|
|
|
31
37
|
```
|
|
@@ -156,8 +162,9 @@ like any other boolean. A bare `date` column is treated as a **local calendar da
|
|
|
156
162
|
- `AVG`, `MIN`, `MAX` similarly
|
|
157
163
|
|
|
158
164
|
> ⚠️ **Set aggregation is NOT a Formula (`F`) feature.** `SUM([set].[field])` in an `F` column is
|
|
159
|
-
> unsupported by
|
|
160
|
-
>
|
|
165
|
+
> unsupported by **either** engine — the formula runtime has no `SUM`/`COUNT`/`AVG`, and nav
|
|
166
|
+
> resolution walks N:1 references only, never a 1:N set, however many hops it is given. It
|
|
167
|
+
> silently renders **empty** (no error).
|
|
161
168
|
|
|
162
169
|
Put set aggregations in a **Generated (`G`) column** instead. The installer detects the set
|
|
163
170
|
navigation and maintains the value with a database trigger on the child table. The aggregated
|
|
@@ -186,9 +193,20 @@ and the JSON shape: `column-types.md` → "Roll-up totals over child rows — us
|
|
|
186
193
|
[account].[primary_contact].[phone] -- chained
|
|
187
194
|
```
|
|
188
195
|
|
|
189
|
-
Navigation works through `columnType: "R"` columns (reference columns). Chains of length 1 are **synchronous** and resolved instantly. Chains of length ≥ 2 are **asynchronous** — the formula engine resolves them after the initial data load.
|
|
196
|
+
Navigation works through `columnType: "R"` columns (reference columns) — every step except the last must be a Reference, and the last reads a field on the final referenced record. Chains of length 1 are **synchronous** and resolved instantly. Chains of length ≥ 2 are **asynchronous** — the formula engine resolves them after the initial data load.
|
|
197
|
+
|
|
198
|
+
**SQL-time (reports, filters, sorts).** Multi-hop navigation works here too: QueryBuilder pre-scans the formula and registers one `LEFT JOIN` per hop, then the translator resolves the path against the final alias. A nav path may also land on a *formula* column of the referenced entity — that formula is translated against the referenced entity's alias, and its own hops are joined off that alias. The same nav map drives formula columns in filters and ORDER BY, so filtering and sorting a report by a nav-formula column works.
|
|
199
|
+
|
|
200
|
+
Hops are counted from the query's **root** entity and capped at `MaxNavigationDepth`, whether they come from a dotted column, a formula's own navigation, or the combination — so the hops spent reaching a formula reduce the budget left for that formula's own navigation.
|
|
201
|
+
|
|
202
|
+
Still unavailable at SQL time — the column comes back NULL and the **response carries a warning** naming the column and the reason:
|
|
203
|
+
|
|
204
|
+
- Settings references `$[SettingName]`
|
|
205
|
+
- Navigation past the hop cap
|
|
206
|
+
- **Extension-table columns** — a column an extension module added lives in a separate 1:1 ext table needing its own JOIN, and every alias the translator emits is the entity's own base alias. Applies to the hidden FK and to the Reference built on it, on the query's own entity as well as a referenced one
|
|
207
|
+
- **Misauthored nav paths** — a final segment that isn't a column of the referenced entity, or a dot after something that isn't a Reference (`account.account_name.foo`)
|
|
190
208
|
|
|
191
|
-
|
|
209
|
+
Warning wording follows the clause, because the same unresolvable path costs a different thing in each: a SELECT slot goes NULL (*"could not be computed and will be empty"*), an ORDER BY term is dropped so rows come back in a different order (*"Sorting by … was skipped"*), and a WHERE condition is dropped so **more** rows come back than were asked for (*"Filter on … was ignored"*). Untranslatable formula filters are never dropped silently.
|
|
192
210
|
|
|
193
211
|
## Sync vs async formulas
|
|
194
212
|
|
|
@@ -79,8 +79,10 @@ If the source uses non-standard audit column names (e.g. `date_created`, `modifi
|
|
|
79
79
|
|
|
80
80
|
For each FK, create **two columns** following the FK+Reference pattern:
|
|
81
81
|
|
|
82
|
-
1. Hidden FK column: `flags: "EM"
|
|
83
|
-
2. Visible Reference column: `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags
|
|
82
|
+
1. Hidden FK column: `flags: "E"` — `"EM"` only when the source column is `NOT NULL` — matching `dbDatatype`
|
|
83
|
+
2. Visible Reference column: `columnType: "R"`, `fieldTypeCd: "lookup"`, `flags` **matching the FK** (`"VE"` / `"VEM"`), with `link`
|
|
84
|
+
|
|
85
|
+
`M` resolves to `isNullable: false` at install, so carrying it onto an FK the source declares nullable would import the column as `NOT NULL`. `dforge_dbml_import` / `dforge_module_import` take `M` from the source's own nullability.
|
|
84
86
|
|
|
85
87
|
Plus declare the constraint in the entity's `references` block.
|
|
86
88
|
|
|
@@ -42,8 +42,9 @@ For each entity:
|
|
|
42
42
|
### FK+Reference pattern
|
|
43
43
|
|
|
44
44
|
- [ ] Every reference column (`columnType: "R"`) has a paired hidden FK column
|
|
45
|
-
- [ ] The hidden FK column has `flags: "EM"` (not `V`, not `I`)
|
|
46
|
-
- [ ] The reference column has `flags
|
|
45
|
+
- [ ] The hidden FK column has `flags: "E"` — `"EM"` only if the relation is required (not `V`, not `I`)
|
|
46
|
+
- [ ] The reference column has `flags` **matching the FK** (`"VE"` / `"VEM"`), `fieldTypeCd: "lookup"`, and a `link` object
|
|
47
|
+
- [ ] No field declares `M` together with `"isNullable": true` — that combination fails the pack
|
|
47
48
|
- [ ] `link.entity` points to an entity that exists (in this module or a dependency)
|
|
48
49
|
- [ ] `link.thisKey` matches the FK column name on this entity
|
|
49
50
|
- [ ] `link.otherKey` matches the PK column name on the target entity
|
|
@@ -57,8 +57,10 @@ Enough to author inline; load the linked reference for the full detail.
|
|
|
57
57
|
- **A relation is TWO columns + a references entry.** Use
|
|
58
58
|
`dforge_entity_reference_add` — it emits all three parts. → `column-types`
|
|
59
59
|
- **Flags** are letters from `V I E M H` only (never `U`/`S`/`P`): `VEM`
|
|
60
|
-
required+visible, `VE` optional+visible, `V` read-only, `EM` hidden FK
|
|
61
|
-
trait-provided.
|
|
60
|
+
required+visible, `VE` optional+visible, `V` read-only, `E`/`EM` hidden FK
|
|
61
|
+
(optional/required), `I` trait-provided. `M` means required — it resolves to
|
|
62
|
+
`isNullable: false`, so it makes the column `NOT NULL`; don't spend it as
|
|
63
|
+
decoration, and never pair it with `"isNullable": true`. → `flags`
|
|
62
64
|
- **`fieldTypeCd` = UI control; `dbDatatype` = SQL type.** Omit `dbDatatype` on
|
|
63
65
|
plain data columns — it's derived. Set it only for a hidden FK or to override
|
|
64
66
|
size/precision. Common fixes: `number` not `integer`/`float`; `phone` not
|
|
@@ -168,9 +170,14 @@ to fill a sub-step** — a pure CRUD module skips Phase 2 entirely (record it wi
|
|
|
168
170
|
| 2c Jobs | a cron timer fires | `dforge_job_add` |
|
|
169
171
|
| 2d Webhooks | a DB event → outbound POST | `dforge_webhook_add` |
|
|
170
172
|
|
|
171
|
-
**2a Actions.**
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
**2a Actions.** First check the action is an action at all: it must **change**
|
|
174
|
+
something. If the `execute:` block would only read, compute and `info()` the
|
|
175
|
+
answer, build a report, a formula column, or an action that writes a result
|
|
176
|
+
record instead — a printed number is never stored and can't be re-read
|
|
177
|
+
(`dforge://reference/action-dsl`, *When an action is the wrong tool*). Then load
|
|
178
|
+
all four resources in the action row — the wrong-field-access /
|
|
179
|
+
wrong-batch-flag / wrong-`ui/actions.json`-property mistakes only surface when
|
|
180
|
+
you cross-check them. Draft the DSL, run
|
|
174
181
|
**`dforge_action_check({ dslBody, executionMode })`** to catch install failures
|
|
175
182
|
before committing, then `dforge_action_add`. One action per turn.
|
|
176
183
|
|