@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.
- package/CHANGELOG.md +45 -0
- package/dist/index.d.ts +217 -1
- package/dist/index.js +668 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/entity.schema.json +25 -4
- package/schemas/registers.schema.json +27 -13
- package/schemas/stored_procedures.schema.json +24 -1
- package/schemas/translations.schema.json +7 -0
- package/src/index.ts +35 -2
- package/src/registers/check-binding.ts +233 -0
- package/src/registers/check-declaration.ts +425 -0
- package/src/registers/entities.ts +79 -0
- package/src/registers/index.ts +17 -0
- package/src/registers/issues.ts +25 -0
- package/src/registers/read.ts +81 -0
- package/src/registers/types.ts +141 -0
- package/src/stored-procedures.ts +7 -0
package/dist/index.js
CHANGED
|
@@ -420,6 +420,658 @@ var DEP_COLUMN_TYPES = [
|
|
|
420
420
|
"bytea"
|
|
421
421
|
];
|
|
422
422
|
|
|
423
|
+
// src/registers/types.ts
|
|
424
|
+
var REGISTER_KINDS = ["balance", "turnover", "info", "accounting"];
|
|
425
|
+
var isRegisterKind = (value) => typeof value === "string" && REGISTER_KINDS.includes(value);
|
|
426
|
+
var REGISTER_CODE = /^[a-z][a-z0-9_]*$/;
|
|
427
|
+
var REGISTER_COLUMN_NAME = /^[a-z_][a-z0-9_]*$/;
|
|
428
|
+
var REGISTER_TOTALS_PERIODS = ["day", "month", "quarter", "year"];
|
|
429
|
+
|
|
430
|
+
// src/registers/read.ts
|
|
431
|
+
var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
432
|
+
var safeTrim = (value) => typeof value === "string" && value.trim() ? value : void 0;
|
|
433
|
+
var stringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
434
|
+
var hasKey = (obj, key) => obj[key] !== void 0;
|
|
435
|
+
var registerDimensions = (def) => stringList(def.dimensions);
|
|
436
|
+
var registerResources = (def) => !isRecord(def.resources) ? [] : Object.entries(def.resources).map(([name, raw]) => ({ name, def: isRecord(raw) ? raw : {} }));
|
|
437
|
+
function registerCarrier(def) {
|
|
438
|
+
switch (def.kind) {
|
|
439
|
+
case "balance":
|
|
440
|
+
case "info":
|
|
441
|
+
return safeTrim(def.entity);
|
|
442
|
+
case "turnover":
|
|
443
|
+
case "accounting":
|
|
444
|
+
return safeTrim(def.journal);
|
|
445
|
+
default:
|
|
446
|
+
return void 0;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
var registerResourceShape = (resource) => "debitField" in resource || "creditField" in resource ? "split" : "field";
|
|
450
|
+
var registerLineMode = (def) => def.lineMode === "compound" ? "compound" : "classic";
|
|
451
|
+
var INFO_PROPERTIES = ["period", "recorder", "recordChangesOnly"];
|
|
452
|
+
var ACCOUNTING_PROPERTIES = [
|
|
453
|
+
"chart",
|
|
454
|
+
"composition",
|
|
455
|
+
"analyticFields",
|
|
456
|
+
"lineMode",
|
|
457
|
+
"account",
|
|
458
|
+
"debitAccount",
|
|
459
|
+
"creditAccount",
|
|
460
|
+
"debitAnalyticFields",
|
|
461
|
+
"creditAnalyticFields",
|
|
462
|
+
"sideDimensions"
|
|
463
|
+
];
|
|
464
|
+
var BALANCE_PROPERTIES = ["allowNegative", "autoCreate"];
|
|
465
|
+
var REFUSED = {
|
|
466
|
+
balance: [...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES],
|
|
467
|
+
turnover: ["entity", ...INFO_PROPERTIES, ...ACCOUNTING_PROPERTIES, ...BALANCE_PROPERTIES],
|
|
468
|
+
info: ["journal", "journalDirection", "journalDate", ...ACCOUNTING_PROPERTIES, "totals", ...BALANCE_PROPERTIES],
|
|
469
|
+
accounting: ["entity", ...INFO_PROPERTIES, "journalDirection", ...BALANCE_PROPERTIES]
|
|
470
|
+
};
|
|
471
|
+
var REQUIRED = {
|
|
472
|
+
balance: ["entity"],
|
|
473
|
+
turnover: ["journal", "journalDirection", "journalDate"],
|
|
474
|
+
info: ["entity"],
|
|
475
|
+
accounting: ["chart", "journal", "journalDate", "lineMode"]
|
|
476
|
+
};
|
|
477
|
+
var registerRefusedProps = (kind) => REFUSED[kind];
|
|
478
|
+
var registerRequiredProps = (kind) => REQUIRED[kind];
|
|
479
|
+
var KIND_LABEL = {
|
|
480
|
+
balance: { sentence: "A balance register", inline: "a balance register" },
|
|
481
|
+
turnover: { sentence: "A turnover register", inline: "a turnover register" },
|
|
482
|
+
info: { sentence: "An information register", inline: "an information register" },
|
|
483
|
+
accounting: { sentence: "An accounting register", inline: "an accounting register" }
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// src/registers/entities.ts
|
|
487
|
+
function createEntityChecks(lookup, error, warning) {
|
|
488
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
489
|
+
const columnSets = /* @__PURE__ */ new WeakMap();
|
|
490
|
+
const entityAt = (path, code, what) => {
|
|
491
|
+
if (!code || !lookup) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
if (!resolved.has(code)) {
|
|
495
|
+
resolved.set(code, lookup(code));
|
|
496
|
+
}
|
|
497
|
+
const found = resolved.get(code);
|
|
498
|
+
if (found === void 0) {
|
|
499
|
+
error(path, "unknown-entity", `${what} '${code}' isn't an entity this module can see.`);
|
|
500
|
+
}
|
|
501
|
+
return found;
|
|
502
|
+
};
|
|
503
|
+
const columnsOf = (entity) => {
|
|
504
|
+
if (!entity) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (!columnSets.has(entity)) {
|
|
508
|
+
columnSets.set(entity, entity.columns ? new Set(entity.columns) : null);
|
|
509
|
+
}
|
|
510
|
+
return columnSets.get(entity) ?? null;
|
|
511
|
+
};
|
|
512
|
+
const columnAt = (path, entity, entityCode, column, what) => {
|
|
513
|
+
if (typeof column !== "string" || !column.trim()) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!REGISTER_COLUMN_NAME.test(column)) {
|
|
517
|
+
error(path, "column-name", `${what} '${column}' isn't a column name (lowercase letters, digits and '_').`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const columns = columnsOf(entity);
|
|
521
|
+
if (columns && !columns.has(column)) {
|
|
522
|
+
warning(path, "unknown-column", `${what} '${column}' isn't a stored column of ${entityCode}.`);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
return { entityAt, columnAt };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/registers/issues.ts
|
|
529
|
+
function createIssueReporter(issues, namespace, severity) {
|
|
530
|
+
return (path, code, message) => {
|
|
531
|
+
issues.push({ path, code: `${namespace}/${code}`, message, severity });
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/registers/check-declaration.ts
|
|
536
|
+
var MAX_DIMENSIONS = 8;
|
|
537
|
+
var MAX_RESOURCES = 64;
|
|
538
|
+
var MAX_ANALYTIC_SLOTS = 8;
|
|
539
|
+
var MAX_REGISTERS = 100;
|
|
540
|
+
function checkColumnPair(error, code, first, second, where) {
|
|
541
|
+
if (!first || !second) {
|
|
542
|
+
error(where.missing, code, where.missingMessage);
|
|
543
|
+
}
|
|
544
|
+
if (first && first === second) {
|
|
545
|
+
error(where.same, code, where.sameMessage);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function refuseKeys(def, keys, error, code, message) {
|
|
549
|
+
for (const key of keys) {
|
|
550
|
+
if (hasKey(def, key)) {
|
|
551
|
+
error([key], code, message(key));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function checkProperties({ def, kind, error }) {
|
|
556
|
+
for (const key of registerRequiredProps(kind)) {
|
|
557
|
+
if (safeTrim(def[key]) === void 0) {
|
|
558
|
+
error([key], "required", `${KIND_LABEL[kind].sentence} needs '${key}'.`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
refuseKeys(def, registerRefusedProps(kind), error, "refused", (key) => `'${key}' isn't allowed on ${KIND_LABEL[kind].inline}.`);
|
|
562
|
+
if ((safeTrim(def.journalDirection) || safeTrim(def.journalDate)) && !safeTrim(def.journal)) {
|
|
563
|
+
error(["journal"], "journal-stamps", "'journalDirection' and 'journalDate' are columns of the journal \u2014 name the journal entity.");
|
|
564
|
+
}
|
|
565
|
+
if (kind === "info" && def.recordChangesOnly === true && !safeTrim(def.recorder)) {
|
|
566
|
+
error(["recordChangesOnly"], "record-changes-only", "'recordChangesOnly' needs a 'recorder' \u2014 only a document's record can be compared with the slice in effect.");
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function resolveEntities(def, kind, checks) {
|
|
570
|
+
const { entityAt, columnAt } = checks;
|
|
571
|
+
const entityCode = safeTrim(def.entity);
|
|
572
|
+
const journalCode = safeTrim(def.journal);
|
|
573
|
+
const carriedByEntity = kind === "balance" || kind === "info";
|
|
574
|
+
const entity = carriedByEntity ? entityAt(["entity"], entityCode, "Entity") : null;
|
|
575
|
+
const journal = kind !== "info" ? entityAt(["journal"], journalCode, "Journal") : null;
|
|
576
|
+
if (kind === "accounting") {
|
|
577
|
+
entityAt(["chart"], safeTrim(def.chart), "Chart");
|
|
578
|
+
}
|
|
579
|
+
columnAt(["journalDirection"], journal, journalCode, def.journalDirection, "Journal direction column");
|
|
580
|
+
columnAt(["journalDate"], journal, journalCode, def.journalDate, "Journal date column");
|
|
581
|
+
columnAt(["period"], entity, entityCode, def.period, "Period column");
|
|
582
|
+
columnAt(["recorder"], entity, entityCode, def.recorder, "Recorder column");
|
|
583
|
+
return { journalCode, journal, carrierCode: registerCarrier(def), carrier: carriedByEntity ? entity : journal };
|
|
584
|
+
}
|
|
585
|
+
function checkDimensions({ def, dimensions, sides, carrier, carrierCode, columnAt, error }) {
|
|
586
|
+
if (!Array.isArray(def.dimensions) || dimensions.length === 0) {
|
|
587
|
+
error(["dimensions"], "dimensions", "A register needs at least one dimension.");
|
|
588
|
+
}
|
|
589
|
+
if (dimensions.length > MAX_DIMENSIONS) {
|
|
590
|
+
error(["dimensions"], "dimensions", `At most ${MAX_DIMENSIONS} dimensions.`);
|
|
591
|
+
}
|
|
592
|
+
const twice = dimensions.filter((name, index) => dimensions.indexOf(name) !== index);
|
|
593
|
+
if (twice.length > 0) {
|
|
594
|
+
error(["dimensions"], "dimensions", `Dimension listed more than once: ${[...new Set(twice)].join(", ")}.`);
|
|
595
|
+
}
|
|
596
|
+
dimensions.forEach((name, i) => {
|
|
597
|
+
if (!(name in sides)) {
|
|
598
|
+
columnAt(["dimensions", i], carrier, carrierCode, name, "Dimension");
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function checkResourceShape(ctx, name, resource, path) {
|
|
603
|
+
const { def, kind, error } = ctx;
|
|
604
|
+
const field = safeTrim(resource.field);
|
|
605
|
+
const debit = safeTrim(resource.debitField);
|
|
606
|
+
const credit = safeTrim(resource.creditField);
|
|
607
|
+
const split = registerResourceShape(resource) === "split";
|
|
608
|
+
const classic = kind === "accounting" && registerLineMode(def) === "classic";
|
|
609
|
+
if (split && hasKey(resource, "field")) {
|
|
610
|
+
error(path("field"), "resource-shape", `Resource '${name}': either 'field' or 'debitField' + 'creditField', not both.`);
|
|
611
|
+
}
|
|
612
|
+
if (classic && !split) {
|
|
613
|
+
error(path(), "resource-shape", `Resource '${name}': on a classic accounting register every resource is split \u2014 'debitField' and 'creditField'.`);
|
|
614
|
+
} else if (kind !== "accounting" && split) {
|
|
615
|
+
error(path(), "resource-shape", `Resource '${name}': 'debitField' / 'creditField' belong to an accounting register \u2014 name one 'field'.`);
|
|
616
|
+
} else if (split) {
|
|
617
|
+
checkColumnPair(error, "resource-shape", debit, credit, {
|
|
618
|
+
missing: path(),
|
|
619
|
+
missingMessage: `Resource '${name}' needs both 'debitField' and 'creditField'.`,
|
|
620
|
+
same: path("creditField"),
|
|
621
|
+
sameMessage: `Resource '${name}': the debit and credit columns must differ.`
|
|
622
|
+
});
|
|
623
|
+
} else if (!field) {
|
|
624
|
+
error(path(), "resource-shape", `Resource '${name}' needs its column ('field').`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function checkResources(ctx) {
|
|
628
|
+
const { def, kind, resources, dimensions, carrier, carrierCode, columnAt, error } = ctx;
|
|
629
|
+
if (!isRecord(def.resources) || resources.length === 0) {
|
|
630
|
+
error(["resources"], "resources", "A register needs at least one resource.");
|
|
631
|
+
}
|
|
632
|
+
if (resources.length > MAX_RESOURCES) {
|
|
633
|
+
error(["resources"], "resources", `At most ${MAX_RESOURCES} resources.`);
|
|
634
|
+
}
|
|
635
|
+
const takesAbsentAs = kind === "info" && !!safeTrim(def.recorder) && def.recordChangesOnly === true;
|
|
636
|
+
const storage = /* @__PURE__ */ new Map();
|
|
637
|
+
for (const { name, def: resource } of resources) {
|
|
638
|
+
const path = (key) => key ? ["resources", name, key] : ["resources", name];
|
|
639
|
+
if (!REGISTER_COLUMN_NAME.test(name)) {
|
|
640
|
+
error(path(), "resource-name", `Resource '${name}' isn't a name (lowercase letters, digits and '_').`);
|
|
641
|
+
}
|
|
642
|
+
checkResourceShape(ctx, name, resource, path);
|
|
643
|
+
for (const key of ["field", "debitField", "creditField"]) {
|
|
644
|
+
const column = safeTrim(resource[key]);
|
|
645
|
+
if (!column) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
columnAt(path(key), carrier, carrierCode, column, `Resource '${name}' column`);
|
|
649
|
+
const other = storage.get(column);
|
|
650
|
+
if (other && other !== name) {
|
|
651
|
+
error(path(key), "resource-column-shared", `Resources '${other}' and '${name}' both store into '${column}' \u2014 posting would add both into one column.`);
|
|
652
|
+
}
|
|
653
|
+
storage.set(column, name);
|
|
654
|
+
}
|
|
655
|
+
const field = safeTrim(resource.field);
|
|
656
|
+
if (field && dimensions.includes(field)) {
|
|
657
|
+
error(path("field"), "resource-is-dimension", `'${field}' is both a dimension and resource '${name}'.`);
|
|
658
|
+
}
|
|
659
|
+
if (hasKey(resource, "absentAs") && !takesAbsentAs) {
|
|
660
|
+
error(path("absentAs"), "absent-as", `Resource '${name}': 'absentAs' needs 'recordChangesOnly' on a subordinate information register.`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function checkAnalyticSlotColumns(ctx, key, columns) {
|
|
665
|
+
columns.forEach((column, i) => ctx.columnAt([key, i], ctx.journal, ctx.journalCode, column, "Analytic slot column"));
|
|
666
|
+
}
|
|
667
|
+
function checkSideDimensions(ctx) {
|
|
668
|
+
const { dimensions, sides, journal, journalCode, columnAt, error } = ctx;
|
|
669
|
+
for (const [name, side] of Object.entries(sides)) {
|
|
670
|
+
const base = ["sideDimensions", name];
|
|
671
|
+
if (!dimensions.includes(name)) {
|
|
672
|
+
error(base, "side-dimension", `Two-sided dimension '${name}' must also be listed in 'dimensions'.`);
|
|
673
|
+
}
|
|
674
|
+
const sideDef = isRecord(side) ? side : {};
|
|
675
|
+
const debitField = safeTrim(sideDef.debitField);
|
|
676
|
+
const creditField = safeTrim(sideDef.creditField);
|
|
677
|
+
checkColumnPair(error, "side-dimension", debitField, creditField, {
|
|
678
|
+
missing: base,
|
|
679
|
+
missingMessage: `Two-sided dimension '${name}' needs 'debitField' and 'creditField'.`,
|
|
680
|
+
same: [...base, "creditField"],
|
|
681
|
+
sameMessage: `Two-sided dimension '${name}': the two columns must differ.`
|
|
682
|
+
});
|
|
683
|
+
columnAt([...base, "debitField"], journal, journalCode, debitField, `Dimension '${name}' debit column`);
|
|
684
|
+
columnAt([...base, "creditField"], journal, journalCode, creditField, `Dimension '${name}' credit column`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function checkCompoundAccounts(ctx) {
|
|
688
|
+
const { def, journal, journalCode, columnAt, error } = ctx;
|
|
689
|
+
refuseKeys(def, ["account", "analyticFields"], error, "line-mode", (key) => `'${key}' belongs to a classic register; a compound one names 'debitAccount' and 'creditAccount'.`);
|
|
690
|
+
const debit = safeTrim(def.debitAccount);
|
|
691
|
+
const credit = safeTrim(def.creditAccount);
|
|
692
|
+
checkColumnPair(error, "accounts", debit, credit, {
|
|
693
|
+
missing: ["debitAccount"],
|
|
694
|
+
missingMessage: "A compound register needs 'debitAccount' and 'creditAccount'.",
|
|
695
|
+
same: ["creditAccount"],
|
|
696
|
+
sameMessage: "'debitAccount' and 'creditAccount' must differ \u2014 one column would record no correspondence."
|
|
697
|
+
});
|
|
698
|
+
columnAt(["debitAccount"], journal, journalCode, debit, "Debit account column");
|
|
699
|
+
columnAt(["creditAccount"], journal, journalCode, credit, "Credit account column");
|
|
700
|
+
const debitSlots = stringList(def.debitAnalyticFields);
|
|
701
|
+
const creditSlots = stringList(def.creditAnalyticFields);
|
|
702
|
+
if (debitSlots.length > MAX_ANALYTIC_SLOTS || creditSlots.length > MAX_ANALYTIC_SLOTS) {
|
|
703
|
+
error(["debitAnalyticFields"], "analytic-slots", `At most ${MAX_ANALYTIC_SLOTS} analytic slots per side.`);
|
|
704
|
+
}
|
|
705
|
+
const shared = debitSlots.filter((column) => creditSlots.includes(column));
|
|
706
|
+
if (shared.length > 0) {
|
|
707
|
+
error(["creditAnalyticFields"], "analytic-slots", `Analytic slot column on both sides: ${shared.join(", ")}.`);
|
|
708
|
+
}
|
|
709
|
+
checkAnalyticSlotColumns(ctx, "debitAnalyticFields", debitSlots);
|
|
710
|
+
checkAnalyticSlotColumns(ctx, "creditAnalyticFields", creditSlots);
|
|
711
|
+
const needsComposition = debitSlots.length > 0 || creditSlots.length > 0;
|
|
712
|
+
checkSideDimensions(ctx);
|
|
713
|
+
return needsComposition;
|
|
714
|
+
}
|
|
715
|
+
function checkClassicAccounts(ctx) {
|
|
716
|
+
const { def, journal, journalCode, columnAt, error } = ctx;
|
|
717
|
+
refuseKeys(def, ["debitAccount", "creditAccount", "debitAnalyticFields", "creditAnalyticFields", "sideDimensions"], error, "line-mode", (key) => `'${key}' belongs to a compound register.`);
|
|
718
|
+
if (!safeTrim(def.account)) {
|
|
719
|
+
error(["account"], "accounts", "A classic register names the posting's account column ('account').");
|
|
720
|
+
}
|
|
721
|
+
columnAt(["account"], journal, journalCode, safeTrim(def.account), "Account column");
|
|
722
|
+
const slots = stringList(def.analyticFields);
|
|
723
|
+
if (slots.length > MAX_ANALYTIC_SLOTS) {
|
|
724
|
+
error(["analyticFields"], "analytic-slots", `At most ${MAX_ANALYTIC_SLOTS} analytic slots.`);
|
|
725
|
+
}
|
|
726
|
+
checkAnalyticSlotColumns(ctx, "analyticFields", slots);
|
|
727
|
+
return slots.length > 0;
|
|
728
|
+
}
|
|
729
|
+
function checkComposition(ctx, needsComposition) {
|
|
730
|
+
const { def, entityAt, columnAt, error } = ctx;
|
|
731
|
+
const composition = isRecord(def.composition) ? def.composition : void 0;
|
|
732
|
+
if (needsComposition && !composition) {
|
|
733
|
+
error(["composition"], "composition", "Analytic slots need a 'composition' \u2014 where the chart says which analytic each account takes in which slot.");
|
|
734
|
+
}
|
|
735
|
+
if (!needsComposition && composition) {
|
|
736
|
+
error(["composition"], "composition", "'composition' is for a register with analytic slots; this one declares none.");
|
|
737
|
+
}
|
|
738
|
+
if (!composition) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
for (const key of ["entity", "account", "slot", "analytic"]) {
|
|
742
|
+
if (!safeTrim(composition[key])) {
|
|
743
|
+
error(["composition", key], "composition", `'composition' needs '${key}'.`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const code = safeTrim(composition.entity);
|
|
747
|
+
const entity = entityAt(["composition", "entity"], code, "Composition entity");
|
|
748
|
+
for (const key of ["account", "slot", "analytic"]) {
|
|
749
|
+
columnAt(["composition", key], entity, code, composition[key], `Composition '${key}' column`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function checkAccounting(ctx) {
|
|
753
|
+
const { def, kind, error } = ctx;
|
|
754
|
+
if (kind !== "accounting") {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const mode = safeTrim(def.lineMode);
|
|
758
|
+
if (mode && mode !== "classic" && mode !== "compound") {
|
|
759
|
+
error(["lineMode"], "line-mode", "'lineMode' is 'classic' or 'compound'.");
|
|
760
|
+
}
|
|
761
|
+
checkComposition(ctx, mode === "compound" ? checkCompoundAccounts(ctx) : checkClassicAccounts(ctx));
|
|
762
|
+
}
|
|
763
|
+
function checkTotals(ctx) {
|
|
764
|
+
const { def, kind, dimensions, resources, entityAt, columnAt, error } = ctx;
|
|
765
|
+
const totals = isRecord(def.totals) ? def.totals : void 0;
|
|
766
|
+
if (!totals || kind === "info") {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const totalsCode = safeTrim(totals.entity);
|
|
770
|
+
if (!totalsCode) {
|
|
771
|
+
error(["totals", "entity"], "totals", "The totals cache needs its entity.");
|
|
772
|
+
}
|
|
773
|
+
const totalsEntity = entityAt(["totals", "entity"], totalsCode, "Totals entity");
|
|
774
|
+
const period = safeTrim(totals.period);
|
|
775
|
+
if (!period || !REGISTER_TOTALS_PERIODS.includes(period)) {
|
|
776
|
+
error(["totals", "period"], "totals", "The totals cache needs a period: day, month, quarter or year.");
|
|
777
|
+
}
|
|
778
|
+
if (!safeTrim(totals.periodField)) {
|
|
779
|
+
error(["totals", "periodField"], "totals", "The totals cache needs 'periodField' \u2014 the column holding each period's first date.");
|
|
780
|
+
}
|
|
781
|
+
columnAt(["totals", "periodField"], totalsEntity, totalsCode, safeTrim(totals.periodField), "Totals period column");
|
|
782
|
+
if (kind === "accounting") {
|
|
783
|
+
if (!safeTrim(totals.accountField)) {
|
|
784
|
+
error(["totals", "accountField"], "totals", "An accounting register's cache needs 'accountField'.");
|
|
785
|
+
}
|
|
786
|
+
columnAt(["totals", "accountField"], totalsEntity, totalsCode, safeTrim(totals.accountField), "Totals account column");
|
|
787
|
+
stringList(totals.dimensions).forEach((name, i) => {
|
|
788
|
+
if (!dimensions.includes(name)) {
|
|
789
|
+
error(["totals", "dimensions", i], "totals", `Totals dimension '${name}' isn't a dimension of the register.`);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
} else {
|
|
793
|
+
if (hasKey(totals, "accountField")) {
|
|
794
|
+
error(["totals", "accountField"], "totals", "'accountField' belongs to an accounting register's cache.");
|
|
795
|
+
}
|
|
796
|
+
if (hasKey(totals, "dimensions")) {
|
|
797
|
+
error(["totals", "dimensions"], "totals", "A cache grouped by fewer dimensions is for an accounting register only.");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const cached = isRecord(totals.resources) ? totals.resources : {};
|
|
801
|
+
const pair = kind === "accounting" ? ["debitField", "creditField"] : ["inField", "outField"];
|
|
802
|
+
for (const { name } of resources) {
|
|
803
|
+
const entry = cached[name];
|
|
804
|
+
if (!isRecord(entry)) {
|
|
805
|
+
error(["totals", "resources"], "totals", `The totals cache must cover every resource \u2014 '${name}' is missing.`);
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
for (const key of pair) {
|
|
809
|
+
if (!safeTrim(entry[key])) {
|
|
810
|
+
error(["totals", "resources", name], "totals", `Totals resource '${name}' needs '${key}'.`);
|
|
811
|
+
}
|
|
812
|
+
columnAt(["totals", "resources", name, key], totalsEntity, totalsCode, safeTrim(entry[key]), `Totals '${name}' ${key}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const name of Object.keys(cached)) {
|
|
816
|
+
if (!resources.some((resource) => resource.name === name)) {
|
|
817
|
+
error(["totals", "resources", name], "totals", `Totals cache names '${name}', which isn't a resource of the register.`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
function checkWarnings({ def, kind, journalCode, warning }) {
|
|
822
|
+
if (!safeTrim(def.label)) {
|
|
823
|
+
warning(["label"], "label", "No label \u2014 the query builder shows the code.");
|
|
824
|
+
}
|
|
825
|
+
if (kind === "balance") {
|
|
826
|
+
if (!journalCode) {
|
|
827
|
+
warning(["journal"], "no-journal", "No journal: unposting has to recompute from the document, and as-of-date reads are refused.");
|
|
828
|
+
} else if (!safeTrim(def.journalDirection) || !safeTrim(def.journalDate)) {
|
|
829
|
+
warning(["journal"], "journal-stamps", "Declare both journal stamps (direction and date) from the start \u2014 rows journalled without them can't be backfilled.");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
function checkRegister(raw, lookup) {
|
|
834
|
+
const out = [];
|
|
835
|
+
const error = createIssueReporter(out, "registers", "error");
|
|
836
|
+
const warning = createIssueReporter(out, "registers", "warning");
|
|
837
|
+
if (!isRecord(raw)) {
|
|
838
|
+
error([], "shape", "A register declaration is a JSON object.");
|
|
839
|
+
return out;
|
|
840
|
+
}
|
|
841
|
+
const def = raw;
|
|
842
|
+
if (!isRegisterKind(def.kind)) {
|
|
843
|
+
error(["kind"], "kind", "Pick a kind: balance, turnover, info or accounting.");
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
const kind = def.kind;
|
|
847
|
+
checkProperties({ def, kind, error });
|
|
848
|
+
const checks = createEntityChecks(lookup, error, warning);
|
|
849
|
+
const ctx = {
|
|
850
|
+
def,
|
|
851
|
+
kind,
|
|
852
|
+
error,
|
|
853
|
+
warning,
|
|
854
|
+
...checks,
|
|
855
|
+
...resolveEntities(def, kind, checks),
|
|
856
|
+
dimensions: registerDimensions(def),
|
|
857
|
+
sides: isRecord(def.sideDimensions) ? def.sideDimensions : {},
|
|
858
|
+
resources: registerResources(def)
|
|
859
|
+
};
|
|
860
|
+
checkDimensions(ctx);
|
|
861
|
+
checkResources(ctx);
|
|
862
|
+
checkAccounting(ctx);
|
|
863
|
+
checkTotals(ctx);
|
|
864
|
+
checkWarnings(ctx);
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
function checkRegisters(file, lookup) {
|
|
868
|
+
const out = [];
|
|
869
|
+
const error = createIssueReporter(out, "registers", "error");
|
|
870
|
+
if (!isRecord(file)) {
|
|
871
|
+
error([], "shape", "registers.json is an object of register declarations.");
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
const codes = Object.keys(file).filter((code) => !code.startsWith("$"));
|
|
875
|
+
if (codes.length === 0) {
|
|
876
|
+
error([], "empty", "A module with no registers has no registers.json \u2014 the file needs at least one.");
|
|
877
|
+
}
|
|
878
|
+
if (codes.length > MAX_REGISTERS) {
|
|
879
|
+
error([], "limit", `A module declares at most ${MAX_REGISTERS} registers.`);
|
|
880
|
+
}
|
|
881
|
+
for (const code of codes) {
|
|
882
|
+
if (!REGISTER_CODE.test(code)) {
|
|
883
|
+
error([code], "code", `'${code}': lowercase letters, digits and '_', starting with a letter \u2014 a binding names it as module.code.`);
|
|
884
|
+
}
|
|
885
|
+
for (const issue of checkRegister(file[code], lookup)) {
|
|
886
|
+
out.push({ ...issue, path: [code, ...issue.path] });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/registers/check-binding.ts
|
|
893
|
+
var isPlainColumnRef = (value) => !value.startsWith("=") && !value.startsWith("'") && !value.includes(".");
|
|
894
|
+
function checkPosting(params, ledger, error) {
|
|
895
|
+
const lineEntity = safeTrim(params.lineEntity);
|
|
896
|
+
const locked = stringList(params.lockedFields);
|
|
897
|
+
const lockedLines = stringList(params.lockedLineFields);
|
|
898
|
+
if (!Array.isArray(params.lockedFields) || locked.length === 0) {
|
|
899
|
+
error(["lockedFields"], "register-locked-fields", `'lockedFields' is required: the document columns frozen while posted (or ["*"] for the whole row).`);
|
|
900
|
+
}
|
|
901
|
+
if (lineEntity) {
|
|
902
|
+
if (!safeTrim(params.lineField)) {
|
|
903
|
+
error(["lineField"], "register-line", "A line posting needs 'lineField' \u2014 the line's key to the document.");
|
|
904
|
+
}
|
|
905
|
+
if (!Array.isArray(params.lockedLineFields) || lockedLines.length === 0) {
|
|
906
|
+
error(["lockedLineFields"], "register-locked-fields", `A line posting needs 'lockedLineFields' (or ["*"]).`);
|
|
907
|
+
}
|
|
908
|
+
} else {
|
|
909
|
+
if (hasKey(params, "lineField")) {
|
|
910
|
+
error(["lineField"], "register-line", "'lineField' needs a 'lineEntity'.");
|
|
911
|
+
}
|
|
912
|
+
if (hasKey(params, "lockedLineFields")) {
|
|
913
|
+
error(["lockedLineFields"], "register-line", "'lockedLineFields' is for a line posting \u2014 name the 'lineEntity'.");
|
|
914
|
+
}
|
|
915
|
+
if (hasKey(params, "headerMap")) {
|
|
916
|
+
error(["headerMap"], "register-header-map", "'headerMap' is for a binding that posts from lines; a document posting reads every value from the document already.");
|
|
917
|
+
}
|
|
918
|
+
if (hasKey(params, "groupLinesBy")) {
|
|
919
|
+
error(["groupLinesBy"], "register-group-lines", "'groupLinesBy' needs lines to group \u2014 name the 'lineEntity'.");
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (ledger) {
|
|
923
|
+
if (!lineEntity) {
|
|
924
|
+
error(["lineEntity"], "register-line", "An L column posts lines: 'lineEntity' and 'lineField' are required.");
|
|
925
|
+
}
|
|
926
|
+
for (const key of ["headerMap", "skipPostingWhenEmpty", "dateTrunc", "groupLinesBy"]) {
|
|
927
|
+
if (hasKey(params, key)) {
|
|
928
|
+
error([key], "register-ledger-only", `'${key}' is read by A columns only.`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const dateField = safeTrim(params.dateField);
|
|
933
|
+
if (hasKey(params, "dateTrunc") && !dateField) {
|
|
934
|
+
error(["dateTrunc"], "register-date", "'dateTrunc' needs a 'dateField'.");
|
|
935
|
+
}
|
|
936
|
+
if (dateField && !locked.includes("*") && isPlainColumnRef(dateField) && !locked.includes(dateField)) {
|
|
937
|
+
error(["dateField"], "register-date-unlocked", `'${dateField}' must be in 'lockedFields': a posted document re-dated would leave what it posted under the old date.`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
function checkInfoBinding({ params, register, qualified, error }) {
|
|
941
|
+
if (!safeTrim(register.recorder)) {
|
|
942
|
+
error(["register"], "register-info-independent", `"${qualified}" is an independent information register (no 'recorder'): its records are written by data operations, not by posting \u2014 unposting could never find them.`);
|
|
943
|
+
}
|
|
944
|
+
for (const key of ["direction", "sign"]) {
|
|
945
|
+
if (hasKey(params, key)) {
|
|
946
|
+
error([key], "register-info-direction", `'${key}' is refused on a binding to an information register, which records values rather than quantities.`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function checkLedgerAccounts({ params, register, error }) {
|
|
951
|
+
if (register.lineMode === "compound") {
|
|
952
|
+
if (hasKey(params, "accountField")) {
|
|
953
|
+
error(["accountField"], "register-accounts", "A compound register takes 'debitAccountField' and 'creditAccountField', not 'accountField'.");
|
|
954
|
+
}
|
|
955
|
+
if (!safeTrim(params.debitAccountField) || !safeTrim(params.creditAccountField)) {
|
|
956
|
+
error(["register"], "register-accounts", "A binding to a compound register names 'debitAccountField' and 'creditAccountField'.");
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (!safeTrim(params.accountField)) {
|
|
961
|
+
error(["register"], "register-accounts", "A binding to a classic register names the line's 'accountField'.");
|
|
962
|
+
}
|
|
963
|
+
for (const key of ["debitAccountField", "creditAccountField"]) {
|
|
964
|
+
if (hasKey(params, key)) {
|
|
965
|
+
error([key], "register-accounts", `'${key}' belongs to a compound register; a classic one takes 'accountField'.`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function checkLedgerMapValue(ctx, key, value, resources) {
|
|
970
|
+
const { register, error } = ctx;
|
|
971
|
+
const resource = resources[key];
|
|
972
|
+
const split = key in resources ? isRecord(resource) && registerResourceShape(resource) === "split" : isRecord(register.sideDimensions) && key in register.sideDimensions;
|
|
973
|
+
if (split) {
|
|
974
|
+
if (!isRecord(value) || !safeTrim(value.debit) || !safeTrim(value.credit)) {
|
|
975
|
+
error(["map", key], "register-map-value", `"${key}" has a column per side; map both 'debit' and 'credit' to line columns.`);
|
|
976
|
+
}
|
|
977
|
+
} else if (!safeTrim(value)) {
|
|
978
|
+
error(["map", key], "register-map-value", `"${key}" is shared between the sides; map it to one line column.`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function checkMaps(ctx, dimensions, resources) {
|
|
982
|
+
const { params, qualified, ledger, register, error } = ctx;
|
|
983
|
+
const known = /* @__PURE__ */ new Set([...dimensions, ...Object.keys(resources)]);
|
|
984
|
+
const mapped = /* @__PURE__ */ new Set();
|
|
985
|
+
for (const mapKey of ["map", "headerMap"]) {
|
|
986
|
+
const map = isRecord(params[mapKey]) ? params[mapKey] : null;
|
|
987
|
+
if (!map) {
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
for (const key of Object.keys(map)) {
|
|
991
|
+
if (mapped.has(key)) {
|
|
992
|
+
error([mapKey, key], "register-map-key", `"${key}" is in both 'map' and 'headerMap' \u2014 one value cannot come from two places.`);
|
|
993
|
+
}
|
|
994
|
+
mapped.add(key);
|
|
995
|
+
if (!known.has(key)) {
|
|
996
|
+
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"}).`);
|
|
997
|
+
} else if (ledger && mapKey === "map") {
|
|
998
|
+
checkLedgerMapValue(ctx, key, map[key], resources);
|
|
999
|
+
} else if (mapKey === "headerMap" && key in resources && register.kind !== "info") {
|
|
1000
|
+
error([mapKey, key], "register-header-map", `A resource ("${key}") can't come from the document on a line posting \u2014 it would be added once per line. Only an information register's records take it.`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return mapped;
|
|
1005
|
+
}
|
|
1006
|
+
function checkMapping(ctx) {
|
|
1007
|
+
const { params, register, qualified, error } = ctx;
|
|
1008
|
+
const dimensions = stringList(register.dimensions);
|
|
1009
|
+
const resources = isRecord(register.resources) ? register.resources : {};
|
|
1010
|
+
const mapped = checkMaps(ctx, dimensions, resources);
|
|
1011
|
+
const unmapped = Object.entries(resources).filter(([resource, def]) => !mapped.has(resource) && !(isRecord(def) && def.required === false)).map(([resource]) => resource);
|
|
1012
|
+
if (unmapped.length > 0) {
|
|
1013
|
+
error(
|
|
1014
|
+
[hasKey(params, "map") ? "map" : "register"],
|
|
1015
|
+
"register-unmapped-resource",
|
|
1016
|
+
`Every required resource of ${qualified} must be mapped \u2014 missing: ${unmapped.join(", ")}. A writer that omitted one would post a silent zero.`
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
stringList(params.skipPostingWhenEmpty).forEach((name, i) => {
|
|
1020
|
+
if (!dimensions.includes(name)) {
|
|
1021
|
+
error(["skipPostingWhenEmpty", i], "register-skip", `"${name}" isn't a dimension of ${qualified}; 'skipPostingWhenEmpty' names register dimensions.`);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
function checkRegisterBinding(columnType, rawParams, register, options = {}) {
|
|
1026
|
+
const out = [];
|
|
1027
|
+
const error = createIssueReporter(out, "entity", "error");
|
|
1028
|
+
if (!isRecord(rawParams)) {
|
|
1029
|
+
return out;
|
|
1030
|
+
}
|
|
1031
|
+
const params = rawParams;
|
|
1032
|
+
const qualified = safeTrim(params.register);
|
|
1033
|
+
if (!qualified) {
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
const ledger = columnType === "L";
|
|
1037
|
+
if (hasKey(params, "registries")) {
|
|
1038
|
+
error(["registries"], "register-and-inline", "A column binds to a register OR restates one inline in 'registries' \u2014 keep one.");
|
|
1039
|
+
}
|
|
1040
|
+
if (!qualified.includes(".")) {
|
|
1041
|
+
error(["register"], "register-unqualified", `A register is named as module.register, e.g. "${options.moduleCode ?? "module"}.${qualified}".`);
|
|
1042
|
+
return out;
|
|
1043
|
+
}
|
|
1044
|
+
const dot = qualified.indexOf(".");
|
|
1045
|
+
const moduleCode = qualified.slice(0, dot);
|
|
1046
|
+
const registerCode = qualified.slice(dot + 1);
|
|
1047
|
+
checkPosting(params, ledger, error);
|
|
1048
|
+
if (register === null) {
|
|
1049
|
+
return out;
|
|
1050
|
+
}
|
|
1051
|
+
if (!register) {
|
|
1052
|
+
error(["register"], "unknown-register", `No register "${qualified}" \u2014 ${moduleCode}/registers.json doesn't declare "${registerCode}".`);
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
const kind = register.kind;
|
|
1056
|
+
if (ledger && kind !== "accounting") {
|
|
1057
|
+
error(["register"], "register-kind", `An L column posts to an accounting register; "${qualified}" is a ${kind || "?"} register \u2014 use an A column.`);
|
|
1058
|
+
return out;
|
|
1059
|
+
}
|
|
1060
|
+
if (!ledger && kind === "accounting") {
|
|
1061
|
+
error(["register"], "register-kind", `An A column posts to a balance, turnover or info register; "${qualified}" is an accounting register \u2014 use an L column.`);
|
|
1062
|
+
return out;
|
|
1063
|
+
}
|
|
1064
|
+
const ctx = { params, register, qualified, ledger, error };
|
|
1065
|
+
if (kind === "info") {
|
|
1066
|
+
checkInfoBinding(ctx);
|
|
1067
|
+
}
|
|
1068
|
+
if (ledger) {
|
|
1069
|
+
checkLedgerAccounts(ctx);
|
|
1070
|
+
}
|
|
1071
|
+
checkMapping(ctx);
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
423
1075
|
// src/index.ts
|
|
424
1076
|
var flagDefs = [
|
|
425
1077
|
{ value: "V", label: "Visible" },
|
|
@@ -434,9 +1086,16 @@ export {
|
|
|
434
1086
|
FILTER_GROUP_OPERATORS,
|
|
435
1087
|
FILTER_OPERATORS,
|
|
436
1088
|
NUMERIC_TOTAL,
|
|
1089
|
+
REGISTER_CODE,
|
|
1090
|
+
REGISTER_COLUMN_NAME,
|
|
1091
|
+
REGISTER_KINDS,
|
|
1092
|
+
REGISTER_TOTALS_PERIODS,
|
|
437
1093
|
baseDatatypes,
|
|
438
1094
|
baseToDbDatatype,
|
|
439
1095
|
chartTypes,
|
|
1096
|
+
checkRegister,
|
|
1097
|
+
checkRegisterBinding,
|
|
1098
|
+
checkRegisters,
|
|
440
1099
|
columnTypes,
|
|
441
1100
|
dataViewKinds,
|
|
442
1101
|
defaultParams,
|
|
@@ -455,6 +1114,15 @@ export {
|
|
|
455
1114
|
getPrimaryKeys,
|
|
456
1115
|
getTrait,
|
|
457
1116
|
isFieldTypeCd,
|
|
1117
|
+
isPlainColumnRef,
|
|
1118
|
+
isRegisterKind,
|
|
1119
|
+
registerCarrier,
|
|
1120
|
+
registerDimensions,
|
|
1121
|
+
registerLineMode,
|
|
1122
|
+
registerRefusedProps,
|
|
1123
|
+
registerRequiredProps,
|
|
1124
|
+
registerResourceShape,
|
|
1125
|
+
registerResources,
|
|
458
1126
|
traitFieldConflicts,
|
|
459
1127
|
traits,
|
|
460
1128
|
vizTypes
|