@dforge-core/dforge-mcp 0.1.10 → 0.1.13
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 +108 -0
- package/dist/server.js +10 -1
- package/package.json +43 -43
- package/resources/docs/conventions.md +1 -1
- package/resources/docs/dsl-reference.md +81 -1
- package/resources/schemas/entity.schema.json +3 -3
- package/skills/dforge-mcp-author/SKILL.md +4 -1
- package/skills/dforge-mcp-author/references/action-dsl.md +104 -5
- package/skills/dforge-mcp-author/references/column-domains.md +94 -0
- package/skills/dforge-mcp-author/references/document-extraction.md +113 -0
- package/skills/dforge-mcp-author/references/field-types.md +8 -2
- package/skills/dforge-mcp-author/references/formulas.md +90 -22
- package/skills/dforge-mcp-author/references/jobs.md +2 -2
- package/skills/dforge-mcp-author/references/translations.md +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,114 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
|
|
|
4
4
|
`0.1.0-rc.N` pre-release tags; the published version is set at publish time via
|
|
5
5
|
the release workflow, so committed `package.json` versions are placeholders.
|
|
6
6
|
|
|
7
|
+
## 0.1.12
|
|
8
|
+
|
|
9
|
+
> Release prerequisite: publish `@dforge-core/metadata@0.0.10` first (adds the
|
|
10
|
+
> `domains.schema.json` schema + the domain-backed column branch in
|
|
11
|
+
> `entity.schema.json`), then `pnpm install` + `sync-schemas` here so the
|
|
12
|
+
> vendored schemas match.
|
|
13
|
+
|
|
14
|
+
### Skill
|
|
15
|
+
|
|
16
|
+
- **Column domains** — new `references/column-domains.md`: a reusable field type
|
|
17
|
+
(base datatype + control + shared option list) declared in `domains.json` and
|
|
18
|
+
referenced from a column via a single `"domain": "module_cd.domain_cd"` key
|
|
19
|
+
instead of restating the type. Covers the owned-field rules (restating a
|
|
20
|
+
domain-owned field fails install), scalar-only constraint, cross-module
|
|
21
|
+
dependency requirement, and the localize-once payoff. Added to the SKILL
|
|
22
|
+
loading-policy table and cross-linked from `field-types.md`.
|
|
23
|
+
- **Document extraction / OCR** — new `references/document-extraction.md`: the
|
|
24
|
+
`ocrExtract()` forms (v1 raw string, v2 inline `schema` + `{ mode: 'extract' }`,
|
|
25
|
+
v2 `{ profile }`), the `logic/extraction_profiles.json` registry (profile
|
|
26
|
+
lifecycle + `detect` rules), and the `detectDocument(rawText)` doc-type
|
|
27
|
+
auto-detect built-in. The DSL reference (`dforge://docs/dsl`) gains an
|
|
28
|
+
"External integration → Document extraction" section documenting both built-ins.
|
|
29
|
+
- **Dropdown / domain option localization** (`translations.md`): per-option labels
|
|
30
|
+
are translatable (opt-in) under `entities.<e>.fields.<f>.options` (value-keyed,
|
|
31
|
+
string shorthand or `{label,icon,color}` partial override — `value` never
|
|
32
|
+
translated), and a column domain's label + shared option labels are translated
|
|
33
|
+
once under a top-level `domains` section, inherited by every consuming column.
|
|
34
|
+
- **Formulas** (`formulas.md`) — corrected against the real parser and runtime,
|
|
35
|
+
after a reported `F` column that rendered empty on every row with no error
|
|
36
|
+
anywhere. The doc had documented functions the engine never implemented
|
|
37
|
+
(`ISNULL`, `NULLIF`, `MOD`, `LTRIM`, `RTRIM`) — since the parser accepts any
|
|
38
|
+
`NAME(...)` as a call, those installed cleanly and then rendered blank. They
|
|
39
|
+
are implemented now, and an unknown function is rejected at module install.
|
|
40
|
+
Also fixed: `==`, `%` and `"double-quoted strings"` are **parse errors**
|
|
41
|
+
(four examples used them); `CONTAINS`/`STARTS_WITH`/`ENDS_WITH` are infix
|
|
42
|
+
operators, not functions; and the documented `CASE` signature was actually
|
|
43
|
+
`SWITCH`'s — `CASE([priority], 'high', 3, …)` returns `'high'` for every row,
|
|
44
|
+
because the real `CASE` takes condition/result pairs. Adds the previously
|
|
45
|
+
undocumented `LEFT`/`RIGHT`/`FROUND`/`MID`/`INDEX_OF`/`SPLIT` and the
|
|
46
|
+
conversion functions, so the list now matches the engine exactly, plus notes
|
|
47
|
+
on date comparison, 0-based string indexing, and out-of-domain math. Every
|
|
48
|
+
function is now implemented client-side *and* SQL-side, so a column reads the
|
|
49
|
+
same in a grid and in a report.
|
|
50
|
+
|
|
51
|
+
### Resources
|
|
52
|
+
|
|
53
|
+
- New `dforge://schema/domains` (JSON Schema for `domains.json`).
|
|
54
|
+
- New `dforge://reference/column-domains` and `dforge://reference/document-extraction`.
|
|
55
|
+
|
|
56
|
+
### Schema
|
|
57
|
+
|
|
58
|
+
- Re-vendored `entity.schema.json` — adds the "Domain-backed column" branch
|
|
59
|
+
(`required: ["domain"]`, forbidding restatement of domain-owned fields) and the
|
|
60
|
+
`domain` field property.
|
|
61
|
+
- Vendored `domains.schema.json` (new) from `@dforge-core/metadata@0.0.10`.
|
|
62
|
+
|
|
63
|
+
## 0.1.11
|
|
64
|
+
|
|
65
|
+
> Release prerequisite: publish `@dforge-core/metadata@0.0.9` first (adds the
|
|
66
|
+
> `avatar`/`markdown` field types to the runtime registry — until then the field
|
|
67
|
+
> tools reject them), then `pnpm install` + `sync-schemas` here.
|
|
68
|
+
|
|
69
|
+
### Skill
|
|
70
|
+
|
|
71
|
+
- **Action DSL: `select()` / `update()` / `delete()` documented.**
|
|
72
|
+
`select('module.entity', {columns, filter, orderBy, limit, offset})` is the
|
|
73
|
+
structured multi-row read: canonical `{c,o,v}`/`{g,i}` filter JSON (same
|
|
74
|
+
operators as data views), one-hop nav paths `'ref.target as alias'` that emit
|
|
75
|
+
LEFT JOINs, **fail-loud** semantics (unknown column/operator/nav path throws —
|
|
76
|
+
unlike UI filters, which drop the condition), and no folder row-filter/column
|
|
77
|
+
security (it hits the table directly; filter nav paths instead of `R`
|
|
78
|
+
columns). `update()`/`delete()` take a scalar PK **or** a `{col: val}` object
|
|
79
|
+
key (string ids coerced to the PK storage type; array and null keys rejected).
|
|
80
|
+
`query()` is repositioned as the escape hatch (aggregates, multi-hop joins,
|
|
81
|
+
CTEs, `FOR UPDATE`). New real example: `comm.dispatch_messages` (cron-scan
|
|
82
|
+
`select()` + scalar-key `update()`). Also documents `exit(message?, level?)` —
|
|
83
|
+
the DSL's early-return — and the rule to always qualify entity codes
|
|
84
|
+
(`update('comm.message', …)`) (`action-dsl.md`, `jobs.md`, `SKILL.md`).
|
|
85
|
+
- **Formula date family completed + SQL-time parity** (`formulas.md`). Adds the
|
|
86
|
+
missing `WEEKDAY`, `DATE`, and period-boundary functions (`STARTMONTH`,
|
|
87
|
+
`ENDMONTH`, `STARTQUARTER`, `ENDQUARTER`, `STARTYEAR`, `ENDYEAR`,
|
|
88
|
+
`STARTNEXTMONTH` — optional arg defaults to today) and corrects
|
|
89
|
+
`DATEADD`/`DATEDIFF` signatures (string-literal units `'DAY'`/`'HOUR'`/
|
|
90
|
+
`'MINUTE'`/`'SECOND'`/`'MONTH'`/`'YEAR'`; `DATEDIFF(d1, d2, unit)` = `d2 − d1`).
|
|
91
|
+
The whole family now evaluates identically client-side and SQL-time (reports,
|
|
92
|
+
filters, sorts) — the server SQL translator gained the full set, so
|
|
93
|
+
`DATEDIFF([due_date], TODAY(), 'DAY')` no longer comes back NULL in reports.
|
|
94
|
+
- **Field types** (`field-types.md`): added `avatar` (initials circle +
|
|
95
|
+
optional photo via `params.imageColumn`), `markdown` (Write/Preview editor,
|
|
96
|
+
distinct from `richtext` WYSIWYG — the alias table previously mapped
|
|
97
|
+
`markdown` → `richtext`, now wrong), and `list` (card-list rendering of a 1:N
|
|
98
|
+
set, sibling of `grid`).
|
|
99
|
+
|
|
100
|
+
### Resources
|
|
101
|
+
|
|
102
|
+
- **`dforge://reference/dsl` (`resources/docs/dsl-reference.md`) caught up with the
|
|
103
|
+
engine**: documents `select()`, `update()`, `delete()` (previously absent — the
|
|
104
|
+
reference stopped at `query()`/`insert()`), plus `entityLink()`, the
|
|
105
|
+
qualify-entity-codes rule, and scalar-vs-object key semantics.
|
|
106
|
+
|
|
107
|
+
### Schema
|
|
108
|
+
|
|
109
|
+
- **`entity.schema.json` re-vendored** (from `@dforge-core/metadata@0.0.9`):
|
|
110
|
+
`fieldTypeCd` enum gains `avatar`, `markdown`, `list`; `refFilter` description
|
|
111
|
+
now documents `@[field]` dynamic placeholders (resolved against the current
|
|
112
|
+
record at option load; an unset placeholder drops the condition, and lookups
|
|
113
|
+
re-resolve options on every open).
|
|
114
|
+
|
|
7
115
|
## 0.1.10
|
|
8
116
|
|
|
9
117
|
### Schema
|
package/dist/server.js
CHANGED
|
@@ -31180,6 +31180,7 @@ var AGG_TYPE_LIST = Object.values(AggType);
|
|
|
31180
31180
|
// node_modules/@dforge-core/metadata/dist/field-types.js
|
|
31181
31181
|
var fieldTypes = [
|
|
31182
31182
|
{ cd: "text", name: "Text", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31183
|
+
{ cd: "avatar", name: "Avatar Name", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31183
31184
|
{ cd: "email", name: "Email", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31184
31185
|
{ cd: "phone", name: "Phone", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31185
31186
|
{ cd: "url", name: "URL", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
@@ -31198,6 +31199,7 @@ var fieldTypes = [
|
|
|
31198
31199
|
{ cd: "color", name: "Color Picker", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: 20, precision: null, useStringInFilter: false },
|
|
31199
31200
|
{ cd: "image", name: "Image", columnType: "D", baseDatatypeCd: "binary", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: false },
|
|
31200
31201
|
{ cd: "file", name: "File", columnType: "D", baseDatatypeCd: "binary", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: false },
|
|
31202
|
+
{ cd: "markdown", name: "Markdown", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31201
31203
|
{ cd: "richtext", name: "Rich Text Editor", columnType: "D", baseDatatypeCd: "string", supportedAgg: null, maxLen: null, precision: null, useStringInFilter: true },
|
|
31202
31204
|
{ cd: "lookup", name: "Lookup", columnType: "R", baseDatatypeCd: "guid", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: true },
|
|
31203
31205
|
{ cd: "user", name: "User Picker", columnType: "D", baseDatatypeCd: "cuid", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: false },
|
|
@@ -31355,7 +31357,7 @@ function expandTraits(traitCds, entityName) {
|
|
|
31355
31357
|
|
|
31356
31358
|
// node_modules/@dforge-core/metadata/dist/derive.js
|
|
31357
31359
|
var NUMERIC_TOTAL = 18;
|
|
31358
|
-
var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "richtext", "code"]);
|
|
31360
|
+
var TEXT_BACKED = /* @__PURE__ */ new Set(["textarea", "markdown", "richtext", "code"]);
|
|
31359
31361
|
var DB_DATATYPE_OVERRIDE = {
|
|
31360
31362
|
file: "jsonb",
|
|
31361
31363
|
image: "jsonb"
|
|
@@ -34364,6 +34366,11 @@ var resources = [
|
|
|
34364
34366
|
"Entity JSON schema",
|
|
34365
34367
|
"JSON Schema for entity files under entities/*.json (description, dbObject, toString, traits, fields)."
|
|
34366
34368
|
),
|
|
34369
|
+
schema(
|
|
34370
|
+
"domains",
|
|
34371
|
+
"Column domains JSON schema",
|
|
34372
|
+
'JSON Schema for domains.json \u2014 reusable column domains (base datatype + control + sizing + shared option list) referenced from a field via "domain": "module_cd.domain_cd".'
|
|
34373
|
+
),
|
|
34367
34374
|
schema(
|
|
34368
34375
|
"data-views",
|
|
34369
34376
|
"Data view JSON schema",
|
|
@@ -34442,6 +34449,7 @@ var resources = [
|
|
|
34442
34449
|
reference("field-types", "Field types \u2014 fieldTypeCd (UI control) vs dbDatatype (SQL type), and the correct value for each. Load before adding any field."),
|
|
34443
34450
|
reference("flags", "Column flags \u2014 only V/I/E/M/H; valid combos (VEM/VE/V/EM/I) and why 'VEMHI' is invalid. Load before setting any flags."),
|
|
34444
34451
|
reference("column-types", "The FK + Reference two-column pattern (the #1 source of broken modules) and Set columns. Load before any relation."),
|
|
34452
|
+
reference("column-domains", "Column domains: a reusable field type (datatype + control + shared option list) referenced via a single `domain` key. Use when the same enum/type repeats across columns. Load before authoring domains.json or a domain-backed column."),
|
|
34445
34453
|
reference("formulas", "Formula columns (columnType 'F'): baseDatatypeCd, no dbDatatype, flags 'V', and the formula expression grammar."),
|
|
34446
34454
|
reference("traits", "Entity traits (identity, audit, audit-full, ...). identity \u2192 PK is '{entity}_id'; don't redefine trait columns."),
|
|
34447
34455
|
reference("data-views", "Data views: dataSources array, columns, the order string-array ('order': ['-col','col']), and specialized view configs."),
|
|
@@ -34453,6 +34461,7 @@ var resources = [
|
|
|
34453
34461
|
reference("settings", "Module settings shape."),
|
|
34454
34462
|
reference("jobs", "Scheduled jobs: cron, timeout, jobClass, and the no-record-context constraint."),
|
|
34455
34463
|
reference("number-sequences", "Number sequences for reference numbers / codes."),
|
|
34464
|
+
reference("document-extraction", "OCR / document extraction: ocrExtract() (v2 schema-driven + profile forms), logic/extraction_profiles.json, and the detectDocument() doc-type auto-detect built-in. Load before wiring any OCR/intake action."),
|
|
34456
34465
|
reference("print-templates", "Liquid HTML print templates."),
|
|
34457
34466
|
reference("translations", "Translation files: locale-keyed, every trait-provided field needs an entry."),
|
|
34458
34467
|
reference("queries", "Pre-built saved queries."),
|
package/package.json
CHANGED
|
@@ -1,45 +1,45 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
2
|
+
"name": "@dforge-core/dforge-mcp",
|
|
3
|
+
"version": "0.1.13",
|
|
4
|
+
"description": "MCP server for dForge module authoring. Exposes scaffold/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/iash44/dForge-core",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/dforge-core/dforge-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"dforge-mcp": "dist/server.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist/",
|
|
16
|
+
"docs/",
|
|
17
|
+
"resources/",
|
|
18
|
+
"skills/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"main": "dist/server.js",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"sync-schemas": "node scripts/vendor-schemas.cjs",
|
|
29
|
+
"prepublishOnly": "node scripts/vendor-schemas.cjs && tsup",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@dforge-core/dforge-cli": "^0.2.7",
|
|
35
|
+
"@dforge-core/metadata": "^0.0.10",
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
|
+
"zod": "^4.4.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^22.20.1",
|
|
41
|
+
"tsup": "^8.5.1",
|
|
42
|
+
"typescript": "^6.0.3",
|
|
43
|
+
"vitest": "^3.2.7"
|
|
44
|
+
}
|
|
45
45
|
}
|
|
@@ -562,7 +562,7 @@ Cron-driven action fires. Each entry pairs an existing action (declared in `ui/a
|
|
|
562
562
|
- `schedule` — five fields, minute granularity (sub-minute schedules are rejected at install)
|
|
563
563
|
- `timeout` — required, in seconds, range `(0, 3600]`
|
|
564
564
|
- `timeout > 300` requires `"class": "long_running"` (the standard pool is capped at 300s)
|
|
565
|
-
- Referenced action must NOT use `[field]` or `for x in records` — scheduled fires have no record context. Install fails fast if it does. Use `
|
|
565
|
+
- Referenced action must NOT use `[field]` or `for x in records` — scheduled fires have no record context. Install fails fast if it does. Use `select()` / `insert()` / `update()` for table-level work.
|
|
566
566
|
- Max 50 jobs per module
|
|
567
567
|
- Optional `timeZone` (IANA name) overrides `auth.tenant.time_zone` for that job
|
|
568
568
|
- Use `paused: true` to freeze a job during incidents — admin "Run now" still works while the cron path is silent
|
|
@@ -130,10 +130,13 @@ execute:
|
|
|
130
130
|
|
|
131
131
|
---
|
|
132
132
|
|
|
133
|
-
## Built-in functions
|
|
133
|
+
## Built-in functions
|
|
134
134
|
|
|
135
135
|
All exposed by `ActionScriptEngine.cs` as host globals — no `import` needed.
|
|
136
136
|
|
|
137
|
+
> **Entity codes: always qualify** (`'comm.message'`, not `'message'`) in every data
|
|
138
|
+
> built-in, even inside the owning module.
|
|
139
|
+
|
|
137
140
|
### Database & data
|
|
138
141
|
|
|
139
142
|
#### `query(sql, params?) → array`
|
|
@@ -168,6 +171,46 @@ var po = insert('purchase_order', { supplier_id: params[supplier].supplier_id })
|
|
|
168
171
|
insert('po_line', { po_id: po.purchase_order_id, product_id: [product_id], qty: 1 })
|
|
169
172
|
```
|
|
170
173
|
|
|
174
|
+
#### `select(entityCd, opts?) → array`
|
|
175
|
+
Structured multi-row read — the counterpart to `insert()`/`update()`/`delete()`; prefer
|
|
176
|
+
it over `query()` for row-shaped reads. `opts` (all optional): `columns` (paths; a
|
|
177
|
+
one-hop nav path `'ref.target as alias'` LEFT JOINs the referenced entity; formula
|
|
178
|
+
columns translate to SQL; default = all physical columns), `filter` (canonical
|
|
179
|
+
`{c,o,v}`/`{g,i}` filter JSON, nav paths allowed), `orderBy` (`'-'` prefix =
|
|
180
|
+
descending; default = PK), `limit`, `offset`. **Fail-loud:** an unknown column,
|
|
181
|
+
operator, or nav path throws instead of silently dropping the condition. Hits the
|
|
182
|
+
table directly (no folder row-filter / column security); a plain reference (`R`)
|
|
183
|
+
column can't be selected or string-filtered — use the FK column or a nav path.
|
|
184
|
+
`query()` remains the escape hatch (aggregates, multi-hop joins, CTEs, `FOR UPDATE`).
|
|
185
|
+
|
|
186
|
+
```dsl
|
|
187
|
+
var queued = select('comm.message', {
|
|
188
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject'],
|
|
189
|
+
filter: { g: 'and', i: [
|
|
190
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
191
|
+
{ c: 'channel', o: '=', v: 'email' }
|
|
192
|
+
] },
|
|
193
|
+
orderBy: ['message_id'],
|
|
194
|
+
limit: 10
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
#### `update(entityCd, key, fields) → int`
|
|
199
|
+
Update the row(s) matched by `key`; returns rows affected. Refreshes update-phase
|
|
200
|
+
server defaults (audit `last_updated` / `last_updated_by`) and coerces values like
|
|
201
|
+
`insert()`. `key` is a scalar PK (`update('task', 42, {…})`) or an object
|
|
202
|
+
(`{ task_id: 42 }`; multiple entries for a compound key). Scalar string ids are
|
|
203
|
+
coerced to the PK storage type (snowflake-as-string binds `int8`). A null key
|
|
204
|
+
**throws**; array keys are rejected — loop and mutate per id.
|
|
205
|
+
|
|
206
|
+
```dsl
|
|
207
|
+
update('comm.message', msg.message_id, { status: 'sent', sent_at: now() })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `delete(entityCd, key) → int`
|
|
211
|
+
Delete the row(s) matched by `key`; returns rows affected. Same key shapes and
|
|
212
|
+
rules as `update()`.
|
|
213
|
+
|
|
171
214
|
#### `getRecord(entityCd, key) → record`
|
|
172
215
|
Fetch a single record by key. **Throws** a localized "not found" error if no row
|
|
173
216
|
matches — the safe default for a lookup that should always resolve (e.g. an FK you
|
|
@@ -374,8 +417,45 @@ var result = callService('studio.validate', { module_cd: [module_cd] })
|
|
|
374
417
|
if (!result.isValid) { error('Validation failed: ' + result.errors[0]) }
|
|
375
418
|
```
|
|
376
419
|
|
|
420
|
+
### Document extraction (OCR)
|
|
421
|
+
|
|
422
|
+
Full guide + intake patterns: `dforge://reference/document-extraction`.
|
|
423
|
+
|
|
424
|
+
#### `ocrExtract(fileField, endpointBaseUrl, schemaOrHints?, opts?) → string | object`
|
|
425
|
+
Send a file to the OCR service and get structured fields back. Three forms:
|
|
426
|
+
|
|
427
|
+
- **v1 (raw):** `ocrExtract([doc_file], getSetting('ocr_endpoint_url'))` → JSON **string** (you `JSON.parse`). Optional third arg = hints (array | string | object).
|
|
428
|
+
- **v2 inline schema:** `ocrExtract([doc_file], url, schema, { mode: 'extract' })` → **parsed** `{ fields, confidence?, unmatched, raw_text, meta }`.
|
|
429
|
+
- **v2 profile:** `ocrExtract([doc_file], url, null, { profile: 'module_cd.profile_cd' })` → same parsed shape, schema from `logic/extraction_profiles.json`.
|
|
430
|
+
|
|
431
|
+
v2 is selected **only** by `opts.mode` (`'extract' | 'summary' | 'raw'`) or `opts.profile` — a bare schema object without `opts.mode` stays on the v1 (raw string) contract. `opts.profile` implies `mode: 'extract'` and is mutually exclusive with an inline `schema`.
|
|
432
|
+
|
|
433
|
+
```dsl
|
|
434
|
+
var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), null, { profile: 'proc.bill_of_lading' })
|
|
435
|
+
insert('bill_of_lading', { bl_no: r.fields.bl_no, issue_date: r.fields.issue_date })
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
#### `detectDocument(rawText) → object | null`
|
|
439
|
+
Score a document's text against every reachable extraction profile's `detect` rules and return the best match at/above its threshold: `{ profile, docType, score }` (profile module-qualified, ready for `ocrExtract(..., { profile })`) — or `null`. Advisory: malformed rules are skipped, let the human confirm.
|
|
440
|
+
|
|
441
|
+
```dsl
|
|
442
|
+
var det = detectDocument(data.raw_text) // { profile: 'proc.bill_of_lading', docType: 'bill_of_lading', score } | null
|
|
443
|
+
if (det != null) { var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), null, { profile: det.profile }) }
|
|
444
|
+
```
|
|
445
|
+
|
|
377
446
|
### Utility
|
|
378
447
|
|
|
448
|
+
#### `entityLink(entityCd, record, description?) → link value`
|
|
449
|
+
Build a clickable link to any entity record for storing in an `entitylink` (jsonb)
|
|
450
|
+
column. `record` is a row object (e.g. from `insert()` / `getRecord()`); its PK
|
|
451
|
+
columns are stored as **strings** so snowflake/cuid ids > 2^53 stay exact. Optional
|
|
452
|
+
`description` sets the display text.
|
|
453
|
+
|
|
454
|
+
```dsl
|
|
455
|
+
var task = insert('workspace.task', { title: 'Follow up' })
|
|
456
|
+
insert('workspace.task_note', { link: entityLink('workspace.task', task, task.title) })
|
|
457
|
+
```
|
|
458
|
+
|
|
379
459
|
#### `flush()`
|
|
380
460
|
Commit pending changes mid-script AND broadcast SSE updates to connected clients. **Async / background actions only** — in sync actions, everything commits at the end automatically.
|
|
381
461
|
|
|
@@ -120,10 +120,10 @@
|
|
|
120
120
|
"fieldTypeCd": {
|
|
121
121
|
"type": "string",
|
|
122
122
|
"enum": [
|
|
123
|
-
"text", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
123
|
+
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
124
124
|
"checkbox", "date", "datetime", "time",
|
|
125
125
|
"dropdown", "flags", "tags", "json", "hidden", "color", "image", "file",
|
|
126
|
-
"richtext", "lookup", "user", "grid", "entitylink", "code"
|
|
126
|
+
"markdown", "richtext", "lookup", "user", "grid", "list", "entitylink", "code"
|
|
127
127
|
],
|
|
128
128
|
"description": "UI field type code from field_type table"
|
|
129
129
|
},
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"description": "On a self-referencing reference column (R column whose link.entity is the owning entity), set true to ALLOW parent/child cycles. When absent the self-reference is treated as an acyclic hierarchy and DB-level guards reject loops. Ignored on non-self-referencing columns."
|
|
211
211
|
},
|
|
212
212
|
"refFilter": {
|
|
213
|
-
"description": "Filter criteria for reference lookups"
|
|
213
|
+
"description": "Filter criteria for reference lookups. Condition values support @[field] placeholders resolved against the current record at lookup time (e.g. {\"c\":\"channel\",\"o\":\"eq\",\"v\":\"@[channel]\"} narrows options to rows matching the record's own channel); a condition whose placeholder is unset is dropped rather than matching nothing."
|
|
214
214
|
}
|
|
215
215
|
},
|
|
216
216
|
"oneOf": [
|
|
@@ -64,6 +64,7 @@ The phase column below indicates the **typical** use. During a backtrack, the ba
|
|
|
64
64
|
|---|---|---|
|
|
65
65
|
| Add any field | `dforge://reference/field-types`, `dforge://reference/flags`, `dforge://example/entities/todo_item.json` | — |
|
|
66
66
|
| Add a Reference or Set column | `dforge://reference/column-types` (FK+Reference pattern), `dforge://example/entities/todo_item.json` | — |
|
|
67
|
+
| Reuse a field type / enum across columns (a `domain`) | `dforge://reference/column-domains` | `dforge://schema/domains` |
|
|
67
68
|
| Add a formula column | `dforge://reference/formulas` | — |
|
|
68
69
|
| Add a trait | `dforge://reference/traits` | — |
|
|
69
70
|
| Add a data view (grid / list) | `dforge://reference/data-views`, `dforge://example/ui/data_views.json` | — |
|
|
@@ -78,6 +79,8 @@ The phase column below indicates the **typical** use. During a backtrack, the ba
|
|
|
78
79
|
| Add security roles or folders | `dforge://reference/security`, `dforge://reference/filters`, `dforge://example/security/roles.json` | — |
|
|
79
80
|
| Add a report | `dforge://reference/reports` | `dforge://schema/reports` |
|
|
80
81
|
| Add a print template | `dforge://reference/print-templates` | — |
|
|
82
|
+
| Wire OCR / document extraction (`ocrExtract` / `detectDocument`) | `dforge://reference/document-extraction` | `dforge://docs/dsl` (built-ins) |
|
|
83
|
+
| Localize dropdown options / a column domain | `dforge://reference/translations` (§Dropdown option labels / §Column domain labels) | — |
|
|
81
84
|
| Add translations | `dforge://reference/translations` | — |
|
|
82
85
|
| Add a number sequence | `dforge://reference/number-sequences` | — |
|
|
83
86
|
| Add module settings | `dforge://reference/settings` | — |
|
|
@@ -274,7 +277,7 @@ For each trigger, propose: entity + event + (optional) condition formula + targe
|
|
|
274
277
|
Load the "scheduled job" row from the Loading policy table (`dforge://reference/jobs` + `dforge://schema/jobs`).
|
|
275
278
|
|
|
276
279
|
Constraints baked into the tool:
|
|
277
|
-
- Action MUST NOT use record-context (`[field]`) syntax — jobs run as system user with NO current record. Wrap any record-context action in a thin job-friendly action that uses `query()` to fetch the records it needs.
|
|
280
|
+
- Action MUST NOT use record-context (`[field]`) syntax — jobs run as system user with NO current record. Wrap any record-context action in a thin job-friendly action that uses `select()` (or `query()`) to fetch the records it needs.
|
|
278
281
|
- `timeout` is required, ≤ 3600s.
|
|
279
282
|
- If `timeout > 300`, you MUST set `jobClass: 'long_running'`.
|
|
280
283
|
|
|
@@ -106,11 +106,68 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
106
106
|
| Function | Returns | Description |
|
|
107
107
|
|---|---|---|
|
|
108
108
|
| `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
109
|
+
| `select('entity', opts?)` | Array of row objects | Structured multi-row read — see "select()" below. `opts` = `{columns, filter, orderBy, limit, offset}`, all optional. Prefer over `query()` for row-shaped reads. |
|
|
110
|
+
| `update('entity', key, { field: value, … })` | Rows affected (number) | Update the row(s) matched by `key`. Refreshes update-phase server defaults (audit `last_updated` / `last_updated_by`) and coerces values like `insert()`. |
|
|
111
|
+
| `delete('entity', key)` | Rows affected (number) | Delete the row(s) matched by `key`. |
|
|
112
|
+
| `query('SQL', { arg: value })` | Array of row objects | Execute parameterized SQL. Use `@argName` placeholders. Schema-qualified table names (`crm.contact`). Access row fields with `row.field_name` or `row['field_name']`. **Escape hatch** — prefer `select()`/`update()`/`delete()` when the structured shape fits. |
|
|
113
|
+
| `getRecord('entity', key)` | Row object | Fetch a single record by key. Access fields with `rec.field_name` (or `rec.get('field_name')`). **Throws** a localized "not found" error if absent. |
|
|
111
114
|
| `getRecordOrNull('entity', key)` | Row object or `null` | Like `getRecord`, but returns `null` instead of throwing when the row is absent — use for an expected-absence lookup, guarded with `if (rec == null)`. |
|
|
112
115
|
| `preloadRef('fk_column')` | Row object | Load the referenced record for a FK column. Access its fields with `ref.get('field')`. |
|
|
113
116
|
|
|
117
|
+
> **Entity codes: always qualify.** Every data built-in (`select`, `insert`, `update`, `delete`,
|
|
118
|
+
> `getRecord`, `nextNumber`) accepts a qualified `module.entity` code — write `update('comm.message', …)`,
|
|
119
|
+
> not `update('message', …)`, even inside the owning module. It reads unambiguously and keeps the
|
|
120
|
+
> script correct if it's ever moved to a bridge module.
|
|
121
|
+
|
|
122
|
+
> **Keys: scalar or object.** In `update()` / `delete()` / `getRecord()` / `getRecordOrNull()`,
|
|
123
|
+
> `key` is either a scalar PK value (`update('task', 42, {…})` — single-column PK only) or an
|
|
124
|
+
> object (`{ task_id: 42 }`; multiple entries for compound keys). Scalar **string** ids are coerced
|
|
125
|
+
> to the PK's storage type, so a snowflake id carried as a string binds as `int8`, not text. A
|
|
126
|
+
> `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
|
|
127
|
+
> loop and mutate per id instead.
|
|
128
|
+
|
|
129
|
+
### select() — structured multi-row reads
|
|
130
|
+
|
|
131
|
+
The read counterpart to `insert()`/`update()`/`delete()`. Entity codes are metadata-validated,
|
|
132
|
+
values are parameterized and type-coerced per column, and reference navigation replaces
|
|
133
|
+
hand-written LEFT JOINs. A bare `select('entity')` reads every physical column of every row,
|
|
134
|
+
ordered by PK.
|
|
135
|
+
|
|
136
|
+
```javascript
|
|
137
|
+
var queued = select('comm.message', {
|
|
138
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject'],
|
|
139
|
+
filter: { g: 'and', i: [
|
|
140
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
141
|
+
{ c: 'channel', o: '=', v: 'email' },
|
|
142
|
+
{ g: 'or', i: [
|
|
143
|
+
{ c: 'to_addr', o: '!null' },
|
|
144
|
+
{ c: 'to_point.value', o: '!null' }
|
|
145
|
+
] }
|
|
146
|
+
] },
|
|
147
|
+
orderBy: ['message_id'],
|
|
148
|
+
limit: 10
|
|
149
|
+
})
|
|
150
|
+
// queued = [{ message_id: 123, to_addr: null, point_value: 'a@b.c', subject: '…' }, …]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
| Option | Shape | Notes |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `columns` | array of strings | Column paths. A one-hop nav path `'ref.target'` LEFT JOINs the referenced entity; `' as alias'` renames the key in the returned rows (avoids bracket-indexed `row['ref.target']` access). Formula columns are translated to SQL. Defaults to all physical columns. |
|
|
156
|
+
| `filter` | object | The platform's canonical filter JSON — `{c, o, v}` conditions inside `{g, i}` groups, same operators as data views (`=`, `!=`, `>`, `in`, `start`, `contains`, `null`, `!null`, `between`, …; see `filters.md`). Nav paths work here too. |
|
|
157
|
+
| `orderBy` | array of strings | Column paths; `'-'` prefix for descending (`['-message_id']`). Defaults to the PK for deterministic results. |
|
|
158
|
+
| `limit` / `offset` | int | Positive / non-negative. |
|
|
159
|
+
|
|
160
|
+
- **Fail-loud:** unlike UI filters (which drop untranslatable conditions with a warning),
|
|
161
|
+
`select()` **throws** on an unknown column, unknown operator, or unresolvable nav path — a
|
|
162
|
+
silently narrowed filter in an action means processing the wrong rows.
|
|
163
|
+
- **Low-level:** like the other data built-ins, `select()` hits the table directly — no folder
|
|
164
|
+
row-filter, column security, or reference display machinery. A plain reference (`R`) column has
|
|
165
|
+
no value here; select its FK column or a nav path (the error message says which). String
|
|
166
|
+
operators on a reference column are rejected for the same reason — filter a nav path like
|
|
167
|
+
`'customer.company_name'` instead.
|
|
168
|
+
- `query()` remains the escape hatch for anything this shape can't express: aggregates, multi-hop
|
|
169
|
+
joins, CTEs, `FOR UPDATE`.
|
|
170
|
+
|
|
114
171
|
### Messaging
|
|
115
172
|
|
|
116
173
|
| Function | Description |
|
|
@@ -118,6 +175,7 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
118
175
|
| `error('message')` | **Abort** the action and show an error to the user. Rolls back all changes. |
|
|
119
176
|
| `warn('message')` | Show a warning but continue execution. |
|
|
120
177
|
| `info('message')` | Show an informational message. |
|
|
178
|
+
| `exit('message'?, 'level'?)` | **Stop the script early without error** — changes made so far are kept. Level `'info'` (default) or `'warn'` styles the message. This is the DSL's early-return: a bare `return` is not valid at the top level of `execute:`. |
|
|
121
179
|
| `notify(userId, 'message')` | Send an in-app notification to a specific user. First arg is a user ID (typically `[owner_id]` or similar). |
|
|
122
180
|
|
|
123
181
|
### Email
|
|
@@ -315,6 +373,42 @@ execute:
|
|
|
315
373
|
info('Received ' + poLines.length + ' line(s) from PO ' + [po_number])
|
|
316
374
|
```
|
|
317
375
|
|
|
376
|
+
> The two `query()` reads and the raw `query('UPDATE …')` above predate `select()`/`update()` —
|
|
377
|
+
> today each would be a structured call (`select('wms.stock', { filter: … })`,
|
|
378
|
+
> `update('wms.stock', stockId, { quantity: newQty })`).
|
|
379
|
+
|
|
380
|
+
### select() + update() with a scalar key (cron-scan dispatch)
|
|
381
|
+
|
|
382
|
+
From `modules/comm/logic/actions/dispatch_messages.dsl` (a scheduled-job action — no record context):
|
|
383
|
+
|
|
384
|
+
```
|
|
385
|
+
execute:
|
|
386
|
+
var queued = select('comm.message', {
|
|
387
|
+
columns: ['message_id', 'to_addr', 'to_point.value as point_value', 'subject', 'body_html', 'body_text', 'attempt_count'],
|
|
388
|
+
filter: { g: 'and', i: [
|
|
389
|
+
{ c: 'status', o: '=', v: 'queued' },
|
|
390
|
+
{ c: 'channel', o: '=', v: 'email' }
|
|
391
|
+
] },
|
|
392
|
+
orderBy: ['message_id'],
|
|
393
|
+
limit: 10
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
if (queued.length == 0) {
|
|
397
|
+
exit('No queued messages', 'info')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (var i = 0; i < queued.length; i++) {
|
|
401
|
+
var msg = queued[i]
|
|
402
|
+
sendEmail(msg.to_addr || msg.point_value, msg.subject, msg.body_html || msg.body_text || '')
|
|
403
|
+
|
|
404
|
+
update('comm.message', msg.message_id, {
|
|
405
|
+
status: 'sent',
|
|
406
|
+
sent_at: now(),
|
|
407
|
+
attempt_count: (msg.attempt_count || 0) + 1
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
318
412
|
## Execution modes
|
|
319
413
|
|
|
320
414
|
Actions run in one of two modes, declared in `ui/actions.json`:
|
|
@@ -376,10 +470,12 @@ In `ui/actions.json`:
|
|
|
376
470
|
|
|
377
471
|
## SQL in query() — important notes
|
|
378
472
|
|
|
379
|
-
|
|
473
|
+
`query()` is the **escape hatch** — reach for it only when the structured built-ins can't express the read/write (aggregates, multi-hop joins, CTEs, `FOR UPDATE`). For row-shaped work prefer `select()` / `insert()` / `update()` / `delete()`: they validate entity codes against metadata, coerce values per column, and (for `insert`/`update`) maintain auto-fill and audit columns.
|
|
474
|
+
|
|
475
|
+
1. **Table names are schema-qualified** within the module: `crm.contact`, `wms.stock_movement`, `fin.invoice`. Use the module code as the schema prefix. (Module codes with a hyphen, e.g. `crm-fin`, cannot be schema-qualified in raw SQL — another reason to use the structured built-ins, which accept the qualified `module.entity` code.)
|
|
380
476
|
2. **Use `@param` placeholders** for values — NEVER concatenate user input into SQL strings. The engine auto-parameterizes.
|
|
381
477
|
3. **SELECT, INSERT, UPDATE, DELETE all work** via `query()`. For reads, it returns row arrays. For writes, it executes the statement.
|
|
382
|
-
4. **`insert()` is preferred over `query('INSERT ...')
|
|
478
|
+
4. **`insert()` is preferred over `query('INSERT ...')`**, `update()`/`delete()` over `query('UPDATE/DELETE ...')`, and `select()` over `query('SELECT ...')` for module entities — raw SQL skips auto-fill (PKs, audit, number sequences) and metadata validation.
|
|
383
479
|
|
|
384
480
|
## Common mistakes
|
|
385
481
|
|
|
@@ -387,7 +483,10 @@ In `ui/actions.json`:
|
|
|
387
483
|
- Writing `params["note"]` — **also wrong in DSL syntax**. Use `params[note]` without quotes.
|
|
388
484
|
- Writing `update [status] = 'Draft'` (declarative style) — **wrong**. Use `[status] = 'Draft'` (assignment).
|
|
389
485
|
- Writing `insert('entity', field1, value1)` (positional args) — **wrong**. Use `insert('entity', { field: value })` (object).
|
|
390
|
-
- Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders.
|
|
486
|
+
- Writing `query('entity', filter)` (filter object) — **wrong**. `query()` takes raw SQL strings with `@param` placeholders. A structured read is `select('entity', { filter: … })`.
|
|
487
|
+
- Passing an **array of ids** as the `key` of `update()`/`delete()` — **rejected**. Loop over the ids and mutate one at a time.
|
|
488
|
+
- Selecting or string-filtering a plain reference (`R`) column in `select()` — **throws**. Use the FK column or a nav path (`'customer.company_name'`).
|
|
489
|
+
- Unqualified entity codes (`update('message', …)`) — works inside the owning module but **always prefer the qualified form** (`update('comm.message', …)`).
|
|
391
490
|
- Forgetting `var` in loops — `for (i = 0; ...)` creates a global. Always `for (var i = 0; ...)`.
|
|
392
491
|
- Using ES6 syntax (`const`, `let`, `=>`, template literals) — **may not work**. Jint supports ES5.1 primarily. Stick to `var`, `function`, string concatenation with `+`.
|
|
393
492
|
- Calling `TODAY()` or `NOW()` (uppercase) inside `execute:` — **wrong**, they're undefined there and install fails with `'TODAY' is not defined`. Use lowercase **`now()`** in `execute:`; `TODAY()`/`NOW()` are formula-only (`canExecute:`, formula columns).
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Column Domains Reference
|
|
2
|
+
|
|
3
|
+
A **column domain** is a reusable, named field type — a base datatype, a control, sizing, and a shared option list under one code. Columns reference it instead of restating the definition. Use one whenever the same enum or typed column repeats across entities (a `Draft/Posted/Reversed` status, a currency code, a priority, an account type).
|
|
4
|
+
|
|
5
|
+
Lives in: `domains.json` at the module root (a flat map `domain_cd → definition`).
|
|
6
|
+
|
|
7
|
+
Schema: `dforge://schema/domains`.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
Without domains, every "status" column restates the same `fieldTypeCd: "dropdown"` + the full `params.options` list — and, if you localize, the same option translations, once per column. A domain declares that once; every consuming column inherits the type, the control, the options, **and** their translations. Localize `fin.doc_status` a single time and all its uses update together.
|
|
12
|
+
|
|
13
|
+
## `domains.json`
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"doc_status": {
|
|
18
|
+
"description": "Document Status",
|
|
19
|
+
"baseDatatypeCd": "string",
|
|
20
|
+
"dbDatatype": "varchar",
|
|
21
|
+
"maxLen": 20,
|
|
22
|
+
"fieldTypeCd": "dropdown",
|
|
23
|
+
"params": {
|
|
24
|
+
"options": [
|
|
25
|
+
{ "value": "draft", "label": "Draft" },
|
|
26
|
+
{ "value": "posted", "label": "Posted", "color": "#0a0" }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Key | Required | Notes |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `baseDatatypeCd` | **yes** | What a consuming column's DDL is generated from (`string`, `number`, `date`, …). |
|
|
36
|
+
| `description` | no | Label. Localizable — see below. |
|
|
37
|
+
| `dbDatatype` | no | Physical Postgres type; omit to infer from `fieldTypeCd`. |
|
|
38
|
+
| `fieldTypeCd` | no | Control (`dropdown`, `text`, `number`, …). |
|
|
39
|
+
| `maxLen` / `precision` | no | Sizing for string / numeric domains. |
|
|
40
|
+
| `params` | no | Shared field-type config — most importantly `options` for `dropdown`/`radio`/`flags`. |
|
|
41
|
+
|
|
42
|
+
## Using a domain in an entity
|
|
43
|
+
|
|
44
|
+
A column swaps its whole type block for a single `domain` key — qualified as `module_cd.domain_cd`, or bare for a domain in the current module (unqualified resolves the current module first, then tenant-created domains):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
"status": {
|
|
48
|
+
"domain": "fin.doc_status",
|
|
49
|
+
"flags": "VEM",
|
|
50
|
+
"orderNum": 40,
|
|
51
|
+
"description": "Status"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The column keeps what is genuinely its own — `description` (its label), `flags`, `orderNum`, `isNullable`, `isPk`, `columnGroupCd`. The domain supplies the rest.
|
|
56
|
+
|
|
57
|
+
## Rules (these fail the install)
|
|
58
|
+
|
|
59
|
+
1. **Don't restate what the domain owns.** Declaring `dbDatatype`, `fieldTypeCd`, `maxLen`, `precision`, `baseDatatypeCd` or `params` alongside `domain` is rejected, naming the conflicting key — it is never silently overridden. Domains are authoritative.
|
|
60
|
+
2. **Scalar columns only.** A reference (`R`) or set (`S`) column can't use a domain — it has no datatype for the domain to supply. Drop the `domain` key on those.
|
|
61
|
+
3. **Declare the dependency for cross-module use.** `"domain": "fin.doc_status"` resolves by looking the domain up in the installed tenant, so declare `fin` in the manifest `dependencies` — dependencies drive install order, and without it nothing guarantees `fin` is installed first, so the resolve fails with "references unknown column domain".
|
|
62
|
+
|
|
63
|
+
## Localizing a domain (and its options)
|
|
64
|
+
|
|
65
|
+
Translate a domain **once** in `translations/<locale>.json` under a top-level `domains` section — its label and its per-option labels flow to every consuming column:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
"domains": {
|
|
69
|
+
"doc_status": {
|
|
70
|
+
"label": "Belegstatus",
|
|
71
|
+
"options": { "draft": "Entwurf", "posted": { "label": "Gebucht", "color": "#0a0" } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
An option override is a partial merged over the base — a bare string is shorthand for `{ "label": … }`; `value` is the stored code and is never translated. See `translations.md` ("Dropdown option localization").
|
|
77
|
+
|
|
78
|
+
## How it resolves (mental model)
|
|
79
|
+
|
|
80
|
+
- **Install** materializes the domain's structural fields (datatype/control/sizing) onto each consuming column, because DDL generation needs them locally.
|
|
81
|
+
- **Runtime** resolves `params` (the options) — and their per-culture overrides — from the domain, not the column. That is what lets a shared list be authored and translated once.
|
|
82
|
+
|
|
83
|
+
You don't act on this split; just know that the option list stays on the domain, so **don't** paste `params.options` onto a domain-backed column (the install rejects it).
|
|
84
|
+
|
|
85
|
+
## When NOT to use a domain
|
|
86
|
+
|
|
87
|
+
- A one-off column whose type/options appear nowhere else — inline it (`fieldTypeCd` + `params.options`) and skip the indirection.
|
|
88
|
+
- Anything non-scalar (references, sets, formulas).
|
|
89
|
+
|
|
90
|
+
## Common mistakes
|
|
91
|
+
|
|
92
|
+
- Putting `params.options` on the column **and** naming a `domain` → install error. Options belong on the domain.
|
|
93
|
+
- Using a cross-module domain without declaring the dependency → "unknown column domain" at install.
|
|
94
|
+
- Expecting per-column option overrides — the domain is authoritative in v1; narrow with `optionSets` (which filters by `value`) if you need a conditional subset.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Document Extraction Reference (OCR)
|
|
2
|
+
|
|
3
|
+
dForge turns uploaded documents (PDFs, scans, photos) into structured records via a generic OCR service plus a per-module **field schema**. The load-bearing principle: **OCR is schema-agnostic, the module owns the schema.** The platform service is a write-once "document + schema → fields" engine; every new document type is a new schema in *your* module, never a change to the service.
|
|
4
|
+
|
|
5
|
+
Two DSL built-ins do the work, both host functions callable from `logic/actions/*.dsl`:
|
|
6
|
+
|
|
7
|
+
- `ocrExtract(...)` — send a file to the OCR endpoint, get structured fields back.
|
|
8
|
+
- `detectDocument(rawText)` — score a document's text against your profiles' detect rules to guess its type.
|
|
9
|
+
|
|
10
|
+
The OCR endpoint URL is a module setting (commonly `ocr_endpoint_url`), read with `getSetting('ocr_endpoint_url')`.
|
|
11
|
+
|
|
12
|
+
## `ocrExtract()`
|
|
13
|
+
|
|
14
|
+
Three forms, from raw to managed:
|
|
15
|
+
|
|
16
|
+
**v1 — raw bundle** (back-compat; returns a JSON **string**, you `JSON.parse` it):
|
|
17
|
+
```
|
|
18
|
+
var raw = ocrExtract([doc_file], getSetting('ocr_endpoint_url')) // hints optional (array | string | object)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**v2 — inline schema** (explicit opt-in via `opts.mode`; returns the **parsed** object):
|
|
22
|
+
```
|
|
23
|
+
var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), schema, { mode: 'extract' })
|
|
24
|
+
// r → { fields, confidence?, unmatched, raw_text, meta }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**v2 — extraction profile** (the managed path; schema lives in a row, not inline):
|
|
28
|
+
```
|
|
29
|
+
var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), null, { profile: 'proc.bill_of_lading' })
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Notes:
|
|
33
|
+
- v2 is selected **only** by `opts.mode` (`'extract' | 'summary' | 'raw'`) or `opts.profile`. A bare object third arg *without* `opts.mode` stays on the v1 hints contract — so passing a schema object alone does **not** switch to v2.
|
|
34
|
+
- `opts.profile` implies `mode: 'extract'` and is mutually exclusive with an inline `schema`. Unqualified profile codes resolve the action's own module first, then tenant-created rows. Missing/inactive profiles fail loudly.
|
|
35
|
+
- The parsed response is `{ fields, confidence?, unmatched, raw_text, meta }` — map `r.fields.<name>` straight into `insert()`.
|
|
36
|
+
|
|
37
|
+
## The `schema:` block (inline)
|
|
38
|
+
|
|
39
|
+
Declare the field schema next to the mapping, in a `schema:` block in the action `.dsl`. It compiles verbatim into `var schema = {…}`, so it's a **JS object-literal body** — entries comma-separated, **string values quoted** (`type: 'string'`, not `type: string`, which fails the compile with `'string' is not defined`):
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
schema:
|
|
43
|
+
bl_no: { type: 'string', description: "Bill of lading number (label 'B.L. No.')" },
|
|
44
|
+
container_no: { type: 'string', description: 'ISO 6346 container number' },
|
|
45
|
+
issue_date: { type: 'date' }
|
|
46
|
+
|
|
47
|
+
execute:
|
|
48
|
+
var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), schema, { mode: 'extract' })
|
|
49
|
+
var bl = insert('bill_of_lading', { bl_no: r.fields.bl_no, issue_date: r.fields.issue_date })
|
|
50
|
+
if (r.fields.container_no != null) {
|
|
51
|
+
insert('container', { bill_of_lading_id: bl.bill_of_lading_id, container_no: r.fields.container_no })
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each field descriptor carries a `type` (`string` | `date` | `number` | …) and an optional `description` — the human-language hint the recognizer/LLM uses to find it. Keep descriptions concrete ("label 'B.L. No.'", "ISO 6346 container number").
|
|
56
|
+
|
|
57
|
+
## Extraction profiles — `logic/extraction_profiles.json`
|
|
58
|
+
|
|
59
|
+
The managed evolution of the inline `schema:` block: the same field schema stored as a row in `dForge.extraction_profile` (a platform-managed entity), so it's editable in the admin UI and reusable across actions. Mirrors the print-template lifecycle.
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"bill_of_lading": {
|
|
64
|
+
"description": "Ocean bill of lading",
|
|
65
|
+
"docType": "bill_of_lading",
|
|
66
|
+
"schema": {
|
|
67
|
+
"bl_no": { "type": "string", "description": "Bill of lading number (label 'B.L. No.')" },
|
|
68
|
+
"container_no": { "type": "string", "description": "ISO 6346 container number" },
|
|
69
|
+
"issue_date": { "type": "date" }
|
|
70
|
+
},
|
|
71
|
+
"detect": {
|
|
72
|
+
"signals": [
|
|
73
|
+
{ "regex": "bill of lading", "weight": 3 },
|
|
74
|
+
{ "regex": "B/?L\\s*No", "weight": 2 }
|
|
75
|
+
],
|
|
76
|
+
"threshold": 4
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **Keyed by `profile_cd`** (no `.` — it collides with the `module.profile` qualified form).
|
|
83
|
+
- **Registered on install** by `(module_id, profile_cd)`; profiles dropped from the manifest are **reaped** on upgrade; uninstall removes the module's rows. Tenant-created profiles (`module_id IS NULL`) are authored in the admin UI and never touched by install.
|
|
84
|
+
- Reference at runtime with `ocrExtract(..., null, { profile: 'module_cd.profile_cd' })`.
|
|
85
|
+
|
|
86
|
+
## `detectDocument()` — doc-type auto-detect
|
|
87
|
+
|
|
88
|
+
A profile's optional `detect` rules let the platform guess a document's type before you extract. `detectDocument(rawText)` scores every reachable profile's `signals` (case-insensitive regexes, summed weights, per-pattern timeout) and returns the best profile at/above its `threshold`:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
execute:
|
|
92
|
+
var det = detectDocument(data.raw_text) // → { profile: 'proc.bill_of_lading', docType: 'bill_of_lading', score } | null
|
|
93
|
+
if (det != null && det.docType != null) { [doc_type] = det.docType }
|
|
94
|
+
var r = ocrExtract([doc_file], getSetting('ocr_endpoint_url'), null, { profile: det.profile })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- Returns `{ profile, docType, score }` (the profile code is already module-qualified, ready to pass to `ocrExtract`) or `null`.
|
|
98
|
+
- **Advisory by design** — malformed rules are skipped, and the returned type is a suggestion; let the human confirm before committing an irreversible mapping.
|
|
99
|
+
- `rawText` is typically `ocrExtract`'s `raw_text` (or a v1 raw bundle's text) from a first pass.
|
|
100
|
+
|
|
101
|
+
## Typical intake flow
|
|
102
|
+
|
|
103
|
+
1. User uploads a file to an intake record (a `file`/`image` column).
|
|
104
|
+
2. An action runs OCR once to get `raw_text`, calls `detectDocument(raw_text)` to suggest the type, and stores the suggestion for the user to confirm.
|
|
105
|
+
3. On confirm, `ocrExtract(..., { profile })` returns structured `fields`; the action `insert()`s the target record(s), applying resolution transforms (`lookupRef`, `matchCatalog`, `parseMoney`) for anything that needs turning into a DB ref.
|
|
106
|
+
|
|
107
|
+
## Common mistakes
|
|
108
|
+
|
|
109
|
+
- Passing a schema object as the third arg **without** `{ mode: 'extract' }` — that's still v1 (raw string), not v2.
|
|
110
|
+
- Unquoted values in a `schema:` block (`type: string`) — compile fails; quote them.
|
|
111
|
+
- A `profile_cd` containing `.` — rejected (reserved for the `module.profile` form).
|
|
112
|
+
- Treating `detectDocument` as authoritative — it's advisory; confirm the type before an irreversible write.
|
|
113
|
+
- Forgetting the OCR endpoint is a **setting** — read it with `getSetting(...)`, don't hardcode.
|
|
@@ -10,16 +10,20 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
10
10
|
>
|
|
11
11
|
> Reference (`columnType: "R"`) and formula (`columnType: "F"`) columns never get a `dbDatatype` at all.
|
|
12
12
|
|
|
13
|
+
> **Repeating the same type across columns?** If several columns share a datatype + control + option list (a `Draft/Posted/…` status, a currency, a priority), define a **column domain** once and reference it with a single `domain` key instead of restating `fieldTypeCd` + `params.options` each time — the options (and their translations) are then authored once for all consumers. See `dforge://reference/column-domains`.
|
|
14
|
+
|
|
13
15
|
## String-backed
|
|
14
16
|
|
|
15
17
|
| `fieldTypeCd` | `baseDatatypeCd` | Description | Common `dbDatatype` | Params |
|
|
16
18
|
|---|---|---|---|---|
|
|
17
19
|
| `text` | `string` | Single-line text | `varchar` (with `maxLen`) | `maxLen` |
|
|
20
|
+
| `avatar` | `string` | Person/company name rendered as an initials circle; optional photo. | `varchar` | `imageColumn` (an `image` column on the same entity to use as the photo) |
|
|
18
21
|
| `email` | `string` | Email address. Client-side format validation. | `varchar` 250 | — |
|
|
19
22
|
| `phone` | `string` | Phone/telephone. | `varchar` 50 | — |
|
|
20
23
|
| `url` | `string` | Web URL. | `varchar` 500 | — |
|
|
21
24
|
| `textarea` | `string` | Multi-line plain text. | `text` | `defaultLines`, `maxLines` |
|
|
22
|
-
| `
|
|
25
|
+
| `markdown` | `string` | Markdown editor (Write/Preview tabs), rendered sanitized. | `text` | — |
|
|
26
|
+
| `richtext` | `string` | Rich text WYSIWYG editor, stores sanitized HTML. | `text` | — |
|
|
23
27
|
| `code` | `string` | Code editor with syntax highlighting. | `text` | `language` |
|
|
24
28
|
| `dropdown` | `string` | Fixed list of options. | `varchar` | `options: ["a","b",…]` or `[{value,label,icon,color}]` |
|
|
25
29
|
| `flags` | `string` | Multiple checkboxes with letter flags. | `varchar` | `optionSets`, `style: "buttons"` |
|
|
@@ -67,6 +71,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
67
71
|
| `lookup` | `guid` | `R` | Reference to another entity (always paired with a hidden FK column — see SKILL.md "FK+Reference pattern") |
|
|
68
72
|
| `user` | `cuid` | `D` | User picker. Writes a user ID directly, no paired FK needed. |
|
|
69
73
|
| `grid` | `set` | `S` | Detail grid of related records (1:N backwards reference). Used with `link` declaring the relation. |
|
|
74
|
+
| `list` | `set` | `S` | Detail list — same 1:N set as `grid`, rendered as a card list instead of a table. |
|
|
70
75
|
| `entitylink` | `json` | `D` | Polymorphic link to any entity (stores `{entity, id}` pair). Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in (see `action-dsl.md`). |
|
|
71
76
|
|
|
72
77
|
## JSON
|
|
@@ -137,7 +142,8 @@ These are the field type names LLMs tend to invent. **They are all wrong.**
|
|
|
137
142
|
| `picture`, `photo` | `image` |
|
|
138
143
|
| `attachment` | `file` |
|
|
139
144
|
| `multiline` | `textarea` |
|
|
140
|
-
| `
|
|
145
|
+
| `md` | `markdown` |
|
|
146
|
+
| `richText`, `wysiwyg`, `html` | `richtext` |
|
|
141
147
|
| `userPicker` | `user` |
|
|
142
148
|
| `phoneNumber` | `phone` |
|
|
143
149
|
|
|
@@ -18,11 +18,14 @@ The same grammar applies everywhere.
|
|
|
18
18
|
|
|
19
19
|
- **Field references**: `[column_name]` — the value of another column on the same entity
|
|
20
20
|
- **Navigation**: `[reference_column].[field]` — traverse a reference to a related entity's field
|
|
21
|
-
- **Literals**: numbers (`42`, `3.14`), strings (`'hello'
|
|
22
|
-
- **Operators**: `+`, `-`, `*`, `/`,
|
|
23
|
-
- **Function calls**: `FUNCTION_NAME(arg1, arg2, ...)` — uppercase by convention
|
|
21
|
+
- **Literals**: numbers (`42`, `3.14`), strings in **single quotes** (`'hello'`; embed a quote by doubling it: `'it''s'`), booleans (`true`, `false`), null (`null`). Double quotes are **not** string delimiters — `"hello"` is a lex error
|
|
22
|
+
- **Operators**: `+`, `-`, `*`, `/`, `=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`, `BETWEEN … AND …`, `CONTAINS`, `STARTS_WITH`, `ENDS_WITH`. Equality is a **single** `=` — `==` is a syntax error. There is no `%` operator; use `MOD(a, b)`
|
|
23
|
+
- **Function calls**: `FUNCTION_NAME(arg1, arg2, ...)` — uppercase by convention. Only the functions listed below exist; any other name is rejected at module install (see "Unknown functions" below)
|
|
24
24
|
- **Parentheses** for grouping
|
|
25
25
|
|
|
26
|
+
`CONTAINS` / `STARTS_WITH` / `ENDS_WITH` are infix operators, not functions — write
|
|
27
|
+
`[note] CONTAINS 'urgent'`, never `CONTAINS([note], 'urgent')`.
|
|
28
|
+
|
|
26
29
|
## Examples
|
|
27
30
|
|
|
28
31
|
```
|
|
@@ -34,15 +37,17 @@ The same grammar applies everywhere.
|
|
|
34
37
|
|
|
35
38
|
[account].[billing_address].[country]
|
|
36
39
|
|
|
37
|
-
[status]
|
|
40
|
+
[status] = 'active' AND [balance] > 0
|
|
41
|
+
|
|
42
|
+
TODAY() > [due_date] AND [total_paid] < [total]
|
|
38
43
|
|
|
39
|
-
|
|
44
|
+
SWITCH([priority], 'high', 3, 'medium', 2, 'low', 1, 0)
|
|
40
45
|
|
|
41
|
-
IF([total] > 1000,
|
|
46
|
+
IF([total] > 1000, 'large', 'small')
|
|
42
47
|
|
|
43
|
-
COALESCE([nickname], [first_name],
|
|
48
|
+
COALESCE([nickname], [first_name], 'Unknown')
|
|
44
49
|
|
|
45
|
-
FORMAT([created_date],
|
|
50
|
+
FORMAT([created_date], 'yyyy-MM-dd')
|
|
46
51
|
```
|
|
47
52
|
|
|
48
53
|
## Built-in functions
|
|
@@ -50,38 +55,99 @@ FORMAT([created_date], "yyyy-MM-dd")
|
|
|
50
55
|
### String functions
|
|
51
56
|
|
|
52
57
|
- `CONCAT(a, b, ...)` — concatenate strings
|
|
53
|
-
- `LEN(s)` — string length
|
|
58
|
+
- `LEN(s)` — string length (`LENGTH` is an alias)
|
|
59
|
+
- `LEFT(s, n)`, `RIGHT(s, n)` — leading / trailing `n` characters
|
|
54
60
|
- `UPPER(s)`, `LOWER(s)` — case conversion
|
|
55
61
|
- `TRIM(s)`, `LTRIM(s)`, `RTRIM(s)` — whitespace removal
|
|
56
|
-
- `SUBSTRING(s, start,
|
|
57
|
-
- `
|
|
58
|
-
- `
|
|
59
|
-
- `
|
|
60
|
-
- `
|
|
62
|
+
- `SUBSTRING(s, start, end)` — substring by 0-based offsets, `end` exclusive
|
|
63
|
+
- `MID(s, start, len)` — substring by 0-based offset and length
|
|
64
|
+
- `INDEX_OF(s, find)`, `LAST_INDEX_OF(s, find)` — 0-based position, `-1` when absent
|
|
65
|
+
- `SPLIT(s, separator)` — split into a list
|
|
66
|
+
- `REPLACE(s, find, with)` — replace **every** occurrence
|
|
67
|
+
- Substring / prefix / suffix tests are the **operators** `CONTAINS`, `STARTS_WITH`, `ENDS_WITH` (`[note] CONTAINS 'x'`), not functions
|
|
68
|
+
- `FORMAT(date, 'pattern')` — format a **date** as text with .NET-style tokens (case-sensitive: `MM` = month, `mm` = minute): `yyyy`, `yy`, `MM`/`M`, `dd`/`d`, `HH`/`H`, `mm`/`m`, `ss`/`s`; single-letter variants unpadded, other characters pass through. Pattern must be a string literal. Dates only — numbers are not supported. Works client-side and SQL-time (reports)
|
|
61
69
|
|
|
62
70
|
### Number functions
|
|
63
71
|
|
|
64
72
|
- `ABS(n)`, `ROUND(n, digits)`, `FLOOR(n)`, `CEIL(n)`, `CEILING(n)`
|
|
65
|
-
- `MIN(a, b)`, `MAX(a, b)` —
|
|
73
|
+
- `MIN(a, b, ...)`, `MAX(a, b, ...)` — smallest / largest argument; nulls are ignored
|
|
66
74
|
- `POW(base, exp)`, `SQRT(n)`
|
|
67
|
-
- `MOD(a, b)` — modulo
|
|
75
|
+
- `MOD(a, b)` — modulo; a zero divisor is null, not an error
|
|
76
|
+
- `TRUNC(n)`, `SIGN(n)`, `EXP(n)`, `LOG(n)` (natural), `LOG10(n)`, `LOG2(n)`, `LOG1P(n)`
|
|
77
|
+
- `FROUND(n)` — round to single (32-bit float) precision
|
|
78
|
+
- Trigonometry: `SIN`, `COS`, `TAN`, `ASIN`, `ACOS`, `ATAN`, `ATAN2(y, x)`, and the hyperbolic `SINH`, `COSH`, `TANH`, `ASINH`, `ATANH`
|
|
68
79
|
|
|
69
80
|
### Date functions
|
|
70
81
|
|
|
71
82
|
- `TODAY()` — current date
|
|
72
83
|
- `NOW()` — current datetime with timezone
|
|
73
|
-
- `YEAR(d)`, `MONTH(d)`, `DAY(d)` — date parts
|
|
84
|
+
- `YEAR(d)`, `MONTH(d)`, `DAY(d)` — date parts (`MONTH` is 1-based)
|
|
74
85
|
- `HOUR(dt)`, `MINUTE(dt)`, `SECOND(dt)` — time parts
|
|
75
|
-
- `
|
|
76
|
-
- `
|
|
86
|
+
- `WEEKDAY(d)` — day of week, `0` = Sunday
|
|
87
|
+
- `DATE(y, m, d)` — build a date from parts; `DATE(v)` casts a value to a date
|
|
88
|
+
- `DATEADD(d, count, unit)` — add time; unit is a **string literal**: `'DAY'`, `'HOUR'`, `'MINUTE'`, `'SECOND'`, `'MONTH'`, `'YEAR'` (plural forms accepted)
|
|
89
|
+
- `DATEDIFF(d1, d2, unit)` — difference `d2 − d1` in the given unit (same unit list). `DAY`/`HOUR`/`MINUTE`/`SECOND` floor the elapsed time; `MONTH`/`YEAR` are calendar-component deltas. E.g. days overdue: `DATEDIFF([due_date], TODAY(), 'DAY')`
|
|
90
|
+
- `STARTMONTH(d?)`, `ENDMONTH(d?)`, `STARTQUARTER(d?)`, `ENDQUARTER(d?)`, `STARTYEAR(d?)`, `ENDYEAR(d?)`, `STARTNEXTMONTH(d?)` — period boundaries. **Omitting** the argument means today; passing one that is null gives null (a row with no date renders blank, it does not fall back to today)
|
|
91
|
+
|
|
92
|
+
The whole date family evaluates identically **client-side and SQL-time** (reports, filters, sorts on formula columns) — the SQL translator mirrors the client runtime, including `MONTH` 1-based and `WEEKDAY` 0=Sunday. Unit arguments must be string literals; an unknown unit fails loud with the accepted list.
|
|
77
93
|
|
|
78
94
|
### Logical functions
|
|
79
95
|
|
|
80
|
-
- `IF(cond, then, else)` — ternary
|
|
81
|
-
- `CASE(
|
|
96
|
+
- `IF(cond, then, else)` — ternary (`IIF` is an alias)
|
|
97
|
+
- `CASE(cond1, result1, cond2, result2, ..., default)` — first **condition** that holds wins
|
|
98
|
+
- `SWITCH(expr, val1, result1, val2, result2, ..., default)` — first **value** that matches
|
|
99
|
+
`expr` wins. Note the difference: `CASE` takes conditions, `SWITCH` takes values to compare
|
|
82
100
|
- `COALESCE(a, b, c, ...)` — first non-null
|
|
83
101
|
- `NULLIF(a, b)` — null if equal, else a
|
|
84
|
-
- `ISNULL(x)` — boolean null check
|
|
102
|
+
- `ISNULL(x)` — boolean **null check** (returns true/false). This is *not* T-SQL's two-arg "replace null with" — that is `COALESCE(x, fallback)`
|
|
103
|
+
|
|
104
|
+
### Conversion and context functions
|
|
105
|
+
|
|
106
|
+
- `TEXT(v)` — as text (null becomes `''`)
|
|
107
|
+
- `NUMBER(v)` — as a number, or null when the value isn't numeric (including a blank string)
|
|
108
|
+
- `BOOLEAN(v)` — null, `''` and `0` are false; everything else is true
|
|
109
|
+
- `JSON_GET(json, 'key' [, type])` — read a key out of a JSON column. The optional type is a
|
|
110
|
+
string literal: `'text'` (default), `'number'`/`'numeric'`, `'int'`/`'integer'`/`'bigint'`,
|
|
111
|
+
`'bool'`/`'boolean'`, `'date'`, `'datetime'`/`'timestamp'`/`'timestamptz'`
|
|
112
|
+
- `CURRENT_USER_ID()` — the id of the requesting user
|
|
113
|
+
|
|
114
|
+
### Client-side vs SQL-time evaluation
|
|
115
|
+
|
|
116
|
+
An `F` column is evaluated **client-side** in grids and cards, but translated to **SQL** when a
|
|
117
|
+
report selects it, or when it is used in a filter or sort. **Every function listed here is
|
|
118
|
+
implemented in both**, so a column reads the same in a grid and in a report; a test executes
|
|
119
|
+
each one against PostgreSQL and diffs the result against the client runtime.
|
|
120
|
+
|
|
121
|
+
Two deliberate points of agreement, because the engines disagree by nature:
|
|
122
|
+
|
|
123
|
+
- **Out-of-domain math is null, not an error.** `SQRT(-1)`, `LOG(0)`, `ASIN(2)`, `EXP(1000)`,
|
|
124
|
+
`POW(0, -1)`, `MOD(x, 0)` — JavaScript would answer NaN or Infinity and PostgreSQL would
|
|
125
|
+
raise (failing the whole report). Both return null, so the cell renders blank.
|
|
126
|
+
- **String indexing is JavaScript-flavoured, not SQL's.** `SUBSTRING(s, start, end)` and
|
|
127
|
+
`MID(s, start, len)` take a **0-based** start, and `SUBSTRING`'s third argument is an **end
|
|
128
|
+
offset**, not a length. `INDEX_OF` / `LAST_INDEX_OF` return a 0-based index, or `-1`.
|
|
129
|
+
|
|
130
|
+
Known remaining difference: `CONCAT` on a numeric column renders PostgreSQL's declared scale
|
|
131
|
+
(`-3.0`) where the client renders the JavaScript number (`-3`). Use `FORMAT`/`TEXT` when the
|
|
132
|
+
exact rendering matters.
|
|
133
|
+
|
|
134
|
+
`CURRENT_USER_ID()` needs the requesting user, so it resolves in grids, reports, filters and
|
|
135
|
+
`select()`, but not in a context with no user (an unattended job).
|
|
136
|
+
|
|
137
|
+
### Unknown functions
|
|
138
|
+
|
|
139
|
+
The parser treats any `NAME(...)` as a call, so a misspelled or non-existent function is a
|
|
140
|
+
*grammatically valid* formula. Module install rejects it with the offending name (and a "did
|
|
141
|
+
you mean" suggestion where one is close). Before that check existed, such a formula installed
|
|
142
|
+
cleanly and then rendered as an **empty cell on every row**, with no error in the server log,
|
|
143
|
+
the browser console, or the install output.
|
|
144
|
+
|
|
145
|
+
### Comparing dates
|
|
146
|
+
|
|
147
|
+
Date comparisons work directly — `TODAY() > [due_date]`, `[due_date] BETWEEN STARTMONTH() AND
|
|
148
|
+
ENDMONTH()`, `[due_date] = TODAY()` — and combine with other conditions through `AND` / `OR`
|
|
149
|
+
like any other boolean. A bare `date` column is treated as a **local calendar date**, matching
|
|
150
|
+
`TODAY()` and `DATE(y, m, d)`.
|
|
85
151
|
|
|
86
152
|
### Aggregation over a child set — use a Generated (`G`) column, not `F`
|
|
87
153
|
|
|
@@ -122,6 +188,8 @@ and the JSON shape: `column-types.md` → "Roll-up totals over child rows — us
|
|
|
122
188
|
|
|
123
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.
|
|
124
190
|
|
|
191
|
+
**SQL-time (reports, filters, sorts):** single-hop navigation (`[ref].[field]`) is translated to a `LEFT JOIN` and computes in reports and query datasets. Multi-hop chains and `$[Setting]` references are **not** available at SQL time — the column returns NULL there and the response carries a warning naming the column. Keep report-bound formulas to single-hop navigation.
|
|
192
|
+
|
|
125
193
|
## Sync vs async formulas
|
|
126
194
|
|
|
127
195
|
- **Sync formula**: pure local math, no navigation, or one-level navigation that can be JOINed. Evaluated on load and on every edit to a dependency.
|
|
@@ -62,7 +62,7 @@ Scheduled actions run with **no record bound**. Specifically:
|
|
|
62
62
|
- `notify()` writes to inbox only (no live SSE in Phase 1)
|
|
63
63
|
- `sendEmail()` raw mode only in Phase 1
|
|
64
64
|
|
|
65
|
-
**Record context is forbidden.** The install pipeline rejects any action whose compiled DSL references `[field]` or `for x in records { … }` — those compile to `__r.` / `__records.` which would `ReferenceError` at fire time. Refactor to operate via `
|
|
65
|
+
**Record context is forbidden.** The install pipeline rejects any action whose compiled DSL references `[field]` or `for x in records { … }` — those compile to `__r.` / `__records.` which would `ReferenceError` at fire time. Refactor to operate via `select()` / `insert()` / `update()`:
|
|
66
66
|
|
|
67
67
|
```
|
|
68
68
|
execute:
|
|
@@ -132,7 +132,7 @@ Full source: [`modules/chore/`](../../../modules/chore/).
|
|
|
132
132
|
## Common mistakes
|
|
133
133
|
|
|
134
134
|
- **Cross-module action reference.** A job in `crm` cannot reference an action in `fin`. Declare a thin `crm.run_fin_thing` action in your own module and bind the job to that.
|
|
135
|
-
- **Record-bound action.** Any `[field]` or `for x in records` in the DSL fails install — the action has no record at fire time. Refactor to `
|
|
135
|
+
- **Record-bound action.** Any `[field]` or `for x in records` in the DSL fails install — the action has no record at fire time. Refactor to `select()`/`insert()`/`update()`.
|
|
136
136
|
- **Missing `timeout`.** Required, no default. Pick 30 s if you don't know; bump when you have evidence.
|
|
137
137
|
- **`timeout > 300` without `class: "long_running"`.** Install fails. Heavy jobs are an explicit opt-in.
|
|
138
138
|
- **Sub-minute cron.** The scheduler ticks every 60 s. Anything finer is dishonest — declare a minute cron.
|
|
@@ -85,6 +85,8 @@ Translation files mirror the module's content structure. Each translatable objec
|
|
|
85
85
|
| `actions` | Action labels, descriptions, and param labels | Action codes (keys from `ui/actions.json`) → `{ label, desc, params: { param_cd: { label } } }` |
|
|
86
86
|
| `reports` | Report dataset captions and param labels | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label } } }` |
|
|
87
87
|
| `entities.<e>.constraints` | Check/unique constraint violation messages (opt-in — warned, not enforced) | Constraint codes → `{ message }` under the owning entity |
|
|
88
|
+
| `entities.<e>.fields.<f>.options` | Dropdown/radio/flags **option labels** (opt-in) | Option `value` → localized label (string) or `{ label, icon, color }` |
|
|
89
|
+
| `domains` | Column domain labels **and** their shared option labels (opt-in) | Domain codes → `{ label, options: { value: … } }` |
|
|
88
90
|
|
|
89
91
|
### Constraint violation messages ARE translatable (opt-in)
|
|
90
92
|
|
|
@@ -105,6 +107,47 @@ A check/unique constraint declares a user-facing `message` in the entity JSON (`
|
|
|
105
107
|
- **Opt-in, never mandatory** (unlike roles/labels, which completeness *enforces*). If the manifest lists `supportedLocales` and a constraint `message` has no override for one of those (non-English) locales, install emits a **non-fatal warning** naming the missing `entities.<e>.constraints.<c>.message` keys (printed by the CLI, returned in the marketplace install response). Install still commits with the base message as the fallback. `dforge_module_validate` surfaces the same gap pre-flight as a warning.
|
|
106
108
|
- Only constraints that declare a `message` are scanned; English (`en`/`en-*`) is the base and is never warned; extension entities (`"extends": "..."`) are skipped — their constraint translations belong with the foreign module's files.
|
|
107
109
|
|
|
110
|
+
### Dropdown option labels ARE translatable (opt-in)
|
|
111
|
+
|
|
112
|
+
The `options` on a `dropdown`/`radio`/`flags` column are the **base (fallback) labels**. To translate the labels a user actually sees, add an `options` map next to the field's `label`, keyed by the option's **value** (the stored code):
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
"entities": {
|
|
116
|
+
"position": {
|
|
117
|
+
"fields": {
|
|
118
|
+
"status": {
|
|
119
|
+
"label": "Status",
|
|
120
|
+
"options": {
|
|
121
|
+
"on_hold": { "label": "In Wartestellung", "color": "#f90" },
|
|
122
|
+
"draft": "Entwurf"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- An override is a **partial merged over the base** — a bare string is shorthand for `{ "label": … }`; any field it omits (icon/color) falls through to the authored option. Only `label`, `icon`, `color` are translatable; **`value` is the stored code and is never translated** (translations match on it).
|
|
131
|
+
- **Opt-in, never mandatory** — an option with no override keeps its authored label; nothing warns or fails.
|
|
132
|
+
- Resolved once at metadata-load time and cached per culture, so the localized labels show up identically in the grid, the form, **and** print templates (`_fmt.<col>` / bare field refs) — which previously always rendered the base English label.
|
|
133
|
+
- `optionSets` conditional narrowing is unaffected: it filters by `value`, which translation never touches.
|
|
134
|
+
|
|
135
|
+
### Column domain labels + options ARE translatable (opt-in) — and shared
|
|
136
|
+
|
|
137
|
+
If a column uses a **domain** (see `column-domains.md`), translate the domain **once** under a top-level `domains` section — its label and per-option labels then flow to *every* column that references it, instead of once per column:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
"domains": {
|
|
141
|
+
"doc_status": {
|
|
142
|
+
"label": "Belegstatus",
|
|
143
|
+
"options": { "draft": "Entwurf", "posted": { "label": "Gebucht", "color": "#0a0" } }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- Keys are domain codes from this module's `domains.json`. The `options` map uses the same value-keyed, partial-override shape as field options above.
|
|
149
|
+
- This is the whole point of domains for localization: the shared option list is authored and translated in one place. Don't also translate the consuming columns' options — they inherit from the domain.
|
|
150
|
+
|
|
108
151
|
### Roles ARE translated — and completeness is enforced
|
|
109
152
|
|
|
110
153
|
Role labels are **required and localized** (this changed — older docs said roles were skipped; they are not). `SecurityRegistrar` gives every role a `res_id`, so `TranslationRegistrar` wires the role label exactly like entities/views/menus:
|