Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Each derives both directions from one declaration. Decode swaps `from` and `to`
| `move` | `from`, `to` | carries any JSON value across | carries it back | always |
| `scale` | `factor`: finite, non-zero | `value * factor` | `value / factor` | the factor is an integer or a power of two; other factors can lose the last bit |
| `enum` | `map`, `mapBack` | `map[value]` | `mapBack[value]` | always: `mapBack` must be exactly the inverse of an injective `map`, checked when the crosswalk is parsed, and an unmapped value is an error |
| `format` | `kind`: `isoDateTime` or `numberText` | parses the text: an ISO-8601 date or timestamp to epoch milliseconds, a decimal string to a number | renders the canonical text: `toISOString`, or `String(number)` | the input is already in the canonical spelling |
| `format` | `kind`: `isoDateTime`, `isoDate`, `numberText`, `booleanText` or `jsonText` | parses the text: an ISO-8601 date or timestamp to epoch milliseconds, a calendar date (`YYYY-MM-DD`) to its UTC midnight, a decimal string to a number, `"true"` or `"false"` to a boolean, JSON text to the value it denotes | renders the canonical text: `toISOString`, its date part, `String(number)`, `"true"` or `"false"`, `JSON.stringify` | the input is already in the canonical spelling (for `jsonText`, no whitespace outside strings; a `__proto__` key is refused, since the output cannot carry it) |
| `split` | `separator`: non-empty | cuts the string at every separator | joins the parts with it | no part contains the separator; decode throws otherwise |
| `template` | `from`: a record of field names to paths, `template`: text with `{name}` placeholders | renders the string fields into the template | splits the text back into the fields | always: every field is used exactly once and a literal separates every two placeholders, checked when the crosswalk is parsed, and encode throws for a value containing the literal that follows its placeholder |
| `copy` | `from`, `to`: two or more paths | writes the one value at every `to` | reads every copy and writes the value back | always: decode throws when the copies disagree |
Expand Down Expand Up @@ -261,11 +261,10 @@ The crosswalk format is published as a JSON Schema 2020-12 document, generated f
Each of these is a deliberate omission, listed so it is not mistaken for an oversight:

- `nest` and `unnest`: a `move` whose `to` is deeper nests, one whose `to` is shallower flattens, and a `move` of a whole record carries every field in it, so a dedicated operation would add nothing the format cannot already say.
- Lookup against external tables.
- Array filter and sort.
- Regex split.
- Further `format` kinds.
- A streaming executor.
- Lookup against external tables: the crosswalk would stop defining both directions on its own, since decode inverts encode only if the caller supplies the identical table both times. An injective table belongs in an `enum` rule (build `map` from it and `mapBack` from its inverse), where the bijection is checked and travels with the document.
- Array filter and sort: wildcards are index-aligned, and moving elements away from their positions would break every rule that decodes from them. Per-element `when` gating applies a rule to some elements without moving any.
- Regex split: nothing records which text a pattern matched, so the join is undetermined, and a backtracking pattern in a crosswalk read from anywhere would let its author make encoding arbitrarily expensive.
- A streaming executor: `encode` and `decode` take parsed values, so the document is already in memory, and a `when` can read any path of it. A sequence of records streams by calling `encode` per record.

## Development

