@dforge-core/dforge-mcp 0.1.0-rc.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +84 -27
  3. package/dist/server.js +2957 -616
  4. package/docs/creating-modules.md +11 -4
  5. package/package.json +11 -6
  6. package/resources/docs/conventions.md +22 -16
  7. package/resources/docs/dsl-reference.md +767 -0
  8. package/resources/schemas/entity.schema.json +8 -1
  9. package/resources/schemas/print-templates.schema.json +82 -0
  10. package/resources/schemas/reports.schema.json +4 -0
  11. package/resources/schemas/triggers.schema.json +59 -0
  12. package/skills/dforge-mcp-author/SKILL.md +269 -69
  13. package/skills/dforge-mcp-author/examples/matrix-budget/README.md +43 -0
  14. package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_category.json +24 -0
  15. package/skills/dforge-mcp-author/examples/matrix-budget/entities/budget_line.json +56 -0
  16. package/skills/dforge-mcp-author/examples/matrix-budget/manifest.json +30 -0
  17. package/skills/dforge-mcp-author/examples/matrix-budget/security/roles.json +9 -0
  18. package/skills/dforge-mcp-author/examples/matrix-budget/seed-data/01-categories.json +8 -0
  19. package/skills/dforge-mcp-author/examples/matrix-budget/ui/data_views.json +42 -0
  20. package/skills/dforge-mcp-author/examples/simple-todo/README.md +38 -0
  21. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_item.json +83 -0
  22. package/skills/dforge-mcp-author/examples/simple-todo/entities/todo_list.json +43 -0
  23. package/skills/dforge-mcp-author/examples/simple-todo/logic/actions/mark_done.dsl +6 -0
  24. package/skills/dforge-mcp-author/examples/simple-todo/manifest.json +32 -0
  25. package/skills/dforge-mcp-author/examples/simple-todo/security/roles.json +10 -0
  26. package/skills/dforge-mcp-author/examples/simple-todo/seed-data/01-lists.json +17 -0
  27. package/skills/dforge-mcp-author/examples/simple-todo/ui/actions.json +11 -0
  28. package/skills/dforge-mcp-author/examples/simple-todo/ui/data_views.json +35 -0
  29. package/skills/dforge-mcp-author/examples/simple-todo/ui/menus.json +28 -0
  30. package/skills/dforge-mcp-author/references/action-dsl.md +397 -0
  31. package/skills/dforge-mcp-author/references/column-types.md +168 -0
  32. package/skills/dforge-mcp-author/references/conventions.md +177 -0
  33. package/skills/dforge-mcp-author/references/data-migration.md +270 -0
  34. package/skills/dforge-mcp-author/references/data-views.md +243 -0
  35. package/skills/dforge-mcp-author/references/excel-import.md +61 -0
  36. package/skills/dforge-mcp-author/references/field-types.md +144 -0
  37. package/skills/dforge-mcp-author/references/filters.md +326 -0
  38. package/skills/dforge-mcp-author/references/flags.md +73 -0
  39. package/skills/dforge-mcp-author/references/formulas.md +206 -0
  40. package/skills/dforge-mcp-author/references/jobs.md +149 -0
  41. package/skills/dforge-mcp-author/references/manifest.md +123 -0
  42. package/skills/dforge-mcp-author/references/menus.md +164 -0
  43. package/skills/dforge-mcp-author/references/number-sequences.md +117 -0
  44. package/skills/dforge-mcp-author/references/print-templates.md +159 -0
  45. package/skills/dforge-mcp-author/references/queries.md +312 -0
  46. package/skills/dforge-mcp-author/references/reports.md +398 -0
  47. package/skills/dforge-mcp-author/references/schema-import.md +331 -0
  48. package/skills/dforge-mcp-author/references/security.md +244 -0
  49. package/skills/dforge-mcp-author/references/settings.md +120 -0
  50. package/skills/dforge-mcp-author/references/traits.md +153 -0
  51. package/skills/dforge-mcp-author/references/translations.md +158 -0
  52. package/skills/dforge-mcp-author/references/validation-checklist.md +183 -0
  53. package/skills/dforge-mcp-author/scripts/xlsx_to_model.py +198 -0
