@x12i/memorix-metadata 3.2.3 → 3.4.0

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/dist/validate.js CHANGED
@@ -3,7 +3,7 @@ import { foldLegacyAbstractBindings } from "./fold-bindings.js";
3
3
  import { isForbiddenOperationalKindId, METADATA_KIND_IDS, } from "./kinds.js";
4
4
  import { validateMappingMap } from "@x12i/memorix-mapping";
5
5
  import { isPlatformInterfaceId, platformInterfaceForCategory, } from "./platform-interfaces.js";
6
- import { parentIdsOf } from "./types.js";
6
+ import { ANALYTICS_PSEUDO_PROPERTIES, ANALYTICS_RESERVED_BUCKETS, parentIdsOf, } from "./types.js";
7
7
  function issue(path, code, message) {
8
8
  return { path, code, message };
9
9
  }
@@ -142,6 +142,26 @@ function validateObjectType(raw, path, issues) {
142
142
  requireNonEmptyString(o.contentTypes[i]?.id, `${path}.contentTypes[${i}].id`, issues);
143
143
  }
144
144
  }
145
+ if (o.fields !== undefined) {
146
+ if (!Array.isArray(o.fields)) {
147
+ issues.push(issue(`${path}.fields`, "META-TYPE", `${path}.fields must be an array`));
148
+ }
149
+ else {
150
+ for (let i = 0; i < o.fields.length; i++) {
151
+ const f = o.fields[i];
152
+ const fp = `${path}.fields[${i}]`;
153
+ if (!f || typeof f !== "object") {
154
+ issues.push(issue(fp, "META-TYPE", `${fp} must be an object`));
155
+ continue;
156
+ }
157
+ requireNonEmptyString(f.path, `${fp}.path`, issues);
158
+ requireNonEmptyString(f.valueType, `${fp}.valueType`, issues);
159
+ if (f.enum !== undefined) {
160
+ validateEnumList(f.enum, `${fp}.enum`, issues);
161
+ }
162
+ }
163
+ }
164
+ }
145
165
  }
146
166
  function validateAbstractType(raw, path, issues) {
147
167
  if (!raw || typeof raw !== "object") {
@@ -167,6 +187,9 @@ function validateAbstractType(raw, path, issues) {
167
187
  const p = a.properties[i];
168
188
  requireNonEmptyString(p?.id, `${path}.properties[${i}].id`, issues);
169
189
  requireNonEmptyString(p?.valueType, `${path}.properties[${i}].valueType`, issues);
190
+ if (p?.enum !== undefined) {
191
+ validateEnumList(p.enum, `${path}.properties[${i}].enum`, issues);
192
+ }
170
193
  }
171
194
  }
172
195
  }
@@ -331,6 +354,423 @@ function validateWrite(raw, path, issues) {
331
354
  requireNonEmptyString(w.objectType, `${path}.objectType`, issues);
332
355
  requireNonEmptyString(w.contentType, `${path}.contentType`, issues);
333
356
  }