Expand Down
20 changes: 19 additions & 1 deletion src/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const whenQtyIsBig: When = { kind: "compare", op: "gt", left: { kind: "reference
/**
* Samples carry only mapped fields (the crosswalk is a total mapping: an unmapped field does not survive encode, so a sample carrying one would fail its own round trip) and use the canonical ISO form decode emits, since any other ISO spelling names the same instant but not the same string.
*
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, template, copy, drop, default encode-direction, default decode-direction, aggregate), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, format isoDate, format booleanText, format jsonText, split, template, copy, drop, default encode-direction, default decode-direction, aggregate), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
*/
export const CORPUS: readonly CorpusCell[] = [
{
Expand Down Expand Up @@ -47,6 +47,21 @@ export const CORPUS: readonly CorpusCell[] = [
crosswalk: { source: "native", target: "shared", rules: [{ op: "format", from: "count", to: "countText", kind: "numberText" }], oneWay: [] },
samples: [{ count: "1234" }, { count: "-7.5" }],
},
{
name: "format isoDate",
crosswalk: { source: "native", target: "shared", rules: [{ op: "format", from: "on", to: "onMs", kind: "isoDate" }], oneWay: [] },
samples: [{ on: "2026-10-18" }, { on: "2024-02-29" }, { on: "1969-12-31" }],
},
{
name: "format booleanText",
crosswalk: { source: "native", target: "shared", rules: [{ op: "format", from: "flag", to: "isSet", kind: "booleanText" }], oneWay: [] },
samples: [{ flag: "true" }, { flag: "false" }],
},
{
name: "format jsonText",
crosswalk: { source: "native", target: "shared", rules: [{ op: "format", from: "raw", to: "value", kind: "jsonText" }], oneWay: [] },
samples: [{ raw: '{"a":[1,null,"x"]}' }, { raw: '"text"' }, { raw: "[]" }],
},
{
name: "split",
crosswalk: { source: "native", target: "shared", rules: [{ op: "split", from: "names", to: "nameParts", separator: "," }], oneWay: [] },
Expand Down Expand Up @@ -217,6 +232,9 @@ export const REQUIRED_ROWS: readonly string[] = [
"enum",
"format isoDateTime",
"format numberText",
"format isoDate",
"format booleanText",
"format jsonText",
"split",
"template",
"copy",
Expand Down
3 changes: 2 additions & 1 deletion src/crosswalk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const EnumRuleSchema = z
});
export type EnumRule = z.infer<typeof EnumRuleSchema>;

export const FormatKindSchema = z.enum(["isoDateTime", "numberText"]);
/** The textual forms `format` converts: each is a bijection between its canonical text and a machine value, which is what makes the round trip hold for canonical input. */
export const FormatKindSchema = z.enum(["isoDateTime", "isoDate", "numberText", "booleanText", "jsonText"]);
export type FormatKind = z.infer<typeof FormatKindSchema>;

export const FormatRuleSchema = z
Expand Down
123 changes: 117 additions & 6 deletions src/ops/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,131 @@ function parseNumberText(value: string): number {
return parsed;
}

/** The encode half of `format`: parses the native textual form into the shared machine form (an ISO-8601 timestamp to epoch milliseconds; a decimal string to a number). */
export function formatEncode(rule: FormatRule, value: unknown): number {
/** The length of a UTC day in milliseconds: epoch milliseconds denote a calendar date only at a multiple of it (ECMAScript time has no leap seconds). */
const MILLISECONDS_PER_DAY = 86_400_000;

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/u;

function parseIsoDate(value: string): number {
if (!ISO_DATE.test(value)) {
throw new Error(`value is not an ISO-8601 calendar date (YYYY-MM-DD): ${JSON.stringify(value)}`);
}

return parseIsoDateTime(value);
}

function renderIsoDate(value: number): string {
if (!Number.isInteger(value / MILLISECONDS_PER_DAY)) {
throw new Error(`value is not a UTC midnight, so it names no calendar date: ${String(value)}`);
}
const text = new Date(value).toISOString().slice(0, "YYYY-MM-DD".length);
if (!ISO_DATE.test(text)) {
throw new Error(`value is outside the four-digit years an ISO calendar date can spell: ${String(value)}`);
}

return text;
}

function parseBooleanText(value: string): boolean {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`value is not "true" or "false": ${JSON.stringify(value)}`);
}

/**
* Parses JSON text. A parsed `__proto__` key is refused: JSON.parse makes it an ordinary own key, but the output's JSON value check drops it without a word, so the value would silently lose it (the reason the path grammar refuses that key too).
*/
function parseJsonText(value: string): unknown {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new Error(`value is not JSON text: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
}
if (holdsProtoKey(parsed)) {
throw new Error(`JSON text holds a __proto__ key, which the output cannot carry: ${JSON.stringify(value)}`);
}

return parsed;
}

function holdsProtoKey(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some((element: unknown) => holdsProtoKey(element));
}
if (typeof value === "object" && value !== null) {
return Object.entries(value).some(([key, child]) => key === "__proto__" || holdsProtoKey(child));
}

return false;
}

/** The encode half of `format`: parses the native textual form into the shared machine form (an ISO-8601 timestamp or calendar date to epoch milliseconds, a decimal string to a number, `"true"` or `"false"` to a boolean, JSON text to the value it denotes). */
export function formatEncode(rule: FormatRule, value: unknown): unknown {
if (typeof value !== "string") {
throw new Error(`format (${rule.kind}) applies to strings, found: ${JSON.stringify(value)}`);
}

return rule.kind === "isoDateTime" ? parseIsoDateTime(value) : parseNumberText(value);
switch (rule.kind) {
case "isoDateTime":
return parseIsoDateTime(value);
case "isoDate":
return parseIsoDate(value);
case "numberText":
return parseNumberText(value);
case "booleanText":
return parseBooleanText(value);
case "jsonText":
return parseJsonText(value);
default:
return unhandledKind();
}
}

/** The decode half of `format`: renders the shared machine form back into the native textual form. */
/** The decode half of `format`: renders the shared machine form back into the canonical textual form (`toISOString`, its date part, `String(number)`, `"true"` or `"false"`, `JSON.stringify`). */
export function formatDecode(rule: FormatRule, value: unknown): string {
switch (rule.kind) {
case "isoDateTime":
return new Date(finiteNumber(rule, value)).toISOString();
case "isoDate":
return renderIsoDate(finiteNumber(rule, value));
case "numberText":
return String(finiteNumber(rule, value));
case "booleanText":
if (typeof value !== "boolean") {
throw new Error(`format (booleanText) decode applies to booleans, found: ${JSON.stringify(value)}`);
}

return String(value);
case "jsonText":
return renderJsonText(value);
default:
return unhandledKind();
}
}

/** JSON.stringify returns `undefined`, not text, for a value JSON cannot express (`undefined` or a function at the top level), so that case is an error here rather than a non-string leaking out. */
function renderJsonText(value: unknown): string {
const text: unknown = JSON.stringify(value);
if (typeof text !== "string") {
throw new Error("format (jsonText) decode applies to JSON values, found a value JSON cannot express");
}

return text;
}

function finiteNumber(rule: FormatRule, value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`format (${rule.kind}) decode applies to finite numbers, found: ${JSON.stringify(value)}`);
}

return rule.kind === "isoDateTime" ? new Date(value).toISOString() : String(value);
return value;
}

/** Unreachable for any rule that passed the schema: the default arm exists so the switch is total. */
function unhandledKind(): never {
throw new Error("no format handler for the rule's kind: the schema and the format op disagree");
}
50 changes: 50 additions & 0 deletions src/ops/format.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,53 @@ describe("format numberText", () => {
expect(() => formatEncode(rule, "")).toThrow(/decimal number/);
});
});

describe("format isoDate", () => {
const rule: FormatRule = { op: "format", from: "on", to: "onMs", kind: "isoDate" };

it("parses a calendar date to its UTC midnight and renders the date back", () => {
expect(formatEncode(rule, data.octoberDateOnly)).toBe(epochOf(data.octoberDateOnly));
expect(formatDecode(rule, epochOf(data.octoberDateOnly))).toBe(data.octoberDateOnly);
});

it.each(["2026-10-18T10:15:00Z", "2026-02-30", "18/10/2026", "2026-1-8"])("refuses %s on encode", (text) => {
expect(() => formatEncode(rule, text)).toThrow(Error);
});

it("refuses an instant that is not a UTC midnight on decode", () => {
expect(() => formatDecode(rule, epochOf(data.octoberInstant))).toThrow(/UTC midnight/);
});
});

describe("format booleanText", () => {
const rule: FormatRule = { op: "format", from: "flag", to: "isSet", kind: "booleanText" };

it("converts both spellings and refuses others", () => {
expect(formatEncode(rule, "true")).toBe(true);
expect(formatEncode(rule, "false")).toBe(false);
expect(formatDecode(rule, true)).toBe("true");
expect(formatDecode(rule, false)).toBe("false");
expect(() => formatEncode(rule, "True")).toThrow(Error);
expect(() => formatEncode(rule, "1")).toThrow(Error);
expect(() => formatDecode(rule, "true")).toThrow(Error);
});
});

describe("format jsonText", () => {
const rule: FormatRule = { op: "format", from: "raw", to: "value", kind: "jsonText" };

it("parses JSON text and renders it back canonically", () => {
const text = '{"a":[1,null,"x"],"b":{"c":true}}';
expect(formatEncode(rule, text)).toEqual({ a: [1, null, "x"], b: { c: true } });
expect(formatDecode(rule, formatEncode(rule, text))).toBe(text);
});

it("refuses text that is not JSON, and JSON holding a __proto__ key anywhere", () => {
expect(() => formatEncode(rule, "{a:1}")).toThrow(/not JSON text/);
expect(() => formatEncode(rule, '{"x":[{"__proto__":1}]}')).toThrow(/__proto__/);
});

it("refuses a decode value JSON cannot express", () => {
expect(() => formatDecode(rule, undefined)).toThrow(/JSON cannot express/);
});
});