@dforge-core/metadata 0.0.34 → 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.
@@ -114,7 +114,24 @@
114
114
  },
115
115
  "indexes": {
116
116
  "type": "object",
117
- "description": "Database index definitions"
117
+ "description": "Database indexes, keyed by index name (the Postgres index name). A register's journal and state tables need none declared: the platform builds the indexes its own reads and writes use.",
118
+ "additionalProperties": {
119
+ "type": "object",
120
+ "required": ["fields"],
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "fields": {
124
+ "type": "array",
125
+ "minItems": 1,
126
+ "items": { "type": "string" },
127
+ "description": "Indexed columns, in order. Install refuses any key other than fields and using - \"columns\", which a unique constraint accepts, included."
128
+ },
129
+ "using": {
130
+ "enum": ["btree", "hash", "gin", "gist", "brin"],
131
+ "description": "Index method. Omitted, btree."
132
+ }
133
+ }
134
+ }
118
135
  },
119
136
  "numberSequence": {
120
137
  "$ref": "#/$defs/numberSequence",
@@ -242,9 +259,11 @@
242
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." },
243
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." },
244
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'." },
245
- "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)." },
246
- "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." },
247
- "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>'." },
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])\"." },
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
+ "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
+ "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')." },
266
+ "dateTrunc": { "type": "string", "enum": ["day", "month", "year"], "description": "Takes the date 'dateField' supplies back to the start of its day, month or year before the register stamps it — for a document that carries a time while the register's records sit on whole days. The calendar is the value's as stored; no time zone is applied. Needs 'dateField'. Accumulation (A) columns only." },
248
267
  "skipPostingWhenEmpty": { "type": "array", "items": { "type": "string" }, "description": "THE EXCEPTION, not the normal way to model this — read the guide before declaring it. Register dimensions (by the register's names) whose empty value means THIS POSTING records nothing: the line, or the document when the binding posts from the document. One posting is skipped, never the register and never the document. When that is all the document would have recorded it is left unposted to this register, and the flag is per binding, so its other registers post as usual. An empty dimension NOT listed refuses the posting, naming the row and the dimension. It leaves a document fully posted while some of its lines produced no movement, which usually means the document is modelling two kinds of line in one table: since 'lineEntity' is declared per BINDING, prefer giving each register its own line table (goods lines post to stock, service lines do not) so that every line of each table posts. Declare this only when the distinction is not structural but lives in the data — a batch or serial number filled on some rows and not others — so there is no second table to put those rows in. Accumulation (A) columns only. Formerly skipWhenNull: a package using the old name is refused at install, while a tenant's already-stored params keep being read under it." },
249
268
  "periodEntity": { "type": "string", "description": "Period entity this registry closes against — one of this module's, or 'module.entity' from a dependency." },
250
269
  "periodDateField": { "type": "string", "description": "Header date column that resolves the period." },
@@ -253,6 +272,8 @@
253
272
  },
