@dforge-core/dforge-mcp 0.2.36 → 0.2.38
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 +33 -0
- package/dist/server.js +42 -6
- package/package.json +2 -2
- package/resources/schemas/demo-vars.schema.json +28 -0
- package/resources/schemas/deps.schema.json +14 -2
- package/resources/schemas/entity.schema.json +1 -1
- package/resources/schemas/settings.schema.json +31 -0
- package/resources/schemas/translations.schema.json +13 -1
- package/skills/dforge-mcp-author/SKILL.md +1 -1
- package/skills/dforge-mcp-author/references/column-types.md +11 -0
- package/skills/dforge-mcp-author/references/conventions.md +15 -0
- package/skills/dforge-mcp-author/references/data-views.md +39 -7
- package/skills/dforge-mcp-author/references/field-types.md +4 -4
- package/skills/dforge-mcp-author/references/flags.md +2 -1
- package/skills/dforge-mcp-author/references/formulas.md +41 -14
- package/skills/dforge-mcp-author/references/jobs.md +2 -2
- package/skills/dforge-mcp-author/references/registers.md +274 -0
- package/skills/dforge-mcp-author/references/security.md +7 -0
- package/skills/dforge-mcp-author/references/traits.md +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
|
|
|
4
4
|
`0.1.0-rc.N` pre-release tags; the published version is set at publish time via
|
|
5
5
|
the release workflow, so committed `package.json` versions are placeholders.
|
|
6
6
|
|
|
7
|
+
## 0.2.38 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
**A register column's `map` may name a debit and a credit column.** On an accounting (`L`) column,
|
|
10
|
+
a resource the register splits by side maps to `{ "debit": "<line column>", "credit": "<line column>" }`;
|
|
11
|
+
install requires it. `dforge://schema/entity` now allows it (metadata 0.0.39). Before this,
|
|
12
|
+
`gl`, `acc-registry` and any module with a two-sided ledger failed schema validation.
|
|
13
|
+
|
|
14
|
+
**A registers reference.** `dforge://reference/registers` covers the register kinds (balance, turnover,
|
|
15
|
+
information, accounting classic and compound), `registers.json`, binding a document from its `A` / `L`
|
|
16
|
+
columns, the DSL and report reads, and the common mistakes.
|
|
17
|
+
|
|
18
|
+
**Demo data is documented.** The conventions reference gains a "Demo data" section: `demo-data/` beside
|
|
19
|
+
`seed-data/`, loaded on request; `$f` formulas, `repeat`, and `vars.json`; the workspace-type gate.
|
|
20
|
+
|
|
21
|
+
**Three schemas are served.** `dforge://schema/registers`, `card-layouts` and `demo-vars` were vendored or
|
|
22
|
+
new but not registered. `demo-vars` is the schema for `demo-data/vars.json` (metadata 0.0.38).
|
|
23
|
+
|
|
24
|
+
## 0.2.37 — 2026-10-12
|
|
25
|
+
|
|
26
|
+
**Author references brought in line with the hand-authoring copy in dForge-core.** Content that
|
|
27
|
+
had only reached one of the two now reaches both:
|
|
28
|
+
|
|
29
|
+
- **Data views:** a grid view needs a renderable scalar column, from `dataSources[].columns` or
|
|
30
|
+
from the entity's `V` flags; the `columns` array is authoritative. The old text calling it
|
|
31
|
+
layout-only was wrong.
|
|
32
|
+
- **Formulas:** the full `NUMBER()` / `BOOLEAN()` rules, where the client and server engines
|
|
33
|
+
agree, and multi-hop navigation at SQL time with its depth cap.
|
|
34
|
+
- **Security:** don't grant a right on a reference's target just to make its name show up.
|
|
35
|
+
- **Columns and traits:** how the reference picker behaves on a large target; how to show audit
|
|
36
|
+
columns from a trait; corrected params for the `code`, `tags` and `percent` field types.
|
|
37
|
+
- **Jobs:** `paused` seeds the initial value only; an upgrade never overwrites it.
|
|
38
|
+
- **Flags:** a view column's `notes` row in the example.
|
|
39
|
+
|
|
7
40
|
## 0.2.36 — 2026-10-12
|
|
8
41
|
|
|
9
42
|
**`settings.json` gains `$constraints`**: check constraints across a module's settings, SQL over
|
package/dist/server.js
CHANGED
|
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
|
|
|
31002
31002
|
var fs2 = __toESM(require("fs"));
|
|
31003
31003
|
var path2 = __toESM(require("path"));
|
|
31004
31004
|
|
|
31005
|
-
// node_modules/.pnpm/@dforge-core+dforge-cli@0.2.
|
|
31005
|
+
// node_modules/.pnpm/@dforge-core+dforge-cli@0.2.29/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
|
|
31006
31006
|
function buildManifest(opts, moduleId) {
|
|
31007
31007
|
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
31008
31008
|
const manifest = {
|
|
@@ -31290,7 +31290,7 @@ function buildZedTasks() {
|
|
|
31290
31290
|
var fs = __toESM(require("fs"));
|
|
31291
31291
|
var path = __toESM(require("path"));
|
|
31292
31292
|
|
|
31293
|
-
// node_modules/.pnpm/@dforge-core+metadata@0.0.
|
|
31293
|
+
// node_modules/.pnpm/@dforge-core+metadata@0.0.39/node_modules/@dforge-core/metadata/dist/index.js
|
|
31294
31294
|
var AggType = {
|
|
31295
31295
|
Sum: "sum",
|
|
31296
31296
|
Count: "count",
|
|
@@ -31530,6 +31530,26 @@ function deriveDbDatatype(fieldTypeCd, options = {}) {
|
|
|
31530
31530
|
}
|
|
31531
31531
|
return base;
|
|
31532
31532
|
}
|
|
31533
|
+
var INFO_PROPERTIES = ["period", "recorder", "recordChangesOnly"];
|
|
31534
|
+
var ACCOUNTING_PROPERTIES = [
|
|
31535
|
+
"chart",
|
|
31536
|
+
"composition",
|
|
31537
|
+
"analyticFields",
|
|
31538
|
+
"lineMode",
|
|
31539
|
+
"account",
|
|
31540
|
+
"debitAccount",
|
|
31541
|
+
"creditAccount",
|
|
31542
|
+
"debitAnalyticFields",
|
|
31543
|
+
"creditAnalyticFields",
|
|
31544
|
+
"sideDimensions"
|
|
31545
|
+
];
|
|
31546
|
+
var BALANCE_PROPERTIES = ["allowNegative", "autoCreate"];
|
|
31547
|
+
var REFUSED = {
|
|
31548
|
+
balance: [...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES],
|
|
31549
|
+
turnover: ["entity", ...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES, ...BALANCE_PROPERTIES],
|
|
31550
|
+
info: ["journal", "journalDirection", "journalDate", ...ACCOUNTING_PROPERTIES, "totals", ...BALANCE_PROPERTIES],
|
|
31551
|
+
accounting: ["entity", ...INFO_PROPERTIES, "journalDirection", ...BALANCE_PROPERTIES]
|
|
31552
|
+
};
|
|
31533
31553
|
|
|
31534
31554
|
// src/tools/_helpers.ts
|
|
31535
31555
|
function modulePaths(moduleDir) {
|
|
@@ -34602,7 +34622,7 @@ var path10 = __toESM(require("path"));
|
|
|
34602
34622
|
var fs7 = __toESM(require("fs"));
|
|
34603
34623
|
var path9 = __toESM(require("path"));
|
|
34604
34624
|
|
|
34605
|
-
// node_modules/.pnpm/@dforge-core+metadata@0.0.
|
|
34625
|
+
// node_modules/.pnpm/@dforge-core+metadata@0.0.39/node_modules/@dforge-core/metadata/dist/dsl/index.js
|
|
34606
34626
|
var PUNCT3 = ["===", "!=="];
|
|
34607
34627
|
var PUNCT2 = [
|
|
34608
34628
|
"==",
|
|
@@ -35529,9 +35549,9 @@ var BUILTINS = [
|
|
|
35529
35549
|
},
|
|
35530
35550
|
// Utilities
|
|
35531
35551
|
{ name: "now", signature: "now()", returns: "datetime", doc: "Current date/time. Lowercase \u2014 this is the execute-block date function." },
|
|
35532
|
-
{ name: "addDays", signature: "addDays(date, n)", returns: "date", doc: "Add `n` days
|
|
35533
|
-
{ name: "addMinutes", signature: "addMinutes(date, n)", returns: "datetime", doc: "Add `n` minutes." },
|
|
35534
|
-
{ name: "addSeconds", signature: "addSeconds(date, n)", returns: "datetime", doc: "Add `n` seconds." },
|
|
35552
|
+
{ name: "addDays", signature: "addDays(date, n)", returns: "date | string", doc: "Add `n` days. Takes a date field, a `Date` or an ISO string; a date field stays a date and a `yyyy-MM-dd` string stays a string. Null stays null; any other value is an error." },
|
|
35553
|
+
{ name: "addMinutes", signature: "addMinutes(date, n)", returns: "datetime", doc: "Add `n` minutes. Takes a date field, a `Date` or an ISO string; the result is a timestamp. Null stays null; any other value is an error." },
|
|
35554
|
+
{ name: "addSeconds", signature: "addSeconds(date, n)", returns: "datetime", doc: "Add `n` seconds. Takes a date field, a `Date` or an ISO string; the result is a timestamp. Null stays null; any other value is an error." },
|
|
35535
35555
|
{ name: "currentUserId", signature: "currentUserId()", returns: "long", doc: "The acting user's tenant user ID." },
|
|
35536
35556
|
{ name: "flush", signature: "flush()", returns: "void", doc: "Flush pending writes so a following read sees them." },
|
|
35537
35557
|
{ name: "tryParseJson", signature: "tryParseJson(value)", returns: "any | null", doc: "Parse JSON, returning `null` on failure instead of throwing." },
|
|
@@ -38871,6 +38891,21 @@ var resources = [
|
|
|
38871
38891
|
"Stored procedures JSON schema",
|
|
38872
38892
|
"JSON Schema for logic/stored_procedures.json \u2014 map of stored-procedure code \u2192 { functionName, schemaName?, params?, columns? }. Required for any report dataset with datasetType 'S': the .sql file under logic/reports/ creates the function, this file registers it as the spCd a dataset binds and the sec_object an sp: right is granted on. functionName names the FUNCTION, never the script."
|
|
38873
38893
|
),
|
|
38894
|
+
schema(
|
|
38895
|
+
"registers",
|
|
38896
|
+
"Registers JSON schema",
|
|
38897
|
+
"JSON Schema for registers.json at the module root \u2014 map of register code \u2192 { kind (balance | turnover | info | accounting), dimensions, resources, journal/entity, totals, ... }. Documents bind to a register from their A / L columns instead of restating it."
|
|
38898
|
+
),
|
|
38899
|
+
schema(
|
|
38900
|
+
"card-layouts",
|
|
38901
|
+
"Card layouts JSON schema",
|
|
38902
|
+
"JSON Schema for ui/card_layouts.json \u2014 layout name \u2192 sections (columnGroup, set, tabGroup). Master/detail is a card layout with a tabGroup of set tabs, not a view type."
|
|
38903
|
+
),
|
|
38904
|
+
schema(
|
|
38905
|
+
"demo-vars",
|
|
38906
|
+
"Demo data variables JSON schema",
|
|
38907
|
+
'JSON Schema for demo-data/vars.json \u2014 a flat map of variable name \u2192 literal or { "$f": formula }. Demo-data formulas read them as @name.'
|
|
38908
|
+
),
|
|
38874
38909
|
{
|
|
38875
38910
|
uri: "dforge://docs/conventions",
|
|
38876
38911
|
name: "dForge module conventions",
|
|
@@ -38914,6 +38949,7 @@ var resources = [
|
|
|
38914
38949
|
mimeType: "text/x-python",
|
|
38915
38950
|
read: () => readSkill("scripts/xlsx_to_model.py")
|
|
38916
38951
|
},
|
|
38952
|
+
reference("registers", "Registers: accumulation (balance / turnover / info) and accounting (classic / compound) registers, registers.json, binding a document from its A / L columns (map, per-side debit/credit objects), and the reads. Load before authoring registers.json or an A / L column."),
|
|
38917
38953
|
reference("data-migration", "Migrating data from a legacy database."),
|
|
38918
38954
|
reference("manifest", "manifest.json shape: moduleId UUID, semver, entities map, locale-keyed translations, security block."),
|
|
38919
38955
|
reference("validation-checklist", "Final pre-pack self-review checklist covering every file type."),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/dforge-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.38",
|
|
4
4
|
"description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/iash44/dForge-core",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@dforge-core/dforge-cli": "^0.2.29",
|
|
30
|
-
"@dforge-core/metadata": "^0.0.
|
|
30
|
+
"@dforge-core/metadata": "^0.0.39",
|
|
31
31
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
32
32
|
"zod": "^4.4.3"
|
|
33
33
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://dforge.dev/schemas/demo_vars.schema.json",
|
|
4
|
+
"title": "dForge Demo Data Variables",
|
|
5
|
+
"description": "demo-data/vars.json: the variables demo-data formulas read as @name. A flat map of variable name to value. It is not a data file; the loader skips it, and each module has its own (nothing is shared between modules).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"propertyNames": {
|
|
8
|
+
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
|
|
9
|
+
"description": "A letter or underscore, then letters, digits or underscores. Names are case-insensitive, and a repeat variable of the same name shadows the file's."
|
|
10
|
+
},
|
|
11
|
+
"additionalProperties": {
|
|
12
|
+
"description": "A plain value (string, number, boolean or null) is substituted as a literal. { \"$f\": \"...\" } is a formula expanded where the variable is used, e.g. { \"$f\": \"DATEADD(TODAY(), -30, 'DAY')\" }; it may read other variables with @name (a cycle fails the install) but not [column] values.",
|
|
13
|
+
"oneOf": [
|
|
14
|
+
{ "type": ["string", "number", "boolean", "null"] },
|
|
15
|
+
{
|
|
16
|
+
"type": "object",
|
|
17
|
+
"required": ["$f"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"$f": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "Formula whose result is the variable's value"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"additionalProperties": false
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -18,12 +18,24 @@
|
|
|
18
18
|
},
|
|
19
19
|
"entities": {
|
|
20
20
|
"type": "object",
|
|
21
|
-
"description": "Consumed provider entities, keyed by entity code (e.g. 'party'). At least one, except against a system module (admin, metadata, workspace, sys-billing), where {} declares a pure platform-version gate for a feature that introduced no entity.",
|
|
21
|
+
"description": "Consumed provider entities, keyed by entity code (e.g. 'party'). At least one, except against a system module (admin, metadata, workspace, sys-billing), where {} declares a pure platform-version gate for a feature that introduced no entity, or when 'functions' imports at least one function.",
|
|
22
22
|
"additionalProperties": { "$ref": "#/definitions/entityContract" }
|
|
23
|
+
},
|
|
24
|
+
"functions": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"description": "The provider's DSL library functions (logic/functions/*.dsl) the consumer's actions call, as <provider code with '-' replaced by '_'>.<name>(...). Only listed names are reachable, and install checks the installed provider declares each. See docs/business-logic/dsl-functions.md.",
|
|
27
|
+
"items": { "type": "string", "pattern": "^[A-Za-z_$][A-Za-z0-9_$]*$" },
|
|
28
|
+
"uniqueItems": true,
|
|
29
|
+
"minItems": 1
|
|
23
30
|
}
|
|
24
31
|
},
|
|
25
32
|
"if": {
|
|
26
|
-
"not": {
|
|
33
|
+
"not": {
|
|
34
|
+
"anyOf": [
|
|
35
|
+
{ "properties": { "module": { "enum": ["admin", "metadata", "workspace", "sys-billing"] } }, "required": ["module"] },
|
|
36
|
+
{ "required": ["functions"] }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
27
39
|
},
|
|
28
40
|
"then": {
|
|
29
41
|
"properties": { "entities": { "minProperties": 1 } }
|
|
@@ -259,7 +259,7 @@
|
|
|
259
259
|
"lineField": { "type": "string", "description": "The line's foreign-key column(s) to the header, comma-separated in the header's key order for a composite key." },
|
|
260
260
|
"stateField": { "type": "string", "description": "The bool column that records the posted state, when it is not the registry column itself. Written only by the register engine." },
|
|
261
261
|
"register": { "type": "string", "description": "The register this column posts to, as 'module_cd.register_cd' (declared in that module's registers.json). Binding to one replaces restating what the register is: the declaration supplies the state entity, dimensions, resources, journal, allowNegative and autoCreate, and this column keeps only what legitimately differs per writer — direction/sign, 'map' and 'dateField'." },
|
|
262
|
-
"map": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Register dimension or resource name -> this document's column carrying it. Every resource the register declares must be mapped (install refuses otherwise); a dimension left out binds to a column of the same name. Only meaningful with 'register'. On an accumulation (A) column a value may be a one-hop reference path, '<reference column>.<target column>', read through the reference at posting time (off the line when the binding posts from lines), or an expression: a string starting with '=' holding an F-column formula over the same row with one-hop reference reads — \"=COALESCE([transit_warehouse_id], [dest_warehouse_id])\"." },
|
|
262
|
+
"map": { "type": "object", "additionalProperties": { "oneOf": [ { "type": "string" }, { "type": "object", "required": ["debit", "credit"], "additionalProperties": false, "properties": { "debit": { "type": "string" }, "credit": { "type": "string" } } } ] }, "description": "Register dimension or resource name -> this document's column carrying it. On an accounting (L) column a resource or dimension the register splits by side maps to { \"debit\": \"<line column>\", \"credit\": \"<line column>\" } instead of one column. Every resource the register declares must be mapped (install refuses otherwise); a dimension left out binds to a column of the same name. Only meaningful with 'register'. On an accumulation (A) column a value may be a one-hop reference path, '<reference column>.<target column>', read through the reference at posting time (off the line when the binding posts from lines), or an expression: a string starting with '=' holding an F-column formula over the same row with one-hop reference reads — \"=COALESCE([transit_warehouse_id], [dest_warehouse_id])\"." },
|
|
263
263
|
"headerMap": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Register dimension or resource name -> this DOCUMENT's column carrying it, for a binding that posts from LINES. A line binding reads every other value off the line, and a dimension that belongs to the document (a dealer, an organisation, a date) cannot be put on the line: a G column is a same-row expression or an aggregate over a child set, so it cannot read a parent. Explicit rather than a fallback, because 'not on the line, try the header' turns a typo into a silent read of an unrelated header column with the same name. Install refuses: a name on neither table, a field also named in 'map', a column also read off the line by 'map', a column that is part of the line's key, a column 'lockedFields' does not freeze (the posting is keyed by it, so it has to hold still while the document is posted), and a RESOURCE unless the register is 'info' (a quantity from the document would be added once per line; an information register's records replace rather than add). A value may be a one-hop reference path off the document, whose reference key column 'lockedFields' must then freeze, or an expression over the document ('=...', see 'map')." },
|
|
264
264
|
"groupLinesBy": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true, "description": "Line columns whose distinct values each post ONE movement, instead of one per line — a production report posting 'this unit worked today' once per unit with a count of 1. 'map' is then read over a group row: the grouped line columns, every column of the document, and one-hop reads through a reference of either, so an expression may mix the document and the group key. A quoted constant is stamped once per group; a numeric line column mapped to a resource is summed over the group. Install refuses: a binding that posts from the document, an L column, a grouped column the line does not store or 'lockedLineFields' does not freeze, a line column read that is neither grouped nor a summed resource, a non-numeric summed resource, a column both grouped and summed, an ungrouped line resource on an 'info' register, a name both the line and the document carry, and a document column read that 'lockedFields' does not freeze. Accumulation (A) columns only." },
|
|
265
265
|
"dateField": { "type": "string", "description": "This document's date column, supplying the date the register stamps on each journal row. Required once the register declares a 'journalDate' — one writer that supplied none would make every balance read as of a date quietly short, across all writers. On an accumulation (A) column it may be a one-hop reference path, '<reference column>.<target column>', or an expression over the document ('=...', see 'map')." },
|
|
@@ -4,10 +4,41 @@
|
|
|
4
4
|
"title": "dForge Module Settings",
|
|
5
5
|
"description": "Module-level settings definitions: a flat map of setting code → setting definition. Settings are folder-scoped at runtime (values inherit through the folder tree) and surface in the Module Settings UI.",
|
|
6
6
|
"type": "object",
|
|
7
|
+
"propertyNames": {
|
|
8
|
+
"pattern": "^(\\$constraints|[^$].*)$",
|
|
9
|
+
"description": "A setting code cannot start with '$'; $constraints is the only reserved key."
|
|
10
|
+
},
|
|
11
|
+
"properties": {
|
|
12
|
+
"$constraints": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"description": "Checks across this module's settings, keyed by constraint name. The settings are the columns of one virtual row, so each is an entity check constraint: SQL over setting codes. settings.set and settings.setMany run every rule naming a written setting, at the folder written and at each folder below it that overrides a setting the rule names; install refuses defaults that fail one. NULL passes, as in a CHECK.",
|
|
15
|
+
"additionalProperties": {
|
|
16
|
+
"$ref": "#/$defs/constraint"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
7
20
|
"additionalProperties": {
|
|
8
21
|
"$ref": "#/$defs/setting"
|
|
9
22
|
},
|
|
10
23
|
"$defs": {
|
|
24
|
+
"constraint": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["type", "expression"],
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"type": {
|
|
30
|
+
"const": "check"
|
|
31
|
+
},
|
|
32
|
+
"expression": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Boolean over this module's settings in SQL CHECK syntax, e.g. \"default_discount_pct <= max_discount_pct\": comparisons, IS [NOT] NULL, [NOT] IN, [NOT] BETWEEN, regex matches (~ ~* !~ !~*) against a string literal, AND/OR/NOT, + - * /, literals and setting codes (bare or \"quoted\"). No functions, casts or [field] brackets; IS NULL rather than = null. The settings it names are found in it: writing any of them re-checks the rule."
|
|
35
|
+
},
|
|
36
|
+
"message": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Violation message. Localizable via translations/<locale>.json under settings.$constraints.<name>.message."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
11
42
|
"setting": {
|
|
12
43
|
"type": "object",
|
|
13
44
|
"properties": {
|
|
@@ -46,7 +46,19 @@
|
|
|
46
46
|
},
|
|
47
47
|
"settings": {
|
|
48
48
|
"type": "object",
|
|
49
|
-
"description": "Setting code (settings.json) → label and description.",
|
|
49
|
+
"description": "Setting code (settings.json) → label and description; $constraints → constraint name → message.",
|
|
50
|
+
"properties": {
|
|
51
|
+
"$constraints": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"additionalProperties": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"properties": {
|
|
57
|
+
"message": { "type": "string" }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
50
62
|
"additionalProperties": {
|
|
51
63
|
"$ref": "#/$defs/labelDesc"
|
|
52
64
|
}
|
|
@@ -145,7 +145,7 @@ Load these by URI, one per step, at the step that needs them.
|
|
|
145
145
|
**Per-element references** — `dforge://reference/<name>`:
|
|
146
146
|
`field-types`, `flags`, `column-types`, `column-domains`, `formulas`, `traits`,
|
|
147
147
|
`data-views`, `menus`, `action-dsl`, `filters`, `security`, `reports`,
|
|
148
|
-
`settings`, `jobs`, `number-sequences`, `document-extraction`,
|
|
148
|
+
`settings`, `jobs`, `number-sequences`, `registers`, `document-extraction`,
|
|
149
149
|
`print-templates`, `translations`, `queries`, `schema-import`, `excel-import`,
|
|
150
150
|
`data-migration`, `manifest`, `validation-checklist`, `conventions`.
|
|
151
151
|
|
|
@@ -101,6 +101,17 @@ A reference entry may declare `onDelete` and/or `onUpdate` to emit an `ON DELETE
|
|
|
101
101
|
|
|
102
102
|
**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.
|
|
103
103
|
|
|
104
|
+
### How the picker behaves on a large target
|
|
105
|
+
|
|
106
|
+
Opening the picker loads the **first 200 rows** of the target entity — enough to browse, cheap on a table of any size. Typing does *not* filter those 200 rows: the query goes to the server as a case-insensitive match over the columns named in the target's `toString`, so a target with 200 000 rows is as pickable as one with 20. Every whitespace-separated term must match at least one of those columns, which is what lets a `toString` of `"{first_name} {last_name}"` answer "John Smith".
|
|
107
|
+
|
|
108
|
+
Two consequences for the entity you point at:
|
|
109
|
+
|
|
110
|
+
- **Put the columns people search by in `toString`.** They are the only ones the picker searches. A target whose `toString` is `"{code}"` cannot be found by name, however prominent the name column is elsewhere.
|
|
111
|
+
- **A `toString` built only from formula columns can't be searched server-side.** Neither formula nor set columns translate to a `WHERE` clause, so the picker falls back to narrowing the 200 rows it already loaded — rows past that point stay unreachable. Name at least one stored column in the template.
|
|
112
|
+
|
|
113
|
+
`refFilter` and folder row filters still apply: they are AND-ed onto the search, so a filtered-out row does not surface just because the user typed its name.
|
|
114
|
+
|
|
104
115
|
## Set columns (`columnType: "S"`)
|
|
105
116
|
|
|
106
117
|
Virtual 1:N backwards reference. Shows a grid of related records on the parent's detail view.
|
|
@@ -112,6 +112,21 @@ Use explicit **numeric** PKs in seed data so cross-file references work. The `id
|
|
|
112
112
|
|
|
113
113
|
**Common mistake**: using UUID strings like `"10000000-0000-0000-0000-000000000001"` — **wrong**. The `cuid` datatype is a numeric `int8`, not a UUID string. Seed data PKs must be numbers. Use a numbering scheme that avoids collisions (e.g. 1001-1099 for countries, 2001-2099 for regions, etc.).
|
|
114
114
|
|
|
115
|
+
## Demo data
|
|
116
|
+
|
|
117
|
+
Sample records live in `demo-data/`, not `seed-data/`. `seed-data/` holds reference tables every install needs; `demo-data/` is loaded only on request, so a workspace for real work does not start full of fake customers and invoices.
|
|
118
|
+
|
|
119
|
+
- **Same file shape as seed data** (`entityCode` + `records`, numbered prefixes for FK order, explicit primary keys), and the same schema.
|
|
120
|
+
- **Loaded on request:** `dforge-cli module install … --with-demo-data`, the "Install demo data" checkbox in the install dialogs, or `dforge-cli data demo add` on an installed module. A module's dependencies' demo data loads first. Rows already there are left alone, so repeating is safe.
|
|
121
|
+
- **Workspace type gates it:** a `production` workspace refuses demo data; `staging` or an unset type needs confirmation (`--yes`).
|
|
122
|
+
- **A value can be a formula**, evaluated once at insert: `{ "$f": "DATEADD(TODAY(), -80, 'DAY')" }`. `[column]` reads another column of the same row, and `@name` reads a variable. `TODAY()`, `DATEADD`, `YEAR`, `FORMAT` and `CONCAT` exist. Relative dates keep a scenario intact whatever the install date.
|
|
123
|
+
- **`repeat`** builds several rows from one template, with `@m` in formulas.
|
|
124
|
+
- **`demo-data/vars.json`** declares the variables: a flat `{ "name": value }` map, where a value is a literal or `{ "$f": "formula" }` and may read other variables. It is not a data file; the loader skips it, and each module has its own.
|
|
125
|
+
- **References by text:** a reference column may name its target by its `toString` text instead of a numeric key; no match, or two, is an error.
|
|
126
|
+
- **Not re-evaluated:** a formula runs at insert; an upgrade never recomputes it.
|
|
127
|
+
|
|
128
|
+
Depth: the platform changelog entry "Demo data: its own folder, formulas, and a checkbox at install".
|
|
129
|
+
|
|
115
130
|
## Translations
|
|
116
131
|
|
|
117
132
|
Translation files live in `translations/` and are named after the **IETF locale tag** (`de-DE.json`, `uk-UA.json`, etc.). Filenames are auto-discovered; declare which ones you ship in `manifest.supportedLocales`. English is the default — labels and descriptions live in entity/view JSON, so **don't** create `en.json` or `en-US.json` (they're skipped by the installer).
|
|
@@ -60,26 +60,58 @@ Lives in: `ui/data_views.json`
|
|
|
60
60
|
}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
## Critical rule — the
|
|
63
|
+
## Critical rule — the view needs something to draw
|
|
64
64
|
|
|
65
|
-
A grid-style view
|
|
65
|
+
A grid-style view resolves its columns one of two ways, and **one of them has to produce a renderable scalar column** — anything that isn't a set (`columnType: "S"`, a 1:N collection with no per-row value):
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
1. **The source names its own columns.** `dataSources[].columns` wins outright — see [the section below](#the-columns-array-is-authoritative). Flags don't enter into it, so this route works even when every field on the entity is internal.
|
|
68
|
+
2. **It doesn't, and the entity's `V` flags decide.** With no `columns` array the view falls back to the entity's **visible scalar columns** — fields whose `flags` include `V` and whose `columnType` isn't `S`.
|
|
69
|
+
|
|
70
|
+
With neither, the view renders the runtime empty state *"No visible columns configured for this entity."* — a broken screen the user only hits when they open the menu item. `dforge_module_validate` / `dforge_module_pack` **reject** that before install.
|
|
68
71
|
|
|
69
72
|
```jsonc
|
|
70
|
-
// WRONG — grid
|
|
73
|
+
// WRONG — grid falls back to flags, and no field has "V"
|
|
71
74
|
"fields": {
|
|
72
|
-
"code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M
|
|
73
|
-
"lines": { "columnType": "S", "flags": "
|
|
75
|
+
"code": { "fieldTypeCd": "text", "flags": "EM" }, // E=editable, M=… but no V
|
|
76
|
+
"lines": { "columnType": "S", "flags": "VEM" } // visible, but a SET — doesn't count for a grid
|
|
74
77
|
}
|
|
75
|
-
// RIGHT — at least one visible scalar field
|
|
78
|
+
// RIGHT (route 2) — at least one visible scalar field
|
|
76
79
|
"fields": {
|
|
77
80
|
"code": { "fieldTypeCd": "text", "flags": "VEM" } // V present, not a set → satisfies the grid
|
|
78
81
|
}
|
|
82
|
+
// ALSO RIGHT (route 1) — the entity's fields stay internal, the view says what it shows
|
|
83
|
+
"dataSources": [
|
|
84
|
+
{ "entityCode": "audit_log", "columns": ["created_date", "created_by_user"] }
|
|
85
|
+
]
|
|
79
86
|
```
|
|
80
87
|
|
|
88
|
+
Practical guidance: reach for route 2. Flags are what every other surface reads — the card, the Fields popover, quick-add — so an entity whose real business fields are invisible is usually a mistake, even when one view papers over it. Route 1 is for columns that are *deliberately* internal, audit columns above all.
|
|
89
|
+
|
|
81
90
|
This applies to every column-rendering view type (grid, list, kanban, calendar, gallery, tree-grid, master-detail, and the default). The column-agnostic types — `diagram`, `matrix`, `library` — draw from `viewConfig`/relationships instead, so they're **exempt** from this rule.
|
|
82
91
|
|
|
92
|
+
## The `columns` array is authoritative
|
|
93
|
+
|
|
94
|
+
A column you name in `dataSources[].columns` renders **even if its flags omit `V`**. The list is your statement of what this view shows, so it wins over the entity's default visibility — that's how the internal (`flags: "I"`) columns the `audit` / `audit-full` traits contribute get onto a grid:
|
|
95
|
+
|
|
96
|
+
```jsonc
|
|
97
|
+
{
|
|
98
|
+
"entityCode": "trip",
|
|
99
|
+
"columns": ["trip_number", "status", "created_by_user"] // renders Created By, read-only
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A promoted column stays **read-only** unless its flags also carry `E`, and it shows up in the Fields popover like any other column.
|
|
104
|
+
|
|
105
|
+
What `columns` can't do — each fails `dforge_module_validate` / `dforge_module_pack`, instead of dropping silently:
|
|
106
|
+
|
|
107
|
+
| Entry | Why it's rejected |
|
|
108
|
+
|-------|-------------------|
|
|
109
|
+
| A code the entity doesn't define | Nothing to render — usually a typo (`avatar` for `avatar_image`) |
|
|
110
|
+
| A set column (`columnType: "S"`) | A 1:N collection has no per-row value for a column |
|
|
111
|
+
| A nav path (`account.company_name`) | The list takes plain column codes — add a Reference (`R`) or Formula (`F`) column to the entity and list that |
|
|
112
|
+
|
|
113
|
+
The `matrix` / `diagram` / `library` types are exempt here too: their `columns` entry feeds axes and config, not a header row.
|
|
114
|
+
|
|
83
115
|
See [flags.md](flags.md) for the flag string, [column-types.md](column-types.md) for `columnType` (`R`/`S`/`F`).
|
|
84
116
|
|
|
85
117
|
## View types
|
|
@@ -24,10 +24,10 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
24
24
|
| `textarea` | `string` | Multi-line plain text. | `text` | `defaultLines`, `maxLines` |
|
|
25
25
|
| `markdown` | `string` | Markdown editor (Write/Preview tabs), rendered sanitized. | `text` | — |
|
|
26
26
|
| `richtext` | `string` | Rich text WYSIWYG editor, stores sanitized HTML. | `text` | — |
|
|
27
|
-
| `code` | `string` | Code editor
|
|
27
|
+
| `code` | `string` | Code editor (CodeMirror, dForge DSL grammar). | `text` | `defaultLines` |
|
|
28
28
|
| `dropdown` | `string` | Fixed list of options. | `varchar` | `options: ["a","b",…]` or `[{value,label,icon,color}]` |
|
|
29
29
|
| `flags` | `string` | Multiple checkboxes with letter flags. | `varchar` | `optionSets`, `style: "buttons"` |
|
|
30
|
-
| `tags` | `string` | Free-form tag list. | `varchar` or `text` |
|
|
30
|
+
| `tags` | `string` | Free-form comma-separated tag list. | `varchar` or `text` | `options` (optional suggestions) |
|
|
31
31
|
| `color` | `string` | Color picker. | `varchar(20)` | — |
|
|
32
32
|
| `hidden` | `string` | Internal, never shown in UI. | `varchar` | — |
|
|
33
33
|
|
|
@@ -37,7 +37,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
37
37
|
|---|---|---|---|---|
|
|
38
38
|
| `number` | `number` | Numeric value. | `int`, `bigint`, `numeric` | `min`, `max`, `scale` |
|
|
39
39
|
| `currency` | `number` | Monetary value. Precision 2. | `numeric(18,2)` | `currency: "USD"`, `min`, `max` |
|
|
40
|
-
| `percent` | `number` | Percentage
|
|
40
|
+
| `percent` | `number` | Percentage. Precision 2. | `numeric(5,2)` | `min`, `max` |
|
|
41
41
|
|
|
42
42
|
## Boolean-backed
|
|
43
43
|
|
|
@@ -72,7 +72,7 @@ Source of truth: `server/database/system-modules/metadata/seed-data/field_types.
|
|
|
72
72
|
| `user` | `cuid` | `D` | User picker. Writes a user ID directly, no paired FK needed. |
|
|
73
73
|
| `grid` | `set` | `S` | Detail grid of related records (1:N backwards reference). Used with `link` declaring the relation. |
|
|
74
74
|
| `list` | `set` | `S` | Detail list — same 1:N set as `grid`, rendered as a card list instead of a table. |
|
|
75
|
-
| `entitylink` | `json` | `D` | Polymorphic link to any entity
|
|
75
|
+
| `entitylink` | `json` | `D` | Polymorphic link to any entity. Stores `{entityId, entity, pk: {col: "val"}, description?}` — `pk` is a per-column map (values as strings), there is no `id` key. Populate from action DSL with the `entityLink('entityCd', record, description?)` built-in; pass a qualified entity code (see `action-dsl.md`). |
|
|
76
76
|
|
|
77
77
|
## JSON
|
|
78
78
|
|
|
@@ -57,7 +57,8 @@ Because a view writes no DDL, the requiredness `M` produces there is runtime-onl
|
|
|
57
57
|
"party_id": {},
|
|
58
58
|
"party_name": { "flags": "V" },
|
|
59
59
|
"vat_no": { "flags": "VEM" },
|
|
60
|
-
"salary": { "flags": "" }
|
|
60
|
+
"salary": { "flags": "" },
|
|
61
|
+
"notes": { "flags": "VE", "isNullable": true }
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
}
|
|
@@ -129,8 +129,12 @@ The whole date family evaluates identically **client-side and SQL-time** (report
|
|
|
129
129
|
### Conversion and context functions
|
|
130
130
|
|
|
131
131
|
- `TEXT(v)` — as text (null becomes `''`)
|
|
132
|
-
- `NUMBER(v)` — as a number, or null when the
|
|
133
|
-
|
|
132
|
+
- `NUMBER(v)` — as a number, or null when the text isn't one. "Is a number" means an optional
|
|
133
|
+
sign, digits and an optional fraction/exponent — a blank string, `'0x10'` and `'Infinity'`
|
|
134
|
+
are all null, because SQL would reject them too
|
|
135
|
+
- `BOOLEAN(v)` — null is false; a number is false only at **zero**; text is false only when
|
|
136
|
+
**empty**; anything else is true. Note that text and numbers differ: the string `'0'` is
|
|
137
|
+
true (it isn't empty) while the number `0` is false
|
|
134
138
|
- `JSON_GET(json, 'key' [, type])` — read a key out of a JSON column. The optional type is a
|
|
135
139
|
string literal: `'text'` (default), `'number'`/`'numeric'`, `'int'`/`'integer'`/`'bigint'`,
|
|
136
140
|
`'bool'`/`'boolean'`, `'date'`, `'datetime'`/`'timestamp'`/`'timestamptz'`
|
|
@@ -143,21 +147,44 @@ report selects it, or when it is used in a filter or sort. **Every function list
|
|
|
143
147
|
implemented in both**, so a column reads the same in a grid and in a report; a test executes
|
|
144
148
|
each one against PostgreSQL and diffs the result against the client runtime.
|
|
145
149
|
|
|
146
|
-
|
|
150
|
+
Four deliberate points of agreement, because the engines disagree by nature:
|
|
147
151
|
|
|
148
|
-
- **
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
- **Comparison is null-safe and two-valued.** `null = null` is true and `[x] != null` is true
|
|
153
|
+
for any non-null `x` — there is no SQL "unknown" third answer that an enclosing `IF`/`AND`
|
|
154
|
+
would then read as false. `IF([completed_date] != null AND …, 100, 0)` therefore scores the
|
|
155
|
+
same in a grid and in a report. (At SQL time `=`/`!=` become `IS NOT DISTINCT FROM` /
|
|
156
|
+
`IS DISTINCT FROM`.) `ISNULL([x])` remains the clearer spelling of an explicit null check.
|
|
157
|
+
|
|
158
|
+
- **Out-of-domain math is null, not an error.** `SQRT(-1)`, `LOG(0)`, `ASIN(2)`, `ATANH(1)`,
|
|
159
|
+
`EXP(1000)`, `POW(0, -1)`, `POW(-8, 0.5)`, `POW(10, 1000)`, `MOD(x, 0)` — JavaScript would
|
|
160
|
+
answer NaN or Infinity and PostgreSQL would raise (failing the whole report). Both return
|
|
161
|
+
null, so the cell renders blank. A result too *small* to represent is the one exception:
|
|
162
|
+
`POW(10, -1000)` is **0** on both sides, because zero is the answer, not "no answer".
|
|
151
163
|
- **String indexing is JavaScript-flavoured, not SQL's.** `SUBSTRING(s, start, end)` and
|
|
152
164
|
`MID(s, start, len)` take a **0-based** start, and `SUBSTRING`'s third argument is an **end
|
|
153
|
-
offset**, not a length.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
offset**, not a length. A negative offset clamps to 0, and reversed bounds **swap** —
|
|
166
|
+
`SUBSTRING(s, 4, 1)` is the same as `SUBSTRING(s, 1, 4)`. `INDEX_OF` / `LAST_INDEX_OF`
|
|
167
|
+
return a 0-based index, or `-1`.
|
|
168
|
+
- **Comparison never invents a number.** `[a] = [b]` reads both sides as numbers only when
|
|
169
|
+
both genuinely are (a number, or text SQL would also accept as one). A blank string is not
|
|
170
|
+
zero, so `NULLIF([note], '')` leaves a note of `'0'` alone, and `[qty] = ''` is false. The
|
|
171
|
+
same comparator backs `=`, `!=`, `<`, `>`, `<=`, `>=` and `BETWEEN`, so they can't disagree
|
|
172
|
+
with each other.
|
|
173
|
+
|
|
174
|
+
Three known remaining differences:
|
|
175
|
+
|
|
176
|
+
- `CONCAT` on a numeric column renders PostgreSQL's declared scale (`-3.0`) where the client
|
|
177
|
+
renders the JavaScript number (`-3`). Use `FORMAT`/`TEXT` when the exact rendering matters.
|
|
178
|
+
- `MIN`/`MAX` are **number** functions. Handed a non-numeric argument the client silently
|
|
179
|
+
drops it, while a report raises on the type mismatch. Wrap the argument in `NUMBER()` if a
|
|
180
|
+
column might not be numeric.
|
|
181
|
+
- `NUMBER('1e309')` is null client-side — the magnitude exceeds what a JavaScript number can
|
|
182
|
+
hold, and every non-finite result in the client is null. PostgreSQL's `numeric` has a far
|
|
183
|
+
wider range, so a report keeps the value.
|
|
184
|
+
|
|
185
|
+
`CURRENT_USER_ID()` needs the requesting user. It resolves in grids, cards, reports, filters,
|
|
186
|
+
sorts and `select()` — anywhere a request has one — but not in a context with no user, such as
|
|
187
|
+
a scheduled job.
|
|
161
188
|
|
|
162
189
|
### Unknown functions
|
|
163
190
|
|
|
@@ -37,7 +37,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
|
|
|
37
37
|
| `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
|
|
38
38
|
| `params` | No | `{}` | Static parameters passed to the action as `__params`. Manifest-fixed — an admin cannot change them. For tenant-configurable values use module settings, resolved from the job's folder (below). |
|
|
39
39
|
| `enabled` | No | `true` | `false` fully disables — no scheduler, no manual trigger. |
|
|
40
|
-
| `paused` | No | `false` | Soft pause. Scheduler skips, but "Run now" still works.
|
|
40
|
+
| `paused` | No | `false` | Soft pause. Scheduler skips, but "Run now" still works. Seeds the initial value only — upgrades never overwrite it. |
|
|
41
41
|
| `description` | No | | Human-readable description. |
|
|
42
42
|
|
|
43
43
|
**Hard cap: 50 jobs per module.**
|
|
@@ -152,7 +152,7 @@ Full source: [`modules/chore/`](../../../modules/chore/).
|
|
|
152
152
|
- **Sub-minute cron.** The scheduler ticks every 60 s. Anything finer is dishonest — declare a minute cron.
|
|
153
153
|
- **Renaming `code` between versions.** Treats it as a new job and leaves the old `scheduled_job` row orphaned until uninstall (actually reaped on upgrade — but you lose `job_run` history). Keep `code` stable.
|
|
154
154
|
- **"Schedule this 5 minutes from now" pattern.** There is no per-call `schedule()`. Use a cron-scan job that watches a queue table — see [memory: cron-scan pattern](../../../docs/business-logic/jobs.md#when-to-use-a-job-vs-a-trigger).
|
|
155
|
-
- **Confusing `enabled` and `paused`.** `enabled: false` → "Run now" no-ops. `paused: true` → cron skips but "Run now" works. Use `paused` for incident response; use `enabled: false` when you mean "turn this off entirely".
|
|
155
|
+
- **Confusing `enabled` and `paused`.** `enabled: false` → "Run now" no-ops. `paused: true` → cron skips but "Run now" works. Use `paused` for incident response; use `enabled: false` when you mean "turn this off entirely". Only `enabled` is rewritten on upgrade — `paused` is operator state, so shipping `paused: true` in a new version does nothing to tenants that already have the job.
|
|
156
156
|
|
|
157
157
|
## Reference
|
|
158
158
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Registers Reference
|
|
2
|
+
|
|
3
|
+
A **register** is a declared store of movements or values that documents write to by posting, and that the platform reads back with fixed primitives. Declare it once in `registers.json`; documents bind to it from an `A` or `L` column.
|
|
4
|
+
|
|
5
|
+
Lives in: `registers.json` at the module root (a keyed dictionary, like `logic/jobs.json`). Schema: [`docs/schemas/registers.schema.json`](../../../docs/schemas/registers.schema.json); binding params: `registryColumnParams` in [`docs/schemas/entity.schema.json`](../../../docs/schemas/entity.schema.json). Full guide: [`docs/business-logic/registers.md`](../../../docs/business-logic/registers.md). Check with `dforge_module_validate`, then `dforge_module_pack` and `dforge_module_install`.
|
|
6
|
+
|
|
7
|
+
## Register or plain table?
|
|
8
|
+
|
|
9
|
+
| You need | Use |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Running quantity or amount per key, with undo on unpost | `balance` register |
|
|
12
|
+
| "How much moved in a period", no running total | `turnover` register |
|
|
13
|
+
| A value that changes over time, or a keyed lookup, with "value as of a date" | `info` register |
|
|
14
|
+
| Double-entry postings by account, with analytics | `accounting` register |
|
|
15
|
+
| Ordinary master or transactional data nobody aggregates by key | Plain entity |
|
|
16
|
+
| An ad hoc slice by an optional attribute | A report, not a dimension |
|
|
17
|
+
|
|
18
|
+
A register earns its place when something must be guarded or cached by its key: a negative-stock check, a unique slice, a period lock. Posting and unposting are then the platform's job, not a DSL's.
|
|
19
|
+
|
|
20
|
+
## The kinds
|
|
21
|
+
|
|
22
|
+
| `kind` | Keeps | Written by | Read by | Key rules |
|
|
23
|
+
|---|---|---|---|---|
|
|
24
|
+
| `balance` | State row per dimension tuple, plus optional journal | `A` column | `getBalance`, `getTurnover` | Needs `entity`. Set `journal`, `journalDirection`, `journalDate` from the start. `allowNegative` default `false`, `autoCreate` default `true`. |
|
|
25
|
+
| `turnover` | Journal only | `A` column | `getTurnover` | No `entity`, no `allowNegative`/`autoCreate`. `journal`, `journalDirection`, `journalDate` mandatory. `getBalance` refuses it. |
|
|
26
|
+
| `info` | One table of records | `data.set` (independent) or `A` column (subordinate, has `recorder`) | `getSlice` | Needs `entity`, no `journal`. Optional `period`. Independent: unique constraint over dimensions + `period`. Binding needs a `recorder`. |
|
|
27
|
+
| `accounting`, `classic` | Postings, one account per row | `L` column | `getAccountBalance`, `getAccountTurnover`, dataset `R` | `chart`, `account`, `analyticFields`, `composition`. Every resource split `debitField`/`creditField`. No correspondence. |
|
|
28
|
+
| `accounting`, `compound` | Postings, debit and credit account on one row | `L` column | Same, plus `getCorrespondence` | `debitAccount` != `creditAccount`, per-side analytic arrays, optional `sideDimensions`. Resource shared (`field`) or split. Balance check refused. |
|
|
29
|
+
|
|
30
|
+
Every register needs `dimensions` (1-8 columns) and `resources` (1-64). A resource is `required` by default; set `"required": false` when only some writers supply it. A property a kind refuses fails install; it is never ignored.
|
|
31
|
+
|
|
32
|
+
## `registers.json`
|
|
33
|
+
|
|
34
|
+
Register codes: lowercase letters, digits, underscores. A document addresses one as `module_cd.register_cd`. `label` gives it a translatable name.
|
|
35
|
+
|
|
36
|
+
### Balance (`wms-registry.stock`)
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"stock": {
|
|
41
|
+
"kind": "balance",
|
|
42
|
+
"label": "Stock on Hand",
|
|
43
|
+
"entity": "stock",
|
|
44
|
+
"journal": "stock_movement",
|
|
45
|
+
"journalDirection": "movement_direction",
|
|
46
|
+
"journalDate": "movement_date",
|
|
47
|
+
"dimensions": ["warehouse_id", "product_id"],
|
|
48
|
+
"resources": { "quantity": { "field": "quantity" } },
|
|
49
|
+
"allowNegative": false,
|
|
50
|
+
"autoCreate": true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Turnover (`wms-registry.product_flow`)
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"product_flow": {
|
|
60
|
+
"kind": "turnover",
|
|
61
|
+
"journal": "product_flow",
|
|
62
|
+
"journalDirection": "flow_direction",
|
|
63
|
+
"journalDate": "flow_date",
|
|
64
|
+
"dimensions": ["warehouse_id", "product_id"],
|
|
65
|
+
"resources": {
|
|
66
|
+
"received": { "field": "received", "required": false },
|
|
67
|
+
"shipped": { "field": "shipped", "required": false }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Info (`wms-registry.outlet_price`, subordinate)
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"outlet_price": {
|
|
78
|
+
"kind": "info",
|
|
79
|
+
"entity": "outlet_price",
|
|
80
|
+
"recorder": "source",
|
|
81
|
+
"period": "effective_from",
|
|
82
|
+
"dimensions": ["warehouse_id", "product_id"],
|
|
83
|
+
"resources": {
|
|
84
|
+
"price": { "field": "price" },
|
|
85
|
+
"currency_cd": { "field": "currency_cd", "required": false }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`recorder` is a jsonb column the platform stamps with the writing document. Add `"recordChangesOnly": true` (and `absentAs` per resource) to skip a record equal to the slice already in effect.
|
|
92
|
+
|
|
93
|
+
### Accounting, classic (`acc-registry.gl`)
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"gl": {
|
|
98
|
+
"kind": "accounting",
|
|
99
|
+
"lineMode": "classic",
|
|
100
|
+
"chart": "account",
|
|
101
|
+
"journal": "gl_acc_posting",
|
|
102
|
+
"journalDate": "posted_on",
|
|
103
|
+
"account": "account_id",
|
|
104
|
+
"composition": {
|
|
105
|
+
"entity": "account_analytic",
|
|
106
|
+
"account": "account_id",
|
|
107
|
+
"slot": "slot_no",
|
|
108
|
+
"analytic": "analytic_kind_cd"
|
|
109
|
+
},
|
|
110
|
+
"analyticFields": ["analytic1", "analytic2", "analytic3", "analytic4"],
|
|
111
|
+
"dimensions": ["period_key"],
|
|
112
|
+
"resources": {
|
|
113
|
+
"amount": { "debitField": "debit_amount", "creditField": "credit_amount" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`composition` is where the chart says which analytic kind each account carries in which slot. It is required exactly when slot columns are declared; `"analyticFields": []` needs none. Its entity needs a unique constraint over `(account, slot)`.
|
|
120
|
+
|
|
121
|
+
### Accounting, compound (`acc-registry.gl_corr`)
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"gl_corr": {
|
|
126
|
+
"kind": "accounting",
|
|
127
|
+
"lineMode": "compound",
|
|
128
|
+
"chart": "account",
|
|
129
|
+
"journal": "gl_corr_posting",
|
|
130
|
+
"journalDate": "posted_on",
|
|
131
|
+
"debitAccount": "debit_account_id",
|
|
132
|
+
"creditAccount": "credit_account_id",
|
|
133
|
+
"composition": { "entity": "account_analytic", "account": "account_id", "slot": "slot_no", "analytic": "analytic_kind_cd" },
|
|
134
|
+
"debitAnalyticFields": ["dt_analytic1", "dt_analytic2"],
|
|
135
|
+
"creditAnalyticFields": ["ct_analytic1", "ct_analytic2"],
|
|
136
|
+
"dimensions": ["period_key", "organization_id", "currency_cd"],
|
|
137
|
+
"sideDimensions": {
|
|
138
|
+
"currency_cd": { "debitField": "dt_currency_cd", "creditField": "ct_currency_cd" }
|
|
139
|
+
},
|
|
140
|
+
"resources": {
|
|
141
|
+
"amount": { "field": "amount" },
|
|
142
|
+
"currency_amount": { "debitField": "dt_currency_amount", "creditField": "ct_currency_amount" }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The slot cap lives in three places that must agree: the posting's slot columns, `analyticFields`, and the chart's check constraint on the composition table. The platform does not parse that check; a mismatch is refused at post time.
|
|
149
|
+
|
|
150
|
+
## Binding a document
|
|
151
|
+
|
|
152
|
+
A register column is a `bool` state column. `A` writes `balance`, `turnover` and `info`; `L` writes `accounting`. Install resolves `register` and refuses an undeclared one.
|
|
153
|
+
|
|
154
|
+
### `A` column (`wms-registry.stock`)
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
"stock_out": {
|
|
158
|
+
"columnType": "A",
|
|
159
|
+
"dbDatatype": "bool",
|
|
160
|
+
"flags": "V",
|
|
161
|
+
"description": "Stock Out (source)",
|
|
162
|
+
"params": {
|
|
163
|
+
"stateField": "stock_out_state",
|
|
164
|
+
"register": "wms-registry.stock",
|
|
165
|
+
"direction": "decrease",
|
|
166
|
+
"dateField": "transfer_date",
|
|
167
|
+
"map": {
|
|
168
|
+
"warehouse_id": "source_warehouse_id",
|
|
169
|
+
"product_id": "product_id",
|
|
170
|
+
"quantity": "quantity"
|
|
171
|
+
},
|
|
172
|
+
"lockedFields": ["transfer_date", "source_warehouse_id", "product_id", "quantity"]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Param | Meaning |
|
|
178
|
+
|---|---|
|
|
179
|
+
| `register` | `module_cd.register_cd`. Do not combine with inline `registries`. |
|
|
180
|
+
| `direction` | `increase` or `decrease`. Refused on `info`. `sign` decides per row instead. |
|
|
181
|
+
| `dateField` | Document date stamped into `journalDate` or `period`. Required once the register declares either. Must be in `lockedFields`. `dateTrunc` (`day`/`month`/`year`) truncates it. |
|
|
182
|
+
| `map` | Register name to column. A dimension left out binds to the column of the same name. Every required resource must be mapped. |
|
|
183
|
+
| `lineEntity` + `lineField` | Post from lines: one movement per line. Declared per binding. Needs `lockedLineFields`. |
|
|
184
|
+
| `headerMap` | On a line posting, register name to a **document** column. A resource may come from the document only on an `info` register. Every column must be in `lockedFields`. |
|
|
185
|
+
| `groupLinesBy` | One movement per distinct value of these line columns. `map` is read over a group row; numeric resources are summed. |
|
|
186
|
+
| `skipPostingWhenEmpty` | Dimensions whose emptiness means "this row posts nothing". The exception, see below. |
|
|
187
|
+
| `lockedFields` / `lockedLineFields` | Columns frozen while posted. `["*"]` freezes the whole row. |
|
|
188
|
+
| `movementMapping` | Journal attribute column to document or line column. |
|
|
189
|
+
|
|
190
|
+
### `map` value forms
|
|
191
|
+
|
|
192
|
+
| Form | Example | Notes |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| Column | `"qty"` | The default. |
|
|
195
|
+
| One-hop path | `"product.product_group"` | Root is an `R` column with a `link`. The reference key column must be locked. Two hops refused. |
|
|
196
|
+
| Expression | `"=COALESCE([transit_warehouse_id], [dest_warehouse_id])"` | Leading `=`, `F`-column formula, one-hop `[ref].[col]` reads. Every column read must be locked. |
|
|
197
|
+
| Quoted constant | `"'1'"` | Stamped as is. A resource constant must be a number, outside `info`. |
|
|
198
|
+
| Per-side object | `{ "debit": "dt_currency_cd", "credit": "ct_currency_cd" }` | `L` column only. For a resource or dimension the register splits by side. Both sides named. |
|
|
199
|
+
|
|
200
|
+
The per-side object shape was missing from the entity schema until metadata 0.0.39. Validate with metadata 0.0.39 or later.
|
|
201
|
+
|
|
202
|
+
Paths, expressions, `dateTrunc`, `headerMap`, `groupLinesBy` and `skipPostingWhenEmpty` are `A`-only. An `L` column refuses them.
|
|
203
|
+
|
|
204
|
+
### `L` column (`acc-registry.gl_corr`)
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
"params": {
|
|
208
|
+
"register": "acc-registry.gl_corr",
|
|
209
|
+
"lineEntity": "gl_corr_line",
|
|
210
|
+
"lineField": "gl_corr_entry_id",
|
|
211
|
+
"debitAccountField": "debit_account_id",
|
|
212
|
+
"creditAccountField": "credit_account_id",
|
|
213
|
+
"dateField": "entry_date",
|
|
214
|
+
"periodDimField": "period_key",
|
|
215
|
+
"map": {
|
|
216
|
+
"organization_id": "organization_id",
|
|
217
|
+
"currency_cd": { "debit": "dt_currency_cd", "credit": "ct_currency_cd" },
|
|
218
|
+
"amount": "amount",
|
|
219
|
+
"currency_amount": { "debit": "dt_currency_amount", "credit": "ct_currency_amount" }
|
|
220
|
+
},
|
|
221
|
+
"debitAnalytics": { "cost_center": "dt_cost_center_id" },
|
|
222
|
+
"creditAnalytics": { "counterparty": "ct_counterparty_id" },
|
|
223
|
+
"lockedFields": ["entry_date", "period_key"],
|
|
224
|
+
"lockedLineFields": ["*"]
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- Classic uses `accountField` and `analytics`. Compound uses `debitAccountField` + `creditAccountField` and `debitAnalytics` / `creditAnalytics`. Mixing them is refused.
|
|
229
|
+
- `analytics` maps an analytic **kind** to a line column. The chart decides the slot per account, so one line column can land in `analytic1` on one account and `analytic2` on another.
|
|
230
|
+
- `lineEntity` and `lineField` are required. `lockedLineFields` must cover every line column the posting copies, or be `["*"]`.
|
|
231
|
+
- Every register dimension must be mapped, be a same-name line column, or be the `periodDimField` (a shared dimension).
|
|
232
|
+
- `validation.balanced: ["amount"]` checks debits equal credits on classic. It is refused on compound, which balances by construction.
|
|
233
|
+
|
|
234
|
+
## Reads
|
|
235
|
+
|
|
236
|
+
| Read | Kinds | Returns |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| `getBalance(register, dims?, asOf?)` | `balance` | `{ resource: value }`, zeros when empty |
|
|
239
|
+
| `getTurnover(register, dims, from, to)` | `balance`, `turnover` | `{ resource: { in, out, net } }` |
|
|
240
|
+
| `getSlice(register, dims, onDate?, mode?)` | `info` | Record plus `_period`, or `null`. `mode` is `last` (default) or `first`. |
|
|
241
|
+
| `getAccountBalance(register, account, filter?, asOf?)` | `accounting` | `{ resource: { debit, credit, net } }` |
|
|
242
|
+
| `getAccountTurnover(register, account, filter, from, to)` | `accounting` | Same shape |
|
|
243
|
+
| `getCorrespondence(register, debitAccount, creditAccount, filter, from, to)` | compound | Same shape |
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
execute:
|
|
247
|
+
var onHand = getBalance("wms-registry.stock", { warehouse_id: [warehouse_id], product_id: [product_id] })
|
|
248
|
+
var price = getSlice("wms-registry.outlet_price", { warehouse_id: [warehouse_id], product_id: [product_id] }, [order_date])
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- A dimension left out is summed over by `getBalance` and `getTurnover`. `getSlice` refuses an omitted dimension.
|
|
252
|
+
- As-of reads and `getTurnover` need a journal with both stamps.
|
|
253
|
+
- A read of the wrong kind is refused, naming the right one.
|
|
254
|
+
- DSL reads are gated by the action's Execute right, not Select on the register tables.
|
|
255
|
+
- **Reports:** a dataset with `"datasetType": "R"` reads an accounting register over an interval. It returns one row per chart account with `opening_*`, `turnover_*` and `closing_*` debit and credit for one resource. See [`reports.md`](reports.md) and [`docs/business-logic/gl-ledger-to-declared-register.md`](../../../docs/business-logic/gl-ledger-to-declared-register.md).
|
|
256
|
+
|
|
257
|
+
## Totals cache
|
|
258
|
+
|
|
259
|
+
`totals` caches the turnover inside each `day`/`month`/`quarter`/`year`, never a closing balance, so a backdated posting touches one row. Declare an `entity` whose PRIMARY KEY is exactly (dimensions, `periodField`), with `inField`/`outField` per resource (`debitField`/`creditField` and an `accountField` on accounting). Posting maintains it; install rebuilds it when its shape changes. Not available on `info`. Details: registers.md section 8.4.
|
|
260
|
+
|
|
261
|
+
## Common mistakes
|
|
262
|
+
|
|
263
|
+
- **Undeclared or unqualified register.** `params.register` must be `module_cd.register_cd` and exist. Install fails, not the first posting.
|
|
264
|
+
- **Unmapped required resource.** Map it, or mark the resource `"required": false` when only some writers supply it.
|
|
265
|
+
- **Empty dimension.** An empty dimension refuses the posting. `skipPostingWhenEmpty` is the exception: it leaves a document posted while some lines moved nothing. Prefer one line table per binding. Use it only when the distinction lives in the data, such as a serial number filled on some rows.
|
|
266
|
+
- **Missing or unlocked `dateField`.** Required once the register has `journalDate` or `period`, and it must be in `lockedFields`.
|
|
267
|
+
- **Key over dimensions.** The journal, a subordinate `info` register's records and an `autoCreate` state table are written by the platform. Each needs **one** surrogate primary key of type `bigint` (`identity`/`cuid`) or `uuid`. A key over dimensions, a multi-column key, a text key and `int4` are refused.
|
|
268
|
+
- **State column narrower than the journal column.** It must hold the journal's value exactly, or unposting cannot take back what posting added.
|
|
269
|
+
- **Two resources from one document column**, or two resources storing into one register column. Refused.
|
|
270
|
+
- **Binding to an independent `info` register.** Refused. Write it with `data.set`, or give it a `recorder`.
|
|
271
|
+
- **Direct writes to register tables.** They are ordinary entities. Withhold `I`/`U`/`D` on state, journal and cache in the module's roles.
|
|
272
|
+
- **`A`/`L` column in an `extends` entity.** An `A` column is allowed (a bridge posts another module's document; the state lives in the bridge's `_ext` table). An `L` column is allowed but not yet tested. See [`register-posting-from-extensions.md`](../../../docs/business-logic/register-posting-from-extensions.md).
|
|
273
|
+
- **`mark-imported` is not `post`.** A document posted in another system is marked with `dforge-cli register mark-imported --entity <e> --state-column <col> --where "<sql>"`. Its state column stays false, and the engine refuses to post or unpost it. Do not set the flag by hand.
|
|
274
|
+
- **Posting a mark-imported document, or unposting a receipt a later shipment consumed** (with `allowNegative: false`), is refused with `REGISTER_NEGATIVE_BALANCE` or an imported-document error.
|
|
@@ -145,6 +145,13 @@ installer resolves those within this module only, so a dotted one silently grant
|
|
|
145
145
|
}
|
|
146
146
|
```
|
|
147
147
|
8. **Folder access** is granted separately — roles can reference folder codes, but folder definitions themselves live in `ui/folders.json`.
|
|
148
|
+
9. **Don't grant a right on a reference's target just to make the name show up.** A reference column
|
|
149
|
+
resolves its display value for anyone who can read the record carrying it — a read-only driver role
|
|
150
|
+
with `S` on `trip` sees each trip's Vehicle name without any grant on `vehicle`. Grant `S` on the
|
|
151
|
+
target only when the role should genuinely *browse* that entity, open its records, or when a view
|
|
152
|
+
or formula navigates **through** the reference to another of its columns (`vehicle.plate_no`) —
|
|
153
|
+
that still requires the right. Granting it for the name alone unlocks the entity as a full catalog
|
|
154
|
+
everywhere, which is strictly more than intended.
|
|
148
155
|
|
|
149
156
|
## Folders (`ui/folders.json`)
|
|
150
157
|
|
|
@@ -42,6 +42,8 @@ Adds **two timestamp columns** (no user tracking):
|
|
|
42
42
|
|
|
43
43
|
Both columns have `flags: "I"` (internal — not shown in UI by default, managed by the platform).
|
|
44
44
|
|
|
45
|
+
To show one, name it in a data view's `dataSources[].columns` — an explicitly listed column renders (read-only) regardless of its flags. See [Showing audit columns](#showing-audit-columns) below.
|
|
46
|
+
|
|
45
47
|
**Important**: `audit` does NOT add `created_by` / `updated_by` user columns. For user tracking, use `audit-full`.
|
|
46
48
|
|
|
47
49
|
### `audit-full`
|
|
@@ -59,6 +61,34 @@ Use `audit-full` when you need to know **who** created/modified a record, not ju
|
|
|
59
61
|
|
|
60
62
|
**You don't need to declare both `audit` and `audit-full`** — `audit-full` inherits from `audit` automatically.
|
|
61
63
|
|
|
64
|
+
### Showing audit columns
|
|
65
|
+
|
|
66
|
+
Trait columns are internal by default, not off-limits. Two ways to surface them:
|
|
67
|
+
|
|
68
|
+
1. **Per view (usual case)** — list the column in `ui/data_views.json`. A column named in `dataSources[].columns` renders whatever its flags say, so this is all it takes:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"entityCode": "trip",
|
|
73
|
+
"columns": ["trip_number", "status", "created_by_user", "last_updated_by_user"]
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It renders **read-only** (the trait flags carry no `'E'`) and shows up in the Fields popover so users can toggle it off. Use the Reference columns (`created_by_user`) for a readable name — `created_by` is the raw cuid FK.
|
|
78
|
+
|
|
79
|
+
2. **Everywhere, or editable** — redeclare the field on the entity with your own flags. An explicit field definition wins over the trait's, as long as the type matches:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
"created_by_user": {
|
|
83
|
+
"columnType": "R",
|
|
84
|
+
"fieldTypeCd": "lookup",
|
|
85
|
+
"flags": "V",
|
|
86
|
+
"orderNum": 9904,
|
|
87
|
+
"description": "Created By",
|
|
88
|
+
"link": { "entity": "user", "thisKey": "created_by", "otherKey": "user_id" }
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
62
92
|
> **⚠️ `audit-full` + seed data needs handling.** `created_by` and `last_updated_by` are
|
|
63
93
|
> **required** (NOT NULL) cuid FKs to `user`, with **no default formula**. At runtime the platform
|
|
64
94
|
> fills them from the current user, but at **seed** time there is no current user, so a seeded
|