@dforge-core/metadata 0.0.35 → 0.0.36
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 +18 -0
- package/dist/index.d.ts +210 -1
- package/dist/index.js +668 -0
- package/dist/index.js.map +1 -1
- package/package.json +66 -67
- package/src/index.ts +35 -2
- package/src/registers/check-binding.ts +233 -0
- package/src/registers/check-declaration.ts +425 -0
- package/src/registers/entities.ts +79 -0
- package/src/registers/index.ts +17 -0
- package/src/registers/issues.ts +25 -0
- package/src/registers/read.ts +81 -0
- package/src/registers/types.ts +141 -0
package/package.json
CHANGED
|
@@ -1,68 +1,67 @@
|
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
2
|
+
"name": "@dforge-core/metadata",
|
|
3
|
+
"version": "0.0.36",
|
|
4
|
+
"description": "Canonical dForge authoring registries — field types, base datatypes, column types, traits, data-view kinds and chart types — with derivation helpers. Runtime-dependency-free; the single source of truth shared by editors, the web app and the CLI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "dForge",
|
|
7
|
+
"homepage": "https://github.com/iash44/dForge-core/tree/main/clients/packages/metadata#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/iash44/dForge-core.git",
|
|
11
|
+
"directory": "clients/packages/metadata"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/iash44/dForge-core/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dforge",
|
|
18
|
+
"metadata",
|
|
19
|
+
"field-type",
|
|
20
|
+
"trait",
|
|
21
|
+
"registry",
|
|
22
|
+
"low-code"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"module": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"source": "./src/index.ts",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./dsl": {
|
|
38
|
+
"source": "./src/dsl/index.ts",
|
|
39
|
+
"types": "./dist/dsl/index.d.ts",
|
|
40
|
+
"default": "./dist/dsl/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./schemas/*": "./schemas/*",
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"files": [
|
|
47
|
+
"dist",
|
|
48
|
+
"src",
|
|
49
|
+
"schemas",
|
|
50
|
+
"CHANGELOG.md"
|
|
51
|
+
],
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^25.9.4",
|
|
54
|
+
"ajv": "^8.20.0",
|
|
55
|
+
"tsup": "^8.5.1",
|
|
56
|
+
"typescript": "^6.0.3",
|
|
57
|
+
"vitest": "^4.1.10"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"sync-schemas": "node scripts/sync-schemas.mjs",
|
|
61
|
+
"build": "tsup",
|
|
62
|
+
"dev": "tsup --watch",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"package": "pnpm pack"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// from a friendly field-type choice;
|
|
7
7
|
// • structural types — the authoring shape of every module-package file
|
|
8
8
|
// (manifest, entities, data views, reports, stored procedures, menus, folders,
|
|
9
|
-
// roles, settings, jobs, triggers, webhooks, print templates, seed data,
|
|
10
|
-
// actions, diagrams), mirrored from the JSON schemas under docs/schemas
|
|
9
|
+
// roles, settings, registers, jobs, triggers, webhooks, print templates, seed data,
|
|
10
|
+
// translations, actions, diagrams), mirrored from the JSON schemas under docs/schemas/;
|
|
11
|
+
// • shared rules — the action-DSL analyzer (./dsl) and the register checks
|
|
12
|
+
// (checkRegisters / checkRegisterBinding), run by editors and validators alike.
|
|
11
13
|
//
|
|
12
14
|
// No runtime dependencies; safe to import from editors, the web app, the CLI,
|
|
13
15
|
// the MCP server and the VS Code extension.
|
|
@@ -131,6 +133,37 @@ export type {
|
|
|
131
133
|
TranslatedWithOptions,
|
|
132
134
|
} from "./translations";
|
|
133
135
|
export type { SettingsFile, SettingDef, SettingBaseDatatype } from "./settings";
|
|
136
|
+
export {
|
|
137
|
+
REGISTER_KINDS,
|
|
138
|
+
REGISTER_CODE,
|
|
139
|
+
REGISTER_COLUMN_NAME,
|
|
140
|
+
REGISTER_TOTALS_PERIODS,
|
|
141
|
+
isRegisterKind,
|
|
142
|
+
isPlainColumnRef,
|
|
143
|
+
registerDimensions,
|
|
144
|
+
registerResources,
|
|
145
|
+
registerCarrier,
|
|
146
|
+
registerResourceShape,
|
|
147
|
+
registerLineMode,
|
|
148
|
+
registerRefusedProps,
|
|
149
|
+
registerRequiredProps,
|
|
150
|
+
checkRegister,
|
|
151
|
+
checkRegisters,
|
|
152
|
+
checkRegisterBinding,
|
|
153
|
+
type RegisterKind,
|
|
154
|
+
type RegisterDef,
|
|
155
|
+
type RegistersFile,
|
|
156
|
+
type RegisterResourceDef,
|
|
157
|
+
type RegisterTotalsDef,
|
|
158
|
+
type RegisterTotalsPeriod,
|
|
159
|
+
type RegisterCompositionDef,
|
|
160
|
+
type RegisterBindingParams,
|
|
161
|
+
type RegisterMapValue,
|
|
162
|
+
type RegisterBindingTarget,
|
|
163
|
+
type RegisterIssue,
|
|
164
|
+
type RegisterEntityInfo,
|
|
165
|
+
type RegisterEntityLookup,
|
|
166
|
+
} from "./registers";
|
|
134
167
|
export type { JobsFile, JobDef } from "./jobs";
|
|
135
168
|
export type { TriggersFile, TriggerDef, EntityEvent } from "./triggers";
|
|
136
169
|
export type { WebhooksFile, WebhookSubscription, WebhookPayload } from "./webhooks";
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Checking an A / L column's `params` against the register it binds.
|
|
2
|
+
|
|
3
|
+
import { createIssueReporter } from "./issues";
|
|
4
|
+
import type { RegisterIssue, RegisterReporter } from "./issues";
|
|
5
|
+
import { hasKey, isRecord, registerResourceShape, safeTrim, stringList } from "./read";
|
|
6
|
+
import type { Raw } from "./read";
|
|
7
|
+
import type { RegisterDef } from "./types";
|
|
8
|
+
|
|
9
|
+
/** What a binding is checked against: the parts of a declaration it has to agree with. */
|
|
10
|
+
export type RegisterBindingTarget = Pick<RegisterDef, "kind" | "dimensions" | "resources" | "recorder" | "lineMode" | "sideDimensions">;
|
|
11
|
+
|
|
12
|
+
/** A reference path (`ref.column`), an expression (`=…`) or a quoted constant — not a plain column. */
|
|
13
|
+
export const isPlainColumnRef = (value: string): boolean => !value.startsWith("=") && !value.startsWith("'") && !value.includes(".");
|
|
14
|
+
|
|
15
|
+
interface BindingContext {
|
|
16
|
+
params: Raw;
|
|
17
|
+
register: RegisterBindingTarget;
|
|
18
|
+
qualified: string;
|
|
19
|
+
ledger: boolean;
|
|
20
|
+
error: RegisterReporter;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkPosting(params: Raw, ledger: boolean, error: RegisterReporter): void {
|
|
24
|
+
const lineEntity = safeTrim(params.lineEntity);
|
|
25
|
+
const locked = stringList(params.lockedFields);
|
|
26
|
+
const lockedLines = stringList(params.lockedLineFields);
|
|
27
|
+
|
|
28
|
+
if (!Array.isArray(params.lockedFields) || locked.length === 0) {
|
|
29
|
+
error(["lockedFields"], "register-locked-fields", "'lockedFields' is required: the document columns frozen while posted (or [\"*\"] for the whole row).");
|
|
30
|
+
}
|
|
31
|
+
if (lineEntity) {
|
|
32
|
+
if (!safeTrim(params.lineField)) {
|
|
33
|
+
error(["lineField"], "register-line", "A line posting needs 'lineField' — the line's key to the document.");
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(params.lockedLineFields) || lockedLines.length === 0) {
|
|
36
|
+
error(["lockedLineFields"], "register-locked-fields", "A line posting needs 'lockedLineFields' (or [\"*\"]).");
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
if (hasKey(params, "lineField")) {
|
|
40
|
+
error(["lineField"], "register-line", "'lineField' needs a 'lineEntity'.");
|
|
41
|
+
}
|
|
42
|
+
if (hasKey(params, "lockedLineFields")) {
|
|
43
|
+
error(["lockedLineFields"], "register-line", "'lockedLineFields' is for a line posting — name the 'lineEntity'.");
|
|
44
|
+
}
|
|
45
|
+
if (hasKey(params, "headerMap")) {
|
|
46
|
+
error(["headerMap"], "register-header-map", "'headerMap' is for a binding that posts from lines; a document posting reads every value from the document already.");
|
|
47
|
+
}
|
|
48
|
+
if (hasKey(params, "groupLinesBy")) {
|
|
49
|
+
error(["groupLinesBy"], "register-group-lines", "'groupLinesBy' needs lines to group — name the 'lineEntity'.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (ledger) {
|
|
53
|
+
if (!lineEntity) {
|
|
54
|
+
error(["lineEntity"], "register-line", "An L column posts lines: 'lineEntity' and 'lineField' are required.");
|
|
55
|
+
}
|
|
56
|
+
for (const key of ["headerMap", "skipPostingWhenEmpty", "dateTrunc", "groupLinesBy"]) {
|
|
57
|
+
if (hasKey(params, key)) {
|
|
58
|
+
error([key], "register-ledger-only", `'${key}' is read by A columns only.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const dateField = safeTrim(params.dateField);
|
|
63
|
+
if (hasKey(params, "dateTrunc") && !dateField) {
|
|
64
|
+
error(["dateTrunc"], "register-date", "'dateTrunc' needs a 'dateField'.");
|
|
65
|
+
}
|
|
66
|
+
if (dateField && !locked.includes("*") && isPlainColumnRef(dateField) && !locked.includes(dateField)) {
|
|
67
|
+
error(["dateField"], "register-date-unlocked", `'${dateField}' must be in 'lockedFields': a posted document re-dated would leave what it posted under the old date.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkInfoBinding({ params, register, qualified, error }: BindingContext): void {
|
|
72
|
+
if (!safeTrim(register.recorder)) {
|
|
73
|
+
error(["register"], "register-info-independent", `"${qualified}" is an independent information register (no 'recorder'): its records are written by data operations, not by posting — unposting could never find them.`);
|
|
74
|
+
}
|
|
75
|
+
for (const key of ["direction", "sign"]) {
|
|
76
|
+
if (hasKey(params, key)) {
|
|
77
|
+
error([key], "register-info-direction", `'${key}' is refused on a binding to an information register, which records values rather than quantities.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function checkLedgerAccounts({ params, register, error }: BindingContext): void {
|
|
83
|
+
if (register.lineMode === "compound") {
|
|
84
|
+
if (hasKey(params, "accountField")) {
|
|
85
|
+
error(["accountField"], "register-accounts", "A compound register takes 'debitAccountField' and 'creditAccountField', not 'accountField'.");
|
|
86
|
+
}
|
|
87
|
+
if (!safeTrim(params.debitAccountField) || !safeTrim(params.creditAccountField)) {
|
|
88
|
+
error(["register"], "register-accounts", "A binding to a compound register names 'debitAccountField' and 'creditAccountField'.");
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!safeTrim(params.accountField)) {
|
|
93
|
+
error(["register"], "register-accounts", "A binding to a classic register names the line's 'accountField'.");
|
|
94
|
+
}
|
|
95
|
+
for (const key of ["debitAccountField", "creditAccountField"]) {
|
|
96
|
+
if (hasKey(params, key)) {
|
|
97
|
+
error([key], "register-accounts", `'${key}' belongs to a compound register; a classic one takes 'accountField'.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A ledger `map` entry takes a column per side where the register keeps one per side. */
|
|
103
|
+
function checkLedgerMapValue(ctx: BindingContext, key: string, value: unknown, resources: Raw): void {
|
|
104
|
+
const { register, error } = ctx;
|
|
105
|
+
const resource = resources[key];
|
|
106
|
+
const split = key in resources
|
|
107
|
+
? isRecord(resource) && registerResourceShape(resource) === "split"
|
|
108
|
+
: isRecord(register.sideDimensions) && key in register.sideDimensions;
|
|
109
|
+
if (split) {
|
|
110
|
+
if (!isRecord(value) || !safeTrim(value.debit) || !safeTrim(value.credit)) {
|
|
111
|
+
error(["map", key], "register-map-value", `"${key}" has a column per side; map both 'debit' and 'credit' to line columns.`);
|
|
112
|
+
}
|
|
113
|
+
} else if (!safeTrim(value)) {
|
|
114
|
+
error(["map", key], "register-map-value", `"${key}" is shared between the sides; map it to one line column.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Returns the names the binding maps, from `map` and `headerMap` alike. */
|
|
119
|
+
function checkMaps(ctx: BindingContext, dimensions: string[], resources: Raw): Set<string> {
|
|
120
|
+
const { params, qualified, ledger, register, error } = ctx;
|
|
121
|
+
const known = new Set([...dimensions, ...Object.keys(resources)]);
|
|
122
|
+
const mapped = new Set<string>();
|
|
123
|
+
for (const mapKey of ["map", "headerMap"] as const) {
|
|
124
|
+
const map = isRecord(params[mapKey]) ? params[mapKey] : null;
|
|
125
|
+
if (!map) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
for (const key of Object.keys(map)) {
|
|
129
|
+
if (mapped.has(key)) {
|
|
130
|
+
error([mapKey, key], "register-map-key", `"${key}" is in both 'map' and 'headerMap' — one value cannot come from two places.`);
|
|
131
|
+
}
|
|
132
|
+
mapped.add(key);
|
|
133
|
+
if (!known.has(key)) {
|
|
134
|
+
error([mapKey, key], "register-map-key", `"${key}" is neither a dimension nor a resource of ${qualified} (dimensions: ${dimensions.join(", ") || "none"}; resources: ${Object.keys(resources).join(", ") || "none"}).`);
|
|
135
|
+
} else if (ledger && mapKey === "map") {
|
|
136
|
+
checkLedgerMapValue(ctx, key, map[key], resources);
|
|
137
|
+
} else if (mapKey === "headerMap" && key in resources && register.kind !== "info") {
|
|
138
|
+
error([mapKey, key], "register-header-map", `A resource ("${key}") can't come from the document on a line posting — it would be added once per line. Only an information register's records take it.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return mapped;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkMapping(ctx: BindingContext): void {
|
|
146
|
+
const { params, register, qualified, error } = ctx;
|
|
147
|
+
const dimensions = stringList(register.dimensions);
|
|
148
|
+
const resources: Raw = isRecord(register.resources) ? register.resources : {};
|
|
149
|
+
const mapped = checkMaps(ctx, dimensions, resources);
|
|
150
|
+
|
|
151
|
+
const unmapped = Object.entries(resources)
|
|
152
|
+
.filter(([resource, def]) => !mapped.has(resource) && !(isRecord(def) && def.required === false))
|
|
153
|
+
.map(([resource]) => resource);
|
|
154
|
+
if (unmapped.length > 0) {
|
|
155
|
+
error(
|
|
156
|
+
[hasKey(params, "map") ? "map" : "register"],
|
|
157
|
+
"register-unmapped-resource",
|
|
158
|
+
`Every required resource of ${qualified} must be mapped — missing: ${unmapped.join(", ")}. A writer that omitted one would post a silent zero.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
stringList(params.skipPostingWhenEmpty).forEach((name, i) => {
|
|
162
|
+
if (!dimensions.includes(name)) {
|
|
163
|
+
error(["skipPostingWhenEmpty", i], "register-skip", `"${name}" isn't a dimension of ${qualified}; 'skipPostingWhenEmpty' names register dimensions.`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check an A / L column's `params` against the register it binds — paths
|
|
170
|
+
* relative to `params`. `register` is the resolved declaration, `undefined`
|
|
171
|
+
* when the module named declares no such register, `null` when that module
|
|
172
|
+
* can't be seen (every rule that needs the declaration then stands down).
|
|
173
|
+
*/
|
|
174
|
+
export function checkRegisterBinding(
|
|
175
|
+
columnType: "A" | "L",
|
|
176
|
+
rawParams: unknown,
|
|
177
|
+
register: RegisterBindingTarget | null | undefined,
|
|
178
|
+
options: { moduleCode?: string } = {},
|
|
179
|
+
): RegisterIssue[] {
|
|
180
|
+
const out: RegisterIssue[] = [];
|
|
181
|
+
const error = createIssueReporter(out, "entity", "error");
|
|
182
|
+
if (!isRecord(rawParams)) {
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
const params = rawParams;
|
|
186
|
+
const qualified = safeTrim(params.register);
|
|
187
|
+
if (!qualified) {
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const ledger = columnType === "L";
|
|
191
|
+
|
|
192
|
+
if (hasKey(params, "registries")) {
|
|
193
|
+
error(["registries"], "register-and-inline", "A column binds to a register OR restates one inline in 'registries' — keep one.");
|
|
194
|
+
}
|
|
195
|
+
if (!qualified.includes(".")) {
|
|
196
|
+
error(["register"], "register-unqualified", `A register is named as module.register, e.g. "${options.moduleCode ?? "module"}.${qualified}".`);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
const dot = qualified.indexOf(".");
|
|
200
|
+
const moduleCode = qualified.slice(0, dot);
|
|
201
|
+
const registerCode = qualified.slice(dot + 1);
|
|
202
|
+
|
|
203
|
+
// Posting inputs are validated even when the target register cannot be resolved.
|
|
204
|
+
checkPosting(params, ledger, error);
|
|
205
|
+
|
|
206
|
+
if (register === null) {
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
if (!register) {
|
|
210
|
+
error(["register"], "unknown-register", `No register "${qualified}" — ${moduleCode}/registers.json doesn't declare "${registerCode}".`);
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
const kind = register.kind;
|
|
214
|
+
if (ledger && kind !== "accounting") {
|
|
215
|
+
error(["register"], "register-kind", `An L column posts to an accounting register; "${qualified}" is a ${kind || "?"} register — use an A column.`);
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
if (!ledger && kind === "accounting") {
|
|
219
|
+
error(["register"], "register-kind", `An A column posts to a balance, turnover or info register; "${qualified}" is an accounting register — use an L column.`);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
const ctx: BindingContext = { params, register, qualified, ledger, error };
|
|
223
|
+
if (kind === "info") {
|
|
224
|
+
checkInfoBinding(ctx);
|
|
225
|
+
}
|
|
226
|
+
// No rule on a missing `direction`: the engine posts an increase by default
|
|
227
|
+
// (gl's VAT binding ships without one).
|
|
228
|
+
if (ledger) {
|
|
229
|
+
checkLedgerAccounts(ctx);
|
|
230
|
+
}
|
|
231
|
+
checkMapping(ctx);
|
|
232
|
+
return out;
|
|
233
|
+
}
|