@dforge-core/metadata 0.0.44 → 0.0.46
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 +32 -0
- package/dist/{actions-DvYTrCbP.d.ts → actions-D-0G_mqf.d.ts} +19 -1
- package/dist/dsl/index.d.ts +1 -1
- package/dist/dsl/index.js +13 -1
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +51 -3
- package/dist/index.js +130 -36
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/entity.schema.json +1 -1
- package/schemas/registers.schema.json +8 -3
- package/src/actions.ts +19 -0
- package/src/derive.ts +1 -0
- package/src/dsl/builtins.ts +13 -1
- package/src/field-types.ts +1 -0
- package/src/index.ts +9 -1
- package/src/registers/check-declaration.ts +39 -34
- package/src/registers/columns.ts +114 -0
- package/src/registers/entities.ts +27 -6
- package/src/registers/index.ts +3 -1
- package/src/registers/read.ts +1 -0
- package/src/registers/types.ts +2 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dforge-core/metadata",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.46",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "dForge",
|
|
@@ -428,7 +428,7 @@
|
|
|
428
428
|
"fieldTypeCd": {
|
|
429
429
|
"type": "string",
|
|
430
430
|
"enum": [
|
|
431
|
-
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent",
|
|
431
|
+
"text", "avatar", "email", "phone", "url", "textarea", "number", "currency", "percent", "filesize",
|
|
432
432
|
"checkbox", "date", "datetime", "time",
|
|
433
433
|
"dropdown", "flags", "tags", "json", "hidden", "color", "image", "file",
|
|
434
434
|
"markdown", "richtext", "lookup", "user", "grid", "list", "entitylink", "code"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"account": false,
|
|
25
25
|
"debitAccount": false, "creditAccount": false,
|
|
26
26
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
27
|
-
"sideDimensions": false, "analyticTotals": false
|
|
27
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false
|
|
28
28
|
},
|
|
29
29
|
"description": "A balance register keeps state, so it needs the entity that holds it. It dates its movements on the journal row under 'journalDate'; 'period' belongs to an information register, whose records ARE the dated thing. Its writer is recorded on the journal row, which is what 'recorder' names for an information register that has no journal."
|
|
30
30
|
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"account": false,
|
|
42
42
|
"debitAccount": false, "creditAccount": false,
|
|
43
43
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
44
|
-
"sideDimensions": false, "analyticTotals": false,
|
|
44
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false,
|
|
45
45
|
"allowNegative": false,
|
|
46
46
|
"autoCreate": false
|
|
47
47
|
},
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"account": false,
|
|
61
61
|
"debitAccount": false, "creditAccount": false,
|
|
62
62
|
"debitAnalyticFields": false, "creditAnalyticFields": false,
|
|
63
|
-
"sideDimensions": false, "analyticTotals": false,
|
|
63
|
+
"sideDimensions": false, "analyticTotals": false, "importedKeyField": false,
|
|
64
64
|
"totals": false,
|
|
65
65
|
"allowNegative": false,
|
|
66
66
|
"autoCreate": false
|
|
@@ -375,6 +375,11 @@
|
|
|
375
375
|
"items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
|
|
376
376
|
"description": "Analytic slot columns for the CREDITED account, in slot order and capped independently of the debit side. Only valid on a compound register."
|
|
377
377
|
},
|
|
378
|
+
"importedKeyField": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"pattern": "^[a-z_][a-z0-9_]*$",
|
|
381
|
+
"description": "Accounting only. The posting column holding the document's key on a posting LOADED beside a document posted in another system — one with no 'source' stamp. getAccountBalance / getAccountTurnover 'exclude' then leaves such postings out by that key as well as the stamped ones. Matches documents with a one-column key."
|
|
382
|
+
},
|
|
378
383
|
"sideDimensions": {
|
|
379
384
|
"type": "object",
|
|
380
385
|
"minProperties": 1,
|
package/src/actions.ts
CHANGED
|
@@ -60,6 +60,25 @@ export interface ActionDef {
|
|
|
60
60
|
* "Run in Background". A parameterless `isAsync` action is queued directly.
|
|
61
61
|
*/
|
|
62
62
|
isAsync?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Code of a print template of the action's entity. The run dialog renders it as a PDF beside the params when the
|
|
65
|
+
* action is opened on one record, so the user sees the document the action sends before it goes.
|
|
66
|
+
*/
|
|
67
|
+
previewTemplate?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Saved parameter sets the module ships for this action, keyed by set name. They install as shared sets owned by
|
|
70
|
+
* the module: read-only for users and refreshed on every install, and a user can save a set of their own under the
|
|
71
|
+
* same name, which wins for them. Each `values` key must be a param the action declares; a string starting with `=`
|
|
72
|
+
* is a formula. Needs the action to declare params.
|
|
73
|
+
*/
|
|
74
|
+
paramSets?: Record<string, ActionParamSetDef>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One shipped parameter set of an action. */
|
|
78
|
+
export interface ActionParamSetDef {
|
|
79
|
+
description?: string;
|
|
80
|
+
/** Value by param code. */
|
|
81
|
+
values: Record<string, unknown>;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/** A `ui/actions.json` file: action code → definition. */
|
package/src/derive.ts
CHANGED
package/src/dsl/builtins.ts
CHANGED
|
@@ -151,7 +151,7 @@ export const BUILTINS: Builtin[] = [
|
|
|
151
151
|
name: "sendEmail",
|
|
152
152
|
signature: "sendEmail(to, subjectOrTemplate, dataOrBody?, attachments?)",
|
|
153
153
|
returns: "void",
|
|
154
|
-
doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>…</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code — so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL).
|
|
154
|
+
doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>…</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code — so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()`, `attachText()` or `attachBase64()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL). A `printPdf()` file can be attached only by the user who generated it (or a workspace admin) and not in a scheduled job; a file built with `attachText()` / `attachBase64()` has no owner and works everywhere, a scheduled job included. At most 5 files and 10 MB in all per email (`Email:MaxAttachments`, `Email:MaxAttachmentBytes`). A file the sender may not attach fails the action.",
|
|
155
155
|
},
|
|
156
156
|
|
|
157
157
|
// External calls
|
|
@@ -206,6 +206,18 @@ export const BUILTINS: Builtin[] = [
|
|
|
206
206
|
returns: "string",
|
|
207
207
|
doc: "`downloadText()` for bytes: decodes `base64` and hands it to the user as `fileName` (`application/octet-stream` unless `mimeType` says otherwise). Returns the signed download URL.",
|
|
208
208
|
},
|
|
209
|
+
{
|
|
210
|
+
name: "attachText",
|
|
211
|
+
signature: "attachText(content, fileName, encoding?, mimeType?)",
|
|
212
|
+
returns: "object",
|
|
213
|
+
doc: "Build a text file to attach to an email: encodes `content` in `encoding` (`utf-8` by default) and returns a descriptor for `sendEmail()`'s fourth argument, e.g. `sendEmail(to, 'Interview', body, attachText(ics, 'invite.ics', 'utf-8', 'text/calendar'))`. The file keeps `mimeType` (`text/plain` by default). It is the script's own output, so no user's rights are involved and it works in a scheduled job. Only the emails of this run can name it. Max 10 MB per file and 10 files per run, shared with `downloadText()` / `downloadBase64()`.",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "attachBase64",
|
|
217
|
+
signature: "attachBase64(base64, fileName, mimeType?)",
|
|
218
|
+
returns: "object",
|
|
219
|
+
doc: "`attachText()` for bytes given as base64 (`application/octet-stream` unless `mimeType` says otherwise).",
|
|
220
|
+
},
|
|
209
221
|
{
|
|
210
222
|
name: "getSetting",
|
|
211
223
|
signature: "getSetting(settingCd)",
|
package/src/field-types.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const fieldTypes = [
|
|
|
20
20
|
{ cd: "number", name: "Number", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: null, useStringInFilter: false },
|
|
21
21
|
{ cd: "currency", name: "Currency", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: 2, useStringInFilter: false },
|
|
22
22
|
{ cd: "percent", name: "Percent", columnType: "D", baseDatatypeCd: "number", supportedAgg: "CAM", maxLen: null, precision: 2, useStringInFilter: false },
|
|
23
|
+
{ cd: "filesize", name: "File Size", columnType: "D", baseDatatypeCd: "number", supportedAgg: "SCAM", maxLen: null, precision: null, useStringInFilter: false },
|
|
23
24
|
{ cd: "checkbox", name: "Checkbox", columnType: "D", baseDatatypeCd: "bool", supportedAgg: "C", maxLen: null, precision: null, useStringInFilter: false },
|
|
24
25
|
{ cd: "date", name: "Date", columnType: "D", baseDatatypeCd: "date", supportedAgg: "CM", maxLen: null, precision: null, useStringInFilter: false },
|
|
25
26
|
{ cd: "datetime", name: "Date & Time", columnType: "D", baseDatatypeCd: "timestamp", supportedAgg: "CM", maxLen: null, precision: null, useStringInFilter: false },
|
package/src/index.ts
CHANGED
|
@@ -150,6 +150,10 @@ export {
|
|
|
150
150
|
registerLineMode,
|
|
151
151
|
registerRefusedProps,
|
|
152
152
|
registerRequiredProps,
|
|
153
|
+
REGISTER_COLUMN_SLOTS,
|
|
154
|
+
registerColumnSlot,
|
|
155
|
+
registerColumnHost,
|
|
156
|
+
isNumericDatatype,
|
|
153
157
|
checkRegister,
|
|
154
158
|
checkRegisters,
|
|
155
159
|
checkRegisterBinding,
|
|
@@ -166,6 +170,10 @@ export {
|
|
|
166
170
|
type RegisterIssue,
|
|
167
171
|
type RegisterEntityInfo,
|
|
168
172
|
type RegisterEntityLookup,
|
|
173
|
+
type RegisterHosts,
|
|
174
|
+
type RegisterColumnSlot,
|
|
175
|
+
type RegisterColumnHost,
|
|
176
|
+
type ResolvedColumnSlot,
|
|
169
177
|
} from "./registers";
|
|
170
178
|
export type { JobsFile, JobDef } from "./jobs";
|
|
171
179
|
export type { TriggersFile, TriggerDef, EntityEvent } from "./triggers";
|
|
@@ -185,7 +193,7 @@ export type {
|
|
|
185
193
|
TabGroupSection,
|
|
186
194
|
} from "./card-layouts";
|
|
187
195
|
export type { SeedDataFile, SeedDataRepeat } from "./seed-data";
|
|
188
|
-
export type { ActionsFile, ActionDef, ActionExecutionMode } from "./actions";
|
|
196
|
+
export type { ActionsFile, ActionDef, ActionParamSetDef, ActionExecutionMode } from "./actions";
|
|
189
197
|
|
|
190
198
|
// Flag catalog — the only letters valid on an authored column, at field level and
|
|
191
199
|
// on a per-view override alike (both `flags` patterns in entity.schema.json are
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Every rule FAILS OPEN: what cannot be resolved is not reported.
|
|
3
3
|
|
|
4
4
|
import { createEntityChecks } from "./entities";
|
|
5
|
-
import type { EntityChecks, RegisterEntityLookup, ResolvedRegisterEntity } from "./entities";
|
|
5
|
+
import type { EntityChecks, RegisterEntityLookup, RegisterHosts, ResolvedRegisterEntity } from "./entities";
|
|
6
6
|
import { createIssueReporter } from "./issues";
|
|
7
7
|
import type { IssuePath, RegisterIssue, RegisterReporter } from "./issues";
|
|
8
8
|
import {
|
|
@@ -24,9 +24,7 @@ interface Context extends EntityChecks {
|
|
|
24
24
|
error: RegisterReporter;
|
|
25
25
|
warning: RegisterReporter;
|
|
26
26
|
journalCode?: string;
|
|
27
|
-
|
|
28
|
-
carrierCode?: string;
|
|
29
|
-
carrier: ResolvedRegisterEntity;
|
|
27
|
+
hosts: RegisterHosts;
|
|
30
28
|
dimensions: string[];
|
|
31
29
|
sides: Raw;
|
|
32
30
|
resources: ReturnType<typeof registerResources>;
|
|
@@ -76,7 +74,8 @@ function resolveEntities(
|
|
|
76
74
|
def: Raw,
|
|
77
75
|
kind: RegisterKind,
|
|
78
76
|
checks: EntityChecks,
|
|
79
|
-
|
|
77
|
+
hosts: RegisterHosts,
|
|
78
|
+
): Pick<Context, "journalCode"> {
|
|
80
79
|
const { entityAt, columnAt } = checks;
|
|
81
80
|
const entityCode = safeTrim(def.entity);
|
|
82
81
|
const journalCode = safeTrim(def.journal);
|
|
@@ -87,15 +86,20 @@ function resolveEntities(
|
|
|
87
86
|
entityAt(["chart"], safeTrim(def.chart), "Chart");
|
|
88
87
|
}
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
const carrier = carriedByEntity ? entity : journal;
|
|
90
|
+
hosts.entity = { entity, code: entityCode };
|
|
91
|
+
hosts.journal = { entity: journal, code: journalCode };
|
|
92
|
+
hosts.carrier = { entity: carrier, code: registerCarrier(def) };
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
columnAt(["journalDirection"], def.journalDirection);
|
|
95
|
+
columnAt(["journalDate"], def.journalDate);
|
|
96
|
+
columnAt(["period"], def.period);
|
|
97
|
+
columnAt(["recorder"], def.recorder);
|
|
98
|
+
|
|
99
|
+
return { journalCode };
|
|
96
100
|
}
|
|
97
101
|
|
|
98
|
-
function checkDimensions({ def, dimensions, sides,
|
|
102
|
+
function checkDimensions({ def, dimensions, sides, columnAt, error }: Context): void {
|
|
99
103
|
if (!Array.isArray(def.dimensions) || dimensions.length === 0) {
|
|
100
104
|
error(["dimensions"], "dimensions", "A register needs at least one dimension.");
|
|
101
105
|
}
|
|
@@ -109,7 +113,7 @@ function checkDimensions({ def, dimensions, sides, carrier, carrierCode, columnA
|
|
|
109
113
|
dimensions.forEach((name, i) => {
|
|
110
114
|
// A two-sided dimension lives in its side columns, not under its own name.
|
|
111
115
|
if (!(name in sides)) {
|
|
112
|
-
columnAt(["dimensions", i],
|
|
116
|
+
columnAt(["dimensions", i], name);
|
|
113
117
|
}
|
|
114
118
|
});
|
|
115
119
|
}
|
|
@@ -142,7 +146,7 @@ function checkResourceShape(ctx: Context, name: string, resource: Raw, path: (ke
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
function checkResources(ctx: Context): void {
|
|
145
|
-
const { def, kind, resources, dimensions,
|
|
149
|
+
const { def, kind, resources, dimensions, columnAt, error } = ctx;
|
|
146
150
|
if (!isRecord(def.resources) || resources.length === 0) {
|
|
147
151
|
error(["resources"], "resources", "A register needs at least one resource.");
|
|
148
152
|
}
|
|
@@ -162,7 +166,7 @@ function checkResources(ctx: Context): void {
|
|
|
162
166
|
if (!column) {
|
|
163
167
|
continue;
|
|
164
168
|
}
|
|
165
|
-
columnAt(path(key),
|
|
169
|
+
columnAt(path(key), column);
|
|
166
170
|
const other = storage.get(column);
|
|
167
171
|
if (other && other !== name) {
|
|
168
172
|
error(path(key), "resource-column-shared", `Resources '${other}' and '${name}' both store into '${column}' — posting would add both into one column.`);
|
|
@@ -180,11 +184,11 @@ function checkResources(ctx: Context): void {
|
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
function checkAnalyticSlotColumns(ctx: Context, key: string, columns: string[]): void {
|
|
183
|
-
columns.forEach((column, i) => ctx.columnAt([key, i],
|
|
187
|
+
columns.forEach((column, i) => ctx.columnAt([key, i], column));
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
function checkSideDimensions(ctx: Context): void {
|
|
187
|
-
const { dimensions, sides,
|
|
191
|
+
const { dimensions, sides, columnAt, error } = ctx;
|
|
188
192
|
for (const [name, side] of Object.entries(sides)) {
|
|
189
193
|
const base: IssuePath = ["sideDimensions", name];
|
|
190
194
|
if (!dimensions.includes(name)) {
|
|
@@ -199,14 +203,14 @@ function checkSideDimensions(ctx: Context): void {
|
|
|
199
203
|
same: [...base, "creditField"],
|
|
200
204
|
sameMessage: `Two-sided dimension '${name}': the two columns must differ.`,
|
|
201
205
|
});
|
|
202
|
-
columnAt([...base, "debitField"],
|
|
203
|
-
columnAt([...base, "creditField"],
|
|
206
|
+
columnAt([...base, "debitField"], debitField);
|
|
207
|
+
columnAt([...base, "creditField"], creditField);
|
|
204
208
|
}
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
/** Returns whether the register declares analytic slots. */
|
|
208
212
|
function checkCompoundAccounts(ctx: Context): boolean {
|
|
209
|
-
const { def,
|
|
213
|
+
const { def, columnAt, error } = ctx;
|
|
210
214
|
refuseKeys(def, ["account", "analyticFields"], error, "line-mode", (key) => `'${key}' belongs to a classic register; a compound one names 'debitAccount' and 'creditAccount'.`);
|
|
211
215
|
const debit = safeTrim(def.debitAccount);
|
|
212
216
|
const credit = safeTrim(def.creditAccount);
|
|
@@ -216,8 +220,8 @@ function checkCompoundAccounts(ctx: Context): boolean {
|
|
|
216
220
|
same: ["creditAccount"],
|
|
217
221
|
sameMessage: "'debitAccount' and 'creditAccount' must differ — one column would record no correspondence.",
|
|
218
222
|
});
|
|
219
|
-
columnAt(["debitAccount"],
|
|
220
|
-
columnAt(["creditAccount"],
|
|
223
|
+
columnAt(["debitAccount"], debit);
|
|
224
|
+
columnAt(["creditAccount"], credit);
|
|
221
225
|
|
|
222
226
|
const debitSlots = stringList(def.debitAnalyticFields);
|
|
223
227
|
const creditSlots = stringList(def.creditAnalyticFields);
|
|
@@ -237,12 +241,12 @@ function checkCompoundAccounts(ctx: Context): boolean {
|
|
|
237
241
|
|
|
238
242
|
/** Returns whether the register declares analytic slots. */
|
|
239
243
|
function checkClassicAccounts(ctx: Context): boolean {
|
|
240
|
-
const { def,
|
|
244
|
+
const { def, columnAt, error } = ctx;
|
|
241
245
|
refuseKeys(def, ["debitAccount", "creditAccount", "debitAnalyticFields", "creditAnalyticFields", "sideDimensions"], error, "line-mode", (key) => `'${key}' belongs to a compound register.`);
|
|
242
246
|
if (!safeTrim(def.account)) {
|
|
243
247
|
error(["account"], "accounts", "A classic register names the posting's account column ('account').");
|
|
244
248
|
}
|
|
245
|
-
columnAt(["account"],
|
|
249
|
+
columnAt(["account"], safeTrim(def.account));
|
|
246
250
|
const slots = stringList(def.analyticFields);
|
|
247
251
|
if (slots.length > MAX_ANALYTIC_SLOTS) {
|
|
248
252
|
error(["analyticFields"], "analytic-slots", `At most ${MAX_ANALYTIC_SLOTS} analytic slots.`);
|
|
@@ -252,7 +256,7 @@ function checkClassicAccounts(ctx: Context): boolean {
|
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
function checkComposition(ctx: Context, needsComposition: boolean): void {
|
|
255
|
-
const { def, entityAt, columnAt, error } = ctx;
|
|
259
|
+
const { def, entityAt, columnAt, hosts, error } = ctx;
|
|
256
260
|
const composition = isRecord(def.composition) ? def.composition : undefined;
|
|
257
261
|
if (needsComposition && !composition) {
|
|
258
262
|
error(["composition"], "composition", "Analytic slots need a 'composition' — where the chart says which analytic each account takes in which slot.");
|
|
@@ -269,9 +273,9 @@ function checkComposition(ctx: Context, needsComposition: boolean): void {
|
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
275
|
const code = safeTrim(composition.entity);
|
|
272
|
-
|
|
276
|
+
hosts.composition = { entity: entityAt(["composition", "entity"], code, "Composition entity"), code };
|
|
273
277
|
for (const key of ["account", "slot", "analytic"]) {
|
|
274
|
-
columnAt(["composition", key],
|
|
278
|
+
columnAt(["composition", key], composition[key]);
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
|
|
@@ -288,7 +292,7 @@ function checkAccounting(ctx: Context): void {
|
|
|
288
292
|
}
|
|
289
293
|
|
|
290
294
|
function checkTotals(ctx: Context): void {
|
|
291
|
-
const { def, kind, dimensions, resources, entityAt, columnAt, error } = ctx;
|
|
295
|
+
const { def, kind, dimensions, resources, entityAt, columnAt, hosts, error } = ctx;
|
|
292
296
|
const totals = isRecord(def.totals) ? def.totals : undefined;
|
|
293
297
|
if (!totals || kind === "info") {
|
|
294
298
|
return;
|
|
@@ -297,7 +301,7 @@ function checkTotals(ctx: Context): void {
|
|
|
297
301
|
if (!totalsCode) {
|
|
298
302
|
error(["totals", "entity"], "totals", "The totals cache needs its entity.");
|
|
299
303
|
}
|
|
300
|
-
|
|
304
|
+
hosts.totals = { entity: entityAt(["totals", "entity"], totalsCode, "Totals entity"), code: totalsCode };
|
|
301
305
|
const period = safeTrim(totals.period);
|
|
302
306
|
if (!period || !(REGISTER_TOTALS_PERIODS as readonly string[]).includes(period)) {
|
|
303
307
|
error(["totals", "period"], "totals", "The totals cache needs a period: day, month, quarter or year.");
|
|
@@ -305,12 +309,12 @@ function checkTotals(ctx: Context): void {
|
|
|
305
309
|
if (!safeTrim(totals.periodField)) {
|
|
306
310
|
error(["totals", "periodField"], "totals", "The totals cache needs 'periodField' — the column holding each period's first date.");
|
|
307
311
|
}
|
|
308
|
-
columnAt(["totals", "periodField"],
|
|
312
|
+
columnAt(["totals", "periodField"], safeTrim(totals.periodField));
|
|
309
313
|
if (kind === "accounting") {
|
|
310
314
|
if (!safeTrim(totals.accountField)) {
|
|
311
315
|
error(["totals", "accountField"], "totals", "An accounting register's cache needs 'accountField'.");
|
|
312
316
|
}
|
|
313
|
-
columnAt(["totals", "accountField"],
|
|
317
|
+
columnAt(["totals", "accountField"], safeTrim(totals.accountField));
|
|
314
318
|
stringList(totals.dimensions).forEach((name, i) => {
|
|
315
319
|
if (!dimensions.includes(name)) {
|
|
316
320
|
error(["totals", "dimensions", i], "totals", `Totals dimension '${name}' isn't a dimension of the register.`);
|
|
@@ -336,7 +340,7 @@ function checkTotals(ctx: Context): void {
|
|
|
336
340
|
if (!safeTrim(entry[key])) {
|
|
337
341
|
error(["totals", "resources", name], "totals", `Totals resource '${name}' needs '${key}'.`);
|
|
338
342
|
}
|
|
339
|
-
columnAt(["totals", "resources", name, key],
|
|
343
|
+
columnAt(["totals", "resources", name, key], safeTrim(entry[key]));
|
|
340
344
|
}
|
|
341
345
|
}
|
|
342
346
|
for (const name of Object.keys(cached)) {
|
|
@@ -379,10 +383,11 @@ export function checkRegister(raw: unknown, lookup?: RegisterEntityLookup): Regi
|
|
|
379
383
|
}
|
|
380
384
|
const kind = def.kind;
|
|
381
385
|
checkProperties({ def, kind, error });
|
|
382
|
-
const
|
|
386
|
+
const hosts: RegisterHosts = {};
|
|
387
|
+
const checks = createEntityChecks(lookup, error, warning, hosts, kind);
|
|
383
388
|
const ctx: Context = {
|
|
384
|
-
def, kind, error, warning, ...checks,
|
|
385
|
-
...resolveEntities(def, kind, checks),
|
|
389
|
+
def, kind, error, warning, hosts, ...checks,
|
|
390
|
+
...resolveEntities(def, kind, checks, hosts),
|
|
386
391
|
dimensions: registerDimensions(def),
|
|
387
392
|
sides: isRecord(def.sideDimensions) ? def.sideDimensions : {},
|
|
388
393
|
resources: registerResources(def),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Every place a declaration names a stored column, once: which entity the column
|
|
2
|
+
// is stored on, what to call it, and whether it must hold a number.
|
|
3
|
+
//
|
|
4
|
+
// `checkRegister` reads a column through this table, and an editor reads the same
|
|
5
|
+
// rows to offer the right entity's columns at a path — so a column slot added
|
|
6
|
+
// here is checked and pickable without touching either.
|
|
7
|
+
|
|
8
|
+
import type { IssuePath } from "./issues";
|
|
9
|
+
import { isRecord, registerCarrier, safeTrim } from "./read";
|
|
10
|
+
import type { Raw } from "./read";
|
|
11
|
+
import type { RegisterKind } from "./types";
|
|
12
|
+
|
|
13
|
+
/** The entity a column slot is stored on. `carrier` is `entity` on a balance / information register, `journal` otherwise. */
|
|
14
|
+
export type RegisterColumnHost = "entity" | "journal" | "carrier" | "totals" | "composition";
|
|
15
|
+
|
|
16
|
+
export interface RegisterColumnSlot {
|
|
17
|
+
/** Where in a declaration; `*` matches any key or index at that depth. */
|
|
18
|
+
path: readonly string[];
|
|
19
|
+
on: RegisterColumnHost;
|
|
20
|
+
/** What the column is called in a message; `{0}`, `{1}`… stand for the `*` matches in order. */
|
|
21
|
+
what: string;
|
|
22
|
+
/**
|
|
23
|
+
* The kinds of register for which the column is added up and so must store a
|
|
24
|
+
* number. An information register's resources are what a slice returns — a
|
|
25
|
+
* currency code as readily as a price — so it is not among them.
|
|
26
|
+
*/
|
|
27
|
+
numericFor?: readonly RegisterKind[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SUMMED: readonly RegisterKind[] = ["balance", "turnover", "accounting"];
|
|
31
|
+
|
|
32
|
+
const slot = (path: string, on: RegisterColumnHost, what: string, summed = false): RegisterColumnSlot => ({
|
|
33
|
+
path: path.split("."),
|
|
34
|
+
on,
|
|
35
|
+
what,
|
|
36
|
+
...(summed ? { numericFor: SUMMED } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const REGISTER_COLUMN_SLOTS: readonly RegisterColumnSlot[] = [
|
|
40
|
+
slot("journalDirection", "journal", "Journal direction column"),
|
|
41
|
+
slot("journalDate", "journal", "Journal date column"),
|
|
42
|
+
slot("period", "entity", "Period column"),
|
|
43
|
+
slot("recorder", "entity", "Recorder column"),
|
|
44
|
+
slot("dimensions.*", "carrier", "Dimension"),
|
|
45
|
+
slot("resources.*.field", "carrier", "Resource '{0}' column", true),
|
|
46
|
+
slot("resources.*.debitField", "carrier", "Resource '{0}' column", true),
|
|
47
|
+
slot("resources.*.creditField", "carrier", "Resource '{0}' column", true),
|
|
48
|
+
slot("account", "journal", "Account column"),
|
|
49
|
+
slot("debitAccount", "journal", "Debit account column"),
|
|
50
|
+
slot("creditAccount", "journal", "Credit account column"),
|
|
51
|
+
slot("analyticFields.*", "journal", "Analytic slot column"),
|
|
52
|
+
slot("debitAnalyticFields.*", "journal", "Analytic slot column"),
|
|
53
|
+
slot("creditAnalyticFields.*", "journal", "Analytic slot column"),
|
|
54
|
+
slot("sideDimensions.*.debitField", "journal", "Dimension '{0}' debit column"),
|
|
55
|
+
slot("sideDimensions.*.creditField", "journal", "Dimension '{0}' credit column"),
|
|
56
|
+
slot("composition.account", "composition", "Composition 'account' column"),
|
|
57
|
+
slot("composition.slot", "composition", "Composition 'slot' column"),
|
|
58
|
+
slot("composition.analytic", "composition", "Composition 'analytic' column"),
|
|
59
|
+
slot("totals.periodField", "totals", "Totals period column"),
|
|
60
|
+
slot("totals.accountField", "totals", "Totals account column"),
|
|
61
|
+
slot("totals.resources.*.inField", "totals", "Totals '{0}' inField", true),
|
|
62
|
+
slot("totals.resources.*.outField", "totals", "Totals '{0}' outField", true),
|
|
63
|
+
slot("totals.resources.*.debitField", "totals", "Totals '{0}' debitField", true),
|
|
64
|
+
slot("totals.resources.*.creditField", "totals", "Totals '{0}' creditField", true),
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export interface ResolvedColumnSlot {
|
|
68
|
+
on: RegisterColumnHost;
|
|
69
|
+
/** The column's name for a message, with the path's keys filled in. */
|
|
70
|
+
what: string;
|
|
71
|
+
numeric: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The slot a path inside one declaration names a column at; nothing for a path that isn't a column. `kind` is the register's, which decides whether the column must be numeric. */
|
|
75
|
+
export function registerColumnSlot(path: IssuePath, kind?: RegisterKind): ResolvedColumnSlot | undefined {
|
|
76
|
+
for (const candidate of REGISTER_COLUMN_SLOTS) {
|
|
77
|
+
if (candidate.path.length !== path.length) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const captured: string[] = [];
|
|
81
|
+
const matches = candidate.path.every((segment, i) => {
|
|
82
|
+
if (segment !== "*") {
|
|
83
|
+
return segment === path[i];
|
|
84
|
+
}
|
|
85
|
+
captured.push(String(path[i]));
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
if (matches) {
|
|
89
|
+
return { on: candidate.on, what: candidate.what.replace(/\{(\d+)\}/g, (_, n) => captured[Number(n)] ?? ""), numeric: !!kind && !!candidate.numericFor?.includes(kind) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The code of the entity a slot's host names in this declaration. */
|
|
96
|
+
export function registerColumnHost(def: Raw, on: RegisterColumnHost): string | undefined {
|
|
97
|
+
switch (on) {
|
|
98
|
+
case "entity":
|
|
99
|
+
return safeTrim(def.entity);
|
|
100
|
+
case "journal":
|
|
101
|
+
return safeTrim(def.journal);
|
|
102
|
+
case "carrier":
|
|
103
|
+
return registerCarrier(def);
|
|
104
|
+
case "totals":
|
|
105
|
+
return isRecord(def.totals) ? safeTrim(def.totals.entity) : undefined;
|
|
106
|
+
case "composition":
|
|
107
|
+
return isRecord(def.composition) ? safeTrim(def.composition.entity) : undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const NUMERIC_DATATYPE = /^(?:(?:numeric|decimal)(?:\s*\(\s*\d+(?:\s*,\s*\d+)?\s*\))?|int[248]?|integer|bigint|smallint|(?:big|small)?serial[248]?|float[48]?|double precision|real|money)$/i;
|
|
112
|
+
|
|
113
|
+
/** Whether a storage type (`numeric(18,2)`, `int8`) is a scalar number; the whole type must match, so `integer[]` is not. */
|
|
114
|
+
export const isNumericDatatype = (datatype: string): boolean => NUMERIC_DATATYPE.test(datatype.trim());
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Resolving the entities and columns a declaration names, through the caller's lookup.
|
|
2
2
|
|
|
3
|
+
import { isNumericDatatype, registerColumnSlot } from "./columns";
|
|
4
|
+
import type { RegisterColumnHost } from "./columns";
|
|
5
|
+
import type { RegisterKind } from "./types";
|
|
3
6
|
import { REGISTER_COLUMN_NAME } from "./types";
|
|
4
7
|
import type { IssuePath, RegisterReporter } from "./issues";
|
|
5
8
|
|
|
@@ -10,6 +13,8 @@ import type { IssuePath, RegisterReporter } from "./issues";
|
|
|
10
13
|
*/
|
|
11
14
|
export interface RegisterEntityInfo {
|
|
12
15
|
columns?: Iterable<string>;
|
|
16
|
+
/** Storage type per column (`numeric(18,2)`); a column missing here has no numeric rule run on it. */
|
|
17
|
+
datatypes?: ReadonlyMap<string, string>;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/**
|
|
@@ -22,17 +27,22 @@ export type RegisterEntityLookup = (code: string) => RegisterEntityInfo | null |
|
|
|
22
27
|
|
|
23
28
|
export type ResolvedRegisterEntity = RegisterEntityInfo | null | undefined;
|
|
24
29
|
|
|
30
|
+
/** The entities of a declaration resolved so far, by the slot host that names them. */
|
|
31
|
+
export type RegisterHosts = Partial<Record<RegisterColumnHost, { entity: ResolvedRegisterEntity; code: string | undefined }>>;
|
|
32
|
+
|
|
25
33
|
export interface EntityChecks {
|
|
26
34
|
/** Resolve `code`, reporting `unknown-entity` when the module is known and has no such entity. */
|
|
27
35
|
entityAt: (path: IssuePath, code: string | undefined, what: string) => ResolvedRegisterEntity;
|
|
28
|
-
/** Check a column
|
|
29
|
-
columnAt: (path: IssuePath,
|
|
36
|
+
/** Check the column named at `path` — a column slot, so the table says which entity, what to call it and whether it must be numeric. A non-string or blank column is skipped. */
|
|
37
|
+
columnAt: (path: IssuePath, column: unknown) => void;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export function createEntityChecks(
|
|
33
41
|
lookup: RegisterEntityLookup | undefined,
|
|
34
42
|
error: RegisterReporter,
|
|
35
43
|
warning: RegisterReporter,
|
|
44
|
+
hosts: RegisterHosts,
|
|
45
|
+
kind: RegisterKind,
|
|
36
46
|
): EntityChecks {
|
|
37
47
|
const resolved = new Map<string, ResolvedRegisterEntity>();
|
|
38
48
|
const columnSets = new WeakMap<RegisterEntityInfo, Set<string> | null>();
|
|
@@ -61,17 +71,28 @@ export function createEntityChecks(
|
|
|
61
71
|
return columnSets.get(entity) ?? null;
|
|
62
72
|
};
|
|
63
73
|
|
|
64
|
-
const columnAt: EntityChecks["columnAt"] = (path,
|
|
74
|
+
const columnAt: EntityChecks["columnAt"] = (path, column) => {
|
|
65
75
|
if (typeof column !== "string" || !column.trim()) {
|
|
66
76
|
return;
|
|
67
77
|
}
|
|
78
|
+
// A call site with no row in REGISTER_COLUMN_SLOTS fails open; the "every call site has a slot" test catches it.
|
|
79
|
+
const slot = registerColumnSlot(path, kind);
|
|
80
|
+
if (!slot) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
68
83
|
if (!REGISTER_COLUMN_NAME.test(column)) {
|
|
69
|
-
error(path, "column-name", `${what} '${column}' isn't a column name (lowercase letters, digits and '_').`);
|
|
84
|
+
error(path, "column-name", `${slot.what} '${column}' isn't a column name (lowercase letters, digits and '_').`);
|
|
70
85
|
return;
|
|
71
86
|
}
|
|
72
|
-
const
|
|
87
|
+
const host = hosts[slot.on];
|
|
88
|
+
const columns = columnsOf(host?.entity);
|
|
73
89
|
if (columns && !columns.has(column)) {
|
|
74
|
-
warning(path, "unknown-column", `${what} '${column}' isn't a stored column of ${
|
|
90
|
+
warning(path, "unknown-column", `${slot.what} '${column}' isn't a stored column of ${host?.code ? `'${host.code}'` : `the ${slot.on} entity`}.`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const datatype = host?.entity?.datatypes?.get(column);
|
|
94
|
+
if (slot.numeric && datatype && !isNumericDatatype(datatype)) {
|
|
95
|
+
warning(path, "column-not-numeric", `${slot.what} '${column}' is ${datatype}, not a number.`);
|
|
75
96
|
}
|
|
76
97
|
};
|
|
77
98
|
|
package/src/registers/index.ts
CHANGED
|
@@ -11,7 +11,9 @@ export {
|
|
|
11
11
|
registerRefusedProps, registerRequiredProps,
|
|
12
12
|
} from "./read";
|
|
13
13
|
export type { RegisterIssue } from "./issues";
|
|
14
|
-
export type { RegisterEntityInfo, RegisterEntityLookup } from "./entities";
|
|
14
|
+
export type { RegisterEntityInfo, RegisterEntityLookup, RegisterHosts } from "./entities";
|
|
15
15
|
export { checkRegister, checkRegisters } from "./check-declaration";
|
|
16
16
|
export { checkRegisterBinding, isPlainColumnRef } from "./check-binding";
|
|
17
17
|
export type { RegisterBindingTarget } from "./check-binding";
|
|
18
|
+
export { REGISTER_COLUMN_SLOTS, registerColumnSlot, registerColumnHost, isNumericDatatype } from "./columns";
|
|
19
|
+
export type { RegisterColumnSlot, RegisterColumnHost, ResolvedColumnSlot } from "./columns";
|
package/src/registers/read.ts
CHANGED
|
@@ -50,6 +50,7 @@ const INFO_PROPERTIES = ["period", "recorder", "recordChangesOnly"] as const;
|
|
|
50
50
|
const ACCOUNTING_PROPERTIES = [
|
|
51
51
|
"chart", "composition", "analyticFields", "lineMode", "account",
|
|
52
52
|
"debitAccount", "creditAccount", "debitAnalyticFields", "creditAnalyticFields", "sideDimensions",
|
|
53
|
+
"importedKeyField",
|
|
53
54
|
] as const;
|
|
54
55
|
const BALANCE_PROPERTIES = ["allowNegative", "autoCreate"] as const;
|
|
55
56
|
|
package/src/registers/types.ts
CHANGED
|
@@ -90,6 +90,8 @@ export interface RegisterDef {
|
|
|
90
90
|
dimensions: string[];
|
|
91
91
|
/** Two-sided dimensions of a compound register, with the column per side. */
|
|
92
92
|
sideDimensions?: Record<string, { debitField: string; creditField: string }>;
|
|
93
|
+
/** Accounting only: the posting column a loaded posting keeps its document's key in, so `exclude` can leave it out. */
|
|
94
|
+
importedKeyField?: string;
|
|
93
95
|
resources: Record<string, RegisterResourceDef>;
|
|
94
96
|
totals?: RegisterTotalsDef;
|
|
95
97
|
/** `balance`: may the state go below zero (default false). */
|