357
+ function validateEnumList(value, path, issues) {
358
+ if (!Array.isArray(value) || value.length === 0) {
359
+ issues.push(issue(path, "META-ENUM", `${path} must be a non-empty string array`));
360
+ return;
361
+ }
362
+ const seen = new Set();
363
+ for (let i = 0; i < value.length; i++) {
364
+ const v = value[i];
365
+ if (typeof v !== "string" || !v.trim()) {
366
+ issues.push(issue(`${path}[${i}]`, "META-ENUM", `${path}[${i}] must be a non-empty string (empty string is never a vocabulary member)`));
367
+ continue;
368
+ }
369
+ if (seen.has(v)) {
370
+ issues.push(issue(`${path}[${i}]`, "META-DUP", `${path} duplicate value "${v}"`));
371
+ }
372
+ seen.add(v);
373
+ }
374
+ }
375
+ function requirePredicate(raw, path, issues) {
376
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
377
+ issues.push(issue(path, "META-TYPE", `${path} must be an object`));
378
+ return;
379
+ }
380
+ const p = raw;
381
+ requireNonEmptyString(p.property, `${path}.property`, issues);
382
+ const forms = ["equals", "in", "exists"].filter((k) => k in p);
383
+ if (forms.length !== 1) {
384
+ issues.push(issue(path, "META-REQUIRED", `${path} must set exactly one of equals, in, exists`));
385
+ return;
386
+ }
387
+ if (forms[0] === "in" && (!Array.isArray(p.in) || p.in.length === 0)) {
388
+ issues.push(issue(`${path}.in`, "META-TYPE", `${path}.in must be a non-empty array`));
389
+ }
390
+ if (forms[0] === "exists" && typeof p.exists !== "boolean") {
391
+ issues.push(issue(`${path}.exists`, "META-TYPE", `${path}.exists must be a boolean`));
392
+ }
393
+ }
394
+ function requireWhere(raw, path, issues) {
395
+ if (Array.isArray(raw)) {
396
+ if (raw.length === 0) {
397
+ issues.push(issue(path, "META-TYPE", `${path} must be a predicate or a non-empty array`));
398
+ return;
399
+ }
400
+ raw.forEach((p, i) => requirePredicate(p, `${path}[${i}]`, issues));
401
+ return;
402
+ }
403
+ requirePredicate(raw, path, issues);
404
+ }
405
+ function whereList(where) {
406
+ if (!where)
407
+ return [];
408
+ return Array.isArray(where) ? where : [where];
409
+ }
410
+ function rejectReservedBuckets(buckets, path, issues) {
411
+ if (!Array.isArray(buckets))
412
+ return;
413
+ for (let i = 0; i < buckets.length; i++) {
414
+ const b = buckets[i];
415
+ if (typeof b !== "string")
416
+ continue;
417
+ if (ANALYTICS_RESERVED_BUCKETS.includes(b.trim().toLowerCase())) {
418
+ issues.push(issue(`${path}[${i}]`, "META-ANALYTICS-BUCKET", `${path}[${i}] "${b}" is not a vocabulary member; use split.missing for absent values`));
419
+ }
420
+ }
421
+ }
422
+ function validateAnalytics(raw, path, issues) {
423
+ if (!raw || typeof raw !== "object") {
424
+ issues.push(issue(path, "META-TYPE", `${path} must be an object`));
425
+ return;
426
+ }
427
+ const a = raw;
428
+ requireNonEmptyString(a.id, `${path}.id`, issues);
429
+ requireNonEmptyString(a.name, `${path}.name`, issues);
430
+ if (a.scope === undefined) {
431
+ // organization-wide product
432
+ }
433
+ else if (!Array.isArray(a.scope) || a.scope.length !== 2) {
434
+ issues.push(issue(`${path}.scope`, "META-ANALYTICS-SCOPE", `${path}.scope must be an ordered pair of two properties`));
435
+ }
436
+ else {
437
+ for (let i = 0; i < 2; i++) {
438
+ const s = a.scope[i];
439
+ const sp = `${path}.scope[${i}]`;
440
+ if (!s || typeof s !== "object") {
441
+ issues.push(issue(sp, "META-TYPE", `${sp} must be an object`));
442
+ continue;
443
+ }
444
+ requireNonEmptyString(s.key, `${sp}.key`, issues);
445
+ requireNonEmptyString(s.abstractType, `${sp}.abstractType`, issues);
446
+ requireNonEmptyString(s.property, `${sp}.property`, issues);
447
+ }
448
+ }
449
+ if (a.clock !== undefined) {
450
+ if (!a.clock || typeof a.clock !== "object") {
451
+ issues.push(issue(`${path}.clock`, "META-TYPE", `${path}.clock must be an object`));
452
+ }
453
+ else {
454
+ requireNonEmptyString(a.clock.abstractType, `${path}.clock.abstractType`, issues);
455
+ requireNonEmptyString(a.clock.property, `${path}.clock.property`, issues);
456
+ }
457
+ }
458
+ if (!a.target || typeof a.target !== "object") {
459
+ issues.push(issue(`${path}.target`, "META-REQUIRED", `${path}.target is required`));
460
+ }
461
+ else {
462
+ requireNonEmptyString(a.target.objectType, `${path}.target.objectType`, issues);
463
+ requireNonEmptyString(a.target.contentType, `${path}.target.contentType`, issues);
464
+ requireNonEmptyString(a.target.recordId, `${path}.target.recordId`, issues);
465
+ }
466
+ if (a.history !== undefined) {
467
+ if (!a.history || typeof a.history !== "object") {
468
+ issues.push(issue(`${path}.history`, "META-TYPE", `${path}.history must be an object`));
469
+ }
470
+ else {
471
+ requireNonEmptyString(a.history.recordId, `${path}.history.recordId`, issues);
472
+ if (a.history.limit !== undefined) {
473
+ if (typeof a.history.limit !== "number" || !Number.isInteger(a.history.limit) || a.history.limit < 1) {
474
+ issues.push(issue(`${path}.history.limit`, "META-TYPE", `${path}.history.limit must be a positive integer`));
475
+ }
476
+ }
477
+ }
478
+ }
479
+ if (a.measures !== undefined) {
480
+ if (!Array.isArray(a.measures)) {
481
+ issues.push(issue(`${path}.measures`, "META-TYPE", `${path}.measures must be an array`));
482
+ }
483
+ else {
484
+ requireUniqueIds(a.measures.map((m) => ({ id: m.key })), `${path}.measures`, issues);
485
+ for (let i = 0; i < a.measures.length; i++) {
486
+ const m = a.measures[i];
487
+ const mp = `${path}.measures[${i}]`;
488
+ requireNonEmptyString(m?.key, `${mp}.key`, issues);
489
+ requireNonEmptyString(m?.abstractType, `${mp}.abstractType`, issues);
490
+ if (!m?.kind ||
491
+ !["count", "distinct", "sum", "mean", "max"].includes(m.kind)) {
492
+ issues.push(issue(`${mp}.kind`, "META-ANALYTICS", `${mp}.kind must be count|distinct|sum|mean|max`));
493
+ }
494
+ if (m?.kind && m.kind !== "count" && !(typeof m.property === "string" && m.property.trim())) {
495
+ issues.push(issue(`${mp}.property`, "META-REQUIRED", `${mp}.property is required for kind ${m.kind}`));
496
+ }
497
+ if (m?.where !== undefined)
498
+ requireWhere(m.where, `${mp}.where`, issues);
499
+ if (m?.windows !== undefined) {
500
+ if (m.kind !== "count") {
501
+ issues.push(issue(`${mp}.windows`, "META-ANALYTICS", `${mp}.windows is only valid for kind count`));
502
+ }
503
+ if (!Array.isArray(m.windows) || m.windows.length === 0) {
504
+ issues.push(issue(`${mp}.windows`, "META-TYPE", `${mp}.windows must be a non-empty array`));
505
+ }
506
+ else {
507
+ for (let j = 0; j < m.windows.length; j++) {
508
+ if (!["24h", "7d", "30d"].includes(m.windows[j])) {
509
+ issues.push(issue(`${mp}.windows[${j}]`, "META-ANALYTICS", `${mp}.windows[${j}] must be 24h|7d|30d`));
510
+ }
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+ }
517
+ if (a.groups !== undefined) {
518
+ if (!Array.isArray(a.groups)) {
519
+ issues.push(issue(`${path}.groups`, "META-TYPE", `${path}.groups must be an array`));
520
+ }
521
+ else {
522
+ requireUniqueIds(a.groups.map((g) => ({ id: g?.key })), `${path}.groups`, issues);
523
+ for (let i = 0; i < a.groups.length; i++) {
524
+ const g = a.groups[i];
525
+ const gp = `${path}.groups[${i}]`;
526
+ requireNonEmptyString(g?.key, `${gp}.key`, issues);
527
+ requireNonEmptyString(g?.abstractType, `${gp}.abstractType`, issues);
528
+ requireNonEmptyString(g?.property, `${gp}.property`, issues);
529
+ const openBuckets = g?.property === "@datasource" && g.buckets === undefined;
530
+ if (!openBuckets) {
531
+ if (!Array.isArray(g?.buckets) || g.buckets.length === 0) {
532
+ issues.push(issue(`${gp}.buckets`, "META-REQUIRED", `${gp}.buckets must be a non-empty array`));
533
+ }
534
+ else {
535
+ validateEnumList(g.buckets, `${gp}.buckets`, issues);
536
+ rejectReservedBuckets(g.buckets, `${gp}.buckets`, issues);
537
+ }
538
+ }
539
+ if (g?.subject !== undefined)
540
+ requireNonEmptyString(g.subject, `${gp}.subject`, issues);
541
+ if (g?.where !== undefined)
542
+ requireWhere(g.where, `${gp}.where`, issues);
543
+ if (g?.split !== undefined) {
544
+ const s = g.split;
545
+ const sp = `${gp}.split`;
546
+ if (!s || typeof s !== "object") {
547
+ issues.push(issue(sp, "META-TYPE", `${sp} must be an object`));
548
+ }
549
+ else {
550
+ requireNonEmptyString(s.key, `${sp}.key`, issues);
551
+ requireNonEmptyString(s.property, `${sp}.property`, issues);
552
+ if (!Array.isArray(s.buckets) || s.buckets.length === 0) {
553
+ issues.push(issue(`${sp}.buckets`, "META-REQUIRED", `${sp}.buckets must be a non-empty array`));
554
+ }
555
+ else {
556
+ validateEnumList(s.buckets, `${sp}.buckets`, issues);
557
+ rejectReservedBuckets(s.buckets, `${sp}.buckets`, issues);
558
+ }
559
+ if (s.missing !== undefined) {
560
+ if (requireNonEmptyString(s.missing, `${sp}.missing`, issues)) {
561
+ if (Array.isArray(s.buckets) && s.buckets.includes(s.missing)) {
562
+ issues.push(issue(`${sp}.missing`, "META-ANALYTICS-BUCKET", `${sp}.missing "${s.missing}" must not also be a declared bucket`));
563
+ }
564
+ }
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+ }
571
+ if (a.derived !== undefined) {
572
+ if (!Array.isArray(a.derived)) {
573
+ issues.push(issue(`${path}.derived`, "META-TYPE", `${path}.derived must be an array`));
574
+ }
575
+ else {
576
+ requireUniqueIds(a.derived.map((d) => ({ id: d?.key })), `${path}.derived`, issues);
577
+ for (let i = 0; i < a.derived.length; i++) {
578
+ const d = a.derived[i];
579
+ const dp = `${path}.derived[${i}]`;
580
+ requireNonEmptyString(d?.key, `${dp}.key`, issues);
581
+ if (!d?.op || !["sum", "difference", "ratio"].includes(d.op)) {
582
+ issues.push(issue(`${dp}.op`, "META-ANALYTICS", `${dp}.op must be sum|difference|ratio`));
583
+ }
584
+ if (!Array.isArray(d?.terms) || d.terms.length === 0) {
585
+ issues.push(issue(`${dp}.terms`, "META-REQUIRED", `${dp}.terms must be a non-empty array`));
586
+ continue;
587
+ }
588
+ d.terms.forEach((t, j) => requireNonEmptyString(t, `${dp}.terms[${j}]`, issues));
589
+ if (d.op === "ratio" && d.terms.length !== 2) {
590
+ issues.push(issue(`${dp}.terms`, "META-ANALYTICS", `${dp}.terms must have exactly two terms for ratio`));
591
+ }
592
+ if (d.op === "difference" && d.terms.length < 2) {
593
+ issues.push(issue(`${dp}.terms`, "META-ANALYTICS", `${dp}.terms must have at least two terms for difference`));
594
+ }
595
+ }
596
+ }
597
+ }
598
+ if (a.ratios !== undefined) {
599
+ if (!Array.isArray(a.ratios)) {
600
+ issues.push(issue(`${path}.ratios`, "META-TYPE", `${path}.ratios must be an array`));
601
+ }
602
+ else {
603
+ for (let i = 0; i < a.ratios.length; i++) {
604
+ const r = a.ratios[i];
605
+ const rp = `${path}.ratios[${i}]`;
606
+ requireNonEmptyString(r?.key, `${rp}.key`, issues);
607
+ requireNonEmptyString(r?.numerator, `${rp}.numerator`, issues);
608
+ requireNonEmptyString(r?.denominator, `${rp}.denominator`, issues);
609
+ }
610
+ }
611
+ }
612
+ if (a.splits !== undefined) {
613
+ if (!Array.isArray(a.splits)) {
614
+ issues.push(issue(`${path}.splits`, "META-TYPE", `${path}.splits must be an array`));
615
+ }
616
+ else {
617
+ for (let i = 0; i < a.splits.length; i++) {
618
+ const s = a.splits[i];
619
+ const sp = `${path}.splits[${i}]`;
620
+ requireNonEmptyString(s?.key, `${sp}.key`, issues);
621
+ requireNonEmptyString(s?.abstractType, `${sp}.abstractType`, issues);
622
+ requireNonEmptyString(s?.property, `${sp}.property`, issues);
623
+ requireNonEmptyString(s?.totalKey, `${sp}.totalKey`, issues);
624
+ requireNonEmptyString(s?.subsetKey, `${sp}.subsetKey`, issues);
625
+ if (!Array.isArray(s?.buckets) || s.buckets.length === 0) {
626
+ issues.push(issue(`${sp}.buckets`, "META-REQUIRED", `${sp}.buckets must be a non-empty array`));
627
+ }
628
+ else {
629
+ validateEnumList(s.buckets, `${sp}.buckets`, issues);
630
+ }
631
+ if (s?.subset !== undefined)
632
+ requirePredicate(s.subset, `${sp}.subset`, issues);
633
+ else
634
+ issues.push(issue(`${sp}.subset`, "META-REQUIRED", `${sp}.subset is required`));
635
+ }
636
+ }
637
+ }
638
+ if (a.unwinds !== undefined) {
639
+ if (!Array.isArray(a.unwinds)) {
640
+ issues.push(issue(`${path}.unwinds`, "META-TYPE", `${path}.unwinds must be an array`));
641
+ }
642
+ else {
643
+ for (let i = 0; i < a.unwinds.length; i++) {
644
+ const u = a.unwinds[i];
645
+ const up = `${path}.unwinds[${i}]`;
646
+ requireNonEmptyString(u?.key, `${up}.key`, issues);
647
+ requireNonEmptyString(u?.abstractType, `${up}.abstractType`, issues);
648
+ requireNonEmptyString(u?.arrayProperty, `${up}.arrayProperty`, issues);
649
+ if (!Array.isArray(u?.columns) || u.columns.length === 0) {
650
+ issues.push(issue(`${up}.columns`, "META-REQUIRED", `${up}.columns must be a non-empty array`));
651
+ }
652
+ else {
653
+ for (let j = 0; j < u.columns.length; j++) {
654
+ requireNonEmptyString(u.columns[j]?.key, `${up}.columns[${j}].key`, issues);
655
+ requireNonEmptyString(u.columns[j]?.path, `${up}.columns[${j}].path`, issues);
656
+ }
657
+ }
658
+ }
659
+ }
660
+ }
661
+ if (a.leaders !== undefined) {
662
+ if (!Array.isArray(a.leaders)) {
663
+ issues.push(issue(`${path}.leaders`, "META-TYPE", `${path}.leaders must be an array`));
664
+ }
665
+ else {
666
+ for (let i = 0; i < a.leaders.length; i++) {
667
+ const l = a.leaders[i];
668
+ const lp = `${path}.leaders[${i}]`;
669
+ requireNonEmptyString(l?.key, `${lp}.key`, issues);
670
+ requireNonEmptyString(l?.abstractType, `${lp}.abstractType`, issues);
671
+ requireNonEmptyString(l?.idProperty, `${lp}.idProperty`, issues);
672
+ requireNonEmptyString(l?.labelProperty, `${lp}.labelProperty`, issues);
673
+ if (l?.countOf !== undefined) {
674
+ if (!l.countOf || typeof l.countOf !== "object") {
675
+ issues.push(issue(`${lp}.countOf`, "META-TYPE", `${lp}.countOf must be an object`));
676
+ }
677
+ else {
678
+ requireNonEmptyString(l.countOf.abstractType, `${lp}.countOf.abstractType`, issues);
679
+ requireNonEmptyString(l.countOf.property, `${lp}.countOf.property`, issues);
680
+ if (l.countOf.where !== undefined)
681
+ requireWhere(l.countOf.where, `${lp}.countOf.where`, issues);
682
+ }
683
+ }
684
+ if (l?.sort === undefined && l?.countOf !== undefined) {
685
+ // ranked by count
686
+ }
687
+ else if (!Array.isArray(l?.sort) || l.sort.length === 0) {
688
+ issues.push(issue(`${lp}.sort`, "META-REQUIRED", `${lp}.sort must be a non-empty array`));
689
+ }
690
+ else {
691
+ for (let j = 0; j < l.sort.length; j++) {
692
+ requireNonEmptyString(l.sort[j]?.property, `${lp}.sort[${j}].property`, issues);
693
+ }
694
+ }
695
+ }
696
+ }
697
+ }
698
+ if (a.coverage !== undefined) {
699
+ if (!Array.isArray(a.coverage)) {
700
+ issues.push(issue(`${path}.coverage`, "META-TYPE", `${path}.coverage must be an array`));
701
+ }
702
+ else {
703
+ for (let i = 0; i < a.coverage.length; i++) {
704
+ const c = a.coverage[i];
705
+ const cp = `${path}.coverage[${i}]`;
706
+ requireNonEmptyString(c?.key, `${cp}.key`, issues);
707
+ requireNonEmptyString(c?.abstractType, `${cp}.abstractType`, issues);
708
+ if (typeof c?.expected === "number") {
709
+ if (!Number.isFinite(c.expected) || c.expected < 0) {
710
+ issues.push(issue(`${cp}.expected`, "META-TYPE", `${cp}.expected must be a non-negative number`));
711
+ }
712
+ }
713
+ else if (c?.expected && typeof c.expected === "object" && "measure" in c.expected) {
714
+ requireNonEmptyString(c.expected.measure, `${cp}.expected.measure`, issues);
715
+ }
716
+ else {
717
+ issues.push(issue(`${cp}.expected`, "META-REQUIRED", `${cp}.expected must be a number or { measure }`));
718
+ }
719
+ if (c?.where !== undefined)
720
+ requireWhere(c.where, `${cp}.where`, issues);
721
+ }
722
+ }
723
+ }
724
+ if (a.deltas !== undefined) {
725
+ if (!Array.isArray(a.deltas)) {
726
+ issues.push(issue(`${path}.deltas`, "META-TYPE", `${path}.deltas must be an array`));
727
+ }
728
+ else {
729
+ for (let i = 0; i < a.deltas.length; i++) {
730
+ requireNonEmptyString(a.deltas[i]?.key, `${path}.deltas[${i}].key`, issues);
731
+ requireNonEmptyString(a.deltas[i]?.measure, `${path}.deltas[${i}].measure`, issues);
732
+ }
733
+ }
734
+ }
735
+ if (a.transitions !== undefined) {
736
+ if (!Array.isArray(a.transitions)) {
737
+ issues.push(issue(`${path}.transitions`, "META-TYPE", `${path}.transitions must be an array`));
738
+ }
739
+ else {
740
+ for (let i = 0; i < a.transitions.length; i++) {
741
+ const t = a.transitions[i];
742
+ const tp = `${path}.transitions[${i}]`;
743
+ requireNonEmptyString(t?.key, `${tp}.key`, issues);
744
+ requireNonEmptyString(t?.abstractType, `${tp}.abstractType`, issues);
745
+ requireNonEmptyString(t?.property, `${tp}.property`, issues);
746
+ }
747
+ }
748
+ }
749
+ if (a.trends !== undefined) {
750
+ if (!Array.isArray(a.trends)) {
751
+ issues.push(issue(`${path}.trends`, "META-TYPE", `${path}.trends must be an array`));
752
+ }
753
+ else {
754
+ for (let i = 0; i < a.trends.length; i++) {
755
+ const t = a.trends[i];
756
+ const tp = `${path}.trends[${i}]`;
757
+ requireNonEmptyString(t?.key, `${tp}.key`, issues);
758
+ requireNonEmptyString(t?.label, `${tp}.label`, issues);
759
+ requireNonEmptyString(t?.measure, `${tp}.measure`, issues);
760
+ if (!Array.isArray(t?.windows) || t.windows.length === 0) {
761
+ issues.push(issue(`${tp}.windows`, "META-REQUIRED", `${tp}.windows must be a non-empty array`));
762
+ }
763
+ else {
764
+ for (let j = 0; j < t.windows.length; j++) {
765
+ if (!["snapshots", "7d", "30d"].includes(t.windows[j])) {
766
+ issues.push(issue(`${tp}.windows[${j}]`, "META-ANALYTICS", `${tp}.windows[${j}] must be snapshots|7d|30d`));
767
+ }
768
+ }
769
+ }
770
+ }
771
+ }
772
+ }
773
+ }
334
774
  function validateSource(raw, path, issues) {
335
775
  if (!raw || typeof raw !== "object") {
336
776
  issues.push(issue(path, "META-TYPE", `${path} must be an object`));
@@ -708,6 +1148,166 @@ function validateReferenceIntegrity(pack, issues) {
708
1148
  issues.push(issue(`writes/${w.id}.objectType`, "META-REF-OBJECT", `write references unknown object type "${w.objectType}"`));
709
1149
  }
710
1150
  }
1151
+ const absProps = new Map();
1152
+ for (const abs of pack.abstractTypes) {
1153
+ const props = new Map();
1154
+ for (const p of abs.properties ?? []) {
1155
+ if (p?.id)
1156
+ props.set(p.id, { enum: p.enum });
1157
+ }
1158
+ absProps.set(abs.id, props);
1159
+ }
1160
+ const measureKeysByAnalytics = new Map();
1161
+ for (const an of pack.analytics ?? []) {
1162
+ const measureKeys = new Set((an.measures ?? []).map((m) => m.key).filter(Boolean));
1163
+ measureKeysByAnalytics.set(an.id, measureKeys);
1164
+ const checkAbsProp = (abstractType, property, path) => {
1165
+ if (!abstractIds.has(abstractType)) {
1166
+ issues.push(issue(path, "META-REF-ABSTRACT", `analytics references unknown abstract "${abstractType}"`));
1167
+ return null;
1168
+ }
1169
+ if (ANALYTICS_PSEUDO_PROPERTIES.includes(property))
1170
+ return null;
1171
+ const props = absProps.get(abstractType);
1172
+ if (props && props.size > 0 && !props.has(property)) {
1173
+ issues.push(issue(path, "META-REF-PROP", `analytics references unknown property "${property}" on "${abstractType}"`));
1174
+ return null;
1175
+ }
1176
+ return props?.get(property) ?? null;
1177
+ };
1178
+ for (const s of an.scope ?? []) {
1179
+ if (s?.abstractType && s?.property) {
1180
+ checkAbsProp(s.abstractType, s.property, `analytics/${an.id}.scope`);
1181
+ }
1182
+ }
1183
+ if (an.clock?.abstractType && an.clock.property) {
1184
+ checkAbsProp(an.clock.abstractType, an.clock.property, `analytics/${an.id}.clock`);
1185
+ }
1186
+ if (an.target?.objectType && !objectTypeIds.has(an.target.objectType)) {
1187
+ issues.push(issue(`analytics/${an.id}.target.objectType`, "META-REF-OBJECT", `analytics target references unknown object type "${an.target.objectType}"`));
1188
+ }
1189
+ for (const m of an.measures ?? []) {
1190
+ if (m.abstractType && !abstractIds.has(m.abstractType)) {
1191
+ issues.push(issue(`analytics/${an.id}.measures/${m.key}`, "META-REF-ABSTRACT", `analytics references unknown abstract "${m.abstractType}"`));
1192
+ }
1193
+ if (m.property && m.abstractType) {
1194
+ checkAbsProp(m.abstractType, m.property, `analytics/${an.id}.measures/${m.key}.property`);
1195
+ }
1196
+ for (const p of whereList(m.where)) {
1197
+ if (p?.property && m.abstractType) {
1198
+ checkAbsProp(m.abstractType, p.property, `analytics/${an.id}.measures/${m.key}.where`);
1199
+ }
1200
+ }
1201
+ }
1202
+ const checkBucketsInEnum = (prop, buckets, path, label) => {
1203
+ if (!prop?.enum || !Array.isArray(buckets))
1204
+ return;
1205
+ for (const b of buckets) {
1206
+ if (!prop.enum.includes(b)) {
1207
+ issues.push(issue(path, "META-ENUM-BUCKET", `bucket "${b}" is not in enum for ${label}`));
1208
+ }
1209
+ }
1210
+ };
1211
+ const groupBuckets = new Map();
1212
+ for (const g of an.groups ?? []) {
1213
+ const prop = g.abstractType && g.property
1214
+ ? checkAbsProp(g.abstractType, g.property, `analytics/${an.id}.groups/${g.key}`)
1215
+ : null;
1216
+ checkBucketsInEnum(prop, g.buckets, `analytics/${an.id}.groups/${g.key}.buckets`, `${g.abstractType}.${g.property}`);
1217
+ if (g.key)
1218
+ groupBuckets.set(g.key, Array.isArray(g.buckets) ? new Set(g.buckets) : "open");
1219
+ for (const p of whereList(g.where)) {
1220
+ if (p?.property && g.abstractType) {
1221
+ checkAbsProp(g.abstractType, p.property, `analytics/${an.id}.groups/${g.key}.where`);
1222
+ }
1223
+ }
1224
+ if (g.split?.property && g.abstractType) {
1225
+ const splitProp = checkAbsProp(g.abstractType, g.split.property, `analytics/${an.id}.groups/${g.key}.split`);
1226
+ checkBucketsInEnum(splitProp, g.split.buckets, `analytics/${an.id}.groups/${g.key}.split.buckets`, `${g.abstractType}.${g.split.property}`);
1227
+ }
1228
+ }
1229
+ const ratioKeys = new Set((an.ratios ?? []).map((r) => r.key).filter(Boolean));
1230
+ const derivedSoFar = new Set();
1231
+ for (const d of an.derived ?? []) {
1232
+ for (const term of d.terms ?? []) {
1233
+ if (typeof term !== "string" || !term)
1234
+ continue;
1235
+ let known = measureKeys.has(term) || ratioKeys.has(term) || derivedSoFar.has(term);
1236
+ for (let dot = term.indexOf("."); !known && dot > 0; dot = term.indexOf(".", dot + 1)) {
1237
+ const buckets = groupBuckets.get(term.slice(0, dot));
1238
+ known = buckets === "open" || Boolean(buckets?.has(term.slice(dot + 1)));
1239
+ }
1240
+ if (!known) {
1241
+ issues.push(issue(`analytics/${an.id}.derived/${d.key}`, "META-REF-MEASURE", `derived term "${term}" is not a measure, ratio, earlier derived key, or group.bucket`));
1242
+ }
1243
+ }
1244
+ if (d.key)
1245
+ derivedSoFar.add(d.key);
1246
+ }
1247
+ for (const s of an.splits ?? []) {
1248
+ const prop = s.abstractType && s.property
1249
+ ? checkAbsProp(s.abstractType, s.property, `analytics/${an.id}.splits/${s.key}`)
1250
+ : null;
1251
+ if (prop?.enum && Array.isArray(s.buckets)) {
1252
+ for (const b of s.buckets) {
1253
+ if (!prop.enum.includes(b)) {
1254
+ issues.push(issue(`analytics/${an.id}.splits/${s.key}.buckets`, "META-ENUM-BUCKET", `bucket "${b}" is not in enum for ${s.abstractType}.${s.property}`));
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ for (const u of an.unwinds ?? []) {
1260
+ if (u.abstractType && u.arrayProperty) {
1261
+ checkAbsProp(u.abstractType, u.arrayProperty, `analytics/${an.id}.unwinds/${u.key}`);
1262
+ }
1263
+ }
1264
+ for (const l of an.leaders ?? []) {
1265
+ if (l.abstractType) {
1266
+ checkAbsProp(l.abstractType, l.idProperty, `analytics/${an.id}.leaders/${l.key}.idProperty`);
1267
+ checkAbsProp(l.abstractType, l.labelProperty, `analytics/${an.id}.leaders/${l.key}.labelProperty`);
1268
+ }
1269
+ if (l.countOf?.abstractType && l.countOf.property) {
1270
+ checkAbsProp(l.countOf.abstractType, l.countOf.property, `analytics/${an.id}.leaders/${l.key}.countOf`);
1271
+ for (const p of whereList(l.countOf.where)) {
1272
+ if (p?.property) {
1273
+ checkAbsProp(l.countOf.abstractType, p.property, `analytics/${an.id}.leaders/${l.key}.countOf.where`);
1274
+ }
1275
+ }
1276
+ }
1277
+ }
1278
+ for (const c of an.coverage ?? []) {
1279
+ if (c.abstractType && !abstractIds.has(c.abstractType)) {
1280
+ issues.push(issue(`analytics/${an.id}.coverage/${c.key}`, "META-REF-ABSTRACT", `analytics references unknown abstract "${c.abstractType}"`));
1281
+ }
1282
+ for (const p of whereList(c.where)) {
1283
+ if (p?.property && c.abstractType) {
1284
+ checkAbsProp(c.abstractType, p.property, `analytics/${an.id}.coverage/${c.key}.where`);
1285
+ }
1286
+ }
1287
+ if (typeof c.expected === "object" && c.expected?.measure && !measureKeys.has(c.expected.measure)) {
1288
+ issues.push(issue(`analytics/${an.id}.coverage/${c.key}.expected`, "META-REF-MEASURE", `coverage expected measure "${c.expected.measure}" unknown`));
1289
+ }
1290
+ }
1291
+ for (const d of an.deltas ?? []) {
1292
+ if (d.measure && !measureKeys.has(d.measure)) {
1293
+ issues.push(issue(`analytics/${an.id}.deltas/${d.key}`, "META-REF-MEASURE", `delta references unknown measure "${d.measure}"`));
1294
+ }
1295
+ }
1296
+ for (const t of an.trends ?? []) {
1297
+ if (t.measure && !measureKeys.has(t.measure)) {
1298
+ issues.push(issue(`analytics/${an.id}.trends/${t.key}`, "META-REF-MEASURE", `trend references unknown measure "${t.measure}"`));
1299
+ }
1300
+ }
1301
+ for (const r of an.ratios ?? []) {
1302
+ if (r.numerator && !measureKeys.has(r.numerator)) {
1303
+ issues.push(issue(`analytics/${an.id}.ratios/${r.key}`, "META-REF-MEASURE", `ratio numerator "${r.numerator}" unknown`));
1304
+ }
1305
+ if (r.denominator && !measureKeys.has(r.denominator)) {
1306
+ issues.push(issue(`analytics/${an.id}.ratios/${r.key}`, "META-REF-MEASURE", `ratio denominator "${r.denominator}" unknown`));
1307
+ }
1308
+ }
1309
+ }
1310
+ void measureKeysByAnalytics;
711
1311
  for (const p of pack.pipelines) {
712
1312
  for (const step of p.steps) {
713
1313
  if (step.sourceId && !sourceIds.has(step.sourceId)) {
@@ -823,6 +1423,7 @@ export function validateMetadataPack(input, opts) {
823
1423
  ["services", pack.services, validateService],
824
1424
  ["pipelines", pack.pipelines, validatePipeline],
825
1425
  ["schedules", pack.schedules, validateSchedule],
1426
+ ["analytics", pack.analytics ?? [], validateAnalytics],
826
1427
  ["kindDeclarations", pack.kindDeclarations ?? [], validateKindDeclaration],
827
1428
  ];
828
1429
  for (const [name, value, validator] of arrays) {