diff --git a/package-lock.json b/package-lock.json index d37cfbbba..4bc15ba5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5553,8 +5553,8 @@ "version": "1.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-telemetry": "1.4.0", - "typebox": "1.3.27" + "@earendil-works/pi-schema": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0" }, "devDependencies": { "@types/ws": "8.18.2", diff --git a/packages/chord/CHANGELOG.md b/packages/chord/CHANGELOG.md index 326d4b8de..4f50cc098 100644 --- a/packages/chord/CHANGELOG.md +++ b/packages/chord/CHANGELOG.md @@ -4,6 +4,7 @@ ### Breaking Changes +- Schemas are JSON Schema checked by `@earendil-works/pi-schema` instead of TypeBox, which is no longer a dependency of Chord. Contracts, `self.state(...)` schemas and method signatures take pi-schema builders or any JSON Schema object (TypeBox-built schemas still validate, being JSON Schema, but type as `unknown`); handler and reference types come from pi-schema's `Static`. Validation, input defaults and the stripping of unknown fields work on any JSON Schema by its shape (before, defaults and stripping applied only to TypeBox-built schemas). `format` is an annotation, no longer checked: `str({ format: "email" })` accepts `"nope"` (TypeBox checked the formats it knew); check a format in the handler where it matters. An `invalid` error's message is now ` ` with pi-schema's paths and messages (a missing field at its own path, `missing`; `expected string, got number` instead of `must be string`), and its `details` are pi-schema's `ValidationError`s (`{ path, keyword, message }`) instead of TypeBox's errors. Link frames, descriptions, fingerprints and identities are unchanged for schemas with the same JSON. Migration, keeping a contract's fingerprint (it is computed from the schema's JSON): `Type.String/Number/Integer/Boolean/Null/Unknown/Array/Union/Optional(...)` become `str/num/int/bool/nul/unknown/arr/union/opt(...)` with the same options; `Type.Object(p)` becomes `obj(p, { open: true })` (pi-schema's `obj` is closed by default) and `Type.Object(p, { additionalProperties: false })` `obj(p)`; `Type.Enum(values)` becomes `oneOf(values)`; `Type.Literal(v)` becomes `{ type: typeof v, const: v }` (pi-schema's `lit(v)` has no `type`); `Type.Record(Type.String(), v)` becomes `{ type: "object", patternProperties: { "^.*$": v } }` (pi-schema's `map(v)` uses `additionalProperties`); `Type.Intersect(list)` becomes `{ allOf: list }`; `Type.Function(params, result)` and `Type.Void()` become `{ type: "function", parameters, returnType }` and `{ type: "void" }`; `Type.Unsafe(schema)` becomes `schema as Schema`; `Type.Immutable(schema)` becomes the schema (cast its `Schema` type); `opt(...)` marks only an `obj` property; `Static` and `TSchema` come from pi-schema (`Static`, `Schema`). pi-schema's own `lit`, `map` and closed `obj` give other JSON and so other fingerprints, which matters only while peers of both versions talk. - `traceIn(options, fn, context)` takes the context last (was `traceIn(context, options, fn)`), like the rest of the API. - Watch listeners take one change object before the context: `WatchHandle.start((change, context) => …)` with `change: WatchChange` = `{ value, ops, retired }`, and `NativeWatchHandle.start(({ value }, context) => …)` with `NativeWatchChange`. A change is plain data, those fields only; the frame's context is the second argument. `Watch.advance(change, context)` takes the same object, and a frame delivered alone, with nothing folded into it, reaches the listener as the object the source passed. Migration: `start(async (value, ops, ctx) => …)` becomes `start(async ({ value, ops }, ctx) => …)`, `start(async (value, ctx) => …)` on a native watch `start(async ({ value }, ctx) => …)`, and `advance(value, ops, ctx)` becomes `advance({ value, ops, retired: false }, ctx)`. - `SqliteExecutor.runCounted(sql, ...params)` runs one statement like `run` and resolves with the rows it inserted, updated or deleted, as SQLite's `changes()` counts them (index writes and rows changed by triggers do not count). The Node driver takes it from `node:sqlite`; the Durable Object driver reads `SELECT changes()` after the statement (the cursor's `rowsWritten` also counts index writes), so `run` stays as cheap as before. Migration: custom `SqliteDatabase` and `SqliteExecutor` implementations add `runCounted`. @@ -30,6 +31,7 @@ ### Changed +- Chord v2 (`@earendil-works/chord/next`) checks its schemas with `@earendil-works/pi-schema` instead of TypeBox: its helpers (`str`, `num`, `int`, `bool`, `nul`, `lit`, `oneOf`, `union`, `obj`, `arr`, `map`, `opt`, `bytes`, `unknown`) build on pi-schema's builders and keep the JSON contracts were fingerprinted with, so every fingerprint, normal form and stored record stays the same (a test records them and reads a store written before the switch). `Static` and `Schema` (was `TSchema`) come from pi-schema; `opt(...)` marks an object field or a table column and is no schema elsewhere (a top-level `opt` had no effect before); a schema with a non-enumerable key (a TypeBox schema's metadata) is refused at declaration; `map(bytes())` is accepted now (it was refused while `bytes()` carried a TypeBox refinement). `format` is an annotation, no longer checked (`{ type: "string", format: "email" }` accepts `"nope"`; TypeBox checked the formats it knew). A failed check's message comes from pi-schema (`args/title: expected string, got integer`), its `details.path` escapes `~` and `/` (JSON Pointer); reasons and codes are unchanged. Declaring and compiling are cheaper (a 20-method contract 1.07 ms to 0.86 ms, a validator's first use 172 µs to 48 µs), a check costs the same (0.25 µs), the message envelope check up to 100 ns more per message. - The delta tracker keeps its one open change in a plain field and needs a `WeakRef` only for a second change open at the same time (one per change before). A `WeakRef` target survives the scavenges of its job, so a run of changes in one job promoted every change's overlay to old space: 200,000 small changes in one job now promote 2 MB instead of 47.5 MB and take 3.1 µs each instead of 3.8 µs (3.0 instead of 3.5 µs when they span jobs). A draft dropped without `prepare()` or `abort()` now stays referenced by its tracker until the tracker's next `beginChange()` or `adopt()`. - `applyImmutable()`, `applyImmutableBatches()` and tracker revisions copy each container on a changed path with spread (or `Object.assign()` onto a null-prototype object) instead of one `Object.defineProperty()` per key, about 25 times faster per container ([#9265](https://github.com/earendil-works/pi/issues/9265)) diff --git a/packages/chord/README.md b/packages/chord/README.md index 9d9d95587..081e3fe21 100644 --- a/packages/chord/README.md +++ b/packages/chord/README.md @@ -8,7 +8,7 @@ Chord builds applications out of **objects** that live in several processes (a s - **State** is observable JSON. Remote viewers get a snapshot followed by deltas. - **Hot reload** swaps code while it runs. Work in flight finishes on the code it started with, state is kept, and incompatible changes migrate at a safe point. -Contracts are runtime values described with [TypeBox](https://github.com/sinclairzx81/typebox) schemas. Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket. +Contracts are runtime values described with JSON Schema, written with the builders of `@earendil-works/pi-schema` (any JSON Schema object works; `format` is an annotation, never checked). Hosts exchange JSON frames over links: in memory, a Node IPC channel, or a WebSocket. ## Entry points @@ -27,18 +27,18 @@ Contracts are runtime values described with [TypeBox](https://github.com/sinclai ## Quick start ```ts -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { createHost, hostNamed, method, remote } from "@earendil-works/chord"; // contract.ts: shared by provider and consumers export const Counter = remote("demo.counter", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }), host: hostNamed("server"), // where its objects live; callers never name a host variants: { default: { - state: { count: Type.Number() }, - methods: { increment: method(Type.Object({ by: Type.Number() })) }, + state: { count: num() }, + methods: { increment: method(obj({ by: num() })) }, }, }, }); @@ -46,7 +46,7 @@ export const Counter = remote("demo.counter", { const host = createHost({ name: "server", self: { kind: "server" } }); host.serve(Counter, ({ name }, _principal, self) => { - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); return { variant: "default", count, diff --git a/packages/chord/docs/guide.md b/packages/chord/docs/guide.md index 59c1414b7..c3b7b3211 100644 --- a/packages/chord/docs/guide.md +++ b/packages/chord/docs/guide.md @@ -36,17 +36,17 @@ Chord builds applications out of **objects** that live in several processes (a s ## Quick start ```ts -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { createHost, method, remote } from "@earendil-works/chord"; // contract.ts: shared by provider and consumers export const Counter = remote("demo.counter", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }), variants: { default: { - state: { count: Type.Number() }, - methods: { increment: method(Type.Object({ by: Type.Number() })) }, + state: { count: num() }, + methods: { increment: method(obj({ by: num() })) }, }, }, }); @@ -54,7 +54,7 @@ export const Counter = remote("demo.counter", { const host = createHost({ name: "server", self: { kind: "server" } }); host.serve(Counter, ({ name }, _principal, self) => { - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); return { variant: "default", count, @@ -82,7 +82,7 @@ Remote types can be reached from other hosts. Everything that crosses the wire h ```ts export const Chat = remote("pi.chat", { version: 1, - input: Type.Object({ session: Type.String(), conversation: Type.Number() }), + input: obj({ session: str(), conversation: num() }), host: hostFamily("session:", "session"), // lives on `session:S1` for `{ session: "S1", … }` variants: { viewer: { @@ -91,7 +91,7 @@ export const Chat = remote("pi.chat", { participant: { state: { view: ConversationViewSchema }, methods: { - send: method(SendInputSchema, Type.Object({ submissionId: Type.Number() })), + send: method(SendInputSchema, obj({ submissionId: num() })), stop: method(), // no arguments, no result: `chat.stop(ctx)` }, }, @@ -119,10 +119,10 @@ Local types are reachable only inside their host. Their members may be anything: ```ts export const Index = local("pi.index", { version: 1, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }), members: { - search: method(Type.Object({ query: Type.String() }), Type.Array(HitSchema)), - onChange: method(Type.Function([ChangeSchema], Type.Void()), UnsubscribeSchema), + search: method(obj({ query: str() }), arr(HitSchema)), + onChange: method({ type: "function", parameters: [ChangeSchema], returnType: { type: "void" } }, UnsubscribeSchema), harness: opaque("pi.durable-harness@1"), }, }); @@ -181,7 +181,7 @@ A reload runs the factory again over the same object. Declarations that keep som ```ts host.serve(StatusStore, ({ session }, _host, self) => { const status = self.state("status", StatusSchema, { running: false, tasks: 0 }); - const errors = self.state("errors", Type.Array(Type.String()), []); + const errors = self.state("errors", arr(str()), []); const catalog = self.get(Catalog, {}); const db = self.resource("db", { version: 1, @@ -230,7 +230,7 @@ host.serve(Visits, (_, _p, self) => { // right host.serve(Visits, (_, _p, self) => { - const visits = self.state("visits", Type.Number(), 0); + const visits = self.state("visits", num(), 0); return { hit: async (ctx) => { visits.change(ctx, (n) => n + 1); @@ -240,7 +240,7 @@ host.serve(Visits, (_, _p, self) => { }); ``` -**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, Type.Number())` takes no arguments and returns a number. +**Methods** are declared with `method(args?, result?)` in the contract. Without `args` a method takes none: callers write `ref.hit(ctx)` (or `ref.hit()`), and its implementation is `(ctx) => …`. Without `result` it returns nothing: the implementation returns nothing, and callers get `undefined`. `method(undefined, num())` takes no arguments and returns a number. ## Activation @@ -428,7 +428,7 @@ chat.release(); ## State ```ts -const todos = self.state("todos", Type.Array(TodoSchema), []); +const todos = self.state("todos", arr(TodoSchema), []); todos.change(ctx, (draft) => { draft.push({ id: "t1", text: "Write docs", done: false }); @@ -457,7 +457,7 @@ view.replace(ctx, rebuilt, { diff: true }); // always diff ```ts const db = self.resource("db", { version: 1, open: () => openSqlite(path), close: (db) => db.close() }); -const notes = self.state("notes", Type.Array(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") }); +const notes = self.state("notes", arr(NoteSchema), { init: () => db.value.all("SELECT * FROM notes") }); ``` `init(ctx)` runs once the resources are open and the references live, before the object goes live, so no method, diff --git a/packages/chord/docs/spec.md b/packages/chord/docs/spec.md index ca9c93b38..ca910d758 100644 --- a/packages/chord/docs/spec.md +++ b/packages/chord/docs/spec.md @@ -3,7 +3,7 @@ This document specifies the runtime in `src`. Where it and the code disagree, the disagreement is a bug. [Known limitations and decisions](#12-known-limitations-and-decisions) lists what the runtime does not do yet and the choices the sections below leave open. Hard constraints: -- Chord's runtime depends only on its own `delta` module (tracker, applyImmutable, encoder/decoder, diff) and Node/browser built-ins plus `typebox` for schemas. No other Pi packages. +- Chord's runtime depends only on its own `delta` module (tracker, applyImmutable, encoder/decoder, diff) and Node/browser built-ins, plus `@earendil-works/pi-telemetry` for telemetry and `@earendil-works/pi-schema` for schemas. No other Pi packages. - Built-in plugins are trusted, loaded from source in-process. The plugin loader uses dynamic `import()` with a cache-busting query (owner-approved exception, `TODO(loader)`: replace with an isolating, evicting loader; old module copies leak; TUI plugins need isolation). - Hot reload is required. Host restart only for Chord/runtime code changes or host-level crash recovery. @@ -29,23 +29,23 @@ Contracts are runtime values for every type (shipped bundles have no TypeScript; ```ts export const Chat = remote("pi.chat", { version: 1, // contract version: a plain number, bumped only on breaking changes - input: Type.Object({ session: Type.String(), conversation: Type.Number() }), + input: obj({ session: str(), conversation: num() }), host: hostFamily("session:", "session"), // where its objects live: `session:` + input.session variants: { viewer: { state: { view: ViewSchema } }, participant: { state: { view: ViewSchema }, - methods: { send: method(SendSchema, Type.Object({ submissionId: Type.Number() })) }, + methods: { send: method(SendSchema, obj({ submissionId: num() })) }, }, }, }); export const Index = local("pi.index", { version: 1, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }), members: { - search: method(Type.Object({ query: Type.String() }), Type.Array(Hit)), - onChange: method(Type.Function([Change], Type.Void()), Unsubscribe), // non-JSON allowed for locals + search: method(obj({ query: str() }), arr(Hit)), + onChange: method({ type: "function", parameters: [Change], returnType: { type: "void" } }, Unsubscribe), // non-JSON allowed for locals harness: opaque("pi.durable-harness@1"), // native object, by name+version }, }); @@ -81,7 +81,7 @@ Factories are synchronous; returning a promise throws. A declaration has a name host.serve(StatusStore, ({ session }, _host, self) => { const status = self.state("status", StatusSchema, { running: false, tasks: 0 }, // schema required, initial value { version: 2, migrate: (old, from) => upgrade(old, from) }); // per cell; default version 1 - const errors = self.state("errors", Type.Array(Type.String()), []); + const errors = self.state("errors", arr(str()), []); const catalog = self.get(Catalog, {}); // reference; identity = canonical address (+ at) const watch = self.resource("watch", { version: 1, deps: [catalog], exclusive: false, @@ -278,7 +278,7 @@ The tests cover at least: lifetime/references/linger/forced disposal/gone; activ - **Joining during a reload.** Participants are the child hosts in step when a reload starts. A child whose hello arrives while it runs is decided when it ends: in step, or reconciled to the committed code version; a child already behind is fenced at once, so the calls its roots make meanwhile are admitted in the parent's code version. A reconciliation that is running when a reload starts syncs its child only after that reload ended, against the code version it committed. A reconciliation announces the parent's retired code versions before its `prepare`. - **Two-version rule.** An object of a changed type created after a commit has only new code; a previous-code-version root calling it gets `superseded`. - **Validation.** Remote calls are validated at reference dispatch, in-host too: a direct binding against the provider's schema, a binding through an adapter against the consumer's. A provider validates each snapshot and reset of a remote state member once per (re)subscription, against the consumer's contract through an `accepts` adapter and the provider's otherwise; ops are validated only in dev mode. At reload, the new provider's description must cover the old provider's when the version is unchanged, in addition to the check against live consumers and facets' `uses`. -- **Contracts.** Local contracts list their members, so a loading reference knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; with TypeBox, use `Type.Optional(Type.X({ default }))` to let callers omit them. Provider variants must cover consumer variants; members the consumer's contract does not list are hidden. Schema change detection compares canonical JSON, so reordering `anyOf` branches counts as a change. Fingerprints use a non-cryptographic 64-bit hash: synchronous and browser-safe; contracts are trusted. +- **Contracts.** Local contracts list their members, so a loading reference knows whether a member is a method, state or opaque. Canonical identity omits input fields equal to their default; use `opt(str({ default }))` (pi-schema) to let callers omit them. Provider variants must cover consumer variants; members the consumer's contract does not list are hidden. Schema change detection compares canonical JSON, so reordering `anyOf` branches counts as a change. Fingerprints use a non-cryptographic 64-bit hash: synchronous and browser-safe; contracts are trusted. - **Principals.** A root ctx at ingress acts as its principal (`host.root(fn, { principal })`); contexts inside objects act as `host`. The acceptor decides a link's kind and name (`accept(link, { principal, kind, name })`); only configured host links are addressable, carry roots and may vote, and control frames are accepted only from the parent. Clients never carry roots or code versions. Relaying needs a matching `route`; `authorize(principal, hostName)` runs before `resolve` and again after, and revoking or re-stamping a principal closes its relays at once. `invalidatePrincipal(match)` re-stamps or closes matching links and propagates pattern matches to child hosts. - **Cells and readables.** A value returned from `change` replaces the value and is always validated; `replace` transfers ownership without copying; by default (`{ diff }` overrides) it publishes a diff when the new value reuses a container of the old one at the same key, else `r`. `subscribe(fn)` delivers values, starting with the current one; `events(fn)` delivers `snap`, then `ops`, or `reset`. Reference state members are stable readables that throw `not_ready` while loading and `not_in_variant` outside the variant, and reset when their source moves; a listener added before they are readable gets its snapshot then. Remote state members stay hydrated while the reference is held. - **References and calls.** A reference's identity is its address; a second `self.get` of the same address in one run returns the same reference. A reference call without ctx, or with a task ctx, starts a fresh root in the current code version; a ctx whose call or root already settled is refused (`aborted/expired`), one from another host `invalid/foreign_ctx`. Calling a reference inside a factory throws `not_ready`. diff --git a/packages/chord/docs/tutorial.md b/packages/chord/docs/tutorial.md index fe16dce4c..65a6334ed 100644 --- a/packages/chord/docs/tutorial.md +++ b/packages/chord/docs/tutorial.md @@ -45,11 +45,12 @@ shuts down its objects and connections. Before callers can use an object, they need to agree on its name and members. That agreement is a **contract**: a runtime value that also gives TypeScript its -types. A **schema** describes a value's shape. We use TypeBox's `Type` to write -schemas. `method(args, result)` declares a method by its argument and result -schemas: `method()` takes no arguments and returns nothing, and -`method(undefined, Type.String())`, used below, takes no arguments and returns a -string. +types. A **schema** describes a value's shape: plain JSON Schema, which we write +with the builders of `@earendil-works/pi-schema` (`obj`, `str`, `num`, ...; +`{ open: true }` lets an object carry fields it does not name). +`method(args, result)` declares a method by its argument and result schemas: +`method()` takes no arguments and returns nothing, and +`method(undefined, str())`, used below, takes no arguments and returns a string. Start with a label for a named list. `local` means only code inside this host can reach it. The input `{ list: "family" }` identifies which list we want; @@ -59,8 +60,8 @@ it is not an argument to `label`. ```ts const TodoLabel = local("tutorial.todos", { version: 1, - input: Type.Object({ list: Type.String() }), - members: { label: method(undefined, Type.String()) }, + input: obj({ list: str() }, { open: true }), + members: { label: method(undefined, str()) }, }); ``` @@ -116,17 +117,17 @@ name, because it holds nothing a reload must keep. ```ts -const Todo = Type.Object({ text: Type.String(), done: Type.Boolean() }); -const Rows = Type.Array(Todo); +const Todo = obj({ text: str(), done: bool() }, { open: true }); +const Rows = arr(Todo); const TodoStore = local("tutorial.todos", { version: 1, - input: Type.Object({ list: Type.String() }), + input: obj({ list: str() }, { open: true }), members: { - label: method(undefined, Type.String()), + label: method(undefined, str()), rows: Rows, - openCount: Type.Number(), - add: method(Type.Object({ text: Type.String() })), - finish: method(Type.Object({ index: Type.Integer() })), + openCount: num(), + add: method(obj({ text: str() }, { open: true })), + finish: method(obj({ index: int() }, { open: true })), reset: method(Rows), }, }); @@ -230,13 +231,13 @@ to a declared resource, without reading the resource during the factory. ```ts const Database = local("tutorial.database", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { db: opaque<{ saved: string[] }>("tutorial.database@1") }, }); const SavedTodos = local("tutorial.saved-todos", { version: 1, - input: Type.Object({ list: Type.String() }), - members: { add: method(Type.Object({ text: Type.String() })) }, + input: obj({ list: str() }, { open: true }), + members: { add: method(obj({ text: str() }, { open: true })) }, }); const host = createHost({ self: "server" }); let opens = 0; @@ -338,13 +339,13 @@ puts every `Todos` object on the host named `server`. Section 6 uses that. ```ts const Todos = remote("tutorial.todo-view", { version: 1, - input: Type.Object({ list: Type.String() }), + input: obj({ list: str() }, { open: true }), host: hostNamed("server"), variants: { viewer: { state: { rows: Rows } }, editor: { state: { rows: Rows }, - methods: { add: method(Type.Object({ text: Type.String() })) }, + methods: { add: method(obj({ text: str() }, { open: true })) }, }, }, }); @@ -499,8 +500,8 @@ test; they are not stored application state. ```ts const Activity = local("tutorial.activity", { version: 1, - input: Type.Object({}), - members: { seen: Type.Number() }, + input: obj({}, { open: true }), + members: { seen: num() }, }); const host = createHost({ self: "server" }); host.serve(TodoStore, todoFactory); @@ -509,7 +510,7 @@ const seeded = Promise.withResolvers(); const stopped = Promise.withResolvers(); host.serve(Activity, (_input, _principal, self) => { const todos = self.get(TodoStore, { list: "family" }); - const seen = self.state("seen", Type.Number(), 0); + const seen = self.state("seen", num(), 0); self.on("rows", todos.rows, (rows, ctx) => { seen.replace(ctx, rows?.length ?? 0); if (rows?.length === 1) observed.resolve(); @@ -570,7 +571,7 @@ default lifetime, `"referenced"`, are still created only when referenced. ```ts const Actions = defineRegistry<(ctx: Ctx) => Promise>("tutorial.actions"); -const Menu = local("tutorial.menu", { version: 1, input: Type.Object({}) }); +const Menu = local("tutorial.menu", { version: 1, input: obj({}, { open: true }) }); let click: (() => Promise) | undefined; const storeFacet: Facet = { name: "todo-store", @@ -745,7 +746,7 @@ const { label: _label, ...members } = TodoStore.def.members; const RenamedStore = local("tutorial.todos", { version: 2, input: TodoStore.def.input, - members: { ...members, heading: method(undefined, Type.String()) }, + members: { ...members, heading: method(undefined, str()) }, }); await host.reload([ { @@ -890,7 +891,7 @@ tutorial's source-matching check, not by the application. ```ts import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { type Static, Type } from "typebox"; +import { arr, bool, int, num, obj, type Static, str } from "@earendil-works/pi-schema"; import { it, onTestFinished } from "vitest"; import { type Ctx, diff --git a/packages/chord/package.json b/packages/chord/package.json index 908005452..edced8cd5 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -90,8 +90,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-telemetry": "1.4.0", - "typebox": "1.3.27" + "@earendil-works/pi-schema": "1.4.0", + "@earendil-works/pi-telemetry": "1.4.0" }, "peerDependencies": { "esbuild": "0.28.2", diff --git a/packages/chord/src/cell.ts b/packages/chord/src/cell.ts index 65d391128..d520258e3 100644 --- a/packages/chord/src/cell.ts +++ b/packages/chord/src/cell.ts @@ -1,4 +1,4 @@ -import type { TSchema } from "typebox"; +import type { Schema } from "@earendil-works/pi-schema"; import { type Draft, diffRevisions, type JsonValue, type Op, type Tracker, track } from "./delta/index.ts"; import { ChordError } from "./errors.ts"; import { enqueue, holdPublications } from "./publish.ts"; @@ -87,7 +87,7 @@ export abstract class Source implements Readable { readonly seq = ++sequence; readonly dependents = new Set>(); /** Schemas of remote state members currently exposing this source (checked per write in dev mode). */ - schemas: readonly TSchema[] = []; + schemas: readonly Schema[] = []; protected readonly options: SourceOptions; protected readonly fanout: Fanout; @@ -114,7 +114,7 @@ export abstract class Source implements Readable { for (const schema of this.schemas) assertValid("state", `state ${this.key}`, schema, value); } - protected ownSchemas(): readonly TSchema[] { + protected ownSchemas(): readonly Schema[] { return []; } } @@ -149,7 +149,7 @@ function unbox(ops: readonly Op[], value: unknown): Op[] { /** A state cell holding any JSON value, validated against its schema at creation and (dev mode) on writes. */ export class Cell extends Source { /** Replaced at a reload commit that changes the schema (state-version bump). */ - schema: TSchema; + schema: Schema; #tracker: Tracker>; readonly #guard: WriteGuard; /** False until `initialize` for a cell declared with `init`: reads and writes throw `not_ready`. */ @@ -161,7 +161,7 @@ export class Cell extends Source { */ constructor( key: string, - schema: TSchema, + schema: Schema, initial: { readonly value: T } | undefined, guard: WriteGuard, options: SourceOptions, @@ -182,7 +182,7 @@ export class Cell extends Source { this.#initialized = true; } - protected override ownSchemas(): readonly TSchema[] { + protected override ownSchemas(): readonly Schema[] { return [this.schema]; } @@ -238,7 +238,7 @@ export class Cell extends Source { * Installs a migrated value and its schema (reload commit). The caller validated the value and the derivations * over it against every schema that will apply, so nothing is checked here. Subscribers receive a reset. */ - reset(value: T, schema: TSchema): void { + reset(value: T, schema: Schema): void { this.schema = schema; this.#publish(this.#tracker.prepareReplace({ v: value }), "reset", false); } diff --git a/packages/chord/src/contract.ts b/packages/chord/src/contract.ts index c2ebd6b96..1a6d64031 100644 --- a/packages/chord/src/contract.ts +++ b/packages/chord/src/contract.ts @@ -1,15 +1,14 @@ -import { type Static, type TSchema, Type } from "typebox"; -import { Value } from "typebox/value"; +import { int, lit, map, obj, opt, type Schema, type Static, str, union } from "@earendil-works/pi-schema"; import { ChordError, invalid } from "./errors.ts"; -import { canonicalJson, fits, hash64 } from "./schema.ts"; +import { canonicalJson, fits, hash64, isValid } from "./schema.ts"; import type { Ctx, Exposed, Readable, RefBase, StateSource } from "./types.ts"; const MEMBER = Symbol("chord.member"); /** A method's arguments and result; `undefined`: it takes no arguments, or returns nothing (void). */ export interface MethodSpec< - A extends TSchema | undefined = TSchema | undefined, - R extends TSchema | undefined = TSchema | undefined, + A extends Schema | undefined = Schema | undefined, + R extends Schema | undefined = Schema | undefined, > { readonly [MEMBER]: "method"; readonly args: A; @@ -29,9 +28,9 @@ export interface OpaqueSpec { * for a method without arguments that returns something. */ export function method(): MethodSpec; -export function method(args: A): MethodSpec; -export function method(args: A, result: R): MethodSpec; -export function method(args?: TSchema, result?: TSchema): MethodSpec { +export function method(args: A): MethodSpec; +export function method(args: A, result: R): MethodSpec; +export function method(args?: Schema, result?: Schema): MethodSpec { return { [MEMBER]: "method", args, result }; } @@ -42,7 +41,7 @@ export const opaque = (id: string): OpaqueSpec => { }; export interface VariantDef { - readonly state?: Readonly>; + readonly state?: Readonly>; readonly methods?: Readonly>; } @@ -102,63 +101,59 @@ export const placementText = (placement: Placement): string => export interface RemoteDef { readonly version: number; - readonly input: TSchema; + readonly input: Schema; /** Where its objects live: `hostNamed(…)` or `hostFamily(…)`. Part of the contract (and its fingerprint). */ readonly host: Placement; readonly variants: Readonly>; } /** A local member: a method, an opaque native object, or a state member described by its schema. */ -export type LocalMember = MethodSpec | OpaqueSpec | TSchema; +export type LocalMember = MethodSpec | OpaqueSpec | Schema; export interface LocalDef { readonly version: number; - readonly input: TSchema; + readonly input: Schema; readonly members?: Readonly>; } // A description travels as a reference's contract token (wire.ts), so it is declared once, as the schema that validates it. -// `TSchema` is an empty interface: any object is one, which is all a peer's schema is checked for here. -const SchemaObject = Type.Unsafe(Type.Object({})); +// Any object is a schema, which is all a peer's schema is checked for here. +const SchemaObject = obj({}, { open: true }) as Schema; -const MemberDescriptionSchema = Type.Union([ +const MemberDescriptionSchema = union([ // Absent `args`: the method takes none. Absent `result`: it returns nothing. - Type.Object({ - kind: Type.Literal("method"), - args: Type.Optional(SchemaObject), - result: Type.Optional(SchemaObject), - }), - Type.Object({ kind: Type.Literal("state"), schema: SchemaObject }), - Type.Object({ kind: Type.Literal("opaque"), id: Type.String() }), + obj({ kind: lit("method"), args: opt(SchemaObject), result: opt(SchemaObject) }, { open: true }), + obj({ kind: lit("state"), schema: SchemaObject }, { open: true }), + obj({ kind: lit("opaque"), id: str() }, { open: true }), ]); export type MemberDescription = Static; /** Local contracts have one unnamed variant. */ export const LOCAL_VARIANT = ""; -const PlacementSchema = Type.Union([ - Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }), - Type.Object( - { prefix: Type.String({ minLength: 1 }), key: Type.String({ minLength: 1 }) }, - { additionalProperties: false }, - ), +const PlacementSchema = union([ + obj({ name: str({ minLength: 1 }) }), + obj({ prefix: str({ minLength: 1 }), key: str({ minLength: 1 }) }), ]); -export const DescriptionSchema = Type.Object({ - name: Type.String({ minLength: 1 }), - scope: Type.Union([Type.Literal("remote"), Type.Literal("local")]), - version: Type.Integer(), - input: SchemaObject, - /** Remote types only (required there): where their objects live. */ - host: Type.Optional(PlacementSchema), - variants: Type.Record(Type.String(), Type.Record(Type.String(), MemberDescriptionSchema)), -}); +export const DescriptionSchema = obj( + { + name: str({ minLength: 1 }), + scope: union([lit("remote"), lit("local")]), + version: int(), + input: SchemaObject, + /** Remote types only (required there): where their objects live. */ + host: opt(PlacementSchema), + variants: map(map(MemberDescriptionSchema)), + }, + { open: true }, +); export type Description = Static; interface ContractBase { readonly name: string; readonly version: number; - readonly input: TSchema; + readonly input: Schema; readonly description: Description; readonly fingerprint: string; } @@ -204,7 +199,7 @@ function describeMember(name: string, member: unknown, scope: "remote" | "local" if (scope === "remote") throw new TypeError(`remote contracts cannot expose opaque members: ${name}`); return { kind: "opaque", id: (member as OpaqueSpec).id }; } - return { kind: "state", schema: member as TSchema }; + return { kind: "state", schema: member as Schema }; } function finish(contract: Omit): C { @@ -222,7 +217,7 @@ export function remote( ): RemoteContract { checkHeader(name, def.version); const placement = def.host as Placement | undefined; - if (placement === undefined || !Value.Check(PlacementSchema, placement)) + if (placement === undefined || !isValid(PlacementSchema, placement)) throw new TypeError(`remote contract ${name} needs \`host\`: hostNamed(…) or hostFamily(…)`); // A JSON copy: the description travels, and a placement object may carry nothing else. const host: Placement = @@ -353,23 +348,23 @@ export function bind(provider: AnyContract, accepts: Accepts | undefined, consum // ─── Static types ──────────────────────────────────────────────────────────── type Empty = Record; -type StateOf = V extends { readonly state: infer S extends Readonly> } ? S : Empty; -type StaticOf = S extends TSchema ? Static : never; +type StateOf = V extends { readonly state: infer S extends Readonly> } ? S : Empty; +type StaticOf = S extends Schema ? Static : never; type MethodsOf = V extends { readonly methods: infer M extends Readonly> } ? M : Empty; /** What a handler returns: the result's value, or nothing for a void method. */ -type Returned = [R] extends [TSchema] ? Static | Promise> : void | Promise; +type Returned = [R] extends [Schema] ? Static | Promise> : void | Promise; /** What a call resolves to. */ -type Resolved = [R] extends [TSchema] ? Static : undefined; +type Resolved = [R] extends [Schema] ? Static : undefined; /** A method's handler; `P` is the app's principal type (`ctx.principal`). */ export type MethodImplementation = M extends MethodSpec - ? [A] extends [TSchema] + ? [A] extends [Schema] ? (args: Static, ctx: Ctx

) => Returned : (ctx: Ctx

) => Returned : never; export type Call = M extends MethodSpec - ? [A] extends [TSchema] + ? [A] extends [Schema] ? (args: Static, ctx?: Ctx) => Promise> : (ctx?: Ctx) => Promise> : never; @@ -402,21 +397,21 @@ type LocalImplementationMember = M extends MethodSpec ? MethodImplementation : M extends OpaqueSpec ? T | Exposed - : M extends TSchema + : M extends Schema ? StateSource> : never; type LocalRefMember = M extends MethodSpec ? Call : M extends OpaqueSpec ? T | undefined - : M extends TSchema + : M extends Schema ? Readable> : never; type LocalMembers = D extends { readonly members: infer M extends Readonly> } ? M : Empty; -export type InputOf = C extends { readonly def: { readonly input: infer I extends TSchema } } ? Static : never; +export type InputOf = C extends { readonly def: { readonly input: infer I extends Schema } } ? Static : never; /** What a factory returns for contract C; its methods' `ctx.principal` is `P`. */ export type ImplementationOf = C extends RemoteContract diff --git a/packages/chord/src/next/contracts/README.md b/packages/chord/src/next/contracts/README.md index bd79e9bc0..7cfd5fd9d 100644 --- a/packages/chord/src/next/contracts/README.md +++ b/packages/chord/src/next/contracts/README.md @@ -34,7 +34,12 @@ type NotesProvider = Implementation; // { variant: "editor"; membe key built like an identity key (`session|S1`); two addresses are equal when their keys are. The params are checked where the address is routed or served (`invalid/host_address`), not when it is made. No host-name strings in app code. - Schema helpers: `str`, `num`, `int`, `bool`, `nul`, `lit`, `oneOf`, `union`, `obj`, `arr`, `map`, `opt`, `bytes`, - `unknown`, and `Static`. They return ordinary TypeBox schemas. + `unknown`, `Schema` and `Static`, over `@earendil-works/pi-schema`'s builders: plain JSON Schema typed with the values + it accepts. They keep the shapes contracts were fingerprinted with: `lit` carries its JSON `type`, `oneOf(...)` is a + union of `lit`s, `map` one pattern property (`^.*$`), `obj` is closed. `opt(...)` marks an object field (or a table + column) optional and is no schema elsewhere. Any other JSON Schema object of the vocabulary below works too; a + non-enumerable key (a TypeBox schema's metadata) is refused. `format` is an annotation: it is part of the normal form + and the fingerprint, but no format is checked. - `evolve(previous, (e) => e...)`: the next version from structured edits (`state`, `addState`, `removeState`, `args`, `result`, `method`, `addMethod`, `removeMethod`); it must return the editor it was given. - Types: `Contract`, `Implementation` (what a factory returns: for `local` a `Watchable` per state member and a diff --git a/packages/chord/src/next/contracts/contract.ts b/packages/chord/src/next/contracts/contract.ts index 123757d70..696696b19 100644 --- a/packages/chord/src/next/contracts/contract.ts +++ b/packages/chord/src/next/contracts/contract.ts @@ -1,13 +1,12 @@ // Contract handles: the branded, immutable value an app declares, its type-level shape, and the Chord-owned data // behind it. -import type { TNull } from "typebox"; import type { Context } from "../../context/index.ts"; import type { Watchable, WatchHandle } from "../../watch.ts"; import type { Change, CurrentStep, History, VariantShape } from "./history.ts"; import type { Address } from "./host-kind.ts"; import type { Params, ParamsData } from "./params.ts"; -import type { Method, Static } from "./schema.ts"; +import type { Method, NullSchema, Static } from "./schema.ts"; import type { Snapshot } from "./snapshot.ts"; export type Kind = "remote" | "local"; @@ -88,7 +87,7 @@ export interface KeyedWatchable { } /** A method as a function: without the argument when its argument schema is `null` (`null` stays the wire form). */ -type MethodFunction = M["args"] extends TNull +type MethodFunction = M["args"] extends NullSchema ? (context: X) => Promise> : (args: Static, context: X) => Promise>; diff --git a/packages/chord/src/next/contracts/declare.ts b/packages/chord/src/next/contracts/declare.ts index 8bd3f5533..1ef0b0ee8 100644 --- a/packages/chord/src/next/contracts/declare.ts +++ b/packages/chord/src/next/contracts/declare.ts @@ -16,11 +16,11 @@ import type { MemberNames, MethodAt, VariantShape } from "./history.ts"; import type { Address } from "./host-kind.ts"; import { checkName } from "./names.ts"; import { type Params, type RequiredFields, readParams } from "./params.ts"; -import { KeyedMember, Method, type Static, type TSchema } from "./schema.ts"; +import { KeyedMember, Method, type Schema, type Static } from "./schema.ts"; import { type Snapshot, snapshotArguments, snapshotSchema } from "./snapshot.ts"; type VariantDeclaration = { - readonly state?: { readonly [member: string]: TSchema }; + readonly state?: { readonly [member: string]: Schema }; readonly methods?: { readonly [member: string]: Method }; }; @@ -42,7 +42,7 @@ type LocalVariants = { }; /** A local contract's member: a state schema, `method(...)` or `keyed(schema)`. */ -type LocalMember = TSchema | Method | KeyedMember; +type LocalMember = Schema | Method | KeyedMember; type SchemaOf = M extends KeyedMember ? S : never; diff --git a/packages/chord/src/next/contracts/evolve.ts b/packages/chord/src/next/contracts/evolve.ts index 04462793b..d3cb975f4 100644 --- a/packages/chord/src/next/contracts/evolve.ts +++ b/packages/chord/src/next/contracts/evolve.ts @@ -16,7 +16,7 @@ import { import { checkMembers, memberPath, readMethod } from "./declare.ts"; import { materialize } from "./fingerprint.ts"; import type { Change, CurrentStep, Increment, MemberNames, MethodAt, Variants, VariantsWith } from "./history.ts"; -import { Method, type TSchema } from "./schema.ts"; +import { Method, type Schema } from "./schema.ts"; import { type Snapshot, snapshotArguments, snapshotSchema } from "./snapshot.ts"; /** Method edits, the same for both kinds: each changes the method in every variant that has it. */ @@ -25,7 +25,7 @@ interface MethodEditor { readonly "~variants"?: V; /** Type only: the changes made by the edits so far. */ readonly "~changes"?: C; - args, A extends TSchema>( + args, A extends Schema>( member: M, args: A, ): Editor< @@ -33,7 +33,7 @@ interface MethodEditor { Put, "methods", M, Method["result"]>>, C | [VariantsWith, M, "args"] >; - result, R extends TSchema>( + result, R extends Schema>( member: M, result: R, ): Editor< @@ -41,7 +41,7 @@ interface MethodEditor { Put, "methods", M, Method["args"], R>>, C | [VariantsWith, M, "result"] >; - method, A extends TSchema, R extends TSchema>( + method, A extends Schema, R extends Schema>( member: M, args: A, result: R, @@ -51,12 +51,12 @@ interface MethodEditor { /** Edits of a remote contract. */ interface RemoteEditor extends MethodEditor<"remote", V, C> { - state( + state( variant: X, member: M, schema: S, ): RemoteEditor, C | [X, M, "state"]>; - addState( + addState( variant: X, member: M, schema: S, @@ -65,7 +65,7 @@ interface RemoteEditor extends MethodEdito variant: X, member: M, ): RemoteEditor, C>; - addMethod( + addMethod( variant: X, member: M, args: A, @@ -75,26 +75,26 @@ interface RemoteEditor extends MethodEdito /** Edits of a local contract: like remote ones, without a variant. */ interface LocalEditor extends MethodEditor<"local", V, C> { - state( + state( member: M, schema: S, ): LocalEditor, C | [Local, M, "state"]>; - addState( + addState( member: M, schema: S, ): LocalEditor, C | [Local, M, "state"]>; removeState(member: M): LocalEditor, C>; - addMethod( + addMethod( member: M, args: A, result: R, ): LocalEditor>, C | [Local, M, "args" | "result"]>; /** Changes keyed member `member`'s schema (slice 7b: an older consumer reads each key mapped, or is refused). */ - keyed & string, S extends TSchema>( + keyed & string, S extends Schema>( member: M, schema: S, ): LocalEditor, C | [Local, M, "keyed"]>; - addKeyed( + addKeyed( member: M, schema: S, ): LocalEditor, C | [Local, M, "keyed"]>; @@ -220,14 +220,14 @@ class Draft { this.#kinds = new Map(data.kinds); } - state(variant: string, member: string, schema: TSchema): object { + state(variant: string, member: string, schema: Schema): object { if (!this.#variant(variant).state.has(member)) { throw new Error(`${this.#path(variant, member, "state")}: no such state member`); } return this.#putState(variant, member, schema); } - addState(variant: string, member: string, schema: TSchema): object { + addState(variant: string, member: string, schema: Schema): object { this.#refuseDeclared(variant, member, "state"); return this.#putState(variant, member, schema); } @@ -239,19 +239,19 @@ class Draft { return this.editor; } - args(member: string, args: TSchema): object { + args(member: string, args: Schema): object { return this.#changeMethod(member, args, undefined); } - result(member: string, result: TSchema): object { + result(member: string, result: Schema): object { return this.#changeMethod(member, undefined, result); } - method(member: string, args: TSchema, result: TSchema): object { + method(member: string, args: Schema, result: Schema): object { return this.#changeMethod(member, args, result); } - addMethod(variant: string, member: string, args: TSchema, result: TSchema): object { + addMethod(variant: string, member: string, args: Schema, result: Schema): object { this.#refuseDeclared(variant, member, "method"); this.#variant(variant).methods.set( member, @@ -261,14 +261,14 @@ class Draft { return this.#checked(); } - keyed(variant: string, member: string, schema: TSchema): object { + keyed(variant: string, member: string, schema: Schema): object { if (!this.#variant(variant).keyed.has(member)) { throw new Error(`${this.#path(variant, member, "keyed")}: no such keyed member`); } return this.#putKeyed(variant, member, schema); } - addKeyed(variant: string, member: string, schema: TSchema): object { + addKeyed(variant: string, member: string, schema: Schema): object { this.#refuseDeclared(variant, member, "keyed"); return this.#putKeyed(variant, member, schema); } @@ -292,20 +292,20 @@ class Draft { return { ...this.#data, versions: [...versions, version], kinds: this.#kinds }; } - #putState(variant: string, member: string, schema: TSchema): object { + #putState(variant: string, member: string, schema: Schema): object { this.#variant(variant).state.set(member, snapshotSchema(schema, this.#path(variant, member, "state"))); this.#changes.push([variant, member, "state"]); return this.#checked(); } - #putKeyed(variant: string, member: string, schema: TSchema): object { + #putKeyed(variant: string, member: string, schema: Schema): object { this.#variant(variant).keyed.set(member, snapshotSchema(schema, this.#path(variant, member, "keyed"))); this.#changes.push([variant, member, "keyed"]); return this.#checked(); } /** Changes the given sides of a method in every variant that has it. */ - #changeMethod(member: string, args: TSchema | undefined, result: TSchema | undefined): object { + #changeMethod(member: string, args: Schema | undefined, result: Schema | undefined): object { const variants = this.#variantsWith(member); const path = this.#path(variants[0]!, member, "method"); const newArgs = args === undefined ? undefined : snapshotArguments(args, `${path}.args`); @@ -355,16 +355,16 @@ class Draft { /** The local editor: the draft's edits with the variant filled in. */ function localEditor(draft: Draft): object { draft.editor = { - state: (member: string, schema: TSchema) => draft.state(LOCAL, member, schema), - addState: (member: string, schema: TSchema) => draft.addState(LOCAL, member, schema), + state: (member: string, schema: Schema) => draft.state(LOCAL, member, schema), + addState: (member: string, schema: Schema) => draft.addState(LOCAL, member, schema), removeState: (member: string) => draft.removeState(LOCAL, member), - addMethod: (member: string, args: TSchema, result: TSchema) => draft.addMethod(LOCAL, member, args, result), - args: (member: string, args: TSchema) => draft.args(member, args), - result: (member: string, result: TSchema) => draft.result(member, result), - method: (member: string, args: TSchema, result: TSchema) => draft.method(member, args, result), + addMethod: (member: string, args: Schema, result: Schema) => draft.addMethod(LOCAL, member, args, result), + args: (member: string, args: Schema) => draft.args(member, args), + result: (member: string, result: Schema) => draft.result(member, result), + method: (member: string, args: Schema, result: Schema) => draft.method(member, args, result), removeMethod: (member: string) => draft.removeMethod(member), - keyed: (member: string, schema: TSchema) => draft.keyed(LOCAL, member, schema), - addKeyed: (member: string, schema: TSchema) => draft.addKeyed(LOCAL, member, schema), + keyed: (member: string, schema: Schema) => draft.keyed(LOCAL, member, schema), + addKeyed: (member: string, schema: Schema) => draft.addKeyed(LOCAL, member, schema), removeKeyed: (member: string) => draft.removeKeyed(LOCAL, member), }; return draft.editor; diff --git a/packages/chord/src/next/contracts/history.ts b/packages/chord/src/next/contracts/history.ts index a664f98ff..2a3fb1a80 100644 --- a/packages/chord/src/next/contracts/history.ts +++ b/packages/chord/src/next/contracts/history.ts @@ -1,13 +1,13 @@ // The type-level history of a contract: every version's members and the changes that produced it. -import type { Method, TSchema } from "./schema.ts"; +import type { Method, Schema } from "./schema.ts"; /** One variant's members at one version: state schemas and method signatures. */ export type VariantShape = { - readonly state: { readonly [member: string]: TSchema }; + readonly state: { readonly [member: string]: Schema }; readonly methods: { readonly [member: string]: Method }; /** A local contract's keyed state members (slice 6a §12). */ - readonly keyed?: { readonly [member: string]: TSchema }; + readonly keyed?: { readonly [member: string]: Schema }; }; /** Variants by name. A local contract has the single variant `members`. */ diff --git a/packages/chord/src/next/contracts/index.ts b/packages/chord/src/next/contracts/index.ts index 299bac9d3..da4a7fbe9 100644 --- a/packages/chord/src/next/contracts/index.ts +++ b/packages/chord/src/next/contracts/index.ts @@ -7,24 +7,30 @@ export { evolve } from "./evolve.ts"; export { type Address, type HostKind, hostKind, type KindShape, type ParamsOfKind } from "./host-kind.ts"; export { arr, + type BooleanSchema, type Bounds, bool, bytes, type Defaulted, + type IntegerSchema, int, type KeyedMember, keyed, lit, map, method, + type NullSchema, + type NumberSchema, nul, num, + type Optional, obj, oneOf, opt, + type Schema, type Static, + type StringSchema, str, - type TSchema, union, unknown, } from "./schema.ts"; diff --git a/packages/chord/src/next/contracts/params.ts b/packages/chord/src/next/contracts/params.ts index 565c88500..d827edc3a 100644 --- a/packages/chord/src/next/contracts/params.ts +++ b/packages/chord/src/next/contracts/params.ts @@ -1,26 +1,35 @@ // Params declarations: the policy for params fields and the closed object schema checked on every get. -import type { TBoolean, TInteger, TLiteral, TString, TUnion } from "typebox"; import type { JsonValue } from "../../json.ts"; -import type { TSchema } from "./schema.ts"; +import { + type BooleanSchema, + type IntegerSchema, + isOptional, + type LiteralsSchema, + type Optional, + type Schema, + type StringSchema, +} from "./schema.ts"; import { snapshotSchema } from "./snapshot.ts"; -/** A params field: a string, an integer, a boolean or a union of string literals; constraints allowed. */ -type ParamSchema = TString | TInteger | TBoolean | TUnion[]>; +/** A params field: a string, an integer, a boolean or a union of string literals; constraints allowed, no default. */ +type ParamSchema = (StringSchema | IntegerSchema | BooleanSchema | LiteralsSchema) & { + readonly default?: never; +}; /** Params: a flat record of required fields, in identity order. */ export type Params = { readonly [field: string]: ParamSchema }; /** Refuses optional params fields, which the field types alone admit. */ export type RequiredFields

= { - [F in keyof P]: P[F] extends { readonly "~optional": true } ? never : P[F]; + [F in keyof P]: P[F] extends Optional ? never : P[F]; }; export type ParamsData = { /** Field names in identity order. */ readonly fields: readonly string[]; /** The closed object schema of all fields. */ - readonly schema: TSchema; + readonly schema: Schema; /** `[field, normal form]` pairs in identity order. */ readonly normal: JsonValue; }; @@ -29,10 +38,10 @@ export type ParamsData = { export function readParams(declared: readonly [string, unknown][], path: string): ParamsData { const fields = declared.map(([field, schema]) => { const fieldPath = `${path}.${field}`; + const refused = new Error(`${fieldPath}: a param is a required str(), int(), bool() or oneOf(...) of strings`); + if (isOptional(schema)) throw refused; const snapshot = snapshotSchema(schema, fieldPath); - if (Object.hasOwn(schema as object, "~optional") || !isParamSchema(snapshot.normal)) { - throw new Error(`${fieldPath}: a param is a required str(), int(), bool() or oneOf(...) of strings`); - } + if (!isParamSchema(snapshot.normal)) throw refused; return [field, snapshot] as const; }); const schema = { diff --git a/packages/chord/src/next/contracts/schema.ts b/packages/chord/src/next/contracts/schema.ts index fc987dbb2..230d3a039 100644 --- a/packages/chord/src/next/contracts/schema.ts +++ b/packages/chord/src/next/contracts/schema.ts @@ -1,24 +1,26 @@ -// Schema helpers: short constructors for TypeBox schemas, the bytes leaf, and method declarations. +// Schema helpers: Chord's short constructors over pi-schema's builders (plain JSON Schema, typed with the values they +// accept), and method and keyed member declarations. The helpers keep the shapes contracts were fingerprinted with: +// literals carry their `type`, `oneOf` is a union of literals, `map` a pattern-property record. -import Type, { - type TArray, - type TBoolean, - type TInteger, - type TLiteral, - type TNull, - type TNumber, - type TObject, - type TOptional, - type TProperties, - type TRecord, - type TSchema, - type TString, - type TUnion, - type TUnknown, - type TUnsafe, -} from "typebox"; +import { + arr as arrSchema, + bool as boolSchema, + bytes as bytesSchema, + int as intSchema, + nul as nulSchema, + num as numSchema, + Optional, + obj as objSchema, + type Properties, + type Schema, + type Static, + type StaticObject, + str as strSchema, + union as unionSchema, + unknown as unknownSchema, +} from "@earendil-works/pi-schema"; -export type { Static, TSchema } from "typebox"; +export { Optional, opt, type Schema, type Static } from "@earendil-works/pi-schema"; /** Bounds of a string length, a number or an array length. */ export type Bounds = { readonly min?: number; readonly max?: number }; @@ -26,118 +28,132 @@ export type Bounds = { readonly min?: number; readonly max?: number }; /** A declared default value: a table column with one may be left out of a `put` (slice 6a §5). */ export type Defaulted = { readonly default: T }; +// Interfaces, not intersections: declarations of apps' exported contracts and tables name them (an intersection with +// `Schema` would print pi-schema's private brand, which declaration files cannot name). + +/** A string schema (`str()`), told apart from the other scalars by its `type`. */ +export interface StringSchema extends Schema { + readonly type: "string"; +} + +/** A number schema (`num()`). */ +export interface NumberSchema extends Schema { + readonly type: "number"; +} + +/** An integer schema (`int()`). */ +export interface IntegerSchema extends Schema { + readonly type: "integer"; +} + +/** A boolean schema (`bool()`). */ +export interface BooleanSchema extends Schema { + readonly type: "boolean"; +} + +/** The `null` schema (`nul()`): also the omitted side of a method. */ +export interface NullSchema extends Schema { + readonly type: "null"; +} + +/** A union of literals (`oneOf(...)`). */ +export interface LiteralsSchema extends Schema { + readonly anyOf: readonly Schema[]; +} + /** A string, with optional length bounds, pattern and default. */ export function str( options: Bounds & { readonly pattern?: string; readonly default: string }, -): TString & Defaulted; -export function str(options?: Bounds & { readonly pattern?: string }): TString; -export function str(options: Bounds & { readonly pattern?: string; readonly default?: string } = {}): TString { +): StringSchema & Defaulted; +export function str(options?: Bounds & { readonly pattern?: string }): StringSchema; +export function str(options: Bounds & { readonly pattern?: string; readonly default?: string } = {}): StringSchema { const pattern = options.pattern === undefined ? {} : { pattern: options.pattern }; - return Type.String({ ...limits(options, "minLength", "maxLength"), ...pattern, ...defaultOf(options) }); + return strSchema({ + ...limits(options, "minLength", "maxLength"), + ...pattern, + ...defaultOf(options), + }) as StringSchema; } /** A finite number within optional bounds, with an optional default. */ -export function num(options: Bounds & { readonly default: number }): TNumber & Defaulted; -export function num(options?: Bounds): TNumber; -export function num(options: Bounds & { readonly default?: number } = {}): TNumber { - return Type.Number({ ...limits(options, "minimum", "maximum"), ...defaultOf(options) }); +export function num(options: Bounds & { readonly default: number }): NumberSchema & Defaulted; +export function num(options?: Bounds): NumberSchema; +export function num(options: Bounds & { readonly default?: number } = {}): NumberSchema { + return numSchema({ ...limits(options, "minimum", "maximum"), ...defaultOf(options) }) as NumberSchema; } /** An integer within optional bounds, with an optional default. */ -export function int(options: Bounds & { readonly default: number }): TInteger & Defaulted; -export function int(options?: Bounds): TInteger; -export function int(options: Bounds & { readonly default?: number } = {}): TInteger { - return Type.Integer({ ...limits(options, "minimum", "maximum"), ...defaultOf(options) }); +export function int(options: Bounds & { readonly default: number }): IntegerSchema & Defaulted; +export function int(options?: Bounds): IntegerSchema; +export function int(options: Bounds & { readonly default?: number } = {}): IntegerSchema { + return intSchema({ ...limits(options, "minimum", "maximum"), ...defaultOf(options) }) as IntegerSchema; } /** A boolean, with an optional default. */ -export function bool(options: { readonly default: boolean }): TBoolean & Defaulted; -export function bool(options?: Record): TBoolean; -export function bool(options: { readonly default?: boolean } = {}): TBoolean { - return Type.Boolean(defaultOf(options)); +export function bool(options: { readonly default: boolean }): BooleanSchema & Defaulted; +export function bool(options?: Record): BooleanSchema; +export function bool(options: { readonly default?: boolean } = {}): BooleanSchema { + return boolSchema(defaultOf(options)) as BooleanSchema; } /** The `default` keyword of `options`, left out when absent (snapshots refuse `undefined`). */ -function defaultOf(options: { readonly default?: string | number | boolean }): { default?: string | number | boolean } { +function defaultOf(options: { readonly default?: T }): { default?: T } { return options.default === undefined ? {} : { default: options.default }; } /** `null`; also the omitted side of a method. */ -export function nul(): TNull { - return Type.Null(); +export function nul(): NullSchema { + // The builder's `type: "null"`, typed as such. + return nulSchema() as NullSchema; } -/** Exactly the given value. */ -export function lit(value: V): TLiteral { - return Type.Literal(value); +/** Exactly the given value, with its JSON type (`{ type: "string", const: "a" }`). */ +export function lit(value: V): Schema { + return { type: typeof value, const: value }; } -/** One of the given literals. */ -export function oneOf(...values: V): TUnion> { - // One literal schema per value, in order. - return Type.Union(values.map((value) => Type.Literal(value))) as TUnion>; +/** A value `lit(...)` and `oneOf(...)` take. */ +export type Literal = string | number | boolean; + +/** One of the given literals: a union of `lit(...)` schemas, in order. */ +export function oneOf(...values: V): LiteralsSchema { + return { anyOf: values.map((value) => lit(value)) }; } -type Literal = string | number | boolean; - -type Literals = V extends [infer First extends Literal, ...infer Rest extends Literal[]] - ? [TLiteral, ...Literals] - : []; - /** Any of the given schemas. */ -export function union(schemas: [...S]): TUnion { - return Type.Union(schemas); +export function union(schemas: S): Schema> { + return unionSchema(schemas); } -/** A closed object: unknown fields are refused. */ -export function obj

(properties: P): TObject

{ - return Type.Object(properties, { additionalProperties: false }); +/** A closed object: unknown fields are refused; `opt(...)` marks an optional field. */ +export function obj(properties: P): Schema> { + return objSchema(properties); } /** An array, with optional length bounds. */ -export function arr(items: S, options: Bounds = {}): TArray { - return Type.Array(items, limits(options, "minItems", "maxItems")); +export function arr(items: Schema, options: Bounds = {}): Schema { + return arrSchema(items, limits(options, "minItems", "maxItems")); } -/** A string-keyed record. */ -export function map(value: S): TRecord<"^.*$", S> { - return Type.Record(Type.String(), value); -} - -/** An optional object field. */ -export function opt(schema: S): TOptional { - return Type.Optional(schema) as TOptional; +/** A string-keyed record: one pattern property matching every key. */ +export function map(value: Schema): Schema> { + return { type: "object", patternProperties: { "^.*$": value } }; } /** Any value. */ -export function unknown(): TUnknown { - return Type.Unknown(); +export function unknown(): Schema { + return unknownSchema(); } /** - * A `Uint8Array` leaf, checked by `instanceof` and length, never enumerated. Its enumerable form is the data marker - * `{ type: "bytes", minLength?, maxLength? }`. + * A `Uint8Array` leaf, checked by `instanceof` and length, never enumerated: `{ type: "bytes", minLength?, maxLength? }` + * (pi-schema's extension of JSON Schema for values that never pass through JSON). */ -export function bytes(options: Bounds = {}): TUnsafe { - return withBytesCheck({ type: "bytes", ...limits(options, "minLength", "maxLength") }); +export function bytes(options: Bounds = {}): Schema { + return bytesSchema(limits(options, "minLength", "maxLength")); } -/** The data marker of a bytes schema. */ -export type BytesMarker = { readonly type: "bytes"; readonly minLength?: number; readonly maxLength?: number }; - -/** Attaches the TypeBox check of a bytes marker, non-enumerably so it is never copied as data. */ -export function withBytesCheck(marker: BytesMarker): TUnsafe { - const { minLength = 0, maxLength = Number.POSITIVE_INFINITY } = marker; - const refinement = { - check: (value: unknown) => value instanceof Uint8Array && value.length >= minLength && value.length <= maxLength, - error: () => "expected bytes of the declared length", - }; - Object.defineProperty(marker, "~refine", { value: [refinement] }); - // The marker object is the schema; TypeBox reads the attached check. - return marker as unknown as TUnsafe; -} - -/** TypeBox options for the given bounds; an absent bound is left out, since snapshots refuse `undefined`. */ +/** Builder options for the given bounds; an absent bound is left out, since snapshots refuse `undefined`. */ function limits(options: Bounds, min: string, max: string): { [keyword: string]: number } { const result: { [keyword: string]: number } = {}; if (options.min !== undefined) result[min] = options.min; @@ -145,8 +161,13 @@ function limits(options: Bounds, min: string, max: string): { [keyword: string]: return result; } +/** Whether a declared object field is `opt(...)`. */ +export function isOptional(value: unknown): value is Optional { + return value instanceof Optional; +} + /** A method declaration: its argument and result schemas; an omitted side is `null` on the wire. */ -export class Method { +export class Method { readonly args: A; readonly result: R; @@ -157,7 +178,7 @@ export class Method { } /** A keyed state member's declaration (slice 6a §12): documents of `schema` by string key; only in `local` contracts. */ -export class KeyedMember { +export class KeyedMember { readonly schema: S; constructor(schema: S) { @@ -166,15 +187,15 @@ export class KeyedMember { } /** Declares a keyed state member of a `local` contract: documents of `schema` by string key, each watchable. */ -export function keyed(schema: S): KeyedMember { +export function keyed(schema: S): KeyedMember { return new KeyedMember(schema); } /** Declares a method: `method()`, `method(args)`, `method(args, result)` or `method(undefined, result)`. */ -export function method(): Method; -export function method(args: A): Method; -export function method(args: undefined, result: R): Method; -export function method(args: A, result: R): Method; -export function method(args?: TSchema, result?: TSchema): Method { +export function method(): Method; +export function method(args: A): Method; +export function method(args: undefined, result: R): Method; +export function method(args: A, result: R): Method; +export function method(args?: Schema, result?: Schema): Method { return new Method(args ?? nul(), result ?? nul()); } diff --git a/packages/chord/src/next/contracts/snapshot.ts b/packages/chord/src/next/contracts/snapshot.ts index a84ed79c0..c8ec2eafb 100644 --- a/packages/chord/src/next/contracts/snapshot.ts +++ b/packages/chord/src/next/contracts/snapshot.ts @@ -1,14 +1,14 @@ // Schema snapshots: one schema-aware walk checks an app schema against Chord's vocabulary and produces a // Chord-owned copy and its normal form (the fingerprint input). +import type { JsonSchema } from "@earendil-works/pi-schema"; import { isJsonValue, type JsonValue } from "../../json.ts"; -import { type BytesMarker, type TSchema, withBytesCheck } from "./schema.ts"; /** * A Chord-owned copy of an app schema, its normal form, and whether it has a `bytes()` leaf (values crossing a link * then carry raw byte segments); immutable by convention. */ -export type Snapshot = { readonly schema: TSchema; readonly normal: JsonValue; readonly bytes: boolean }; +export type Snapshot = { readonly schema: JsonSchema; readonly normal: JsonValue; readonly bytes: boolean }; /** Snapshot of a state, result or params schema. Throws an Error naming `path` for anything outside the vocabulary. */ export function snapshotSchema(source: unknown, path: string): Snapshot { @@ -51,11 +51,6 @@ const KEYWORDS = new Map( const TYPES = new Set(["null", "boolean", "number", "integer", "string", "array", "object", "bytes"]); -/** TypeBox's executable features, attached non-enumerably. */ -const EXECUTABLE = ["~refine", "~codec", "~unsafe"]; - -const BYTES_EXECUTABLE = ["~refine", "~unsafe"]; - const BYTES_KEYWORDS = new Set(["type", "minLength", "maxLength"]); function walk(source: unknown, path: string, objects: Objects, ancestors: Set): Snapshot { @@ -63,13 +58,6 @@ function walk(source: unknown, path: string, objects: Objects, ancestors: Set): Copy { @@ -167,15 +151,15 @@ function sortedCopy(value: JsonValue): JsonValue { return Object.fromEntries(keys.map((key) => [key, sortedCopy(value[key]!)])); } -/** Enumerable data entries sorted by key; symbol keys and accessors are refused. */ +/** Data entries sorted by key; symbol keys, accessors and non-enumerable keys (not data) are refused. */ function ownEntries(source: object, path: string): [string, unknown][] { const entries: [string, unknown][] = []; for (const key of Reflect.ownKeys(source)) { if (typeof key === "symbol") throw new Error(`${path}: symbol keys are not supported`); const descriptor = Object.getOwnPropertyDescriptor(source, key)!; if (!("value" in descriptor)) throw new Error(`${path}.${key}: accessors are not supported`); - // Non-enumerable keys are TypeBox metadata (`~kind`, `~optional`), never schema data. - if (descriptor.enumerable) entries.push([key, descriptor.value]); + if (!descriptor.enumerable) throw new Error(`${path}.${key}: non-enumerable keys are not supported`); + entries.push([key, descriptor.value]); } return entries.sort(([a], [b]) => compare(a, b)); } diff --git a/packages/chord/src/next/data/README.md b/packages/chord/src/next/data/README.md index c3caba867..74248adb4 100644 --- a/packages/chord/src/next/data/README.md +++ b/packages/chord/src/next/data/README.md @@ -51,8 +51,9 @@ Rules: them. Results and state are never checked. Nothing in L1 is frozen. - A method without arguments (argument schema `null`) omits the argument in references and implementations; `null` is its internal and wire form, which `checkArgs` checks. Unknown fields fail. -- A failed check is `invalid/args`, `invalid/params` or `invalid/host_address` with `details.path`, a JSON Pointer from TypeBox's first error. - TypeBox does not escape `~` and `/`, so keys containing them make the path ambiguous. +- A failed check is `invalid/args`, `invalid/params` or `invalid/host_address` with `details.path`, the JSON Pointer of + the first error pi-schema explains (`~` and `/` in keys escaped as `~0` and `~1`); a missing field is reported at its + own path ("missing"), an unknown one too ("unknown field"), the value itself at `/`. - `checkArgs` refuses an unknown method with `invalid/member` and an unaccepted version or a refused method with `contract_mismatch`. It never invokes a `call` replacement. - Error codes, reasons and their pairings are link protocol: changing them bumps `PROTOCOL_VERSION`. diff --git a/packages/chord/src/next/data/args.ts b/packages/chord/src/next/data/args.ts index 3239d263e..83fe60013 100644 --- a/packages/chord/src/next/data/args.ts +++ b/packages/chord/src/next/data/args.ts @@ -1,6 +1,6 @@ // Arguments: a provider's binding (its dispatch table per accepted version) and the per-call argument check. -import type { Validator } from "typebox/compile"; +import type { Validator } from "@earendil-works/pi-schema"; import type { Context } from "../../context/index.ts"; import { type AcceptedVersion, type AcceptsTable, checkAccepts } from "../contracts/accepts.ts"; import { diff --git a/packages/chord/src/next/data/validator.ts b/packages/chord/src/next/data/validator.ts index 4a16599de..f7995f9ab 100644 --- a/packages/chord/src/next/data/validator.ts +++ b/packages/chord/src/next/data/validator.ts @@ -1,28 +1,19 @@ -// Compiled checks: TypeBox validators of Chord-owned schemas, and the `invalid` error of a failed check. +// Compiled checks: pi-schema validators of Chord-owned schemas, and the `invalid` error of a failed check. -import { Compile, type Validator } from "typebox/compile"; -import type { TLocalizedValidationError } from "typebox/error"; -import { ResetExternal, ResetFunctions } from "typebox/schema"; -import { Errors } from "typebox/value"; +import { compile, type JsonSchema, type Validator } from "@earendil-works/pi-schema"; import type { Context } from "../../context/index.ts"; -import type { TSchema } from "../contracts/schema.ts"; import { type ChordError, invalid } from "./errors.ts"; /** A Chord-owned record holding a schema: a snapshot, or a contract's params. */ -type SchemaOwner = { readonly schema: TSchema }; +type SchemaOwner = { readonly schema: JsonSchema }; /** The validator of an owner's schema, compiled on first use (counted) and cached weakly by the owner. */ export function validatorFor(owner: SchemaOwner, context: Context): Validator { let validator = validators.get(owner); if (validator === undefined) { context.telemetry.metrics?.counter("chord.data.compilations").add(1); - try { - validator = Compile(owner.schema); - } finally { - // TypeBox keeps its last build in module-level accumulators; reset them so no build state is retained. - ResetFunctions(); - ResetExternal(); - } + // `format` is an annotation (decided for the schema switch): no format checks are compiled in. + validator = compile(owner.schema); validators.set(owner, validator); } return validator; @@ -38,35 +29,23 @@ export function check( context: Context, ): void { context.telemetry.metrics?.counter("chord.data.validations").add(1); - if (!validator.Check(value)) throw failedCheck(validator.Type(), value, reason, context); + if (!validator.check(value)) throw failedCheck(validator, value, reason, context); } /** - * The error of a failed check: TypeBox's first error from its diagnostic walk (counted; it builds the whole list). - * TypeBox does not escape `~` and `/` in its instance paths, so keys containing them make the path ambiguous. + * The error of a failed check: the first error of the validator's explanation (counted: it walks the schema). A + * missing field is reported at the field ("missing"), an unknown one at its own path ("unknown field"); the value + * itself at `/`. */ function failedCheck( - schema: TSchema, + validator: Validator, value: unknown, reason: "args" | "params" | "host_address", context: Context, ): ChordError { context.telemetry.metrics?.counter("chord.data.walks").add(1); - const [error] = Errors(schema, value); - const { path, problem } = describe(error); - return invalid(reason, `${reason}${path === "/" ? "" : path}: ${problem}`, { path }); -} - -/** - * The path and problem of a TypeBox error: TypeBox reports a missing field at its parent, and an unknown one as a - * false schema (`additionalProperties: false`) at its own path. Anything else keeps TypeBox's message. - */ -function describe(error: TLocalizedValidationError): { path: string; problem: string } { - if (error.keyword === "required") { - return { path: `${error.instancePath}/${error.params.requiredProperties[0]}`, problem: "missing" }; - } - if (error.keyword === "boolean" && error.schemaPath.endsWith("/additionalProperties")) { - return { path: error.instancePath, problem: "unknown field" }; - } - return { path: error.instancePath || "/", problem: error.message }; + // A value that failed the check has at least one error. + const error = validator.errors(value)[0]!; + const path = error.path || "/"; + return invalid(reason, `${reason}${path === "/" ? "" : path}: ${error.message}`, { path }); } diff --git a/packages/chord/src/next/documents/definition.ts b/packages/chord/src/next/documents/definition.ts index e9a2eecf1..26c2105e6 100644 --- a/packages/chord/src/next/documents/definition.ts +++ b/packages/chord/src/next/documents/definition.ts @@ -4,7 +4,7 @@ import type { Context } from "../../context/index.ts"; import type { Op } from "../../delta/index.ts"; import type { JsonValue } from "../../json.ts"; -import type { Static, TSchema } from "../contracts/index.ts"; +import type { Schema, Static } from "../contracts/index.ts"; import { checkName, fingerprintOf } from "../contracts/internal.ts"; import { snapshotSchema } from "../contracts/snapshot.ts"; import { validatorFor } from "../data/internal.ts"; @@ -31,7 +31,7 @@ export type CheckpointInfo = { export type CheckpointWhen = (value: T, ops: readonly Op[], info: CheckpointInfo) => boolean; /** What `defineState` takes for any document. */ -type StateOptions = { +type StateOptions = { readonly schema: S; readonly version: number; readonly initial: () => DocumentOf; @@ -41,7 +41,7 @@ type StateOptions = { }; /** What a persisted document adds (slice 6a §10): `checkpointWhen` is required. */ -type PersistOptions = { +type PersistOptions = { /** Stored in the host's store; only in `local` types with empty params (P3). */ readonly persist: true; /** Called once per commit per changed document; creations and version changes always store a base. */ @@ -52,15 +52,15 @@ type PersistOptions = { * Declares a document: a singleton per object, or with `keyed: true` many per object, each by a string key; with * `persist: true` stored in the host's store. Its name is a restricted name (lowercase `[a-z0-9@/._~-]`). */ -export function defineState( +export function defineState( name: string, options: StateOptions & { readonly persist?: false; readonly checkpointWhen?: never }, ): StateDefinition, K, false>; -export function defineState( +export function defineState( name: string, options: StateOptions & PersistOptions, ): StateDefinition, K, true>; -export function defineState( +export function defineState( name: string, options: StateOptions & { readonly persist?: boolean; @@ -82,14 +82,14 @@ export function defineState( } /** The value of a document whose schema is `S`: its static type, which must be a JSON object. */ -type DocumentOf = Static extends JsonObject ? Static : never; +type DocumentOf = Static extends JsonObject ? Static : never; const NEVER: CheckpointWhen = () => false; /** A declared document; `T` is its value, `K` whether it is keyed, `P` whether it is persisted. */ export class StateDefinition { readonly name: string; - readonly schema: TSchema; + readonly schema: Schema; readonly version: number; readonly keyed: boolean; readonly persist: boolean; @@ -110,7 +110,7 @@ export class StateDefinition T, migrate: ((old: JsonObject, from: number) => T) | undefined, @@ -159,7 +159,7 @@ export class StateDefinition | undefined, > = { /** The job's version; every change of its args, result or state bumps it. */ @@ -40,7 +40,7 @@ export type JobOptions< export type JobPersistence = { readonly resume: "rerun" | "abandon" }; /** Per older version, its mappings forwards: its args to the current args, its result to the current result. */ -export type JobAccepts = { +export type JobAccepts = { readonly [version: number]: { readonly args: (old: never) => Static; readonly result: (old: never) => Static }; }; @@ -66,8 +66,8 @@ export type JobError = /** Declares a job: owned work a call starts that outlives it (§15). */ export function defineJob< - const A extends TSchema, - const R extends TSchema, + const A extends Schema, + const R extends Schema, D extends StateDefinition | undefined = undefined, >(name: string, options: JobOptions): JobDefinition, Static, StateOf> { // Declaration policy: JavaScript callers reach every check below. diff --git a/packages/chord/src/next/link/README.md b/packages/chord/src/next/link/README.md index 806bb4f19..d68605b53 100644 --- a/packages/chord/src/next/link/README.md +++ b/packages/chord/src/next/link/README.md @@ -72,7 +72,7 @@ tab.connect(webSocketLink(new WebSocket(url)), { principal: { kind: "server" } } ## For Chord's layers (`internal.ts`) -- `Message` (`message.ts`): the protocol's one tagged union, from the TypeBox schema `MESSAGE`: `hello { protocol, +- `Message` (`message.ts`): the protocol's one tagged union, from the schema `MESSAGE`: `hello { protocol, incarnation, calls }` (`calls`: how many concurrent calls this side accepts per origin, its call window), `hold { hold, type, version, fingerprint, params, address, principal?, incarnation? }` (`address`: `{ kind, params }` the caller's `hostAddress` computed, it routes the hold; the provider places it by its params; `principal` and `incarnation`: whom it acts for, read only diff --git a/packages/chord/src/next/link/message.ts b/packages/chord/src/next/link/message.ts index 21ac9e0d4..745ce0ad7 100644 --- a/packages/chord/src/next/link/message.ts +++ b/packages/chord/src/next/link/message.ts @@ -1,7 +1,7 @@ -// Messages (§22, R1, R4, 2b): the one `Message` type every link carries, defined as a TypeBox schema and checked after -// every decode, and the codecs that turn it into bytes. +// Messages (§22, R1, R4, 2b): the one `Message` type every link carries, defined as a schema and checked after every +// decode, and the codecs that turn it into bytes. -import Type from "typebox"; +import { obj as openObj, type Schema } from "@earendil-works/pi-schema"; import type { Context } from "../../context/index.ts"; import { arr, int, lit, obj, oneOf, opt, type Static, str, union, unknown } from "../contracts/index.ts"; import { validatorFor } from "../data/internal.ts"; @@ -30,19 +30,19 @@ const wireError = union([ const failed = arr(obj({ member: index, error: wireError })); /** A part's envelope: the codec built it, so any value passes (its content is the owner's to parse). */ -const part = Type.Unsafe({}); +const part = unknown() as Schema; /** * The byte segments of a payload part whose method's schema has a `bytes()` leaf, referenced from its JSON by * `{"$bytes": i}`; absent for every other payload. Built by the codec, like the part. */ -const segments = opt(Type.Unsafe({})); +const segments = opt(unknown() as Schema); /** An address as a hold names it: its host kind and params, checked against the kind where it is routed. */ const address = obj({ kind: str(), params: unknown() }); /** A principal on a hold: JSON with a `kind`, honoured only on a parent link. */ -const principal = Type.Object({ kind: str() }); +const principal = openObj({ kind: str() }, { open: true }); /** A uuidv7 as text. */ const incarnation = str({ pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }); @@ -74,7 +74,7 @@ const MESSAGE = union([ principal: opt(principal), incarnation: opt(incarnation), // The instance ids of the activations waiting for this hold (Q5); read only on a trusted link. - chain: opt(Type.Array(incarnation, { maxItems: CHAIN })), + chain: opt(arr(incarnation, { max: CHAIN })), // The decoded limit per part of the link the hold was admitted on (Q2); read only on a parent link. limit: opt(int({ min: 0 })), }), @@ -185,7 +185,7 @@ const MESSAGE_SCHEMA = { schema: MESSAGE }; */ export function checkMessage(message: unknown, context: Context): Message { context.telemetry.metrics?.counter("chord.link.checks").add(1); - if (!validatorFor(MESSAGE_SCHEMA, context).Check(message)) throw new Error("message: fails the message schema"); + if (!validatorFor(MESSAGE_SCHEMA, context).check(message)) throw new Error("message: fails the message schema"); // The check above establishes the type. return message as Message; } diff --git a/packages/chord/src/next/store/archive.ts b/packages/chord/src/next/store/archive.ts index 1db668048..922057994 100644 --- a/packages/chord/src/next/store/archive.ts +++ b/packages/chord/src/next/store/archive.ts @@ -2,9 +2,21 @@ // are operations", "Writes carry their versions; archives are lossless"): the parts an export yields and an import // takes, their order, and the compiled schemas every imported part and every applied write is checked against first. -import Type, { type TInteger } from "typebox"; import type { Context } from "../../context/index.ts"; -import { arr, bool, int, lit, num, obj, opt, str, union, unknown } from "../contracts/index.ts"; +import { + arr, + bool, + type IntegerSchema, + int, + lit, + map, + num, + obj, + opt, + str, + union, + unknown, +} from "../contracts/index.ts"; import { invalid, validatorFor } from "../data/internal.ts"; import type { DocumentWrite } from "./engine.ts"; import type { TableWrite } from "./table-writes.ts"; @@ -34,7 +46,7 @@ export type ArchivePart = | { readonly kind: "end"; readonly writes: number }; /** An integer from `min` up to 2^53 - 1: seqs, ids, versions and counts stay exact (a seq after 2^53 would repeat). */ -function safe(min: number): TInteger { +function safe(min: number): IntegerSchema { return int({ min, max: Number.MAX_SAFE_INTEGER }); } @@ -48,14 +60,14 @@ const column = obj({ fingerprint: opt(str()), }); -const versions = Type.Record(Type.String(), version); +const versions = map(version); const TABLE = obj({ kind: lit("table"), table: str(), version, state: union([lit("ready"), lit("migrating")]), - columns: Type.Record(Type.String(), column), + columns: map(column), key: arr(str()), fingerprint: str(), }); @@ -63,7 +75,7 @@ const TABLE = obj({ const PUT = obj({ kind: lit("put"), table: str(), - row: Type.Record(Type.String(), unknown()), + row: map(unknown()), version, columns: versions, }); @@ -117,7 +129,7 @@ const edit = union([ obj({ kind: lit("json"), name: str(), column }), ]); -const key = Type.Record(Type.String(), union([str(), num(), bool()])); +const key = map(union([str(), num(), bool()])); /** One recorded write of a commit (a batch's); `where` and `set` nodes are checked by the applier. */ const WRITE = union([ @@ -158,7 +170,7 @@ const WRITE_SCHEMA = { schema: WRITE }; /** Checks one imported part against the archive schema (a trust boundary); `invalid/args` naming its position. */ export function checkPart(part: unknown, position: number, context: Context): ArchivePart { context.telemetry.metrics?.counter("chord.data.validations").add(1); - if (!validatorFor(PART_SCHEMA, context).Check(part)) { + if (!validatorFor(PART_SCHEMA, context).check(part)) { throw invalid("args", `import: part ${position}: not an archive part`); } // The check above establishes the type. @@ -168,7 +180,7 @@ export function checkPart(part: unknown, position: number, context: Context): Ar /** Checks one write of a batch's commit against the write schema (a trust boundary); `what` names its place. */ export function checkWrite(write: unknown, what: string, context: Context): StoreWrite { context.telemetry.metrics?.counter("chord.data.validations").add(1); - if (!validatorFor(WRITE_SCHEMA, context).Check(write)) throw invalid("args", `${what}: not a store write`); + if (!validatorFor(WRITE_SCHEMA, context).check(write)) throw invalid("args", `${what}: not a store write`); // The check above establishes the type, the nodes aside (the applier checks them). return write as StoreWrite; } diff --git a/packages/chord/src/next/store/columns.ts b/packages/chord/src/next/store/columns.ts index 8d4118262..180251efb 100644 --- a/packages/chord/src/next/store/columns.ts +++ b/packages/chord/src/next/store/columns.ts @@ -2,8 +2,8 @@ // subset of L1's helpers a table's `columns` take (`str int num bool`, `opt(...)` of them, `json(...)`, defaults), // their types for rows, keys and column references, and the column descriptors and records the store keeps. -import type { TBoolean, TInteger, TNumber, TOptional, TString } from "typebox"; -import type { Static, TSchema } from "../contracts/index.ts"; +import type { BooleanSchema, IntegerSchema, NumberSchema, Schema, Static, StringSchema } from "../contracts/index.ts"; +import { isOptional, type Optional } from "../contracts/schema.ts"; import { JsonColumn, type JsonColumnSchema } from "./json-column.ts"; import { storedText } from "./text.ts"; @@ -11,9 +11,9 @@ import { storedText } from "./text.ts"; export type ColumnSchema = | ScalarSchema | JsonColumnSchema - | TOptional>; + | Optional>; -type ScalarSchema = TString | TInteger | TNumber | TBoolean; +type ScalarSchema = StringSchema | IntegerSchema | NumberSchema | BooleanSchema; /** A table's `columns`: a flat record of column schemas. */ export type Columns = { readonly [name: string]: ColumnSchema }; @@ -22,23 +22,23 @@ export type Columns = { readonly [name: string]: ColumnSchema }; export type ColumnType = "str" | "int" | "num" | "bool" | "json"; /** The store type of column schema `S`. */ -export type ColumnTypeOf = S extends TOptional +export type ColumnTypeOf = S extends Optional ? ColumnTypeOf - : S extends TString + : S extends StringSchema ? "str" - : S extends TInteger + : S extends IntegerSchema ? "int" - : S extends TNumber + : S extends NumberSchema ? "num" - : S extends TBoolean + : S extends BooleanSchema ? "bool" : "json"; /** The value type of column schema `S` (without `undefined` for an `opt(...)` one). */ -export type ValueOf = S extends TOptional ? ValueOf : S extends TSchema ? Static : never; +export type ValueOf = S extends Optional ? ValueOf : S extends Schema ? Static : never; /** The names of the columns that are not `opt(...)`. */ -type RequiredNames = { [K in keyof C]: C[K] extends TOptional ? never : K }[keyof C]; +type RequiredNames = { [K in keyof C]: C[K] extends Optional ? never : K }[keyof C]; /** The names of the columns with a declared default. */ type DefaultedNames = { @@ -57,7 +57,7 @@ export type PutRowOf = { [K in Exclude, Defa /** The columns a key, an index or an `orderBy` may use: non-`opt` `str` or `int`. */ export type OrderColumn = { - [K in keyof C]: C[K] extends TString | TInteger ? (C[K] extends TOptional ? never : K) : never; + [K in keyof C]: C[K] extends Optional ? never : C[K] extends StringSchema | IntegerSchema ? K : never; }[keyof C] & string; @@ -112,15 +112,15 @@ const SCALAR_TYPES: { readonly [type: string]: "str" | "int" | "num" | "bool" } boolean: "bool", }; -function describeColumn(name: string, schema: ColumnSchema, what: string): Column { - const optional = Object.hasOwn(schema, "~optional"); +function describeColumn(name: string, column: ColumnSchema, what: string): Column { + const optional = isOptional(column); // Declaration policy: a JavaScript caller may pass any schema, so its fields are read as unknown data. - const fields = schema as unknown as { readonly [key: string]: unknown }; + const fields = (optional ? column.schema : column) as { readonly [key: string]: unknown }; const json = fields.json; if (json instanceof JsonColumn) return { name, type: "json", optional, json }; const type = SCALAR_TYPES[String(fields.type)]; if (type === undefined) throw new Error(`${what}: expected str(), int(), num(), bool(), opt(...) or json(...)`); - for (const key of Object.keys(schema)) { + for (const key of Object.keys(fields)) { if (key !== "type" && key !== "default") throw new Error(`${what}: "${key}" is not supported in a table column`); } const value = fields.default; diff --git a/packages/chord/src/next/store/expression.ts b/packages/chord/src/next/store/expression.ts index f62f47cf4..0cbc5885f 100644 --- a/packages/chord/src/next/store/expression.ts +++ b/packages/chord/src/next/store/expression.ts @@ -2,9 +2,8 @@ // form"): the builders plugins write `where` and `set` with, and their compilation at staging into one data-only // vocabulary (what the outbox and archives carry; `evaluate.ts` runs it in JavaScript, `sql.ts` renders it). -import type { TOptional } from "typebox"; import type { JsonValue } from "../../json.ts"; -import type { TSchema } from "../contracts/index.ts"; +import type { Optional } from "../contracts/index.ts"; import { invalid } from "../data/internal.ts"; import type { Column, Columns, ColumnType, ColumnTypeOf, ValueOf } from "./columns.ts"; import { checkScalar, encodeCell, type Scalar } from "./rows.ts"; @@ -89,7 +88,7 @@ export class Condition { * the column's type; `undefined` clears an `opt(...)` column. */ export type SetOf = { - readonly [P in Exclude]?: C[P] extends TOptional + readonly [P in Exclude]?: C[P] extends Optional ? ValueOf | Expression, ColumnTypeOf> | undefined : ValueOf | Expression, ColumnTypeOf>; }; diff --git a/packages/chord/src/next/store/json-column.ts b/packages/chord/src/next/store/json-column.ts index 8e8a0a1c7..42195a95e 100644 --- a/packages/chord/src/next/store/json-column.ts +++ b/packages/chord/src/next/store/json-column.ts @@ -2,10 +2,10 @@ // cell with its own version, typed when read, migrated on access (the result checked once, R8), never walked on write // (R9). -import Type, { type TUnsafe } from "typebox"; +import type { JsonSchema } from "@earendil-works/pi-schema"; import type { Context } from "../../context/index.ts"; import type { JsonValue } from "../../json.ts"; -import type { Static, TSchema } from "../contracts/index.ts"; +import type { Schema, Static } from "../contracts/index.ts"; import { fingerprintOf } from "../contracts/internal.ts"; import { snapshotSchema } from "../contracts/snapshot.ts"; import { ChordError } from "../data/index.ts"; @@ -20,24 +20,25 @@ export type JsonColumnOptions = { }; /** A `json(...)` column as `columns` takes it: a schema of the cells' type carrying the column. */ -export type JsonColumnSchema = TUnsafe & { readonly json: JsonColumn }; +export interface JsonColumnSchema extends Schema { + readonly json: JsonColumn; +} /** A column holding one JSON value of `schema` per row, at `version`; wrap it in `opt(...)` to make it nullable. */ -export function json(schema: S, options: JsonColumnOptions>): JsonColumnSchema> { - const column = new JsonColumn(schema, options.version, options.migrate); - // A TypeBox schema (so `opt(...)` and `Static` take it) whose enumerable `json` field `opt`'s clone keeps. - return Type.Unsafe>({ json: column }) as JsonColumnSchema>; +export function json(schema: S, options: JsonColumnOptions>): JsonColumnSchema> { + // Typed as the cells' schema, so `opt(...)` and `Static` take it; the table reads its `json` field. + return { json: new JsonColumn(schema, options.version, options.migrate) }; } /** A `json(...)` column's definition: its schema snapshot, version and fingerprint, and the read of a stored cell. */ export class JsonColumn { - readonly schema: TSchema; + readonly schema: JsonSchema; readonly version: number; /** The fingerprint of the schema's normal form, recorded with the table: the same version with another is `broken`. */ readonly fingerprint: string; readonly #migrate: ((old: JsonValue, from: number) => unknown) | undefined; - constructor(schema: TSchema, version: number, migrate: ((old: JsonValue, from: number) => unknown) | undefined) { + constructor(schema: Schema, version: number, migrate: ((old: JsonValue, from: number) => unknown) | undefined) { // Declaration policy: JavaScript callers reach this check. if (!Number.isSafeInteger(version) || version < 1) throw new Error(`json(...): version ${version} is not a positive integer`); @@ -65,7 +66,7 @@ export class JsonColumn { throw refused(`${what}: migrate from version ${version} threw`, error); } context.telemetry.metrics?.counter("chord.data.validations").add(1); - if (!validatorFor(this, context).Check(migrated)) { + if (!validatorFor(this, context).check(migrated)) { throw refused(`${what}: migrate from version ${version} does not match the schema`); } return migrated; diff --git a/packages/chord/src/next/store/table-evolve.ts b/packages/chord/src/next/store/table-evolve.ts index 3e31ff82e..d0d8723c5 100644 --- a/packages/chord/src/next/store/table-evolve.ts +++ b/packages/chord/src/next/store/table-evolve.ts @@ -3,9 +3,9 @@ // data step, `drop`, `index` and `dropIndex` after it) and track the columns and key through the chain; at runtime one // editor records the edits, checks each as it is made, and materializes the new version at the end. -import type { TOptional } from "typebox"; -import { opt, type TSchema } from "../contracts/index.ts"; +import { opt } from "../contracts/index.ts"; import { editWith, type Increment } from "../contracts/internal.ts"; +import { isOptional, type Optional } from "../contracts/schema.ts"; import { type ColumnSchema, type Columns, @@ -59,7 +59,7 @@ export interface TableEditor ext } /** A column `add` takes: `opt(...)` or one with a `default`. */ -export type AddedColumn = ColumnSchema & (TOptional | { readonly default: unknown }); +export type AddedColumn = ColumnSchema & (Optional | { readonly default: unknown }); /** A `rows` patch: new values of non-key columns; `undefined` clears an `opt(...)` one. */ export type PatchOf = { @@ -85,7 +85,7 @@ type JsonNames = { [P in keyof C]: ColumnTypeOf extends type WithJson = With< C, N, - C[N] extends TOptional ? TOptional : S + C[N] extends Optional ? Optional> : S >; /** `V + 1` for a literal version (contracts' `Increment`); `number` stays `number`. */ @@ -170,7 +170,7 @@ class Editor { this.#assertBefore("json"); const existing = describeColumns({ [name]: this.#assertColumn(name, "json") }, this.#what)[0]!; if (existing.type !== "json") throw new Error(`${this.#what}: json: column "${name}" is not a json(...) column`); - if (Object.hasOwn(column, "~optional")) { + if (isOptional(column)) { throw new Error(`${this.#what}: json: pass json(...) itself; the column keeps its nullability`); } const schema = existing.optional ? opt(column) : column; diff --git a/packages/chord/src/object.ts b/packages/chord/src/object.ts index 6bd1ee24f..d13df8475 100644 --- a/packages/chord/src/object.ts +++ b/packages/chord/src/object.ts @@ -1,5 +1,4 @@ -import type { Static, TSchema } from "typebox"; -import { Value } from "typebox/value"; +import type { Schema, Static } from "@earendil-works/pi-schema"; import { Cell, Derived, @@ -30,7 +29,7 @@ import { asChordError, ChordError, invalid, runAll } from "./errors.ts"; import { enqueue } from "./publish.ts"; import type { Registration, Registry } from "./registry.ts"; import type { RemoteHost } from "./remote.ts"; -import { assertValid, normalize } from "./schema.ts"; +import { assertValid, cleaned, normalize } from "./schema.ts"; import type { Ctx, Exposed, GetOptions, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; const noop = (): void => {}; @@ -161,7 +160,7 @@ export interface Self

{ * it when its version rises, and refuses a schema change without a version bump. A cell a reload adds runs its `init` * while the reload prepares. */ - state( + state( name: string, schema: S, initial: Static | StateInit, P>, @@ -317,7 +316,7 @@ type TaskFn = (ctx: Ctx) => Promise; /** Everything one factory run declared, by kind and key. */ export class Decls { readonly cells = new Map>(); - readonly cellSchemas = new Map(); + readonly cellSchemas = new Map(); readonly cellVersions = new Map(); readonly cellMigrations = new Map unknown>(); readonly refs = new Map(); @@ -1297,7 +1296,7 @@ export class ObjectImpl { /** Every state source exposed as a remote state member gets that member's schema (checked per write in dev mode). */ refreshSchemas(): void { - const schemas = new Map, TSchema[]>(); + const schemas = new Map, Schema[]>(); for (const installed of this.implementations.values()) { for (const [source, list] of memberSchemas(installed.service, installed.implementation)) { schemas.set(source, [...(schemas.get(source) ?? []), ...list]); @@ -1592,8 +1591,8 @@ export class ObjectImpl { // The additive rule assumes a consumer sends only what its own schema knows, and a provider sees only what its // schema knows: strip with both before validating. const own = ref.contract.scope === "remote" && wanted?.kind === "method" ? wanted.args : undefined; - const sent = own === undefined ? args : Value.Clean(own, Value.Clone(args)); - input = validated?.args ? Value.Clean(validated.args, Value.Clone(sent)) : sent; + const sent = own === undefined ? args : cleaned(own, args); + input = validated?.args ? cleaned(validated.args, sent) : sent; if (validated?.args) assertValid("args", `${this.address}.${name} args`, validated.args, input); } this.lease(ctx); @@ -1731,8 +1730,8 @@ const isReadableLike = (value: unknown): boolean => export function memberSchemas( service: Service, implementation: Record, -): Map, TSchema[]> { - const schemas = new Map, TSchema[]>(); +): Map, Schema[]> { + const schemas = new Map, Schema[]>(); const contract = service.contract; if (contract.scope !== "remote") return schemas; const members = contract.description.variants[variantOf(implementation) ?? ""] ?? {}; @@ -1794,7 +1793,7 @@ class SelfImpl implements Self { if (map.has(name)) throw invalid("duplicate", `${this.#object.typeName}: duplicate ${kind} name ${name}`); } - state( + state( name: string, schema: S, initial: Static | StateInit>, diff --git a/packages/chord/src/remote.ts b/packages/chord/src/remote.ts index 73c1e1d24..47e8ffa77 100644 --- a/packages/chord/src/remote.ts +++ b/packages/chord/src/remote.ts @@ -1,4 +1,3 @@ -import { Value } from "typebox/value"; import { HYDRATION, type Hydration } from "./cell.ts"; import { type AnyContract, takesNoArgs } from "./contract.ts"; import { applyImmutable, type Decoder, decoder } from "./delta/index.ts"; @@ -13,6 +12,7 @@ import { refsByProxy, type StatusListener, } from "./object.ts"; +import { cleaned } from "./schema.ts"; import type { Ctx, Readable, RefStatus, Shape, StateEvent } from "./types.ts"; import { fromWireError, type RootRef, type WireError } from "./wire.ts"; @@ -344,7 +344,7 @@ export class RemoteBinding { throw new ChordError("not_in_variant", `${this.key}: ${name} is not a method of variant ${variant}`); } // Send only what the consumer's own schema knows (the additive rule's premise; see ObjectImpl.call). - const sent = description.args === undefined ? undefined : Value.Clean(description.args, Value.Clone(args)); + const sent = description.args === undefined ? undefined : cleaned(description.args, args); return hold.channel.call( hold.id, name, diff --git a/packages/chord/src/schema.ts b/packages/chord/src/schema.ts index 7883969e5..527accd9a 100644 --- a/packages/chord/src/schema.ts +++ b/packages/chord/src/schema.ts @@ -1,8 +1,7 @@ -import type { TSchema } from "typebox"; -import { Value } from "typebox/value"; +import { compile, type JsonSchema, type ValidationError, type Validator } from "@earendil-works/pi-schema"; import { type ChordErrorReason, invalid } from "./errors.ts"; -/** JSON Schema as data. TypeBox schemas are plain JSON Schema objects. */ +/** JSON Schema as data: pi-schema builders, or any JSON Schema object (TypeBox's included). */ type Schema = { readonly [key: string]: unknown }; const asSchema = (schema: unknown): Schema => @@ -34,23 +33,187 @@ export function deepEqual(a: unknown, b: unknown): boolean { return a === b || canonicalJson(a) === canonicalJson(b); } +const validators = new WeakMap(); + +/** The compiled validator of `schema`, compiled once per schema object; `format` is an annotation. */ +function validatorOf(schema: JsonSchema): Validator { + if (typeof schema === "boolean") return compile(schema); + let validator = validators.get(schema); + if (validator === undefined) { + validator = compile(schema); + validators.set(schema, validator); + } + return validator; +} + +/** Whether `value` matches `schema`. */ +export function isValid(schema: JsonSchema, value: unknown): boolean { + return validatorOf(schema).check(value); +} + /** Throws `invalid` (with `reason`) and the first schema errors when `value` does not match. */ -export function assertValid(reason: ChordErrorReason, what: string, schema: TSchema, value: unknown): void { - if (Value.Check(schema, value)) return; - const errors = [...Value.Errors(schema, value)].slice(0, 3); - const summary = errors.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; "); +export function assertValid(reason: ChordErrorReason, what: string, schema: JsonSchema, value: unknown): void { + const validator = validatorOf(schema); + if (validator.check(value)) return; + const errors: ValidationError[] = validator.errors(value).slice(0, 3); + const summary = errors.map((error) => `${error.path || "/"} ${error.message}`).join("; "); throw invalid(reason, `${what}: ${summary}`, errors); } /** Applies defaults, strips unknown fields, validates. Returns a fresh value. */ -export function normalize(schema: TSchema, value: unknown, what: string): unknown { - const normalized = Value.Clean(schema, Value.Default(schema, Value.Clone(value ?? {}))); +export function normalize(schema: JsonSchema, value: unknown, what: string): unknown { + const normalized = cleanFrom(asSchema(schema), withDefaults(asSchema(schema), cloneData(value ?? {}))); assertValid("input", what, schema, normalized); return normalized; } +/** A copy of `value` with only what `schema` knows: unknown object fields dropped, recursively (TypeBox's `Clean`). */ +export function cleaned(schema: JsonSchema, value: unknown): unknown { + return cleanFrom(asSchema(schema), cloneData(value)); +} + +/** A deep copy of plain data: arrays, plain objects and bytes are copied, any other value is kept. */ +function cloneData(value: unknown): unknown { + if (Array.isArray(value)) return value.map(cloneData); + if (value instanceof Uint8Array) return value.slice(); + if (value === null || typeof value !== "object") return value; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + const copy: Record = {}; + for (const [key, item] of Object.entries(value)) copy[key] = cloneData(item); + return copy; +} + +const isPlainObject = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Uint8Array); + +const isSchemaObject = (value: unknown): value is Schema => + value !== null && typeof value === "object" && !Array.isArray(value); + +/** The object an `allOf` of object schemas evaluates to (TypeBox's `Evaluate`), or undefined for any other `allOf`. */ +function mergedAllOf(schema: Schema): Schema | undefined { + if (!Array.isArray(schema.allOf) || !schema.allOf.every((branch) => isSchemaObject(branch.properties))) + return undefined; + const properties: Record = {}; + const required = new Set(); + for (const branch of schema.allOf as Schema[]) { + Object.assign(properties, branch.properties); + for (const key of (branch.required as string[] | undefined) ?? []) required.add(key); + } + return { + type: "object", + properties, + required: [...required], + ...(schema.unevaluatedProperties === undefined ? {} : { additionalProperties: schema.unevaluatedProperties }), + }; +} + +/** The pattern and value schema of a record (`patternProperties` with one pattern). */ +function recordOf(schema: Schema): [RegExp, unknown] | undefined { + if (!isSchemaObject(schema.patternProperties) || isSchemaObject(schema.properties)) return undefined; + const [entry] = Object.entries(schema.patternProperties); + return entry === undefined ? undefined : [new RegExp(entry[0], "u"), entry[1]]; +} + +/** Whether `additional` (an object's `additionalProperties`) keeps an unknown field holding `value`. */ +const keepsUnknown = (additional: unknown, value: unknown): boolean => + additional === true || (isSchemaObject(additional) && isValid(additional, value)); + +// Mutates `value` (a fresh copy), as TypeBox's `Clean` does. +function cleanFrom(schema: Schema, value: unknown): unknown { + const merged = mergedAllOf(schema); + if (merged !== undefined) return cleanFrom(merged, value); + if (Array.isArray(schema.anyOf)) { + for (const branch of schema.anyOf) { + const candidate = cleanFrom(asSchema(branch), cloneData(value)); + if (isValid(asSchema(branch), candidate)) return candidate; + } + return value; + } + if (Array.isArray(value)) { + const prefix = Array.isArray(schema.items) + ? schema.items + : Array.isArray(schema.prefixItems) + ? schema.prefixItems + : undefined; + if (prefix !== undefined) { + const length = Math.min(value.length, prefix.length); + for (let index = 0; index < length; index++) value[index] = cleanFrom(asSchema(prefix[index]), value[index]); + return value.length > length ? value.slice(0, length) : value; + } + if (isSchemaObject(schema.items)) return value.map((item) => cleanFrom(schema.items as Schema, item)); + return value; + } + if (!isPlainObject(value)) return value; + const record = recordOf(schema); + const properties = isSchemaObject(schema.properties) ? schema.properties : undefined; + // An object schema names its fields, a record its key pattern, a map (pi-schema's) its value schema. + if (properties === undefined && record === undefined && !isSchemaObject(schema.additionalProperties)) return value; + for (const key of Object.keys(value)) { + const known = properties !== undefined && Object.hasOwn(properties, key) ? properties[key] : undefined; + const matched = known ?? (record?.[0].test(key) ? record[1] : undefined); + if (matched !== undefined) value[key] = cleanFrom(asSchema(matched), value[key]); + else if (keepsUnknown(schema.additionalProperties, value[key])) + value[key] = cleanFrom(asSchema(schema.additionalProperties), value[key]); + else delete value[key]; + } + return value; +} + +// Mutates `value` (a fresh copy), as TypeBox's `Default` does: a missing value takes its schema's default, a +// missing optional field never does. +function withDefaults(schema: Schema, value: unknown): unknown { + const defaulted = value === undefined && "default" in schema ? cloneData(schema.default) : value; + const merged = mergedAllOf(schema); + if (merged !== undefined) return withDefaults(merged, defaulted); + if (Array.isArray(schema.anyOf)) { + for (const branch of schema.anyOf) { + const candidate = withDefaults(asSchema(branch), cloneData(defaulted)); + if (isValid(asSchema(branch), candidate)) return candidate; + } + return defaulted; + } + if (Array.isArray(defaulted)) { + const prefix = Array.isArray(schema.items) + ? schema.items + : Array.isArray(schema.prefixItems) + ? schema.prefixItems + : undefined; + if (prefix !== undefined) { + for (let index = 0; index < prefix.length; index++) { + const item = withDefaults(asSchema(prefix[index]), defaulted[index]); + if (index < defaulted.length || item !== undefined) defaulted[index] = item; + } + return defaulted; + } + if (isSchemaObject(schema.items)) return defaulted.map((item) => withDefaults(schema.items as Schema, item)); + return defaulted; + } + if (!isPlainObject(defaulted)) return defaulted; + const properties = isSchemaObject(schema.properties) ? schema.properties : undefined; + const record = recordOf(schema); + if (properties !== undefined) { + const required = (schema.required as string[] | undefined) ?? []; + for (const [key, property] of Object.entries(properties)) { + const item = withDefaults(asSchema(property), defaulted[key]); + if (item === undefined && (!required.includes(key) || !("default" in asSchema(property)))) continue; + defaulted[key] = item; + } + } else if (record !== undefined && isSchemaObject(record[1]) && "default" in record[1]) { + for (const key of Object.keys(defaulted)) { + if (record[0].test(key)) defaulted[key] = withDefaults(record[1], defaulted[key]); + } + } + if (!isSchemaObject(schema.additionalProperties)) return defaulted; + for (const key of Object.keys(defaulted)) { + const known = properties !== undefined && Object.hasOwn(properties, key); + if (!known && !record?.[0].test(key)) defaulted[key] = withDefaults(schema.additionalProperties, defaulted[key]); + } + return defaulted; +} + /** Canonical identity of a normalized value: fields equal to their schema default are omitted. */ -export function canonicalIdentity(schema: TSchema, value: unknown): string { +export function canonicalIdentity(schema: JsonSchema, value: unknown): string { return canonicalJson(stripDefaults(asSchema(schema), value)); } diff --git a/packages/chord/src/wire.ts b/packages/chord/src/wire.ts index b9271f130..bd4bdf175 100644 --- a/packages/chord/src/wire.ts +++ b/packages/chord/src/wire.ts @@ -1,5 +1,21 @@ -import { type Static, type TSchema, Type } from "typebox"; -import { Value } from "typebox/value"; +import { + arr, + bool, + compile, + int, + lit, + map, + num, + obj, + oneOf, + opt, + type Properties, + type Schema, + type Static, + str, + union, + unknown, +} from "@earendil-works/pi-schema"; import { DescriptionSchema } from "./contract.ts"; import { CHORD_ERROR_CODES, ChordError } from "./errors.ts"; @@ -23,55 +39,57 @@ import { CHORD_ERROR_CODES, ChordError } from "./errors.ts"; */ export const PROTOCOL_VERSION = 4; -const Id = Type.Integer({ minimum: 1 }); -const Name = Type.String({ minLength: 1 }); -const Reload = Type.Integer({ minimum: 1 }); -/** Arrays a sender may pass as readonly. */ -const List = (item: T) => Type.Immutable(Type.Array(item)); +const Id = int({ minimum: 1 }); +const Name = str({ minLength: 1 }); +const Reload = int({ minimum: 1 }); +/** Arrays a sender may pass as readonly: the same schema, a readonly type. */ +const List = (item: Schema) => arr(item) as Schema; +/** An object that admits fields it does not name (a newer peer's), as every frame does. */ +const Open = (properties: P) => obj(properties, { open: true }); -const RootRefSchema = Type.Object({ rootId: Name, codeVersion: Type.Integer({ minimum: 1 }) }); +const RootRefSchema = Open({ rootId: Name, codeVersion: int({ minimum: 1 }) }); export type RootRef = Static; -const WireErrorSchema = Type.Object({ - code: Type.Enum(CHORD_ERROR_CODES), - message: Type.String(), - reason: Type.Optional(Type.String()), +const WireErrorSchema = Open({ + code: oneOf(CHORD_ERROR_CODES), + message: str(), + reason: opt(str()), }); export type WireError = Static; -const WireReloadOptionsSchema = Type.Object({ - remove: Type.Optional(List(Type.String())), - gateDeadline: Type.Optional(Type.Number()), - retireDeadline: Type.Optional(Type.Number()), +const WireReloadOptionsSchema = Open({ + remove: opt(List(str())), + gateDeadline: opt(num()), + retireDeadline: opt(num()), }); export type WireReloadOptions = Static; /** A child's prepared reload, sent with its vote (same fields as `ReloadReport`, without children). */ -const WireReloadReportSchema = Type.Object({ - codeVersion: Type.Integer(), - previous: Type.Integer(), - changed: List(Type.String()), - removed: List(Type.String()), - prepared: List(Type.String()), - gated: List(Type.String()), +const WireReloadReportSchema = Open({ + codeVersion: int(), + previous: int(), + changed: List(str()), + removed: List(str()), + prepared: List(str()), + gated: List(str()), }); export type WireReloadReport = Static; -const FrameSchema = Type.Union([ - Type.Object({ - t: Type.Literal("hello"), +const FrameSchema = union([ + Open({ + t: lit("hello"), /** The link protocol version (`PROTOCOL_VERSION`; absent: 1). Peers of another version close the link. */ - protocol: Type.Optional(Type.Integer({ minimum: 1 })), + protocol: opt(int({ minimum: 1 })), name: Name, - kind: Type.Union([Type.Literal("host"), Type.Literal("client")]), - codeVersion: Type.Integer({ minimum: 0 }), + kind: union([lit("host"), lit("client")]), + codeVersion: int({ minimum: 0 }), /** The sender acknowledges what it receives (`ack`). Flow control applies only when both hellos say so. */ - acks: Type.Optional(Type.Boolean()), + acks: opt(bool()), }), /** Every frame received so far except `ack` frames, counted from the link's start, once processed. */ - Type.Object({ t: Type.Literal("ack"), n: Type.Integer({ minimum: 1 }) }), - Type.Object({ - t: Type.Literal("hold"), + Open({ t: lit("ack"), n: int({ minimum: 1 }) }), + Open({ + t: lit("hold"), id: Id, /** * Target host name, resolved by the consumer from the token's `host` and `input`. Relays route on it after @@ -81,92 +99,92 @@ const FrameSchema = Type.Union([ /** The consumer's contract token; the provider checks that it covers it. Remote tokens carry `host`. */ token: DescriptionSchema, /** Normalized with the consumer's contract. */ - input: Type.Unknown(), + input: unknown(), /** A forwarded principal. Honored only on frames from the parent link. */ - principal: Type.Optional(Type.Unknown()), + principal: opt(unknown()), /** * The client connection the hold comes from: an opaque id the server assigned to it, forwarded by relays. Honored * only on frames from the parent link; present means the hold is a client's (not a reload participant). */ - origin: Type.Optional(Name), + origin: opt(Name), /** False: no state for this hold until `sub` (a method-only binding). */ - state: Type.Optional(Type.Literal(false)), + state: opt(lit(false)), /** The consumer already has values (a re-hold after a link loss): its unit is not urgent. */ - again: Type.Optional(Type.Literal(true)), + again: opt(lit(true)), }), - Type.Object({ t: Type.Literal("release"), id: Id }), - Type.Object({ - t: Type.Literal("shape"), + Open({ t: lit("release"), id: Id }), + Open({ + t: lit("shape"), id: Id, - variant: Type.Optional(Type.String()), - members: List(Type.String()), - contract: Type.String(), + variant: opt(str()), + members: List(str()), + contract: str(), /** The state members whose snapshots follow this frame; the consumer applies the shape once they are here. */ state: List(Name), }), - Type.Object({ t: Type.Literal("gone"), id: Id, error: WireErrorSchema }), - Type.Object({ - t: Type.Literal("call"), + Open({ t: lit("gone"), id: Id, error: WireErrorSchema }), + Open({ + t: lit("call"), id: Id, hold: Id, member: Name, - args: Type.Optional(Type.Unknown()), + args: opt(unknown()), /** Absent: the receiving host starts a root (browsers never carry roots). */ - root: Type.Optional(RootRefSchema), + root: opt(RootRefSchema), }), - Type.Object({ - t: Type.Literal("result"), + Open({ + t: lit("result"), id: Id, - value: Type.Optional(Type.Unknown()), - error: Type.Optional(WireErrorSchema), + value: opt(unknown()), + error: opt(WireErrorSchema), }), - Type.Object({ t: Type.Literal("cancel"), id: Id }), + Open({ t: lit("cancel"), id: Id }), /** Starts the state of a hold opened with `state: false` (answered with a unit). */ - Type.Object({ t: Type.Literal("sub"), hold: Id }), + Open({ t: lit("sub"), hold: Id }), /** Stops the state of a hold. */ - Type.Object({ t: Type.Literal("unsub"), hold: Id }), + Open({ t: lit("unsub"), hold: Id }), /** Sends member `m` again as a reset (a relay that had to drop what waited for it). */ - Type.Object({ t: Type.Literal("resync"), hold: Id, m: Name }), - Type.Object({ - t: Type.Literal("state"), + Open({ t: lit("resync"), hold: Id, m: Name }), + Open({ + t: lit("state"), hold: Id, m: Name, /** `snap`/`reset` without `value`: the value is `undefined`. */ - as: Type.Union([Type.Literal("snap"), Type.Literal("reset"), Type.Literal("ops"), Type.Literal("error")]), - value: Type.Optional(Type.Unknown()), + as: union([lit("snap"), lit("reset"), lit("ops"), lit("error")]), + value: opt(unknown()), /** Encoded with the stream's delta encoder; the decoder validates each op. */ - ops: Type.Optional(List(List(Type.Unknown()))), - error: Type.Optional(WireErrorSchema), + ops: opt(List(List(unknown()))), + error: opt(WireErrorSchema), }), - Type.Object({ t: Type.Literal("ping"), n: Type.Integer() }), - Type.Object({ t: Type.Literal("pong"), n: Type.Integer() }), + Open({ t: lit("ping"), n: int() }), + Open({ t: lit("pong"), n: int() }), /** Ends the link (consumed by the link itself). `reason: "protocol-version "`: the sender speaks version n. */ - Type.Object({ t: Type.Literal("bye"), reason: Type.Optional(Type.String()) }), + Open({ t: lit("bye"), reason: opt(str()) }), /** The root ended at its origin: its leases on objects end (sent on every link the root's calls used). */ - Type.Object({ t: Type.Literal("root_end"), rootId: Name }), - Type.Object({ t: Type.Literal("invalidate"), pattern: Type.Record(Type.String(), Type.Unknown()) }), - Type.Object({ - t: Type.Literal("prepare"), + Open({ t: lit("root_end"), rootId: Name }), + Open({ t: lit("invalidate"), pattern: map(unknown()) }), + Open({ + t: lit("prepare"), reload: Reload, - codeVersion: Type.Integer({ minimum: 1 }), - plugins: List(Type.String()), + codeVersion: int({ minimum: 1 }), + plugins: List(str()), options: WireReloadOptionsSchema, }), - Type.Object({ - t: Type.Literal("vote"), + Open({ + t: lit("vote"), reload: Reload, - error: Type.Optional(WireErrorSchema), - report: Type.Optional(WireReloadReportSchema), + error: opt(WireErrorSchema), + report: opt(WireReloadReportSchema), }), - Type.Object({ t: Type.Literal("commit"), reload: Reload }), + Open({ t: lit("commit"), reload: Reload }), /** The parent dropped `codeVersion`: roots of it may still come from there no longer. */ - Type.Object({ t: Type.Literal("retire"), codeVersion: Type.Integer({ minimum: 1 }) }), - Type.Object({ t: Type.Literal("abort"), reload: Reload }), + Open({ t: lit("retire"), codeVersion: int({ minimum: 1 }) }), + Open({ t: lit("abort"), reload: Reload }), ]); export type Frame = Static; /** A frame that passes validation; anything else is a protocol violation. */ -export const isFrame = (value: unknown): value is Frame => Value.Check(FrameSchema, value); +export const isFrame: (value: unknown) => value is Frame = compile(FrameSchema).check; export function toWireError(error: unknown): WireError { if (error instanceof ChordError) { diff --git a/packages/chord/test/awaiting.test.ts b/packages/chord/test/awaiting.test.ts index 655d66736..975cb31f8 100644 --- a/packages/chord/test/awaiting.test.ts +++ b/packages/chord/test/awaiting.test.ts @@ -1,7 +1,7 @@ // Entry 8: awaited gets. `host.get` and `ctx.get` resolve once the reference is ready; abort, the ctx closing and the // host's `readyTimeout` reject them and release only that reference. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, createHost, type Host, hostNamed, isChordError, memoryLinkPair, remote } from "../src/index.ts"; import { CountingLink } from "./fixtures/counting.ts"; @@ -14,9 +14,9 @@ async function until(check: () => boolean, attempts = 200): Promise { const Slow = remote("t.e8-slow", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { n: Type.Number() } } }, + variants: { main: { state: { n: num() } } }, }); /** A server whose `Slow` object goes live when `open()` is called; a browser whose link counts frames. */ @@ -28,7 +28,7 @@ function setup(readyTimeout?: number) { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Slow, (_input, _principal, self) => { self.resource("gate", { version: 1, open: () => opened }); - return { variant: "main", n: self.state("n", Type.Number(), 1) }; + return { variant: "main", n: self.state("n", num(), 1) }; }); const [down, up] = memoryLinkPair(); server.accept(down, { principal: { user: "u" } }); diff --git a/packages/chord/test/bindings.test.ts b/packages/chord/test/bindings.test.ts index 6e7d9b9b6..e3e642ebe 100644 --- a/packages/chord/test/bindings.test.ts +++ b/packages/chord/test/bindings.test.ts @@ -1,7 +1,7 @@ // Entry 8: one shared binding per host and key; a hold implies the state of its variant (units: shape, then // snapshots); method-only references; origins of client holds; protocol version 4. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, nul, num, obj, opt, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { HostImpl } from "../src/host.ts"; import { @@ -28,15 +28,15 @@ async function until(check: () => boolean, attempts = 200): Promise { assert.ok(check(), "condition not reached"); } -const NoteSchema = Type.Array(Type.String()); +const NoteSchema = arr(str()); const Notes = remote("t.e8-notes", { version: 1, - input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }), + input: obj({ list: opt(str({ default: "main" })) }, { open: true }), host: hostNamed("server"), variants: { main: { - state: { notes: NoteSchema, title: Type.String() }, - methods: { add: method(Type.String()), rename: method(Type.String()) }, + state: { notes: NoteSchema, title: str() }, + methods: { add: method(str()), rename: method(str()) }, }, }, }); @@ -44,7 +44,7 @@ const Notes = remote("t.e8-notes", { function serveNotes(host: Host): void { host.serve(Notes, (_input, _principal, self) => { const notes = self.state("notes", NoteSchema, []); - const title = self.state("title", Type.String(), "untitled"); + const title = self.state("title", str(), "untitled"); return { variant: "main", notes, @@ -88,7 +88,7 @@ function setup( describe("shared bindings: traffic", () => { it("N references to one object: one hold, one snapshot per member, one frame per change, one release", async () => { const { server, browser, link } = setup(); - const Panel = local("t.e8-panel", { version: 1, input: Type.Object({}), members: { count: Type.Number() } }); + const Panel = local("t.e8-panel", { version: 1, input: obj({}, { open: true }), members: { count: num() } }); browser.serve(Panel, (_input, _principal, self) => { const notes = self.get(Notes, {}); return { count: self.derive([notes.notes], (list) => list.length) }; @@ -183,12 +183,12 @@ describe("shared bindings: identity", () => { }); const NotesV2 = remote("t.e8-notes", { version: 2, - input: Type.Object({ list: Type.Optional(Type.String({ default: "main" })) }), + input: obj({ list: opt(str({ default: "main" })) }, { open: true }), host: hostNamed("server"), variants: { main: { - state: { notes: NoteSchema, title: Type.String() }, - methods: { add: method(Type.String()), rename: method(Type.String()), clear: method() }, + state: { notes: NoteSchema, title: str() }, + methods: { add: method(str()), rename: method(str()), clear: method() }, }, }, }); @@ -196,7 +196,7 @@ describe("shared bindings: identity", () => { NotesV2, (_input, _principal, self) => { const notes = self.state("notes", NoteSchema, []); - const title = self.state("title", Type.String(), "untitled"); + const title = self.state("title", str(), "untitled"); return { variant: "main", notes, title, add: () => {}, rename: () => {}, clear: () => {} }; }, { accepts: { 1: (current) => current } }, @@ -230,12 +230,12 @@ describe("shared bindings: identity", () => { describe("hydration over a link", () => { const Matrix = remote("t.e8-matrix", { version: 1, - input: Type.Object({ variant: Type.String() }), + input: obj({ variant: str() }, { open: true }), host: hostNamed("server"), variants: { - values: { state: { none: Type.Null(), empty: Type.Array(Type.String()), maybe: Type.Unknown() } }, - bare: { methods: { ping: method(undefined, Type.String()) } }, - broken: { state: { n: Type.Number() } }, + values: { state: { none: nul(), empty: arr(str()), maybe: unknown() } }, + bare: { methods: { ping: method(undefined, str()) } }, + broken: { state: { n: num() } }, }, }); @@ -243,12 +243,12 @@ describe("hydration over a link", () => { host.serve(Matrix, ({ variant }, _principal, self) => { if (variant === "bare") return { variant: "bare", ping: () => "pong" }; if (variant === "broken") { - const source = self.state("source", Type.String(), "not a number"); + const source = self.state("source", str(), "not a number"); // A derivation exposed under a number schema: its snapshot fails validation. return { variant: "broken", n: self.derive([source], (text) => text as unknown as number) }; } - const none = self.state("none", Type.Null(), null); - const empty = self.state("empty", Type.Array(Type.String()), []); + const none = self.state("none", nul(), null); + const empty = self.state("empty", arr(str()), []); return { variant: "values", none, empty, maybe: self.derive([none], () => undefined as unknown) }; }); } @@ -332,9 +332,9 @@ describe("method-only references (state: false)", () => { describe("origins of client holds", () => { const Counter = remote("t.e8-counter", { version: 1, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }, { open: true }), host: hostFamily("session:", "session"), - variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } }, + variants: { main: { state: { n: num() }, methods: { add: method(num()) } } }, }); function cluster() { @@ -354,7 +354,7 @@ describe("origins of client holds", () => { onError: () => {}, }); created.serve(Counter, (_input, _principal, self) => { - const n = self.state("n", Type.Number(), 0); + const n = self.state("n", num(), 0); return { variant: "main", n, diff --git a/packages/chord/test/boundary.test.ts b/packages/chord/test/boundary.test.ts index 8819a01c1..ae4f1beb4 100644 --- a/packages/chord/test/boundary.test.ts +++ b/packages/chord/test/boundary.test.ts @@ -8,10 +8,11 @@ const sourceDirectory = resolve(packageDirectory, "src"); const IMPORT_SPECIFIER = /(?:import|export)\s+(?:type\s+)?(?:[^;]*?\sfrom\s*)?["']([^"']+)["']/gu; describe("package boundary", () => { - // pi-telemetry is the one allowed Pi package: a leaf without dependencies, needed for Context.telemetry. - const ALLOWED = new Set(["@earendil-works/pi-telemetry"]); + // The allowed Pi packages are leaves without dependencies: pi-telemetry for Context.telemetry, pi-schema for schemas + // (decision "A schema package beneath pi-ai and Chord"). + const ALLOWED = new Set(["@earendil-works/pi-telemetry", "@earendil-works/pi-schema"]); - test("does not depend on Pi packages other than pi-telemetry, or on files outside Chord", async () => { + test("does not depend on Pi packages other than pi-telemetry and pi-schema, or on files outside Chord", async () => { const manifest = JSON.parse(await readFile(resolve(packageDirectory, "package.json"), "utf8")) as { dependencies?: Record; }; diff --git a/packages/chord/test/cluster.test.ts b/packages/chord/test/cluster.test.ts index 1547c3e1f..b0ad5dc97 100644 --- a/packages/chord/test/cluster.test.ts +++ b/packages/chord/test/cluster.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { describe, it, onTestFinished } from "vitest"; import { type Ctx, @@ -30,32 +30,32 @@ function latch() { const Greeter = remote("t.greeter", { version: 1, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }, { open: true }), host: hostFamily("session:", "session"), variants: { main: { - state: { count: Type.Object({ n: Type.Number() }) }, - methods: { greet: method(Type.String(), Type.String()) }, + state: { count: obj({ n: num() }, { open: true }) }, + methods: { greet: method(str(), str()) }, }, }, }); const GreeterV2 = remote("t.greeter", { version: 2, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }, { open: true }), host: hostFamily("session:", "session"), - variants: { main: { methods: { hello: method(Type.String(), Type.String()) } } }, + variants: { main: { methods: { hello: method(str(), str()) } } }, }); /** The server's own greeter: the greeter facet serves this one on the server, `Greeter` on session hosts. */ const ServerGreeter = remote("t.server-greeter", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: { - state: { count: Type.Object({ n: Type.Number() }) }, - methods: { greet: method(Type.String(), Type.String()) }, + state: { count: obj({ n: num() }, { open: true }) }, + methods: { greet: method(str(), str()) }, }, }, }); @@ -68,7 +68,7 @@ function greeterFacet(greeting: string, options: { fail?: boolean; stateVersion? if (options.fail) throw new Error("worker factory fails"); const count = self.state( "count", - Type.Object({ n: Type.Number() }), + obj({ n: num() }, { open: true }), { n: 0 }, { version: options.stateVersion ?? 1 }, ); @@ -105,9 +105,9 @@ function dropping(link: Link, drop: (frame: Frame) => boolean): Link { const Ping = remote("t.cluster-ping", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { ping: method(undefined, Type.String()) } } }, + variants: { main: { methods: { ping: method(undefined, str()) } } }, }); function setup() { @@ -314,7 +314,7 @@ describe("cross-host reload", () => { }, close: () => void log.push("closed"), }); - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); return { variant: "main", count, greet: (name: string) => `slow ${name}` }; }), }; @@ -569,7 +569,7 @@ describe("cross-host reload", () => { const never = latch(); const Slow = remote("t.cluster-slow", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), variants: { main: { methods: { wait: method() } } }, }); @@ -590,7 +590,7 @@ describe("cross-host reload", () => { resolve: async () => watched, principal: (name) => ({ host: name }), }); - const Store = local("t.cluster-store", { version: 1, input: Type.Object({}) }); + const Store = local("t.cluster-store", { version: 1, input: obj({}, { open: true }) }); const storeFacet = (version: number): Facet => ({ name: "store", setup: (api) => diff --git a/packages/chord/test/contract.test.ts b/packages/chord/test/contract.test.ts index df29a8a0b..0abcc1a67 100644 --- a/packages/chord/test/contract.test.ts +++ b/packages/chord/test/contract.test.ts @@ -1,25 +1,30 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { bool, num, obj, opt, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { bind, coverage, hostFamily, hostNamed, isChordError, local, method, opaque, remote } from "../src/index.ts"; import { canonicalIdentity, fits, normalize } from "../src/schema.ts"; -const View = Type.Object({ text: Type.String() }); +const View = obj({ text: str() }, { open: true }); const chatV1 = remote("t.chat", { version: 1, - input: Type.Object({ session: Type.String(), conversation: Type.Number({ default: 0 }) }), + input: obj({ session: str(), conversation: num({ default: 0 }) }, { open: true }), host: hostFamily("session:", "session"), variants: { viewer: { state: { view: View } }, participant: { state: { view: View }, - methods: { send: method(Type.Object({ text: Type.String() }), Type.Object({ id: Type.Number() })) }, + methods: { send: method(obj({ text: str() }, { open: true }), obj({ id: num() }, { open: true })) }, }, }, }); describe("contracts", () => { + it("treats `format` as an annotation: an input with format email accepts any string", () => { + const Mail = local("t.mail", { version: 1, input: obj({ to: str({ format: "email" }) }) }); + assert.deepEqual(normalize(Mail.input, { to: "nope" }, "input"), { to: "nope" }); + }); + it("normalizes input and omits defaults from the identity", () => { const a = normalize(chatV1.input, { session: "s", extra: 1 }, "input"); assert.deepEqual(a, { session: "s", conversation: 0 }); @@ -39,12 +44,12 @@ describe("contracts", () => { }); it("rejects reserved member names and opaque remote members", () => { - assert.throws(() => local("t.x", { version: 1, input: Type.Object({}), members: { status: method() } })); + assert.throws(() => local("t.x", { version: 1, input: obj({}, { open: true }), members: { status: method() } })); assert.throws(() => remote("t.x", { host: hostNamed("server"), version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), variants: { a: { state: { h: opaque("x@1") as never } } }, }), ); @@ -56,11 +61,11 @@ describe("contracts", () => { variants: { ...chatV1.def.variants, participant: { - state: { view: Type.Object({ text: Type.String(), extra: Type.Number() }) }, + state: { view: obj({ text: str(), extra: num() }, { open: true }) }, methods: { send: method( - Type.Object({ text: Type.String(), urgent: Type.Optional(Type.Boolean()) }), - Type.Object({ id: Type.Number(), at: Type.Number() }), + obj({ text: str(), urgent: opt(bool()) }, { open: true }), + obj({ id: num(), at: num() }, { open: true }), ), }, }, @@ -77,8 +82,8 @@ describe("contracts", () => { state: { view: View }, methods: { send: method( - Type.Object({ text: Type.String(), urgent: Type.Boolean() }), - Type.Object({ id: Type.Number() }), + obj({ text: str(), urgent: bool() }, { open: true }), + obj({ id: num() }, { open: true }), ), }, }, @@ -92,18 +97,18 @@ describe("contracts", () => { const retyped = remote("t.chat", { ...chatV1.def, - variants: { ...chatV1.def.variants, viewer: { state: { view: Type.Object({ text: Type.Number() }) } } }, + variants: { ...chatV1.def.variants, viewer: { state: { view: obj({ text: num() }, { open: true }) } } }, }); assert.match(coverage(retyped.description, chatV1.description) ?? "", /state/); }); it("methods without arguments or result: described without them; their presence must match, a void consumer accepts a result", () => { const of = (members: Parameters[1]["members"]) => - local("t.methods", { version: 1, input: Type.Object({}), members }).description; + local("t.methods", { version: 1, input: obj({}, { open: true }), members }).description; const none = of({ m: method() }); assert.deepEqual(none.variants[""]?.m, { kind: "method" }); - const withArgs = of({ m: method(Type.String()) }); - const withResult = of({ m: method(undefined, Type.Number()) }); + const withArgs = of({ m: method(str()) }); + const withResult = of({ m: method(undefined, num()) }); assert.equal(coverage(none, none), undefined); assert.match(coverage(withArgs, none) ?? "", /arguments/, "the consumer sends none, the provider expects some"); assert.match(coverage(none, withArgs) ?? "", /arguments/, "the consumer sends some, the provider takes none"); @@ -124,15 +129,25 @@ describe("contracts", () => { it("compares opaque members by name@version and function members structurally", () => { const a = local("t.idx", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { harness: opaque("h@1"), - onChange: method(Type.Function([Type.String()], Type.Void())), + onChange: method({ type: "function", parameters: [str()], returnType: { type: "void" } }), }, }); const b = local("t.idx", { ...a.def, members: { ...a.def.members, harness: opaque("h@2") } }); assert.match(coverage(b.description, a.description) ?? "", /opaque/); - assert.ok(fits(Type.Function([Type.Unknown()], Type.Void()), Type.Function([Type.String()], Type.Void()))); - assert.ok(!fits(Type.Function([Type.String()], Type.Void()), Type.Function([Type.Unknown()], Type.Void()))); + assert.ok( + fits( + { type: "function", parameters: [unknown()], returnType: { type: "void" } }, + { type: "function", parameters: [str()], returnType: { type: "void" } }, + ), + ); + assert.ok( + !fits( + { type: "function", parameters: [str()], returnType: { type: "void" } }, + { type: "function", parameters: [unknown()], returnType: { type: "void" } }, + ), + ); }); }); diff --git a/packages/chord/test/fixtures/echo.ts b/packages/chord/test/fixtures/echo.ts index ebe12e0b9..20c81af77 100644 --- a/packages/chord/test/fixtures/echo.ts +++ b/packages/chord/test/fixtures/echo.ts @@ -1,11 +1,11 @@ -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { hostNamed, method, remote } from "../../src/index.ts"; export const Echo = remote("t.echo", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("w1"), variants: { - main: { methods: { echo: method(Type.String(), Type.String()), pid: method(undefined, Type.Number()) } }, + main: { methods: { echo: method(str(), str()), pid: method(undefined, num()) } }, }, }); diff --git a/packages/chord/test/fixtures/loader.ts b/packages/chord/test/fixtures/loader.ts index cded6d162..465114bca 100644 --- a/packages/chord/test/fixtures/loader.ts +++ b/packages/chord/test/fixtures/loader.ts @@ -2,11 +2,11 @@ import assert from "node:assert/strict"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { Type } from "typebox"; +import { bool, obj, str } from "@earendil-works/pi-schema"; import { createHost, local, method } from "../../src/index.ts"; import { nodeLoader } from "../../src/loader.ts"; -// Plugin modules live under the package so bare imports (typebox) resolve; removed afterwards. +// Plugin modules live under the package so bare imports (pi-schema) resolve; removed afterwards. const dir = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}/`, import.meta.url)); // Separate from `dir`, which becomes a reload root once its facet loads. const plugins = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}-order/`, import.meta.url)); @@ -15,19 +15,19 @@ const chord = new URL("../../src/index.ts", import.meta.url).href; // The test's own copy of the plugin's contract: binding is by description, not object identity. const Hello = local("t.hello", { version: 1, - input: Type.Object({}), - members: { greet: method(Type.String(), Type.String()) }, + input: obj({}, { open: true }), + members: { greet: method(str(), str()) }, }); const facetSource = ` -import { Type } from "typebox"; +import { obj, str } from "@earendil-works/pi-schema"; import { defineFacet, local, method } from ${JSON.stringify(chord)}; import { greeting } from "./greeting.ts"; const Hello = local("t.hello", { version: 1, - input: Type.Object({}), - members: { greet: method(Type.String(), Type.String()) }, + input: obj({}, { open: true }), + members: { greet: method(str(), str()) }, }); export default defineFacet((host) => { @@ -55,8 +55,8 @@ try { // A facet listed first that imports a later facet's directory shares that facet's copy of the module. const Shared = local("t.shared", { version: 1, - input: Type.Object({}), - members: { same: method(undefined, Type.Boolean()) }, + input: obj({}, { open: true }), + members: { same: method(undefined, bool()) }, }); mkdirSync(`${plugins}a`, { recursive: true }); mkdirSync(`${plugins}b`, { recursive: true }); @@ -67,8 +67,8 @@ try { ); writeFileSync( `${plugins}a/facet.ts`, - `import { Type } from "typebox";\nimport { defineFacet, local, method } from ${JSON.stringify(chord)};\nimport { state } from "../b/state.ts";\n` + - `const Shared = local("t.shared", { version: 1, input: Type.Object({}), members: { same: method(undefined, Type.Boolean()) } });\n` + + `import { bool, obj } from "@earendil-works/pi-schema";\nimport { defineFacet, local, method } from ${JSON.stringify(chord)};\nimport { state } from "../b/state.ts";\n` + + `const Shared = local("t.shared", { version: 1, input: obj({}, { open: true }), members: { same: method(undefined, bool()) } });\n` + `export default defineFacet((host) => host.serve(Shared, () => ({ same: async () => (await import(new URL("../b/facet.ts", import.meta.url).href)).own === state })));\n`, ); const ordered = createHost({ self: { kind: "host" }, loader: nodeLoader, onError: () => {} }); diff --git a/packages/chord/test/fixtures/schemas.ts b/packages/chord/test/fixtures/schemas.ts new file mode 100644 index 000000000..a21a9e09e --- /dev/null +++ b/packages/chord/test/fixtures/schemas.ts @@ -0,0 +1,14 @@ +// Schemas with the exact JSON TypeBox's `Type.Literal` and `Type.Record` built, so contracts declared with them keep +// the fingerprints they had under TypeBox (see the migration notes in CHANGELOG.md). + +import type { Schema } from "@earendil-works/pi-schema"; + +/** `Type.Literal(value)`: the literal with its JSON type (pi-schema's `lit` has no `type`). */ +export function typedLit(value: V): Schema { + return { type: typeof value, const: value }; +} + +/** `Type.Record(Type.String(), value)`: one pattern property for every key (pi-schema's `map` uses `additionalProperties`). */ +export function record(value: Schema): Schema> { + return { type: "object", patternProperties: { "^.*$": value } }; +} diff --git a/packages/chord/test/flow-units.test.ts b/packages/chord/test/flow-units.test.ts index ab578f178..0fd9c984f 100644 --- a/packages/chord/test/flow-units.test.ts +++ b/packages/chord/test/flow-units.test.ts @@ -1,7 +1,7 @@ // Entry 8: flow control with units. First units pass the window, so a get never waits behind other streams; waiting // ops are capped per link; relays ask downstream for a reset when they drop ops. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, num, obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { HostImpl } from "../src/host.ts"; import { @@ -26,12 +26,12 @@ async function until(check: () => boolean, attempts = 400): Promise { const Blob = remote("t.e8-blob", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }, { open: true }), host: hostNamed("server"), variants: { main: { - state: { lines: Type.Array(Type.String()) }, - methods: { push: method(Type.String()), ping: method(undefined, Type.String()) }, + state: { lines: arr(str()) }, + methods: { push: method(str()), ping: method(undefined, str()) }, }, }, }); @@ -41,7 +41,7 @@ const lines = (kib: number, tag: string) => Array.from({ length: kib }, (_, i) = function serveBlob(host: Host, size: (name: string) => number): void { host.serve(Blob, ({ name }, _principal, self) => { - const state = self.state("lines", Type.Array(Type.String()), lines(size(name), name)); + const state = self.state("lines", arr(str()), lines(size(name), name)); return { variant: "main", lines: state, @@ -169,9 +169,9 @@ describe("flow control with units", () => { describe("relay topology", () => { const Counter = remote("t.e8-relayed", { version: 1, - input: Type.Object({ session: Type.String() }), + input: obj({ session: str() }, { open: true }), host: hostFamily("session:", "session"), - variants: { main: { state: { n: Type.Number() }, methods: { add: method(Type.Number()) } } }, + variants: { main: { state: { n: num() }, methods: { add: method(num()) } } }, }); function cluster() { @@ -191,7 +191,7 @@ describe("relay topology", () => { onError: () => {}, }); created.serve(Counter, (_input, _principal, self) => { - const n = self.state("n", Type.Number(), 0); + const n = self.state("n", num(), 0); return { variant: "main", n, diff --git a/packages/chord/test/flow.test.ts b/packages/chord/test/flow.test.ts index 80c8268a7..79835bb25 100644 --- a/packages/chord/test/flow.test.ts +++ b/packages/chord/test/flow.test.ts @@ -1,7 +1,7 @@ // Link flow control: state waits while the peer's acknowledgements lag a window behind, coalesced per subscription; // everything else is sent at once. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -65,12 +65,12 @@ class AckGate implements Link { const counterOn = (name: string, host: string) => remote(name, { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed(host), variants: { main: { - state: { count: Type.Object({ n: Type.Number() }) }, - methods: { add: method(Type.Number(), Type.Number()), wait: method() }, + state: { count: obj({ n: num() }, { open: true }) }, + methods: { add: method(num(), num()), wait: method() }, }, }, }); @@ -89,7 +89,7 @@ const waiting = () => function serveCounter(host: Host, type = Counter): void { host.serve(type, (_input, _principal, self) => { - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); return { variant: "main", count, diff --git a/packages/chord/test/generations.test.ts b/packages/chord/test/generations.test.ts index 3f20b95cc..7496cd1fa 100644 --- a/packages/chord/test/generations.test.ts +++ b/packages/chord/test/generations.test.ts @@ -4,7 +4,7 @@ import assert from "node:assert/strict"; import { setFlagsFromString } from "node:v8"; import { runInNewContext } from "node:vm"; -import { Type } from "typebox"; +import { num, obj } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { createHost, defineRegistry, type Facet, local, method } from "../src/index.ts"; @@ -41,8 +41,8 @@ function tracker() { const Store = local("t.gen-store", { version: 1, - input: Type.Object({}), - members: { get: method(undefined, Type.Number()), count: Type.Number() }, + input: obj({}, { open: true }), + members: { get: method(undefined, num()), count: num() }, }); const Routes = defineRegistry<() => number>("t.gen-routes"); const RELOADS = 6; @@ -57,7 +57,7 @@ describe("generation GC", () => { name: "store", setup: (api) => api.serve(Store, (_input, _principal, self) => { - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); return { count, get: () => sentinel.generation }; }), }; @@ -84,7 +84,7 @@ describe("generation GC", () => { api.serve( Store, (_input, _principal, self) => { - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); // Kept across reloads (same version): adopts each new declaration's close. self.resource("conn", { version: 1, @@ -123,7 +123,7 @@ describe("generation GC", () => { api.serve( Store, (_input, _principal, self) => { - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); self.task(async (ctx) => { await new Promise((resolve) => ctx.signal.addEventListener("abort", () => resolve())); void sentinel.generation; @@ -153,8 +153,8 @@ describe("generation GC", () => { const objects = tracker(); const Item = local("t.gen-item", { version: 1, - input: Type.Object({ id: Type.Number() }), - members: { id: method(undefined, Type.Number()) }, + input: obj({ id: num() }, { open: true }), + members: { id: method(undefined, num()) }, }); const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.serve(Item, (input) => { diff --git a/packages/chord/test/host-objects-reload.test.ts b/packages/chord/test/host-objects-reload.test.ts index 6e762e701..d01912102 100644 --- a/packages/chord/test/host-objects-reload.test.ts +++ b/packages/chord/test/host-objects-reload.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { bool, num, obj, str } from "@earendil-works/pi-schema"; import { onTestFinished, test } from "vitest"; import { HostImpl } from "../src/host.ts"; import { @@ -12,15 +12,16 @@ import { method, type Self, } from "../src/index.ts"; +import { record } from "./fixtures/schemas.ts"; -const input = Type.Object({}); -const flags = Type.Record(Type.String(), Type.Boolean()); +const input = obj({}, { open: true }); +const flags = record(bool()); const SwitchV1 = local("t.eager-switch", { version: 1, input, members: { - enabled: Type.Boolean(), - set: method(Type.Boolean()), + enabled: bool(), + set: method(bool()), }, }); const SwitchV2 = local("t.eager-switch", { @@ -28,7 +29,7 @@ const SwitchV2 = local("t.eager-switch", { input, members: { enabled: flags, - set: method(Type.Object({ conversation: Type.Number(), enabled: Type.Boolean() })), + set: method(obj({ conversation: num(), enabled: bool() }, { open: true })), }, }); const SwitchV3 = local("t.eager-switch", { @@ -36,7 +37,7 @@ const SwitchV3 = local("t.eager-switch", { input, members: { enabled: flags, - update: method(Type.Object({ conversation: Type.Number(), enabled: Type.Boolean() })), + update: method(obj({ conversation: num(), enabled: bool() }, { open: true })), }, }); const Read = defineRegistry<() => unknown>("t.eager-switch-read"); @@ -60,7 +61,7 @@ function switchFacets(log: string[]) { SwitchV1, (_input, _principal, self) => { declareResource(self); - const enabledState = self.state("enabled", Type.Boolean(), false); + const enabledState = self.state("enabled", bool(), false); self.register(Read, { current: () => enabledState.value }); return { enabled: enabledState, @@ -171,13 +172,13 @@ test("the host's reference to a host object follows contract bumps at commit wit const ReadV1 = local("t.eager-read", { version: 1, - input: Type.Object({}), - members: { read: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { read: method(undefined, str()) }, }); const ReadV2 = local("t.eager-read", { version: 2, - input: Type.Object({}), - members: { fetch: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { fetch: method(undefined, str()) }, }); const readerV1: Facet = { name: "read", @@ -219,8 +220,8 @@ test("object-owned old-version references still block a host object's upgrade un onTestFinished(() => host.close()); const Consumer = local("t.eager-consumer", { version: 1, - input: Type.Object({}), - members: { read: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { read: method(undefined, str()) }, }); const oldConsumer: Facet = { name: "consumer", @@ -254,8 +255,8 @@ test("a forgotten contract bump remains refused even when the only consumer is t await host.load([readerV1]); const incompatible = local(ReadV1.name, { version: 1, - input: Type.Object({}), - members: { fetch: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { fetch: method(undefined, str()) }, }); await assert.rejects( host.reload([ @@ -310,7 +311,7 @@ test("a cluster contract upgrade retokens the server's and worker's references t test("a reload that changes a type's lifetime creates its host object, or lets it go", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); - const Kept = local("t.lifetime-change", { version: 1, input: Type.Object({}) }); + const Kept = local("t.lifetime-change", { version: 1, input: obj({}, { open: true }) }); const log: string[] = []; const facet = (lifetime: "host" | "referenced"): Facet => ({ name: "kept", diff --git a/packages/chord/test/host.test.ts b/packages/chord/test/host.test.ts index eb68b5f6b..6a0d34896 100644 --- a/packages/chord/test/host.test.ts +++ b/packages/chord/test/host.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { bool, num, obj, opt, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -27,10 +27,10 @@ async function until(check: () => boolean): Promise { const Counter = local("t.counter", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }, { open: true }), members: { - count: Type.Object({ n: Type.Number() }), - add: method(Type.Number(), Type.Number()), + count: obj({ n: num() }, { open: true }), + add: method(num(), num()), }, }); @@ -39,7 +39,7 @@ function serveCounter(host: Host, log: string[] = [], options: { linger?: number Counter, ({ name }, _principal, self) => { log.push(`factory ${name}`); - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); const conn = self.resource("conn", { version: 1, open: async () => { @@ -123,7 +123,7 @@ describe("host: activation and lifetime", () => { const host = quiet(); const log: string[] = []; let release = () => {}; - const Slow = local("t.slow", { version: 1, input: Type.Object({}) }); + const Slow = local("t.slow", { version: 1, input: obj({}, { open: true }) }); host.serve(Slow, (_input, _principal, self) => { self.resource("r", { version: 1, @@ -156,8 +156,8 @@ describe("host: activation and lifetime", () => { it("propagates activation failures to dependents and retries on the next get", async () => { const host = quiet(); let fail = true; - const Leaf = local("t.leaf", { version: 1, input: Type.Object({}) }); - const Parent = local("t.parent", { version: 1, input: Type.Object({}) }); + const Leaf = local("t.leaf", { version: 1, input: obj({}, { open: true }) }); + const Parent = local("t.parent", { version: 1, input: obj({}, { open: true }) }); host.serve(Leaf, (_input, _principal, self) => { self.resource("r", { version: 1, @@ -184,8 +184,8 @@ describe("host: activation and lifetime", () => { it("unwinds declarations when a factory throws and rejects async factories", async () => { const host = quiet(); - const Leaf = local("t.leaf2", { version: 1, input: Type.Object({}) }); - const Bad = local("t.bad", { version: 1, input: Type.Object({ async: Type.Boolean() }) }); + const Leaf = local("t.leaf2", { version: 1, input: obj({}, { open: true }) }); + const Bad = local("t.bad", { version: 1, input: obj({ async: bool() }, { open: true }) }); let leafLive = 0; host.serve(Leaf, (_input, _principal, self) => { self.resource("r", { @@ -213,10 +213,10 @@ describe("host: activation and lifetime", () => { it("rejects duplicate declaration names", async () => { const host = quiet(); - const Dup = local("t.dup", { version: 1, input: Type.Object({}) }); + const Dup = local("t.dup", { version: 1, input: obj({}, { open: true }) }); host.serve(Dup, (_input, _principal, self) => { - self.state("a", Type.Object({}), {}); - self.state("a", Type.Object({}), {}); + self.state("a", obj({}, { open: true }), {}); + self.state("a", obj({}, { open: true }), {}); return {}; }); await assert.rejects(host.hold(Dup, {}).ready, /duplicate state name a/); @@ -224,8 +224,8 @@ describe("host: activation and lifetime", () => { it("detects hold cycles", async () => { const host = quiet(); - const A = local("t.cycle-a", { version: 1, input: Type.Object({}) }); - const B = local("t.cycle-b", { version: 1, input: Type.Object({}) }); + const A = local("t.cycle-a", { version: 1, input: obj({}, { open: true }) }); + const B = local("t.cycle-b", { version: 1, input: obj({}, { open: true }) }); host.serve(A, (_input, _principal, self) => { self.get(B, {}); return {}; @@ -241,7 +241,7 @@ describe("host: activation and lifetime", () => { const errors: unknown[] = []; const host = quiet(errors); const log: string[] = []; - const Multi = local("t.multi", { version: 1, input: Type.Object({}) }); + const Multi = local("t.multi", { version: 1, input: obj({}, { open: true }) }); host.serve(Multi, (_input, _principal, self) => { const a = self.resource("a", { version: 1, open: () => "a", close: () => void log.push("close a") }); self.resource("b", { @@ -291,15 +291,15 @@ describe("host: activation and lifetime", () => { describe("host: declarations and methods", () => { it("refuses a second declaration of one name per kind; kinds have their own names", async () => { const host = quiet(); - const Twice = local("t.twice", { version: 1, input: Type.Object({ kind: Type.String() }) }); + const Twice = local("t.twice", { version: 1, input: obj({ kind: str() }, { open: true }) }); host.serve(Twice, ({ kind }, _principal, self) => { const open = { version: 1, open: () => null }; - self.state("x", Type.Number(), 0); + self.state("x", num(), 0); self.resource("x", open); - self.on("x", self.state("y", Type.Number(), 0), () => {}); - if (kind === "state") self.state("x", Type.Number(), 1); + self.on("x", self.state("y", num(), 0), () => {}); + if (kind === "state") self.state("x", num(), 1); if (kind === "resource") self.resource("x", open); - if (kind === "listener") self.on("x", self.state("z", Type.Number(), 0), () => {}); + if (kind === "listener") self.on("x", self.state("z", num(), 0), () => {}); return {}; }); await host.hold(Twice, { kind: "none" }).ready; @@ -318,11 +318,11 @@ describe("host: declarations and methods", () => { const host = quiet(); const Tool = local("t.tool", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { ping: method(), - count: method(undefined, Type.Number()), - note: method(Type.String()), + count: method(undefined, num()), + note: method(str()), }, }); const seen: unknown[] = []; @@ -348,8 +348,8 @@ describe("host: declarations and methods", () => { const host = quiet(); const Doubled = local("t.doubled", { version: 1, - input: Type.Object({}), - members: { set: method(Type.Number()) }, + input: obj({}, { open: true }), + members: { set: method(num()) }, }); const seen: number[] = []; const facet = (label: string): Facet => ({ @@ -358,7 +358,7 @@ describe("host: declarations and methods", () => { api.serve( Doubled, (_input, _principal, self) => { - const n = self.state("n", Type.Number(), 1); + const n = self.state("n", num(), 1); const doubled = self.derive([n], (value) => value * 2); self.on("doubled", doubled, (value) => { seen.push(value); @@ -389,7 +389,7 @@ describe("host: declarations and methods", () => { describe("host: lifetime", () => { const Store = local("t.store", { version: 1, - input: Type.Object({ scope: Type.Optional(Type.String({ default: "all" })) }), + input: obj({ scope: opt(str({ default: "all" })) }, { open: true }), members: { seen: opaque("t.seen@1") }, }); @@ -397,11 +397,11 @@ describe("host: lifetime", () => { const host = quiet(); const Remote = remote("t.remote-store", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), variants: { main: {} }, }); - const Keyed = local("t.keyed", { version: 1, input: Type.Object({ id: Type.String() }) }); + const Keyed = local("t.keyed", { version: 1, input: obj({ id: str() }, { open: true }) }); // Each refused call is also a type error; the runtime check is the backstop for untyped callers. const refused: [() => void, RegExp][] = [ // @ts-expect-error: a remote type is never of lifetime "host" @@ -415,7 +415,7 @@ describe("host: lifetime", () => { ]; for (const [serve, error] of refused) assert.throws(serve, error); // Allowed: a local type whose input has only optional fields, and any type with the default lifetime. - const Optional = local("t.optional", { version: 1, input: Type.Object({ id: Type.Optional(Type.String()) }) }); + const Optional = local("t.optional", { version: 1, input: obj({ id: opt(str()) }, { open: true }) }); host.serve(Optional, () => ({}), { lifetime: "host" }); host.serve(Keyed, () => ({}), { lifetime: "referenced", linger: 10 }); host.serve(Remote, () => ({ variant: "main" }), { linger: 10 }); @@ -462,13 +462,13 @@ describe("host: lifetime", () => { describe("host: principals, variants and disposal", () => { const Doc = remote("t.doc", { version: 1, - input: Type.Object({ id: Type.String() }), + input: obj({ id: str() }, { open: true }), host: hostNamed("host"), variants: { - viewer: { state: { view: Type.Object({ text: Type.String() }) } }, + viewer: { state: { view: obj({ text: str() }, { open: true }) } }, editor: { - state: { view: Type.Object({ text: Type.String() }) }, - methods: { edit: method(Type.Object({ text: Type.String() }), Type.Object({ ok: Type.Boolean() })) }, + state: { view: obj({ text: str() }, { open: true }) }, + methods: { edit: method(obj({ text: str() }, { open: true }), obj({ ok: bool() }, { open: true })) }, }, }, }); @@ -476,7 +476,7 @@ describe("host: principals, variants and disposal", () => { function serveDoc(host: Host, principals: unknown[] = []) { host.serve(Doc, (_input, principal, self) => { principals.push(principal); - const view = self.state("view", Type.Object({ text: Type.String() }), { text: "" }); + const view = self.state("view", obj({ text: str() }, { open: true }), { text: "" }); if ((principal as { role?: string }).role !== "editor") return { variant: "viewer", view }; return { variant: "editor", @@ -531,7 +531,7 @@ describe("host: principals, variants and disposal", () => { let seenAbort = false; const Slow = local("t.inflight", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { wait: method() }, }); host.serve(Slow, () => ({ @@ -585,8 +585,8 @@ describe("host: registries", () => { it('only objects of lifetime "host" register; tables follow registration order', async () => { const host = quiet(); - const RoutesA = local("t.routes-a", { version: 1, input: Type.Object({}) }); - const RoutesB = local("t.routes-b", { version: 1, input: Type.Object({}) }); + const RoutesA = local("t.routes-a", { version: 1, input: obj({}, { open: true }) }); + const RoutesB = local("t.routes-b", { version: 1, input: obj({}, { open: true }) }); host.serve( RoutesA, (_input, _principal, self) => { @@ -611,7 +611,7 @@ describe("host: registries", () => { assert.equal(host.registry(Routes).get("GET /b"), "b"); assert.deepEqual(tables.at(-1), ["GET /a", "GET /c", "GET /b"]); - const Lazy = local("t.lazy-register", { version: 1, input: Type.Object({}) }); + const Lazy = local("t.lazy-register", { version: 1, input: obj({}, { open: true }) }); host.serve(Lazy, (_input, _principal, self) => { self.register(Routes, { x: "x" }); return {}; @@ -624,7 +624,7 @@ describe("host: registries", () => { const host = quiet(errors); for (const name of ["t.conflict-a", "t.conflict-b"]) { host.serve( - local(name, { version: 1, input: Type.Object({}) }), + local(name, { version: 1, input: obj({}, { open: true }) }), (_input, _principal, self) => { self.register(Routes, { same: name }); return {}; @@ -641,19 +641,19 @@ describe("host: registries", () => { describe("host: composition", () => { const Source = local("t.source", { version: 1, - input: Type.Object({}), - members: { n: Type.Number(), set: method(Type.Number()) }, + input: obj({}, { open: true }), + members: { n: num(), set: method(num()) }, }); const Mirror = local("t.mirror", { version: 1, - input: Type.Object({}), - members: { copy: Type.Number(), doubled: Type.Number() }, + input: obj({}, { open: true }), + members: { copy: num(), doubled: num() }, }); it("reference state members are sources for listeners and derivations", async () => { const host = quiet(); host.serve(Source, (_input, _principal, self) => { - const n = self.state("n", Type.Number(), 1); + const n = self.state("n", num(), 1); return { n, set: (value: number, ctx: Ctx) => { @@ -663,7 +663,7 @@ describe("host: composition", () => { }); host.serve(Mirror, (_input, _principal, self) => { const source = self.get(Source, {}); - const copy = self.state("copy", Type.Number(), 0); + const copy = self.state("copy", num(), 0); self.on("follow", source.n, (value, ctx) => copy.replace(ctx, value ?? 0)); const doubled = self.derive([source.n], (n) => (n ?? 0) * 2); return { copy, doubled }; @@ -688,12 +688,12 @@ describe("host: composition", () => { const seen: unknown[] = []; const Who = local("t.who", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { ping: method() }, }); const Front = local("t.front", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { relay: method() }, }); host.serve(Who, (_input, principal) => { @@ -711,10 +711,10 @@ describe("host: composition", () => { it("calling a reference inside a factory throws not_ready", async () => { const host = quiet(); host.serve(Source, (_input, _principal, self) => ({ - n: self.state("n", Type.Number(), 0), + n: self.state("n", num(), 0), set: () => {}, })); - const Eager = local("t.eager-call", { version: 1, input: Type.Object({}) }); + const Eager = local("t.eager-call", { version: 1, input: obj({}, { open: true }) }); host.serve(Eager, (_input, _principal, self) => { const source = self.get(Source, {}); void source.set(1); @@ -725,8 +725,8 @@ describe("host: composition", () => { it("a ChordError from a factory keeps its code through dependents", async () => { const host = quiet(); - const Secret = local("t.secret", { version: 1, input: Type.Object({}) }); - const Page = local("t.page", { version: 1, input: Type.Object({}) }); + const Secret = local("t.secret", { version: 1, input: obj({}, { open: true }) }); + const Page = local("t.page", { version: 1, input: obj({}, { open: true }) }); host.serve(Secret, () => { throw forbidden("not for you"); }); diff --git a/packages/chord/test/hydration.test.ts b/packages/chord/test/hydration.test.ts index 2c80255cc..77b3806ac 100644 --- a/packages/chord/test/hydration.test.ts +++ b/packages/chord/test/hydration.test.ts @@ -1,7 +1,7 @@ // Entry 8: a local object goes live only with its state, also where it forwards or derives from a remote reference's // member; activation waits at most `readyTimeout` (a cycle across hosts fails with a hint instead of hanging). import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -20,22 +20,22 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** Lives on the worker `w`; its value is set by `set`. */ const Source = remote("t.e8-source", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("w"), - variants: { main: { state: { v: Type.String() }, methods: { set: method(Type.String()) } } }, + variants: { main: { state: { v: str() }, methods: { set: method(str()) } } }, }); /** Lives on the server; forwards (and derives from) the worker's `Source`. */ const Forward = remote("t.e8-forward", { version: 1, - input: Type.Object({ how: Type.String() }), + input: obj({ how: str() }, { open: true }), host: hostNamed("server"), - variants: { main: { state: { v: Type.String(), size: Type.Number() } } }, + variants: { main: { state: { v: str(), size: num() } } }, }); function serveSource(host: Host): void { host.serve(Source, (_input, _principal, self) => { - const v = self.state("v", Type.String(), "from the worker"); + const v = self.state("v", str(), "from the worker"); return { variant: "main", v, @@ -105,9 +105,9 @@ describe("local hydration", () => { it("a member read inside a factory throws not_ready; it is a plain value once live", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); - const Inner = local("t.e8-inner", { version: 1, input: Type.Object({}), members: { n: Type.Number() } }); - const Outer = local("t.e8-outer", { version: 1, input: Type.Object({}), members: { m: Type.Number() } }); - host.serve(Inner, (_input, _principal, self) => ({ n: self.state("n", Type.Number(), 7) })); + const Inner = local("t.e8-inner", { version: 1, input: obj({}, { open: true }), members: { n: num() } }); + const Outer = local("t.e8-outer", { version: 1, input: obj({}, { open: true }), members: { m: num() } }); + host.serve(Inner, (_input, _principal, self) => ({ n: self.state("n", num(), 7) })); const seen: unknown[] = []; host.serve(Outer, (_input, _principal, self) => { const inner = self.get(Inner, {}); @@ -127,15 +127,15 @@ describe("local hydration", () => { it("a host object of a child holding the parent's object goes live once the parent admits the child", async () => { const Settings = remote("t.e8-settings", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { theme: Type.String() } } }, + variants: { main: { state: { theme: str() } } }, }); - const Local = local("t.e8-boot", { version: 1, input: Type.Object({}), members: { theme: Type.String() } }); + const Local = local("t.e8-boot", { version: 1, input: obj({}, { open: true }), members: { theme: str() } }); const c = cluster({ delay: 20 }); c.server.serve(Settings, (_input, _principal, self) => ({ variant: "main", - theme: self.state("theme", Type.String(), "dark"), + theme: self.state("theme", str(), "dark"), })); await c.worker.install( { @@ -163,13 +163,13 @@ describe("local hydration", () => { it("a reference cycle across hosts fails at readyTimeout with a cycle hint", async () => { const A = remote("t.e8-cycle-a", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: {} }, }); const B = remote("t.e8-cycle-b", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("w"), variants: { main: {} }, }); diff --git a/packages/chord/test/link.test.ts b/packages/chord/test/link.test.ts index 31c0c9229..eee67c8b4 100644 --- a/packages/chord/test/link.test.ts +++ b/packages/chord/test/link.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, num, obj, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -25,24 +25,24 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const counterVariants = { main: { - state: { count: Type.Object({ n: Type.Number() }) }, + state: { count: obj({ n: num() }, { open: true }) }, methods: { - add: method(Type.Number(), Type.Number()), - who: method(undefined, Type.Unknown()), + add: method(num(), num()), + who: method(undefined, unknown()), }, }, }; /** A counter on the server. */ const Counter = remote("t.counter", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }, { open: true }), host: hostNamed("server"), variants: counterVariants, }); /** A counter on a session host (`session:` + its `session` field). */ const SessionCounter = remote("t.session-counter", { version: 1, - input: Type.Object({ session: Type.String(), name: Type.String() }), + input: obj({ session: str(), name: str() }, { open: true }), host: hostFamily("session:", "session"), variants: counterVariants, }); @@ -52,7 +52,7 @@ const SessionCounter = remote("t.session-counter", { function serveCounter(host: Host, principals: unknown[] = []) { const counter = (principal: unknown, self: Self) => { principals.push(principal); - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); return { variant: "main" as const, count, @@ -199,7 +199,7 @@ function cluster(options: { pingInterval?: number; pingTimeout?: number } = {}): open: () => void log.push(`open ${input.name}`), close: () => void log.push(`close ${input.name}`), }); - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); return { variant: "main", count, @@ -346,9 +346,9 @@ describe("links: relay, stamping, lifetime", () => { it("checks contract tokens across links: contract_mismatch for hosts, upgrade_required for browsers", async () => { const CounterV2 = remote("t.counter", { version: 2, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { inc: method(undefined, Type.Number()) } } }, + variants: { main: { methods: { inc: method(undefined, num()) } } }, }); const { server, worker } = serverWithWorker(); server.serve(CounterV2, () => ({ variant: "main", inc: () => 1 })); @@ -408,12 +408,12 @@ describe("links: relay, stamping, lifetime", () => { const Big = remote("t.big", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), variants: { main: { - state: { notes: Type.Array(Type.String()) }, - methods: { echo: method(Type.Array(Type.String()), Type.Array(Type.String())) }, + state: { notes: arr(str()) }, + methods: { echo: method(arr(str()), arr(str())) }, }, }, }); @@ -425,10 +425,10 @@ describe("links: methods without arguments or result", () => { it("travel without args and without a value, and resolve to the result or undefined", async () => { const Tool = remote("t.tool", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { - main: { methods: { ping: method(), count: method(undefined, Type.Number()), note: method(Type.String()) } }, + main: { methods: { ping: method(), count: method(undefined, num()), note: method(str()) } }, }, }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); @@ -551,7 +551,7 @@ describe("links: packed payloads", () => { onError: () => {}, }); worker.serve(Big, (_input, _principal, self) => { - const notesState = self.state("notes", Type.Array(Type.String()), large("a")); + const notesState = self.state("notes", arr(str()), large("a")); return { variant: "main", notes: notesState, echo: (notes: string[]) => notes }; }); return down; diff --git a/packages/chord/test/links-review.test.ts b/packages/chord/test/links-review.test.ts index c607d673f..f6a456afb 100644 --- a/packages/chord/test/links-review.test.ts +++ b/packages/chord/test/links-review.test.ts @@ -1,6 +1,6 @@ // Regression tests for Astra's phase 1b review (findings #1-#11) and the shared-worker test gap. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -66,14 +66,14 @@ class WatchedLink implements Link { const Counter = remote("t.counter", { version: 1, - input: Type.Object({ name: Type.String() }), + input: obj({ name: str() }, { open: true }), host: hostNamed("session:S1"), variants: { main: { - state: { count: Type.Object({ n: Type.Number() }) }, + state: { count: obj({ n: num() }, { open: true }) }, methods: { - add: method(Type.Number(), Type.Number()), - who: method(undefined, Type.Unknown()), + add: method(num(), num()), + who: method(undefined, unknown()), slow: method(), }, }, @@ -138,7 +138,7 @@ function cluster() { open: () => void log.push(`open ${input.name}`), close: () => void log.push(`close ${input.name}`), }); - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 0 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 0 }); return { variant: "main", count, @@ -190,15 +190,15 @@ describe("links review: identity and stamping", () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); const Echo = remote("t.echo", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { root: method(undefined, Type.String()) } } }, + variants: { main: { methods: { root: method(undefined, str()) } } }, }); const facet = (version: number): Facet => ({ name: "echo", setup: (api) => api.serve(Echo, (_input, _principal, self) => { - self.state("n", Type.Number(), 0, { version }); + self.state("n", num(), 0, { version }); return { variant: "main", root: (ctx: Ctx) => ctx.rootId }; }), }); @@ -342,22 +342,22 @@ describe("links review: relay ownership", () => { it("#11 snapshots through an accepts adapter are validated against the consumer's schema", async () => { const DocV1 = remote("t.doc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { view: Type.Object({ text: Type.String() }) } } }, + variants: { main: { state: { view: obj({ text: str() }, { open: true }) } } }, }); const DocV2 = remote("t.doc", { version: 2, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { view: Type.Object({ body: Type.String() }) } } }, + variants: { main: { state: { view: obj({ body: str() }, { open: true }) } } }, }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve( DocV2, (_input, _principal, self) => ({ variant: "main", - view: self.state("view", Type.Object({ body: Type.String() }), { body: "hi" }), + view: self.state("view", obj({ body: str() }, { open: true }), { body: "hi" }), }), { accepts: { @@ -402,9 +402,9 @@ describe("links review: relay ownership", () => { describe("links review: cross-host reload", () => { const Greeter = remote("t.greeter", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), - variants: { main: { methods: { greet: method(Type.String(), Type.String()) } } }, + variants: { main: { methods: { greet: method(str(), str()) } } }, }); const greeterFacet = (greeting: string): Facet => ({ name: "greeter", @@ -468,7 +468,7 @@ describe("links review: cross-host reload", () => { const { server, modules } = setup(); await tick(); const results: unknown[] = []; - const Caller = local("t.caller", { version: 1, input: Type.Object({}) }); + const Caller = local("t.caller", { version: 1, input: obj({}, { open: true }) }); const callerFacet = (active: boolean): Facet => ({ name: "caller", setup: (api) => @@ -517,13 +517,13 @@ describe("links review: cross-host reload", () => { await tick(); const Echo = remote("t.echo2", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: { methods: { ping: method() } } }, }); const Bouncer = remote("t.bouncer", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), variants: { main: { methods: { bounce: method() } } }, }); @@ -531,7 +531,7 @@ describe("links review: cross-host reload", () => { name: "echo", setup: (api) => api.serve(Echo, (_input, _principal, self) => { - self.state("n", Type.Number(), 0, { version }); + self.state("n", num(), 0, { version }); return { variant: "main", ping: () => {} }; }), }); diff --git a/packages/chord/test/next/contracts/declare.test.ts b/packages/chord/test/next/contracts/declare.test.ts index 25e4e0811..9f17d301c 100644 --- a/packages/chord/test/next/contracts/declare.test.ts +++ b/packages/chord/test/next/contracts/declare.test.ts @@ -1,4 +1,3 @@ -import Type from "typebox"; import { describe, expect, it } from "vitest"; import { arr, @@ -143,7 +142,12 @@ describe("declaration checks", () => { [ "open arguments", "n", - { version: 1, params: {}, hostAddress, variants: { a: { methods: { m: method(Type.Object({})) } } } }, + { + version: 1, + params: {}, + hostAddress, + variants: { a: { methods: { m: method({ type: "object", properties: {} }) } } }, + }, "n.a.methods.m.args: argument objects must be closed", ], [ @@ -166,7 +170,7 @@ describe("declaration checks", () => { ], [ "open arguments", - { version: 1, params: {}, members: { m: method(Type.Object({})) } }, + { version: 1, params: {}, members: { m: method({ type: "object", properties: {} }) } }, "i.members.m.args: argument objects must be closed", ], ])("local refuses %s", (_case, declaration, message) => { @@ -181,7 +185,13 @@ describe("declaration checks", () => { }); it("does not check state for closed objects", () => { - expect(() => local("i", { version: 1, params: {}, members: { s: Type.Object({ a: str() }) } })).not.toThrow(); + expect(() => + local("i", { + version: 1, + params: {}, + members: { s: { type: "object", properties: { a: str() }, required: ["a"] } }, + }), + ).not.toThrow(); }); }); diff --git a/packages/chord/test/next/contracts/evolve.test.ts b/packages/chord/test/next/contracts/evolve.test.ts index 4ddfb78ff..0c4a17354 100644 --- a/packages/chord/test/next/contracts/evolve.test.ts +++ b/packages/chord/test/next/contracts/evolve.test.ts @@ -1,4 +1,3 @@ -import Type from "typebox"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { Context } from "../../../src/context/index.ts"; import { type Contract, contractData, type ShapeOf } from "../../../src/next/contracts/contract.ts"; @@ -136,16 +135,18 @@ describe("edits", () => { }); it("checks every edit: closed arguments and the vocabulary", () => { - expect(() => evolve(NotesV1, (e) => e.args("add", Type.Object({ text: str() })))).toThrow( + expect(() => evolve(NotesV1, (e) => e.args("add", { type: "object", properties: { text: str() } }))).toThrow( "notes v2.editor.methods.add.args: argument objects must be closed", ); - expect(() => evolve(InfoV1, (e) => e.addMethod("m", Type.Object({}), nul()))).toThrow( + expect(() => evolve(InfoV1, (e) => e.addMethod("m", { type: "object", properties: {} }, nul()))).toThrow( "info v2.members.m.args: argument objects must be closed", ); expect(() => evolve(NotesV1, (e) => e.state("editor", "count", { not: {} } as never))).toThrow( 'notes v2.editor.state.count: unsupported keyword "not"', ); - expect(() => evolve(NotesV1, (e) => e.state("editor", "count", Type.Object({})))).not.toThrow(); + expect(() => + evolve(NotesV1, (e) => e.state("editor", "count", { type: "object", properties: {} })), + ).not.toThrow(); }); }); diff --git a/packages/chord/test/next/contracts/fingerprint.test.ts b/packages/chord/test/next/contracts/fingerprint.test.ts index f159b7856..a31434198 100644 --- a/packages/chord/test/next/contracts/fingerprint.test.ts +++ b/packages/chord/test/next/contracts/fingerprint.test.ts @@ -1,13 +1,23 @@ -import Type, { type TSchema } from "typebox"; import { describe, expect, it } from "vitest"; -import { evolve, hostKind, int, local, method, obj, remote, str } from "../../../src/next/contracts/index.ts"; +import { + evolve, + hostKind, + int, + local, + method, + obj, + remote, + type Schema, + str, + union, +} from "../../../src/next/contracts/index.ts"; import { NORMAL_FORM_VERSION } from "../../../src/next/contracts/internal.ts"; import type { Params } from "../../../src/next/contracts/params.ts"; const Server = hostKind("server"); /** Fingerprint of a one-variant remote contract with state `s` and params `params`. */ -function fingerprintOf(state: TSchema, params: Params = {}): string { +function fingerprintOf(state: Schema, params: Params = {}): string { return remote("n", { version: 1, params, hostAddress: () => Server(), variants: { a: { state: { s: state } } } }) .fingerprint; } @@ -47,12 +57,12 @@ describe("fingerprints", () => { }); it.each([ - ["defaults", Type.String(), Type.String({ default: "x" })], - ["bounds", Type.String(), Type.String({ maxLength: 3 })], - ["formats", Type.String(), Type.String({ format: "email" })], - ["union order", Type.Union([Type.String(), Type.Integer()]), Type.Union([Type.Integer(), Type.String()])], - ["additionalProperties", Type.Object({}), Type.Object({}, { additionalProperties: false })], - ["readOnly", Type.String(), Type.String({ readOnly: true })], + ["defaults", str(), str({ default: "x" })], + ["bounds", str(), str({ max: 3 })], + ["formats", str(), { type: "string", format: "email" }], + ["union order", union([str(), int()]), union([int(), str()])], + ["additionalProperties", { type: "object", properties: {} }, obj({})], + ["readOnly", str(), { type: "string", readOnly: true }], ])("are sensitive to %s", (_case, a, b) => { expect(fingerprintOf(a)).not.toBe(fingerprintOf(b)); }); diff --git a/packages/chord/test/next/contracts/params.test.ts b/packages/chord/test/next/contracts/params.test.ts index 96187b2b0..e760a9c58 100644 --- a/packages/chord/test/next/contracts/params.test.ts +++ b/packages/chord/test/next/contracts/params.test.ts @@ -1,4 +1,3 @@ -import Type from "typebox"; import { describe, expect, it } from "vitest"; import { arr, @@ -48,7 +47,7 @@ describe("params policy", () => { ["arrays", arr(str())], ["bytes", bytes()], ["numeric literals", oneOf(1, 2)], - ["defaults", Type.String({ default: "x" })], + ["defaults", str({ default: "x" })], ])("refuses %s, in the types and at declaration", (_case, schema) => { // @ts-expect-error: not a params field schema expect(() => remote("n", { version: 1, params: { p: schema }, hostAddress: () => Server(), variants })).toThrow( diff --git a/packages/chord/test/next/contracts/schema.test.ts b/packages/chord/test/next/contracts/schema.test.ts index f509d0262..ee6a05691 100644 --- a/packages/chord/test/next/contracts/schema.test.ts +++ b/packages/chord/test/next/contracts/schema.test.ts @@ -1,4 +1,6 @@ -import { Compile } from "typebox/compile"; +// Tests of the schema helpers: the JSON Schema they build over pi-schema's builders, their types, `bytes()`, `method`. + +import { compile } from "@earendil-works/pi-schema"; import { describe, expect, expectTypeOf, it } from "vitest"; import { arr, @@ -22,7 +24,7 @@ import { Method } from "../../../src/next/contracts/internal.ts"; import type { Equal } from "../support.ts"; describe("schema helpers", () => { - it("produce ordinary TypeBox schemas with bounds under their JSON Schema names", () => { + it("produce plain JSON Schema with bounds under their JSON Schema names", () => { expect(str({ min: 1, max: 3, pattern: "^a" })).toEqual({ type: "string", minLength: 1, @@ -58,34 +60,39 @@ describe("schema helpers", () => { properties: { a: str(), b: int() }, additionalProperties: false, }); - expect(Compile(schema).Check({ a: "x" })).toBe(true); - expect(Compile(schema).Check({ a: "x", c: 1 })).toBe(false); + expect(compile(schema).check({ a: "x" })).toBe(true); + expect(compile(schema).check({ a: "x", c: 1 })).toBe(false); }); - it("Static and TypeBox's compiler work unchanged", () => { + it("Static gives the values' type; pi-schema's compiler checks them", () => { const schema = obj({ kind: oneOf("chat", "agent"), n: opt(int()), tags: arr(str()), data: bytes() }); type Expected = { kind: "chat" | "agent"; n?: number; tags: string[]; data: Uint8Array }; expectTypeOf, Expected>>().toEqualTypeOf(); - expect(Compile(schema).Check({ kind: "chat", tags: [], data: new Uint8Array(1) })).toBe(true); + expect(compile(schema).check({ kind: "chat", tags: [], data: new Uint8Array(1) })).toBe(true); }); }); describe("bytes", () => { - it("is the data marker with the check attached non-enumerably", () => { + it("is plain data: the marker and nothing else", () => { const schema = bytes({ min: 1, max: 2 }); - expect(JSON.parse(JSON.stringify(schema))).toEqual({ type: "bytes", minLength: 1, maxLength: 2 }); - expect(Object.keys(schema)).toEqual(["type", "minLength", "maxLength"]); - expect(Object.getOwnPropertyDescriptor(schema, "~refine")?.enumerable).toBe(false); + expect(Reflect.ownKeys(schema)).toEqual(["type", "minLength", "maxLength"]); + expect(schema).toEqual({ type: "bytes", minLength: 1, maxLength: 2 }); }); it("checks instanceof Uint8Array and the length bounds", () => { - const validator = Compile(bytes({ min: 1, max: 2 })); - expect(validator.Check(new Uint8Array(1))).toBe(true); - expect(validator.Check(new Uint8Array(2))).toBe(true); - expect(validator.Check(new Uint8Array(0))).toBe(false); - expect(validator.Check(new Uint8Array(3))).toBe(false); - expect(validator.Check([1])).toBe(false); - expect(validator.Check("ab")).toBe(false); + const validator = compile(bytes({ min: 1, max: 2 })); + expect(validator.check(new Uint8Array(1))).toBe(true); + expect(validator.check(new Uint8Array(2))).toBe(true); + expect(validator.check(new Uint8Array(0))).toBe(false); + expect(validator.check(new Uint8Array(3))).toBe(false); + expect(validator.check([1])).toBe(false); + expect(validator.check("ab")).toBe(false); + }); + + it("checks inside a record (slices.md L1 carry-over)", () => { + const validator = compile(map(bytes({ max: 1 }))); + expect(validator.check({ a: new Uint8Array(1) })).toBe(true); + expect(validator.check({ a: new Uint8Array(2) })).toBe(false); }); }); diff --git a/packages/chord/test/next/contracts/snapshot.test.ts b/packages/chord/test/next/contracts/snapshot.test.ts index d90e5a284..e3d6d76b8 100644 --- a/packages/chord/test/next/contracts/snapshot.test.ts +++ b/packages/chord/test/next/contracts/snapshot.test.ts @@ -1,27 +1,35 @@ -import Type from "typebox"; -import { Compile } from "typebox/compile"; +// Tests of schema snapshots: the vocabulary, closed arguments, copies and their normal forms, bytes leaves. + +import { compile } from "@earendil-works/pi-schema"; import { describe, expect, it } from "vitest"; -import { arr, bytes, int, map, obj, opt, str } from "../../../src/next/contracts/schema.ts"; +import { arr, bytes, int, map, obj, opt, str, union } from "../../../src/next/contracts/schema.ts"; import { snapshotArguments, snapshotSchema } from "../../../src/next/contracts/snapshot.ts"; describe("vocabulary", () => { it("accepts primitives, arrays, tuples, objects, records, unions, intersections, $defs/$ref, constraints", () => { - const recursive = Type.Cyclic( - { Node: Type.Object({ value: Type.Number(), next: Type.Union([Type.Null(), Type.Ref("Node")]) }) }, - "Node", - ); + const recursive = { + $defs: { + Node: { + type: "object", + properties: { value: { type: "number" }, next: { anyOf: [{ type: "null" }, { $ref: "#/$defs/Node" }] } }, + required: ["value", "next"], + }, + }, + $ref: "#/$defs/Node", + }; const schemas = [ - Type.Tuple([Type.String(), Type.Integer()]), - Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.String() })]), - Type.Enum(["a", "b"]), + { type: "array", prefixItems: [{ type: "string" }, { type: "integer" }], items: false }, + { allOf: [obj({ a: str() }), obj({ b: str() })] }, + { enum: ["a", "b"] }, recursive, str({ min: 1, pattern: "^x" }), - Type.String({ format: "email", default: "a@b.c", readOnly: true, title: "t", description: "d" }), - Type.Number({ multipleOf: 2, exclusiveMinimum: 0 }), - Type.Array(Type.String(), { uniqueItems: true }), - Type.Object({}, { minProperties: 1, maxProperties: 2, $comment: "c", deprecated: true, examples: [{}] }), + { type: "string", format: "email", default: "a@b.c", readOnly: true, title: "t", description: "d" }, + { type: "number", multipleOf: 2, exclusiveMinimum: 0 }, + { type: "array", items: { type: "string" }, uniqueItems: true }, + { type: "object", minProperties: 1, maxProperties: 2, $comment: "c", deprecated: true, examples: [{}] }, map(int()), bytes({ max: 4 }), + map(bytes()), ]; for (const schema of schemas) expect(() => snapshotSchema(schema, "s")).not.toThrow(); }); @@ -43,18 +51,15 @@ describe("vocabulary", () => { ["definitions", { definitions: {} }, 'unsupported keyword "definitions"'], ["oneOf", { oneOf: [{ type: "string" }] }, 'unsupported keyword "oneOf"'], ["unknown keyword", { type: "string", flavour: "x" }, 'unsupported keyword "flavour"'], - ["non-JSON type", Type.BigInt(), 'unsupported type "bigint"'], - ["undefined", Type.Undefined(), 'unsupported type "undefined"'], - ["function", Type.Function([], Type.Null()), "s: unsupported"], - ["Unsafe", Type.Unsafe({ type: "string" }), "unsafe types are not supported"], - ["refinement", Type.Refine(Type.String(), () => true), "refinements"], + ["non-JSON type", { type: "bigint" }, 'unsupported type "bigint"'], + ["undefined", { type: "undefined" }, 'unsupported type "undefined"'], + ["function", { type: "function", parameters: [], returnType: { type: "null" } }, "s: unsupported"], [ - "codec", - Type.Codec(Type.String()) - .Decode((v) => v) - .Encode((v) => v), - "codecs", + "non-enumerable key (a TypeBox refinement)", + Object.defineProperty({ type: "string" }, "~refine", { value: [{ check: () => true }] }), + "s.~refine: non-enumerable keys are not supported", ], + ["opt(...) outside an object", opt(str()), "s: expected a schema"], ["non-JSON data", { type: "number", default: Number.NaN }, "s.default: expected JSON data"], ["undefined data", { type: "number", maximum: undefined }, "s.maximum: expected JSON data"], ["foreign prototype", new (class Schema {})(), "s: expected a schema"], @@ -86,62 +91,62 @@ describe("vocabulary", () => { describe("closed arguments", () => { it("requires every object, nested ones included, to be closed", () => { expect(() => snapshotArguments(obj({ a: obj({ b: str() }) }), "m.args")).not.toThrow(); - expect(() => snapshotArguments(obj({ a: Type.Object({ b: str() }) }), "m.args")).toThrow( + const open = (properties: object) => ({ type: "object", properties }); + expect(() => snapshotArguments(obj({ a: open({ b: str() }) }), "m.args")).toThrow( "m.args.properties.a: argument objects must be closed (additionalProperties: false)", ); - expect(() => snapshotArguments(Type.Object({}), "m.args")).toThrow("m.args: argument objects must be closed"); - expect(() => snapshotArguments(obj({ list: arr(Type.Object({})) }), "m.args")).toThrow( + expect(() => snapshotArguments(open({}), "m.args")).toThrow("m.args: argument objects must be closed"); + expect(() => snapshotArguments(obj({ list: arr(open({})) }), "m.args")).toThrow( "m.args.properties.list.items: argument objects must be closed", ); expect(() => snapshotArguments({ type: "object" }, "m.args")).toThrow("argument objects must be closed"); }); - it("accepts a raw closed Type.Object and records", () => { - expect(() => snapshotArguments(Type.Object({ a: str() }, { additionalProperties: false }), "m")).not.toThrow(); + it("accepts a raw closed object schema and records", () => { + const closed = { type: "object", properties: { a: str() }, additionalProperties: false }; + expect(() => snapshotArguments(closed, "m")).not.toThrow(); expect(() => snapshotArguments(obj({ tags: map(str()) }), "m")).not.toThrow(); }); it("checks objects inside $defs, unions and tuples", () => { - const open = Type.Object({ a: str() }); - expect(() => snapshotArguments(Type.Union([open, str()]), "m")).toThrow("m.anyOf[0]: argument objects"); - expect(() => snapshotArguments(Type.Tuple([open]), "m")).toThrow("m.items[0]: argument objects"); + const open = { type: "object", properties: { a: str() } }; + expect(() => snapshotArguments(union([open, str()]), "m")).toThrow("m.anyOf[0]: argument objects"); + expect(() => snapshotArguments({ type: "array", items: [open] }, "m")).toThrow("m.items[0]: argument objects"); expect(() => snapshotArguments({ $defs: { A: open }, $ref: "A" }, "m")).toThrow("m.$defs.A: argument objects"); }); it("does not apply to state or results", () => { - expect(() => snapshotSchema(Type.Object({ a: str() }), "state")).not.toThrow(); + expect(() => snapshotSchema({ type: "object", properties: { a: str() } }, "state")).not.toThrow(); }); }); describe("snapshots", () => { it("are copies, independent of later changes to the app's schema objects", () => { - const app = obj({ text: str(), tags: arr(str()) }); + const app = obj({ text: str(), tags: arr(str()) }) as { properties: { text: object }; required: string[] }; const { schema } = snapshotSchema(app, "s"); (app.properties.text as { minLength?: number }).minLength = 5; - (app.required as string[]).push("x"); + app.required.push("x"); expect(schema).not.toBe(app); - expect(Compile(schema).Check({ text: "", tags: [] })).toBe(true); + expect(compile(schema).check({ text: "", tags: [] })).toBe(true); }); it("copy data values (defaults, consts)", () => { const fallback = { a: [1] }; - const { schema } = snapshotSchema(Type.Object({}, { default: fallback, additionalProperties: false }), "s"); + const { schema } = snapshotSchema({ type: "object", default: fallback, additionalProperties: false }, "s"); fallback.a.push(2); const copied = (schema as { default: { a: number[] } }).default; expect(copied).toEqual({ a: [1] }); }); - it("rebuild the bytes check from the marker; no app callback is copied", () => { - const app = bytes({ max: 2 }); - const appCheck = Object.getOwnPropertyDescriptor(app, "~refine")!.value; - const { schema } = snapshotSchema(app, "s"); - expect(Object.getOwnPropertyDescriptor(schema, "~refine")!.value).not.toBe(appCheck); - expect(Compile(schema).Check(new Uint8Array(2))).toBe(true); - expect(Compile(schema).Check(new Uint8Array(3))).toBe(false); - // A hand-written marker works too: the check comes from the marker, not from the app. - const marker = snapshotSchema({ type: "bytes", minLength: 1 }, "s").schema; - expect(Compile(marker).Check(new Uint8Array(0))).toBe(false); - expect(Compile(marker).Check(new Uint8Array(1))).toBe(true); + it("copy a bytes leaf as data: the compiled check comes from its keywords", () => { + const { schema } = snapshotSchema(bytes({ max: 2 }), "s"); + expect(schema).toEqual({ type: "bytes", maxLength: 2 }); + expect(compile(schema).check(new Uint8Array(2))).toBe(true); + expect(compile(schema).check(new Uint8Array(3))).toBe(false); + // A record of bytes (slices.md L1 carry-over: refused while bytes carried a TypeBox refinement). + const record = snapshotSchema(map(bytes({ min: 1 })), "s").schema; + expect(compile(record).check({ a: new Uint8Array(1) })).toBe(true); + expect(compile(record).check({ a: new Uint8Array(0) })).toBe(false); }); it("refuse a bytes marker with other keywords or bad bounds", () => { @@ -173,10 +178,9 @@ describe("snapshots", () => { }); it("normal form keeps union and tuple order", () => { - const ab = snapshotSchema(Type.Union([str(), int()]), "s").normal; - const ba = snapshotSchema(Type.Union([int(), str()]), "s").normal; + const ab = snapshotSchema(union([str(), int()]), "s").normal; + const ba = snapshotSchema(union([int(), str()]), "s").normal; expect(ab).not.toEqual(ba); - expect(snapshotSchema(opt(str()), "s").normal).toEqual({ type: "string" }); }); }); @@ -184,7 +188,7 @@ describe("bytes", () => { it("mark a snapshot with a bytes leaf anywhere inside", () => { expect(snapshotSchema(str(), "s").bytes).toBe(false); expect(snapshotSchema(obj({ a: arr(obj({ b: bytes() })) }), "s").bytes).toBe(true); - expect(snapshotSchema(Type.Union([str(), bytes()]), "s").bytes).toBe(true); - expect(snapshotSchema(Type.Object({ a: str() }, { default: { type: "bytes" } }), "s").bytes).toBe(false); + expect(snapshotSchema(union([str(), bytes()]), "s").bytes).toBe(true); + expect(snapshotSchema({ type: "object", default: { type: "bytes" } }, "s").bytes).toBe(false); }); }); diff --git a/packages/chord/test/next/data/args.test.ts b/packages/chord/test/next/data/args.test.ts index 896861fc0..bdd26d32b 100644 --- a/packages/chord/test/next/data/args.test.ts +++ b/packages/chord/test/next/data/args.test.ts @@ -147,10 +147,20 @@ describe("checkArgs", () => { const local1 = bindProvider(Info, {}); expect(checkArgs(local1, "save", { title: "t" }, 1, BACKGROUND_CONTEXT).args).toEqual({ title: "t" }); expect(refusal(() => checkArgs(local1, "save", { title: 1 }, 1, BACKGROUND_CONTEXT))).toMatchObject({ - message: "args/title: must be string", + message: "args/title: expected string, got integer", }); }); + it("treats `format` as an annotation: a contract with format email accepts any string", () => { + const Mail = local("mail", { + version: 1, + params: {}, + members: { send: method(obj({ to: { type: "string", format: "email" } }), nul()) }, + }); + const mail = bindProvider(Mail, {}); + expect(checkArgs(mail, "send", { to: "nope" }, 1, BACKGROUND_CONTEXT).args).toEqual({ to: "nope" }); + }); + it("refuses an unknown method, an unaccepted version and a refused method", () => { expect(refusal(() => checkArgs(provider, "missing", null, 3, BACKGROUND_CONTEXT))).toEqual({ code: "invalid", diff --git a/packages/chord/test/next/data/params.test.ts b/packages/chord/test/next/data/params.test.ts index 5848b0378..20777e981 100644 --- a/packages/chord/test/next/data/params.test.ts +++ b/packages/chord/test/next/data/params.test.ts @@ -59,10 +59,10 @@ describe("checkParams", () => { details: { path: "/extra" }, }); expect(refusal({ ...valid, page: "2" })).toMatchObject({ - message: "params/page: must be integer", + message: "params/page: expected integer, got string", details: { path: "/page" }, }); - expect(refusal("S1")).toMatchObject({ message: "params: must be object", details: { path: "/" } }); + expect(refusal("S1")).toMatchObject({ message: "params: expected object, got string", details: { path: "/" } }); }); it("enforces literal membership and constraints", () => { diff --git a/packages/chord/test/next/data/validator.test.ts b/packages/chord/test/next/data/validator.test.ts index b78b55c58..d1c09ec4c 100644 --- a/packages/chord/test/next/data/validator.test.ts +++ b/packages/chord/test/next/data/validator.test.ts @@ -1,4 +1,5 @@ -import { GetExternal, GetFunctions } from "typebox/schema"; +// Tests of compiled checks: pi-schema validators of Chord-owned schemas and the `invalid` error of a failed check. + import { describe, expect, it } from "vitest"; import { BACKGROUND_CONTEXT } from "../../../src/context/index.ts"; import { arr, bytes, int, map, obj, str } from "../../../src/next/contracts/schema.ts"; @@ -31,17 +32,21 @@ describe("failed checks", () => { expect(failure(valid)).toBe("passed"); }); - it("report TypeBox's first error with its JSON Pointer path", () => { + it("report the first error with its JSON Pointer path", () => { expect(failure({ ...valid, text: 1 })).toEqual({ reason: "args", - message: "args/text: must be string", + message: "args/text: expected string, got integer", details: { path: "/text" }, }); expect(failure({ ...valid, items: [{ n: "x" }] })).toMatchObject({ details: { path: "/items/0/n" } }); - expect(failure(null)).toEqual({ reason: "args", message: "args: must be object", details: { path: "/" } }); + expect(failure(null)).toEqual({ + reason: "args", + message: "args: expected object, got null", + details: { path: "/" }, + }); }); - it("report a missing field at its own path (TypeBox reports it at the parent)", () => { + it("report a missing field at its own path", () => { const { text: _, ...missing } = valid; expect(failure(missing)).toMatchObject({ message: "args/text: missing", @@ -60,14 +65,22 @@ describe("failed checks", () => { }); it("check bytes by instanceof and length", () => { - expect(failure({ ...valid, blob: [1] })).toMatchObject({ - message: "args/blob: expected bytes of the declared length", + expect(failure({ ...valid, blob: [1] })).toMatchObject({ message: "args/blob: expected bytes, got array" }); + expect(failure({ ...valid, blob: new Uint8Array(3) })).toMatchObject({ + message: "args/blob: expected at most 2 bytes", + details: { path: "/blob" }, }); - expect(failure({ ...valid, blob: new Uint8Array(3) })).toMatchObject({ details: { path: "/blob" } }); }); - it("leave keys containing `/` or `~` unescaped (TypeBox's instance path, ambiguous)", () => { - expect(failure({ ...valid, tags: { "a/b": 1 } })).toMatchObject({ details: { path: "/tags/a/b" } }); + it("treat `format` as an annotation: no format is checked", () => { + const owner = snapshotArguments(obj({ mail: { type: "string", format: "email" } }), "m"); + const validator = validatorFor(owner, BACKGROUND_CONTEXT); + expect(validator.check({ mail: "a@b.c" })).toBe(true); + expect(validator.check({ mail: "nope" })).toBe(true); + }); + + it("escape `~` and `/` in keys (JSON Pointer), so a path names one place", () => { + expect(failure({ ...valid, tags: { "a/b": 1, "c~d": "x" } })).toMatchObject({ details: { path: "/tags/a~1b" } }); }); }); @@ -86,20 +99,11 @@ describe("validators", () => { ]); }); - it("leave nothing in TypeBox's build accumulators", () => { - validatorFor(snapshotArguments(obj({ blob: bytes(), n: int() }), "m"), BACKGROUND_CONTEXT); - expect(GetExternal().variables).toEqual([]); - expect(GetFunctions()).toEqual([]); - }); - - it("are collected with their schema owner; TypeBox retains no build state", async () => { + it("are collected with their schema owner, the check function too", async () => { const refs = (() => { - const owner = snapshotArguments(obj({ blob: bytes() }), "m"); + const owner = snapshotArguments(obj({ blob: bytes(), n: int() }), "m"); const validator = validatorFor(owner, BACKGROUND_CONTEXT); - const blob = (owner.schema as { properties: { blob: object } }).properties.blob; - // TypeBox's external variables would hold the bytes check if the build state were retained. - const [check] = Object.getOwnPropertyDescriptor(blob, "~refine")!.value as object[]; - return [new WeakRef(owner), new WeakRef(validator), new WeakRef(check!)]; + return [new WeakRef(owner), new WeakRef(validator), new WeakRef(validator.check)]; })(); await expectCollected(...refs); }); diff --git a/packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/units.ts b/packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/units.ts index e2fc0bfb5..c5a8a13b9 100644 --- a/packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/units.ts +++ b/packages/chord/test/next/examples/37-plugin-imports/plugins/shapes/units.ts @@ -1,7 +1,7 @@ // A file of the shapes plugin that imports a package (not a host package): fine for the shapes plugin itself, but a // plugin importing this file would copy it, and a copied file may not import packages (the ruler plugin shows it). -import { Type } from "typebox"; +import { num } from "@earendil-works/pi-schema"; /** A length in millimetres. */ -export const Millimetres = Type.Number({ minimum: 0 }); +export const Millimetres = num({ minimum: 0 }); diff --git a/packages/chord/test/next/schema/declarations.ts b/packages/chord/test/next/schema/declarations.ts new file mode 100644 index 000000000..cf0a329c0 --- /dev/null +++ b/packages/chord/test/next/schema/declarations.ts @@ -0,0 +1,180 @@ +// The declarations of the schema switch's proofs (TypeBox to pi-schema): one of every schema helper and option in a +// contract, persisted documents, tables with defaults, an optional and a `json(...)` column, an evolved table and a +// persisted job. Their fingerprints were recorded with the TypeBox helpers; the stored data in `stored/` was written +// by that code (`write-stored.ts`). + +import { + arr, + bool, + bytes, + hostKind, + int, + keyed, + lit, + local, + map, + method, + nul, + num, + obj, + oneOf, + opt, + remote, + str, + union, + unknown, +} from "../../../src/next/contracts/index.ts"; +import { defineState } from "../../../src/next/documents/index.ts"; +import { defineJob } from "../../../src/next/jobs/index.ts"; +import { defineTable, evolveTable, json } from "../../../src/next/store/index.ts"; + +/** A schema with every helper and option a declaration can use. */ +export const everything = obj({ + name: str({ min: 1, max: 40, pattern: "^[a-z ]+$" }), + nick: str({ default: "x" }), + age: int({ min: 0, max: 150 }), + count: int({ default: 0 }), + score: num({ min: 0, max: 100 }), + ratio: num({ default: 0.5 }), + active: bool(), + verified: bool({ default: false }), + role: oneOf("admin", "user", "guest"), + level: oneOf(1, 2, 3), + kind: lit("person"), + one: lit(1), + yes: lit(true), + tags: arr(str(), { min: 0, max: 5 }), + points: arr(int()), + extra: map(int()), + note: opt(str()), + nothing: nul(), + choice: union([str(), int(), obj({ deep: arr(obj({ x: num() })) })]), + nested: obj({ inner: opt(obj({ v: str() })) }), +}); + +/** A local contract with state, keyed state and methods of every argument shape. */ +export const Everything = local("fixture.switch.everything", { + version: 1, + params: { id: str(), shard: int() }, + members: { + profile: everything, + notes: keyed(obj({ body: str() })), + plain: method(), + args: method(obj({ a: str(), b: opt(int()), any: unknown() })), + result: method(undefined, arr(str())), + both: method(obj({ when: int({ min: 0 }) }), union([lit("ok"), obj({ error: str() })])), + blob: method(obj({ data: bytes({ max: 1024 }) }), bytes()), + }, +}); + +const Server = hostKind("fixture.switch.server", { region: str() }); + +/** A remote contract with variants and a host address. */ +export const Account = remote("fixture.switch.account", { + version: 1, + params: { user: str({ min: 1 }) }, + hostAddress: () => Server({ region: "eu" }), + variants: { + owner: { state: { balance: num() }, methods: { pay: method(obj({ amount: num({ min: 0 }) }), bool()) } }, + guest: { state: { balance: num() } }, + }, +}); + +/** A persisted singleton document of `everything`. */ +export const Profile = defineState("fixture.switch.profile", { + schema: everything, + version: 1, + initial: () => ({ + name: "ada", + nick: "x", + age: 36, + count: 0, + score: 1, + ratio: 0.5, + active: true, + verified: false, + role: "admin" as const, + level: 1 as const, + kind: "person" as const, + one: 1 as const, + yes: true as const, + tags: [], + points: [], + extra: {}, + nothing: null, + choice: "a", + nested: {}, + }), + persist: true, + checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 2, +}); + +/** A persisted keyed document. */ +export const Notes = defineState("fixture.switch.notes", { + schema: obj({ body: str(), tags: opt(arr(str())) }), + version: 1, + initial: () => ({ body: "" }), + keyed: true, + persist: true, + checkpointWhen: () => false, +}); + +/** A table with defaults, an optional column and a `json(...)` column. */ +export const Rows = defineTable("fixture.switch.rows", { + version: 1, + columns: { + id: str(), + n: int({ default: 0 }), + flag: bool({ default: false }), + label: opt(str()), + data: json(obj({ a: int(), b: opt(arr(str())) }), { version: 1 }), + maybe: opt(json(map(num()), { version: 1 })), + }, + key: ["id"], + indexes: { by_n: ["n", "id"] }, +}); + +/** A table evolved over three versions: a rename, an added column with a default, a json column, a drop. */ +export const EditsV1 = defineTable("fixture.switch.edits", { + version: 1, + columns: { id: str(), file: str(), at: int() }, + key: ["id"], +}); + +export const EditsV2 = evolveTable(EditsV1, (t) => + t + .rename("file", "path") + .add("dir", str({ default: "" })) + .rows((row) => ({ dir: row.path.slice(0, row.path.lastIndexOf("/")) })), +); + +export const EditsV3 = evolveTable(EditsV2, (t) => t.drop("dir").index("by_path", ["path", "at"])); + +/** A persisted job with a state document. */ +export const ImportState = defineState("fixture.switch.import.state", { + schema: obj({ done: int() }), + version: 1, + initial: () => ({ done: 0 }), + persist: true, + checkpointWhen: () => false, +}); + +export const ImportJob = defineJob("fixture.switch.import", { + version: 1, + args: obj({ path: str() }), + result: obj({ count: int() }), + state: ImportState, + persist: { resume: "rerun" }, +}); + +/** The object owning the persisted documents and the tables (a local type with empty params, as tables need). */ +export const Owner = local("fixture.switch.owner", { + version: 1, + params: {}, + members: { + profile: everything, + write: method(obj({ id: str(), body: str() })), + read: method(str(), str()), + dump: method(obj({}), unknown()), + }, +}); diff --git a/packages/chord/test/next/schema/owner.ts b/packages/chord/test/next/schema/owner.ts new file mode 100644 index 000000000..88928df5e --- /dev/null +++ b/packages/chord/test/next/schema/owner.ts @@ -0,0 +1,85 @@ +// The object of the stored-data proof: it owns the persisted documents, the tables and the job of `declarations.ts`, +// writes them (`write-stored.ts`, with the TypeBox helpers) and reads them back (the test, after the switch). + +import type { Context } from "../../../src/context/index.ts"; +import { createHost, defineFacet, type Host } from "../../../src/next/host/index.ts"; +import type { TableDefinition } from "../../../src/next/store/index.ts"; +import { openStore, query, type Store, sqliteBackend } from "../../../src/next/store/index.ts"; +import { openNodeSqliteDatabase } from "../../../src/sqlite/node.ts"; +import { ImportJob, Notes, Owner, Profile, Rows } from "./declarations.ts"; + +/** What the job's `run` does in this process: wait until its context ends, or finish at once. */ +export type RunBehaviour = "wait" | "finish"; + +/** The finished runs' results, in order. */ +export const finished: unknown[] = []; + +/** The owner's facet over `edits` (the table version this process declares). */ +export function ownerFacet(edits: TableDefinition, behaviour: RunBehaviour) { + return defineFacet((host) => { + host.serve(Owner, (_, self) => { + const profile = self.state(Profile); + const notes = self.state(Notes); + const rows = self.table(Rows); + const editsTable = self.table(edits); + const imports = self.job(ImportJob, { + run: async ({ path }, ctx) => { + await ctx.state.change((draft) => { + draft.done += path.length; + }, ctx); + if (behaviour === "wait") { + await new Promise((resolve) => ctx.abortSignal?.addEventListener("abort", resolve)); + } + return { count: ctx.state.value.done }; + }, + finish: async (outcome) => { + finished.push(outcome.kind === "done" ? outcome.result : outcome.kind); + }, + }); + return { + profile, + write: async ({ id, body }, ctx) => { + await self.tx(async (tx) => { + (await tx.draft(notes, id)).body = body; + const draft = await tx.draft(profile); + draft.count += 1; + draft.tags.push(id); + draft.extra[id] = body.length; + draft.choice = { deep: [{ x: draft.count }] }; + draft.nested = { inner: { v: body } }; + tx.put(rows, { + id, + label: body, + data: { a: body.length, b: [id] }, + ...(id === "a" ? { maybe: { n: 1.5 } } : {}), + }); + tx.put(editsTable, { id, file: `/notes/${id}.md`, at: body.length } as never); + }, ctx); + await imports.start({ path: id }, ctx); + return null; + }, + read: async (id, ctx) => (await notes.get(id, ctx))?.body ?? "", + dump: async (_, ctx) => ({ + profile: profile.value, + notes: { a: (await notes.get("a", ctx)) ?? null, b: (await notes.get("b", ctx)) ?? null }, + rows: (await rows.page(query(Rows), 10, ctx)).items, + edits: (await editsTable.page(query(edits as TableDefinition), 10, ctx)).items, + runs: imports.running.value.length, + }), + }; + }); + }); +} + +/** A host serving the owner on the SQLite store at `path`. */ +export async function ownerHost( + path: string, + edits: TableDefinition, + behaviour: RunBehaviour, + context: Context, +): Promise<{ store: Store; host: Host }> { + const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(path)), context); + const host = createHost({ store, onError: () => {}, closeTimeout: 100 }); + await host.load([{ name: "@fixture/owner", facet: ownerFacet(edits, behaviour) }], context); + return { store, host }; +} diff --git a/packages/chord/test/next/schema/proofs.ts b/packages/chord/test/next/schema/proofs.ts new file mode 100644 index 000000000..6f285111e --- /dev/null +++ b/packages/chord/test/next/schema/proofs.ts @@ -0,0 +1,113 @@ +// The values the schema switch must keep (`recorded.json`, made with the TypeBox helpers): the fingerprints and normal +// forms of `declarations.ts`, and what a host reads back from the stored data in `stored/`. + +import { createReadStream } from "node:fs"; +import { copyFile, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Context } from "../../../src/context/index.ts"; +import type { JsonValue } from "../../../src/json.ts"; +import type { Contract } from "../../../src/next/contracts/index.ts"; +import { contractData } from "../../../src/next/contracts/internal.ts"; +import { snapshotSchema } from "../../../src/next/contracts/snapshot.ts"; +import { decodeJsonl, importStore, sqliteBackend } from "../../../src/next/store/index.ts"; +import { openNodeSqliteDatabase } from "../../../src/sqlite/node.ts"; +import * as declared from "./declarations.ts"; +import { finished, ownerHost } from "./owner.ts"; + +/** Every fingerprint and normal form of the declarations. */ +export function fingerprints(): JsonValue { + const contract = (C: Contract) => { + const data = contractData(C); + return { + params: data.params.normal, + versions: data.versions.map((version) => ({ + fingerprint: version.fingerprint, + variants: Object.fromEntries( + [...version.variants].map(([name, variant]) => [ + name, + { + state: Object.fromEntries([...variant.state].map(([m, s]) => [m, s.normal])), + keyed: Object.fromEntries([...variant.keyed].map(([m, s]) => [m, s.normal])), + methods: Object.fromEntries( + [...variant.methods].map(([m, { args, result }]) => [ + m, + { args: args.normal, result: result.normal }, + ]), + ), + }, + ]), + ), + })), + }; + }; + const table = (T: { readonly fingerprint: string; readonly record: unknown }) => ({ + fingerprint: T.fingerprint, + record: T.record as unknown as JsonValue, + }); + return { + contracts: { + everything: contract(declared.Everything), + account: contract(declared.Account), + owner: contract(declared.Owner), + }, + documents: { + profile: { + fingerprint: declared.Profile.fingerprint, + normal: snapshotSchema(declared.everything, "p").normal, + }, + notes: declared.Notes.fingerprint, + importState: declared.ImportState.fingerprint, + }, + tables: { + rows: table(declared.Rows), + editsV1: table(declared.EditsV1), + editsV2: table(declared.EditsV2), + editsV3: table(declared.EditsV3), + }, + job: declared.ImportJob.fingerprint, + }; +} + +const stored = fileURLToPath(new URL("./stored/", import.meta.url)); + +/** + * What a host of the current code reads from the stored data: from a copy of the SQLite store, and from a store + * imported from the archive (both written at table version 1, read at version 3; the stopped job runs resume). + */ +export async function storedReads(context: Context): Promise { + const directory = await mkdtemp(join(tmpdir(), "chord-schema-switch-")); + try { + const fromStore = join(directory, "store.db"); + await copyFile(join(stored, "store.db"), fromStore); + const store = await read(fromStore, context); + const imported = await importStore( + sqliteBackend(await openNodeSqliteDatabase(join(directory, "imported.db"))), + decodeJsonl(createReadStream(join(stored, "archive.jsonl"))), + context, + ); + await imported.close(context); + const archive = await read(join(directory, "imported.db"), context); + return { store, archive }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +/** The owner's dump on the store at `path`, after its stopped runs finished. */ +async function read(path: string, context: Context): Promise { + finished.length = 0; + const { store, host } = await ownerHost(path, declared.EditsV3, "finish", context); + const owner = await host.get(declared.Owner, {}, context); + for (let i = 0; i < 100 && finished.length < 3; i++) await new Promise((resolve) => setTimeout(resolve, 10)); + const dump = await owner.dump({}, context); + const result = { + dump: dump as JsonValue, + finished: [...finished] as JsonValue, + note: await owner.read("b", context), + }; + await host.close(context); + await store.close(context); + return result; +} diff --git a/packages/chord/test/next/schema/recorded.json b/packages/chord/test/next/schema/recorded.json new file mode 100644 index 000000000..00c0a3588 --- /dev/null +++ b/packages/chord/test/next/schema/recorded.json @@ -0,0 +1,1143 @@ +{ + "fingerprints": { + "contracts": { + "everything": { + "params": [ + [ + "id", + { + "type": "string" + } + ], + [ + "shard", + { + "type": "integer" + } + ] + ], + "versions": [ + { + "fingerprint": "3861c5a457ef4aa3", + "variants": { + "members": { + "state": { + "profile": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "age": { + "maximum": 150, + "minimum": 0, + "type": "integer" + }, + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "additionalProperties": false, + "properties": { + "deep": { + "items": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + } + }, + "required": [ + "x" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "deep" + ], + "type": "object" + } + ] + }, + "count": { + "default": 0, + "type": "integer" + }, + "extra": { + "patternProperties": { + "^.*$": { + "type": "integer" + } + }, + "type": "object" + }, + "kind": { + "const": "person", + "type": "string" + }, + "level": { + "anyOf": [ + { + "const": 1, + "type": "number" + }, + { + "const": 2, + "type": "number" + }, + { + "const": 3, + "type": "number" + } + ] + }, + "name": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[a-z ]+$", + "type": "string" + }, + "nested": { + "additionalProperties": false, + "properties": { + "inner": { + "additionalProperties": false, + "properties": { + "v": { + "type": "string" + } + }, + "required": [ + "v" + ], + "type": "object" + } + }, + "type": "object" + }, + "nick": { + "default": "x", + "type": "string" + }, + "note": { + "type": "string" + }, + "nothing": { + "type": "null" + }, + "one": { + "const": 1, + "type": "number" + }, + "points": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "ratio": { + "default": 0.5, + "type": "number" + }, + "role": { + "anyOf": [ + { + "const": "admin", + "type": "string" + }, + { + "const": "user", + "type": "string" + }, + { + "const": "guest", + "type": "string" + } + ] + }, + "score": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "tags": { + "items": { + "type": "string" + }, + "maxItems": 5, + "minItems": 0, + "type": "array" + }, + "verified": { + "default": false, + "type": "boolean" + }, + "yes": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "active", + "age", + "choice", + "count", + "extra", + "kind", + "level", + "name", + "nested", + "nick", + "nothing", + "one", + "points", + "ratio", + "role", + "score", + "tags", + "verified", + "yes" + ], + "type": "object" + } + }, + "keyed": { + "notes": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + } + }, + "methods": { + "plain": { + "args": { + "type": "null" + }, + "result": { + "type": "null" + } + }, + "args": { + "args": { + "additionalProperties": false, + "properties": { + "a": { + "type": "string" + }, + "any": {}, + "b": { + "type": "integer" + } + }, + "required": [ + "a", + "any" + ], + "type": "object" + }, + "result": { + "type": "null" + } + }, + "result": { + "args": { + "type": "null" + }, + "result": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "both": { + "args": { + "additionalProperties": false, + "properties": { + "when": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "when" + ], + "type": "object" + }, + "result": { + "anyOf": [ + { + "const": "ok", + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ] + } + }, + "blob": { + "args": { + "additionalProperties": false, + "properties": { + "data": { + "maxLength": 1024, + "type": "bytes" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "result": { + "type": "bytes" + } + } + } + } + } + } + ] + }, + "account": { + "params": [ + [ + "user", + { + "minLength": 1, + "type": "string" + } + ] + ], + "versions": [ + { + "fingerprint": "821be6a79fbe350a", + "variants": { + "owner": { + "state": { + "balance": { + "type": "number" + } + }, + "keyed": {}, + "methods": { + "pay": { + "args": { + "additionalProperties": false, + "properties": { + "amount": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "amount" + ], + "type": "object" + }, + "result": { + "type": "boolean" + } + } + } + }, + "guest": { + "state": { + "balance": { + "type": "number" + } + }, + "keyed": {}, + "methods": {} + } + } + } + ] + }, + "owner": { + "params": [], + "versions": [ + { + "fingerprint": "b01eec2d4e2311b0", + "variants": { + "members": { + "state": { + "profile": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "age": { + "maximum": 150, + "minimum": 0, + "type": "integer" + }, + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "additionalProperties": false, + "properties": { + "deep": { + "items": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + } + }, + "required": [ + "x" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "deep" + ], + "type": "object" + } + ] + }, + "count": { + "default": 0, + "type": "integer" + }, + "extra": { + "patternProperties": { + "^.*$": { + "type": "integer" + } + }, + "type": "object" + }, + "kind": { + "const": "person", + "type": "string" + }, + "level": { + "anyOf": [ + { + "const": 1, + "type": "number" + }, + { + "const": 2, + "type": "number" + }, + { + "const": 3, + "type": "number" + } + ] + }, + "name": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[a-z ]+$", + "type": "string" + }, + "nested": { + "additionalProperties": false, + "properties": { + "inner": { + "additionalProperties": false, + "properties": { + "v": { + "type": "string" + } + }, + "required": [ + "v" + ], + "type": "object" + } + }, + "type": "object" + }, + "nick": { + "default": "x", + "type": "string" + }, + "note": { + "type": "string" + }, + "nothing": { + "type": "null" + }, + "one": { + "const": 1, + "type": "number" + }, + "points": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "ratio": { + "default": 0.5, + "type": "number" + }, + "role": { + "anyOf": [ + { + "const": "admin", + "type": "string" + }, + { + "const": "user", + "type": "string" + }, + { + "const": "guest", + "type": "string" + } + ] + }, + "score": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "tags": { + "items": { + "type": "string" + }, + "maxItems": 5, + "minItems": 0, + "type": "array" + }, + "verified": { + "default": false, + "type": "boolean" + }, + "yes": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "active", + "age", + "choice", + "count", + "extra", + "kind", + "level", + "name", + "nested", + "nick", + "nothing", + "one", + "points", + "ratio", + "role", + "score", + "tags", + "verified", + "yes" + ], + "type": "object" + } + }, + "keyed": {}, + "methods": { + "write": { + "args": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "body", + "id" + ], + "type": "object" + }, + "result": { + "type": "null" + } + }, + "read": { + "args": { + "type": "string" + }, + "result": { + "type": "string" + } + }, + "dump": { + "args": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "result": {} + } + } + } + } + } + ] + } + }, + "documents": { + "profile": { + "fingerprint": "f345152aff1bb993", + "normal": { + "additionalProperties": false, + "properties": { + "active": { + "type": "boolean" + }, + "age": { + "maximum": 150, + "minimum": 0, + "type": "integer" + }, + "choice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "additionalProperties": false, + "properties": { + "deep": { + "items": { + "additionalProperties": false, + "properties": { + "x": { + "type": "number" + } + }, + "required": [ + "x" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "deep" + ], + "type": "object" + } + ] + }, + "count": { + "default": 0, + "type": "integer" + }, + "extra": { + "patternProperties": { + "^.*$": { + "type": "integer" + } + }, + "type": "object" + }, + "kind": { + "const": "person", + "type": "string" + }, + "level": { + "anyOf": [ + { + "const": 1, + "type": "number" + }, + { + "const": 2, + "type": "number" + }, + { + "const": 3, + "type": "number" + } + ] + }, + "name": { + "maxLength": 40, + "minLength": 1, + "pattern": "^[a-z ]+$", + "type": "string" + }, + "nested": { + "additionalProperties": false, + "properties": { + "inner": { + "additionalProperties": false, + "properties": { + "v": { + "type": "string" + } + }, + "required": [ + "v" + ], + "type": "object" + } + }, + "type": "object" + }, + "nick": { + "default": "x", + "type": "string" + }, + "note": { + "type": "string" + }, + "nothing": { + "type": "null" + }, + "one": { + "const": 1, + "type": "number" + }, + "points": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "ratio": { + "default": 0.5, + "type": "number" + }, + "role": { + "anyOf": [ + { + "const": "admin", + "type": "string" + }, + { + "const": "user", + "type": "string" + }, + { + "const": "guest", + "type": "string" + } + ] + }, + "score": { + "maximum": 100, + "minimum": 0, + "type": "number" + }, + "tags": { + "items": { + "type": "string" + }, + "maxItems": 5, + "minItems": 0, + "type": "array" + }, + "verified": { + "default": false, + "type": "boolean" + }, + "yes": { + "const": true, + "type": "boolean" + } + }, + "required": [ + "active", + "age", + "choice", + "count", + "extra", + "kind", + "level", + "name", + "nested", + "nick", + "nothing", + "one", + "points", + "ratio", + "role", + "score", + "tags", + "verified", + "yes" + ], + "type": "object" + } + }, + "notes": "bd44e8ea54b8f283", + "importState": "53d035820123516d" + }, + "tables": { + "rows": { + "fingerprint": "a8a7a46b1d1b5058", + "record": { + "id": { + "type": "str" + }, + "n": { + "type": "int", + "default": 0 + }, + "flag": { + "type": "bool", + "default": false + }, + "label": { + "type": "str", + "optional": true + }, + "data": { + "type": "json", + "version": 1, + "fingerprint": "595acb546ede10d5" + }, + "maybe": { + "type": "json", + "optional": true, + "version": 1, + "fingerprint": "27fc51c99dad9a88" + } + } + }, + "editsV1": { + "fingerprint": "b6a64f82180e9b7f", + "record": { + "id": { + "type": "str" + }, + "file": { + "type": "str" + }, + "at": { + "type": "int" + } + } + }, + "editsV2": { + "fingerprint": "2fbd0130077d5eed", + "record": { + "id": { + "type": "str" + }, + "path": { + "type": "str" + }, + "at": { + "type": "int" + }, + "dir": { + "type": "str", + "default": "" + } + } + }, + "editsV3": { + "fingerprint": "303055ac505946c3", + "record": { + "id": { + "type": "str" + }, + "path": { + "type": "str" + }, + "at": { + "type": "int" + } + } + } + }, + "job": "c280624818bc83ab" + }, + "stored": { + "store": { + "dump": { + "profile": { + "name": "ada", + "nick": "x", + "age": 36, + "count": 3, + "score": 1, + "ratio": 0.5, + "active": true, + "verified": false, + "role": "admin", + "level": 1, + "kind": "person", + "one": 1, + "yes": true, + "tags": [ + "a", + "b", + "c" + ], + "points": [], + "extra": { + "a": 10, + "b": 6, + "c": 9 + }, + "nothing": null, + "choice": { + "deep": [ + { + "x": 3 + } + ] + }, + "nested": { + "inner": { + "v": "third one" + } + } + }, + "notes": { + "a": { + "body": "first note" + }, + "b": { + "body": "second" + } + }, + "rows": [ + { + "id": "a", + "n": 0, + "flag": false, + "label": "first note", + "data": { + "a": 10, + "b": [ + "a" + ] + }, + "maybe": { + "n": 1.5 + } + }, + { + "id": "b", + "n": 0, + "flag": false, + "label": "second", + "data": { + "a": 6, + "b": [ + "b" + ] + } + }, + { + "id": "c", + "n": 0, + "flag": false, + "label": "third one", + "data": { + "a": 9, + "b": [ + "c" + ] + } + } + ], + "edits": [ + { + "id": "a", + "path": "/notes/a.md", + "at": 10 + }, + { + "id": "b", + "path": "/notes/b.md", + "at": 6 + }, + { + "id": "c", + "path": "/notes/c.md", + "at": 9 + } + ], + "runs": 0 + }, + "finished": [ + { + "count": 2 + }, + { + "count": 2 + }, + { + "count": 2 + } + ], + "note": "second" + }, + "archive": { + "dump": { + "profile": { + "name": "ada", + "nick": "x", + "age": 36, + "count": 3, + "score": 1, + "ratio": 0.5, + "active": true, + "verified": false, + "role": "admin", + "level": 1, + "kind": "person", + "one": 1, + "yes": true, + "tags": [ + "a", + "b", + "c" + ], + "points": [], + "extra": { + "a": 10, + "b": 6, + "c": 9 + }, + "nothing": null, + "choice": { + "deep": [ + { + "x": 3 + } + ] + }, + "nested": { + "inner": { + "v": "third one" + } + } + }, + "notes": { + "a": { + "body": "first note" + }, + "b": { + "body": "second" + } + }, + "rows": [ + { + "id": "a", + "n": 0, + "flag": false, + "label": "first note", + "data": { + "a": 10, + "b": [ + "a" + ] + }, + "maybe": { + "n": 1.5 + } + }, + { + "id": "b", + "n": 0, + "flag": false, + "label": "second", + "data": { + "a": 6, + "b": [ + "b" + ] + } + }, + { + "id": "c", + "n": 0, + "flag": false, + "label": "third one", + "data": { + "a": 9, + "b": [ + "c" + ] + } + } + ], + "edits": [ + { + "id": "a", + "path": "/notes/a.md", + "at": 10 + }, + { + "id": "b", + "path": "/notes/b.md", + "at": 6 + }, + { + "id": "c", + "path": "/notes/c.md", + "at": 9 + } + ], + "runs": 0 + }, + "finished": [ + { + "count": 2 + }, + { + "count": 2 + }, + { + "count": 2 + } + ], + "note": "second" + } + } +} diff --git a/packages/chord/test/next/schema/schema-switch.test.ts b/packages/chord/test/next/schema/schema-switch.test.ts new file mode 100644 index 000000000..830593422 --- /dev/null +++ b/packages/chord/test/next/schema/schema-switch.test.ts @@ -0,0 +1,22 @@ +// Tests of the schema switch from TypeBox to pi-schema (decision "A schema package beneath pi-ai and Chord"): the same +// declarations give byte-identical fingerprints and normal forms, and data stored by the code before the switch loads +// after it (`recorded.json` and `stored/` were made with the TypeBox helpers, see `write-stored.ts`). + +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { fingerprints, storedReads } from "./proofs.ts"; + +const recorded = JSON.parse(readFileSync(new URL("./recorded.json", import.meta.url), "utf8")); + +describe("fingerprints", () => { + it("keeps every fingerprint and normal form of contracts, documents, tables, json columns and jobs", () => { + expect(fingerprints()).toEqual(recorded.fingerprints); + }); +}); + +describe("stored data", () => { + it("reads a store and an archive written before the switch: documents, tables, a migrated table, stopped runs", async () => { + expect(await storedReads(context)).toEqual(recorded.stored); + }); +}); diff --git a/packages/chord/test/next/schema/stored/archive.jsonl b/packages/chord/test/next/schema/stored/archive.jsonl new file mode 100644 index 000000000..7fcbd5972 --- /dev/null +++ b/packages/chord/test/next/schema/stored/archive.jsonl @@ -0,0 +1,36 @@ +{"kind":"manifest","format":1,"schema":2,"lastSeq":13,"nextId":12} +{"kind":"table","table":"fixture.switch.owner:fixture.switch.edits","version":1,"state":"ready","columns":{"id":{"type":"str"},"file":{"type":"str"},"at":{"type":"int"}},"key":["id"],"fingerprint":"b6a64f82180e9b7f"} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.edits","row":{"id":"a","file":"/notes/a.md","at":10},"version":1,"columns":{}} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.edits","row":{"id":"b","file":"/notes/b.md","at":6},"version":1,"columns":{}} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.edits","row":{"id":"c","file":"/notes/c.md","at":9},"version":1,"columns":{}} +{"kind":"table","table":"fixture.switch.owner:fixture.switch.rows","version":1,"state":"ready","columns":{"id":{"type":"str"},"n":{"type":"int","default":0},"flag":{"type":"bool","default":false},"label":{"type":"str","optional":true},"data":{"type":"json","version":1,"fingerprint":"595acb546ede10d5"},"maybe":{"type":"json","optional":true,"version":1,"fingerprint":"27fc51c99dad9a88"}},"key":["id"],"fingerprint":"a8a7a46b1d1b5058"} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.rows","row":{"id":"a","n":0,"flag":false,"label":"first note","data":{"a":10,"b":["a"]},"maybe":{"n":1.5}},"version":1,"columns":{"data":1,"maybe":1}} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.rows","row":{"id":"b","n":0,"flag":false,"label":"second","data":{"a":6,"b":["b"]}},"version":1,"columns":{"data":1}} +{"kind":"put","table":"fixture.switch.owner:fixture.switch.rows","row":{"id":"c","n":0,"flag":false,"label":"third one","data":{"a":9,"b":["c"]}},"version":1,"columns":{"data":1}} +{"kind":"index","table":"fixture.switch.owner:fixture.switch.rows","name":"by_n","columns":["n","id"]} +{"kind":"document.create","record":{"id":2,"owner":"fixture.switch.owner","namespace":"chord","kind":"fixture.switch.notes","key":"a","history":"none","createdAt":2,"revision":1,"version":1,"fingerprint":"bd44e8ea54b8f283"}} +{"kind":"document.revision","id":2,"seq":2,"content":{"kind":"base","version":1,"value":{"body":"first note"}}} +{"kind":"document.create","record":{"id":3,"owner":"fixture.switch.owner","namespace":"chord","kind":"fixture.switch.profile","history":"none","createdAt":2,"revision":3,"version":1,"fingerprint":"f345152aff1bb993"}} +{"kind":"document.revision","id":3,"seq":2,"content":{"kind":"base","version":1,"value":{"name":"ada","nick":"x","age":36,"count":1,"score":1,"ratio":0.5,"active":true,"verified":false,"role":"admin","level":1,"kind":"person","one":1,"yes":true,"tags":["a"],"points":[],"extra":{"a":10},"nothing":null,"choice":{"deep":[{"x":1}]},"nested":{"inner":{"v":"first note"}}}}} +{"kind":"document.revision","id":3,"seq":5,"content":{"kind":"delta","version":1,"ops":[["s",["count"],2],["s",["choice"],{"deep":[{"x":2}]}],["s",["nested"],{"inner":{"v":"second"}}],["p",["tags"],1,0,["b"]],["s",["extra","b"],6]]}} +{"kind":"document.revision","id":3,"seq":9,"content":{"kind":"delta","version":1,"ops":[["s",["count"],3],["s",["choice"],{"deep":[{"x":3}]}],["s",["nested"],{"inner":{"v":"third one"}}],["p",["tags"],2,0,["c"]],["s",["extra","c"],9]]}} +{"kind":"document.create","record":{"id":4,"owner":"fixture.switch.owner","namespace":"chord.jobs","kind":"fixture.switch.import","key":"01a1943d-176c-71f3-a32c-b9cbc67247a3","history":"none","createdAt":3,"revision":2,"version":1,"fingerprint":"chord.job-run.1"}} +{"kind":"document.revision","id":4,"seq":4,"content":{"kind":"base","version":1,"value":{"version":1,"fingerprint":"c280624818bc83ab","args":{"path":"a"},"phase":"running","restarts":0,"finishFailures":0}}} +{"kind":"document.create","record":{"id":5,"owner":"fixture.switch.owner","namespace":"chord.job-states","kind":"fixture.switch.import","key":"01a1943d-176c-71f3-a32c-b9cbc67247a3","history":"none","createdAt":3,"revision":2,"version":1,"fingerprint":"53d035820123516d"}} +{"kind":"document.revision","id":5,"seq":3,"content":{"kind":"base","version":1,"value":{"done":0}}} +{"kind":"document.revision","id":5,"seq":6,"content":{"kind":"delta","version":1,"ops":[["s",["done"],1]]}} +{"kind":"document.create","record":{"id":6,"owner":"fixture.switch.owner","namespace":"chord","kind":"fixture.switch.notes","key":"b","history":"none","createdAt":5,"revision":1,"version":1,"fingerprint":"bd44e8ea54b8f283"}} +{"kind":"document.revision","id":6,"seq":5,"content":{"kind":"base","version":1,"value":{"body":"second"}}} +{"kind":"document.create","record":{"id":7,"owner":"fixture.switch.owner","namespace":"chord.jobs","kind":"fixture.switch.import","key":"01a1943d-176e-71f3-a32c-b9cc7f5426c6","history":"none","createdAt":7,"revision":2,"version":1,"fingerprint":"chord.job-run.1"}} +{"kind":"document.revision","id":7,"seq":8,"content":{"kind":"base","version":1,"value":{"version":1,"fingerprint":"c280624818bc83ab","args":{"path":"b"},"phase":"running","restarts":0,"finishFailures":0}}} +{"kind":"document.create","record":{"id":8,"owner":"fixture.switch.owner","namespace":"chord.job-states","kind":"fixture.switch.import","key":"01a1943d-176e-71f3-a32c-b9cc7f5426c6","history":"none","createdAt":7,"revision":2,"version":1,"fingerprint":"53d035820123516d"}} +{"kind":"document.revision","id":8,"seq":7,"content":{"kind":"base","version":1,"value":{"done":0}}} +{"kind":"document.revision","id":8,"seq":10,"content":{"kind":"delta","version":1,"ops":[["s",["done"],1]]}} +{"kind":"document.create","record":{"id":9,"owner":"fixture.switch.owner","namespace":"chord","kind":"fixture.switch.notes","key":"c","history":"none","createdAt":9,"revision":1,"version":1,"fingerprint":"bd44e8ea54b8f283"}} +{"kind":"document.revision","id":9,"seq":9,"content":{"kind":"base","version":1,"value":{"body":"third one"}}} +{"kind":"document.create","record":{"id":10,"owner":"fixture.switch.owner","namespace":"chord.jobs","kind":"fixture.switch.import","key":"01a1943d-176f-71f3-a32c-b9cf6bb98dc7","history":"none","createdAt":11,"revision":2,"version":1,"fingerprint":"chord.job-run.1"}} +{"kind":"document.revision","id":10,"seq":12,"content":{"kind":"base","version":1,"value":{"version":1,"fingerprint":"c280624818bc83ab","args":{"path":"c"},"phase":"running","restarts":0,"finishFailures":0}}} +{"kind":"document.create","record":{"id":11,"owner":"fixture.switch.owner","namespace":"chord.job-states","kind":"fixture.switch.import","key":"01a1943d-176f-71f3-a32c-b9cf6bb98dc7","history":"none","createdAt":11,"revision":2,"version":1,"fingerprint":"53d035820123516d"}} +{"kind":"document.revision","id":11,"seq":11,"content":{"kind":"base","version":1,"value":{"done":0}}} +{"kind":"document.revision","id":11,"seq":13,"content":{"kind":"delta","version":1,"ops":[["s",["done"],1]]}} +{"kind":"end","writes":34} diff --git a/packages/chord/test/next/schema/stored/store.db b/packages/chord/test/next/schema/stored/store.db new file mode 100644 index 000000000..f9b1e7083 Binary files /dev/null and b/packages/chord/test/next/schema/stored/store.db differ diff --git a/packages/chord/test/next/schema/write-stored.ts b/packages/chord/test/next/schema/write-stored.ts new file mode 100644 index 000000000..e40248d32 --- /dev/null +++ b/packages/chord/test/next/schema/write-stored.ts @@ -0,0 +1,40 @@ +// Writes the stored data of the schema switch's proof (`stored/`): a SQLite store and its export archive, made by the +// code before the switch (TypeBox helpers). Kept as it was written; rerun only to make a new fixture on purpose. +// Run from packages/chord: +// node --conditions=source --experimental-strip-types test/next/schema/write-stored.ts + +import { createWriteStream } from "node:fs"; +import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; +import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts"; +import { encodeJsonl } from "../../../src/next/store/index.ts"; +import { EditsV1, Owner } from "./declarations.ts"; +import { ownerHost } from "./owner.ts"; + +const stored = fileURLToPath(new URL("./stored/", import.meta.url)); +const directory = await mkdtemp(join(tmpdir(), "chord-schema-switch-")); +const path = join(directory, "store.db"); +try { + const { store, host } = await ownerHost(path, EditsV1, "wait", context); + const owner = await host.get(Owner, {}, context); + for (const [id, body] of [ + ["a", "first note"], + ["b", "second"], + ["c", "third one"], + ] as const) { + await owner.write({ id, body }, context); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + await mkdir(stored, { recursive: true }); + await pipeline(encodeJsonl(store.export(context)), createWriteStream(join(stored, "archive.jsonl"))); + // A clean close: the waiting runs are stored `stopped`, resumed by the next host. + await host.close(context); + await store.close(context); + await copyFile(path, join(stored, "store.db")); + console.log("written to", stored); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/packages/chord/test/placement.test.ts b/packages/chord/test/placement.test.ts index 869bfa655..3e3a7bd8e 100644 --- a/packages/chord/test/placement.test.ts +++ b/packages/chord/test/placement.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, opt, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { bind, @@ -22,17 +22,17 @@ const tick = () => new Promise((resolve) => setImmediate(resolve)); /** Lives on `session:` + its input's `session`. */ const Note = remote("t.note", { version: 1, - input: Type.Object({ session: Type.String(), id: Type.String() }), + input: obj({ session: str(), id: str() }, { open: true }), host: hostFamily("session:", "session"), - variants: { main: { methods: { where: method(undefined, Type.String()) } } }, + variants: { main: { methods: { where: method(undefined, str()) } } }, }); /** Lives on `server`. */ const Catalog = remote("t.catalog", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { where: method(undefined, Type.String()) } } }, + variants: { main: { methods: { where: method(undefined, str()) } } }, }); /** A server with two session hosts as children; every host serves what it may and reports its name. */ @@ -126,9 +126,9 @@ describe("placement", () => { it("the host comes from the normalized input: unknown fields and defaults do not change it", async () => { const Doc = remote("t.doc-default", { version: 1, - input: Type.Object({ session: Type.String({ default: "S1" }) }), + input: obj({ session: str({ default: "S1" }) }, { open: true }), host: hostFamily("session:", "session"), - variants: { main: { methods: { where: method(undefined, Type.String()) } } }, + variants: { main: { methods: { where: method(undefined, str()) } } }, }); const { server, s1, close } = cluster(); s1.serve(Doc, () => ({ variant: "main", where: () => "session:S1" })); @@ -143,7 +143,7 @@ describe("placement", () => { const at = (host: string) => remote("t.moved", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed(host), variants: { main: { methods: { ping: method() } } }, }); @@ -155,7 +155,7 @@ describe("placement", () => { api.serve( remote("t.moved", { version, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed(place), variants: { main: { methods: { ping: method() } } }, }), @@ -170,7 +170,7 @@ describe("placement", () => { // A new version that `accepts` version 1 but lives elsewhere: no adapter makes version 1 consumers bind. const moved = remote("t.moved", { version: 2, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), variants: { main: { methods: { ping: method() } } }, }); @@ -182,7 +182,7 @@ describe("placement", () => { }); it("types: the family's field is a required string of the input, and remote contracts need a host", () => { - const input = Type.Object({ session: Type.String(), count: Type.Number(), note: Type.Optional(Type.String()) }); + const input = obj({ session: str(), count: num(), note: opt(str()) }, { open: true }); const variants = { main: {} }; remote("t.ok", { version: 1, input, host: hostFamily("session:", "session"), variants }); // @ts-expect-error: no such field diff --git a/packages/chord/test/principals.test.ts b/packages/chord/test/principals.test.ts index 4aef22cb3..aab011dab 100644 --- a/packages/chord/test/principals.test.ts +++ b/packages/chord/test/principals.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -26,8 +26,8 @@ const principalKey = (principal: Principal): string => const Who = local("t.who", { version: 1, - input: Type.Object({}), - members: { who: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { who: method(undefined, str()) }, }); describe("principals", () => { @@ -86,9 +86,9 @@ describe("principals", () => { it("a child's parent acts as the principal the child gave `connect`; relayed callers keep theirs", async () => { const Echo = remote("t.echo", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("worker"), - variants: { main: { methods: { who: method(undefined, Type.String()) } } }, + variants: { main: { methods: { who: method(undefined, str()) } } }, }); const server = createHost({ name: "server", self: { kind: "host" }, principalKey }); const [down, up] = memoryLinkPair(); diff --git a/packages/chord/test/provider-cost.test.ts b/packages/chord/test/provider-cost.test.ts index 470c74b20..50a8bf533 100644 --- a/packages/chord/test/provider-cost.test.ts +++ b/packages/chord/test/provider-cost.test.ts @@ -1,7 +1,7 @@ // Entry 8: what a change costs the provider. The diff is computed once per change; each feed (one per connection and // member) encodes it with its own stateful encoder and its link serializes it. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, obj, str } from "@earendil-works/pi-schema"; import { describe, it, vi } from "vitest"; import type * as delta from "../src/delta/index.ts"; import { type Ctx, createHost, hostNamed, memoryLinkPair, method, remote } from "../src/index.ts"; @@ -30,16 +30,16 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const List = remote("t.e8-cost", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { items: Type.Array(Type.String()) }, methods: { add: method(Type.String()) } } }, + variants: { main: { state: { items: arr(str()) }, methods: { add: method(str()) } } }, }); describe("provider cost", () => { it("one change to K clients: K encodes (one per feed), one frame per link, the same ops on every link", async () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(List, (_input, _principal, self) => { - const items = self.state("items", Type.Array(Type.String()), []); + const items = self.state("items", arr(str()), []); return { variant: "main", items, diff --git a/packages/chord/test/readiness.test.ts b/packages/chord/test/readiness.test.ts index a781a6e34..b34b2540c 100644 --- a/packages/chord/test/readiness.test.ts +++ b/packages/chord/test/readiness.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { obj } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { createHost, @@ -15,8 +15,8 @@ import { const tick = () => new Promise((resolve) => setImmediate(resolve)); const unexpectedError = (error: unknown) => assert.fail(String(error)); -const Eager = local("ready.eager", { version: 1, input: Type.Object({}) }); -const Lazy = local("ready.lazy", { version: 1, input: Type.Object({}) }); +const Eager = local("ready.eager", { version: 1, input: obj({}, { open: true }) }); +const Lazy = local("ready.lazy", { version: 1, input: obj({}, { open: true }) }); // Startup must not report ready while an eager object has failed. describe("host: eager readiness", () => { @@ -162,7 +162,7 @@ describe("host: eager readiness", () => { it("ready includes direct host serves, but not later serves", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); - const First = local("ready.first", { version: 1, input: Type.Object({}) }); + const First = local("ready.first", { version: 1, input: obj({}, { open: true }) }); let created = 0; try { host.serve( @@ -201,7 +201,7 @@ describe("host: eager readiness", () => { }); const Status = remote("ready.status", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { default: {} }, }); diff --git a/packages/chord/test/reload.test.ts b/packages/chord/test/reload.test.ts index f2931f0c9..b9998b316 100644 --- a/packages/chord/test/reload.test.ts +++ b/packages/chord/test/reload.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, opt, type Schema, str, unknown } from "@earendil-works/pi-schema"; import { describe, it, onTestFinished, vi } from "vitest"; import { type Ctx, @@ -30,15 +30,15 @@ function latch() { const Greeter = local("t.greeter", { version: 1, - input: Type.Object({ id: Type.Optional(Type.String({ default: "a" })) }), - members: { greet: method(Type.String(), Type.String()), count: Type.Unknown() }, + input: obj({ id: opt(str({ default: "a" })) }, { open: true }), + members: { greet: method(str(), str()), count: unknown() }, }); -const CountV1 = Type.Object({ n: Type.Number() }); +const CountV1 = obj({ n: num() }, { open: true }); interface GreeterSpec { readonly greeting: string; - readonly schema?: typeof CountV1 | ReturnType; + readonly schema?: typeof CountV1 | Schema; readonly initial?: object; readonly bump?: (draft: Record) => void; readonly options?: ServeOptions; @@ -129,7 +129,7 @@ describe("reload", () => { const writing = greeterFacet({ greeting: "hi", extra: (self) => { - const probe = self.state("probe", Type.Object({}), {}); + const probe = self.state("probe", obj({}, { open: true }), {}); probe.replace(captured!, {}); }, }); @@ -164,7 +164,7 @@ describe("reload", () => { await host.hold(Greeter, {}).ready; const changed = greeterFacet({ greeting: "hi", - schema: Type.Object({ total: Type.Number() }), + schema: obj({ total: num() }, { open: true }), initial: { total: 0 }, }); await assert.rejects( @@ -191,7 +191,7 @@ describe("reload", () => { const froms: number[] = []; const v2 = greeterFacet({ greeting: "hi", - schema: Type.Object({ total: Type.Number() }), + schema: obj({ total: num() }, { open: true }), initial: { total: 0 }, bump: (draft) => { draft.total = (draft.total ?? 0) + 1; @@ -218,16 +218,16 @@ describe("reload", () => { const host = quiet(); const Pair = local("t.pair", { version: 1, - input: Type.Object({}), - members: { a: Type.Unknown(), b: Type.Unknown(), bump: method() }, + input: obj({}, { open: true }), + members: { a: unknown(), b: unknown(), bump: method() }, }); const migrated: string[] = []; const facet = (version: number): Facet => ({ name: "pair", setup: (api) => api.serve(Pair, (_input, _principal, self) => { - const a = self.state("a", Type.Number(), 1); - const b = self.state("b", Type.Number(), 1, { + const a = self.state("a", num(), 1); + const b = self.state("b", num(), 1, { version, migrate: (old, from) => { migrated.push(`b ${from}->${version}`); @@ -294,7 +294,7 @@ describe("reload", () => { await new Promise((resolve) => ctx.signal.addEventListener("abort", () => resolve())); log.push(`stop ${greeting}`); }); - const count = self.state("count", Type.Number(), 0, { version }); + const count = self.state("count", num(), 0, { version }); return { count, greet: (name: string) => `${greeting} ${name}` }; }), }); @@ -340,7 +340,7 @@ describe("reload", () => { open: () => void log.push(`open cache ${rev}`), close: () => void log.push(`close cache ${rev}`), }); - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); return { count, greet: (name: string) => `${lock.value} ${name}` }; }), }); @@ -523,21 +523,21 @@ describe("reload", () => { it("checks contracts at reload: forgotten bumps, live consumers, accepts adapters", async () => { const DocV1 = remote("t.rdoc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { methods: { read: method(undefined, Type.Object({ text: Type.String() })) } } }, + variants: { main: { methods: { read: method(undefined, obj({ text: str() }, { open: true })) } } }, }); const DocBroken = remote("t.rdoc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { methods: { read: method(undefined, Type.Object({ text: Type.Number() })) } } }, + variants: { main: { methods: { read: method(undefined, obj({ text: num() }, { open: true })) } } }, }); const DocV2 = remote("t.rdoc", { version: 2, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { methods: { fetch: method(undefined, Type.Object({ body: Type.String() })) } } }, + variants: { main: { methods: { fetch: method(undefined, obj({ body: str() }, { open: true })) } } }, }); const host = quiet(); host.install({ @@ -590,14 +590,14 @@ describe("reload", () => { const log: string[] = []; const Sum = local("t.sum", { version: 1, - input: Type.Object({}), - members: { sum: Type.Object({ v: Type.Number() }) }, + input: obj({}, { open: true }), + members: { sum: obj({ v: num() }, { open: true }) }, }); const facet = (factor: number): Facet => ({ name: "sum", setup: (api) => api.serve(Sum, (_input, _principal, self) => { - const sum = self.state("sum", Type.Object({ v: Type.Number() }), { v: 0 }); + const sum = self.state("sum", obj({ v: num() }, { open: true }), { v: 0 }); self.on( "add", events, @@ -632,7 +632,7 @@ describe("reload", () => { it("keeps registry tables per code version", async () => { const host = quiet(); const Routes = defineRegistry("t.reload-routes"); - const Router = local("t.router", { version: 1, input: Type.Object({}) }); + const Router = local("t.router", { version: 1, input: obj({}, { open: true }) }); const facet = (value: string): Facet => ({ name: "router", setup: (api) => @@ -672,8 +672,8 @@ describe("reload", () => { await ref.ready; const Clock = local("t.clock", { version: 1, - input: Type.Object({}), - members: { now: method(undefined, Type.Number()) }, + input: obj({}, { open: true }), + members: { now: method(undefined, num()) }, }); const clockFacet: Facet = { name: "clock", setup: (api) => api.serve(Clock, () => ({ now: () => 42 })) }; const consumer = (fail: boolean): Facet => ({ diff --git a/packages/chord/test/requests.test.ts b/packages/chord/test/requests.test.ts index fef1f2a30..5e3cc5abb 100644 --- a/packages/chord/test/requests.test.ts +++ b/packages/chord/test/requests.test.ts @@ -1,6 +1,6 @@ // Regression tests for resolved application requests (#1-#10), numbered as in their test names. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, num, obj, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { applyImmutable } from "../src/delta/index.ts"; import { @@ -36,19 +36,19 @@ function latch() { return { promise, open }; } -const View = Type.Object({ text: Type.String() }); +const View = obj({ text: str() }, { open: true }); describe("application requests", () => { it("#1 a held object's state member can be returned as this object's state member", async () => { const Conv = remote("t.conv", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { view: View }, methods: { set: method(Type.String()) } } }, + variants: { main: { state: { view: View }, methods: { set: method(str()) } } }, }); const Chat = remote("t.chat", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { viewer: { state: { view: View } } }, }); @@ -95,11 +95,11 @@ describe("application requests", () => { it("#2 facets reach their own host through FacetApi.root and FacetApi.registry", async () => { const Config = defineRegistry("t.config"); - const Settings = local("t.settings", { version: 1, input: Type.Object({}) }); + const Settings = local("t.settings", { version: 1, input: obj({}, { open: true }) }); const Reader = local("t.reader", { version: 1, - input: Type.Object({}), - members: { read: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { read: method(undefined, str()) }, }); const captured: FacetApi[] = []; const facet = (value: string): Facet => ({ @@ -135,8 +135,8 @@ describe("application requests", () => { it("#3 a listener keeps its subscription across reloads when resource and select are unchanged", async () => { const Watcher = local("t.watcher", { version: 1, - input: Type.Object({}), - members: { seen: Type.Array(Type.String()) }, + input: obj({}, { open: true }), + members: { seen: arr(str()) }, }); // A source that accepts one subscriber at a time (like a Durable watch). let subscribers = 0; @@ -155,7 +155,7 @@ describe("application requests", () => { name: "watcher", setup: (api) => api.serve(Watcher, (_input, _principal, self) => { - const seen = self.state("seen", Type.Array(Type.String()), []); + const seen = self.state("seen", arr(str()), []); const watchResource = self.resource("watch", { version: 1, open: () => single }); // A new adapter object per call, and a new arrow per factory run. self.on( @@ -181,7 +181,7 @@ describe("application requests", () => { }); it("#3 a changed select resubscribes", async () => { - const Watcher = local("t.watcher2", { version: 1, input: Type.Object({}) }); + const Watcher = local("t.watcher2", { version: 1, input: obj({}, { open: true }) }); let subscribes = 0; const source = { subscribe: () => { @@ -210,10 +210,10 @@ describe("application requests", () => { it("#4 replace diffs a new revision that shares structure; unrelated values replace", async () => { const Mirror = local("t.mirror", { version: 1, - input: Type.Object({}), - members: { doc: Type.Unknown(), set: method(Type.Unknown()) }, + input: obj({}, { open: true }), + members: { doc: unknown(), set: method(unknown()) }, }); - const Doc = Type.Object({ entries: Type.Array(Type.String()), status: Type.String() }); + const Doc = obj({ entries: arr(str()), status: str() }, { open: true }); let lastOptions: { diff?: boolean } | undefined; const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.serve(Mirror, (_input, _principal, self) => { @@ -256,9 +256,9 @@ describe("application requests", () => { it("#5 roots of a child that connected behind the server are admitted in the server's code version", async () => { const Echo = remote("t.echo", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { codeVersion: method(undefined, Type.Number()) } } }, + variants: { main: { methods: { codeVersion: method(undefined, num()) } } }, }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); const echoFacet: Facet = { @@ -293,7 +293,7 @@ describe("application requests", () => { it("#6 self.expose: an opaque member backed by a resource, readable once live", async () => { const Store = local("t.store", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { db: opaque<{ readonly queries: string[] }>("t.db@1") }, }); const gate = latch(); @@ -315,7 +315,7 @@ describe("application requests", () => { await ref.ready; assert.equal(ref.db, native); - const Other = local("t.other", { version: 1, input: Type.Object({}) }); + const Other = local("t.other", { version: 1, input: obj({}, { open: true }) }); host.serve(Other, (_input, _principal, self) => { self.expose({ key: "x", value: 1 }); return {}; @@ -326,9 +326,9 @@ describe("application requests", () => { it("#7 a cluster reload reports what each child prepared", async () => { const Greeter = remote("t.greeter", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("session:S1"), - variants: { main: { methods: { greet: method(Type.String(), Type.String()) } } }, + variants: { main: { methods: { greet: method(str(), str()) } } }, }); const greeter = (text: string): Facet => ({ name: "greeter", @@ -362,13 +362,13 @@ describe("application requests", () => { it("#8 a held remote reference keeps its state without listeners; a later subscriber sees the current value", async () => { const Counter = remote("t.count8", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { n: Type.Number() }, methods: { set: method(Type.Number()) } } }, + variants: { main: { state: { n: num() }, methods: { set: method(num()) } } }, }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Counter, (_input, _principal, self) => { - const n = self.state("n", Type.Number(), 0); + const n = self.state("n", num(), 0); return { variant: "main", n, @@ -404,7 +404,7 @@ describe("application requests", () => { it("#9 references report status changes: live, loading while a link reconnects, gone, released", async () => { const Thing = remote("t.thing9", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: {} }, }); @@ -449,9 +449,9 @@ describe("application requests", () => { const log: string[] = []; const Remote = remote("t.remote10", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { methods: { note: method(Type.String()) } } }, + variants: { main: { methods: { note: method(str()) } } }, }); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Remote, () => ({ @@ -462,10 +462,10 @@ describe("application requests", () => { })); const Proc = local("t.proc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { tracker: opaque>("t.tracker@1") }, }); - const Chat = local("t.chat10", { version: 1, input: Type.Object({}) }); + const Chat = local("t.chat10", { version: 1, input: obj({}, { open: true }) }); const [down, up] = memoryLinkPair(); server.accept(down, { principal: { worker: "w" }, kind: "host", name: "w" }); const worker = createHost({ diff --git a/packages/chord/test/review.test.ts b/packages/chord/test/review.test.ts index 554a24097..418d592ea 100644 --- a/packages/chord/test/review.test.ts +++ b/packages/chord/test/review.test.ts @@ -1,7 +1,6 @@ // Regression tests for Sol's phase 1a review (findings #1-#11) and the requested race tests. import assert from "node:assert/strict"; -import { Type } from "typebox"; -import { Value } from "typebox/value"; +import { arr, bool, compile, num, obj, opt, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -18,6 +17,7 @@ import { } from "../src/index.ts"; import { fits } from "../src/schema.ts"; import { CountingLink } from "./fixtures/counting.ts"; +import { typedLit } from "./fixtures/schemas.ts"; const tick = () => new Promise((resolve) => setImmediate(resolve)); const ticks = async (n = 10) => { @@ -51,8 +51,8 @@ function emitter(options: { failSubscribe?: boolean } = {}) { const Item = local("t.item", { version: 1, - input: Type.Object({ id: Type.String() }), - members: { version: method(undefined, Type.String()) }, + input: obj({ id: str() }, { open: true }), + members: { version: method(undefined, str()) }, }); describe("review: reload commit", () => { @@ -82,15 +82,15 @@ describe("review: reload commit", () => { const host = createHost({ self: { kind: "host" }, dev: true, onError: () => {} }); const V1 = remote("t.counted", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { state: { count: Type.Object({ n: Type.Number() }) } } }, + variants: { main: { state: { count: obj({ n: num() }, { open: true }) } } }, }); const V2 = remote("t.counted", { version: 2, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { state: { count: Type.Object({ total: Type.Number() }) } } }, + variants: { main: { state: { count: obj({ total: num() }, { open: true }) } } }, }); host.install({ name: "counted", @@ -99,7 +99,7 @@ describe("review: reload commit", () => { V1, (_input, _principal, self) => ({ variant: "main", - count: self.state("count", Type.Object({ n: Type.Number() }), { n: 3 }), + count: self.state("count", obj({ n: num() }, { open: true }), { n: 3 }), }), { linger: 60_000 }, ), @@ -117,7 +117,7 @@ describe("review: reload commit", () => { variant: "main", count: self.state( "count", - Type.Object({ total: Type.Number() }), + obj({ total: num() }, { open: true }), { total: 0 }, { version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) }, ), @@ -160,8 +160,8 @@ describe("review: reload commit", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Other = local("t.other", { version: 1, - input: Type.Object({}), - members: { version: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { version: method(undefined, str()) }, }); const facet = (version: string): Facet => ({ name: "pair", @@ -184,8 +184,8 @@ describe("review: reload commit", () => { describe("review: leases, invocations, activation", () => { const Store = local("t.store", { version: 1, - input: Type.Object({}), - members: { count: Type.Unknown() }, + input: obj({}, { open: true }), + members: { count: unknown() }, }); it("#4 a running listener holds a lease: the gate waits for it before migrating", async () => { @@ -197,7 +197,7 @@ describe("review: leases, invocations, activation", () => { name: "store", setup: (api) => api.serve(Store, (_input, _principal, self) => { - const count = self.state("count", Type.Object({ n: Type.Number() }), { n: 1 }); + const count = self.state("count", obj({ n: num() }, { open: true }), { n: 1 }); self.on("slow", events, async () => { await go.promise; seen.push(count.value); @@ -218,7 +218,7 @@ describe("review: leases, invocations, activation", () => { api.serve(Store, (_input, _principal, self) => ({ count: self.state( "count", - Type.Object({ total: Type.Number() }), + obj({ total: num() }, { open: true }), { total: 0 }, { version: 2, migrate: (old) => ({ total: (old as { n: number }).n }) }, ), @@ -238,8 +238,8 @@ describe("review: leases, invocations, activation", () => { it("#5 disposal waits for running methods before closing resources (released and forced)", async () => { const Worker = local("t.busy", { version: 1, - input: Type.Object({ id: Type.String() }), - members: { work: method(undefined, Type.Boolean()) }, + input: obj({ id: str() }, { open: true }), + members: { work: method(undefined, bool()) }, }); const log: string[] = []; let go = latch(); @@ -291,7 +291,9 @@ describe("review: leases, invocations, activation", () => { const host = createHost({ self: { kind: "host" }, onError: (error) => errors.push(error) }); const Routes = defineRegistry("t.review-routes"); const log: string[] = []; - const types = ["t.eager-a", "t.eager-b"].map((name) => local(name, { version: 1, input: Type.Object({}) })); + const types = ["t.eager-a", "t.eager-b"].map((name) => + local(name, { version: 1, input: obj({}, { open: true }) }), + ); for (const type of types) { host.serve( type, @@ -321,7 +323,7 @@ describe("review: leases, invocations, activation", () => { let runs = 0; host.serve(Store, (_input, _principal, self) => { runs++; - const count = self.state("count", Type.Number(), 0); + const count = self.state("count", num(), 0); self.resource("r", { version: 1, open: () => void log.push("open"), @@ -345,8 +347,8 @@ describe("review: leases, invocations, activation", () => { describe("review: prepare isolation and contracts", () => { const Db = local("t.db", { version: 1, - input: Type.Object({}), - members: { read: method(undefined, Type.Unknown()) }, + input: obj({}, { open: true }), + members: { read: method(undefined, unknown()) }, }); it("#7 a prepare rerun cannot touch retained resources, even open ones", async () => { @@ -372,7 +374,7 @@ describe("review: prepare isolation and contracts", () => { it("#7 objects of types a reload adds start listeners and tasks only at commit", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const log: string[] = []; - const Fresh = local("t.fresh", { version: 1, input: Type.Object({}) }); + const Fresh = local("t.fresh", { version: 1, input: obj({}, { open: true }) }); host.install({ name: "item", setup: (api) => api.serve(Item, () => ({ version: () => "v1" })) }); await Promise.all(["a", "b"].map((id) => host.hold(Item, { id }).ready)); const plugins = (fail: boolean): Facet[] => [ @@ -408,20 +410,20 @@ describe("review: prepare isolation and contracts", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const P1 = remote("t.provider", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { methods: { get: method(undefined, Type.String()) } } }, + variants: { main: { methods: { get: method(undefined, str()) } } }, }); const P2 = remote("t.provider", { version: 2, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), - variants: { main: { methods: { fetch: method(undefined, Type.String()) } } }, + variants: { main: { methods: { fetch: method(undefined, str()) } } }, }); const Consumer = local("t.consumer", { version: 1, - input: Type.Object({}), - members: { ask: method(undefined, Type.String()) }, + input: obj({}, { open: true }), + members: { ask: method(undefined, str()) }, }); host.install({ name: "both", @@ -493,7 +495,7 @@ describe("review: prepare isolation and contracts", () => { it("#10 retirement closes dropped resources before releasing dropped holds", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const log: string[] = []; - const Dep = local("t.dep", { version: 1, input: Type.Object({}) }); + const Dep = local("t.dep", { version: 1, input: obj({}, { open: true }) }); const facet = (withDecls: boolean): Facet => ({ name: "item", setup: (api) => { @@ -535,48 +537,49 @@ describe("review: prepare isolation and contracts", () => { assert.deepEqual(log, ["own closed", "dep closed"]); }); - it("#11 fits has no false positives for unmodelled keywords; counterexamples checked with Value.Check", () => { + it("#11 fits has no false positives for unmodelled keywords; counterexamples checked with compile", () => { const cases = [ - [Type.String(), Type.String({ not: { const: "x" } }), "x"], - [Type.Array(Type.Number()), Type.Array(Type.Number(), { contains: Type.Literal(1) }), [2]], + [str(), { ...str(), not: { const: "x" } }, "x"], + [arr(num()), { ...arr(num()), contains: typedLit(1) }, [2]], [ - Type.Object({ a: Type.String() }), - Type.Object({ a: Type.String() }, { additionalProperties: Type.Number() }), + obj({ a: str() }, { open: true }), + { ...obj({ a: str() }, { open: true }), additionalProperties: num() }, { a: "s", b: "t" }, ], [ - Type.Object({ x: Type.String() }), - Type.Object({}, { patternProperties: { "^x$": Type.Number() } }), + obj({ x: str() }, { open: true }), + { ...obj({}, { open: true }), patternProperties: { "^x$": num() } }, { x: "s" }, ], - [Type.Number(), Type.Intersect([Type.Number(), Type.Number({ minimum: 1 })]), 0], + [num(), { allOf: [num(), num({ minimum: 1 })] }, 0], [ - Type.Object({ a: Type.String() }, { additionalProperties: Type.Number() }), - Type.Object({ a: Type.String(), b: Type.Optional(Type.String()) }), + { ...obj({ a: str() }, { open: true }), additionalProperties: num() }, + obj({ a: str(), b: opt(str()) }, { open: true }), { a: "", b: 1 }, ], ] as const; for (const [sub, sup, value] of cases) { - assert.ok(Value.Check(sub, value) && !Value.Check(sup, value), `bad counterexample ${JSON.stringify(value)}`); + assert.ok( + compile(sub).check(value) && !compile(sup).check(value), + `bad counterexample ${JSON.stringify(value)}`, + ); assert.equal(fits(sub, sup), false, `fits(${JSON.stringify(sub)}, ${JSON.stringify(sup)})`); } // Additive changes still fit. - assert.ok( - fits(Type.Object({ a: Type.String() }), Type.Object({ a: Type.String(), b: Type.Optional(Type.Number()) })), - ); - assert.ok(fits(Type.Object({ a: Type.String(), extra: Type.Number() }), Type.Object({ a: Type.String() }))); + assert.ok(fits(obj({ a: str() }, { open: true }), obj({ a: str(), b: opt(num()) }, { open: true }))); + assert.ok(fits(obj({ a: str(), extra: num() }, { open: true }), obj({ a: str() }, { open: true }))); }); it("#11 arguments are stripped to what the consumer knows, then to what the provider knows", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Strict = remote("t.strict", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("host"), variants: { main: { methods: { - echo: method(Type.Object({ a: Type.String() }, { additionalProperties: false }), Type.Unknown()), + echo: method(obj({ a: str() }), unknown()), }, }, }, @@ -590,13 +593,13 @@ describe("review: prepare isolation and contracts", () => { describe("review: ctx races", () => { const Target = local("t.target", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { hit: method() }, }); it("an aborted task ctx cannot start fresh calls", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); - const Runner = local("t.runner", { version: 1, input: Type.Object({}) }); + const Runner = local("t.runner", { version: 1, input: obj({}, { open: true }) }); let hits = 0; const go = latch(); let outcome: unknown; @@ -650,7 +653,7 @@ describe("review: ctx races", () => { it("a remote reference refuses a foreign ctx and a ctx not made by Chord, without calling", async () => { const RemoteTarget = remote("t.remote-target", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: { state: {}, methods: { hit: method() } } }, }); diff --git a/packages/chord/test/state-init.test.ts b/packages/chord/test/state-init.test.ts index 77f101d5e..24d086aac 100644 --- a/packages/chord/test/state-init.test.ts +++ b/packages/chord/test/state-init.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, bool, num, obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, createHost, type Facet, isChordError, local, method } from "../src/index.ts"; @@ -19,16 +19,16 @@ function latch() { const Notes = local("t.notes", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { - notes: Type.Array(Type.String()), - count: Type.Number(), - has: method(Type.String(), Type.Boolean()), - add: method(Type.String()), + notes: arr(str()), + count: num(), + has: method(str(), bool()), + add: method(str()), }, }); -const NotesSchema = Type.Array(Type.String()); +const NotesSchema = arr(str()); describe("state init", () => { it("runs after the resources are open and before the object goes live: nothing sees another value", async () => { @@ -124,7 +124,7 @@ describe("state init", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Source = local("t.source", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { all: method(undefined, NotesSchema) }, }); host.serve(Source, () => ({ all: () => ["from source"] })); @@ -155,7 +155,7 @@ describe("state init", () => { return ["loaded"]; }, }); - const counter = self.state("counter", Type.Number(), 0, { version: spec.countVersion ?? 1 }); + const counter = self.state("counter", num(), 0, { version: spec.countVersion ?? 1 }); const extra = spec.extra; const more = extra === undefined diff --git a/packages/chord/test/state.test.ts b/packages/chord/test/state.test.ts index 656ac0601..2f48c8799 100644 --- a/packages/chord/test/state.test.ts +++ b/packages/chord/test/state.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { arr, num, obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { applyImmutable } from "../src/delta/index.ts"; import { type Ctx, createHost, isChordError, local, method, type StateEvent } from "../src/index.ts"; @@ -22,15 +22,15 @@ function emitter() { }; } -const Item = Type.Object({ id: Type.Number(), label: Type.String() }); -const Items = Type.Object({ items: Type.Array(Item), count: Type.Number() }); +const Item = obj({ id: num(), label: str() }, { open: true }); +const Items = obj({ items: arr(Item), count: num() }, { open: true }); const List = local("t.list", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { list: Items, - labels: Type.Array(Type.String()), + labels: arr(str()), push: method(Item), set: method(Items), }, @@ -84,11 +84,11 @@ describe("state cells", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Counter = local("t.prim", { version: 1, - input: Type.Object({}), - members: { n: Type.Number(), inc: method(undefined, Type.Number()) }, + input: obj({}, { open: true }), + members: { n: num(), inc: method(undefined, num()) }, }); host.serve(Counter, (_input, _principal, self) => { - const nState = self.state("n", Type.Number(), 0); + const nState = self.state("n", num(), 0); return { n: nState, inc: (ctx: Ctx) => { @@ -117,11 +117,11 @@ describe("state cells", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Box = local("t.box", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { put: method() }, }); host.serve(Box, (_input, _principal, self) => { - const list = self.state("list", Type.Array(Type.Number()), []); + const list = self.state("list", arr(num()), []); return { put: (ctx: Ctx) => { // The classic slip: an expression-bodied arrow returns push()'s length. @@ -133,10 +133,10 @@ describe("state cells", () => { }); it("validates initial values always and writes only in dev mode", async () => { - const Bad = local("t.bad-initial", { version: 1, input: Type.Object({}) }); + const Bad = local("t.bad-initial", { version: 1, input: obj({}, { open: true }) }); const host = createHost({ self: { kind: "host" }, onError: () => {} }); host.serve(Bad, (_input, _principal, self) => { - self.state("x", Type.Object({ n: Type.Number() }), { n: "no" } as never); + self.state("x", obj({ n: num() }, { open: true }), { n: "no" } as never); return {}; }); await assert.rejects(host.hold(Bad, {}).ready, /initial value of state x/); @@ -157,13 +157,13 @@ describe("state cells", () => { it("rejects writes from inside a factory and placeholder use before activation", async () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); - const Writer = local("t.writer", { version: 1, input: Type.Object({}) }); + const Writer = local("t.writer", { version: 1, input: obj({}, { open: true }) }); let captured: Ctx | undefined; await host.root((ctx) => { captured = ctx; }); host.serve(Writer, (_input, _principal, self) => { - const x = self.state("x", Type.Object({}), {}); + const x = self.state("x", obj({}, { open: true }), {}); const r = self.resource("r", { version: 1, open: () => 1 }); assert.throws( () => r.value, @@ -180,13 +180,13 @@ describe("state cells", () => { const events = emitter(); const Watcher = local("t.watcher", { version: 1, - input: Type.Object({}), - members: { seen: Type.Object({ sum: Type.Number() }) }, + input: obj({}, { open: true }), + members: { seen: obj({ sum: num() }, { open: true }) }, }); const roots = new Set(); let taskAborted = false; host.serve(Watcher, (_input, _principal, self) => { - const seen = self.state("seen", Type.Object({ sum: Type.Number() }), { sum: 0 }); + const seen = self.state("seen", obj({ sum: num() }, { open: true }), { sum: 0 }); const sourceResource = self.resource("source", { version: 1, open: () => events }); self.on( "onEvent", @@ -223,11 +223,11 @@ describe("state cells", () => { const host = createHost({ self: { kind: "host" }, onError: () => {} }); const Echo = local("t.reentrant", { version: 1, - input: Type.Object({}), - members: { n: Type.Number(), d: Type.Number(), set: method(Type.Number()) }, + input: obj({}, { open: true }), + members: { n: num(), d: num(), set: method(num()) }, }); host.serve(Echo, (_input, _principal, self) => { - const nState = self.state("n", Type.Number(), 0); + const nState = self.state("n", num(), 0); const d = self.derive([nState], (n) => n * 2); // Subscribed first: re-enters the write while revision 1 is being published. self.on("bump", nState, (n, ctx) => { diff --git a/packages/chord/test/tutorial.test.ts b/packages/chord/test/tutorial.test.ts index 55c0dfe17..72f98e1dd 100644 --- a/packages/chord/test/tutorial.test.ts +++ b/packages/chord/test/tutorial.test.ts @@ -1,7 +1,7 @@ // example:imports import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { type Static, Type } from "typebox"; +import { arr, bool, int, num, obj, type Static, str } from "@earendil-works/pi-schema"; import { it, onTestFinished } from "vitest"; import { type Ctx, @@ -51,23 +51,23 @@ function checkExamples(section: string): void { // example:02-contract const TodoLabel = local("tutorial.todos", { version: 1, - input: Type.Object({ list: Type.String() }), - members: { label: method(undefined, Type.String()) }, + input: obj({ list: str() }, { open: true }), + members: { label: method(undefined, str()) }, }); // end-example:02-contract // example:03-contract -const Todo = Type.Object({ text: Type.String(), done: Type.Boolean() }); -const Rows = Type.Array(Todo); +const Todo = obj({ text: str(), done: bool() }, { open: true }); +const Rows = arr(Todo); const TodoStore = local("tutorial.todos", { version: 1, - input: Type.Object({ list: Type.String() }), + input: obj({ list: str() }, { open: true }), members: { - label: method(undefined, Type.String()), + label: method(undefined, str()), rows: Rows, - openCount: Type.Number(), - add: method(Type.Object({ text: Type.String() })), - finish: method(Type.Object({ index: Type.Integer() })), + openCount: num(), + add: method(obj({ text: str() }, { open: true })), + finish: method(obj({ index: int() }, { open: true })), reset: method(Rows), }, }); @@ -107,13 +107,13 @@ const todoFactory = todoStore(); // example:05-views const Todos = remote("tutorial.todo-view", { version: 1, - input: Type.Object({ list: Type.String() }), + input: obj({ list: str() }, { open: true }), host: hostNamed("server"), variants: { viewer: { state: { rows: Rows } }, editor: { state: { rows: Rows }, - methods: { add: method(Type.Object({ text: Type.String() })) }, + methods: { add: method(obj({ text: str() }, { open: true })) }, }, }, }); @@ -190,13 +190,13 @@ it("04: holds, asynchronous resources and release", { timeout: 5000 }, async () // example:04-database const Database = local("tutorial.database", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), members: { db: opaque<{ saved: string[] }>("tutorial.database@1") }, }); const SavedTodos = local("tutorial.saved-todos", { version: 1, - input: Type.Object({ list: Type.String() }), - members: { add: method(Type.Object({ text: Type.String() })) }, + input: obj({ list: str() }, { open: true }), + members: { add: method(obj({ text: str() }, { open: true })) }, }); const host = createHost({ self: "server" }); let opens = 0; @@ -306,8 +306,8 @@ it("07: listeners react and tasks start fresh calls", { timeout: 5000 }, async ( // example:07 const Activity = local("tutorial.activity", { version: 1, - input: Type.Object({}), - members: { seen: Type.Number() }, + input: obj({}, { open: true }), + members: { seen: num() }, }); const host = createHost({ self: "server" }); host.serve(TodoStore, todoFactory); @@ -316,7 +316,7 @@ it("07: listeners react and tasks start fresh calls", { timeout: 5000 }, async ( const stopped = Promise.withResolvers(); host.serve(Activity, (_input, _principal, self) => { const todos = self.get(TodoStore, { list: "family" }); - const seen = self.state("seen", Type.Number(), 0); + const seen = self.state("seen", num(), 0); self.on("rows", todos.rows, (rows, ctx) => { seen.replace(ctx, rows?.length ?? 0); if (rows?.length === 1) observed.resolve(); @@ -345,7 +345,7 @@ it("08: plugins contribute registry entries and expose callbacks", { timeout: 50 onTestFinished(() => host.close()); // example:08 const Actions = defineRegistry<(ctx: Ctx) => Promise>("tutorial.actions"); - const Menu = local("tutorial.menu", { version: 1, input: Type.Object({}) }); + const Menu = local("tutorial.menu", { version: 1, input: obj({}, { open: true }) }); let click: (() => Promise) | undefined; const storeFacet: Facet = { name: "todo-store", @@ -444,7 +444,7 @@ it("09: compatible reload, migration and an old-contract adapter", { timeout: 50 const RenamedStore = local("tutorial.todos", { version: 2, input: TodoStore.def.input, - members: { ...members, heading: method(undefined, Type.String()) }, + members: { ...members, heading: method(undefined, str()) }, }); await host.reload([ { diff --git a/packages/chord/test/types.test.ts b/packages/chord/test/types.test.ts index f64f0ab98..9df86f431 100644 --- a/packages/chord/test/types.test.ts +++ b/packages/chord/test/types.test.ts @@ -1,25 +1,25 @@ // Entry 8: reference types. State members are plain values (a reference you hold has its state); a remote reference is a // union discriminated by `variant`, so another variant's members are reachable only after narrowing. Checked by tsc. -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { describe, expectTypeOf, it } from "vitest"; import { type Ctx, type Host, hostNamed, local, method, type Readable, type Ref, remote } from "../src/index.ts"; const Doc = remote("t.e8-types-doc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { - reader: { state: { text: Type.String() } }, - editor: { state: { text: Type.String() }, methods: { edit: method(Type.String()) } }, + reader: { state: { text: str() } }, + editor: { state: { text: str() }, methods: { edit: method(str()) } }, }, }); const Single = remote("t.e8-types-single", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), - variants: { main: { state: { n: Type.Number() }, methods: { bump: method() } } }, + variants: { main: { state: { n: num() }, methods: { bump: method() } } }, }); -const Local = local("t.e8-types-local", { version: 1, input: Type.Object({}), members: { n: Type.Number() } }); +const Local = local("t.e8-types-local", { version: 1, input: obj({}, { open: true }), members: { n: num() } }); describe("reference types", () => { it("state members are T; other variants' members need narrowing; gets are awaited", () => { diff --git a/packages/chord/test/variants.test.ts b/packages/chord/test/variants.test.ts index 363f50361..3b8c2359d 100644 --- a/packages/chord/test/variants.test.ts +++ b/packages/chord/test/variants.test.ts @@ -1,7 +1,7 @@ // Entry 8: a binding applies a shape only once its new state members have snapshots; members outside the variant // throw `not_in_variant`; a re-hold after a link loss keeps the last values readable and sends one fresh unit. import assert from "node:assert/strict"; -import { Type } from "typebox"; +import { num, obj, str } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { type Ctx, @@ -24,12 +24,12 @@ async function until(check: () => boolean, attempts = 200): Promise { const Doc = remote("t.e8-doc", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { - a: { state: { x: Type.Number() }, methods: { bump: method() } }, - b: { state: { x: Type.Number(), y: Type.String() }, methods: { bump: method() } }, - c: { state: { y: Type.String() }, methods: { bump: method() } }, + a: { state: { x: num() }, methods: { bump: method() } }, + b: { state: { x: num(), y: str() }, methods: { bump: method() } }, + c: { state: { y: str() }, methods: { bump: method() } }, }, }); @@ -39,8 +39,8 @@ function docFacet(variant: "a" | "b" | "c"): Facet { name: "doc", setup: (api) => api.serve(Doc, (_input, _principal, self) => { - const x = self.state("x", Type.Number(), 1); - const y = self.state("y", Type.String(), "why"); + const x = self.state("x", num(), 1); + const y = self.state("y", str(), "why"); const bump = (ctx: Ctx) => { x.replace(ctx, x.value + 1); }; diff --git a/packages/chord/test/websocket.test.ts b/packages/chord/test/websocket.test.ts index 8cb1e95c2..b5bc4389b 100644 --- a/packages/chord/test/websocket.test.ts +++ b/packages/chord/test/websocket.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import type { IncomingMessage } from "node:http"; -import { Type } from "typebox"; +import { arr, obj, str, unknown } from "@earendil-works/pi-schema"; import { describe, it } from "vitest"; import { WebSocketServer } from "ws"; import { @@ -17,12 +17,12 @@ import { const Board = remote("t.board", { version: 1, - input: Type.Object({}), + input: obj({}, { open: true }), host: hostNamed("server"), variants: { main: { - state: { notes: Type.Array(Type.String()) }, - methods: { add: method(Type.String()), who: method(undefined, Type.Unknown()) }, + state: { notes: arr(str()) }, + methods: { add: method(str()), who: method(undefined, unknown()) }, }, }, }); @@ -31,7 +31,7 @@ describe("WebSocket links", () => { it("serves a browser-style client over a real socket, stamped by the authenticator", async () => { const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Board, (_input, principal, self) => { - const notes = self.state("notes", Type.Array(Type.String()), []); + const notes = self.state("notes", arr(str()), []); return { variant: "main", notes, @@ -112,7 +112,7 @@ describe("WebSocket links: packed payloads", () => { const notes = Array.from({ length: 40 }, (_, i) => `${i}:${"x".repeat(4000)}`); const server = createHost({ self: { kind: "host" }, name: "server", onError: () => {} }); server.serve(Board, (_input, _principal, self) => { - const state = self.state("notes", Type.Array(Type.String()), notes); + const state = self.state("notes", arr(str()), notes); return { variant: "main", notes: state, add: () => {}, who: () => notes }; }); const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });