@dforge-core/metadata 0.0.45 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/metadata",
3
- "version": "0.0.45",
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",
@@ -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/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";
@@ -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
- journal: ResolvedRegisterEntity;
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
- ): Pick<Context, "journalCode" | "journal" | "carrierCode" | "carrier"> {
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
- columnAt(["journalDirection"], journal, journalCode, def.journalDirection, "Journal direction column");
91
- columnAt(["journalDate"], journal, journalCode, def.journalDate, "Journal date column");
92
- columnAt(["period"], entity, entityCode, def.period, "Period column");
93
- columnAt(["recorder"], entity, entityCode, def.recorder, "Recorder column");
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
- return { journalCode, journal, carrierCode: registerCarrier(def), carrier: carriedByEntity ? entity : journal };
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, carrier, carrierCode, columnAt, error }: Context): void {
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], carrier, carrierCode, name, "Dimension");
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, carrier, carrierCode, columnAt, error } = ctx;
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), carrier, carrierCode, column, `Resource '${name}' column`);
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], ctx.journal, ctx.journalCode, column, "Analytic slot column"));
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, journal, journalCode, columnAt, error } = ctx;
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"], journal, journalCode, debitField, `Dimension '${name}' debit column`);
203
- columnAt([...base, "creditField"], journal, journalCode, creditField, `Dimension '${name}' credit column`);
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, journal, journalCode, columnAt, error } = ctx;
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"], journal, journalCode, debit, "Debit account column");
220
- columnAt(["creditAccount"], journal, journalCode, credit, "Credit account column");
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, journal, journalCode, columnAt, error } = ctx;
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"], journal, journalCode, safeTrim(def.account), "Account column");
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
- const entity = entityAt(["composition", "entity"], code, "Composition entity");
276
+ hosts.composition = { entity: entityAt(["composition", "entity"], code, "Composition entity"), code };
273
277
  for (const key of ["account", "slot", "analytic"]) {
274
- columnAt(["composition", key], entity, code, composition[key], `Composition '${key}' column`);
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
- const totalsEntity = entityAt(["totals", "entity"], totalsCode, "Totals entity");
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"], totalsEntity, totalsCode, safeTrim(totals.periodField), "Totals period column");
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"], totalsEntity, totalsCode, safeTrim(totals.accountField), "Totals account column");
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], totalsEntity, totalsCode, safeTrim(entry[key]), `Totals '${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 checks = createEntityChecks(lookup, error, warning);
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 name, and that `entity` stores it. A non-string or blank column is skipped. */
29
- columnAt: (path: IssuePath, entity: ResolvedRegisterEntity, entityCode: string | undefined, column: unknown, what: string) => void;
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, entity, entityCode, column, what) => {
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 columns = columnsOf(entity);
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 ${entityCode}.`);
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
 
@@ -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";
@@ -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
 
@@ -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). */