@studyportals/data-api-interface 0.1.0-0 → 0.1.0-1

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 CHANGED
@@ -24,10 +24,10 @@ const record: ImportRecord = {
24
24
  };
25
25
  ```
26
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.
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. A record must set or clear at least one property.
28
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
29
  - Only properties in the registry are writable; anything else is rejected.
30
- - Writable properties: `tuitionFees` and `tuitionFeeDescription` (1–160 characters).
30
+ - Writable properties: `tuitionFees` and `tuitionFeeDescription` (up to 160 characters; empty is allowed).
31
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
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
33
 
package/dist/index.js CHANGED
@@ -102,7 +102,7 @@ var tuitionFees = defineProperty({
102
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
103
  }
104
104
  });
105
- var tuitionFeeDescription = scalarProperty({ shape: import_zod2.z.string(), value: import_zod2.z.string().min(1).max(160) });
105
+ var tuitionFeeDescription = scalarProperty({ shape: import_zod2.z.string(), value: import_zod2.z.string().max(160) });
106
106
 
107
107
  // src/properties/index.ts
108
108
  var properties = {
@@ -157,17 +157,21 @@ var propertyMap = import_zod3.z.preprocess((value, context) => {
157
157
  return value;
158
158
  }, import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()));
159
159
  var propertyMaps = import_zod3.z.object({ set: propertyMap, clear: propertyMap });
160
- var draftEnvelope = import_zod3.z.strictObject({
160
+ var draftObject = import_zod3.z.strictObject({
161
161
  entityType: import_zod3.z.literal("study"),
162
162
  entityId: import_zod3.z.int().positive(),
163
163
  set: propertyMap,
164
164
  clear: propertyMap
165
165
  });
166
- var recordEnvelope = draftEnvelope.extend({
166
+ var recordObject = draftObject.extend({
167
167
  // import_event_log.event_id is VARCHAR(64).
168
168
  eventId: import_zod3.z.string().min(1).max(64),
169
169
  eventTimestamp: import_zod3.z.int().positive().max(MAX_EPOCH_SECONDS)
170
170
  });
171
+ var doesSomething = ({ set, clear }) => Object.keys(set).length + Object.keys(clear).length > 0;
172
+ var NOTHING = "A record must set or clear at least one property";
173
+ var draftEnvelope = draftObject.refine(doesSomething, NOTHING);
174
+ var recordEnvelope = recordObject.refine(doesSomething, NOTHING);
171
175
 
172
176
  // src/validate.ts
173
177
  var invalid = (stage, failures) => ({ valid: false, stage, failures });
package/dist/index.js.map CHANGED
@@ -1 +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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACMO,IAAM,aAAa,CAAyB,OAAmB,MAAY,UAAmB,SAAe,CAAC,MACpH,MAAM,OAAO,IAAI,CAAC,UAAU;AAC3B,QAAM,OAAO,CAAC,GAAG,QAAQ,GAAG,MAAM,IAAI,EAAE,IAAI,MAAM;AAClD,SAAO;AAAA,IACN;AAAA,IACA,GAAI,aAAa,SAAY,CAAC,IAAI,EAAE,SAAS;AAAA,IAC7C,GAAI,KAAK,WAAW,IAAI,CAAC,IAAI,EAAE,MAAM,KAAK,KAAK,GAAG,EAAE;AAAA,IACpD,QAAQ,MAAM;AAAA,EACf;AACD,CAAC;;;ACfF,IAAAA,cAAkB;;;ACAlB,iBAAkB;AA0BlB,IAAM,gBAAgB,CAAC,QAAmBC,OAAY,UAAkB,SAAuD;AAC9H,QAAM,SAASA,SAAQ,OAAO,UAAUA,MAAK,KAAK;AAClD,SAAO,QAAQ,YAAY,QAAQ,WAAW,OAAO,OAAO,kBAAkB,UAAU,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC;AAC9G;AAEO,IAAM,iBAAiB,CAAyE,gBAI9D;AAAA,EACxC,OAAO,WAAW;AAAA,EAClB,KAAK,WAAW,IAAI;AAAA,EACpB,OAAO,WAAW,MAAM;AAAA,EACxB,YAAY,CAAC,UAAU,KAAK,UAAU;AAAA,IACrC,GAAG,cAAc,WAAW,IAAI,OAAO,KAAK,UAAU,KAAK;AAAA,IAC3D,GAAG,cAAc,WAAW,MAAM,OAAO,OAAO,UAAU,OAAO;AAAA,EAClE;AACD;AAEO,IAAM,iBAAiB,CAA6B,QAC1D,eAAe,EAAE,OAAO,IAAI,OAAO,KAAK,OAAO,EAAE,OAAO,aAAE,QAAQ,IAAI,GAAG,OAAO,aAAE,QAAQ,IAAI,EAAE,EAAE,CAAC;;;AD1C7F,IAAM,sBAAsB,CAAC,OAAO,YAAY,iBAAiB,OAAO;AAIxE,IAAM,oBAAoB,CAAC,UAAU,OAAO,QAAQ,SAAS,WAAW,aAAa,YAAY,QAAQ,UAAU,MAAM;AAmBhI,IAAM,WAAW,cAAE,aAAa;AAAA,EAC/B,QAAQ,cAAE,OAAO;AAAA,EACjB,QAAQ,cAAE,OAAO;AAAA,EACjB,UAAU,cAAE,OAAO;AAAA,EACnB,MAAM,cAAE,OAAO;AAAA,EACf,UAAU,cAAE,aAAa,EAAE,MAAM,cAAE,OAAO,GAAG,OAAO,cAAE,OAAO,EAAE,CAAC;AACjE,CAAC;AAGD,IAAM,YAAY;AAElB,IAAM,MAA6B,cAAE,aAAa;AAAA,EACjD,QAAQ,cAAE,KAAK,mBAAmB;AAAA,EAClC,QAAQ,cAAE,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,SAAS;AAAA,EACpC,UAAU,cAAE,aAAa;AAAA,EACzB,MAAM,cAAE,KAAK,iBAAiB;AAAA,EAC9B,UAAU,cAAE,mBAAmB,QAAQ;AAAA,IACtC,cAAE,aAAa,EAAE,MAAM,cAAE,QAAQ,YAAY,GAAG,OAAO,cAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,KAAK,EAAE,CAAC;AAAA;AAAA,IAErF,cAAE,aAAa,EAAE,MAAM,cAAE,QAAQ,MAAM,GAAG,OAAO,cAAE,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC;AAAA,EACrE,CAAC;AACF,CAAC,EAAE;AAAA,EACF,CAAC,QAAQ,IAAI,SAAS,SAAS,UAAU,IAAI,SAAS,UAAU,KAAK,IAAI,SAAS,SAAS,IAAI,SAAS;AAAA,EACxG,EAAE,MAAM,CAAC,YAAY,OAAO,GAAG,SAAS,kDAAkD;AAC3F;AAEA,IAAM,aAAa,CAAC,WAAwC,IAAI,IAAI,MAAM,EAAE,SAAS,OAAO;AAC5F,IAAM,YAAY,CAAC,WAAwC,IAAI,IAAI,MAAM,EAAE,QAAQ;AACnF,IAAM,YAAY,CAAC,MAA8C,WAA4B,KAAK,KAAK,CAAC,QAAQ,IAAI,WAAW,MAAM;AAGrI,IAAM,OAAO,cAAE,MAAM,GAAG,EACtB,OAAO,CAAC,SAAS,WAAW,KAAK,IAAI,CAAC,QAAQ,IAAI,MAAM,CAAC,GAAG,sCAAsC,EAClG,OAAO,CAAC,SAAS,UAAU,KAAK,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,GAAG,sCAAsC,EACnG,OAAO,CAAC,SAAS,UAAU,KAAK,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,GAAG,kCAAkC,EAC3F;AAAA,EACA,CAAC,SAAS,CAAC,UAAU,MAAM,KAAK,KAAK,EAAE,UAAU,MAAM,UAAU,KAAK,UAAU,MAAM,OAAO;AAAA,EAC7F;AACD;AAEM,IAAM,cAAsF,eAAe;AAAA,EACjH,OAAO;AAAA;AAAA,EAEP,KAAK,EAAE,OAAO,cAAE,MAAM,QAAQ,GAAG,OAAO,KAAK,IAAI,CAAC,EAAE;AAAA,EACpD,OAAO;AAAA,IACN,OAAO,cAAE,MAAM,cAAE,OAAO,CAAC;AAAA,IACzB,OAAO,cAAE,MAAM,cAAE,KAAK,mBAAmB,CAAC,EAAE,IAAI,CAAC,EAAE,OAAO,YAAY,yCAAyC;AAAA,EAChH;AACD,CAAC;AAEM,IAAM,wBAAwB,eAAe,EAAE,OAAO,cAAE,OAAO,GAAG,OAAO,cAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,CAAC;;;AExDrG,IAAM,aAAyG;AAAA,EACrH;AAAA,EACA;AACD;AAEO,IAAM,iBAAiB,CAAC,SAAuC,OAAO,OAAO,YAAY,IAAI;AAQ7F,IAAM,gBAAgB,CAAyB,UAAa,UAAsC;AACxG,QAAM,SAAS,WAAW,QAAQ,EAAE,MAAM,UAAU,KAAK;AACzD,SAAO,OAAO,UAAU,CAAC,IAAI,WAAW,OAAO,OAAO,0BAA0B,QAAQ;AACzF;AAGO,IAAM,oBAAoB,CAAC,WACjC,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAG,OAAO,KAAK,OAAO,GAAG,GAAG,GAAG,OAAO,KAAK,OAAO,KAAK,CAAC,CAAC,CAAC;AAExE,IAAM,OAAO,CAAC,QAA2C,aACvD,OAAO,OAAO,QAAQ,QAAQ,IAAI,EAAE,OAAO,OAAO,QAAQ,EAAE,IAAI;AAG3D,IAAM,cAAc,CAAC,WAC3B,kBAAkB,MAAM,EACtB,OAAO,cAAc,EACrB,QAAQ,CAAC,aAAa,WAAW,QAAQ,EAAE,WAAW,UAAU,KAAK,OAAO,KAAK,QAAQ,GAAG,KAAK,OAAO,OAAO,QAAQ,CAAC,CAAC;AAE5H,IAAM,YAAY,CAAC,QAAmB,QAA2C,UAAwB,SAA6C;AACrJ,MAAI,CAAC,OAAO,OAAO,QAAQ,QAAQ,GAAG;AACrC,WAAO,CAAC;AAAA,EACT;AACA,QAAM,SAAS,OAAO,UAAU,OAAO,QAAQ,CAAC;AAChD,SAAO,OAAO,UAAU,CAAC,IAAI,WAAW,OAAO,OAAO,0BAA0B,UAAU,CAAC,MAAM,QAAQ,CAAC;AAC3G;AAGO,IAAM,gBAAgB,CAAC,UAAwB,WAA4D;AAAA,EACjH,GAAG,UAAU,WAAW,QAAQ,EAAE,KAAK,OAAO,KAAK,UAAU,KAAK;AAAA,EAClE,GAAG,UAAU,WAAW,QAAQ,EAAE,OAAO,OAAO,OAAO,UAAU,OAAO;AACzE;;;AC9CA,IAAM,gBAAgB,CAAC,cAA2C;AAAA,EACjE,YAAY,CAAC,QAAQ;AAAA,EACrB,QAAQ,CAAC,CAAC,EAAE,OAAO,MAAiB,cAAc,UAAU,MAAM,CAAC;AACpE;AAOO,IAAM,WAAW;AAAA,EACvB,aAAa,cAAc,aAAa;AAAA,EACxC,uBAAuB,cAAc,uBAAuB;AAC7D;AAIA,IAAM,gBAAgB,OAAO,KAAK,QAAQ;AAGnC,IAAM,iBAAiB,CAAC,WAAwD;AACtF,QAAM,UAAU,IAAI,IAAI,kBAAkB,MAAM,EAAE,OAAO,cAAc,CAAC;AACxE,SAAO,cAAc,OAAO,CAAC,QAAQ,SAAS,GAAG,EAAE,WAAW,KAAK,CAAC,aAAa,QAAQ,IAAI,QAAQ,CAAC,CAAC;AACxG;;;ACzCA,IAAAC,cAAkB;;;ACAlB,IAAAC,cAAkB;AAGlB,IAAM,oBAAoB;AAG1B,IAAM,cAAc,cAAE,WAAW,CAAC,OAAO,YAAY;AACpD,MAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,OAAO,OAAO,OAAO,WAAW,GAAG;AACrF,YAAQ,SAAS,EAAE,MAAM,UAAU,SAAS,yCAAyC,CAAC;AAAA,EACvF;AACA,SAAO;AACR,GAAG,cAAE,OAAO,cAAE,OAAO,GAAG,cAAE,QAAQ,CAAC,CAAC;AAG7B,IAAM,eAAe,cAAE,OAAO,EAAE,KAAK,aAAa,OAAO,YAAY,CAAC;AAEtE,IAAM,gBAAgB,cAAE,aAAa;AAAA,EAC3C,YAAY,cAAE,QAAQ,OAAO;AAAA,EAC7B,UAAU,cAAE,IAAI,EAAE,SAAS;AAAA,EAC3B,KAAK;AAAA,EACL,OAAO;AACR,CAAC;AAEM,IAAM,iBAAiB,cAAc,OAAO;AAAA;AAAA,EAElD,SAAS,cAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,EAAE;AAAA,EACjC,gBAAgB,cAAE,IAAI,EAAE,SAAS,EAAE,IAAI,iBAAiB;AACzD,CAAC;;;ADnBD,IAAM,UAAU,CAAI,OAA8B,cAChD,EAAE,OAAO,OAAO,OAAO,SAAS;AAElC,IAAM,gBAAgB,CAA8B,UAAmC,UAAmD;AACzI,QAAM,SAAS,SAAS,UAAU,KAAK;AACvC,QAAM,OAAO,aAAa,UAAU,KAAK;AACzC,QAAM,WAAW;AAAA,IAChB,GAAI,OAAO,UAAU,CAAC,IAAI,WAAW,OAAO,OAAO,gBAAgB;AAAA,IACnE,GAAI,KAAK,UAAU,YAAY,KAAK,IAAI,IAAI,CAAC;AAAA,EAC9C;AACA,SAAO,OAAO,WAAW,SAAS,WAAW,IAAI,EAAE,OAAO,MAAM,QAAQ,OAAO,KAAK,IAAI,QAAQ,UAAU,QAAQ;AACnH;AAEA,IAAM,kBAAkB,CAA8B,WAA8C;AACnG,QAAM,UAAU,kBAAkB,MAAM;AACxC,QAAM,cAAc,QAClB,OAAO,CAAC,aAAa,CAAC,eAAe,QAAQ,CAAC,EAC9C,IAAI,CAAC,cAAuB,EAAE,MAAM,yBAAyB,UAAU,QAAQ,2BAA2B,EAAE;AAC9G,MAAI,YAAY,SAAS,GAAG;AAC3B,WAAO,QAAQ,YAAY,WAAW;AAAA,EACvC;AACA,QAAM,WAAW,eAAe,MAAM,EAAE,QAAQ,CAAC,QAAQ;AACxD,UAAM,QAAQ,SAAS,GAAG;AAC1B,UAAM,UAAU,EAAE,QAAQ,SAAS,MAAM,WAAW,OAAO,CAAC,aAAa,QAAQ,SAAS,QAAQ,CAAC,EAAE;AACrG,eAAW,aAAa,MAAM,QAAQ;AACrC,YAAM,QAAQ,UAAU,OAAO;AAC/B,UAAI,MAAM,SAAS,GAAG;AACrB,eAAO;AAAA,MACR;AAAA,IACD;AACA,WAAO,CAAC;AAAA,EACT,CAAC;AAED,SAAO,SAAS,SAAS,IAAI,QAAQ,YAAY,QAAQ,IAAI,EAAE,OAAO,MAAM,OAA+B;AAC5G;AAEA,IAAM,QAAQ,CAA8B,WAC3C,OAAO,QAAQ,gBAAgB,OAAO,MAAM,IAAI;AAG1C,IAAM,iBAAiB,CAAC,UAA8D,cAAc,gBAAgB,KAAK;AAMzH,IAAM,qBAAqB,CAAC,WAAoE,gBAAgB,MAAM;AAGtH,IAAM,gBAAgB,CAAC,UAAwD,MAAM,cAAc,eAAe,KAAK,CAAC;AAGxH,IAAM,iBAAiB,CAAC,UAAmD,MAAM,cAAc,gBAAgB,KAAK,CAAC;","names":["import_zod","slot","import_zod","import_zod"]}
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().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, recordObject } 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 = recordObject.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\nconst draftObject = z.strictObject({\n\tentityType: z.literal(\"study\"),\n\tentityId: z.int().positive(),\n\tset: propertyMap,\n\tclear: propertyMap,\n});\n\n/** The record without cross-field rules, for building the JSON Schema. */\nexport const recordObject = draftObject.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\n// A record that neither sets nor clears anything does nothing but use up an eventId.\nconst doesSomething = ({ set, clear }: { readonly set: object; readonly clear: object }): boolean =>\n\tObject.keys(set).length + Object.keys(clear).length > 0;\nconst NOTHING = \"A record must set or clear at least one property\";\n\nexport const draftEnvelope = draftObject.refine(doesSomething, NOTHING);\n\nexport const recordEnvelope = recordObject.refine(doesSomething, NOTHING);\n"],"mappings":"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","names":["import_zod","slot","import_zod","import_zod"]}
package/dist/index.mjs CHANGED
@@ -70,7 +70,7 @@ var tuitionFees = defineProperty({
70
70
  value: z2.array(z2.enum(TUITION_FEE_TARGETS)).min(1).refine(isDistinct, "Each target may be cleared at most once")
71
71
  }
72
72
  });
73
- var tuitionFeeDescription = scalarProperty({ shape: z2.string(), value: z2.string().min(1).max(160) });
73
+ var tuitionFeeDescription = scalarProperty({ shape: z2.string(), value: z2.string().max(160) });
74
74
 
75
75
  // src/properties/index.ts
76
76
  var properties = {
@@ -125,17 +125,21 @@ var propertyMap = z3.preprocess((value, context) => {
125
125
  return value;
126
126
  }, z3.record(z3.string(), z3.unknown()));
127
127
  var propertyMaps = z3.object({ set: propertyMap, clear: propertyMap });
128
- var draftEnvelope = z3.strictObject({
128
+ var draftObject = z3.strictObject({
129
129
  entityType: z3.literal("study"),
130
130
  entityId: z3.int().positive(),
131
131
  set: propertyMap,
132
132
  clear: propertyMap
133
133
  });
134
- var recordEnvelope = draftEnvelope.extend({
134
+ var recordObject = draftObject.extend({
135
135
  // import_event_log.event_id is VARCHAR(64).
136
136
  eventId: z3.string().min(1).max(64),
137
137
  eventTimestamp: z3.int().positive().max(MAX_EPOCH_SECONDS)
138
138
  });
139
+ var doesSomething = ({ set, clear }) => Object.keys(set).length + Object.keys(clear).length > 0;
140
+ var NOTHING = "A record must set or clear at least one property";
141
+ var draftEnvelope = draftObject.refine(doesSomething, NOTHING);
142
+ var recordEnvelope = recordObject.refine(doesSomething, NOTHING);
139
143
 
140
144
  // src/validate.ts
141
145
  var invalid = (stage, failures) => ({ valid: false, stage, failures });
@@ -1 +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"]}
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().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, recordObject } 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 = recordObject.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\nconst draftObject = z.strictObject({\n\tentityType: z.literal(\"study\"),\n\tentityId: z.int().positive(),\n\tset: propertyMap,\n\tclear: propertyMap,\n});\n\n/** The record without cross-field rules, for building the JSON Schema. */\nexport const recordObject = draftObject.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\n// A record that neither sets nor clears anything does nothing but use up an eventId.\nconst doesSomething = ({ set, clear }: { readonly set: object; readonly clear: object }): boolean =>\n\tObject.keys(set).length + Object.keys(clear).length > 0;\nconst NOTHING = \"A record must set or clear at least one property\";\n\nexport const draftEnvelope = draftObject.refine(doesSomething, NOTHING);\n\nexport const recordEnvelope = recordObject.refine(doesSomething, NOTHING);\n"],"mappings":"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","names":["z","slot","z","z","z"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studyportals/data-api-interface",
3
- "version": "0.1.0-0",
3
+ "version": "0.1.0-1",
4
4
  "description": "Schema validation for Data API import records, shared by producers (SIA) and the import pipeline.",
5
5
  "author": "SPNightsWatch",
6
6
  "license": "ISC",
@@ -108,7 +108,6 @@
108
108
  },
109
109
  "tuitionFeeDescription": {
110
110
  "type": "string",
111
- "minLength": 1,
112
111
  "maxLength": 160
113
112
  }
114
113
  },