diff --git a/CHANGES.md b/CHANGES.md index cd76f0ea7..eabc514ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -53,6 +53,15 @@ To be released. ### @fedify/vocab-runtime + - Added `Decimal`, a branded string type for exact `xsd:decimal` values, + along with `isDecimal()`, `canParseDecimal()`, and `parseDecimal()` for + checking and validating XML Schema decimal lexical forms without + introducing a decimal arithmetic dependency. `isDecimal()` performs a + strict lexical-form check, while `canParseDecimal()` and `parseDecimal()` + apply XML Schema whitespace normalization first. This lays the runtime + groundwork for precision-safe marketplace and measurement values such as + those needed by [FEP-0837]. [[#617], [#640]] + - Updated the preloaded JSON-LD context to match the current [GoToSocial] v0.21+ namespace, adding new type terms (`LikeRequest`, `LikeAuthorization`, etc.) and property terms @@ -66,9 +75,12 @@ To be released. APIs to distinguish transport failures from specific HTTP fetch failures. [[#611]] +[FEP-0837]: https://w3id.org/fep/0837 [GoToSocial]: https://gotosocial.org/ [#453]: https://github.com/fedify-dev/fedify/issues/453 +[#617]: https://github.com/fedify-dev/fedify/issues/617 [#622]: https://github.com/fedify-dev/fedify/pull/622 +[#640]: https://github.com/fedify-dev/fedify/pull/640 ### @fedify/cli @@ -134,6 +146,14 @@ To be released. ### @fedify/vocab-tools + - Added `xsd:decimal` support to the vocabulary code generator. Properties + with that range are now generated as `Decimal` in TypeScript, serialized + as `xsd:decimal` JSON-LD literals, validated through + `canParseDecimal()` when checking input data, and normalized through + `parseDecimal()` when decoded. Code generation now also rejects property + ranges that mix `xsd:string` and `xsd:decimal`, since both map to runtime + strings and would make serialization ambiguous. [[#617], [#640]] + - Added `typeless` field to the type YAML schema. When set to `true`, the generated `toJsonLd()` method does not emit `@type` (or `type` in compact form) in the serialized JSON-LD. This is useful for types diff --git a/docs/manual/vocab.md b/docs/manual/vocab.md index dbcae5506..a92a38c1f 100644 --- a/docs/manual/vocab.md +++ b/docs/manual/vocab.md @@ -508,6 +508,7 @@ corresponding TypeScript types: | `xsd:integer` | `number` | | `xsd:nonNegativeInteger` | `number` | | `xsd:float` | `number` | +| `xsd:decimal` | `Decimal` | | `xsd:string` | `string` | | `xsd:anyURI` | [`URL`] | | `xsd:dateTime` | [`Temporal.Instant`] | @@ -521,6 +522,40 @@ corresponding TypeScript types: | Proof purpose | `"assertionMethod" \| "authentication" \| "capabilityInvocation" \| "capabilityDelegation" \| "keyAgreement"` | | Units | `"cm" \| "feet" \| "inches" \| "km" \| "m" \| "miles" \| URL` | +`Decimal` values come from `@fedify/vocab-runtime` as a branded string type. +Use `isDecimal()` when you need to check whether a string is already in the +normalized `xsd:decimal` lexical form, and use `canParseDecimal()` or +`parseDecimal()` when you need XML Schema whitespace normalization before +validation: + +~~~~ typescript twoslash +import type { Decimal } from "@fedify/vocab-runtime"; +import { + canParseDecimal, + isDecimal, + parseDecimal, +} from "@fedify/vocab-runtime"; + +const raw = " 12.50 "; + +isDecimal(raw); // false +canParseDecimal(raw); // true + +const price: Decimal = parseDecimal(raw); +price; // "12.50" +~~~~ + +`Decimal` keeps the original string at runtime instead of converting it to +JavaScript `number`, which avoids floating-point precision loss for exact +decimal values such as prices and measurements. `parseDecimal()` normalizes +XML Schema whitespace before returning the branded value, so the runtime +representation always uses the normalized lexical form. + +A property range must not combine `xsd:string` and `xsd:decimal`. Both map to +runtime strings, so the generated encoder cannot distinguish them reliably +during JSON-LD serialization and Fedify rejects such schema definitions at code +generation time. + [`Temporal.Instant`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant [`Temporal.Duration`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration [`Uint8Array`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array diff --git a/packages/vocab-runtime/src/decimal.test.ts b/packages/vocab-runtime/src/decimal.test.ts new file mode 100644 index 000000000..93bd04029 --- /dev/null +++ b/packages/vocab-runtime/src/decimal.test.ts @@ -0,0 +1,90 @@ +import { deepStrictEqual, throws } from "node:assert"; +import { test } from "node:test"; +import { canParseDecimal, isDecimal, parseDecimal } from "./decimal.ts"; +import { + canParseDecimal as canParseDecimalFromModule, + isDecimal as isDecimalFromModule, + parseDecimal as parseDecimalFromModule, +} from "./mod.ts"; + +test("parseDecimal() accepts valid xsd:decimal lexical forms", () => { + const values = [ + "-1.23", + "12678967.543233", + "+100000.00", + "210", + ".5", + "5.", + "0", + "-0.0", + ]; + + for (const value of values) { + deepStrictEqual(parseDecimal(value), value); + } +}); + +test("isDecimal() reports valid xsd:decimal lexical forms", () => { + deepStrictEqual(isDecimal("12.50"), true); + deepStrictEqual(isDecimal(".5"), true); + deepStrictEqual(isDecimal("1e3"), false); + deepStrictEqual(isDecimal(" 12.50 "), false); + deepStrictEqual(isDecimal("\t12.50\n"), false); +}); + +test("canParseDecimal() accepts whitespace-normalized xsd:decimal strings", () => { + deepStrictEqual(canParseDecimal("12.50"), true); + deepStrictEqual(canParseDecimal(" 12.50 "), true); + deepStrictEqual(canParseDecimal("\t+100000.00\r\n"), true); + deepStrictEqual(canParseDecimal(" .5 "), true); + deepStrictEqual(canParseDecimal("1e3"), false); + deepStrictEqual(canParseDecimal("1 2.50"), false); + deepStrictEqual(canParseDecimal("1\t2.50"), false); +}); + +test("parseDecimal() normalizes XML Schema whitespace", () => { + deepStrictEqual(parseDecimal("12.50"), "12.50"); + deepStrictEqual(parseDecimal(" 12.50 "), "12.50"); + deepStrictEqual(parseDecimal("\t+100000.00\r\n"), "+100000.00"); + deepStrictEqual(parseDecimal(" .5 "), ".5"); +}); + +test("parseDecimal() rejects invalid xsd:decimal lexical forms", () => { + const values = [ + "", + ".", + "+", + "-", + "1e3", + "NaN", + "INF", + "1,2", + "1..2", + "1 2.3", + "1\t2.3", + ]; + + for (const value of values) { + throws( + () => parseDecimal(value), + { + name: "TypeError", + message: `${ + JSON.stringify(value) + } is not a valid xsd:decimal lexical form.`, + }, + ); + } +}); + +test("parseDecimal() is exported from the package root", () => { + deepStrictEqual(parseDecimalFromModule("12.50"), "12.50"); +}); + +test("canParseDecimal() is exported from the package root", () => { + deepStrictEqual(canParseDecimalFromModule(" 12.50 "), true); +}); + +test("isDecimal() is exported from the package root", () => { + deepStrictEqual(isDecimalFromModule("12.50"), true); +}); diff --git a/packages/vocab-runtime/src/decimal.ts b/packages/vocab-runtime/src/decimal.ts new file mode 100644 index 000000000..5eb11a6cd --- /dev/null +++ b/packages/vocab-runtime/src/decimal.ts @@ -0,0 +1,112 @@ +const DECIMAL_PATTERN = /^(\+|-)?([0-9]+(\.[0-9]*)?|\.[0-9]+)$/; +const XML_SCHEMA_WHITESPACE_PATTERN = /[\t\n\r ]+/g; + +function collapseXmlSchemaWhitespace(value: string): string { + return value.replace(XML_SCHEMA_WHITESPACE_PATTERN, " ").trim(); +} + +/** + * A branded string representing an `xsd:decimal` value. + * + * Unlike JavaScript's `number`, `xsd:decimal` is intended for exact decimal + * values such as prices, quantities, and measurements where binary + * floating-point rounding would be inappropriate. Fedify therefore represents + * these values as validated strings at runtime while preserving a distinct + * TypeScript type. + * + * Values of this type must be created through {@link parseDecimal}, which + * validates that the string matches the XML Schema `xsd:decimal` lexical form. + * + * The runtime representation is still a plain string. The brand exists only + * at the type level so APIs can distinguish arbitrary strings from validated + * decimal literals without introducing a decimal arithmetic dependency. + * + * Supported lexical forms include signed and unsigned integers and decimal + * fractions such as `"-1.23"`, `"+100000.00"`, `"210"`, `".5"`, and `"5."`. + * Scientific notation such as `"1e3"` and special values like `"NaN"` are + * rejected. Strings with surrounding XML Schema whitespace can be normalized + * by {@link parseDecimal}, but values of this type are always stored in their + * normalized lexical form. + * + * This representation is designed to be forward-compatible with a future + * native decimal type if JavaScript eventually gains one, while keeping the + * public API semantically precise today. + * + * @since 2.1.0 + */ +export type Decimal = string & { readonly __brand: "Decimal" }; + +/** + * Checks whether a string is a valid `xsd:decimal` lexical form. + * + * This predicate checks the lexical form strictly, without applying XML Schema + * whitespace normalization first. It is useful as a type guard for values + * that are already expected to be normalized decimal strings. + * + * @param value A candidate `xsd:decimal` lexical form. + * @returns `true` if the string matches the XML Schema `xsd:decimal` lexical + * form, or `false` otherwise. + * @since 2.1.0 + */ +export function isDecimal(value: string): value is Decimal { + return DECIMAL_PATTERN.test(value); +} + +/** + * Checks whether a string can be parsed as an `xsd:decimal` lexical form. + * + * Unlike {@link isDecimal}, this predicate first applies the XML Schema + * `whiteSpace="collapse"` normalization step and then validates the + * normalized string. This means values like `" 12.50 "` are parseable even + * though they are not already normalized decimal literals. + * + * @param value A candidate `xsd:decimal` lexical form. + * @returns `true` if the normalized string matches the XML Schema + * `xsd:decimal` lexical form, or `false` otherwise. + * @since 2.1.0 + */ +export function canParseDecimal(value: string): boolean { + return isDecimal(collapseXmlSchemaWhitespace(value)); +} + +/** + * Parses a string as an `xsd:decimal` lexical form and returns it as a + * branded {@link Decimal}. + * + * This function validates the input against the XML Schema `xsd:decimal` + * lexical space after applying the XML Schema `whiteSpace="collapse"` + * normalization step. It returns the normalized string without any further + * canonicalization. + * + * @param value A candidate `xsd:decimal` lexical form. + * @returns The normalized string branded as {@link Decimal}. + * @throws {TypeError} Thrown when the value is not a valid `xsd:decimal` + * lexical form. + * @example + * ```typescript + * const price = parseDecimal("12.50"); + * ``` + * @example + * ```typescript + * const price = parseDecimal(" 12.50 "); + * console.assert(price === "12.50"); + * ``` + * @example + * ```typescript + * try { + * parseDecimal("1e3"); + * } catch (error) { + * console.assert(error instanceof TypeError); + * } + * ``` + * @since 2.1.0 + */ +export function parseDecimal(value: string): Decimal { + const normalized = collapseXmlSchemaWhitespace(value); + if (!isDecimal(normalized)) { + throw new TypeError( + `${JSON.stringify(value)} is not a valid xsd:decimal lexical form.`, + ); + } + return normalized as Decimal; +} diff --git a/packages/vocab-runtime/src/mod.ts b/packages/vocab-runtime/src/mod.ts index ef969898f..1a6bda9e7 100644 --- a/packages/vocab-runtime/src/mod.ts +++ b/packages/vocab-runtime/src/mod.ts @@ -24,6 +24,12 @@ export { importPkcs1, importSpki, } from "./key.ts"; +export { + canParseDecimal, + type Decimal, + isDecimal, + parseDecimal, +} from "./decimal.ts"; export { LanguageString } from "./langstr.ts"; export { decodeMultibase, diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap index 1703c280e..f4edcf682 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.deno.snap @@ -1,13 +1,15 @@ export const snapshot = {}; snapshot[`generateClasses() 1`] = ` -"// deno-lint-ignore-file ban-unused-ignore prefer-const +"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax import jsonld from \\"@fedify/vocab-runtime/jsonld\\"; import { getLogger } from \\"@logtape/logtape\\"; import { type Span, SpanStatusCode, type TracerProvider, trace } from \\"@opentelemetry/api\\"; import { + canParseDecimal, decodeMultibase, + type Decimal, type DocumentLoader, encodeMultibase, exportMultibaseKey, @@ -15,8 +17,10 @@ import { getDocumentLoader, importMultibaseKey, importPem, + isDecimal, LanguageString, - type RemoteDocument, + parseDecimal, + type RemoteDocument } from \\"@fedify/vocab-runtime\\"; diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap index 8d2df61d9..4d3583af9 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.node.snap @@ -1,11 +1,13 @@ exports[`generateClasses() 1`] = ` -"// deno-lint-ignore-file ban-unused-ignore prefer-const +"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax import jsonld from \\"@fedify/vocab-runtime/jsonld\\"; import { getLogger } from \\"@logtape/logtape\\"; import { type Span, SpanStatusCode, type TracerProvider, trace } from \\"@opentelemetry/api\\"; import { + canParseDecimal, decodeMultibase, + type Decimal, type DocumentLoader, encodeMultibase, exportMultibaseKey, @@ -13,8 +15,10 @@ import { getDocumentLoader, importMultibaseKey, importPem, + isDecimal, LanguageString, - type RemoteDocument, + parseDecimal, + type RemoteDocument } from \\"@fedify/vocab-runtime\\"; diff --git a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap index 51b59dc62..e65a687f7 100644 --- a/packages/vocab-tools/src/__snapshots__/class.test.ts.snap +++ b/packages/vocab-tools/src/__snapshots__/class.test.ts.snap @@ -1,13 +1,15 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots exports[`generateClasses() 1`] = ` -"// deno-lint-ignore-file ban-unused-ignore prefer-const +"// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax import jsonld from "@fedify/vocab-runtime/jsonld"; import { getLogger } from "@logtape/logtape"; import { type Span, SpanStatusCode, type TracerProvider, trace } from "@opentelemetry/api"; import { + canParseDecimal, decodeMultibase, + type Decimal, type DocumentLoader, encodeMultibase, exportMultibaseKey, @@ -15,8 +17,10 @@ import { getDocumentLoader, importMultibaseKey, importPem, + isDecimal, LanguageString, - type RemoteDocument, + parseDecimal, + type RemoteDocument } from "@fedify/vocab-runtime"; diff --git a/packages/vocab-tools/src/class.test.ts b/packages/vocab-tools/src/class.test.ts index f58f82dee..4c743a883 100644 --- a/packages/vocab-tools/src/class.test.ts +++ b/packages/vocab-tools/src/class.test.ts @@ -1,9 +1,10 @@ -import { deepStrictEqual, match } from "node:assert"; +import { deepStrictEqual, match, rejects } from "node:assert"; import { basename, dirname, extname, join } from "node:path"; import { test } from "node:test"; import metadata from "../deno.json" with { type: "json" }; import { generateClasses, sortTopologically } from "./class.ts"; -import { loadSchemaFiles } from "./schema.ts"; +import { getDataCheck } from "./type.ts"; +import { loadSchemaFiles, type TypeSchema } from "./schema.ts"; test("sortTopologically()", () => { const sorted = sortTopologically({ @@ -69,6 +70,56 @@ test("generateClasses() imports the browser-safe jsonld entrypoint", async () => match(entireCode, /import jsonld from "@fedify\/vocab-runtime\/jsonld";/); }); +test("generateClasses() imports Decimal helpers for xsd:decimal", async () => { + const entireCode = await getDecimalFixtureCode(); + match(entireCode, /canParseDecimal,/); + match(entireCode, /isDecimal,/); + match(entireCode, /type Decimal,/); + match(entireCode, /parseDecimal/); + match(entireCode, /amount\?: Decimal \| null;/); + match(entireCode, /isDecimal\(values\.amount\)/); + match(entireCode, /parseDecimal\(v\["@value"\]\)/); +}); + +test("getDataCheck() uses canParseDecimal() for xsd:decimal", () => { + const check = getDataCheck( + "http://www.w3.org/2001/XMLSchema#decimal", + {}, + "v", + ); + match(check, /canParseDecimal\(v\["@value"\]\)/); +}); + +test("generateClasses() rejects xsd:string and xsd:decimal unions", async () => { + await rejects( + Array.fromAsync(generateClasses({ + "https://example.com/measure": { + name: "Measure", + uri: "https://example.com/measure", + compactName: "Measure", + entity: false, + description: "A measure.", + properties: [ + { + singularName: "amount", + functional: true, + compactName: "amount", + uri: "https://example.com/amount", + description: "An exact decimal amount.", + range: [ + "http://www.w3.org/2001/XMLSchema#decimal", + "http://www.w3.org/2001/XMLSchema#string", + ], + }, + ], + defaultContext: + "https://example.com/context" as TypeSchema["defaultContext"], + }, + })), + /cannot have both xsd:string and xsd:decimal in its range/, + ); +}); + if ("Deno" in globalThis) { const { assertSnapshot } = await import("@std/testing/snapshot"); Deno.test("generateClasses()", async (t) => { @@ -101,6 +152,31 @@ async function getEntireCode() { return entireCode; } +async function getDecimalFixtureCode() { + const types: Record = { + "https://example.com/measure": { + name: "Measure", + uri: "https://example.com/measure", + compactName: "Measure", + entity: false, + description: "A measure.", + properties: [ + { + singularName: "amount", + functional: true, + compactName: "amount", + uri: "https://example.com/amount", + description: "An exact decimal amount.", + range: ["http://www.w3.org/2001/XMLSchema#decimal"], + }, + ], + defaultContext: + "https://example.com/context" as TypeSchema["defaultContext"], + }, + }; + return (await Array.fromAsync(generateClasses(types))).join(""); +} + async function changeNodeSnapshotPath() { const { snapshot } = await import("node:test"); snapshot.setResolveSnapshotPath( diff --git a/packages/vocab-tools/src/class.ts b/packages/vocab-tools/src/class.ts index 94a3f4de7..87f7fd2ba 100644 --- a/packages/vocab-tools/src/class.ts +++ b/packages/vocab-tools/src/class.ts @@ -3,7 +3,7 @@ import { generateCloner, generateConstructor } from "./constructor.ts"; import { generateFields } from "./field.ts"; import { generateInspector, generateInspectorPostClass } from "./inspector.ts"; import { generateProperties } from "./property.ts"; -import type { TypeSchema } from "./schema.ts"; +import { type TypeSchema, validateTypeSchemas } from "./schema.ts"; import { emitOverride } from "./type.ts"; /** @@ -117,23 +117,31 @@ async function* generateClass( export async function* generateClasses( types: Record, ): AsyncIterable { - yield "// deno-lint-ignore-file ban-unused-ignore prefer-const\n"; + validateTypeSchemas(types); + const runtimeImports = [ + "canParseDecimal", + "decodeMultibase", + "type Decimal", + "type DocumentLoader", + "encodeMultibase", + "exportMultibaseKey", + "exportSpki", + "getDocumentLoader", + "importMultibaseKey", + "importPem", + "isDecimal", + "LanguageString", + "parseDecimal", + "type RemoteDocument", + ]; + yield "// deno-lint-ignore-file ban-unused-ignore no-unused-vars prefer-const verbatim-module-syntax\n"; yield 'import jsonld from "@fedify/vocab-runtime/jsonld";\n'; yield 'import { getLogger } from "@logtape/logtape";\n'; yield `import { type Span, SpanStatusCode, type TracerProvider, trace } from "@opentelemetry/api";\n`; - yield `import { - decodeMultibase, - type DocumentLoader, - encodeMultibase, - exportMultibaseKey, - exportSpki, - getDocumentLoader, - importMultibaseKey, - importPem, - LanguageString, - type RemoteDocument, -} from "@fedify/vocab-runtime";\n`; + yield `import {\n ${ + runtimeImports.join(",\n ") + }\n} from "@fedify/vocab-runtime";\n`; yield "\n\n"; const sorted = sortTopologically(types); for (const typeUri of sorted) { diff --git a/packages/vocab-tools/src/schema.ts b/packages/vocab-tools/src/schema.ts index 769ae95cc..4e08d9204 100644 --- a/packages/vocab-tools/src/schema.ts +++ b/packages/vocab-tools/src/schema.ts @@ -240,6 +240,38 @@ export function hasSingularAccessor(property: PropertySchema): boolean { property.singularAccessor === true; } +const XSD_STRING_URI = "http://www.w3.org/2001/XMLSchema#string"; +const XSD_DECIMAL_URI = "http://www.w3.org/2001/XMLSchema#decimal"; + +/** + * Validates schema combinations that cannot be represented safely by the + * generated code. + * + * In particular, `xsd:string` and `xsd:decimal` cannot coexist in the same + * property range because both are represented as runtime strings, which makes + * JSON-LD serialization ambiguous and order-dependent. + * + * @param types The loaded type schemas to validate. + * @throws {TypeError} Thrown when an unsupported range combination is found. + */ +export function validateTypeSchemas( + types: Record, +): void { + for (const type of Object.values(types)) { + for (const property of type.properties) { + const hasString = property.range.includes(XSD_STRING_URI); + const hasDecimal = property.range.includes(XSD_DECIMAL_URI); + if (hasString && hasDecimal) { + throw new TypeError( + `The property ${type.name}.${property.singularName} cannot have ` + + `both xsd:string and xsd:decimal in its range because the ` + + `generated encoder cannot disambiguate them at runtime.`, + ); + } + } + } +} + /** * An error that occurred while loading a schema file. */ diff --git a/packages/vocab-tools/src/type.ts b/packages/vocab-tools/src/type.ts index 49b84d20f..99f1bb6bc 100644 --- a/packages/vocab-tools/src/type.ts +++ b/packages/vocab-tools/src/type.ts @@ -108,6 +108,30 @@ const scalarTypes: Record = { return `${v}["@value"]`; }, }, + "http://www.w3.org/2001/XMLSchema#decimal": { + name: "Decimal", + typeGuard(v) { + return `typeof ${v} === "string" && isDecimal(${v})`; + }, + encoder(v) { + return `{ + "@type": "http://www.w3.org/2001/XMLSchema#decimal", + "@value": ${v}, + }`; + }, + compactEncoder(v) { + return v; + }, + dataCheck(v) { + return `typeof ${v} === "object" && "@type" in ${v} + && ${v}["@type"] === "http://www.w3.org/2001/XMLSchema#decimal" + && "@value" in ${v} && typeof ${v}["@value"] === "string" + && canParseDecimal(${v}["@value"])`; + }, + decoder(v) { + return `parseDecimal(${v}["@value"])`; + }, + }, "http://www.w3.org/2001/XMLSchema#string": { name: "string", typeGuard(v) {