254
273
  "dependencies": {
255
274
  "headerMap": ["lineEntity"],
275
+ "groupLinesBy": ["lineEntity"],
276
+ "dateTrunc": ["dateField"],
256
277
  "lineEntity": ["lineField", "lockedLineFields"],
257
278
  "lineField": ["lineEntity"],
258
279
  "lockedLineFields": ["lineEntity"],
@@ -19,7 +19,7 @@
19
19
  "then": {
20
20
  "required": ["entity"],
21
21
  "properties": {
22
- "period": false, "recorder": false,
22
+ "period": false, "recorder": false, "recordChangesOnly": false,
23
23
  "chart": false, "composition": false, "analyticFields": false, "lineMode": false,
24
24
  "account": false,
25
25
  "debitAccount": false, "creditAccount": false,
@@ -36,7 +36,7 @@
36
36
  "properties": {
37
37
  "entity": false,
38
38
  "period": false,
39
- "recorder": false,
39
+ "recorder": false, "recordChangesOnly": false,
40
40
  "chart": false, "composition": false, "analyticFields": false, "lineMode": false,
41
41
  "account": false,
42
42
  "debitAccount": false, "creditAccount": false,
@@ -76,7 +76,7 @@
76
76
  "properties": {
77
77
  "entity": false,
78
78
  "period": false,
79
- "recorder": false,
79
+ "recorder": false, "recordChangesOnly": false,
80
80
  "journalDirection": false,
81
81
  "allowNegative": false,
82
82
  "autoCreate": false
@@ -106,7 +106,7 @@
106
106
  }
107
107
  }
108
108
  ],
109
- "description": "An accounting register keys its postings by an account, not by a dimension tuple it declares once. What analytics a posting carries, and what each one MEANS, is a property of the account: measured on a production chart of 468 accounts, the analytic count runs 0 to 4 and slot 2 is Nomenclature on one account and Contracts on another. So the chart and the per-account composition are part of the declaration, and 'entity' \u2014 one row per dimension tuple \u2014 has nothing to name."
109
+ "description": "An accounting register keys its postings by an account, not by a dimension tuple it declares once. What analytics a posting carries, and what each one MEANS, is a property of the account: the analytic count differs from account to account, and slot 2 may be Nomenclature on one account and Contracts on another. So the chart and the per-account composition are part of the declaration, and 'entity' \u2014 one row per dimension tuple \u2014 has nothing to name."
110
110
  }
111
111
  }
112
112
  ,
@@ -146,6 +146,11 @@
146
146
  "enum": ["balance", "turnover", "info", "accounting"],
147
147
  "description": "Register kind. 'balance' keeps a running state per dimension tuple (1C регистр накопления / остатки); 'turnover' keeps none and answers only for intervals (1C регистр накопления / обороты), the majority of accumulation registers in practice; 'info' holds values that change over time, or a plain keyed lookup when it declares no period (1C регистр сведений). 'accounting' keys its postings by an account whose analytics the CHART declares, per account, because what an analytic slot means depends on the account it is on. Both of its line modes are posted and read."
148
148
  },
149
+ "label": {
150
+ "type": "string",
151
+ "maxLength": 200,
152
+ "description": "Display name, shown wherever the register is offered beside entities (the query builder's From picker). Localized under the translation files' registers.<code>.label. The register code when omitted."
153
+ },
149
154
  "description": {
150
155
  "type": "string",
151
156
  "maxLength": 500,
@@ -192,12 +197,12 @@
192
197
  "accountField": {
193
198
  "type": "string",
194
199
  "pattern": "^[a-z_][a-z0-9_]*$",
195
- "description": "Column on the totals entity holding the account a cached row belongs to. Required on an 'accounting' register and refused on every other kind: an accounting register is read per account, so that is what its cache is keyed by, and a compound posting names TWO accounts and folds into two cached rows — one per side, each taking that side's column of every two-sided dimension — so which column holds the one cannot be derived. The analytics are NOT in the key: measured on the base this is ported from, 24.3M postings carry 99.8M analytic rows, so the analytics are where the cardinality is and a cache keyed by them would be about as large as what it summarises. A read that narrows by an analytic goes to the postings, as a read of a part-period goes to them for its edges."
200
+ "description": "Column on the totals entity holding the account a cached row belongs to. Required on an 'accounting' register and refused on every other kind: an accounting register is read per account, so that is what its cache is keyed by, and a compound posting names TWO accounts and folds into two cached rows — one per side, each taking that side's column of every two-sided dimension — so which column holds the one cannot be derived. The analytics are NOT in the key: a posting typically carries several analytic values, so the analytics are where the cardinality is and a cache keyed by them would be about as large as what it summarises. A read that narrows by an analytic goes to the postings, as a read of a part-period goes to them for its edges."
196
201
  },
197
202
  "dimensions": {
198
203
  "type": "array",
199
204
  "items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
200
- "description": "Which of the register's dimensions the cache is grouped by; omitted, it is all of them. Only on an 'accounting' register. What it is for is a dimension a posting may legitimately leave empty — a two-sided one, where the measured original fills a currency only for the accounts that keep one. A cached row's key holds no NULL, so install refuses a cached dimension whose posting column is nullable, naming the two ways out: make the column mandatory, or leave the dimension out here and let the cache sum across it. A read that narrows by an uncached dimension goes to the postings."
205
+ "description": "Which of the register's dimensions the cache is grouped by; omitted, it is all of them. Only on an 'accounting' register. What it is for is a dimension a posting may legitimately leave empty — a two-sided one, such as a currency filled only for the accounts that keep one. A cached row's key holds no NULL, so install refuses a cached dimension whose posting column is nullable, naming the two ways out: make the column mandatory, or leave the dimension out here and let the cache sum across it. A read that narrows by an uncached dimension goes to the postings."
201
206
  },
202
207
  "resources": {
203
208
  "type": "object",
@@ -239,12 +244,17 @@
239
244
  "period": {
240
245
  "type": "string",
241
246
  "pattern": "^[a-z_][a-z0-9_]*$",
242
- "description": "Column on an information register's entity holding the moment each record takes effect. Declaring it makes the register periodic, and 'the value as of a date' becomes the last record at or before it. Omit it for a plain keyed lookup with no history \u2014 121 of the 285 information registers in the 1C base measured for this design are of that shape, so it is the common case rather than a degenerate one. Only valid on 'info'."
247
+ "description": "Column on an information register's entity holding the moment each record takes effect. Declaring it makes the register periodic, and 'the value as of a date' becomes the last record at or before it. Omit it for a plain keyed lookup with no history \u2014 many information registers are of that shape, so it is a common case rather than a degenerate one. Only valid on 'info'."
243
248
  },
244
249
  "recorder": {
245
250
  "type": "string",
246
251
  "pattern": "^[a-z_][a-z0-9_]*$",
247
- "description": "Column on an information register's entity where the platform stamps which document wrote each record \u2014 the same jsonb 'source' shape a journal row carries, holding { entity, pk, line, postedBy }. Declaring it makes the register SUBORDINATE to a document: an A column binds to it, posting writes its records, unposting deletes them, and the register's rows stop being something a user maintains. Omit it for an independent register, whose records are written by ordinary means. The split is not a nuance: measured on the 1C base this design is ported from, 115 of the 285 information registers are subordinate and hold 15.7M of the 20.8M rows, and no register is ever both. Only valid on 'info'."
252
+ "description": "Column on an information register's entity where the platform stamps which document wrote each record \u2014 the same jsonb 'source' shape a journal row carries, holding { entity, pk, line, postedBy }. Declaring it makes the register SUBORDINATE to a document: an A column binds to it, posting writes its records, unposting deletes them, and the register's rows stop being something a user maintains. Omit it for an independent register, whose records are written by ordinary means. The split is not a nuance: subordinate registers tend to hold most of the rows, and no register is ever both. Only valid on 'info'."
253
+ },
254
+ "recordChangesOnly": {
255
+ "type": "boolean",
256
+ "default": false,
257
+ "description": "Posting skips a record whose resources all equal the slice in effect on its period for the same dimensions: a document setting a value that is already so writes nothing, and still posts. A property of the register rather than of any writer, because the answer is in the register. Only valid on an 'info' register that declares a 'recorder'. A resource's 'absentAs' says what it compares as before anything is in effect; without it the first record is always written. 1C posting handlers commonly do the same, comparing the new value with the slice and an absent one as zero."
248
258
  },
249
259
  "chart": {
250
260
  "type": "string",
@@ -268,7 +278,7 @@
268
278
  "slot": {
269
279
  "type": "string",
270
280
  "pattern": "^[a-z_][a-z0-9_]*$",
271
- "description": "Column holding the slot number: WHICH of the posting's analytic columns this analytic is written into. Positional storage is what the measured base's totals tables use, and it is available because the fill is dense \u2014 an unset analytic is written explicitly rather than omitted, so the declared count and the written count matched exactly across 348,227 analytic sets."
281
+ "description": "Column holding the slot number: WHICH of the posting's analytic columns this analytic is written into. Positional storage is what 1C's totals tables use, and it is available because the fill is dense \u2014 an unset analytic is written explicitly rather than omitted, so the declared count and the written count match exactly."
272
282
  },
273
283
  "analytic": {
274
284
  "type": "string",
@@ -313,7 +323,7 @@
313
323
  "minProperties": 1,
314
324
  "maxProperties": 8,
315
325
  "propertyNames": { "pattern": "^[a-z_][a-z0-9_]*$" },
316
- "description": "Dimensions that are TWO-SIDED, with the posting column for each side. Measured on the base this design is ported from, one accounting register keeps Организация shared and splits Валюта and НалоговоеНазначение per side, while a second register in the same base splits one of its two \u2014 so which dimensions split is a property of the declaration and not of the mode. A dimension left in 'dimensions' stays shared: one column for the whole row. Only valid on a compound register.",
326
+ "description": "Dimensions that are TWO-SIDED, with the posting column for each side. One accounting register may keep the organisation shared and split the currency and tax purpose per side, while another splits only one of them \u2014 so which dimensions split is a property of the declaration and not of the mode. A dimension left in 'dimensions' stays shared: one column for the whole row. Only valid on a compound register.",
317
327
  "additionalProperties": {
318
328
  "type": "object",
319
329
  "required": ["debitField", "creditField"],
@@ -338,7 +348,7 @@
338
348
  "maxItems": 8,
339
349
  "uniqueItems": true,
340
350
  "items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
341
- "description": "The posting entity's analytic slot columns, in slot order: slot 1 is written into the first, slot 2 into the second. Each is a jsonb entity link, because an analytic's value is a UNION \u2014 one kind in the measured base resolves to three different document types in the same column, which a 'link' naming one entity cannot type. The length of this array is how many analytics a posting can carry; an account declaring more than that is refused when it posts, not at install, because the platform does not parse a check constraint's expression. Only valid on 'accounting'."
351
+ "description": "The posting entity's analytic slot columns, in slot order: slot 1 is written into the first, slot 2 into the second. Each is a jsonb entity link, because an analytic's value is a UNION \u2014 one kind may resolve to several different document types in the same column, which a 'link' naming one entity cannot type. The length of this array is how many analytics a posting can carry; an account declaring more than that is refused when it posts, not at install, because the platform does not parse a check constraint's expression. Only valid on 'accounting'."
342
352
  },
343
353
  "lineMode": {
344
354
  "type": "string",
@@ -373,7 +383,7 @@
373
383
  "debitField": {
374
384
  "type": "string",
375
385
  "pattern": "^[a-z_][a-z0-9_]*$",
376
- "description": "On an accounting register, the posting column holding this resource's debit side. Declared with 'creditField' and instead of 'field'. Measured on the base this design is ported from, its postings carry one shared amount plus three split by side, and a second accounting register in the same base splits differently again \u2014 so splitting is a property of the resource rather than of the kind. WHAT THE PAIR MEANS DEPENDS ON 'lineMode', which is mandatory, so exactly one reading applies at a time. Under 'classic' a row is one account on one side, so these are where the amount goes when the row is a debit or a credit and exactly one carries a value. Under 'compound' they are the debited side's value and the credited side's value of one resource on one row, and both may carry one \u2014 a currency amount whose two sides are in different currencies is two different numbers, which is why the pair exists at all."
386
+ "description": "On an accounting register, the posting column holding this resource's debit side. Declared with 'creditField' and instead of 'field'. One register's postings may carry one shared amount plus several split by side, and another register split differently again \u2014 so splitting is a property of the resource rather than of the kind. WHAT THE PAIR MEANS DEPENDS ON 'lineMode', which is mandatory, so exactly one reading applies at a time. Under 'classic' a row is one account on one side, so these are where the amount goes when the row is a debit or a credit and exactly one carries a value. Under 'compound' they are the debited side's value and the credited side's value of one resource on one row, and both may carry one \u2014 a currency amount whose two sides are in different currencies is two different numbers, which is why the pair exists at all."
377
387
  },
378
388
  "creditField": {
379
389
  "type": "string",
@@ -385,6 +395,10 @@
385
395
  "pattern": "^[a-z_][a-z0-9_]*$",
386
396
  "description": "Column this resource accumulates into — on the state entity for a 'balance' register, on the journal for a 'turnover' one. On a COMPOUND accounting register it is the natural form: one row, one amount, this account debited and that one credited. A CLASSIC one refuses it, because a one-sided row carrying a shared column would hold an amount with nothing to say which side it fell on."
387
397
  },
398
+ "absentAs": {
399
+ "type": ["number", "string", "boolean"],
400
+ "description": "Under the register's 'recordChangesOnly': what this resource compares as when no record is in effect yet, or the one in effect holds nothing. A number, a boolean or a string, read as the resource column's type. Omit it and an absent slice differs from every value, so the first record is always written; declare 0 to treat a first value of 0 as no change, as 1C does with ЕСТЬNULL(…, 0)."
401
+ },
388
402
  "required": {
389
403
  "type": "boolean",
390
404
  "default": true,
@@ -397,7 +411,7 @@
397
411
  },
398
412
  "additionalProperties": false
399
413
  },
400
- "description": "The register's measured columns, keyed by resource name \u2014 what it accumulates ('balance', 'turnover') or what a slice of it returns ('info'), as opposed to the dimensions that key it. A binding maps a document column to each resource the register marks required. The 64 bound is generous because an 'info' register's resources are VALUES rather than quantities, and a register of settings legitimately carries dozens: measured on a base being ported, one register holding who signs which customs paper carries 28 \u2014 a post and a name per document kind, in two languages, for two factories \u2014 and its one reader names all 28. An accumulation register wanting more than a handful is usually several registers."
414
+ "description": "The register's measured columns, keyed by resource name \u2014 what it accumulates ('balance', 'turnover') or what a slice of it returns ('info'), as opposed to the dimensions that key it. A binding maps a document column to each resource the register marks required. The 64 bound is generous because an 'info' register's resources are VALUES rather than quantities, and a register of settings legitimately carries dozens \u2014 one holding who signs which document may carry a post and a name per document kind, in several languages, for several sites. An accumulation register wanting more than a handful is usually several registers."
401
415
  },
402
416
  "allowNegative": {
403
417
  "type": "boolean",
@@ -117,9 +117,32 @@
117
117
  "type": "object",
118
118
  "description": "Control parameters, the same shape an entity column's `params` takes — `options` for a dropdown or flags, `currency`, `align`, and so on.",
119
119
  "additionalProperties": true
120
+ },
121
+ "domain": {
122
+ "type": "string",
123
+ "description": "Reference to a reusable column domain. Unqualified resolves within this module, then tenant-created domains; 'module_cd.domain_cd' binds to that module. The domain supplies the control, datatype and option list — and its options are translated once, for every consumer."
120
124
  }
121
125
  },
122
- "additionalProperties": false
126
+ "additionalProperties": false,
127
+ "oneOf": [
128
+ {
129
+ "title": "Domain-backed result column",
130
+ "description": "Defers to a column domain. Must not restate what the domain owns.",
131
+ "required": ["domain"],
132
+ "properties": {
133
+ "fieldTypeCd": false,
134
+ "baseDatatypeCd": false,
135
+ "params": false
136
+ }
137
+ },
138
+ {
139
+ "title": "Inline result column",
140
+ "description": "Declares its own control and datatype.",
141
+ "properties": {
142
+ "domain": false
143
+ }
144
+ }
145
+ ]
123
146
  }
124
147
  }
125
148
  }
@@ -58,6 +58,13 @@
58
58
  "$ref": "#/$defs/labelOnly"
59
59
  }
60
60
  },
61
+ "registers": {
62
+ "type": "object",
63
+ "description": "Register code (as registers.json keys it) → label and description.",
64
+ "additionalProperties": {
65
+ "$ref": "#/$defs/labelDesc"
66
+ }
67
+ },
61
68
  "reports": {
62
69
  "type": "object",
63
70
  "description": "Report code → label, dataset captions and parameter labels.",
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, translations,
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
+ }