@studyportals/data-api-interface 0.0.0-stage → 0.1.0-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/README.md +89 -2
- package/dist/index.d.mts +109 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +184 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -5
- package/schema/import-record.schema.json +161 -0
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @studyportals/data-api-interface
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Schema validation for Data API import records. Producers (SIA) validate records with it before pushing them into the Import Data Stream; the import pipeline runs the same code as its schema-level validation, so both sides apply identical rules.
|
|
4
|
+
|
|
5
|
+
Pure functions only: no AWS SDK, no network, no I/O. Ships ES module and CommonJS builds with types. Node 24 or later.
|
|
6
|
+
|
|
7
|
+
## Record
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { ImportRecord } from "@studyportals/data-api-interface";
|
|
11
|
+
|
|
12
|
+
const record: ImportRecord = {
|
|
13
|
+
entityType: "study",
|
|
14
|
+
entityId: 123,
|
|
15
|
+
eventId: "…", // issued by the registration endpoint
|
|
16
|
+
eventTimestamp: 1790165122, // epoch seconds, set right before pushing
|
|
17
|
+
set: {
|
|
18
|
+
tuitionFees: [{ target: "international", amount: 18500, currency: "EUR", unit: "year", discount: { type: "percentage", value: 10 } }],
|
|
19
|
+
tuitionFeeDescription: "Tuition per academic year",
|
|
20
|
+
},
|
|
21
|
+
clear: {
|
|
22
|
+
tuitionFees: ["eea"],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `set` gives properties a new value; `clear` removes values. Every action is explicit: setting never removes anything. A record may clear and set the same thing: clear is applied first, then set, so it replaces.
|
|
28
|
+
- What `clear` holds depends on the property: `true` for a single value (`tuitionFeeDescription: true`), the keys of the items to remove for a list (`tuitionFees: ["eea", "local"]`; removing every fee lists every target).
|
|
29
|
+
- Only properties in the registry are writable; anything else is rejected.
|
|
30
|
+
- Writable properties: `tuitionFees` and `tuitionFeeDescription` (1–160 characters).
|
|
31
|
+
- `tuitionFees` holds `tuition_fees` rows to create or replace, matched by `target` (`eea`, `national`, `international`, `local`); fees of other targets stay. The list is not empty, has at most one fee per target, one currency and one unit for all fees, and no `eea` fee next to a `national` or `local` fee.
|
|
32
|
+
- A fee has a `target`, an integer `amount` (≥ 0), an ISO 4217 `currency` code, a `unit` (`module`, `day`, `week`, `month`, `quarter`, `trimester`, `semester`, `year`, `credit`, `full`) and a `discount`, always given: `percentage` with 1 ≤ value ≤ 99.99, or `flat` with value 0 (no discount; the database has no null) or 1 ≤ value ≤ amount − 1. A percentage of 0 is not allowed.
|
|
33
|
+
|
|
34
|
+
The JSON Schema of the issued record is published as `@studyportals/data-api-interface/import-record.schema.json`. It cannot express cross-field rules such as the flat-discount bound; the TypeScript validators are authoritative.
|
|
35
|
+
|
|
36
|
+
## Producers
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { validateDraft, validateRecord } from "@studyportals/data-api-interface";
|
|
40
|
+
|
|
41
|
+
const draft = validateDraft({ entityType: "study", entityId: 123, set, clear }); // before requesting eventIds
|
|
42
|
+
if (!draft.valid) { … }
|
|
43
|
+
|
|
44
|
+
const result = validateRecord({ ...draft.record, eventId, eventTimestamp }); // right before PutRecords
|
|
45
|
+
if (!result.valid) {
|
|
46
|
+
// result.stage: "schema" | "property"; result.failures: every failure of that stage
|
|
47
|
+
for (const { code, property, path, reason } of result.failures) { … }
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Failure codes: `SCHEMA_INVALID` (wrong shape: envelope, unsupported or missing keys, wrong JSON types), `PROPERTY_NOT_WRITABLE`, `PROPERTY_VALUE_INVALID` (a well-shaped value that breaks a rule, e.g. currency format or one fee per target). Paths start at the record, e.g. `set.tuitionFees.0.amount`. Reasons name properties and expectations, never record values.
|
|
52
|
+
|
|
53
|
+
A complete, runnable producer flow is in [`examples/producer.mts`](examples/producer.mts); `npm run test-consumers` type-checks and runs it against the packed package.
|
|
54
|
+
|
|
55
|
+
Passing these checks does not guarantee the import succeeds: the pipeline also runs validators that need existing data, for example that `day` and `week` units are only allowed for short programmes.
|
|
56
|
+
|
|
57
|
+
## Import pipeline
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { registry, resolveEntries, validateProperties, validateSchema, validateValue, type RegistryKey } from "@studyportals/data-api-interface";
|
|
61
|
+
|
|
62
|
+
const schema = validateSchema(input); // phase 1: envelope and shape of known properties
|
|
63
|
+
// claim the identifier, drop stale properties
|
|
64
|
+
const simple = validateProperties(currentRecord); // phase 2: allowlist and record-only validators
|
|
65
|
+
// enrichment and dependent validators for resolveEntries(currentRecord)
|
|
66
|
+
const failures = validateValue("tuitionFees", mergedFees); // the record's rules, re-run on existing fees merged with the record's
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Extend every registry entry by key with the pipeline's enrichment and dependent validators. The mapped type makes the pipeline fail to compile when a new package version adds an entry it does not handle:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const pipelineRegistry: { readonly [K in RegistryKey]: PipelineEntry } = {
|
|
73
|
+
tuitionFees: { ...registry.tuitionFees, enrich: [Enrichment.STUDY_LEVEL, Enrichment.TUITION_FEE_EXISTING], dependent: [mergedFeesValid, unitMatchesLevel] },
|
|
74
|
+
tuitionFeeDescription: { ...registry.tuitionFeeDescription, enrich: [], dependent: [] },
|
|
75
|
+
};
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
Develop on Node 26 (`.nvmrc`); tests must also pass on Node 24.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm ci
|
|
84
|
+
npm run lint
|
|
85
|
+
npm run test-u # tsc + Vitest, also pins schema/import-record.schema.json
|
|
86
|
+
npm run build # tsup → dist/ (CJS .js + ESM .mjs, .d.ts/.d.mts)
|
|
87
|
+
npm run test-consumers # packs, installs, then import/require and type checks under nodenext, bundler, node10
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
After an intended contract change, regenerate the JSON Schema with `npx vitest run -u` and review the diff.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** A record before the registration endpoint has issued its identifiers. */
|
|
2
|
+
interface ImportRecordDraft {
|
|
3
|
+
readonly entityType: "study";
|
|
4
|
+
readonly entityId: number;
|
|
5
|
+
readonly set: Partial<PropertyValues>;
|
|
6
|
+
readonly clear: Partial<PropertyClears>;
|
|
7
|
+
}
|
|
8
|
+
interface ImportRecord extends ImportRecordDraft {
|
|
9
|
+
readonly eventId: string;
|
|
10
|
+
/** Epoch seconds, set by the producer right before the record is pushed. */
|
|
11
|
+
readonly eventTimestamp: number;
|
|
12
|
+
}
|
|
13
|
+
/** A record whose envelope is valid but whose properties are not checked yet. */
|
|
14
|
+
type Unchecked<T extends ImportRecordDraft> = Omit<T, "set" | "clear"> & {
|
|
15
|
+
readonly set: Readonly<Record<string, unknown>>;
|
|
16
|
+
readonly clear: Readonly<Record<string, unknown>>;
|
|
17
|
+
};
|
|
18
|
+
type ErrorCode = "SCHEMA_INVALID" | "PROPERTY_NOT_WRITABLE" | "PROPERTY_VALUE_INVALID";
|
|
19
|
+
interface Failure<Code extends string = ErrorCode> {
|
|
20
|
+
readonly code: Code;
|
|
21
|
+
readonly property?: string;
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
/** Names properties and expectations, never record values. */
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
}
|
|
26
|
+
type ValidationResult<T> = {
|
|
27
|
+
readonly valid: true;
|
|
28
|
+
readonly record: T;
|
|
29
|
+
} | {
|
|
30
|
+
readonly valid: false;
|
|
31
|
+
readonly stage: "schema" | "property";
|
|
32
|
+
readonly failures: readonly Failure[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
declare const TUITION_FEE_TARGETS: readonly ["eea", "national", "international", "local"];
|
|
36
|
+
type TuitionFeeTarget = (typeof TUITION_FEE_TARGETS)[number];
|
|
37
|
+
declare const TUITION_FEE_UNITS: readonly ["module", "day", "week", "month", "quarter", "trimester", "semester", "year", "credit", "full"];
|
|
38
|
+
type TuitionFeeUnit = (typeof TUITION_FEE_UNITS)[number];
|
|
39
|
+
interface TuitionFeeDiscount {
|
|
40
|
+
readonly type: "flat" | "percentage";
|
|
41
|
+
readonly value: number;
|
|
42
|
+
}
|
|
43
|
+
/** One `tuition_fees` row. */
|
|
44
|
+
interface TuitionFee {
|
|
45
|
+
readonly target: TuitionFeeTarget;
|
|
46
|
+
readonly amount: number;
|
|
47
|
+
readonly currency: string;
|
|
48
|
+
readonly unit: TuitionFeeUnit;
|
|
49
|
+
/** Always given: a flat discount of 0 means no discount, the database has no null for it. */
|
|
50
|
+
readonly discount: TuitionFeeDiscount;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PropertyValues {
|
|
54
|
+
/** Fees to create, matched by target. A record that also clears a target replaces that fee: clear is applied first. */
|
|
55
|
+
tuitionFees: readonly TuitionFee[];
|
|
56
|
+
tuitionFeeDescription: string;
|
|
57
|
+
}
|
|
58
|
+
interface PropertyClears {
|
|
59
|
+
/** Targets whose fees are removed. Removing every fee lists every target. */
|
|
60
|
+
tuitionFees: readonly TuitionFeeTarget[];
|
|
61
|
+
tuitionFeeDescription: true;
|
|
62
|
+
}
|
|
63
|
+
type PropertyName = keyof PropertyValues;
|
|
64
|
+
type PropertyFailure = Failure<"PROPERTY_VALUE_INVALID">;
|
|
65
|
+
/**
|
|
66
|
+
* Checks one property value; paths are relative to the value. Producers get it through the validate
|
|
67
|
+
* functions; the import pipeline also runs it on the enriched value, e.g. existing fees merged with the record's.
|
|
68
|
+
*/
|
|
69
|
+
declare const validateValue: <P extends PropertyName>(property: P, value: unknown) => PropertyFailure[];
|
|
70
|
+
|
|
71
|
+
interface SimpleContext {
|
|
72
|
+
readonly record: Unchecked<ImportRecordDraft>;
|
|
73
|
+
/** The entry's properties that the record sets or clears. */
|
|
74
|
+
readonly touched: readonly PropertyName[];
|
|
75
|
+
}
|
|
76
|
+
/** Returns every failure it finds; an empty array means valid. */
|
|
77
|
+
type Validator<Context = SimpleContext, Code extends string = Failure["code"]> = (context: Context) => Failure<Code>[];
|
|
78
|
+
interface RegistryEntry {
|
|
79
|
+
readonly properties: readonly PropertyName[];
|
|
80
|
+
/** Record-only validators. Run as a chain that stops at the first validator reporting failures. */
|
|
81
|
+
readonly simple: readonly Validator[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The writable-property allowlist. The import pipeline extends each entry by key with its
|
|
85
|
+
* enrichment and dependent validators, typed `{ [K in RegistryKey]: … }` so a new entry
|
|
86
|
+
* cannot be missed.
|
|
87
|
+
*/
|
|
88
|
+
declare const registry: {
|
|
89
|
+
readonly tuitionFees: RegistryEntry;
|
|
90
|
+
readonly tuitionFeeDescription: RegistryEntry;
|
|
91
|
+
};
|
|
92
|
+
type RegistryKey = keyof typeof registry;
|
|
93
|
+
/** Keys of the registry entries covering the record's touched properties. Unknown properties are ignored. */
|
|
94
|
+
declare const resolveEntries: (record: Unchecked<ImportRecordDraft>) => RegistryKey[];
|
|
95
|
+
|
|
96
|
+
/** Phase 1 of the pipeline (and the first half of {@link validateRecord}): envelope and the shape of known properties. */
|
|
97
|
+
declare const validateSchema: (input: unknown) => ValidationResult<Unchecked<ImportRecord>>;
|
|
98
|
+
/**
|
|
99
|
+
* Phase 2, simple part: writable-property allowlist and record-only validators. The pipeline calls it after
|
|
100
|
+
* dropping stale properties and before enrichment.
|
|
101
|
+
*/
|
|
102
|
+
declare const validateProperties: (record: Unchecked<ImportRecord>) => ValidationResult<ImportRecord>;
|
|
103
|
+
/** For producers, before the registration endpoint has issued `eventId`. */
|
|
104
|
+
declare const validateDraft: (input: unknown) => ValidationResult<ImportRecordDraft>;
|
|
105
|
+
/** For producers, right before the record is pushed into the Data Stream. */
|
|
106
|
+
declare const validateRecord: (input: unknown) => ValidationResult<ImportRecord>;
|
|
107
|
+
|
|
108
|
+
export { registry, resolveEntries, validateDraft, validateProperties, validateRecord, validateSchema, validateValue };
|
|
109
|
+
export type { ErrorCode, Failure, ImportRecord, ImportRecordDraft, PropertyClears, PropertyName, PropertyValues, RegistryEntry, RegistryKey, SimpleContext, TuitionFee, TuitionFeeDiscount, TuitionFeeTarget, TuitionFeeUnit, Unchecked, ValidationResult, Validator };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** A record before the registration endpoint has issued its identifiers. */
|
|
2
|
+
interface ImportRecordDraft {
|
|
3
|
+
readonly entityType: "study";
|
|
4
|
+
readonly entityId: number;
|
|
5
|
+
readonly set: Partial<PropertyValues>;
|
|
6
|
+
readonly clear: Partial<PropertyClears>;
|
|
7
|
+
}
|
|
8
|
+
interface ImportRecord extends ImportRecordDraft {
|
|
9
|
+
readonly eventId: string;
|
|
10
|
+
/** Epoch seconds, set by the producer right before the record is pushed. */
|
|
11
|
+
readonly eventTimestamp: number;
|
|
12
|
+
}
|
|
13
|
+
/** A record whose envelope is valid but whose properties are not checked yet. */
|
|
14
|
+
type Unchecked<T extends ImportRecordDraft> = Omit<T, "set" | "clear"> & {
|
|
15
|
+
readonly set: Readonly<Record<string, unknown>>;
|
|
16
|
+
readonly clear: Readonly<Record<string, unknown>>;
|
|
17
|
+
};
|
|
18
|
+
type ErrorCode = "SCHEMA_INVALID" | "PROPERTY_NOT_WRITABLE" | "PROPERTY_VALUE_INVALID";
|
|
19
|
+
interface Failure<Code extends string = ErrorCode> {
|
|
20
|
+
readonly code: Code;
|
|
21
|
+
readonly property?: string;
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
/** Names properties and expectations, never record values. */
|
|
24
|
+
readonly reason: string;
|
|
25
|
+
}
|
|
26
|
+
type ValidationResult<T> = {
|
|
27
|
+
readonly valid: true;
|
|
28
|
+
readonly record: T;
|
|
29
|
+
} | {
|
|
30
|
+
readonly valid: false;
|
|
31
|
+
readonly stage: "schema" | "property";
|
|
32
|
+
readonly failures: readonly Failure[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
declare const TUITION_FEE_TARGETS: readonly ["eea", "national", "international", "local"];
|
|
36
|
+
type TuitionFeeTarget = (typeof TUITION_FEE_TARGETS)[number];
|
|
37
|
+
declare const TUITION_FEE_UNITS: readonly ["module", "day", "week", "month", "quarter", "trimester", "semester", "year", "credit", "full"];
|
|
38
|
+
type TuitionFeeUnit = (typeof TUITION_FEE_UNITS)[number];
|
|
39
|
+
interface TuitionFeeDiscount {
|
|
40
|
+
readonly type: "flat" | "percentage";
|
|
41
|
+
readonly value: number;
|
|
42
|
+
}
|
|
43
|
+
/** One `tuition_fees` row. */
|
|
44
|
+
interface TuitionFee {
|
|
45
|
+
readonly target: TuitionFeeTarget;
|
|
46
|
+
readonly amount: number;
|
|
47
|
+
readonly currency: string;
|
|
48
|
+
readonly unit: TuitionFeeUnit;
|
|
49
|
+
/** Always given: a flat discount of 0 means no discount, the database has no null for it. */
|
|
50
|
+
readonly discount: TuitionFeeDiscount;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface PropertyValues {
|
|
54
|
+
/** Fees to create, matched by target. A record that also clears a target replaces that fee: clear is applied first. */
|
|
55
|
+
tuitionFees: readonly TuitionFee[];
|
|
56
|
+
tuitionFeeDescription: string;
|
|
57
|
+
}
|
|
58
|
+
interface PropertyClears {
|
|
59
|
+
/** Targets whose fees are removed. Removing every fee lists every target. */
|
|
60
|
+
tuitionFees: readonly TuitionFeeTarget[];
|
|
61
|
+
tuitionFeeDescription: true;
|
|
62
|
+
}
|
|
63
|
+
type PropertyName = keyof PropertyValues;
|
|
64
|
+
type PropertyFailure = Failure<"PROPERTY_VALUE_INVALID">;
|
|
65
|
+
/**
|
|
66
|
+
* Checks one property value; paths are relative to the value. Producers get it through the validate
|
|
67
|
+
* functions; the import pipeline also runs it on the enriched value, e.g. existing fees merged with the record's.
|
|
68
|
+
*/
|
|
69
|
+
declare const validateValue: <P extends PropertyName>(property: P, value: unknown) => PropertyFailure[];
|
|
70
|
+
|
|
71
|
+
interface SimpleContext {
|
|
72
|
+
readonly record: Unchecked<ImportRecordDraft>;
|
|
73
|
+
/** The entry's properties that the record sets or clears. */
|
|
74
|
+
readonly touched: readonly PropertyName[];
|
|
75
|
+
}
|
|
76
|
+
/** Returns every failure it finds; an empty array means valid. */
|
|
77
|
+
type Validator<Context = SimpleContext, Code extends string = Failure["code"]> = (context: Context) => Failure<Code>[];
|
|
78
|
+
interface RegistryEntry {
|
|
79
|
+
readonly properties: readonly PropertyName[];
|
|
80
|
+
/** Record-only validators. Run as a chain that stops at the first validator reporting failures. */
|
|
81
|
+
readonly simple: readonly Validator[];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The writable-property allowlist. The import pipeline extends each entry by key with its
|
|
85
|
+
* enrichment and dependent validators, typed `{ [K in RegistryKey]: … }` so a new entry
|
|
86
|
+
* cannot be missed.
|
|
87
|
+
*/
|
|
88
|
+
declare const registry: {
|
|
89
|
+
readonly tuitionFees: RegistryEntry;
|
|
90
|
+
readonly tuitionFeeDescription: RegistryEntry;
|
|
91
|
+
};
|
|
92
|
+
type RegistryKey = keyof typeof registry;
|
|
93
|
+
/** Keys of the registry entries covering the record's touched properties. Unknown properties are ignored. */
|
|
94
|
+
declare const resolveEntries: (record: Unchecked<ImportRecordDraft>) => RegistryKey[];
|
|
95
|
+
|
|
96
|
+
/** Phase 1 of the pipeline (and the first half of {@link validateRecord}): envelope and the shape of known properties. */
|
|
97
|
+
declare const validateSchema: (input: unknown) => ValidationResult<Unchecked<ImportRecord>>;
|
|
98
|
+
/**
|
|
99
|
+
* Phase 2, simple part: writable-property allowlist and record-only validators. The pipeline calls it after
|
|
100
|
+
* dropping stale properties and before enrichment.
|
|
101
|
+
*/
|
|
102
|
+
declare const validateProperties: (record: Unchecked<ImportRecord>) => ValidationResult<ImportRecord>;
|
|
103
|
+
/** For producers, before the registration endpoint has issued `eventId`. */
|
|
104
|
+
declare const validateDraft: (input: unknown) => ValidationResult<ImportRecordDraft>;
|
|
105
|
+
/** For producers, right before the record is pushed into the Data Stream. */
|
|
106
|
+
declare const validateRecord: (input: unknown) => ValidationResult<ImportRecord>;
|
|
107
|
+
|
|
108
|
+
export { registry, resolveEntries, validateDraft, validateProperties, validateRecord, validateSchema, validateValue };
|
|
109
|
+
export type { ErrorCode, Failure, ImportRecord, ImportRecordDraft, PropertyClears, PropertyName, PropertyValues, RegistryEntry, RegistryKey, SimpleContext, TuitionFee, TuitionFeeDiscount, TuitionFeeTarget, TuitionFeeUnit, Unchecked, ValidationResult, Validator };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
registry: () => registry,
|
|
24
|
+
resolveEntries: () => resolveEntries,
|
|
25
|
+
validateDraft: () => validateDraft,
|
|
26
|
+
validateProperties: () => validateProperties,
|
|
27
|
+
validateRecord: () => validateRecord,
|
|
28
|
+
validateSchema: () => validateSchema,
|
|
29
|
+
validateValue: () => validateValue
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/failures.ts
|
|
34
|
+
var toFailures = (error, code, property, prefix = []) => error.issues.map((issue) => {
|
|
35
|
+
const path = [...prefix, ...issue.path].map(String);
|
|
36
|
+
return {
|
|
37
|
+
code,
|
|
38
|
+
...property === void 0 ? {} : { property },
|
|
39
|
+
...path.length === 0 ? {} : { path: path.join(".") },
|
|
40
|
+
reason: issue.message
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// src/properties/tuition-fees.ts
|
|
45
|
+
var import_zod2 = require("zod");
|
|
46
|
+
|
|
47
|
+
// src/properties/definition.ts
|
|
48
|
+
var import_zod = require("zod");
|
|
49
|
+
var shapeFailures = (schema, slot2, property, side) => {
|
|
50
|
+
const result = slot2 && schema.safeParse(slot2.value);
|
|
51
|
+
return result?.success === false ? toFailures(result.error, "SCHEMA_INVALID", property, [side, property]) : [];
|
|
52
|
+
};
|
|
53
|
+
var defineProperty = (definition) => ({
|
|
54
|
+
value: definition.value,
|
|
55
|
+
set: definition.set.value,
|
|
56
|
+
clear: definition.clear.value,
|
|
57
|
+
checkShape: (property, set, clear) => [
|
|
58
|
+
...shapeFailures(definition.set.shape, set, property, "set"),
|
|
59
|
+
...shapeFailures(definition.clear.shape, clear, property, "clear")
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
var scalarProperty = (set) => defineProperty({ value: set.value, set, clear: { shape: import_zod.z.literal(true), value: import_zod.z.literal(true) } });
|
|
63
|
+
|
|
64
|
+
// src/properties/tuition-fees.ts
|
|
65
|
+
var TUITION_FEE_TARGETS = ["eea", "national", "international", "local"];
|
|
66
|
+
var TUITION_FEE_UNITS = ["module", "day", "week", "month", "quarter", "trimester", "semester", "year", "credit", "full"];
|
|
67
|
+
var feeShape = import_zod2.z.strictObject({
|
|
68
|
+
target: import_zod2.z.string(),
|
|
69
|
+
amount: import_zod2.z.number(),
|
|
70
|
+
currency: import_zod2.z.string(),
|
|
71
|
+
unit: import_zod2.z.string(),
|
|
72
|
+
discount: import_zod2.z.strictObject({ type: import_zod2.z.string(), value: import_zod2.z.number() })
|
|
73
|
+
});
|
|
74
|
+
var INT32_MAX = 2147483647;
|
|
75
|
+
var fee = import_zod2.z.strictObject({
|
|
76
|
+
target: import_zod2.z.enum(TUITION_FEE_TARGETS),
|
|
77
|
+
amount: import_zod2.z.int().min(0).max(INT32_MAX),
|
|
78
|
+
currency: import_zod2.z.currencyCode(),
|
|
79
|
+
unit: import_zod2.z.enum(TUITION_FEE_UNITS),
|
|
80
|
+
discount: import_zod2.z.discriminatedUnion("type", [
|
|
81
|
+
import_zod2.z.strictObject({ type: import_zod2.z.literal("percentage"), value: import_zod2.z.number().min(1).max(99.99) }),
|
|
82
|
+
// A flat discount of 0 means no discount.
|
|
83
|
+
import_zod2.z.strictObject({ type: import_zod2.z.literal("flat"), value: import_zod2.z.number().min(0) })
|
|
84
|
+
])
|
|
85
|
+
}).refine(
|
|
86
|
+
(row) => row.discount.type !== "flat" || row.discount.value === 0 || row.discount.value <= row.amount - 1,
|
|
87
|
+
{ path: ["discount", "value"], message: "A flat discount must be 0 or at most amount - 1" }
|
|
88
|
+
);
|
|
89
|
+
var isDistinct = (values) => new Set(values).size === values.length;
|
|
90
|
+
var isUniform = (values) => new Set(values).size <= 1;
|
|
91
|
+
var hasTarget = (rows, target) => rows.some((row) => row.target === target);
|
|
92
|
+
var fees = import_zod2.z.array(fee).refine((rows) => isDistinct(rows.map((row) => row.target)), "Each target may have at most one fee").refine((rows) => isUniform(rows.map((row) => row.currency)), "All fees must have the same currency").refine((rows) => isUniform(rows.map((row) => row.unit)), "All fees must have the same unit").refine(
|
|
93
|
+
(rows) => !hasTarget(rows, "eea") || !(hasTarget(rows, "national") || hasTarget(rows, "local")),
|
|
94
|
+
"An eea fee cannot be combined with a national or local fee"
|
|
95
|
+
);
|
|
96
|
+
var tuitionFees = defineProperty({
|
|
97
|
+
value: fees,
|
|
98
|
+
// A study may have no fees; an empty list in a record would say nothing, removing fees is done with clear.
|
|
99
|
+
set: { shape: import_zod2.z.array(feeShape), value: fees.min(1) },
|
|
100
|
+
clear: {
|
|
101
|
+
shape: import_zod2.z.array(import_zod2.z.string()),
|
|
102
|
+
value: import_zod2.z.array(import_zod2.z.enum(TUITION_FEE_TARGETS)).min(1).refine(isDistinct, "Each target may be cleared at most once")
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
var tuitionFeeDescription = scalarProperty({ shape: import_zod2.z.string(), value: import_zod2.z.string().min(1).max(160) });
|
|
106
|
+
|
|
107
|
+
// src/properties/index.ts
|
|
108
|
+
var properties = {
|
|
109
|
+
tuitionFees,
|
|
110
|
+
tuitionFeeDescription
|
|
111
|
+
};
|
|
112
|
+
var isPropertyName = (name) => Object.hasOwn(properties, name);
|
|
113
|
+
var validateValue = (property, value) => {
|
|
114
|
+
const result = properties[property].value.safeParse(value);
|
|
115
|
+
return result.success ? [] : toFailures(result.error, "PROPERTY_VALUE_INVALID", property);
|
|
116
|
+
};
|
|
117
|
+
var touchedProperties = (record) => [.../* @__PURE__ */ new Set([...Object.keys(record.set), ...Object.keys(record.clear)])];
|
|
118
|
+
var slot = (values, property) => Object.hasOwn(values, property) ? { value: values[property] } : void 0;
|
|
119
|
+
var checkShapes = (record) => touchedProperties(record).filter(isPropertyName).flatMap((property) => properties[property].checkShape(property, slot(record.set, property), slot(record.clear, property)));
|
|
120
|
+
var checkSlot = (schema, values, property, side) => {
|
|
121
|
+
if (!Object.hasOwn(values, property)) {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
const result = schema.safeParse(values[property]);
|
|
125
|
+
return result.success ? [] : toFailures(result.error, "PROPERTY_VALUE_INVALID", property, [side, property]);
|
|
126
|
+
};
|
|
127
|
+
var checkProperty = (property, record) => [
|
|
128
|
+
...checkSlot(properties[property].set, record.set, property, "set"),
|
|
129
|
+
...checkSlot(properties[property].clear, record.clear, property, "clear")
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// src/registry.ts
|
|
133
|
+
var propertyEntry = (property) => ({
|
|
134
|
+
properties: [property],
|
|
135
|
+
simple: [({ record }) => checkProperty(property, record)]
|
|
136
|
+
});
|
|
137
|
+
var registry = {
|
|
138
|
+
tuitionFees: propertyEntry("tuitionFees"),
|
|
139
|
+
tuitionFeeDescription: propertyEntry("tuitionFeeDescription")
|
|
140
|
+
};
|
|
141
|
+
var REGISTRY_KEYS = Object.keys(registry);
|
|
142
|
+
var resolveEntries = (record) => {
|
|
143
|
+
const touched = new Set(touchedProperties(record).filter(isPropertyName));
|
|
144
|
+
return REGISTRY_KEYS.filter((key) => registry[key].properties.some((property) => touched.has(property)));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// src/validate.ts
|
|
148
|
+
var import_zod4 = require("zod");
|
|
149
|
+
|
|
150
|
+
// src/envelope.ts
|
|
151
|
+
var import_zod3 = require("zod");
|
|
152
|
+
var MAX_EPOCH_SECONDS = 9999999999;
|
|
153
|
+
var propertyMap = import_zod3.z.preprocess((value, context) => {
|
|
154
|
+
if (typeof value === "object" && value !== null && Object.hasOwn(value, "__proto__")) {
|
|
155
|
+
context.addIssue({ code: "custom", message: "Property name __proto__ is not allowed" });
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}, import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()));
|
|
159
|
+
var propertyMaps = import_zod3.z.object({ set: propertyMap, clear: propertyMap });
|
|
160
|
+
var draftEnvelope = import_zod3.z.strictObject({
|
|
161
|
+
entityType: import_zod3.z.literal("study"),
|
|
162
|
+
entityId: import_zod3.z.int().positive(),
|
|
163
|
+
set: propertyMap,
|
|
164
|
+
clear: propertyMap
|
|
165
|
+
});
|
|
166
|
+
var recordEnvelope = draftEnvelope.extend({
|
|
167
|
+
// import_event_log.event_id is VARCHAR(64).
|
|
168
|
+
eventId: import_zod3.z.string().min(1).max(64),
|
|
169
|
+
eventTimestamp: import_zod3.z.int().positive().max(MAX_EPOCH_SECONDS)
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// src/validate.ts
|
|
173
|
+
var invalid = (stage, failures) => ({ valid: false, stage, failures });
|
|
174
|
+
var checkEnvelope = (envelope, input) => {
|
|
175
|
+
const result = envelope.safeParse(input);
|
|
176
|
+
const maps = propertyMaps.safeParse(input);
|
|
177
|
+
const failures = [
|
|
178
|
+
...result.success ? [] : toFailures(result.error, "SCHEMA_INVALID"),
|
|
179
|
+
...maps.success ? checkShapes(maps.data) : []
|
|
180
|
+
];
|
|
181
|
+
return result.success && failures.length === 0 ? { valid: true, record: result.data } : invalid("schema", failures);
|
|
182
|
+
};
|
|
183
|
+
var checkProperties = (record) => {
|
|
184
|
+
const touched = touchedProperties(record);
|
|
185
|
+
const notWritable = touched.filter((property) => !isPropertyName(property)).map((property) => ({ code: "PROPERTY_NOT_WRITABLE", property, reason: "Property is not writable" }));
|
|
186
|
+
if (notWritable.length > 0) {
|
|
187
|
+
return invalid("property", notWritable);
|
|
188
|
+
}
|
|
189
|
+
const failures = resolveEntries(record).flatMap((key) => {
|
|
190
|
+
const entry = registry[key];
|
|
191
|
+
const context = { record, touched: entry.properties.filter((property) => touched.includes(property)) };
|
|
192
|
+
for (const validator of entry.simple) {
|
|
193
|
+
const found = validator(context);
|
|
194
|
+
if (found.length > 0) {
|
|
195
|
+
return found;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return [];
|
|
199
|
+
});
|
|
200
|
+
return failures.length > 0 ? invalid("property", failures) : { valid: true, record };
|
|
201
|
+
};
|
|
202
|
+
var chain = (schema) => schema.valid ? checkProperties(schema.record) : schema;
|
|
203
|
+
var validateSchema = (input) => checkEnvelope(recordEnvelope, input);
|
|
204
|
+
var validateProperties = (record) => checkProperties(record);
|
|
205
|
+
var validateDraft = (input) => chain(checkEnvelope(draftEnvelope, input));
|
|
206
|
+
var validateRecord = (input) => chain(checkEnvelope(recordEnvelope, input));
|
|
207
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
208
|
+
0 && (module.exports = {
|
|
209
|
+
registry,
|
|
210
|
+
resolveEntries,
|
|
211
|
+
validateDraft,
|
|
212
|
+
validateProperties,
|
|
213
|
+
validateRecord,
|
|
214
|
+
validateSchema,
|
|
215
|
+
validateValue
|
|
216
|
+
});
|
|
217
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/failures.ts","../src/properties/tuition-fees.ts","../src/properties/definition.ts","../src/properties/index.ts","../src/registry.ts","../src/validate.ts","../src/envelope.ts"],"sourcesContent":["export type { PropertyClears, PropertyName, PropertyValues } from \"./properties\";\nexport { validateValue } from \"./properties\";\nexport type { TuitionFee, TuitionFeeDiscount, TuitionFeeTarget, TuitionFeeUnit } from \"./properties/tuition-fees\";\nexport type { ErrorCode, Failure, ImportRecord, ImportRecordDraft, Unchecked, ValidationResult } from \"./record\";\nexport { registry, resolveEntries, type RegistryEntry, type RegistryKey, type SimpleContext, type Validator } from \"./registry\";\nexport { validateDraft, validateProperties, validateRecord, validateSchema } from \"./validate\";\n","import type { z } from \"zod\";\n\nimport type { ErrorCode, Failure } from \"./record\";\n\nexport type Path = readonly (string | number)[];\n\nexport const toFailures = <Code extends ErrorCode>(error: z.ZodError, code: Code, property?: string, prefix: Path = []): Failure<Code>[] =>\n\terror.issues.map((issue) => {\n\t\tconst path = [...prefix, ...issue.path].map(String);\n\t\treturn {\n\t\t\tcode,\n\t\t\t...(property === undefined ? {} : { property }),\n\t\t\t...(path.length === 0 ? {} : { path: path.join(\".\") }),\n\t\t\treason: issue.message,\n\t\t};\n\t});\n","import { z } from \"zod\";\n\nimport { defineProperty, scalarProperty, type PropertyDefinition } from \"./definition\";\n\nexport const TUITION_FEE_TARGETS = [\"eea\", \"national\", \"international\", \"local\"] as const;\n\nexport type TuitionFeeTarget = (typeof TUITION_FEE_TARGETS)[number];\n\nexport const TUITION_FEE_UNITS = [\"module\", \"day\", \"week\", \"month\", \"quarter\", \"trimester\", \"semester\", \"year\", \"credit\", \"full\"] as const;\n\nexport type TuitionFeeUnit = (typeof TUITION_FEE_UNITS)[number];\n\nexport interface TuitionFeeDiscount {\n\treadonly type: \"flat\" | \"percentage\";\n\treadonly value: number;\n}\n\n/** One `tuition_fees` row. */\nexport interface TuitionFee {\n\treadonly target: TuitionFeeTarget;\n\treadonly amount: number;\n\treadonly currency: string;\n\treadonly unit: TuitionFeeUnit;\n\t/** Always given: a flat discount of 0 means no discount, the database has no null for it. */\n\treadonly discount: TuitionFeeDiscount;\n}\n\nconst feeShape = z.strictObject({\n\ttarget: z.string(),\n\tamount: z.number(),\n\tcurrency: z.string(),\n\tunit: z.string(),\n\tdiscount: z.strictObject({ type: z.string(), value: z.number() }),\n});\n\n// tuition_fees.amount is a signed MySQL INT.\nconst INT32_MAX = 2_147_483_647;\n\nconst fee: z.ZodType<TuitionFee> = z.strictObject({\n\ttarget: z.enum(TUITION_FEE_TARGETS),\n\tamount: z.int().min(0).max(INT32_MAX),\n\tcurrency: z.currencyCode(),\n\tunit: z.enum(TUITION_FEE_UNITS),\n\tdiscount: z.discriminatedUnion(\"type\", [\n\t\tz.strictObject({ type: z.literal(\"percentage\"), value: z.number().min(1).max(99.99) }),\n\t\t// A flat discount of 0 means no discount.\n\t\tz.strictObject({ type: z.literal(\"flat\"), value: z.number().min(0) }),\n\t]),\n}).refine(\n\t(row) => row.discount.type !== \"flat\" || row.discount.value === 0 || row.discount.value <= row.amount - 1,\n\t{ path: [\"discount\", \"value\"], message: \"A flat discount must be 0 or at most amount - 1\" },\n);\n\nconst isDistinct = (values: readonly unknown[]): boolean => new Set(values).size === values.length;\nconst isUniform = (values: readonly unknown[]): boolean => new Set(values).size <= 1;\nconst hasTarget = (rows: readonly { readonly target: string }[], target: string): boolean => rows.some((row) => row.target === target);\n\n// The same rules hold for the record's fees and, in the pipeline, for the fees after enrichment.\nconst fees = z.array(fee)\n\t.refine((rows) => isDistinct(rows.map((row) => row.target)), \"Each target may have at most one fee\")\n\t.refine((rows) => isUniform(rows.map((row) => row.currency)), \"All fees must have the same currency\")\n\t.refine((rows) => isUniform(rows.map((row) => row.unit)), \"All fees must have the same unit\")\n\t.refine(\n\t\t(rows) => !hasTarget(rows, \"eea\") || !(hasTarget(rows, \"national\") || hasTarget(rows, \"local\")),\n\t\t\"An eea fee cannot be combined with a national or local fee\",\n\t);\n\nexport const tuitionFees: PropertyDefinition<readonly TuitionFee[], readonly TuitionFeeTarget[]> = defineProperty({\n\tvalue: fees,\n\t// A study may have no fees; an empty list in a record would say nothing, removing fees is done with clear.\n\tset: { shape: z.array(feeShape), value: fees.min(1) },\n\tclear: {\n\t\tshape: z.array(z.string()),\n\t\tvalue: z.array(z.enum(TUITION_FEE_TARGETS)).min(1).refine(isDistinct, \"Each target may be cleared at most once\"),\n\t},\n});\n\nexport const tuitionFeeDescription = scalarProperty({ shape: z.string(), value: z.string().min(1).max(160) });\n","import { z } from \"zod\";\n\nimport { toFailures } from \"../failures\";\nimport type { Failure } from \"../record\";\n\ninterface Schemas<Shape, Value extends Shape> {\n\t/** Keys and JSON types only; failures are SCHEMA_INVALID. */\n\treadonly shape: z.ZodType<Shape>;\n\t/** Shape and rules for what a record holds; failures are PROPERTY_VALUE_INVALID. Also the published JSON Schema. */\n\treadonly value: z.ZodType<Value>;\n}\n\n/** What a record holds for a property in `set` or `clear`; `undefined` when it holds nothing. */\nexport type Slot = { readonly value: unknown } | undefined;\n\nexport interface PropertyDefinition<Value, Clear> {\n\t/** A valid stored value, e.g. the fees after enrichment. */\n\treadonly value: z.ZodType<Value>;\n\t/** What a record may set: the stored-value rules plus record-only ones. */\n\treadonly set: z.ZodType<Value>;\n\t/** What `clear` holds: `true` for a single value, the keys of the items to remove for a list. */\n\treadonly clear: z.ZodType<Clear>;\n\t/** Shape of what the record sets and clears. */\n\treadonly checkShape: (property: string, set: Slot, clear: Slot) => Failure<\"SCHEMA_INVALID\">[];\n}\n\nconst shapeFailures = (schema: z.ZodType, slot: Slot, property: string, side: \"set\" | \"clear\"): Failure<\"SCHEMA_INVALID\">[] => {\n\tconst result = slot && schema.safeParse(slot.value);\n\treturn result?.success === false ? toFailures(result.error, \"SCHEMA_INVALID\", property, [side, property]) : [];\n};\n\nexport const defineProperty = <SetShape, Value extends SetShape, ClearShape, Clear extends ClearShape>(definition: {\n\treadonly value: z.ZodType<Value>;\n\treadonly set: Schemas<SetShape, Value>;\n\treadonly clear: Schemas<ClearShape, Clear>;\n}): PropertyDefinition<Value, Clear> => ({\n\tvalue: definition.value,\n\tset: definition.set.value,\n\tclear: definition.clear.value,\n\tcheckShape: (property, set, clear) => [\n\t\t...shapeFailures(definition.set.shape, set, property, \"set\"),\n\t\t...shapeFailures(definition.clear.shape, clear, property, \"clear\"),\n\t],\n});\n\nexport const scalarProperty = <Shape, Value extends Shape>(set: Schemas<Shape, Value>): PropertyDefinition<Value, true> =>\n\tdefineProperty({ value: set.value, set, clear: { shape: z.literal(true), value: z.literal(true) } });\n","import type { z } from \"zod\";\n\nimport { toFailures } from \"../failures\";\nimport type { Failure, ImportRecordDraft, Unchecked } from \"../record\";\nimport type { PropertyDefinition, Slot } from \"./definition\";\nimport { tuitionFeeDescription, tuitionFees, type TuitionFee, type TuitionFeeTarget } from \"./tuition-fees\";\n\nexport interface PropertyValues {\n\t/** Fees to create, matched by target. A record that also clears a target replaces that fee: clear is applied first. */\n\ttuitionFees: readonly TuitionFee[];\n\ttuitionFeeDescription: string;\n}\n\nexport interface PropertyClears {\n\t/** Targets whose fees are removed. Removing every fee lists every target. */\n\ttuitionFees: readonly TuitionFeeTarget[];\n\ttuitionFeeDescription: true;\n}\n\nexport type PropertyName = keyof PropertyValues;\n\nexport const properties: { readonly [P in PropertyName]: PropertyDefinition<PropertyValues[P], PropertyClears[P]> } = {\n\ttuitionFees,\n\ttuitionFeeDescription,\n};\n\nexport const isPropertyName = (name: string): name is PropertyName => Object.hasOwn(properties, name);\n\ntype PropertyFailure = Failure<\"PROPERTY_VALUE_INVALID\">;\n\n/**\n * Checks one property value; paths are relative to the value. Producers get it through the validate\n * functions; the import pipeline also runs it on the enriched value, e.g. existing fees merged with the record's.\n */\nexport const validateValue = <P extends PropertyName>(property: P, value: unknown): PropertyFailure[] => {\n\tconst result = properties[property].value.safeParse(value);\n\treturn result.success ? [] : toFailures(result.error, \"PROPERTY_VALUE_INVALID\", property);\n};\n\n/** Properties the record sets or clears, in record order, without duplicates. */\nexport const touchedProperties = (record: Pick<Unchecked<ImportRecordDraft>, \"set\" | \"clear\">): string[] =>\n\t[...new Set([...Object.keys(record.set), ...Object.keys(record.clear)])];\n\nconst slot = (values: Readonly<Record<string, unknown>>, property: PropertyName): Slot =>\n\t(Object.hasOwn(values, property) ? { value: values[property] } : undefined);\n\n/** Phase 1 part of properties: the shape of every writable property the record touches. Unknown properties are left to phase 2. */\nexport const checkShapes = (record: Pick<Unchecked<ImportRecordDraft>, \"set\" | \"clear\">): Failure<\"SCHEMA_INVALID\">[] =>\n\ttouchedProperties(record)\n\t\t.filter(isPropertyName)\n\t\t.flatMap((property) => properties[property].checkShape(property, slot(record.set, property), slot(record.clear, property)));\n\nconst checkSlot = (schema: z.ZodType, values: Readonly<Record<string, unknown>>, property: PropertyName, side: \"set\" | \"clear\"): PropertyFailure[] => {\n\tif (!Object.hasOwn(values, property)) {\n\t\treturn [];\n\t}\n\tconst result = schema.safeParse(values[property]);\n\treturn result.success ? [] : toFailures(result.error, \"PROPERTY_VALUE_INVALID\", property, [side, property]);\n};\n\n/** The rules for what the record clears and sets for one property; its shape passed phase 1. */\nexport const checkProperty = (property: PropertyName, record: Unchecked<ImportRecordDraft>): PropertyFailure[] => [\n\t...checkSlot(properties[property].set, record.set, property, \"set\"),\n\t...checkSlot(properties[property].clear, record.clear, property, \"clear\"),\n];\n","import { checkProperty, isPropertyName, touchedProperties, type PropertyName } from \"./properties\";\nimport type { Failure, ImportRecordDraft, Unchecked } from \"./record\";\n\nexport interface SimpleContext {\n\treadonly record: Unchecked<ImportRecordDraft>;\n\t/** The entry's properties that the record sets or clears. */\n\treadonly touched: readonly PropertyName[];\n}\n\n/** Returns every failure it finds; an empty array means valid. */\nexport type Validator<Context = SimpleContext, Code extends string = Failure[\"code\"]> = (context: Context) => Failure<Code>[];\n\nexport interface RegistryEntry {\n\treadonly properties: readonly PropertyName[];\n\t/** Record-only validators. Run as a chain that stops at the first validator reporting failures. */\n\treadonly simple: readonly Validator[];\n}\n\nconst propertyEntry = (property: PropertyName): RegistryEntry => ({\n\tproperties: [property],\n\tsimple: [({ record }): Failure[] => checkProperty(property, record)],\n});\n\n/**\n * The writable-property allowlist. The import pipeline extends each entry by key with its\n * enrichment and dependent validators, typed `{ [K in RegistryKey]: … }` so a new entry\n * cannot be missed.\n */\nexport const registry = {\n\ttuitionFees: propertyEntry(\"tuitionFees\"),\n\ttuitionFeeDescription: propertyEntry(\"tuitionFeeDescription\"),\n} as const satisfies { readonly [P in PropertyName]: RegistryEntry };\n\nexport type RegistryKey = keyof typeof registry;\n\nconst REGISTRY_KEYS = Object.keys(registry) as RegistryKey[];\n\n/** Keys of the registry entries covering the record's touched properties. Unknown properties are ignored. */\nexport const resolveEntries = (record: Unchecked<ImportRecordDraft>): RegistryKey[] => {\n\tconst touched = new Set(touchedProperties(record).filter(isPropertyName));\n\treturn REGISTRY_KEYS.filter((key) => registry[key].properties.some((property) => touched.has(property)));\n};\n","import { z } from \"zod\";\n\nimport { draftEnvelope, propertyMaps, recordEnvelope } from \"./envelope\";\nimport { toFailures } from \"./failures\";\nimport { checkShapes, isPropertyName, properties, touchedProperties } from \"./properties\";\nimport type { Failure, ImportRecord, ImportRecordDraft, Unchecked, ValidationResult } from \"./record\";\nimport { registry, resolveEntries } from \"./registry\";\n\nconst invalid = <T>(stage: \"schema\" | \"property\", failures: readonly Failure[]): ValidationResult<T> =>\n\t({ valid: false, stage, failures });\n\nconst checkEnvelope = <T extends ImportRecordDraft>(envelope: z.ZodType<Unchecked<T>>, input: unknown): ValidationResult<Unchecked<T>> => {\n\tconst result = envelope.safeParse(input);\n\tconst maps = propertyMaps.safeParse(input);\n\tconst failures = [\n\t\t...(result.success ? [] : toFailures(result.error, \"SCHEMA_INVALID\")),\n\t\t...(maps.success ? checkShapes(maps.data) : []),\n\t];\n\treturn result.success && failures.length === 0 ? { valid: true, record: result.data } : invalid(\"schema\", failures);\n};\n\nconst checkProperties = <T extends ImportRecordDraft>(record: Unchecked<T>): ValidationResult<T> => {\n\tconst touched = touchedProperties(record);\n\tconst notWritable = touched\n\t\t.filter((property) => !isPropertyName(property))\n\t\t.map((property): Failure => ({ code: \"PROPERTY_NOT_WRITABLE\", property, reason: \"Property is not writable\" }));\n\tif (notWritable.length > 0) {\n\t\treturn invalid(\"property\", notWritable);\n\t}\n\tconst failures = resolveEntries(record).flatMap((key) => {\n\t\tconst entry = registry[key];\n\t\tconst context = { record, touched: entry.properties.filter((property) => touched.includes(property)) };\n\t\tfor (const validator of entry.simple) {\n\t\t\tconst found = validator(context);\n\t\t\tif (found.length > 0) {\n\t\t\t\treturn found;\n\t\t\t}\n\t\t}\n\t\treturn [];\n\t});\n\t// Every touched property is writable and its set and clear values passed their rules, so the record has its checked type.\n\treturn failures.length > 0 ? invalid(\"property\", failures) : { valid: true, record: record as unknown as T };\n};\n\nconst chain = <T extends ImportRecordDraft>(schema: ValidationResult<Unchecked<T>>): ValidationResult<T> =>\n\tschema.valid ? checkProperties(schema.record) : schema;\n\n/** Phase 1 of the pipeline (and the first half of {@link validateRecord}): envelope and the shape of known properties. */\nexport const validateSchema = (input: unknown): ValidationResult<Unchecked<ImportRecord>> => checkEnvelope(recordEnvelope, input);\n\n/**\n * Phase 2, simple part: writable-property allowlist and record-only validators. The pipeline calls it after\n * dropping stale properties and before enrichment.\n */\nexport const validateProperties = (record: Unchecked<ImportRecord>): ValidationResult<ImportRecord> => checkProperties(record);\n\n/** For producers, before the registration endpoint has issued `eventId`. */\nexport const validateDraft = (input: unknown): ValidationResult<ImportRecordDraft> => chain(checkEnvelope(draftEnvelope, input));\n\n/** For producers, right before the record is pushed into the Data Stream. */\nexport const validateRecord = (input: unknown): ValidationResult<ImportRecord> => chain(checkEnvelope(recordEnvelope, input));\n\n/** The issued record as JSON Schema; cross-field rules such as discount bounds are not expressible there. */\nexport const importRecordJsonSchema = (): Record<string, unknown> => {\n\tconst definitions = Object.entries(properties);\n\tconst contract = recordEnvelope.extend({\n\t\tset: z.strictObject(Object.fromEntries(definitions.map(([property, { set }]) => [property, set.optional()]))),\n\t\tclear: z.strictObject(Object.fromEntries(definitions.map(([property, { clear }]) => [property, clear.optional()]))),\n\t});\n\t// A local registry: .meta() would write into Zod's global registry, which consumers may share.\n\tconst metadata = z.registry<{ title: string }>();\n\tmetadata.add(contract, { title: \"Data API import record\" });\n\treturn z.toJSONSchema(contract, { metadata });\n};\n","import { z } from \"zod\";\n\n// Ten digits: anything larger is almost certainly milliseconds.\nconst MAX_EPOCH_SECONDS = 9_999_999_999;\n\n// Zod's record parser drops a \"__proto__\" key without an issue; reject it rather than lose it.\nconst propertyMap = z.preprocess((value, context) => {\n\tif (typeof value === \"object\" && value !== null && Object.hasOwn(value, \"__proto__\")) {\n\t\tcontext.addIssue({ code: \"custom\", message: \"Property name __proto__ is not allowed\" });\n\t}\n\treturn value;\n}, z.record(z.string(), z.unknown()));\n\n/** Just the property maps, so their shapes can be checked even when other envelope fields are invalid. */\nexport const propertyMaps = z.object({ set: propertyMap, clear: propertyMap });\n\nexport const draftEnvelope = z.strictObject({\n\tentityType: z.literal(\"study\"),\n\tentityId: z.int().positive(),\n\tset: propertyMap,\n\tclear: propertyMap,\n});\n\nexport const recordEnvelope = draftEnvelope.extend({\n\t// import_event_log.event_id is VARCHAR(64).\n\teventId: z.string().min(1).max(64),\n\teventTimestamp: z.int().positive().max(MAX_EPOCH_SECONDS),\n});\n"],"mappings":"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","names":["import_zod","slot","import_zod","import_zod"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// src/failures.ts
|
|
2
|
+
var toFailures = (error, code, property, prefix = []) => error.issues.map((issue) => {
|
|
3
|
+
const path = [...prefix, ...issue.path].map(String);
|
|
4
|
+
return {
|
|
5
|
+
code,
|
|
6
|
+
...property === void 0 ? {} : { property },
|
|
7
|
+
...path.length === 0 ? {} : { path: path.join(".") },
|
|
8
|
+
reason: issue.message
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// src/properties/tuition-fees.ts
|
|
13
|
+
import { z as z2 } from "zod";
|
|
14
|
+
|
|
15
|
+
// src/properties/definition.ts
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var shapeFailures = (schema, slot2, property, side) => {
|
|
18
|
+
const result = slot2 && schema.safeParse(slot2.value);
|
|
19
|
+
return result?.success === false ? toFailures(result.error, "SCHEMA_INVALID", property, [side, property]) : [];
|
|
20
|
+
};
|
|
21
|
+
var defineProperty = (definition) => ({
|
|
22
|
+
value: definition.value,
|
|
23
|
+
set: definition.set.value,
|
|
24
|
+
clear: definition.clear.value,
|
|
25
|
+
checkShape: (property, set, clear) => [
|
|
26
|
+
...shapeFailures(definition.set.shape, set, property, "set"),
|
|
27
|
+
...shapeFailures(definition.clear.shape, clear, property, "clear")
|
|
28
|
+
]
|
|
29
|
+
});
|
|
30
|
+
var scalarProperty = (set) => defineProperty({ value: set.value, set, clear: { shape: z.literal(true), value: z.literal(true) } });
|
|
31
|
+
|
|
32
|
+
// src/properties/tuition-fees.ts
|
|
33
|
+
var TUITION_FEE_TARGETS = ["eea", "national", "international", "local"];
|
|
34
|
+
var TUITION_FEE_UNITS = ["module", "day", "week", "month", "quarter", "trimester", "semester", "year", "credit", "full"];
|
|
35
|
+
var feeShape = z2.strictObject({
|
|
36
|
+
target: z2.string(),
|
|
37
|
+
amount: z2.number(),
|
|
38
|
+
currency: z2.string(),
|
|
39
|
+
unit: z2.string(),
|
|
40
|
+
discount: z2.strictObject({ type: z2.string(), value: z2.number() })
|
|
41
|
+
});
|
|
42
|
+
var INT32_MAX = 2147483647;
|
|
43
|
+
var fee = z2.strictObject({
|
|
44
|
+
target: z2.enum(TUITION_FEE_TARGETS),
|
|
45
|
+
amount: z2.int().min(0).max(INT32_MAX),
|
|
46
|
+
currency: z2.currencyCode(),
|
|
47
|
+
unit: z2.enum(TUITION_FEE_UNITS),
|
|
48
|
+
discount: z2.discriminatedUnion("type", [
|
|
49
|
+
z2.strictObject({ type: z2.literal("percentage"), value: z2.number().min(1).max(99.99) }),
|
|
50
|
+
// A flat discount of 0 means no discount.
|
|
51
|
+
z2.strictObject({ type: z2.literal("flat"), value: z2.number().min(0) })
|
|
52
|
+
])
|
|
53
|
+
}).refine(
|
|
54
|
+
(row) => row.discount.type !== "flat" || row.discount.value === 0 || row.discount.value <= row.amount - 1,
|
|
55
|
+
{ path: ["discount", "value"], message: "A flat discount must be 0 or at most amount - 1" }
|
|
56
|
+
);
|
|
57
|
+
var isDistinct = (values) => new Set(values).size === values.length;
|
|
58
|
+
var isUniform = (values) => new Set(values).size <= 1;
|
|
59
|
+
var hasTarget = (rows, target) => rows.some((row) => row.target === target);
|
|
60
|
+
var fees = z2.array(fee).refine((rows) => isDistinct(rows.map((row) => row.target)), "Each target may have at most one fee").refine((rows) => isUniform(rows.map((row) => row.currency)), "All fees must have the same currency").refine((rows) => isUniform(rows.map((row) => row.unit)), "All fees must have the same unit").refine(
|
|
61
|
+
(rows) => !hasTarget(rows, "eea") || !(hasTarget(rows, "national") || hasTarget(rows, "local")),
|
|
62
|
+
"An eea fee cannot be combined with a national or local fee"
|
|
63
|
+
);
|
|
64
|
+
var tuitionFees = defineProperty({
|
|
65
|
+
value: fees,
|
|
66
|
+
// A study may have no fees; an empty list in a record would say nothing, removing fees is done with clear.
|
|
67
|
+
set: { shape: z2.array(feeShape), value: fees.min(1) },
|
|
68
|
+
clear: {
|
|
69
|
+
shape: z2.array(z2.string()),
|
|
70
|
+
value: z2.array(z2.enum(TUITION_FEE_TARGETS)).min(1).refine(isDistinct, "Each target may be cleared at most once")
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
var tuitionFeeDescription = scalarProperty({ shape: z2.string(), value: z2.string().min(1).max(160) });
|
|
74
|
+
|
|
75
|
+
// src/properties/index.ts
|
|
76
|
+
var properties = {
|
|
77
|
+
tuitionFees,
|
|
78
|
+
tuitionFeeDescription
|
|
79
|
+
};
|
|
80
|
+
var isPropertyName = (name) => Object.hasOwn(properties, name);
|
|
81
|
+
var validateValue = (property, value) => {
|
|
82
|
+
const result = properties[property].value.safeParse(value);
|
|
83
|
+
return result.success ? [] : toFailures(result.error, "PROPERTY_VALUE_INVALID", property);
|
|
84
|
+
};
|
|
85
|
+
var touchedProperties = (record) => [.../* @__PURE__ */ new Set([...Object.keys(record.set), ...Object.keys(record.clear)])];
|
|
86
|
+
var slot = (values, property) => Object.hasOwn(values, property) ? { value: values[property] } : void 0;
|
|
87
|
+
var checkShapes = (record) => touchedProperties(record).filter(isPropertyName).flatMap((property) => properties[property].checkShape(property, slot(record.set, property), slot(record.clear, property)));
|
|
88
|
+
var checkSlot = (schema, values, property, side) => {
|
|
89
|
+
if (!Object.hasOwn(values, property)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const result = schema.safeParse(values[property]);
|
|
93
|
+
return result.success ? [] : toFailures(result.error, "PROPERTY_VALUE_INVALID", property, [side, property]);
|
|
94
|
+
};
|
|
95
|
+
var checkProperty = (property, record) => [
|
|
96
|
+
...checkSlot(properties[property].set, record.set, property, "set"),
|
|
97
|
+
...checkSlot(properties[property].clear, record.clear, property, "clear")
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// src/registry.ts
|
|
101
|
+
var propertyEntry = (property) => ({
|
|
102
|
+
properties: [property],
|
|
103
|
+
simple: [({ record }) => checkProperty(property, record)]
|
|
104
|
+
});
|
|
105
|
+
var registry = {
|
|
106
|
+
tuitionFees: propertyEntry("tuitionFees"),
|
|
107
|
+
tuitionFeeDescription: propertyEntry("tuitionFeeDescription")
|
|
108
|
+
};
|
|
109
|
+
var REGISTRY_KEYS = Object.keys(registry);
|
|
110
|
+
var resolveEntries = (record) => {
|
|
111
|
+
const touched = new Set(touchedProperties(record).filter(isPropertyName));
|
|
112
|
+
return REGISTRY_KEYS.filter((key) => registry[key].properties.some((property) => touched.has(property)));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/validate.ts
|
|
116
|
+
import { z as z4 } from "zod";
|
|
117
|
+
|
|
118
|
+
// src/envelope.ts
|
|
119
|
+
import { z as z3 } from "zod";
|
|
120
|
+
var MAX_EPOCH_SECONDS = 9999999999;
|
|
121
|
+
var propertyMap = z3.preprocess((value, context) => {
|
|
122
|
+
if (typeof value === "object" && value !== null && Object.hasOwn(value, "__proto__")) {
|
|
123
|
+
context.addIssue({ code: "custom", message: "Property name __proto__ is not allowed" });
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}, z3.record(z3.string(), z3.unknown()));
|
|
127
|
+
var propertyMaps = z3.object({ set: propertyMap, clear: propertyMap });
|
|
128
|
+
var draftEnvelope = z3.strictObject({
|
|
129
|
+
entityType: z3.literal("study"),
|
|
130
|
+
entityId: z3.int().positive(),
|
|
131
|
+
set: propertyMap,
|
|
132
|
+
clear: propertyMap
|
|
133
|
+
});
|
|
134
|
+
var recordEnvelope = draftEnvelope.extend({
|
|
135
|
+
// import_event_log.event_id is VARCHAR(64).
|
|
136
|
+
eventId: z3.string().min(1).max(64),
|
|
137
|
+
eventTimestamp: z3.int().positive().max(MAX_EPOCH_SECONDS)
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// src/validate.ts
|
|
141
|
+
var invalid = (stage, failures) => ({ valid: false, stage, failures });
|
|
142
|
+
var checkEnvelope = (envelope, input) => {
|
|
143
|
+
const result = envelope.safeParse(input);
|
|
144
|
+
const maps = propertyMaps.safeParse(input);
|
|
145
|
+
const failures = [
|
|
146
|
+
...result.success ? [] : toFailures(result.error, "SCHEMA_INVALID"),
|
|
147
|
+
...maps.success ? checkShapes(maps.data) : []
|
|
148
|
+
];
|
|
149
|
+
return result.success && failures.length === 0 ? { valid: true, record: result.data } : invalid("schema", failures);
|
|
150
|
+
};
|
|
151
|
+
var checkProperties = (record) => {
|
|
152
|
+
const touched = touchedProperties(record);
|
|
153
|
+
const notWritable = touched.filter((property) => !isPropertyName(property)).map((property) => ({ code: "PROPERTY_NOT_WRITABLE", property, reason: "Property is not writable" }));
|
|
154
|
+
if (notWritable.length > 0) {
|
|
155
|
+
return invalid("property", notWritable);
|
|
156
|
+
}
|
|
157
|
+
const failures = resolveEntries(record).flatMap((key) => {
|
|
158
|
+
const entry = registry[key];
|
|
159
|
+
const context = { record, touched: entry.properties.filter((property) => touched.includes(property)) };
|
|
160
|
+
for (const validator of entry.simple) {
|
|
161
|
+
const found = validator(context);
|
|
162
|
+
if (found.length > 0) {
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [];
|
|
167
|
+
});
|
|
168
|
+
return failures.length > 0 ? invalid("property", failures) : { valid: true, record };
|
|
169
|
+
};
|
|
170
|
+
var chain = (schema) => schema.valid ? checkProperties(schema.record) : schema;
|
|
171
|
+
var validateSchema = (input) => checkEnvelope(recordEnvelope, input);
|
|
172
|
+
var validateProperties = (record) => checkProperties(record);
|
|
173
|
+
var validateDraft = (input) => chain(checkEnvelope(draftEnvelope, input));
|
|
174
|
+
var validateRecord = (input) => chain(checkEnvelope(recordEnvelope, input));
|
|
175
|
+
export {
|
|
176
|
+
registry,
|
|
177
|
+
resolveEntries,
|
|
178
|
+
validateDraft,
|
|
179
|
+
validateProperties,
|
|
180
|
+
validateRecord,
|
|
181
|
+
validateSchema,
|
|
182
|
+
validateValue
|
|
183
|
+
};
|
|
184
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/failures.ts","../src/properties/tuition-fees.ts","../src/properties/definition.ts","../src/properties/index.ts","../src/registry.ts","../src/validate.ts","../src/envelope.ts"],"sourcesContent":["import type { z } from \"zod\";\n\nimport type { ErrorCode, Failure } from \"./record\";\n\nexport type Path = readonly (string | number)[];\n\nexport const toFailures = <Code extends ErrorCode>(error: z.ZodError, code: Code, property?: string, prefix: Path = []): Failure<Code>[] =>\n\terror.issues.map((issue) => {\n\t\tconst path = [...prefix, ...issue.path].map(String);\n\t\treturn {\n\t\t\tcode,\n\t\t\t...(property === undefined ? {} : { property }),\n\t\t\t...(path.length === 0 ? {} : { path: path.join(\".\") }),\n\t\t\treason: issue.message,\n\t\t};\n\t});\n","import { z } from \"zod\";\n\nimport { defineProperty, scalarProperty, type PropertyDefinition } from \"./definition\";\n\nexport const TUITION_FEE_TARGETS = [\"eea\", \"national\", \"international\", \"local\"] as const;\n\nexport type TuitionFeeTarget = (typeof TUITION_FEE_TARGETS)[number];\n\nexport const TUITION_FEE_UNITS = [\"module\", \"day\", \"week\", \"month\", \"quarter\", \"trimester\", \"semester\", \"year\", \"credit\", \"full\"] as const;\n\nexport type TuitionFeeUnit = (typeof TUITION_FEE_UNITS)[number];\n\nexport interface TuitionFeeDiscount {\n\treadonly type: \"flat\" | \"percentage\";\n\treadonly value: number;\n}\n\n/** One `tuition_fees` row. */\nexport interface TuitionFee {\n\treadonly target: TuitionFeeTarget;\n\treadonly amount: number;\n\treadonly currency: string;\n\treadonly unit: TuitionFeeUnit;\n\t/** Always given: a flat discount of 0 means no discount, the database has no null for it. */\n\treadonly discount: TuitionFeeDiscount;\n}\n\nconst feeShape = z.strictObject({\n\ttarget: z.string(),\n\tamount: z.number(),\n\tcurrency: z.string(),\n\tunit: z.string(),\n\tdiscount: z.strictObject({ type: z.string(), value: z.number() }),\n});\n\n// tuition_fees.amount is a signed MySQL INT.\nconst INT32_MAX = 2_147_483_647;\n\nconst fee: z.ZodType<TuitionFee> = z.strictObject({\n\ttarget: z.enum(TUITION_FEE_TARGETS),\n\tamount: z.int().min(0).max(INT32_MAX),\n\tcurrency: z.currencyCode(),\n\tunit: z.enum(TUITION_FEE_UNITS),\n\tdiscount: z.discriminatedUnion(\"type\", [\n\t\tz.strictObject({ type: z.literal(\"percentage\"), value: z.number().min(1).max(99.99) }),\n\t\t// A flat discount of 0 means no discount.\n\t\tz.strictObject({ type: z.literal(\"flat\"), value: z.number().min(0) }),\n\t]),\n}).refine(\n\t(row) => row.discount.type !== \"flat\" || row.discount.value === 0 || row.discount.value <= row.amount - 1,\n\t{ path: [\"discount\", \"value\"], message: \"A flat discount must be 0 or at most amount - 1\" },\n);\n\nconst isDistinct = (values: readonly unknown[]): boolean => new Set(values).size === values.length;\nconst isUniform = (values: readonly unknown[]): boolean => new Set(values).size <= 1;\nconst hasTarget = (rows: readonly { readonly target: string }[], target: string): boolean => rows.some((row) => row.target === target);\n\n// The same rules hold for the record's fees and, in the pipeline, for the fees after enrichment.\nconst fees = z.array(fee)\n\t.refine((rows) => isDistinct(rows.map((row) => row.target)), \"Each target may have at most one fee\")\n\t.refine((rows) => isUniform(rows.map((row) => row.currency)), \"All fees must have the same currency\")\n\t.refine((rows) => isUniform(rows.map((row) => row.unit)), \"All fees must have the same unit\")\n\t.refine(\n\t\t(rows) => !hasTarget(rows, \"eea\") || !(hasTarget(rows, \"national\") || hasTarget(rows, \"local\")),\n\t\t\"An eea fee cannot be combined with a national or local fee\",\n\t);\n\nexport const tuitionFees: PropertyDefinition<readonly TuitionFee[], readonly TuitionFeeTarget[]> = defineProperty({\n\tvalue: fees,\n\t// A study may have no fees; an empty list in a record would say nothing, removing fees is done with clear.\n\tset: { shape: z.array(feeShape), value: fees.min(1) },\n\tclear: {\n\t\tshape: z.array(z.string()),\n\t\tvalue: z.array(z.enum(TUITION_FEE_TARGETS)).min(1).refine(isDistinct, \"Each target may be cleared at most once\"),\n\t},\n});\n\nexport const tuitionFeeDescription = scalarProperty({ shape: z.string(), value: z.string().min(1).max(160) });\n","import { z } from \"zod\";\n\nimport { toFailures } from \"../failures\";\nimport type { Failure } from \"../record\";\n\ninterface Schemas<Shape, Value extends Shape> {\n\t/** Keys and JSON types only; failures are SCHEMA_INVALID. */\n\treadonly shape: z.ZodType<Shape>;\n\t/** Shape and rules for what a record holds; failures are PROPERTY_VALUE_INVALID. Also the published JSON Schema. */\n\treadonly value: z.ZodType<Value>;\n}\n\n/** What a record holds for a property in `set` or `clear`; `undefined` when it holds nothing. */\nexport type Slot = { readonly value: unknown } | undefined;\n\nexport interface PropertyDefinition<Value, Clear> {\n\t/** A valid stored value, e.g. the fees after enrichment. */\n\treadonly value: z.ZodType<Value>;\n\t/** What a record may set: the stored-value rules plus record-only ones. */\n\treadonly set: z.ZodType<Value>;\n\t/** What `clear` holds: `true` for a single value, the keys of the items to remove for a list. */\n\treadonly clear: z.ZodType<Clear>;\n\t/** Shape of what the record sets and clears. */\n\treadonly checkShape: (property: string, set: Slot, clear: Slot) => Failure<\"SCHEMA_INVALID\">[];\n}\n\nconst shapeFailures = (schema: z.ZodType, slot: Slot, property: string, side: \"set\" | \"clear\"): Failure<\"SCHEMA_INVALID\">[] => {\n\tconst result = slot && schema.safeParse(slot.value);\n\treturn result?.success === false ? toFailures(result.error, \"SCHEMA_INVALID\", property, [side, property]) : [];\n};\n\nexport const defineProperty = <SetShape, Value extends SetShape, ClearShape, Clear extends ClearShape>(definition: {\n\treadonly value: z.ZodType<Value>;\n\treadonly set: Schemas<SetShape, Value>;\n\treadonly clear: Schemas<ClearShape, Clear>;\n}): PropertyDefinition<Value, Clear> => ({\n\tvalue: definition.value,\n\tset: definition.set.value,\n\tclear: definition.clear.value,\n\tcheckShape: (property, set, clear) => [\n\t\t...shapeFailures(definition.set.shape, set, property, \"set\"),\n\t\t...shapeFailures(definition.clear.shape, clear, property, \"clear\"),\n\t],\n});\n\nexport const scalarProperty = <Shape, Value extends Shape>(set: Schemas<Shape, Value>): PropertyDefinition<Value, true> =>\n\tdefineProperty({ value: set.value, set, clear: { shape: z.literal(true), value: z.literal(true) } });\n","import type { z } from \"zod\";\n\nimport { toFailures } from \"../failures\";\nimport type { Failure, ImportRecordDraft, Unchecked } from \"../record\";\nimport type { PropertyDefinition, Slot } from \"./definition\";\nimport { tuitionFeeDescription, tuitionFees, type TuitionFee, type TuitionFeeTarget } from \"./tuition-fees\";\n\nexport interface PropertyValues {\n\t/** Fees to create, matched by target. A record that also clears a target replaces that fee: clear is applied first. */\n\ttuitionFees: readonly TuitionFee[];\n\ttuitionFeeDescription: string;\n}\n\nexport interface PropertyClears {\n\t/** Targets whose fees are removed. Removing every fee lists every target. */\n\ttuitionFees: readonly TuitionFeeTarget[];\n\ttuitionFeeDescription: true;\n}\n\nexport type PropertyName = keyof PropertyValues;\n\nexport const properties: { readonly [P in PropertyName]: PropertyDefinition<PropertyValues[P], PropertyClears[P]> } = {\n\ttuitionFees,\n\ttuitionFeeDescription,\n};\n\nexport const isPropertyName = (name: string): name is PropertyName => Object.hasOwn(properties, name);\n\ntype PropertyFailure = Failure<\"PROPERTY_VALUE_INVALID\">;\n\n/**\n * Checks one property value; paths are relative to the value. Producers get it through the validate\n * functions; the import pipeline also runs it on the enriched value, e.g. existing fees merged with the record's.\n */\nexport const validateValue = <P extends PropertyName>(property: P, value: unknown): PropertyFailure[] => {\n\tconst result = properties[property].value.safeParse(value);\n\treturn result.success ? [] : toFailures(result.error, \"PROPERTY_VALUE_INVALID\", property);\n};\n\n/** Properties the record sets or clears, in record order, without duplicates. */\nexport const touchedProperties = (record: Pick<Unchecked<ImportRecordDraft>, \"set\" | \"clear\">): string[] =>\n\t[...new Set([...Object.keys(record.set), ...Object.keys(record.clear)])];\n\nconst slot = (values: Readonly<Record<string, unknown>>, property: PropertyName): Slot =>\n\t(Object.hasOwn(values, property) ? { value: values[property] } : undefined);\n\n/** Phase 1 part of properties: the shape of every writable property the record touches. Unknown properties are left to phase 2. */\nexport const checkShapes = (record: Pick<Unchecked<ImportRecordDraft>, \"set\" | \"clear\">): Failure<\"SCHEMA_INVALID\">[] =>\n\ttouchedProperties(record)\n\t\t.filter(isPropertyName)\n\t\t.flatMap((property) => properties[property].checkShape(property, slot(record.set, property), slot(record.clear, property)));\n\nconst checkSlot = (schema: z.ZodType, values: Readonly<Record<string, unknown>>, property: PropertyName, side: \"set\" | \"clear\"): PropertyFailure[] => {\n\tif (!Object.hasOwn(values, property)) {\n\t\treturn [];\n\t}\n\tconst result = schema.safeParse(values[property]);\n\treturn result.success ? [] : toFailures(result.error, \"PROPERTY_VALUE_INVALID\", property, [side, property]);\n};\n\n/** The rules for what the record clears and sets for one property; its shape passed phase 1. */\nexport const checkProperty = (property: PropertyName, record: Unchecked<ImportRecordDraft>): PropertyFailure[] => [\n\t...checkSlot(properties[property].set, record.set, property, \"set\"),\n\t...checkSlot(properties[property].clear, record.clear, property, \"clear\"),\n];\n","import { checkProperty, isPropertyName, touchedProperties, type PropertyName } from \"./properties\";\nimport type { Failure, ImportRecordDraft, Unchecked } from \"./record\";\n\nexport interface SimpleContext {\n\treadonly record: Unchecked<ImportRecordDraft>;\n\t/** The entry's properties that the record sets or clears. */\n\treadonly touched: readonly PropertyName[];\n}\n\n/** Returns every failure it finds; an empty array means valid. */\nexport type Validator<Context = SimpleContext, Code extends string = Failure[\"code\"]> = (context: Context) => Failure<Code>[];\n\nexport interface RegistryEntry {\n\treadonly properties: readonly PropertyName[];\n\t/** Record-only validators. Run as a chain that stops at the first validator reporting failures. */\n\treadonly simple: readonly Validator[];\n}\n\nconst propertyEntry = (property: PropertyName): RegistryEntry => ({\n\tproperties: [property],\n\tsimple: [({ record }): Failure[] => checkProperty(property, record)],\n});\n\n/**\n * The writable-property allowlist. The import pipeline extends each entry by key with its\n * enrichment and dependent validators, typed `{ [K in RegistryKey]: … }` so a new entry\n * cannot be missed.\n */\nexport const registry = {\n\ttuitionFees: propertyEntry(\"tuitionFees\"),\n\ttuitionFeeDescription: propertyEntry(\"tuitionFeeDescription\"),\n} as const satisfies { readonly [P in PropertyName]: RegistryEntry };\n\nexport type RegistryKey = keyof typeof registry;\n\nconst REGISTRY_KEYS = Object.keys(registry) as RegistryKey[];\n\n/** Keys of the registry entries covering the record's touched properties. Unknown properties are ignored. */\nexport const resolveEntries = (record: Unchecked<ImportRecordDraft>): RegistryKey[] => {\n\tconst touched = new Set(touchedProperties(record).filter(isPropertyName));\n\treturn REGISTRY_KEYS.filter((key) => registry[key].properties.some((property) => touched.has(property)));\n};\n","import { z } from \"zod\";\n\nimport { draftEnvelope, propertyMaps, recordEnvelope } from \"./envelope\";\nimport { toFailures } from \"./failures\";\nimport { checkShapes, isPropertyName, properties, touchedProperties } from \"./properties\";\nimport type { Failure, ImportRecord, ImportRecordDraft, Unchecked, ValidationResult } from \"./record\";\nimport { registry, resolveEntries } from \"./registry\";\n\nconst invalid = <T>(stage: \"schema\" | \"property\", failures: readonly Failure[]): ValidationResult<T> =>\n\t({ valid: false, stage, failures });\n\nconst checkEnvelope = <T extends ImportRecordDraft>(envelope: z.ZodType<Unchecked<T>>, input: unknown): ValidationResult<Unchecked<T>> => {\n\tconst result = envelope.safeParse(input);\n\tconst maps = propertyMaps.safeParse(input);\n\tconst failures = [\n\t\t...(result.success ? [] : toFailures(result.error, \"SCHEMA_INVALID\")),\n\t\t...(maps.success ? checkShapes(maps.data) : []),\n\t];\n\treturn result.success && failures.length === 0 ? { valid: true, record: result.data } : invalid(\"schema\", failures);\n};\n\nconst checkProperties = <T extends ImportRecordDraft>(record: Unchecked<T>): ValidationResult<T> => {\n\tconst touched = touchedProperties(record);\n\tconst notWritable = touched\n\t\t.filter((property) => !isPropertyName(property))\n\t\t.map((property): Failure => ({ code: \"PROPERTY_NOT_WRITABLE\", property, reason: \"Property is not writable\" }));\n\tif (notWritable.length > 0) {\n\t\treturn invalid(\"property\", notWritable);\n\t}\n\tconst failures = resolveEntries(record).flatMap((key) => {\n\t\tconst entry = registry[key];\n\t\tconst context = { record, touched: entry.properties.filter((property) => touched.includes(property)) };\n\t\tfor (const validator of entry.simple) {\n\t\t\tconst found = validator(context);\n\t\t\tif (found.length > 0) {\n\t\t\t\treturn found;\n\t\t\t}\n\t\t}\n\t\treturn [];\n\t});\n\t// Every touched property is writable and its set and clear values passed their rules, so the record has its checked type.\n\treturn failures.length > 0 ? invalid(\"property\", failures) : { valid: true, record: record as unknown as T };\n};\n\nconst chain = <T extends ImportRecordDraft>(schema: ValidationResult<Unchecked<T>>): ValidationResult<T> =>\n\tschema.valid ? checkProperties(schema.record) : schema;\n\n/** Phase 1 of the pipeline (and the first half of {@link validateRecord}): envelope and the shape of known properties. */\nexport const validateSchema = (input: unknown): ValidationResult<Unchecked<ImportRecord>> => checkEnvelope(recordEnvelope, input);\n\n/**\n * Phase 2, simple part: writable-property allowlist and record-only validators. The pipeline calls it after\n * dropping stale properties and before enrichment.\n */\nexport const validateProperties = (record: Unchecked<ImportRecord>): ValidationResult<ImportRecord> => checkProperties(record);\n\n/** For producers, before the registration endpoint has issued `eventId`. */\nexport const validateDraft = (input: unknown): ValidationResult<ImportRecordDraft> => chain(checkEnvelope(draftEnvelope, input));\n\n/** For producers, right before the record is pushed into the Data Stream. */\nexport const validateRecord = (input: unknown): ValidationResult<ImportRecord> => chain(checkEnvelope(recordEnvelope, input));\n\n/** The issued record as JSON Schema; cross-field rules such as discount bounds are not expressible there. */\nexport const importRecordJsonSchema = (): Record<string, unknown> => {\n\tconst definitions = Object.entries(properties);\n\tconst contract = recordEnvelope.extend({\n\t\tset: z.strictObject(Object.fromEntries(definitions.map(([property, { set }]) => [property, set.optional()]))),\n\t\tclear: z.strictObject(Object.fromEntries(definitions.map(([property, { clear }]) => [property, clear.optional()]))),\n\t});\n\t// A local registry: .meta() would write into Zod's global registry, which consumers may share.\n\tconst metadata = z.registry<{ title: string }>();\n\tmetadata.add(contract, { title: \"Data API import record\" });\n\treturn z.toJSONSchema(contract, { metadata });\n};\n","import { z } from \"zod\";\n\n// Ten digits: anything larger is almost certainly milliseconds.\nconst MAX_EPOCH_SECONDS = 9_999_999_999;\n\n// Zod's record parser drops a \"__proto__\" key without an issue; reject it rather than lose it.\nconst propertyMap = z.preprocess((value, context) => {\n\tif (typeof value === \"object\" && value !== null && Object.hasOwn(value, \"__proto__\")) {\n\t\tcontext.addIssue({ code: \"custom\", message: \"Property name __proto__ is not allowed\" });\n\t}\n\treturn value;\n}, z.record(z.string(), z.unknown()));\n\n/** Just the property maps, so their shapes can be checked even when other envelope fields are invalid. */\nexport const propertyMaps = z.object({ set: propertyMap, clear: propertyMap });\n\nexport const draftEnvelope = z.strictObject({\n\tentityType: z.literal(\"study\"),\n\tentityId: z.int().positive(),\n\tset: propertyMap,\n\tclear: propertyMap,\n});\n\nexport const recordEnvelope = draftEnvelope.extend({\n\t// import_event_log.event_id is VARCHAR(64).\n\teventId: z.string().min(1).max(64),\n\teventTimestamp: z.int().positive().max(MAX_EPOCH_SECONDS),\n});\n"],"mappings":"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","names":["z","slot","z","z","z"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@studyportals/data-api-interface",
|
|
3
|
+
"version": "0.1.0-0",
|
|
4
|
+
"description": "Schema validation for Data API import records, shared by producers (SIA) and the import pipeline.",
|
|
5
|
+
"author": "SPNightsWatch",
|
|
6
|
+
"license": "ISC",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.mjs",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"import": {
|
|
13
|
+
"types": "./dist/index.d.mts",
|
|
14
|
+
"default": "./dist/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"require": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"./import-record.schema.json": "./schema/import-record.schema.json",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"schema"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"test-u": "tsc --noEmit && vitest run",
|
|
36
|
+
"test-consumers": "bash scripts/check-consumers.sh",
|
|
37
|
+
"prepublishOnly": "npm run lint && npm run test-u && npm run build && npm run test-consumers",
|
|
38
|
+
"deploy-beta": "npm version prerelease && npm publish --tag beta",
|
|
39
|
+
"deploy-patch": "npm version patch && npm publish",
|
|
40
|
+
"deploy-minor": "npm version minor && npm publish",
|
|
41
|
+
"deploy-major": "npm version major && npm publish"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"zod": "^4.6.7"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@studyportals/code-style": "^3.0.0",
|
|
48
|
+
"@types/node": "^24.19.2",
|
|
49
|
+
"tsup": "^9.0.0",
|
|
50
|
+
"typescript": "^6.0.3",
|
|
51
|
+
"vitest": "^5.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"entityType": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"const": "study"
|
|
8
|
+
},
|
|
9
|
+
"entityId": {
|
|
10
|
+
"type": "integer",
|
|
11
|
+
"exclusiveMinimum": 0,
|
|
12
|
+
"maximum": 9007199254740991
|
|
13
|
+
},
|
|
14
|
+
"set": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"tuitionFees": {
|
|
18
|
+
"minItems": 1,
|
|
19
|
+
"type": "array",
|
|
20
|
+
"items": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"target": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"enum": [
|
|
26
|
+
"eea",
|
|
27
|
+
"national",
|
|
28
|
+
"international",
|
|
29
|
+
"local"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"amount": {
|
|
33
|
+
"type": "integer",
|
|
34
|
+
"minimum": 0,
|
|
35
|
+
"maximum": 2147483647
|
|
36
|
+
},
|
|
37
|
+
"currency": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"format": "currency_code",
|
|
40
|
+
"pattern": "^(?:AED|AFN|ALL|AMD|AOA|ARS|AUD|AWG|AZN|BAM|BBD|BDT|BHD|BIF|BMD|BND|BOB|BOV|BRL|BSD|BTN|BWP|BYN|BZD|CAD|CDF|CHE|CHF|CHW|CLF|CLP|CNY|COP|COU|CRC|CUP|CVE|CZK|DJF|DKK|DOP|DZD|EGP|ERN|ETB|EUR|FJD|FKP|GBP|GEL|GHS|GIP|GMD|GNF|GTQ|GYD|HKD|HNL|HTG|HUF|IDR|ILS|INR|IQD|IRR|ISK|JMD|JOD|JPY|KES|KGS|KHR|KMF|KPW|KRW|KWD|KYD|KZT|LAK|LBP|LKR|LRD|LSL|LYD|MAD|MDL|MGA|MKD|MMK|MNT|MOP|MRU|MUR|MVR|MWK|MXN|MXV|MYR|MZN|NAD|NGN|NIO|NOK|NPR|NZD|OMR|PAB|PEN|PGK|PHP|PKR|PLN|PYG|QAR|RON|RSD|RUB|RWF|SAR|SBD|SCR|SDG|SEK|SGD|SHP|SLE|SOS|SRD|SSP|STN|SVC|SYP|SZL|THB|TJS|TMT|TND|TOP|TRY|TTD|TWD|TZS|UAH|UGX|USD|USN|UYI|UYU|UYW|UZS|VED|VES|VND|VUV|WST|XAD|XAF|XAG|XAU|XBA|XBB|XBC|XBD|XCD|XCG|XDR|XOF|XPD|XPF|XPT|XSU|XTS|XUA|XXX|YER|ZAR|ZMW|ZWG)$"
|
|
41
|
+
},
|
|
42
|
+
"unit": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"enum": [
|
|
45
|
+
"module",
|
|
46
|
+
"day",
|
|
47
|
+
"week",
|
|
48
|
+
"month",
|
|
49
|
+
"quarter",
|
|
50
|
+
"trimester",
|
|
51
|
+
"semester",
|
|
52
|
+
"year",
|
|
53
|
+
"credit",
|
|
54
|
+
"full"
|
|
55
|
+
]
|
|
56
|
+
},
|
|
57
|
+
"discount": {
|
|
58
|
+
"oneOf": [
|
|
59
|
+
{
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": {
|
|
62
|
+
"type": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"const": "percentage"
|
|
65
|
+
},
|
|
66
|
+
"value": {
|
|
67
|
+
"type": "number",
|
|
68
|
+
"minimum": 1,
|
|
69
|
+
"maximum": 99.99
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"required": [
|
|
73
|
+
"type",
|
|
74
|
+
"value"
|
|
75
|
+
],
|
|
76
|
+
"additionalProperties": false
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"type": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"const": "flat"
|
|
84
|
+
},
|
|
85
|
+
"value": {
|
|
86
|
+
"type": "number",
|
|
87
|
+
"minimum": 0
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"required": [
|
|
91
|
+
"type",
|
|
92
|
+
"value"
|
|
93
|
+
],
|
|
94
|
+
"additionalProperties": false
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"required": [
|
|
100
|
+
"target",
|
|
101
|
+
"amount",
|
|
102
|
+
"currency",
|
|
103
|
+
"unit",
|
|
104
|
+
"discount"
|
|
105
|
+
],
|
|
106
|
+
"additionalProperties": false
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"tuitionFeeDescription": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"minLength": 1,
|
|
112
|
+
"maxLength": 160
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"additionalProperties": false
|
|
116
|
+
},
|
|
117
|
+
"clear": {
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"tuitionFees": {
|
|
121
|
+
"minItems": 1,
|
|
122
|
+
"type": "array",
|
|
123
|
+
"items": {
|
|
124
|
+
"type": "string",
|
|
125
|
+
"enum": [
|
|
126
|
+
"eea",
|
|
127
|
+
"national",
|
|
128
|
+
"international",
|
|
129
|
+
"local"
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
"tuitionFeeDescription": {
|
|
134
|
+
"type": "boolean",
|
|
135
|
+
"const": true
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"additionalProperties": false
|
|
139
|
+
},
|
|
140
|
+
"eventId": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"minLength": 1,
|
|
143
|
+
"maxLength": 64
|
|
144
|
+
},
|
|
145
|
+
"eventTimestamp": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"exclusiveMinimum": 0,
|
|
148
|
+
"maximum": 9999999999
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"required": [
|
|
152
|
+
"entityType",
|
|
153
|
+
"entityId",
|
|
154
|
+
"set",
|
|
155
|
+
"clear",
|
|
156
|
+
"eventId",
|
|
157
|
+
"eventTimestamp"
|
|
158
|
+
],
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"title": "Data API import record"
|
|
161
|
+
}
|