@@ -0,0 +1,397 @@
1
+ # Action DSL Reference
2
+
3
+ Actions are dForge's business logic. They are scripts with three declarative blocks (`params:`, `canExecute:`, `execute:`) where the `execute:` block uses **JavaScript syntax** executed server-side via the Jint engine, with platform-provided global functions.
4
+
5
+ Lives in: `logic/actions/<action_name>.dsl`
6
+ Registered in: `ui/actions.json`
7
+
8
+ ## Structure
9
+
10
+ A DSL file has up to three blocks. All are optional (but `execute:` is needed to actually do something).
11
+
12
+ ```
13
+ params:
14
+ <name>: <type> [required] ["Label"]
15
+
16
+ canExecute:
17
+ <formula expression returning boolean>
18
+
19
+ execute:
20
+ <JavaScript with platform globals>
21
+ ```
22
+
23
+ ## The three blocks
24
+
25
+ ### `params:` — declare user inputs
26
+
27
+ Each param is one line: `name: type [required] ["Label"]`
28
+
29
+ ```
30
+ params:
31
+ new_stage: dropdown required "New Stage"
32
+ note: textarea "Follow-up Note"
33
+ adjustment_qty: number required "Adjustment Quantity"
34
+ reason: text required "Reason"
35
+ ```
36
+
37
+ Types are a subset of column field types: `text`, `textarea`, `number`, `currency`, `percent`, `checkbox`, `date`, `datetime`, `dropdown`, `lookup`, `user`.
38
+
39
+ Access in execute block: `params[param_name]` (bracket syntax, NOT dot syntax).
40
+
41
+ ### `canExecute:` — availability formula
42
+
43
+ A formula expression (same grammar as formula columns) that evaluates to true/false. When false, the action button is hidden or disabled in the UI.
44
+
45
+ ```
46
+ canExecute:
47
+ [status] = 'Draft'
48
+
49
+ canExecute:
50
+ [status] != 'Paid' AND [status] != 'Cancelled'
51
+
52
+ canExecute:
53
+ [stage] != 'Closed Won' AND [stage] != 'Closed Lost'
54
+
55
+ canExecute:
56
+ [status] = 'Submitted'
57
+
58
+ canExecute:
59
+ [quantity] >= 0
60
+ ```
61
+
62
+ Uses formula syntax: `[field]` for field access, `=` / `!=` / `<` / `>` / `AND` / `OR` operators, `TODAY()`, etc.
63
+
64
+ **Note**: `canExecute` uses single `=` for equality (formula syntax), NOT `==` (JS syntax).
65
+
66
+ ### `execute:` — the logic (JavaScript with platform globals)
67
+
68
+ This is **JavaScript** executed via the Jint engine. Standard JS syntax works: `var`, `for`, `if/else`, `+` concatenation, object literals, array indexing, etc.
69
+
70
+ On top of standard JS, the platform injects global functions and a special field-access syntax.
71
+
72
+ ## Field access and assignment
73
+
74
+ **Read a field** on the current record:
75
+
76
+ ```javascript
77
+ var name = [first_name] // bracket syntax — reads the field value
78
+ var total = [quantity] * [unit_price]
79
+ ```
80
+
81
+ `[field_name]` is shorthand for `__r.get('field_name')`. Both work; bracket syntax is cleaner.
82
+
83
+ **Write a field** on the current record:
84
+
85
+ ```javascript
86
+ [status] = 'Approved' // bracket assignment — updates the field
87
+ [approved_date] = now()
88
+ [quantity] = newQty
89
+ ```
90
+
91
+ `[field] = value` is shorthand for `__r.set('field', value)`. Both work.
92
+
93
+ **Access params**:
94
+
95
+ ```javascript
96
+ var qty = params[adjustment_qty] // bracket syntax; no quotes around param name in this DSL
97
+ var note = params[note] // no quotes around param name in this DSL
98
+ ```
99
+
100
+ `params[param_name]` — note: NO quotes around the param name inside brackets.
101
+
102
+ ## Platform global functions
103
+
104
+ ### Data operations
105
+
106
+ | Function | Returns | Description |
107
+ |---|---|---|
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
+ | `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']`. |
110
+ | `getRecord('entity', id)` | Row object | Fetch a single record by primary key. |
111
+ | `preloadRef('fk_column')` | Row object | Load the referenced record for a FK column. Access its fields with `ref.get('field')`. |
112
+
113
+ ### Messaging
114
+
115
+ | Function | Description |
116
+ |---|---|
117
+ | `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
118
+ | `warn('message')` | Show a warning but continue execution. |
119
+ | `info('message')` | Show an informational message. |
120
+ | `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
121
+
122
+ ### Email
123
+
124
+ | Function | Description |
125
+ |---|---|
126
+ | `sendEmail(to, subject, htmlBody)` | Send a raw email. `to` is an email address string. |
127
+ | `sendEmail(to, subject, htmlBody, templateCode)` | Send a templated email (if email templates are configured). |
128
+
129
+ ### External API Calls
130
+
131
+ | Function | Returns | Description |
132
+ |---|---|---|
133
+ | `callApi(url, method, headers?, body?)` | String | Make an HTTP request to an external API. Returns response body as string. Parse JSON with `JSON.parse()`. Max 10 calls per script, 120s timeout, 5MB response limit. Content-Type defaults to `application/json` but can be overridden via headers. |
134
+ | `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])`. |
135
+ | `getFileUrl(fileFieldValue)` | String | Generate a temporary signed download URL (relative path) for a file field. Use in email templates or notifications — not for `callApi()`. |
136
+ | `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
137
+
138
+ ### Utilities
139
+
140
+ | Function | Returns | Description |
141
+ |---|---|---|
142
+ | `now()` | DateTime | Current date/time. Use for `date`, `datetime`, and `timestamp` fields. **Lowercase** — this is the execute-block date function. |
143
+ | `IF(condition, trueVal, falseVal)` | Any | Ternary helper. |
144
+ | `addDays(date, n)` | Date | Add `n` days to a date, e.g. `addDays(now(), 30)`. |
145
+ | `nextNumber('entity')` | String | Generate next value from a number sequence. Usually not needed — platform auto-fills on `insert()`. Use for pre-generating numbers. |
146
+ | `callProc('proc_name', { args })` | Result | Call a stored procedure. Args are passed as named parameters. |
147
+
148
+ > **Dates: `now()` in `execute:`, `TODAY()`/`NOW()` in formulas.** The `execute:` block runs as
149
+ > JavaScript (Jint) and exposes **lowercase `now()`** only — `TODAY()` and `NOW()` (uppercase) are
150
+ > **formula-engine** functions and are **undefined in `execute:`** (using them throws
151
+ > `'TODAY' is not defined` at install). Use `TODAY()`/`NOW()` only in `canExecute:`, formula
152
+ > columns, and other formula contexts; use `now()` everywhere inside `execute:`. (Uppercase `NOW()`
153
+ > is also fine *inside a raw SQL string* passed to `query()`, because that's SQL, not JS.)
154
+
155
+ ## Real examples from the codebase
156
+
157
+ ### Simple field update (status transition)
158
+
159
+ From `modules/fin/logic/actions/send_reminder.dsl`:
160
+
161
+ ```
162
+ canExecute:
163
+ [status] != 'Paid' AND [status] != 'Cancelled' AND [status] != 'Draft' AND [due_date] < TODAY()
164
+
165
+ execute:
166
+ [status] = 'Overdue'
167
+ notify([owner_id], 'Overdue reminder sent for invoice ' + [invoice_number])
168
+ ```
169
+
170
+ ### Params + validation + insert
171
+
172
+ From `modules/wms/logic/actions/adjust_stock.dsl`:
173
+
174
+ ```
175
+ params:
176
+ adjustment_qty: number required "Adjustment Quantity"
177
+ reason: text required "Reason"
178
+
179
+ canExecute:
180
+ [quantity] >= 0
181
+
182
+ execute:
183
+ var newQty = [quantity] + params[adjustment_qty]
184
+
185
+ if (newQty < 0) {
186
+ error('Adjustment would result in negative stock (' + newQty + ')')
187
+ }
188
+
189
+ var movementType = IF(params[adjustment_qty] >= 0, 'Adjustment', 'Write-off')
190
+
191
+ insert('stock_movement', {
192
+ movement_type: movementType,
193
+ warehouse_id: [warehouse_id],
194
+ product_id: [product_id],
195
+ quantity: IF(params[adjustment_qty] >= 0, params[adjustment_qty], params[adjustment_qty] * -1),
196
+ movement_date: now(),
197
+ reference_no: params[reason]
198
+ })
199
+
200
+ [quantity] = newQty
201
+ info('Stock adjusted by ' + params[adjustment_qty] + '. New balance: ' + newQty)
202
+ ```
203
+
204
+ ### Query + loop + multi-insert (complex orchestration)
205
+
206
+ From `modules/crm/logic/actions/create_quote_from_opp.dsl`:
207
+
208
+ ```
209
+ canExecute:
210
+ [stage] != 'Closed Won' AND [stage] != 'Closed Lost'
211
+
212
+ execute:
213
+ var lines = query('SELECT line_id, product_id, quantity, unit_price, discount_pct, description, sort_order FROM crm.opportunity_line WHERE opportunity_id = @oppId ORDER BY sort_order', { oppId: [opportunity_id] })
214
+
215
+ if (lines.length == 0) {
216
+ error('Cannot create quote: opportunity has no product lines. Add products first.')
217
+ }
218
+
219
+ var subtotal = 0
220
+ for (var i = 0; i < lines.length; i++) {
221
+ var line = lines[i]
222
+ subtotal = subtotal + (line.quantity * line.unit_price)
223
+ }
224
+
225
+ var quote = insert('quote', {
226
+ opportunity_id: [opportunity_id],
227
+ account_id: [account_id],
228
+ contact_id: [contact_id],
229
+ status: 'Draft',
230
+ quote_date: now(),
231
+ expiry_date: addDays(now(), 30),
232
+ subtotal: subtotal,
233
+ discount_pct: 0,
234
+ tax_pct: 0,
235
+ notes: 'Created from opportunity: ' + [opportunity_name]
236
+ })
237
+
238
+ for (var i = 0; i < lines.length; i++) {
239
+ var line = lines[i]
240
+ insert('quote_line', {
241
+ quote_id: quote.quote_id,
242
+ product_id: line.product_id,
243
+ quantity: line.quantity,
244
+ unit_price: line.unit_price,
245
+ discount_pct: line.discount_pct || 0,
246
+ description: line.description,
247
+ sort_order: line.sort_order || (i + 1)
248
+ })
249
+ }
250
+
251
+ info('Quote ' + quote.quote_number + ' created from opportunity ' + [opportunity_name])
252
+ notify([owner_id], 'Quote ' + quote.quote_number + ' created for ' + [opportunity_name])
253
+ ```
254
+
255
+ ### Reference preloading + email
256
+
257
+ From `modules/crm/logic/actions/approve_quote.dsl`:
258
+
259
+ ```
260
+ canExecute:
261
+ [status] = 'Sent'
262
+
263
+ execute:
264
+ [status] = 'Accepted'
265
+ notify([owner_id], 'Quote ' + [quote_number] + ' has been accepted')
266
+
267
+ var contact = preloadRef('contact_id')
268
+ if (contact.get('email') != null) {
269
+ sendEmail(contact.get('email'), 'Your quote ' + [quote_number] + ' is confirmed', '<p>Dear ' + contact.get('first_name') + ',</p><p>Your quote has been accepted.</p>')
270
+ }
271
+ ```
272
+
273
+ ### Query + update (cross-entity stock management)
274
+
275
+ From `modules/wms/logic/actions/receive_goods.dsl`:
276
+
277
+ ```
278
+ canExecute:
279
+ [status] = 'Approved'
280
+
281
+ execute:
282
+ var poLines = query("SELECT line_id, product_id, quantity FROM purchase_order_line WHERE purchase_order_id = @poId", { poId: [purchase_order_id] })
283
+
284
+ for (var i = 0; i < poLines.length; i++) {
285
+ var line = poLines[i]
286
+ var productId = line['product_id']
287
+ var qty = line['quantity']
288
+
289
+ insert('stock_movement', {
290
+ movement_type: 'Receipt',
291
+ warehouse_id: [warehouse_id],
292
+ product_id: productId,
293
+ quantity: qty,
294
+ movement_date: now(),
295
+ reference_no: [po_number]
296
+ })
297
+
298
+ var existing = query("SELECT stock_id, quantity FROM stock WHERE warehouse_id = @wh AND product_id = @prod", { wh: [warehouse_id], prod: productId })
299
+
300
+ if (existing.length > 0) {
301
+ var newQty = existing[0]['quantity'] + qty
302
+ query("UPDATE stock SET quantity = @newQty, last_updated = NOW() WHERE stock_id = @sid", { newQty: newQty, sid: existing[0]['stock_id'] })
303
+ } else {
304
+ insert('stock', {
305
+ warehouse_id: [warehouse_id],
306
+ product_id: productId,
307
+ quantity: qty
308
+ })
309
+ }
310
+ }
311
+
312
+ [status] = 'Received'
313
+ info('Received ' + poLines.length + ' line(s) from PO ' + [po_number])
314
+ ```
315
+
316
+ ## Execution modes
317
+
318
+ Actions run in one of two modes, declared in `ui/actions.json`:
319
+
320
+ | Mode | Description | `[field]` refers to |
321
+ |---|---|---|
322
+ | `"single"` (default) | Action runs once per selected record (server loops over the keys) | The current record |
323
+ | `"each"` | Synonym for `"single"` — identical runtime behavior; kept for readability when intent is "do this for each selected row" | The current record |
324
+ | `"batch"` | Action runs once with all records exposed as `__records`; iterate explicitly with `for x in records { ... }` | No bare `[field]` — use `x[field]` inside the loop |
325
+
326
+ In `batch` mode, access individual records with `__records.get(i)`, the count with `__records.count()`, and IDs via `__records.ids`.
327
+
328
+ ## Async actions
329
+
330
+ For long-running actions, set `"isAsync": true` in `ui/actions.json`. The action runs in the background via Hangfire, and the user gets a completion notification via SSE. The DSL syntax is the same.
331
+
332
+ ## Registering the action
333
+
334
+ In `ui/actions.json`:
335
+
336
+ ```json
337
+ {
338
+ "mark_done": {
339
+ "label": "Mark Done",
340
+ "description": "Mark this item as completed",
341
+ "icon": "bi-check-circle",
342
+ "entityCode": "todo_item",
343
+ "executionMode": "single",
344
+ "script": "mark_done",
345
+ "isTransacted": true,
346
+ "orderNum": 10
347
+ },
348
+ "create_quote_from_opp": {
349
+ "label": "Create Quote",
350
+ "description": "Create a draft quote from this opportunity's products",
351
+ "icon": "bi-file-earmark-plus",
352
+ "entityCode": "opportunity",
353
+ "executionMode": "single",
354
+ "script": "create_quote_from_opp",
355
+ "isTransacted": true,
356
+ "orderNum": 50
357
+ }
358
+ }
359
+ ```
360
+
361
+ **Action registration properties:**
362
+
363
+ | Property | Required | Description |
364
+ |---|---|---|
365
+ | `label` | Yes | Button label in the UI |
366
+ | `description` | Yes | Tooltip/description text |
367
+ | `icon` | Yes | Bootstrap icon **with** `bi-` prefix (e.g. `"bi-check-circle"`) — unlike menus which omit the prefix |
368
+ | `entityCode` | Yes | Which entity this action applies to |
369
+ | `executionMode` | Yes | `"single"` (default) or `"each"` — runs per record, DSL uses `[field]`; or `"batch"` — runs once, DSL uses `for x in records { ... }` with `__records` |
370
+ | `script` | Yes | DSL script name (without path or extension — matches filename in `logic/actions/`) |
371
+ | `isTransacted` | Recommended | `true` = all changes roll back on error; `false` = partial commits possible |
372
+ | `orderNum` | Recommended | Display order in the action menu |
373
+ | `isAsync` | Optional | `true` = runs in background via Hangfire, user gets completion notification |
374
+
375
+ ## SQL in query() — important notes
376
+
377
+ 1. **Table names are schema-qualified** within the module: `crm.contact`, `wms.stock_movement`, `fin.invoice`. Use the module code as the schema prefix.
378
+ 2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
379
+ 3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
380
+ 4. **`insert()` is preferred over `query('INSERT ...')`** for inserting into module entities — it handles auto-fill (PKs, audit, number sequences). Use raw `query('INSERT ...')` only for cross-module or system tables.
381
+
382
+ ## Common mistakes
383
+
384
+ - Writing `params.note` instead of `params[note]` — **wrong**. Use bracket syntax for params.
385
+ - Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
386
+ - Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
387
+ - Writing `insert('entity', field1, value1)` (positional args) — **wrong**. Use `insert('entity', { field: value })` (object).
388
+ - Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders.
389
+ - Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
390
+ - Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
391
+ - 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).
392
+ - Forgetting that `insert()` returns the full row — you can use `quote.quote_id` immediately after insert.
393
+ - Using `[field]` inside a `query()` SQL string — **wrong**. `[field]` is resolved in JS scope, not inside SQL strings. Pass values as `@param` arguments.
394
+
395
+ ## Internal API (advanced)
396
+
397
+ The platform globals (`insert()`, `query()`, `error()`, etc.) are sugar on top of an internal API: `__ctx.insert()`, `__ctx.query()`, `__ctx.error()`, `__r.get('field')`, `__r.set('field', value)`. Both forms work in DSL scripts. The bare form (without `__ctx.` / `__r.`) is preferred for readability.
@@ -0,0 +1,168 @@
1
+ # Column Types Reference
2
+
3
+ Every column has a `columnType` (optional for the default physical type). Seven values exist:
4
+
5
+ | `columnType` | Physical DB column? | Description |
6
+ |---|---|---|
7
+ | (omitted) or `"D"` | Yes | **Data column** — default. Maps to a real SQL column. |
8
+ | `"R"` | No | **Reference column** — virtual N:1 lookup. Paired with a hidden FK column (see FK+Reference pattern). |
9
+ | `"S"` | No | **Set column** — virtual 1:N backwards reference. Used for detail grids. |
10
+ | `"F"` | No | **Formula column** — virtual computed value. Evaluated by the formula engine. |
11
+ | `"A"` | Yes | **Accumulation register column** — stores posted state for accumulation registers. Advanced (accounting modules). |
12
+ | `"L"` | Yes | **Ledger register column** — stores posted state for double-entry bookkeeping. Advanced (accounting modules). |
13
+ | `"G"` | Yes | **Generated column** — DB-level computed aggregate (e.g. SUM over child set via trigger, or PostgreSQL `GENERATED ALWAYS AS`). The aggregated child column **must be physical** (a `D` column) — never a virtual `F`/`R`/`S` column. For a simple roll-up total, prefer `F` (see below). |
14
+
15
+ **For most module development, you'll only use D, R, S, and F.** Types A, L, and G are for advanced accounting/registry modules that use the `postable`, `accumulation`, or `ledger` traits.
16
+
17
+ ## Data columns (`columnType` omitted or `"D"`)
18
+
19
+ Most columns. They map 1:1 to a physical SQL column.
20
+
21
+ ```json
22
+ "first_name": {
23
+ "dbDatatype": "varchar",
24
+ "fieldTypeCd": "text",
25
+ "flags": "VEM",
26
+ "maxLen": 100,
27
+ "orderNum": 20,
28
+ "description": "First Name"
29
+ }
30
+ ```
31
+
32
+ ## Reference columns (`columnType: "R"`)
33
+
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
+
36
+ ```json
37
+ // Hidden FK column — physical, hidden
38
+ "account_id": {
39
+ "dbDatatype": "cuid",
40
+ "flags": "EM",
41
+ "orderNum": 90,
42
+ "description": "Account ID"
43
+ },
44
+ // Visible Reference column — virtual, shown as lookup picker
45
+ "account": {
46
+ "columnType": "R",
47
+ "fieldTypeCd": "lookup",
48
+ "flags": "VEM",
49
+ "orderNum": 95,
50
+ "description": "Account",
51
+ "link": {
52
+ "entity": "account",
53
+ "thisKey": "account_id",
54
+ "otherKey": "account_id"
55
+ }
56
+ }
57
+ ```
58
+
59
+ Plus declare the FK constraint in the entity's `references` block:
60
+
61
+ ```json
62
+ "references": {
63
+ "FK_Contact_Account": {
64
+ "from": { "field": "account_id" },
65
+ "to": { "entity": "account", "field": "account_id" }
66
+ }
67
+ }
68
+ ```
69
+
70
+ > **FK column `dbDatatype` must exactly match the referenced entity's PK `dbDatatype`.** Never guess.
71
+ > - Entities using the `identity` trait → PK is `dbDatatype: "cuid"` → FK column must also be `"cuid"`
72
+ > - Cross-module or legacy entities → call `dforge_module_inspect` on the referenced module and read the PK column's `dbDatatype` before declaring the FK
73
+ > - **`bigint`, `integer`, `int8` are wrong values for FK columns** — even though `cuid` is physically stored as int8, the platform type name is `cuid`, not `bigint`
74
+
75
+ **Why two columns?** The FK stores the actual value. The Reference column configures how the value is rendered in the UI (as a typeahead picker showing the target's `toString`). They must be kept in sync — column order in JSON doesn't matter but `orderNum` determines UI ordering.
76
+
77
+ ## Set columns (`columnType: "S"`)
78
+
79
+ Virtual 1:N backwards reference. Shows a grid of related records on the parent's detail view.
80
+
81
+ ```json
82
+ "activities": {
83
+ "columnType": "S",
84
+ "fieldTypeCd": "grid",
85
+ "flags": "VEM",
86
+ "orderNum": 150,
87
+ "description": "Activities",
88
+ "link": {
89
+ "entity": "activity",
90
+ "thisKey": "contact_id", // column on THIS entity (parent PK)
91
+ "otherKey": "contact_id" // column on the OTHER entity (FK back to parent)
92
+ }
93
+ }
94
+ ```
95
+
96
+ `link.entity` is the child entity. `link.thisKey` is the column on the current entity (typically the PK). `link.otherKey` is the FK column on the child entity pointing back to the parent.
97
+
98
+ Set columns are **not** declared in the `references` block — the FK already lives on the child entity.
99
+
100
+ ## Formula columns (`columnType: "F"`)
101
+
102
+ Virtual computed column. No physical storage. Value is calculated at runtime by the formula engine.
103
+
104
+ ```json
105
+ "full_name": {
106
+ "columnType": "F",
107
+ "fieldTypeCd": "text",
108
+ "baseDatatypeCd": "string",
109
+ "flags": "V",
110
+ "orderNum": 25,
111
+ "formula": "[first_name] + ' ' + [last_name]",
112
+ "description": "Full Name"
113
+ }
114
+ ```
115
+
116
+ **Required fields for formula columns**:
117
+
118
+ - `columnType: "F"`
119
+ - `fieldTypeCd` — how to render it
120
+ - `baseDatatypeCd` — **required** on formula columns even though there's no physical column; used for type inference
121
+ - `flags: "V"` (usually — formula columns are almost always read-only)
122
+ - `formula` — the formula expression
123
+ - `orderNum`
124
+ - `description`
125
+
126
+ Do **not** include `dbDatatype` on formula columns (there's no physical storage).
127
+
128
+ See `formulas.md` for syntax.
129
+
130
+ ## Roll-up totals over child rows — use `F`, not `G`
131
+
132
+ To total a child set on a header (line items → order total, movements → quantity on hand),
133
+ **use a Formula column (`"F"`) with `SUM([set].[field])`**. It is computed at query time, so it
134
+ can reference **any** child column — including the child's own formula columns.
135
+
136
+ ```json
137
+ // purchase_order.total_amount — query-time roll-up (installs cleanly)
138
+ "total_amount": {
139
+ "columnType": "F",
140
+ "fieldTypeCd": "currency",
141
+ "baseDatatypeCd": "number",
142
+ "flags": "V",
143
+ "orderNum": 90,
144
+ "formula": "SUM([lines].[line_total])", // line_total may itself be a formula column
145
+ "description": "Total Amount"
146
+ }
147
+ ```
148
+
149
+ > ⛔ **Never make a `G` (Generated) column aggregate a virtual `F` column.** A `G` aggregate is
150
+ > maintained by a database trigger that reads the child's `OLD`/`NEW` *physical* values. A formula
151
+ > (`F`) column has no physical storage, so the trigger references a column that doesn't exist and
152
+ > **install fails** with `db_error: column old.<field> does not exist`. If you need a stored
153
+ > (`G`) aggregate over a *derived* quantity (e.g. a signed movement quantity), make that child
154
+ > column **physical** first — either a plain `D` column written by the action logic, or a
155
+ > same-row `G`/`GENERATED ALWAYS AS` column with a `dbDatatype` — then aggregate that. For most
156
+ > modules a query-time `F` roll-up is the right choice; reserve `G` for high-volume stored aggregates.
157
+
158
+ ## Quick reference: which columnType do I use?
159
+
160
+ - **Plain text/number/date/etc. field that stores a value** → omit `columnType` (default `"D"`)
161
+ - **"This contact belongs to one account"** → `"R"` (plus hidden FK)
162
+ - **"This account has many contacts"** → `"S"` on the account side
163
+ - **"Compute full_name from first_name + last_name"** → `"F"`
164
+ - **"User picker" (points to admin.user)** → omit `columnType`, use `fieldTypeCd: "user"` (writes user ID directly, no paired FK)
165
+ - **"Polymorphic link to any entity"** → omit `columnType`, use `fieldTypeCd: "entitylink"` (stored as JSON)
166
+ - **"Roll-up total over child rows"** (line items → header total, movements → quantity on hand) → `"F"` with `SUM([set].[field])` (query-time; see "Roll-up totals" above). Reserve `"G"` for high-volume *stored* aggregates, and only over a **physical** child column — never over an `F` column.
167
+ - **"Accumulation register state"** → `"A"` (advanced accounting — requires `postable` + `accumulation` traits)
168
+ - **"Double-entry ledger state"** → `"L"` (advanced accounting — requires `postable` + `ledger` traits)