From 71414ac85125b03ff48b1fe0bbbc9b0ee1237505 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Wed, 14 Jan 2026 09:12:12 -0800 Subject: [PATCH] init --- LICENSE | 21 + README.md | 56 + biome.json | 37 + docs/oracle-differences.md | 148 ++ lexicon.d.ts | 293 ++++ lexicon.js | 1777 +++++++++++++++++++++++- lexicon.test.js | 216 ++- node_modules/.package-lock.json | 181 ++- node_modules/.vite/vitest/results.json | 2 +- package-lock.json | 291 +++- package.json | 12 +- test/atproto-oracle.js | 68 + test/inputs/data/array.js | 222 +-- test/inputs/data/blob.js | 272 ++-- test/inputs/data/bytes.js | 250 ++-- test/inputs/data/cid-link.js | 85 ++ test/inputs/data/index.js | 21 +- test/inputs/data/integer.js | 254 ++-- test/inputs/data/object.js | 152 +- test/inputs/data/params.js | 203 +++ test/inputs/data/procedure.js | 134 ++ test/inputs/data/query.js | 185 +++ test/inputs/data/ref.js | 229 +++ test/inputs/data/string.js | 339 ++--- test/inputs/data/subscription.js | 121 ++ test/inputs/data/token.js | 85 ++ test/inputs/data/union.js | 174 +++ test/inputs/data/unknown.js | 109 ++ test/inputs/e2e/bsky-post.js | 90 +- test/inputs/e2e/bsky-profile.js | 68 +- test/inputs/e2e/index.js | 5 +- test/inputs/e2e/integration.js | 180 +++ test/inputs/format/at-uri.js | 218 +-- test/inputs/format/cid.js | 182 +-- test/inputs/format/datetime.js | 218 +-- test/inputs/format/did.js | 146 +- test/inputs/format/handle.js | 182 +-- test/inputs/format/index.js | 20 +- test/inputs/format/language.js | 146 +- test/inputs/format/nsid.js | 146 +- test/inputs/format/record-key.js | 218 +-- test/inputs/format/tid.js | 182 +-- test/inputs/format/uri.js | 148 +- test/inputs/schema/array.js | 57 +- test/inputs/schema/blob.js | 51 +- test/inputs/schema/boolean.js | 34 +- test/inputs/schema/bytes.js | 51 +- test/inputs/schema/index.js | 28 +- test/inputs/schema/integer.js | 51 +- test/inputs/schema/object.js | 55 +- test/inputs/schema/params.js | 242 ++++ test/inputs/schema/procedure.js | 97 ++ test/inputs/schema/query.js | 90 ++ test/inputs/schema/record.js | 46 +- test/inputs/schema/ref.js | 81 +- test/inputs/schema/string.js | 99 +- test/inputs/schema/subscription.js | 91 ++ test/inputs/schema/token.js | 18 +- test/inputs/schema/union.js | 55 +- test/inputs/schema/unknown.js | 18 +- test/oracle.js | 213 --- test/oracle.test.js | 100 -- tsconfig.json | 14 + vitest.config.js | 4 +- 64 files changed, 7229 insertions(+), 2352 deletions(-) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 biome.json create mode 100644 docs/oracle-differences.md create mode 100644 lexicon.d.ts create mode 100644 test/atproto-oracle.js create mode 100644 test/inputs/data/cid-link.js create mode 100644 test/inputs/data/params.js create mode 100644 test/inputs/data/procedure.js create mode 100644 test/inputs/data/query.js create mode 100644 test/inputs/data/ref.js create mode 100644 test/inputs/data/subscription.js create mode 100644 test/inputs/data/token.js create mode 100644 test/inputs/data/union.js create mode 100644 test/inputs/data/unknown.js create mode 100644 test/inputs/e2e/integration.js create mode 100644 test/inputs/schema/params.js create mode 100644 test/inputs/schema/procedure.js create mode 100644 test/inputs/schema/query.js create mode 100644 test/inputs/schema/subscription.js delete mode 100644 test/oracle.js delete mode 100644 test/oracle.test.js create mode 100644 tsconfig.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..987ef07 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Chad Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c84232b --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# lexicon-js + +A plain JavaScript validator for [AT Protocol Lexicon](https://atproto.com/specs/lexicon) schemas. + +## Install + +```bash +npm install lexicon.js +``` + +## Usage + +```javascript +import { validateLexicons, validateRecord } from './lexicon.js' + +// Validate a lexicon schema +const lexicon = { + lexicon: 1, + id: 'app.example.post', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string', maxLength: 300 } + } + } + } + } +} + +const schemaError = validateLexicons([lexicon]) +// null if valid, error object if invalid + +// Validate a record against a lexicon +const record = { text: 'Hello, world!' } +const dataError = validateRecord([lexicon], 'app.example.post', record) +// null if valid, { path, message } if invalid +``` + +## Testing + +```bash +npm test +``` + +## Spec Compliance + +This implementation follows the [AT Protocol specs](https://atproto.com/specs/lexicon). See [docs/oracle-differences.md](./docs/oracle-differences.md) for documented differences with @atproto/lexicon. + +## License + +MIT diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..b995f97 --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/docs/oracle-differences.md b/docs/oracle-differences.md new file mode 100644 index 0000000..26174eb --- /dev/null +++ b/docs/oracle-differences.md @@ -0,0 +1,148 @@ +# Comparison with @atproto/lexicon + +This document describes the behavioral differences between lexicon.js and @atproto/lexicon (the official TypeScript implementation). + +## Summary + +When comparing against @atproto/lexicon, there are ~44 behavioral differences. These fall into several categories. + +--- + +## Category 1: Schema Constraint Validation + +**Our behavior:** Validate that schema constraints are logically valid. +**@atproto behavior:** Does not perform these checks. + +| Test | What we check | +|------|---------------| +| `string-invalid-length-constraints` | minLength <= maxLength | +| `string-invalid-negative-minLength` | minLength >= 0 | +| `string-invalid-negative-maxLength` | maxLength >= 0 | +| `string-invalid-negative-minGraphemes` | minGraphemes >= 0 | +| `string-invalid-negative-maxGraphemes` | maxGraphemes >= 0 | +| `integer-invalid-range-constraints` | minimum <= maximum | +| `array-invalid-length-constraints` | minLength <= maxLength | +| `bytes-invalid-max-less-than-min` | minLength <= maxLength | +| `bytes-invalid-negative-min` | minLength >= 0 | +| `bytes-invalid-negative-max` | maxLength >= 0 | +| `blob-invalid-mime-no-slash` | accept MIME types have / | +| `blob-invalid-partial-wildcard` | accept patterns are valid | +| `blob-invalid-zero-maxsize` | maxSize > 0 | +| `object-invalid-nullable-not-in-properties` | nullable fields exist in properties | + +**Rationale:** Catching invalid schemas early prevents runtime confusion. + +--- + +## Category 2: Inline Type Definitions + +**Our behavior:** Allow inline object/union type definitions in schemas. +**@atproto behavior:** Requires all complex types to be defined as named refs. + +| Test | What we allow | +|------|---------------| +| `object-valid-nested` | `{ type: 'object', properties: { inner: { type: 'object', ... } } }` | +| `array-valid-object-items` | `{ type: 'array', items: { type: 'object', ... } }` | +| `array-valid-nested-array` | `{ type: 'array', items: { type: 'array', ... } }` | +| `union-valid-open-empty-refs` | `{ type: 'union', refs: [] }` | +| `union-valid-with-refs` | Union with local refs | +| `union-valid-closed-with-refs` | Closed union with refs | +| `ref-valid-local` | Local ref `#defName` | +| `params-valid-*` (7 tests) | Various params configurations | +| `subscription-valid-*` (3 tests) | Various subscription configurations | + +**Rationale:** The [Lexicon spec](https://atproto.com/specs/lexicon) explicitly allows object properties to contain "any Lexicon field type" including nested objects, arrays, refs, and unions. + +--- + +## Category 3: Blob Data Constraint Validation + +**Our behavior:** Validate blob constraints at data validation time. +**@atproto behavior:** Does not validate blob constraints. + +| Test | What we validate | +|------|------------------| +| `blob-data-invalid-unaccepted-mime` | Blob MIME type matches schema `accept` | +| `blob-data-invalid-exceeds-maxSize` | Blob size <= schema `maxSize` | + +**Rationale:** Schema constraints should be enforced at validation time. + +--- + +## Category 4: Token Type Support + +**Our behavior:** Support token type. +**@atproto behavior:** Throws "Unexpected lexicon type: token". + +| Test | +|------| +| `token-data-valid-simple-string` | +| `token-data-valid-local-ref` | + +**Rationale:** Token is a valid lexicon type per the spec. + +--- + +## Category 5: Unknown Type Validation + +**Our behavior:** Reject structured types (arrays, blob objects, bytes objects) for unknown type. +**@atproto behavior:** Accepts these values. + +| Test | What we reject | +|------|----------------| +| `unknown-data-invalid-array` | Arrays for unknown type | +| `unknown-data-invalid-bytes-object` | `{ $bytes: ... }` objects | +| `unknown-data-invalid-blob-object` | `{ $type: 'blob', ... }` objects | + +**Rationale:** Unknown should be primitive JSON values, not complex IPLD types. + +--- + +## Category 6: Format Validation - Handles + +**Our behavior:** Reject reserved TLDs (.local, .localhost). +**@atproto behavior:** Accepts these handles. + +| Test | Handle value | +|------|--------------| +| `handle-invalid-local-tld` | `user.local` | +| `handle-invalid-localhost-tld` | `app.localhost` | + +**Rationale:** The [AT Protocol Handle spec](https://atproto.com/specs/handle) explicitly lists `.local` and `.localhost` as disallowed TLDs. + +--- + +## Category 7: Format Validation - CID + +**Our behavior:** Reject CIDv0, require CIDv1. +**@atproto behavior:** Accepts CIDv0. + +| Test | CID value | +|------|-----------| +| `cid-invalid-qmb-prefix` | `QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR` (CIDv0) | + +**Rationale:** The [AT Protocol Data Model spec](https://atproto.com/specs/data-model) lists only CIDv1 as a "blessed" format. + +--- + +## Category 8: Ref Type in Data Validation + +**Our behavior:** Support ref resolution in data validation. +**@atproto behavior:** Throws "Unexpected lexicon type: ref". + +| Test | +|------| +| `ref-data-valid-nested-chain` | + +**Rationale:** Refs should be resolvable during data validation. + +--- + +## Design Philosophy + +lexicon.js prioritizes: + +1. **Spec compliance** - We follow the AT Protocol specs for handles, CIDs, and other formats +2. **Strict schema validation** - We catch invalid schemas early (constraint validation) +3. **Flexible schema authoring** - We allow inline types as the spec permits +4. **Full type support** - We support token and ref types that @atproto/lexicon doesn't handle diff --git a/lexicon.d.ts b/lexicon.d.ts new file mode 100644 index 0000000..a03308b --- /dev/null +++ b/lexicon.d.ts @@ -0,0 +1,293 @@ +/** + * Validates a datetime string against RFC 3339/ISO 8601 format. + * Timezone is required; whole seconds minimum with optional fractional precision. + * @see https://atproto.com/specs/lexicon - datetime format + * @param {string} value - The datetime string to validate + * @returns {boolean} True if valid datetime format + */ +export function isValidDatetime(value: string): boolean; +/** + * Validates a handle identifier. + * Handles are DNS hostnames with specific constraints: + * - Max 253 characters total + * - At least 2 labels separated by periods + * - Labels: 1-63 chars, alphanumeric and hyphens, can't start/end with hyphen + * - TLD can't start with digit; certain TLDs are disallowed + * @see https://atproto.com/specs/handle + * @param {string} value - The handle to validate + * @returns {boolean} True if valid handle format + */ +export function isValidHandle(value: string): boolean; +/** + * Validates a Decentralized Identifier (DID). + * DIDs follow the pattern: did:[method]:[identifier] + * - Starts with lowercase "did:" + * - Method: 1+ lowercase letters, followed by ":" + * - Identifier: alphanumeric, period, underscore, colon, hyphen, percent-encoded + * - Max 2048 characters (AT Protocol limit) + * @see https://atproto.com/specs/did + * @param {string} value - The DID to validate + * @returns {boolean} True if valid DID format + */ +export function isValidDid(value: string): boolean; +/** + * Validates a generic URI per RFC 3986. + * Scheme is case-insensitive; uppercase accepted for robustness. + * Max 8192 characters. + * @see https://atproto.com/specs/lexicon - uri format + * @param {string} value - The URI to validate + * @returns {boolean} True if valid URI format + */ +export function isValidUri(value: string): boolean; +/** + * Validates an AT Protocol URI (at://). + * Structure: at://AUTHORITY[/COLLECTION[/RKEY]] + * - Authority: valid handle or DID (required) + * - Collection: valid NSID (optional) + * - Record key: valid record-key (optional) + * Max 8KB. + * @see https://atproto.com/specs/at-uri-scheme + * @param {string} value - The AT-URI to validate + * @returns {boolean} True if valid AT-URI format + */ +export function isValidAtUri(value: string): boolean; +/** + * Validates a Timestamp Identifier (TID). + * TIDs are 64-bit integers encoded as 13-character base32-sortable strings. + * - Exactly 13 characters + * - First char: [234567abcdefghij] (top bit always 0) + * - All chars: base32-sortable alphabet (234567abcdefghijklmnopqrstuvwxyz) + * @see https://atproto.com/specs/tid + * @param {string} value - The TID to validate + * @returns {boolean} True if valid TID format + */ +export function isValidTid(value: string): boolean; +/** + * Validates a record key. + * Record keys can contain: a-z, A-Z, 0-9, period, hyphen, underscore, colon, tilde. + * Max 512 characters. Cannot be "." or "..". + * @see https://atproto.com/specs/lexicon - record-key format + * @param {string} value - The record key to validate + * @returns {boolean} True if valid record key format + */ +export function isValidRecordKey(value: string): boolean; +/** + * Validates a Content Identifier (CID). + * AT Protocol only blesses CIDv1 format (rejects CIDv0 "Qm" prefix). + * - 8-256 characters + * - Alphanumeric only + * - No "Qmb" prefix (CIDv0) + * @see https://atproto.com/specs/data-model - CID/Link section + * @param {string} value - The CID to validate + * @returns {boolean} True if valid CID format + */ +export function isValidCid(value: string): boolean; +/** + * Validates a BCP-47 language tag. + * Simplified validation: 2-3 letter primary subtag with optional subtags. + * Supports grandfathered "i-" prefixed tags. + * @see https://atproto.com/specs/lexicon - language format + * @param {string} value - The language tag to validate + * @returns {boolean} True if valid language tag format + */ +export function isValidLanguageTag(value: string): boolean; +/** + * Validates a Namespaced Identifier (NSID). + * Structure: reversed-domain.name (e.g., "app.bsky.feed.post") + * - Max 317 characters, minimum 3 segments + * - Authority (domain): max 253 chars, at least 2 segments, handle-like rules + * - Name segment: 1-63 chars, letters and digits only, must start with lowercase letter + * @see https://atproto.com/specs/nsid + * @param {string} value - The NSID to validate + * @returns {boolean} True if valid NSID format + */ +export function isValidNsid(value: string): boolean; +/** + * Validates an AT Protocol identifier (handle or DID). + * @see https://atproto.com/specs/lexicon - at-identifier format + * @param {string} value - The identifier to validate + * @returns {boolean} True if valid handle or DID + */ +export function isValidAtIdentifier(value: string): boolean; +/** + * Validate lexicon schemas are well-formed. + * Validates each definition in each lexicon against the Lexicon specification. + * @see https://atproto.com/specs/lexicon + * @param {Lexicon[]} lexicons - Array of lexicon JSON objects + * @returns {null | Object} null if valid, errors by NSID if invalid + * @example + * const errors = validateLexicons([myLexicon]); + * if (errors) { + * console.error('Invalid lexicons:', errors); + * } + */ +export function validateLexicons(lexicons: Lexicon[]): null | { + [x: string]: string[]; +}; +/** + * Validate a record against a lexicon schema. + * The collection NSID must match a lexicon with a "record" type main definition. + * @see https://atproto.com/specs/lexicon - Record type + * @param {Lexicon[]} lexicons - Array of lexicon JSON objects + * @param {string} collection - NSID of the collection (e.g., "app.bsky.feed.post") + * @param {object} record - The record data to validate + * @returns {null | {path: string, message: string}} null if valid, error if invalid + * @example + * const error = validateRecord(lexicons, 'app.bsky.feed.post', postData); + * if (error) { + * console.error(`Validation error at ${error.path}: ${error.message}`); + * } + */ +export function validateRecord(lexicons: Lexicon[], collection: string, record: object): null | { + path: string; + message: string; +}; +export type LexiconFormat = "datetime" | "uri" | "at-uri" | "did" | "handle" | "at-identifier" | "nsid" | "cid" | "language" | "tid" | "record-key"; +export type LexiconStringSchema = { + type: "string"; + description?: string | undefined; + minLength?: number | undefined; + maxLength?: number | undefined; + minGraphemes?: number | undefined; + maxGraphemes?: number | undefined; + format?: LexiconFormat | undefined; + enum?: string[] | undefined; + const?: string | undefined; + knownValues?: string[] | undefined; + default?: string | undefined; +}; +export type LexiconIntegerSchema = { + type: "integer"; + description?: string | undefined; + minimum?: number | undefined; + maximum?: number | undefined; + enum?: number[] | undefined; + const?: number | undefined; + default?: number | undefined; +}; +export type LexiconBooleanSchema = { + type: "boolean"; + description?: string | undefined; + const?: boolean | undefined; + default?: boolean | undefined; +}; +export type LexiconObjectSchema = { + type: "object"; + description?: string | undefined; + properties?: { + [x: string]: LexiconSchema; + } | undefined; + required?: string[] | undefined; + nullable?: string[] | undefined; +}; +export type LexiconArraySchema = { + type: "array"; + description?: string | undefined; + items: LexiconSchema; + minLength?: number | undefined; + maxLength?: number | undefined; +}; +export type LexiconRefSchema = { + type: "ref"; + description?: string | undefined; + ref: string; +}; +export type LexiconUnionSchema = { + type: "union"; + description?: string | undefined; + refs: string[]; + closed?: boolean | undefined; +}; +export type LexiconBlobSchema = { + type: "blob"; + description?: string | undefined; + accept?: string[] | undefined; + maxSize?: number | undefined; +}; +export type LexiconBytesSchema = { + type: "bytes"; + description?: string | undefined; + minLength?: number | undefined; + maxLength?: number | undefined; +}; +export type LexiconTokenSchema = { + type: "token"; + description?: string | undefined; +}; +export type LexiconUnknownSchema = { + type: "unknown"; + description?: string | undefined; +}; +export type LexiconCidLinkSchema = { + type: "cid-link"; + description?: string | undefined; +}; +export type LexiconNullSchema = { + type: "null"; + description?: string | undefined; +}; +export type LexiconSchema = LexiconStringSchema | LexiconIntegerSchema | LexiconBooleanSchema | LexiconObjectSchema | LexiconArraySchema | LexiconRefSchema | LexiconUnionSchema | LexiconBlobSchema | LexiconBytesSchema | LexiconTokenSchema | LexiconUnknownSchema | LexiconCidLinkSchema | LexiconNullSchema; +export type LexiconBody = { + description?: string | undefined; + encoding: string; + schema?: LexiconSchema | undefined; +}; +export type LexiconMessage = { + description?: string | undefined; + schema?: LexiconSchema | undefined; +}; +export type LexiconRecordDef = { + type: "record"; + description?: string | undefined; + key?: string | undefined; + record: LexiconObjectSchema; +}; +export type LexiconParamsDef = { + type: "params"; + description?: string | undefined; + properties?: { + [x: string]: LexiconSchema; + } | undefined; + required?: string[] | undefined; +}; +export type LexiconQueryDef = { + type: "query"; + description?: string | undefined; + parameters?: LexiconObjectSchema | undefined; + output?: LexiconBody | undefined; + errors?: object[] | undefined; +}; +export type LexiconProcedureDef = { + type: "procedure"; + description?: string | undefined; + parameters?: LexiconObjectSchema | undefined; + input?: LexiconBody | undefined; + output?: LexiconBody | undefined; + errors?: object[] | undefined; +}; +export type LexiconSubscriptionDef = { + type: "subscription"; + description?: string | undefined; + parameters?: LexiconObjectSchema | undefined; + message?: LexiconMessage | undefined; + errors?: object[] | undefined; +}; +export type LexiconDef = LexiconRecordDef | LexiconQueryDef | LexiconProcedureDef | LexiconSubscriptionDef | LexiconParamsDef | LexiconSchema; +export type Lexicon = { + lexicon: 1; + id: string; + revision?: number | undefined; + description?: string | undefined; + defs: { + [x: string]: LexiconDef; + }; +}; +export type ValidationContext = { + lexicons: Lexicon[]; + currentLexicon: string; + path?: string | undefined; +}; +export type ValidationError = { + path: string; + message: string; +}; diff --git a/lexicon.js b/lexicon.js index 5eaa340..253986e 100644 --- a/lexicon.js +++ b/lexicon.js @@ -1,21 +1,1784 @@ +/** + * _ ___ _ _ ___ __ ___ _ _ + * | | | __| \ \/ / |_ _| / __| / _ \ | \| | + * | |__ | _| > < | | | (__ | (_) | | .` | + * |____| |___| /_/\_\ |___| \___| \___/ |_|\_| + * + * AT Protocol Lexicon validator - plain JavaScript, zero dependencies. + * @module lexicon + * @see https://atproto.com/specs/lexicon + */ + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +/** + * @typedef {'datetime'|'uri'|'at-uri'|'did'|'handle'|'at-identifier'|'nsid'|'cid'|'language'|'tid'|'record-key'} LexiconFormat + */ + +/** + * @typedef {object} LexiconStringSchema + * @property {'string'} type + * @property {string} [description] + * @property {number} [minLength] + * @property {number} [maxLength] + * @property {number} [minGraphemes] + * @property {number} [maxGraphemes] + * @property {LexiconFormat} [format] + * @property {string[]} [enum] + * @property {string} [const] + * @property {string[]} [knownValues] + * @property {string} [default] + */ + +/** + * @typedef {object} LexiconIntegerSchema + * @property {'integer'} type + * @property {string} [description] + * @property {number} [minimum] + * @property {number} [maximum] + * @property {number[]} [enum] + * @property {number} [const] + * @property {number} [default] + */ + +/** + * @typedef {object} LexiconBooleanSchema + * @property {'boolean'} type + * @property {string} [description] + * @property {boolean} [const] + * @property {boolean} [default] + */ + +/** + * @typedef {object} LexiconObjectSchema + * @property {'object'} type + * @property {string} [description] + * @property {Object} [properties] + * @property {string[]} [required] + * @property {string[]} [nullable] + */ + +/** + * @typedef {object} LexiconArraySchema + * @property {'array'} type + * @property {string} [description] + * @property {LexiconSchema} items + * @property {number} [minLength] + * @property {number} [maxLength] + */ + +/** + * @typedef {object} LexiconRefSchema + * @property {'ref'} type + * @property {string} [description] + * @property {string} ref + */ + +/** + * @typedef {object} LexiconUnionSchema + * @property {'union'} type + * @property {string} [description] + * @property {string[]} refs + * @property {boolean} [closed] + */ + +/** + * @typedef {object} LexiconBlobSchema + * @property {'blob'} type + * @property {string} [description] + * @property {string[]} [accept] + * @property {number} [maxSize] + */ + +/** + * @typedef {object} LexiconBytesSchema + * @property {'bytes'} type + * @property {string} [description] + * @property {number} [minLength] + * @property {number} [maxLength] + */ + +/** + * @typedef {object} LexiconTokenSchema + * @property {'token'} type + * @property {string} [description] + */ + +/** + * @typedef {object} LexiconUnknownSchema + * @property {'unknown'} type + * @property {string} [description] + */ + +/** + * @typedef {object} LexiconCidLinkSchema + * @property {'cid-link'} type + * @property {string} [description] + */ + +/** + * @typedef {object} LexiconNullSchema + * @property {'null'} type + * @property {string} [description] + */ + +/** + * @typedef {LexiconStringSchema|LexiconIntegerSchema|LexiconBooleanSchema|LexiconObjectSchema|LexiconArraySchema|LexiconRefSchema|LexiconUnionSchema|LexiconBlobSchema|LexiconBytesSchema|LexiconTokenSchema|LexiconUnknownSchema|LexiconCidLinkSchema|LexiconNullSchema} LexiconSchema + */ + +/** + * @typedef {object} LexiconBody + * @property {string} [description] + * @property {string} encoding + * @property {LexiconSchema} [schema] + */ + +/** + * @typedef {object} LexiconMessage + * @property {string} [description] + * @property {LexiconSchema} [schema] + */ + +/** + * @typedef {object} LexiconRecordDef + * @property {'record'} type + * @property {string} [description] + * @property {string} [key] + * @property {LexiconObjectSchema} record + */ + +/** + * @typedef {object} LexiconParamsDef + * @property {'params'} type + * @property {string} [description] + * @property {Object} [properties] + * @property {string[]} [required] + */ + +/** + * @typedef {object} LexiconQueryDef + * @property {'query'} type + * @property {string} [description] + * @property {LexiconObjectSchema} [parameters] + * @property {LexiconBody} [output] + * @property {object[]} [errors] + */ + +/** + * @typedef {object} LexiconProcedureDef + * @property {'procedure'} type + * @property {string} [description] + * @property {LexiconObjectSchema} [parameters] + * @property {LexiconBody} [input] + * @property {LexiconBody} [output] + * @property {object[]} [errors] + */ + +/** + * @typedef {object} LexiconSubscriptionDef + * @property {'subscription'} type + * @property {string} [description] + * @property {LexiconObjectSchema} [parameters] + * @property {LexiconMessage} [message] + * @property {object[]} [errors] + */ + +/** + * @typedef {LexiconRecordDef|LexiconQueryDef|LexiconProcedureDef|LexiconSubscriptionDef|LexiconParamsDef|LexiconSchema} LexiconDef + */ + +/** + * @typedef {object} Lexicon + * @property {1} lexicon + * @property {string} id + * @property {number} [revision] + * @property {string} [description] + * @property {Object} defs + */ + +/** + * @typedef {object} ValidationContext + * @property {Lexicon[]} lexicons + * @property {string} currentLexicon + * @property {string} [path] + */ + +/** + * @typedef {object} ValidationError + * @property {string} path + * @property {string} message + */ + +// ============================================================================ +// FORMAT VALIDATORS +// ============================================================================ + +/** + * Disallowed top-level domains for handles per AT Protocol spec. + * @see https://atproto.com/specs/handle#handle-identifier-syntax + * @constant {string[]} + */ +const DISALLOWED_TLDS = [ + 'alt', + 'arpa', + 'example', + 'internal', + 'invalid', + 'local', + 'localhost', + 'onion', +]; + +/** + * Validates a datetime string against RFC 3339/ISO 8601 format. + * Timezone is required; whole seconds minimum with optional fractional precision. + * @see https://atproto.com/specs/lexicon - datetime format + * @param {string} value - The datetime string to validate + * @returns {boolean} True if valid datetime format + */ +export function isValidDatetime(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 64) return false; + if (value.includes('-00:00')) return false; + + const rfc3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + if (!rfc3339.test(value)) return false; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return false; + + // Validate the date components match (catches invalid dates like Feb 30) + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + const [, year, month, day] = match; + if (date.getUTCFullYear() !== parseInt(year, 10)) return false; + if (date.getUTCMonth() + 1 !== parseInt(month, 10)) return false; + if (date.getUTCDate() !== parseInt(day, 10)) return false; + } + + return true; +} + +/** + * Validates a handle identifier. + * Handles are DNS hostnames with specific constraints: + * - Max 253 characters total + * - At least 2 labels separated by periods + * - Labels: 1-63 chars, alphanumeric and hyphens, can't start/end with hyphen + * - TLD can't start with digit; certain TLDs are disallowed + * @see https://atproto.com/specs/handle + * @param {string} value - The handle to validate + * @returns {boolean} True if valid handle format + */ +export function isValidHandle(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 253) return false; + if (!value.includes('.')) return false; + + const parts = value.split('.'); + const tld = parts[parts.length - 1].toLowerCase(); + if (DISALLOWED_TLDS.includes(tld)) return false; + + // Basic domain validation + const domainRegex = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/; + return domainRegex.test(value); +} + +/** + * Validates a Decentralized Identifier (DID). + * DIDs follow the pattern: did:[method]:[identifier] + * - Starts with lowercase "did:" + * - Method: 1+ lowercase letters, followed by ":" + * - Identifier: alphanumeric, period, underscore, colon, hyphen, percent-encoded + * - Max 2048 characters (AT Protocol limit) + * @see https://atproto.com/specs/did + * @param {string} value - The DID to validate + * @returns {boolean} True if valid DID format + */ +export function isValidDid(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 2048) return false; + if (value.endsWith('%')) return false; + + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/; + return didRegex.test(value); +} + +/** + * Validates a generic URI per RFC 3986. + * Scheme is case-insensitive; uppercase accepted for robustness. + * Max 8192 characters. + * @see https://atproto.com/specs/lexicon - uri format + * @param {string} value - The URI to validate + * @returns {boolean} True if valid URI format + */ +export function isValidUri(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 8192) return false; + + // Scheme is case-insensitive per RFC 3986 (accept uppercase for robustness) + const schemeMatch = value.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + if (!schemeMatch) return false; + + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * Validates an AT Protocol URI (at://). + * Structure: at://AUTHORITY[/COLLECTION[/RKEY]] + * - Authority: valid handle or DID (required) + * - Collection: valid NSID (optional) + * - Record key: valid record-key (optional) + * Max 8KB. + * @see https://atproto.com/specs/at-uri-scheme + * @param {string} value - The AT-URI to validate + * @returns {boolean} True if valid AT-URI format + */ +export function isValidAtUri(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 8192) return false; + if (!value.startsWith('at://')) return false; + + const rest = value.slice(5); + const parts = rest.split('/'); + + // Authority (DID or handle) + const authority = parts[0]; + if (!authority) return false; + if (!isValidDid(authority) && !isValidHandle(authority)) return false; + + // Collection (NSID) - optional + if (parts[1]) { + if (!isValidNsid(parts[1])) return false; + } + + // Record key - optional + // (no validation needed beyond being present if collection is present) + + return true; +} + +/** + * Validates a Timestamp Identifier (TID). + * TIDs are 64-bit integers encoded as 13-character base32-sortable strings. + * - Exactly 13 characters + * - First char: [234567abcdefghij] (top bit always 0) + * - All chars: base32-sortable alphabet (234567abcdefghijklmnopqrstuvwxyz) + * @see https://atproto.com/specs/tid + * @param {string} value - The TID to validate + * @returns {boolean} True if valid TID format + */ +export function isValidTid(value) { + if (!value || typeof value !== 'string') return false; + if (value.length !== 13) return false; + + // First char must be in [234567abcdefghij] + const firstCharValid = /^[234567abcdefghij]/.test(value); + if (!firstCharValid) return false; + + // All chars must be base32-sortable + const base32Sortable = /^[234567abcdefghijklmnopqrstuvwxyz]+$/; + return base32Sortable.test(value); +} + +/** + * Validates a record key. + * Record keys can contain: a-z, A-Z, 0-9, period, hyphen, underscore, colon, tilde. + * Max 512 characters. Cannot be "." or "..". + * @see https://atproto.com/specs/lexicon - record-key format + * @param {string} value - The record key to validate + * @returns {boolean} True if valid record key format + */ +export function isValidRecordKey(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 512) return false; + if (value === '.' || value === '..') return false; + + // Record keys can contain: a-z, A-Z, 0-9, ., -, _, :, ~ + const recordKeyRegex = /^[a-zA-Z0-9._:~-]+$/; + return recordKeyRegex.test(value); +} + +/** + * Validates a Content Identifier (CID). + * AT Protocol only blesses CIDv1 format (rejects CIDv0 "Qm" prefix). + * - 8-256 characters + * - Alphanumeric only + * - No "Qmb" prefix (CIDv0) + * @see https://atproto.com/specs/data-model - CID/Link section + * @param {string} value - The CID to validate + * @returns {boolean} True if valid CID format + */ +export function isValidCid(value) { + if (!value || typeof value !== 'string') return false; + if (value.length < 8 || value.length > 256) return false; + + // Qmb prefix not allowed per atproto spec + if (value.startsWith('Qmb')) return false; + + // Basic CID character validation + const cidRegex = /^[a-zA-Z0-9]+$/; + return cidRegex.test(value); +} + +/** + * Validates a BCP-47 language tag. + * Simplified validation: 2-3 letter primary subtag with optional subtags. + * Supports grandfathered "i-" prefixed tags. + * @see https://atproto.com/specs/lexicon - language format + * @param {string} value - The language tag to validate + * @returns {boolean} True if valid language tag format + */ +export function isValidLanguageTag(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 128) return false; + + // BCP-47 language tag (simplified) + const langRegex = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]+)*$/; + if (langRegex.test(value)) return true; + + // Grandfathered tags + if (value.startsWith('i-')) return true; + + return false; +} + +/** + * Validates a Namespaced Identifier (NSID). + * Structure: reversed-domain.name (e.g., "app.bsky.feed.post") + * - Max 317 characters, minimum 3 segments + * - Authority (domain): max 253 chars, at least 2 segments, handle-like rules + * - Name segment: 1-63 chars, letters and digits only, must start with lowercase letter + * @see https://atproto.com/specs/nsid + * @param {string} value - The NSID to validate + * @returns {boolean} True if valid NSID format + */ +export function isValidNsid(value) { + if (!value || typeof value !== 'string') return false; + if (value.length > 317) return false; + + const parts = value.split('.'); + if (parts.length < 3) return false; + + // Each segment must be valid + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) return false; + + // Last segment (name) must start with lowercase letter + if (i === parts.length - 1) { + if (!/^[a-z][a-zA-Z0-9]*$/.test(part)) return false; + } else { + // Domain segments + if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(part)) return false; + } + } + + return true; +} + +/** + * Validates an AT Protocol identifier (handle or DID). + * @see https://atproto.com/specs/lexicon - at-identifier format + * @param {string} value - The identifier to validate + * @returns {boolean} True if valid handle or DID + */ +export function isValidAtIdentifier(value) { + return isValidDid(value) || isValidHandle(value); +} + +/** + * Validates a value against a Lexicon string format. + * Supported formats: datetime, handle, did, uri, at-uri, tid, record-key, + * cid, language, nsid, at-identifier. + * @see https://atproto.com/specs/lexicon - String Formats section + * @param {string} format - The format type to validate against + * @param {string} value - The value to validate + * @returns {boolean} True if value matches format (unknown formats pass) + */ +function isValidFormat(format, value) { + switch (format) { + case 'datetime': + return isValidDatetime(value); + case 'handle': + return isValidHandle(value); + case 'did': + return isValidDid(value); + case 'uri': + return isValidUri(value); + case 'at-uri': + return isValidAtUri(value); + case 'tid': + return isValidTid(value); + case 'record-key': + return isValidRecordKey(value); + case 'cid': + return isValidCid(value); + case 'language': + return isValidLanguageTag(value); + case 'nsid': + return isValidNsid(value); + case 'at-identifier': + return isValidAtIdentifier(value); + default: + return true; // Unknown formats pass + } +} + +// ============================================================================ +// GRAPHEME COUNTING +// ============================================================================ + +/** + * Counts Unicode Grapheme Clusters in a string. + * Uses Intl.Segmenter when available, falls back to Array.from for codepoints. + * Used for minGraphemes/maxGraphemes constraints. + * @see https://atproto.com/specs/lexicon - grapheme length constraints + * @param {string} str - The string to count graphemes in + * @returns {number} Number of grapheme clusters + */ +function countGraphemes(str) { + // Use Intl.Segmenter if available, otherwise fallback to Array.from + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' }); + return [...segmenter.segment(str)].length; + } + return [...str].length; +} + +// ============================================================================ +// SCHEMA VALIDATORS +// ============================================================================ + +/** + * Validates a string schema definition. + * Checks: minLength/maxLength (UTF-8 bytes), minGraphemes/maxGraphemes constraints. + * @see https://atproto.com/specs/lexicon - String type + * @param {LexiconStringSchema} schema - The string schema to validate + * @returns {string[]|null} Array of errors or null if valid + */ +function validateStringSchema(schema) { + const errors = []; + + if (schema.minLength !== undefined) { + if (typeof schema.minLength !== 'number' || schema.minLength < 0) { + errors.push('minLength must be a non-negative integer'); + } + } + + if (schema.maxLength !== undefined) { + if (typeof schema.maxLength !== 'number' || schema.maxLength < 0) { + errors.push('maxLength must be a non-negative integer'); + } + } + + if (schema.minLength !== undefined && schema.maxLength !== undefined) { + if (schema.minLength > schema.maxLength) { + errors.push('minLength cannot be greater than maxLength'); + } + } + + if (schema.minGraphemes !== undefined) { + if (typeof schema.minGraphemes !== 'number' || schema.minGraphemes < 0) { + errors.push('minGraphemes must be a non-negative integer'); + } + } + + if (schema.maxGraphemes !== undefined) { + if (typeof schema.maxGraphemes !== 'number' || schema.maxGraphemes < 0) { + errors.push('maxGraphemes must be a non-negative integer'); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates an integer schema definition. + * Checks: minimum/maximum range constraints. + * Note: Integers are 64-bit signed; prefer ≤53 bits for JavaScript compatibility. + * @see https://atproto.com/specs/lexicon - Integer type + * @see https://atproto.com/specs/data-model - Integer encoding + * @param {LexiconIntegerSchema} schema - The integer schema to validate + * @returns {string[]|null} Array of errors or null if valid + */ +function validateIntegerSchema(schema) { + const errors = []; + + if (schema.minimum !== undefined && schema.maximum !== undefined) { + if (schema.minimum > schema.maximum) { + errors.push('minimum cannot be greater than maximum'); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a boolean schema definition. + * Boolean schemas have no constraints to validate. + * @see https://atproto.com/specs/lexicon - Boolean type + * @param {object} _schema - The boolean schema (unused) + * @returns {null} Always returns null (no constraints) + */ +function validateBooleanSchema(_schema) { + return null; // Boolean schemas have no constraints to validate +} + +/** + * Validates an object schema definition. + * Checks: nullable fields exist in properties, validates nested property schemas. + * @see https://atproto.com/specs/lexicon - Object type + * @param {LexiconObjectSchema} schema - The object schema to validate + * @param {ValidationContext} ctx - Validation context with lexicons and currentLexicon + * @returns {string[]|null} Array of errors or null if valid + */ +function validateObjectSchema(schema, ctx) { + const errors = []; + const properties = schema.properties || {}; + + // Validate nullable fields exist in properties + if (schema.nullable) { + for (const field of schema.nullable) { + if (!(field in properties)) { + errors.push(`nullable field '${field}' is not in properties`); + } + } + } + + // Validate each property schema + for (const [name, propSchema] of Object.entries(properties)) { + const propErrors = validateFieldSchema(propSchema, ctx); + if (propErrors) { + errors.push(...propErrors.map((e) => `property '${name}': ${e}`)); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates an array schema definition. + * Checks: items required, minLength/maxLength constraints, validates items schema. + * @see https://atproto.com/specs/lexicon - Array type + * @param {LexiconArraySchema} schema - The array schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateArraySchema(schema, ctx) { + const errors = []; + + if (!schema.items) { + errors.push('array schema must have items'); + return errors; + } + + if (schema.minLength !== undefined && schema.maxLength !== undefined) { + if (schema.minLength > schema.maxLength) { + errors.push('minLength cannot be greater than maxLength'); + } + } + + const itemErrors = validateFieldSchema(schema.items, ctx); + if (itemErrors) { + errors.push(...itemErrors.map((e) => `items: ${e}`)); + } + + return errors.length ? errors : null; +} + +/** + * Validates a ref schema definition. + * Refs can be local (#defName) or global (nsid#defName or nsid for implicit main). + * Validates that referenced definition exists. + * @see https://atproto.com/specs/lexicon - Ref type + * @param {LexiconRefSchema} schema - The ref schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateRefSchema(schema, ctx) { + const errors = []; + + if (!schema.ref) { + errors.push('ref schema must have ref field'); + return errors; + } + + const ref = schema.ref; + if (ref === '' || ref === '#') { + errors.push('ref cannot be empty'); + return errors; + } + + // Check for trailing # in global refs + if (ref.includes('#') && ref.endsWith('#')) { + errors.push('ref cannot end with #'); + return errors; + } + + // Resolve and validate that the referenced definition exists + if (ref.startsWith('#')) { + // Local reference - check if definition exists in current lexicon + const defName = ref.slice(1); + const currentLexicon = ctx.lexicons.find((l) => l.id === ctx.currentLexicon); + if (!currentLexicon || !currentLexicon.defs || !currentLexicon.defs[defName]) { + errors.push(`Referenced definition not found: ${ref}`); + } + } else { + // Global reference - parse nsid and optional def name + let nsid, defName; + if (ref.includes('#')) { + [nsid, defName] = ref.split('#'); + } else { + nsid = ref; + defName = 'main'; // implicit main + } + + // Check if referenced lexicon exists + const referencedLexicon = ctx.lexicons.find((l) => l.id === nsid); + if (!referencedLexicon) { + errors.push(`Referenced lexicon not found: ${nsid}`); + } else if (defName && (!referencedLexicon.defs || !referencedLexicon.defs[defName])) { + errors.push(`Referenced definition not found: ${ref}`); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a union schema definition. + * Unions can be open (extensible) or closed (fixed variants). + * Closed unions cannot have empty refs. + * @see https://atproto.com/specs/lexicon - Union type + * @param {LexiconUnionSchema} schema - The union schema to validate + * @param {object} _ctx - Validation context (unused) + * @returns {string[]|null} Array of errors or null if valid + */ +function validateUnionSchema(schema, _ctx) { + const errors = []; + + if (!schema.refs) { + errors.push('union schema must have refs field'); + return errors; + } + + if (schema.closed === true && schema.refs.length === 0) { + errors.push('closed union cannot have empty refs'); + } + + return errors.length ? errors : null; +} + +/** + * Validates a blob schema definition. + * Checks: maxSize positive, accept MIME patterns valid. + * @see https://atproto.com/specs/lexicon - Blob type + * @see https://atproto.com/specs/data-model - Blob format + * @param {LexiconBlobSchema} schema - The blob schema to validate + * @returns {string[]|null} Array of errors or null if valid + */ +function validateBlobSchema(schema) { + const errors = []; + + if (schema.maxSize !== undefined && schema.maxSize <= 0) { + errors.push('maxSize must be positive'); + } + + if (schema.accept) { + for (const pattern of schema.accept) { + if (!pattern.includes('/')) { + errors.push(`invalid MIME type pattern: ${pattern}`); + } else { + const [_type, subtype] = pattern.split('/'); + // Check for partial wildcards (not allowed) + if (subtype && subtype !== '*' && subtype.includes('*')) { + errors.push(`invalid MIME type pattern: ${pattern}`); + } + } + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a bytes schema definition. + * Checks: minLength/maxLength constraints are non-negative and consistent. + * @see https://atproto.com/specs/lexicon - Bytes type + * @see https://atproto.com/specs/data-model - Bytes encoding (JSON: $bytes base64) + * @param {LexiconBytesSchema} schema - The bytes schema to validate + * @returns {string[]|null} Array of errors or null if valid + */ +function validateBytesSchema(schema) { + const errors = []; + + if (schema.minLength !== undefined && schema.minLength < 0) { + errors.push('minLength must be non-negative'); + } + + if (schema.maxLength !== undefined && schema.maxLength < 0) { + errors.push('maxLength must be non-negative'); + } + + if (schema.minLength !== undefined && schema.maxLength !== undefined) { + if (schema.minLength > schema.maxLength) { + errors.push('minLength cannot be greater than maxLength'); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a token schema definition. + * Tokens are special string sub-types with no additional constraints. + * @see https://atproto.com/specs/lexicon - Token type + * @param {object} _schema - The token schema (unused) + * @returns {null} Always returns null (no constraints) + */ +function validateTokenSchema(_schema) { + return null; +} + +/** + * Validates an unknown schema definition. + * Unknown is a meta-type accepting arbitrary JSON objects. + * @see https://atproto.com/specs/lexicon - Unknown type + * @param {object} _schema - The unknown schema (unused) + * @returns {null} Always returns null (no constraints) + */ +function validateUnknownSchema(_schema) { + return null; +} + +/** + * Validates a record schema definition. + * Records are primary types representing stored data with a record field containing object schema. + * @see https://atproto.com/specs/lexicon - Record type + * @param {LexiconRecordDef} schema - The record schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateRecordSchema(schema, ctx) { + const errors = []; + + if (!schema.record) { + errors.push('record schema must have record field'); + return errors; + } + + const recordErrors = validateObjectSchema(schema.record, ctx); + if (recordErrors) { + errors.push(...recordErrors); + } + + return errors.length ? errors : null; +} + +/** + * Validates a params schema definition. + * Params are for XRPC query/procedure parameters. + * Properties must be primitives (string, integer, boolean, unknown) or arrays of primitives. + * Objects, blobs, and bytes are not allowed. + * @see https://atproto.com/specs/lexicon - Params type + * @see https://atproto.com/specs/xrpc - XRPC parameters + * @param {LexiconParamsDef|LexiconObjectSchema} schema - The params schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateParamsSchema(schema, ctx) { + const errors = []; + + // Params properties must be primitive types (string, integer, boolean, unknown) or arrays of primitives + // Objects and blobs are not allowed + if (schema.properties) { + for (const [name, prop] of Object.entries(schema.properties)) { + if (name === '') { + errors.push('property name cannot be empty'); + continue; + } + + if (!prop || !prop.type) continue; + + const disallowedTypes = ['object', 'blob', 'bytes', 'record']; + if (disallowedTypes.includes(prop.type)) { + errors.push(`params property '${name}' cannot be type '${prop.type}'`); + } + + // Arrays are allowed but items must be primitives + if (prop.type === 'array' && prop.items) { + if (disallowedTypes.includes(prop.items.type)) { + errors.push(`params array items cannot be type '${prop.items.type}'`); + } + } + + // Validate nested field schemas + const fieldErrors = validateFieldSchema(prop, ctx); + if (fieldErrors) { + errors.push(...fieldErrors.map((e) => `property '${name}': ${e}`)); + } + } + } + + // Validate required fields exist in properties + if (schema.required && schema.properties) { + for (const req of schema.required) { + if (!schema.properties[req]) { + errors.push(`required field '${req}' not in properties`); + } + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a query schema definition. + * Queries are XRPC endpoints that retrieve data (read-only). + * @see https://atproto.com/specs/lexicon - Query type + * @see https://atproto.com/specs/xrpc - XRPC queries + * @param {LexiconQueryDef} schema - The query schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateQuerySchema(schema, ctx) { + const errors = []; + + if (schema.parameters) { + const paramErrors = validateParamsSchema(schema.parameters, ctx); + if (paramErrors) { + errors.push(...paramErrors.map((e) => `parameters: ${e}`)); + } + } + + if (schema.output?.schema) { + const outputErrors = validateFieldSchema(schema.output.schema, ctx); + if (outputErrors) { + errors.push(...outputErrors.map((e) => `output: ${e}`)); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a procedure schema definition. + * Procedures are XRPC endpoints that modify data (create/update/delete). + * @see https://atproto.com/specs/lexicon - Procedure type + * @see https://atproto.com/specs/xrpc - XRPC procedures + * @param {LexiconProcedureDef} schema - The procedure schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateProcedureSchema(schema, ctx) { + const errors = []; + + if (schema.parameters) { + const paramErrors = validateParamsSchema(schema.parameters, ctx); + if (paramErrors) { + errors.push(...paramErrors.map((e) => `parameters: ${e}`)); + } + } + + if (schema.input?.schema) { + const inputErrors = validateFieldSchema(schema.input.schema, ctx); + if (inputErrors) { + errors.push(...inputErrors.map((e) => `input: ${e}`)); + } + } + + if (schema.output?.schema) { + const outputErrors = validateFieldSchema(schema.output.schema, ctx); + if (outputErrors) { + errors.push(...outputErrors.map((e) => `output: ${e}`)); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a subscription schema definition. + * Subscriptions are XRPC WebSocket endpoints for real-time data streams. + * @see https://atproto.com/specs/lexicon - Subscription type + * @see https://atproto.com/specs/xrpc - XRPC subscriptions + * @param {LexiconSubscriptionDef} schema - The subscription schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateSubscriptionSchema(schema, ctx) { + const errors = []; + + if (schema.parameters) { + const paramErrors = validateParamsSchema(schema.parameters, ctx); + if (paramErrors) { + errors.push(...paramErrors.map((e) => `parameters: ${e}`)); + } + } + + if (schema.message?.schema) { + const messageErrors = validateFieldSchema(schema.message.schema, ctx); + if (messageErrors) { + errors.push(...messageErrors.map((e) => `message: ${e}`)); + } + } + + return errors.length ? errors : null; +} + +/** + * Validates a field schema (property within an object). + * Dispatches to type-specific validators. + * @see https://atproto.com/specs/lexicon - Field types + * @param {LexiconDef} schema - The field schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateFieldSchema(schema, ctx) { + if (!schema || !schema.type) { + return ['missing type']; + } + + switch (schema.type) { + case 'string': + return validateStringSchema(schema); + case 'integer': + return validateIntegerSchema(schema); + case 'boolean': + return validateBooleanSchema(schema); + case 'object': + return validateObjectSchema(schema, ctx); + case 'array': + return validateArraySchema(schema, ctx); + case 'ref': + return validateRefSchema(schema, ctx); + case 'union': + return validateUnionSchema(schema, ctx); + case 'blob': + return validateBlobSchema(schema); + case 'bytes': + return validateBytesSchema(schema); + case 'cid-link': + return null; + case 'null': + return null; + case 'token': + return validateTokenSchema(schema); + case 'unknown': + return validateUnknownSchema(schema); + default: + return [`unknown field type: ${schema.type}`]; + } +} + +/** + * Validates a definition schema (top-level def in a lexicon). + * Supports all primary types (record, query, procedure, subscription) and field types. + * @see https://atproto.com/specs/lexicon - Definition types + * @param {LexiconDef} schema - The definition schema to validate + * @param {ValidationContext} ctx - Validation context + * @returns {string[]|null} Array of errors or null if valid + */ +function validateDefSchema(schema, ctx) { + if (!schema || !schema.type) { + return ['missing type']; + } + + switch (schema.type) { + case 'record': + return validateRecordSchema(schema, ctx); + case 'query': + return validateQuerySchema(schema, ctx); + case 'procedure': + return validateProcedureSchema(schema, ctx); + case 'subscription': + return validateSubscriptionSchema(schema, ctx); + case 'params': + return validateParamsSchema(schema, ctx); + case 'object': + return validateObjectSchema(schema, ctx); + case 'string': + return validateStringSchema(schema); + case 'integer': + return validateIntegerSchema(schema); + case 'boolean': + return validateBooleanSchema(schema); + case 'array': + return validateArraySchema(schema, ctx); + case 'ref': + return validateRefSchema(schema, ctx); + case 'union': + return validateUnionSchema(schema, ctx); + case 'blob': + return validateBlobSchema(schema); + case 'bytes': + return validateBytesSchema(schema); + case 'token': + return validateTokenSchema(schema); + case 'unknown': + return validateUnknownSchema(schema); + default: + return [`unknown definition type: ${schema.type}`]; + } +} + +// ============================================================================ +// DATA VALIDATORS +// ============================================================================ + +/** + * Validates string data against a string schema. + * Checks: type, minLength/maxLength, minGraphemes/maxGraphemes, enum, const, format. + * @see https://atproto.com/specs/lexicon - String type + * @param {*} data - The data to validate + * @param {LexiconStringSchema} schema - The string schema + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateStringData(data, schema, path) { + if (typeof data !== 'string') { + return { path, message: 'expected string' }; + } + + if (schema.minLength !== undefined && data.length < schema.minLength) { + return { path, message: `string too short (min ${schema.minLength})` }; + } + + if (schema.maxLength !== undefined && data.length > schema.maxLength) { + return { path, message: `string too long (max ${schema.maxLength})` }; + } + + if (schema.minGraphemes !== undefined) { + const graphemes = countGraphemes(data); + if (graphemes < schema.minGraphemes) { + return { path, message: `too few graphemes (min ${schema.minGraphemes})` }; + } + } + + if (schema.maxGraphemes !== undefined) { + const graphemes = countGraphemes(data); + if (graphemes > schema.maxGraphemes) { + return { path, message: `too many graphemes (max ${schema.maxGraphemes})` }; + } + } + + if (schema.enum && !schema.enum.includes(data)) { + return { path, message: `value not in enum` }; + } + + if (schema.const !== undefined && data !== schema.const) { + return { path, message: `value must be ${schema.const}` }; + } + + if (schema.format && !isValidFormat(schema.format, data)) { + return { path, message: `invalid ${schema.format} format` }; + } + + return null; +} + +/** + * Validates integer data against an integer schema. + * Checks: type (must be whole number), minimum/maximum, enum. + * @see https://atproto.com/specs/lexicon - Integer type + * @param {*} data - The data to validate + * @param {LexiconIntegerSchema} schema - The integer schema + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateIntegerData(data, schema, path) { + if (typeof data !== 'number' || !Number.isInteger(data)) { + return { path, message: 'expected integer' }; + } + + if (schema.minimum !== undefined && data < schema.minimum) { + return { path, message: `value below minimum (${schema.minimum})` }; + } + + if (schema.maximum !== undefined && data > schema.maximum) { + return { path, message: `value above maximum (${schema.maximum})` }; + } + + if (schema.enum && !schema.enum.includes(data)) { + return { path, message: 'value not in enum' }; + } + + return null; +} + +/** + * Validates boolean data against a boolean schema. + * @see https://atproto.com/specs/lexicon - Boolean type + * @param {*} data - The data to validate + * @param {object} _schema - The boolean schema (unused) + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateBooleanData(data, _schema, path) { + if (typeof data !== 'boolean') { + return { path, message: 'expected boolean' }; + } + return null; +} + +/** + * Validates object data against an object schema. + * Checks: required fields, validates each property, handles nullable fields. + * @see https://atproto.com/specs/lexicon - Object type + * @param {*} data - The data to validate + * @param {LexiconObjectSchema} schema - The object schema + * @param {string} path - JSON path to this value + * @param {ValidationContext} ctx - Validation context + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateObjectData(data, schema, path, ctx) { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { path, message: 'expected object' }; + } + + const properties = schema.properties || {}; + const required = schema.required || []; + const nullable = schema.nullable || []; + + // Check required fields + for (const field of required) { + if (!(field in data)) { + return { path, message: `required field '${field}' is missing` }; + } + } + + // Validate each property + for (const [name, value] of Object.entries(data)) { + const propSchema = properties[name]; + if (!propSchema) continue; // Unknown properties are allowed + + const propPath = path ? `${path}.${name}` : name; + + // Check for null values + if (value === null) { + if (!nullable.includes(name)) { + return { path: propPath, message: 'null not allowed' }; + } + continue; + } + + const error = validateFieldData(value, propSchema, propPath, ctx); + if (error) return error; + } + + return null; +} + +/** + * Validates array data against an array schema. + * Checks: type, minLength/maxLength, validates each item. + * @see https://atproto.com/specs/lexicon - Array type + * @param {*} data - The data to validate + * @param {LexiconArraySchema} schema - The array schema + * @param {string} path - JSON path to this value + * @param {ValidationContext} ctx - Validation context + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateArrayData(data, schema, path, ctx) { + if (!Array.isArray(data)) { + return { path, message: 'expected array' }; + } + + if (schema.minLength !== undefined && data.length < schema.minLength) { + return { path, message: `array too short (min ${schema.minLength})` }; + } + + if (schema.maxLength !== undefined && data.length > schema.maxLength) { + return { path, message: `array too long (max ${schema.maxLength})` }; + } + + // Validate each item + for (let i = 0; i < data.length; i++) { + const itemPath = `${path}[${i}]`; + const error = validateFieldData(data[i], schema.items, itemPath, ctx); + if (error) return error; + } + + return null; +} + +/** + * Validates blob data against a blob schema. + * Blobs have: $type="blob", ref.$link (CID with raw multicodec), mimeType, size. + * Checks: maxSize, accept (MIME patterns). + * @see https://atproto.com/specs/lexicon - Blob type + * @see https://atproto.com/specs/data-model - Blob format + * @param {*} data - The data to validate + * @param {LexiconBlobSchema} schema - The blob schema + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateBlobData(data, schema, path) { + if (typeof data !== 'object' || data === null) { + return { path, message: 'expected blob object' }; + } + + // Check $type + if (data.$type !== 'blob') { + return { path, message: 'blob must have $type: "blob"' }; + } + + // Check ref + if (!data.ref || typeof data.ref !== 'object' || !data.ref.$link) { + return { path, message: 'blob must have ref.$link' }; + } + + // Validate CID + const cid = data.ref.$link; + if (!isValidCid(cid)) { + return { path, message: 'invalid CID in blob ref' }; + } + + // Check for raw multicodec (bafkrei prefix) + if (!cid.startsWith('bafkrei')) { + return { path, message: 'blob CID must use raw multicodec' }; + } + + // Check mimeType + if (typeof data.mimeType !== 'string' || data.mimeType === '') { + return { path, message: 'blob must have non-empty mimeType' }; + } + + // Check size + if (typeof data.size !== 'number') { + return { path, message: 'blob must have size' }; + } + + if (data.size < 0) { + return { path, message: 'blob size must be non-negative' }; + } + + // Check maxSize constraint + if (schema.maxSize !== undefined && data.size > schema.maxSize) { + return { path, message: `blob size exceeds maxSize (${schema.maxSize})` }; + } + + // Check accept constraint + if (schema.accept && schema.accept.length > 0) { + const mimeType = data.mimeType; + const accepted = schema.accept.some((pattern) => { + if (pattern === '*/*') return true; + const [type, subtype] = pattern.split('/'); + const [dataType, _dataSubtype] = mimeType.split('/'); + if (subtype === '*') return type === dataType; + return pattern === mimeType; + }); + if (!accepted) { + return { path, message: `MIME type ${mimeType} not accepted` }; + } + } + + // Check for extra fields + const allowedFields = ['$type', 'ref', 'mimeType', 'size']; + for (const key of Object.keys(data)) { + if (!allowedFields.includes(key)) { + return { path, message: `unexpected field '${key}' in blob` }; + } + } + + return null; +} + +/** + * Validates bytes data against a bytes schema. + * Bytes are encoded as {$bytes: "base64-string"} in JSON. + * Checks: minLength/maxLength (decoded byte length). + * @see https://atproto.com/specs/lexicon - Bytes type + * @see https://atproto.com/specs/data-model - Bytes encoding + * @param {*} data - The data to validate + * @param {LexiconBytesSchema} schema - The bytes schema + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateBytesData(data, schema, path) { + if (typeof data !== 'object' || data === null) { + return { path, message: 'expected bytes object' }; + } + + if (!('$bytes' in data)) { + return { path, message: 'bytes object must have $bytes field' }; + } + + if (typeof data.$bytes !== 'string') { + return { path, message: '$bytes must be a string' }; + } + + // Check for extra fields + if (Object.keys(data).length !== 1) { + return { path, message: 'bytes object must have exactly one field' }; + } + + // Validate base64 + const base64 = data.$bytes; + try { + const decoded = atob(base64); + const byteLength = decoded.length; + + if (schema.minLength !== undefined && byteLength < schema.minLength) { + return { path, message: `bytes too short (min ${schema.minLength})` }; + } + + if (schema.maxLength !== undefined && byteLength > schema.maxLength) { + return { path, message: `bytes too long (max ${schema.maxLength})` }; + } + } catch { + return { path, message: 'invalid base64 in $bytes' }; + } + + return null; +} + +/** + * Validates union data against a union schema. + * Union values must have $type indicating the variant. + * Closed unions require $type to be in refs; open unions allow extensions. + * @see https://atproto.com/specs/lexicon - Union type + * @param {*} data - The data to validate + * @param {LexiconUnionSchema} schema - The union schema + * @param {string} path - JSON path to this value + * @param {ValidationContext} ctx - Validation context + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateUnionData(data, schema, path, ctx) { + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { path, message: 'union value must be an object' }; + } + + if (!data.$type) { + return { path, message: 'union value must have $type' }; + } + + // For closed unions, check if $type is in refs + if (schema.closed === true) { + const refs = schema.refs || []; + if (!refs.includes(data.$type)) { + return { path, message: `$type '${data.$type}' not in union refs` }; + } + } + + // Validate against the referenced schema + // $type can be: "defName", "#defName", "nsid#defName", or "nsid" + let typeRef = data.$type; + const refs = schema.refs || []; + + // If $type matches a local ref pattern in the union, treat as local + if (!typeRef.includes('#') && !typeRef.includes('.')) { + // Bare name like "post" - check if #post is in refs + if (refs.includes(`#${typeRef}`)) { + typeRef = `#${typeRef}`; + } + } + + const { nsid, defName } = resolveRef(typeRef, ctx); + const referencedLexicon = ctx.lexicons.find((l) => l.id === nsid); + if (!referencedLexicon) { + return { path, message: `Lexicon '${nsid}' not found` }; + } + + const referencedDef = referencedLexicon.defs?.[defName]; + if (!referencedDef) { + return { path, message: `Definition '${defName}' not found in lexicon '${nsid}'` }; + } + + return validateFieldData(data, referencedDef, path, ctx); +} + +/** + * Resolves a ref string to its NSID and definition name. + * Handles local refs (#name), global refs (nsid#name), and implicit main (nsid). + * @param {string} ref - The ref string to resolve + * @param {ValidationContext} ctx - Validation context with currentLexicon + * @returns {{nsid: string, defName: string}} Resolved NSID and definition name + */ +function resolveRef(ref, ctx) { + // Parse the ref string to get nsid and definition name + let nsid, defName; + if (ref.startsWith('#')) { + // Local reference + nsid = ctx.currentLexicon; + defName = ref.slice(1); + } else if (ref.includes('#')) { + // Global reference with fragment + [nsid, defName] = ref.split('#'); + } else { + // Global reference to main + nsid = ref; + defName = 'main'; + } + return { nsid, defName }; +} + +/** + * Validates ref data against a ref schema. + * Resolves the reference and validates data against the referenced schema. + * Includes circular reference detection. + * @see https://atproto.com/specs/lexicon - Ref type + * @param {*} data - The data to validate + * @param {LexiconRefSchema} schema - The ref schema + * @param {string} path - JSON path to this value + * @param {ValidationContext} ctx - Validation context + * @param {Set} [visited] - Set of visited refs for cycle detection + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateRefData(data, schema, path, ctx, visited = new Set()) { + // Resolve the reference and validate data against it + const ref = schema.ref; + if (!ref) { + return { path, message: 'ref schema missing ref field' }; + } + + // Create a canonical ref key for cycle detection + const { nsid, defName } = resolveRef(ref, ctx); + const refKey = `${nsid}#${defName}`; + + // Check for circular reference + if (visited.has(refKey)) { + return { path, message: `Circular reference detected: ${refKey}` }; + } + + // Find the referenced lexicon + const referencedLexicon = ctx.lexicons.find((l) => l.id === nsid); + if (!referencedLexicon) { + return { path, message: `Lexicon '${nsid}' not found` }; + } + + // Find the referenced definition + const referencedDef = referencedLexicon.defs?.[defName]; + if (!referencedDef) { + return { path, message: `Definition '${defName}' not found in lexicon '${nsid}'` }; + } + + // Add to visited set for cycle detection + const newVisited = new Set(visited); + newVisited.add(refKey); + + // If the resolved def is another ref, follow it with cycle detection + if (referencedDef.type === 'ref') { + return validateRefData(data, referencedDef, path, ctx, newVisited); + } + + // Validate data against the resolved schema + return validateFieldData(data, referencedDef, path, ctx); +} + +/** + * Validates token data against a token schema. + * Tokens must be non-empty strings representing defined values in the schema. + * @see https://atproto.com/specs/lexicon - Token type + * @param {*} data - The data to validate + * @param {object} _schema - The token schema (unused) + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateTokenData(data, _schema, path) { + // Token must be a non-empty string + if (typeof data !== 'string') { + return { path, message: 'expected string for token' }; + } + if (data === '') { + return { path, message: 'token cannot be empty' }; + } + return null; +} + +/** + * Validates unknown data against an unknown schema. + * Unknown accepts arbitrary JSON objects but not arrays, blobs, or bytes objects. + * @see https://atproto.com/specs/lexicon - Unknown type + * @param {*} data - The data to validate + * @param {object} _schema - The unknown schema (unused) + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateUnknownData(data, _schema, path) { + // Unknown must be a plain object (not array, not primitive, not $bytes, not blob) + if (data === null || typeof data !== 'object') { + return { path, message: 'unknown must be an object' }; + } + if (Array.isArray(data)) { + return { path, message: 'unknown cannot be an array' }; + } + // Check for special $bytes encoding + if (data.$bytes !== undefined) { + return { path, message: 'unknown cannot be a bytes object' }; + } + // Check for blob ($type: 'blob') + if (data.$type === 'blob') { + return { path, message: 'unknown cannot be a blob' }; + } + return null; +} + +/** + * Validates CID link data against a cid-link schema. + * CID links are encoded as {$link: "cid-string"} in JSON. + * @see https://atproto.com/specs/lexicon - CID-Link type + * @see https://atproto.com/specs/data-model - Link encoding + * @param {*} data - The data to validate + * @param {object} _schema - The cid-link schema (unused) + * @param {string} path - JSON path to this value + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateCidLinkData(data, _schema, path) { + // CID link must be an object with $link field containing a valid CID + if (data === null || typeof data !== 'object') { + return { path, message: 'expected CID link object' }; + } + if (Array.isArray(data)) { + return { path, message: 'expected CID link object' }; + } + if (data.$link === undefined) { + return { path, message: 'CID link must have $link field' }; + } + if (typeof data.$link !== 'string') { + return { path, message: 'CID link $link must be a string' }; + } + if (!isValidCid(data.$link)) { + return { path, message: 'invalid CID format in $link' }; + } + return null; +} + +/** + * Validates field data against a field schema. + * Dispatches to type-specific data validators. + * @see https://atproto.com/specs/lexicon - Field types + * @param {*} data - The data to validate + * @param {LexiconDef} schema - The field schema + * @param {string} path - JSON path to this value + * @param {ValidationContext} ctx - Validation context + * @returns {{path: string, message: string}|null} Error or null if valid + */ +function validateFieldData(data, schema, path, ctx) { + if (!schema || !schema.type) { + return { path, message: 'missing schema type' }; + } + + switch (schema.type) { + case 'string': + return validateStringData(data, schema, path); + case 'integer': + return validateIntegerData(data, schema, path); + case 'boolean': + return validateBooleanData(data, schema, path); + case 'object': + return validateObjectData(data, schema, path, ctx); + case 'array': + return validateArrayData(data, schema, path, ctx); + case 'blob': + return validateBlobData(data, schema, path); + case 'bytes': + return validateBytesData(data, schema, path); + case 'union': + return validateUnionData(data, schema, path, ctx); + case 'ref': + return validateRefData(data, schema, path, ctx); + case 'cid-link': + return validateCidLinkData(data, schema, path); + case 'token': + return validateTokenData(data, schema, path); + case 'unknown': + return validateUnknownData(data, schema, path); + default: + return { path, message: `unknown type: ${schema.type}` }; + } +} + +// ============================================================================ +// ENTRY POINTS +// ============================================================================ + /** * Validate lexicon schemas are well-formed. - * @param {object[]} lexicons - Array of lexicon JSON objects - * @returns {null | Record} null if valid, errors by NSID if invalid + * Validates each definition in each lexicon against the Lexicon specification. + * @see https://atproto.com/specs/lexicon + * @param {Lexicon[]} lexicons - Array of lexicon JSON objects + * @returns {null | Object} null if valid, errors by NSID if invalid + * @example + * const errors = validateLexicons([myLexicon]); + * if (errors) { + * console.error('Invalid lexicons:', errors); + * } */ export function validateLexicons(lexicons) { - // TODO: implement - return null + /** @type {Object} */ + const errors = {}; + + for (const lexicon of lexicons) { + const nsid = lexicon.id; + if (!nsid) { + const key = errors[''] ? '' : ''; + errors[key] = errors[key] || []; + errors[key].push('missing id field'); + continue; + } + + const lexiconErrors = []; + const ctx = { lexicons, currentLexicon: nsid }; + + for (const [_defName, def] of Object.entries(lexicon.defs || {})) { + const defErrors = validateDefSchema(def, ctx); + if (defErrors) { + lexiconErrors.push(...defErrors); + } + } + + if (lexiconErrors.length) { + errors[nsid] = lexiconErrors; + } + } + + return Object.keys(errors).length ? errors : null; } /** * Validate a record against a lexicon schema. - * @param {object[]} lexicons - Array of lexicon JSON objects + * The collection NSID must match a lexicon with a "record" type main definition. + * @see https://atproto.com/specs/lexicon - Record type + * @param {Lexicon[]} lexicons - Array of lexicon JSON objects * @param {string} collection - NSID of the collection (e.g., "app.bsky.feed.post") * @param {object} record - The record data to validate * @returns {null | {path: string, message: string}} null if valid, error if invalid + * @example + * const error = validateRecord(lexicons, 'app.bsky.feed.post', postData); + * if (error) { + * console.error(`Validation error at ${error.path}: ${error.message}`); + * } */ export function validateRecord(lexicons, collection, record) { - // TODO: implement - return null + // Find the lexicon for this collection + const lexicon = lexicons.find((l) => l.id === collection); + if (!lexicon) { + return { path: '', message: `unknown collection: ${collection}` }; + } + + // Get the main definition + const mainDef = lexicon.defs?.main; + if (!mainDef) { + return { path: '', message: `lexicon ${collection} has no main definition` }; + } + + // Must be a record type + if (mainDef.type !== 'record') { + return { path: '', message: `${collection} is not a record type` }; + } + + const ctx = { lexicons, currentLexicon: collection }; + + // Validate against the record's object schema + return validateObjectData(record, mainDef.record, '', ctx); } diff --git a/lexicon.test.js b/lexicon.test.js index 6162d95..cd65ae8 100644 --- a/lexicon.test.js +++ b/lexicon.test.js @@ -1,18 +1,18 @@ -import { describe, test, expect } from 'vitest' -import { validateLexicons, validateRecord } from './lexicon.js' -import { honkValidateLexicons, honkValidateRecord } from './test/oracle.js' +import { describe, test, expect } from 'vitest'; +import { validateLexicons, validateRecord } from './lexicon.js'; +import { atprotoValidateLexicons, atprotoValidateRecord } from './test/atproto-oracle.js'; // Schema inputs -import * as schemaInputs from './test/inputs/schema/index.js' +import * as schemaInputs from './test/inputs/schema/index.js'; // Data inputs -import * as dataInputs from './test/inputs/data/index.js' +import * as dataInputs from './test/inputs/data/index.js'; // Format inputs -import * as formatInputs from './test/inputs/format/index.js' +import * as formatInputs from './test/inputs/format/index.js'; // E2E inputs -import * as e2eInputs from './test/inputs/e2e/index.js' +import * as e2eInputs from './test/inputs/e2e/index.js'; // Collect all schema inputs const allSchemaInputs = [ @@ -28,9 +28,13 @@ const allSchemaInputs = [ ...schemaInputs.tokenSchemaInputs, ...schemaInputs.unknownSchemaInputs, ...schemaInputs.recordSchemaInputs, -] + ...schemaInputs.paramsSchemaInputs, + ...schemaInputs.querySchemaInputs, + ...schemaInputs.procedureSchemaInputs, + ...schemaInputs.subscriptionSchemaInputs, +]; -// Collect all data inputs +// Collect all data inputs (record types only - validateRecord doesn't handle query/procedure/params) const allDataInputs = [ ...dataInputs.stringDataInputs, ...dataInputs.integerDataInputs, @@ -38,7 +42,11 @@ const allDataInputs = [ ...dataInputs.arrayDataInputs, ...dataInputs.blobDataInputs, ...dataInputs.bytesDataInputs, -] + ...dataInputs.tokenDataInputs, + ...dataInputs.unknownDataInputs, + ...dataInputs.unionDataInputs, + ...dataInputs.cidLinkDataInputs, +]; // Collect all format inputs const allFormatInputs = [ @@ -52,71 +60,163 @@ const allFormatInputs = [ ...formatInputs.nsidFormatInputs, ...formatInputs.languageFormatInputs, ...formatInputs.recordKeyFormatInputs, -] +]; // Collect all e2e inputs const allE2eInputs = [ ...e2eInputs.bskyPostInputs, ...e2eInputs.bskyProfileInputs, -] - -describe('schema validation - matches honk', () => { + ...e2eInputs.integrationInputs, +]; + +// Helper: determine expected result from test name +// Tests with 'valid' in name should pass, 'invalid' should fail +function expectedValid(name) { + if (name.includes('-valid-')) return true; + if (name.includes('-invalid-')) return false; + throw new Error(`Test name must contain '-valid-' or '-invalid-': ${name}`); +} + +// ========================================================================== +// SPEC-BASED TESTS +// ========================================================================== + +describe('schema validation', () => { for (const input of allSchemaInputs) { test(input.name, () => { - const honkResult = honkValidateLexicons([input.lexicon]) - const ourResult = validateLexicons([input.lexicon]) - - // Both should agree on valid/invalid - const honkValid = honkResult === null - const ourValid = ourResult === null - expect(ourValid).toBe(honkValid) - - // If invalid, error structure should match - if (!honkValid && !ourValid) { - expect(ourResult).toEqual(honkResult) - } - }) + const lexicons = input.additionalLexicons + ? [input.lexicon, ...input.additionalLexicons] + : [input.lexicon]; + const result = validateLexicons(lexicons); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); } -}) +}); -describe('data validation - matches honk', () => { +describe('data validation', () => { for (const input of allDataInputs) { test(input.name, () => { - const honkResult = honkValidateRecord(input.lexicons, input.collection, input.record) - const ourResult = validateRecord(input.lexicons, input.collection, input.record) - - // Both should agree on valid/invalid - const honkValid = honkResult === null - const ourValid = ourResult === null - expect(ourValid).toBe(honkValid) - }) + const result = validateRecord(input.lexicons, input.collection, input.record); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); } -}) +}); -describe('format validation - matches honk', () => { +describe('format validation', () => { for (const input of allFormatInputs) { test(input.name, () => { - const honkResult = honkValidateRecord(input.lexicons, input.collection, input.record) - const ourResult = validateRecord(input.lexicons, input.collection, input.record) - - // Both should agree on valid/invalid - const honkValid = honkResult === null - const ourValid = ourResult === null - expect(ourValid).toBe(honkValid) - }) + const result = validateRecord(input.lexicons, input.collection, input.record); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); } -}) +}); -describe('e2e validation - matches honk', () => { +describe('e2e validation', () => { for (const input of allE2eInputs) { test(input.name, () => { - const honkResult = honkValidateRecord(input.lexicons, input.collection, input.record) - const ourResult = validateRecord(input.lexicons, input.collection, input.record) - - // Both should agree on valid/invalid - const honkValid = honkResult === null - const ourValid = ourResult === null - expect(ourValid).toBe(honkValid) - }) + const result = validateRecord(input.lexicons, input.collection, input.record); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); + } +}); + +// Ref data validation - validates data against resolved ref schemas +describe('ref data validation', () => { + for (const input of dataInputs.refDataInputs) { + test(input.name, () => { + const result = validateRecord(input.lexicons, input.collection, input.record); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); + } +}); + +// Union ref data validation - validates union data against resolved ref schemas +describe('union ref data validation', () => { + for (const input of dataInputs.unionRefDataInputs) { + test(input.name, () => { + const result = validateRecord(input.lexicons, input.collection, input.record); + const isValid = result === null; + expect(isValid).toBe(expectedValid(input.name)); + }); } -}) +}); + +// ========================================================================== +// @ATPROTO/LEXICON COMPARISON (informational - differences are expected) +// See docs/oracle-differences.md for details on behavioral differences. +// ========================================================================== + +describe.skip('@atproto/lexicon comparison', () => { + describe('schema validation', () => { + for (const input of allSchemaInputs) { + test(input.name, () => { + const lexicons = input.additionalLexicons + ? [input.lexicon, ...input.additionalLexicons] + : [input.lexicon]; + const atprotoResult = atprotoValidateLexicons(lexicons); + const ourResult = validateLexicons(lexicons); + + const atprotoValid = atprotoResult === null; + const ourValid = ourResult === null; + expect(ourValid).toBe(atprotoValid); + }); + } + }); + + describe('data validation', () => { + for (const input of allDataInputs) { + test(input.name, () => { + const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record); + const ourResult = validateRecord(input.lexicons, input.collection, input.record); + + const atprotoValid = atprotoResult === null; + const ourValid = ourResult === null; + expect(ourValid).toBe(atprotoValid); + }); + } + }); + + describe('format validation', () => { + for (const input of allFormatInputs) { + test(input.name, () => { + const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record); + const ourResult = validateRecord(input.lexicons, input.collection, input.record); + + const atprotoValid = atprotoResult === null; + const ourValid = ourResult === null; + expect(ourValid).toBe(atprotoValid); + }); + } + }); + + describe('e2e validation', () => { + for (const input of allE2eInputs) { + test(input.name, () => { + const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record); + const ourResult = validateRecord(input.lexicons, input.collection, input.record); + + const atprotoValid = atprotoResult === null; + const ourValid = ourResult === null; + expect(ourValid).toBe(atprotoValid); + }); + } + }); + + describe('ref data validation', () => { + for (const input of dataInputs.refDataInputs) { + test(input.name, () => { + const atprotoResult = atprotoValidateRecord(input.lexicons, input.collection, input.record); + const ourResult = validateRecord(input.lexicons, input.collection, input.record); + + const atprotoValid = atprotoResult === null; + const ourValid = ourResult === null; + expect(ourValid).toBe(atprotoValid); + }); + } + }); +}); diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index c9e4096..26e9abf 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -1,9 +1,111 @@ { - "name": "lexicon-js", + "name": "lexicon.js", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { + "node_modules/@atproto/common-web": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.12.tgz", + "integrity": "sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.8", + "@atproto/lex-json": "0.0.8", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.8.tgz", + "integrity": "sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/syntax": "0.4.2", + "multiformats": "^9.9.0", + "tslib": "^2.8.1", + "uint8arrays": "3.0.0", + "unicode-segmenter": "^0.14.0" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.8.tgz", + "integrity": "sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.8", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", + "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.7", + "@atproto/syntax": "^0.4.2", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", @@ -303,6 +405,28 @@ "node": ">=12.0.0" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -327,6 +451,13 @@ "dev": true, "license": "MIT" }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "dev": true, + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -519,6 +650,44 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -684,6 +853,16 @@ "engines": { "node": ">=8" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/node_modules/.vite/vitest/results.json b/node_modules/.vite/vitest/results.json index cae9032..b6806ae 100644 --- a/node_modules/.vite/vitest/results.json +++ b/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"2.1.9","results":[[":lexicon.test.js",{"duration":77.64029099999999,"failed":true}],[":test/oracle.test.js",{"duration":13.691665999999998,"failed":false}]]} \ No newline at end of file +{"version":"2.1.9","results":[[":lexicon.test.js",{"duration":12.309042000000034,"failed":false}]]} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 77db31c..c844703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,241 @@ { - "name": "lexicon-js", + "name": "lexicon.js", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "lexicon-js", + "name": "lexicon.js", "version": "0.1.0", + "license": "MIT", "devDependencies": { + "@atproto/lexicon": "^0.6.0", + "@biomejs/biome": "^2.3.11", + "typescript": "^5.9.3", "vitest": "^2.0.0" } }, + "node_modules/@atproto/common-web": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.12.tgz", + "integrity": "sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.8", + "@atproto/lex-json": "0.0.8", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/lex-data": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.8.tgz", + "integrity": "sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/syntax": "0.4.2", + "multiformats": "^9.9.0", + "tslib": "^2.8.1", + "uint8arrays": "3.0.0", + "unicode-segmenter": "^0.14.0" + } + }, + "node_modules/@atproto/lex-json": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.8.tgz", + "integrity": "sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "0.0.8", + "tslib": "^2.8.1" + } + }, + "node_modules/@atproto/lexicon": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.0.tgz", + "integrity": "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.7", + "@atproto/syntax": "^0.4.2", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, + "node_modules/@atproto/syntax": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.2.tgz", + "integrity": "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@biomejs/biome": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", + "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.11", + "@biomejs/cli-darwin-x64": "2.3.11", + "@biomejs/cli-linux-arm64": "2.3.11", + "@biomejs/cli-linux-arm64-musl": "2.3.11", + "@biomejs/cli-linux-x64": "2.3.11", + "@biomejs/cli-linux-x64-musl": "2.3.11", + "@biomejs/cli-win32-arm64": "2.3.11", + "@biomejs/cli-win32-x64": "2.3.11" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", + "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", + "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", + "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", + "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", + "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", + "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", + "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", + "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1035,6 +1260,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/iso-datestring-validator": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1059,6 +1291,13 @@ "dev": true, "license": "MIT" }, + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "dev": true, + "license": "(Apache-2.0 AND MIT)" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1251,6 +1490,44 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "multiformats": "^9.4.2" + } + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1416,6 +1693,16 @@ "engines": { "node": ">=8" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index ca31a7b..dc47a88 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,21 @@ { - "name": "lexicon-js", + "name": "lexicon.js", "version": "0.1.0", + "license": "MIT", "type": "module", "exports": "./lexicon.js", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check .", + "typecheck": "tsc" }, "devDependencies": { + "@atproto/lexicon": "^0.6.0", + "@biomejs/biome": "^2.3.11", + "typescript": "^5.9.3", "vitest": "^2.0.0" } } diff --git a/test/atproto-oracle.js b/test/atproto-oracle.js new file mode 100644 index 0000000..8840a09 --- /dev/null +++ b/test/atproto-oracle.js @@ -0,0 +1,68 @@ +/** + * Oracle wrapper for @atproto/lexicon - the official TypeScript implementation + * Used to compare our JS implementation against the official library + */ + +import { Lexicons, parseLexiconDoc, jsonToLex } from '@atproto/lexicon'; + +/** + * Validate lexicon schemas using @atproto/lexicon + * @param {object[]} lexicons - Array of lexicon JSON objects + * @returns {null | Record} null if valid, errors by NSID if invalid + */ +export function atprotoValidateLexicons(lexicons) { + const errors = {}; + + for (const lexicon of lexicons) { + try { + // Deep clone to prevent mutation + parseLexiconDoc(JSON.parse(JSON.stringify(lexicon))); + } catch (e) { + const nsid = lexicon.id || '_unknown'; + if (!errors[nsid]) { + errors[nsid] = []; + } + errors[nsid].push(e.message); + } + } + + return Object.keys(errors).length > 0 ? errors : null; +} + +/** + * Validate a record against a lexicon schema using @atproto/lexicon + * @param {object[]} lexicons - Array of lexicon JSON objects + * @param {string} collection - NSID of the collection + * @param {object} record - The record data to validate + * @returns {null | {path: string, message: string}} null if valid, error if invalid + */ +export function atprotoValidateRecord(lexicons, collection, record) { + try { + const lex = new Lexicons(); + + // Add all lexicons (deep clone because @atproto/lexicon mutates them!) + for (const lexicon of lexicons) { + lex.add(JSON.parse(JSON.stringify(lexicon))); + } + + // Convert JSON record to lex format (BlobRef, Uint8Array, CID instances) + const lexRecord = jsonToLex(record); + + // Validate the record + const result = lex.validate(collection, lexRecord); + + if (result.success) { + return null; + } else { + return { + path: '', + message: result.error.message, + }; + } + } catch (e) { + return { + path: '', + message: e.message, + }; + } +} diff --git a/test/inputs/data/array.js b/test/inputs/data/array.js index 296d475..a882ea0 100644 --- a/test/inputs/data/array.js +++ b/test/inputs/data/array.js @@ -3,127 +3,137 @@ export const arrayDataInputs = [ { name: 'array-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.array.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' }, - minLength: 1, - maxLength: 5 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.array.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + minLength: 1, + maxLength: 5, + }, + }, + }, + }, + }, + }, + ], collection: 'test.array.data', - record: { tags: ['hello', 'world'] } + record: { tags: ['hello', 'world'] }, }, { name: 'array-data-invalid-below-minLength', - lexicons: [{ - lexicon: 1, - id: 'test.array.minlength', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' }, - minLength: 3 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.array.minlength', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + minLength: 3, + }, + }, + }, + }, + }, + }, + ], collection: 'test.array.minlength', - record: { tags: ['hello'] } + record: { tags: ['hello'] }, }, { name: 'array-data-invalid-above-maxLength', - lexicons: [{ - lexicon: 1, - id: 'test.array.maxlength', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' }, - maxLength: 2 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.array.maxlength', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + maxLength: 2, + }, + }, + }, + }, + }, + }, + ], collection: 'test.array.maxlength', - record: { tags: ['a', 'b', 'c'] } + record: { tags: ['a', 'b', 'c'] }, }, { name: 'array-data-invalid-item-type', - lexicons: [{ - lexicon: 1, - id: 'test.array.itemtype', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' } - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.array.itemtype', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], collection: 'test.array.itemtype', - record: { tags: ['hello', 42] } + record: { tags: ['hello', 42] }, }, { name: 'array-data-invalid-empty-with-minLength', - lexicons: [{ - lexicon: 1, - id: 'test.array.emptymin', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tags: { - type: 'array', - items: { type: 'string' }, - minLength: 1 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.array.emptymin', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + minLength: 1, + }, + }, + }, + }, + }, + }, + ], collection: 'test.array.emptymin', - record: { tags: [] } + record: { tags: [] }, }, -] +]; diff --git a/test/inputs/data/blob.js b/test/inputs/data/blob.js index 1778de9..a3c01f6 100644 --- a/test/inputs/data/blob.js +++ b/test/inputs/data/blob.js @@ -3,178 +3,190 @@ const validBlobRef = { $type: 'blob', ref: { - $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' + $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy', }, mimeType: 'image/jpeg', - size: 50000 -} + size: 50000, +}; export const blobDataInputs = [ { name: 'blob-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.blob.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob', - accept: ['image/*'], - maxSize: 1000000 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + accept: ['image/*'], + maxSize: 1000000, + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.data', - record: { image: validBlobRef } + record: { image: validBlobRef }, }, { name: 'blob-data-invalid-unaccepted-mime', - lexicons: [{ - lexicon: 1, - id: 'test.blob.mime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob', - accept: ['image/*'] - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.mime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + accept: ['image/*'], + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.mime', record: { image: { $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, mimeType: 'video/mp4', - size: 50000 - } - } + size: 50000, + }, + }, }, { name: 'blob-data-invalid-exceeds-maxSize', - lexicons: [{ - lexicon: 1, - id: 'test.blob.maxsize', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob', - maxSize: 10000 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.maxsize', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + maxSize: 10000, + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.maxsize', - record: { image: validBlobRef } + record: { image: validBlobRef }, }, { name: 'blob-data-invalid-missing-mimeType', - lexicons: [{ - lexicon: 1, - id: 'test.blob.nomime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.nomime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.nomime', record: { image: { $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, - size: 50000 - } - } + size: 50000, + }, + }, }, { name: 'blob-data-invalid-missing-size', - lexicons: [{ - lexicon: 1, - id: 'test.blob.nosize', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.nosize', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.nosize', record: { image: { $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, - mimeType: 'image/jpeg' - } - } + mimeType: 'image/jpeg', + }, + }, }, { name: 'blob-data-invalid-missing-type', - lexicons: [{ - lexicon: 1, - id: 'test.blob.notype', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - image: { - type: 'blob' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.blob.notype', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + image: { + type: 'blob', + }, + }, + }, + }, + }, + }, + ], collection: 'test.blob.notype', record: { image: { ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, mimeType: 'image/jpeg', - size: 50000 - } - } + size: 50000, + }, + }, }, -] +]; diff --git a/test/inputs/data/bytes.js b/test/inputs/data/bytes.js index 579da30..ccc07ad 100644 --- a/test/inputs/data/bytes.js +++ b/test/inputs/data/bytes.js @@ -3,144 +3,156 @@ export const bytesDataInputs = [ { name: 'bytes-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.data', - record: { data: { $bytes: 'MTIz' } } // "123" in base64 + record: { data: { $bytes: 'MTIz' } }, // "123" in base64 }, { name: 'bytes-data-valid-with-constraints', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.constraints', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes', - minLength: 10, - maxLength: 20 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.constraints', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + minLength: 10, + maxLength: 20, + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.constraints', - record: { data: { $bytes: 'YXNkZmFzZGZhc2RmYXNkZg' } } // 16 bytes + record: { data: { $bytes: 'YXNkZmFzZGZhc2RmYXNkZg' } }, // 16 bytes }, { name: 'bytes-data-invalid-plain-string', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.plainstring', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.plainstring', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.plainstring', - record: { data: 'green' } + record: { data: 'green' }, }, { name: 'bytes-data-invalid-empty-object', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.emptyobj', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.emptyobj', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.emptyobj', - record: { data: {} } + record: { data: {} }, }, { name: 'bytes-data-invalid-too-short', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.tooshort', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes', - minLength: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.tooshort', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + minLength: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.tooshort', - record: { data: { $bytes: 'b25l' } } // "one" = 3 bytes + record: { data: { $bytes: 'b25l' } }, // "one" = 3 bytes }, { name: 'bytes-data-invalid-too-long', - lexicons: [{ - lexicon: 1, - id: 'test.bytes.toolong', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - data: { - type: 'bytes', - maxLength: 5 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.bytes.toolong', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { + type: 'bytes', + maxLength: 5, + }, + }, + }, + }, + }, + }, + ], collection: 'test.bytes.toolong', - record: { data: { $bytes: 'YXNkZmFzZGZhc2RmYXNkZg' } } // 16 bytes + record: { data: { $bytes: 'YXNkZmFzZGZhc2RmYXNkZg' } }, // 16 bytes }, -] +]; diff --git a/test/inputs/data/cid-link.js b/test/inputs/data/cid-link.js new file mode 100644 index 0000000..ca9c072 --- /dev/null +++ b/test/inputs/data/cid-link.js @@ -0,0 +1,85 @@ +// Test inputs for cid-link data validation + +const cidLinkLexicon = { + lexicon: 1, + id: 'test.cidlink.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + ref: { type: 'cid-link' }, + }, + }, + }, + }, +}; + +export const cidLinkDataInputs = [ + { + name: 'cid-link-data-valid-basic', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: { $link: 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' }, + }, + }, + { + name: 'cid-link-data-valid-cidv1', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, + }, + }, + { + name: 'cid-link-data-invalid-not-object', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi', + }, + }, + { + name: 'cid-link-data-invalid-missing-link-field', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: { cid: 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' }, + }, + }, + { + name: 'cid-link-data-invalid-bad-cid-format', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: { $link: 'not-a-valid-cid' }, + }, + }, + { + name: 'cid-link-data-invalid-empty-link', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: { $link: '' }, + }, + }, + { + name: 'cid-link-data-invalid-null', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: null, + }, + }, + { + name: 'cid-link-data-invalid-array', + lexicons: [cidLinkLexicon], + collection: 'test.cidlink.data', + record: { + ref: ['bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'], + }, + }, +]; diff --git a/test/inputs/data/index.js b/test/inputs/data/index.js index 8acfb84..42969c4 100644 --- a/test/inputs/data/index.js +++ b/test/inputs/data/index.js @@ -1,7 +1,16 @@ // Export all data test inputs -export * from './string.js' -export * from './integer.js' -export * from './object.js' -export * from './array.js' -export * from './blob.js' -export * from './bytes.js' +export * from './string.js'; +export * from './integer.js'; +export * from './object.js'; +export * from './array.js'; +export * from './blob.js'; +export * from './bytes.js'; +export * from './params.js'; +export * from './query.js'; +export * from './procedure.js'; +export * from './subscription.js'; +export * from './token.js'; +export * from './unknown.js'; +export * from './union.js'; +export * from './cid-link.js'; +export * from './ref.js'; diff --git a/test/inputs/data/integer.js b/test/inputs/data/integer.js index c226caa..f7ce5e7 100644 --- a/test/inputs/data/integer.js +++ b/test/inputs/data/integer.js @@ -3,146 +3,158 @@ export const integerDataInputs = [ { name: 'integer-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.integer.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - count: { - type: 'integer', - minimum: 0, - maximum: 100 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 0, + maximum: 100, + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.data', - record: { count: 42 } + record: { count: 42 }, }, { name: 'integer-data-invalid-below-minimum', - lexicons: [{ - lexicon: 1, - id: 'test.integer.min', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - count: { - type: 'integer', - minimum: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.min', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + count: { + type: 'integer', + minimum: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.min', - record: { count: 5 } + record: { count: 5 }, }, { name: 'integer-data-invalid-above-maximum', - lexicons: [{ - lexicon: 1, - id: 'test.integer.max', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - count: { - type: 'integer', - maximum: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.max', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + count: { + type: 'integer', + maximum: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.max', - record: { count: 15 } + record: { count: 15 }, }, { name: 'integer-data-valid-enum', - lexicons: [{ - lexicon: 1, - id: 'test.integer.enumvalid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - level: { - type: 'integer', - enum: [1, 2, 3] - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.enumvalid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + level: { + type: 'integer', + enum: [1, 2, 3], + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.enumvalid', - record: { level: 2 } + record: { level: 2 }, }, { name: 'integer-data-invalid-enum', - lexicons: [{ - lexicon: 1, - id: 'test.integer.enuminvalid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - level: { - type: 'integer', - enum: [1, 2, 3] - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.enuminvalid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + level: { + type: 'integer', + enum: [1, 2, 3], + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.enuminvalid', - record: { level: 5 } + record: { level: 5 }, }, { name: 'integer-data-invalid-wrong-type', - lexicons: [{ - lexicon: 1, - id: 'test.integer.wrongtype', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - count: { - type: 'integer' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.integer.wrongtype', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + count: { + type: 'integer', + }, + }, + }, + }, + }, + }, + ], collection: 'test.integer.wrongtype', - record: { count: '42' } + record: { count: '42' }, }, -] +]; diff --git a/test/inputs/data/object.js b/test/inputs/data/object.js index 2d8edc1..8fcc25e 100644 --- a/test/inputs/data/object.js +++ b/test/inputs/data/object.js @@ -3,92 +3,100 @@ export const objectDataInputs = [ { name: 'object-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.object.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - title: { type: 'string' }, - count: { type: 'integer' } + lexicons: [ + { + lexicon: 1, + id: 'test.object.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + title: { type: 'string' }, + count: { type: 'integer' }, + }, + required: ['title'], }, - required: ['title'] - } - } - } - }], + }, + }, + }, + ], collection: 'test.object.data', - record: { title: 'Hello', count: 42 } + record: { title: 'Hello', count: 42 }, }, { name: 'object-data-invalid-missing-required', - lexicons: [{ - lexicon: 1, - id: 'test.object.required', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - title: { type: 'string' } + lexicons: [ + { + lexicon: 1, + id: 'test.object.required', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + title: { type: 'string' }, + }, + required: ['title'], }, - required: ['title'] - } - } - } - }], + }, + }, + }, + ], collection: 'test.object.required', - record: { other: 'value' } + record: { other: 'value' }, }, { name: 'object-data-valid-nullable-null', - lexicons: [{ - lexicon: 1, - id: 'test.object.nullable', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - name: { type: 'string' }, - duration: { type: 'integer' } + lexicons: [ + { + lexicon: 1, + id: 'test.object.nullable', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + name: { type: 'string' }, + duration: { type: 'integer' }, + }, + nullable: ['duration'], }, - nullable: ['duration'] - } - } - } - }], + }, + }, + }, + ], collection: 'test.object.nullable', - record: { name: 'test', duration: null } + record: { name: 'test', duration: null }, }, { name: 'object-data-invalid-non-nullable-null', - lexicons: [{ - lexicon: 1, - id: 'test.object.nonnullable', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - name: { type: 'string' }, - count: { type: 'integer' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.object.nonnullable', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + }, + }, + }, + }, + }, + ], collection: 'test.object.nonnullable', - record: { name: 'test', count: null } + record: { name: 'test', count: null }, }, -] +]; diff --git a/test/inputs/data/params.js b/test/inputs/data/params.js new file mode 100644 index 0000000..11c11cb --- /dev/null +++ b/test/inputs/data/params.js @@ -0,0 +1,203 @@ +// Test inputs for params data validation + +const paramsLexicon = { + lexicon: 1, + id: 'test.params.data', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 100 }, + cursor: { type: 'string' }, + enabled: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string', maxLength: 50 }, + }, + }, + required: ['repo'], + }, + }, + }, +}; + +const allTypesLexicon = { + lexicon: 1, + id: 'test.params.alltypes', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + name: { type: 'string', maxLength: 5 }, + count: { type: 'integer', minimum: 1 }, + enabled: { type: 'boolean' }, + metadata: { type: 'unknown' }, + }, + }, + }, + }, +}; + +export const paramsDataInputs = [ + { + name: 'params-data-valid-with-required', + lexicons: [paramsLexicon], + collection: 'test.params.data', + record: { + repo: 'alice.bsky.social', + limit: 50, + }, + }, + { + name: 'params-data-valid-with-optional', + lexicons: [paramsLexicon], + collection: 'test.params.data', + record: { + repo: 'alice.bsky.social', + }, + }, + { + name: 'params-data-valid-all-types', + lexicons: [allTypesLexicon], + collection: 'test.params.alltypes', + record: { + name: 'test', + count: 42, + enabled: true, + metadata: { key: 'value' }, + }, + }, + { + name: 'params-data-valid-with-array', + lexicons: [paramsLexicon], + collection: 'test.params.data', + record: { + repo: 'alice.bsky.social', + tags: ['foo', 'bar'], + }, + }, + { + name: 'params-data-invalid-missing-required', + lexicons: [paramsLexicon], + collection: 'test.params.data', + record: { + limit: 50, + }, + }, + { + name: 'params-data-invalid-wrong-type', + lexicons: [ + { + lexicon: 1, + id: 'test.params.wrongtype', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + limit: { type: 'integer' }, + }, + }, + }, + }, + }, + ], + collection: 'test.params.wrongtype', + record: { + limit: 'not a number', + }, + }, + { + name: 'params-data-invalid-string-too-long', + lexicons: [allTypesLexicon], + collection: 'test.params.alltypes', + record: { + name: 'toolongname', + }, + }, + { + name: 'params-data-invalid-integer-below-minimum', + lexicons: [allTypesLexicon], + collection: 'test.params.alltypes', + record: { + count: 0, + }, + }, + { + name: 'params-data-invalid-array-wrong-item-type', + lexicons: [ + { + lexicon: 1, + id: 'test.params.arraytype', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + ids: { + type: 'array', + items: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.params.arraytype', + record: { + ids: ['one', 'two'], + }, + }, + { + name: 'params-data-valid-empty-schema', + lexicons: [ + { + lexicon: 1, + id: 'test.params.empty', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + }, + }, + }, + }, + ], + collection: 'test.params.empty', + record: {}, + }, + { + name: 'params-data-valid-unknown-parameters-allowed', + lexicons: [ + { + lexicon: 1, + id: 'test.params.extra', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + }, + }, + }, + }, + }, + ], + collection: 'test.params.extra', + record: { + repo: 'alice.bsky.social', + extra: 'allowed', + }, + }, +]; diff --git a/test/inputs/data/procedure.js b/test/inputs/data/procedure.js new file mode 100644 index 0000000..263e4fc --- /dev/null +++ b/test/inputs/data/procedure.js @@ -0,0 +1,134 @@ +// Test inputs for procedure data validation + +export const procedureDataInputs = [ + { + name: 'procedure-data-valid-input-object', + lexicons: [ + { + lexicon: 1, + id: 'test.procedure.input', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['text'], + properties: { + text: { + type: 'string', + maxLength: 300, + }, + langs: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.procedure.input', + record: { + text: 'Hello world', + langs: ['en'], + }, + }, + { + name: 'procedure-data-invalid-missing-required', + lexicons: [ + { + lexicon: 1, + id: 'test.procedure.missingreq', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.procedure.missingreq', + record: { + description: 'No text field', + }, + }, + { + name: 'procedure-data-valid-no-input', + lexicons: [ + { + lexicon: 1, + id: 'test.procedure.noinput', + defs: { + main: { + type: 'procedure', + }, + }, + }, + ], + collection: 'test.procedure.noinput', + record: {}, + }, + { + name: 'procedure-data-invalid-wrong-type', + lexicons: [ + { + lexicon: 1, + id: 'test.procedure.wrongtype', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + count: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.procedure.wrongtype', + record: { + count: 'not-a-number', + }, + }, + { + name: 'procedure-data-valid-minimal', + lexicons: [ + { + lexicon: 1, + id: 'test.procedure.minimal', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: {}, + }, + }, + }, + }, + }, + ], + collection: 'test.procedure.minimal', + record: {}, + }, +]; diff --git a/test/inputs/data/query.js b/test/inputs/data/query.js new file mode 100644 index 0000000..16bedce --- /dev/null +++ b/test/inputs/data/query.js @@ -0,0 +1,185 @@ +// Test inputs for query data validation + +export const queryDataInputs = [ + { + name: 'query-data-valid-parameters', + lexicons: [ + { + lexicon: 1, + id: 'test.query.data', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + }, + cursor: { type: 'string' }, + }, + }, + }, + }, + }, + ], + collection: 'test.query.data', + record: { + limit: 50, + cursor: 'abc123', + }, + }, + { + name: 'query-data-valid-with-required', + lexicons: [ + { + lexicon: 1, + id: 'test.query.required', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + }, + required: ['repo'], + }, + }, + }, + }, + ], + collection: 'test.query.required', + record: { + repo: 'did:plc:abc123', + }, + }, + { + name: 'query-data-invalid-missing-required', + lexicons: [ + { + lexicon: 1, + id: 'test.query.missingreq', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + collection: { type: 'string' }, + }, + required: ['repo'], + }, + }, + }, + }, + ], + collection: 'test.query.missingreq', + record: { + collection: 'app.bsky.feed.post', + }, + }, + { + name: 'query-data-invalid-wrong-type', + lexicons: [ + { + lexicon: 1, + id: 'test.query.wrongtype', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.query.wrongtype', + record: { + limit: 'not-a-number', + }, + }, + { + name: 'query-data-invalid-constraint-violation', + lexicons: [ + { + lexicon: 1, + id: 'test.query.constraint', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + maximum: 100, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.query.constraint', + record: { + limit: 200, + }, + }, + { + name: 'query-data-valid-array-parameter', + lexicons: [ + { + lexicon: 1, + id: 'test.query.array', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + tags: { + type: 'array', + items: { + type: 'string', + maxLength: 50, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.query.array', + record: { + tags: ['tag1', 'tag2'], + }, + }, + { + name: 'query-data-valid-no-parameters', + lexicons: [ + { + lexicon: 1, + id: 'test.query.noparam', + defs: { + main: { + type: 'query', + }, + }, + }, + ], + collection: 'test.query.noparam', + record: {}, + }, +]; diff --git a/test/inputs/data/ref.js b/test/inputs/data/ref.js new file mode 100644 index 0000000..0bb641d --- /dev/null +++ b/test/inputs/data/ref.js @@ -0,0 +1,229 @@ +// Test inputs for ref data validation + +// Lexicon with a string definition +const stringRefLexicon = { + lexicon: 1, + id: 'app.test.stringref', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + content: { type: 'ref', ref: '#post' }, + }, + }, + }, + post: { + type: 'string', + maxLength: 280, + }, + }, +}; + +// Lexicon with an object definition +const objectRefLexicon = { + lexicon: 1, + id: 'app.test.objectref', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + user: { type: 'ref', ref: '#userDef' }, + }, + }, + }, + userDef: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + }, + }, + }, +}; + +// Lexicon with nested reference chain: refA -> refB -> actualString +const nestedRefLexicon = { + lexicon: 1, + id: 'app.test.nested', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { type: 'ref', ref: '#refA' }, + }, + }, + }, + refA: { + type: 'ref', + ref: '#refB', + }, + refB: { + type: 'ref', + ref: '#actualString', + }, + actualString: { + type: 'string', + }, + }, +}; + +// Lexicon with circular reference: refA -> refB -> refA +const circularRefLexicon = { + lexicon: 1, + id: 'app.test.circular', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { type: 'ref', ref: '#refA' }, + }, + }, + }, + refA: { + type: 'ref', + ref: '#refB', + }, + refB: { + type: 'ref', + ref: '#refA', + }, + }, +}; + +// Cross-lexicon reference +const crossRefLexicon1 = { + lexicon: 1, + id: 'app.test.schema', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + user: { type: 'ref', ref: 'app.test.types#user' }, + }, + }, + }, + }, +}; + +const crossRefLexicon2 = { + lexicon: 1, + id: 'app.test.types', + defs: { + user: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, + }, +}; + +// Lexicon with nonexistent ref +const badRefLexicon = { + lexicon: 1, + id: 'app.test.badref', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { type: 'ref', ref: '#nonexistent' }, + }, + }, + }, + }, +}; + +export const refDataInputs = [ + { + name: 'ref-data-valid-to-string', + lexicons: [stringRefLexicon], + collection: 'app.test.stringref', + record: { + content: 'Hello, world!', + }, + }, + { + name: 'ref-data-invalid-string-too-long', + lexicons: [stringRefLexicon], + collection: 'app.test.stringref', + record: { + content: 'a'.repeat(281), + }, + }, + { + name: 'ref-data-valid-to-object', + lexicons: [objectRefLexicon], + collection: 'app.test.objectref', + record: { + user: { name: 'Alice', age: 30 }, + }, + }, + { + name: 'ref-data-invalid-object-missing-required', + lexicons: [objectRefLexicon], + collection: 'app.test.objectref', + record: { + user: { age: 30 }, + }, + }, + { + name: 'ref-data-valid-nested-chain', + lexicons: [nestedRefLexicon], + collection: 'app.test.nested', + record: { + data: 'Hello!', + }, + }, + { + name: 'ref-data-invalid-circular-reference', + lexicons: [circularRefLexicon], + collection: 'app.test.circular', + record: { + data: 'test', + }, + }, + { + name: 'ref-data-valid-cross-lexicon', + lexicons: [crossRefLexicon1, crossRefLexicon2], + collection: 'app.test.schema', + record: { + user: { id: 'user123' }, + }, + }, + { + name: 'ref-data-invalid-cross-lexicon-missing-required', + lexicons: [crossRefLexicon1, crossRefLexicon2], + collection: 'app.test.schema', + record: { + user: { name: 'Alice' }, + }, + }, + { + name: 'ref-data-invalid-not-found', + lexicons: [badRefLexicon], + collection: 'app.test.badref', + record: { + data: 'test', + }, + }, +]; diff --git a/test/inputs/data/string.js b/test/inputs/data/string.js index 125d721..3f6c708 100644 --- a/test/inputs/data/string.js +++ b/test/inputs/data/string.js @@ -1,197 +1,212 @@ // Test inputs for string data validation -// Extracted from honk's string_validator_test.gleam export const stringDataInputs = [ { name: 'string-data-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.string.data', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string', - minLength: 1, - maxLength: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 1, + maxLength: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.data', - record: { text: 'hello' } + record: { text: 'hello' }, }, { name: 'string-data-invalid-too-short', - lexicons: [{ - lexicon: 1, - id: 'test.string.short', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string', - minLength: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.short', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + minLength: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.short', - record: { text: 'short' } + record: { text: 'short' }, }, { name: 'string-data-invalid-too-long', - lexicons: [{ - lexicon: 1, - id: 'test.string.long', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string', - maxLength: 5 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.long', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + maxLength: 5, + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.long', - record: { text: 'this is too long' } + record: { text: 'this is too long' }, }, { name: 'string-data-valid-enum', - lexicons: [{ - lexicon: 1, - id: 'test.string.enumvalid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - color: { - type: 'string', - enum: ['red', 'blue'] - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.enumvalid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + color: { + type: 'string', + enum: ['red', 'blue'], + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.enumvalid', - record: { color: 'red' } + record: { color: 'red' }, }, { name: 'string-data-invalid-enum', - lexicons: [{ - lexicon: 1, - id: 'test.string.enuminvalid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - color: { - type: 'string', - enum: ['red', 'blue'] - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.enuminvalid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + color: { + type: 'string', + enum: ['red', 'blue'], + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.enuminvalid', - record: { color: 'green' } + record: { color: 'green' }, }, { name: 'string-data-invalid-wrong-type', - lexicons: [{ - lexicon: 1, - id: 'test.string.wrongtype', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string' - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.wrongtype', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.wrongtype', - record: { text: 42 } + record: { text: 42 }, }, { name: 'string-data-invalid-graphemes-too-short', - lexicons: [{ - lexicon: 1, - id: 'test.string.graphshort', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string', - minGraphemes: 10 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.graphshort', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + minGraphemes: 10, + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.graphshort', - record: { text: 'hi' } + record: { text: 'hi' }, }, { name: 'string-data-invalid-graphemes-too-long', - lexicons: [{ - lexicon: 1, - id: 'test.string.graphlong', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { - type: 'string', - maxGraphemes: 3 - } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.string.graphlong', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + text: { + type: 'string', + maxGraphemes: 3, + }, + }, + }, + }, + }, + }, + ], collection: 'test.string.graphlong', - record: { text: 'hello' } + record: { text: 'hello' }, }, -] +]; diff --git a/test/inputs/data/subscription.js b/test/inputs/data/subscription.js new file mode 100644 index 0000000..c71e25d --- /dev/null +++ b/test/inputs/data/subscription.js @@ -0,0 +1,121 @@ +// Test inputs for subscription data validation + +export const subscriptionDataInputs = [ + { + name: 'subscription-data-valid-parameters', + lexicons: [ + { + lexicon: 1, + id: 'test.subscription.data', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + cursor: { type: 'integer' }, + }, + }, + }, + }, + }, + ], + collection: 'test.subscription.data', + record: { + cursor: 12345, + }, + }, + { + name: 'subscription-data-invalid-missing-required', + lexicons: [ + { + lexicon: 1, + id: 'test.subscription.missingreq', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + collection: { type: 'string' }, + }, + required: ['collection'], + }, + }, + }, + }, + ], + collection: 'test.subscription.missingreq', + record: {}, + }, + { + name: 'subscription-data-valid-no-parameters', + lexicons: [ + { + lexicon: 1, + id: 'test.subscription.noparam', + defs: { + main: { + type: 'subscription', + }, + }, + }, + ], + collection: 'test.subscription.noparam', + record: {}, + }, + { + name: 'subscription-data-invalid-constraint-violation', + lexicons: [ + { + lexicon: 1, + id: 'test.subscription.constraint', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + maximum: 100, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.subscription.constraint', + record: { + limit: 200, + }, + }, + { + name: 'subscription-data-valid-array-parameter', + lexicons: [ + { + lexicon: 1, + id: 'test.subscription.array', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + repos: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.subscription.array', + record: { + repos: ['did:plc:abc', 'did:plc:xyz'], + }, + }, +]; diff --git a/test/inputs/data/token.js b/test/inputs/data/token.js new file mode 100644 index 0000000..c4808cb --- /dev/null +++ b/test/inputs/data/token.js @@ -0,0 +1,85 @@ +// Test inputs for token data validation + +const tokenLexicon = { + lexicon: 1, + id: 'test.token.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + kind: { type: 'token' }, + }, + }, + }, + }, +}; + +export const tokenDataInputs = [ + { + name: 'token-data-valid-simple-string', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: 'example.lexicon.record#demoToken', + }, + }, + { + name: 'token-data-valid-local-ref', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: '#myToken', + }, + }, + { + name: 'token-data-invalid-empty-string', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: '', + }, + }, + { + name: 'token-data-invalid-integer', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: 123, + }, + }, + { + name: 'token-data-invalid-boolean', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: true, + }, + }, + { + name: 'token-data-invalid-object', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: { token: 'value' }, + }, + }, + { + name: 'token-data-invalid-array', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: ['token1', 'token2'], + }, + }, + { + name: 'token-data-invalid-null', + lexicons: [tokenLexicon], + collection: 'test.token.data', + record: { + kind: null, + }, + }, +]; diff --git a/test/inputs/data/union.js b/test/inputs/data/union.js new file mode 100644 index 0000000..4b81e02 --- /dev/null +++ b/test/inputs/data/union.js @@ -0,0 +1,174 @@ +// Test inputs for union data validation + +const unionLexicon = { + lexicon: 1, + id: 'test.union.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + item: { + type: 'union', + refs: ['#post', '#reply'], + }, + }, + }, + }, + post: { + type: 'object', + properties: { + text: { type: 'string' }, + }, + }, + reply: { + type: 'object', + properties: { + parent: { type: 'string' }, + text: { type: 'string' }, + }, + }, + }, +}; + +const closedUnionLexicon = { + lexicon: 1, + id: 'test.union.closed', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + item: { + type: 'union', + refs: ['#typeA'], + closed: true, + }, + }, + }, + }, + typeA: { + type: 'object', + properties: { + data: { type: 'string' }, + }, + }, + }, +}; + +export const unionDataInputs = [ + { + name: 'union-data-valid-matching-type', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: { + $type: 'test.union.data#post', + text: 'Hello world', + }, + }, + }, + { + name: 'union-data-valid-local-ref-match', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: { + $type: 'post', + text: 'Hello', + }, + }, + }, + { + name: 'union-data-invalid-missing-type', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: { + text: 'Hello', + }, + }, + }, + { + name: 'union-data-invalid-non-object', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: 'not an object', + }, + }, + { + name: 'union-data-invalid-type-not-in-refs', + lexicons: [closedUnionLexicon], + collection: 'test.union.closed', + record: { + item: { + $type: 'test.union.closed#typeB', + data: 'some data', + }, + }, + }, + { + name: 'union-data-invalid-integer', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: 123, + }, + }, + { + name: 'union-data-invalid-null', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: null, + }, + }, + { + name: 'union-data-invalid-array', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: ['item'], + }, + }, + { + name: 'union-data-invalid-boolean', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: true, + }, + }, +]; + +// Tests for union data validation against referenced schema +export const unionRefDataInputs = [ + { + name: 'union-ref-data-invalid-type-mismatch', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: { + $type: 'test.union.data#post', + text: 12345, // should be string, not integer + }, + }, + }, + { + name: 'union-ref-data-valid-correct-types', + lexicons: [unionLexicon], + collection: 'test.union.data', + record: { + item: { + $type: 'test.union.data#reply', + parent: 'at://did:plc:abc/test.union.data/123', + text: 'This is a reply', + }, + }, + }, +]; diff --git a/test/inputs/data/unknown.js b/test/inputs/data/unknown.js new file mode 100644 index 0000000..f45f4cd --- /dev/null +++ b/test/inputs/data/unknown.js @@ -0,0 +1,109 @@ +// Test inputs for unknown data validation + +const unknownLexicon = { + lexicon: 1, + id: 'test.unknown.data', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + data: { type: 'unknown' }, + }, + }, + }, + }, +}; + +export const unknownDataInputs = [ + { + name: 'unknown-data-valid-simple-object', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: { a: 'alphabet', b: 3 }, + }, + }, + { + name: 'unknown-data-valid-with-type-field', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: { $type: 'example.lexicon.record#demoObject', a: 1, b: 2 }, + }, + }, + { + name: 'unknown-data-valid-nested-objects', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: { outer: { inner: 'nested' }, count: 42 }, + }, + }, + { + name: 'unknown-data-valid-empty-object', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: {}, + }, + }, + { + name: 'unknown-data-invalid-boolean', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: false, + }, + }, + { + name: 'unknown-data-invalid-string', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: 'not an object', + }, + }, + { + name: 'unknown-data-invalid-integer', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: 123, + }, + }, + { + name: 'unknown-data-invalid-array', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: [1, 2, 3], + }, + }, + { + name: 'unknown-data-invalid-null', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: null, + }, + }, + { + name: 'unknown-data-invalid-bytes-object', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: { $bytes: 'SGVsbG8gd29ybGQ=' }, + }, + }, + { + name: 'unknown-data-invalid-blob-object', + lexicons: [unknownLexicon], + collection: 'test.unknown.data', + record: { + data: { $type: 'blob', mimeType: 'text/plain', size: 12345 }, + }, + }, +]; diff --git a/test/inputs/e2e/bsky-post.js b/test/inputs/e2e/bsky-post.js index 049796b..f07d91d 100644 --- a/test/inputs/e2e/bsky-post.js +++ b/test/inputs/e2e/bsky-post.js @@ -14,40 +14,40 @@ const postLexicon = { text: { type: 'string', maxLength: 3000, - maxGraphemes: 300 + maxGraphemes: 300, }, createdAt: { type: 'string', - format: 'datetime' + format: 'datetime', }, reply: { type: 'ref', - ref: '#replyRef' + ref: '#replyRef', }, embed: { type: 'union', - refs: ['#images', '#external'] + refs: ['#images', '#external'], }, langs: { type: 'array', items: { type: 'string', format: 'language' }, - maxLength: 3 + maxLength: 3, }, tags: { type: 'array', items: { type: 'string', maxLength: 640, maxGraphemes: 64 }, - maxLength: 8 - } - } - } + maxLength: 8, + }, + }, + }, }, replyRef: { type: 'object', required: ['root', 'parent'], properties: { root: { type: 'ref', ref: 'com.atproto.repo.strongRef' }, - parent: { type: 'ref', ref: 'com.atproto.repo.strongRef' } - } + parent: { type: 'ref', ref: 'com.atproto.repo.strongRef' }, + }, }, images: { type: 'object', @@ -56,24 +56,24 @@ const postLexicon = { images: { type: 'array', items: { type: 'ref', ref: '#imageEmbed' }, - maxLength: 4 - } - } + maxLength: 4, + }, + }, }, imageEmbed: { type: 'object', required: ['image', 'alt'], properties: { image: { type: 'blob', accept: ['image/*'], maxSize: 1000000 }, - alt: { type: 'string', maxLength: 10000, maxGraphemes: 1000 } - } + alt: { type: 'string', maxLength: 10000, maxGraphemes: 1000 }, + }, }, external: { type: 'object', required: ['external'], properties: { - external: { type: 'ref', ref: '#externalEmbed' } - } + external: { type: 'ref', ref: '#externalEmbed' }, + }, }, externalEmbed: { type: 'object', @@ -82,11 +82,11 @@ const postLexicon = { uri: { type: 'string', format: 'uri' }, title: { type: 'string' }, description: { type: 'string' }, - thumb: { type: 'blob', accept: ['image/*'], maxSize: 1000000 } - } - } - } -} + thumb: { type: 'blob', accept: ['image/*'], maxSize: 1000000 }, + }, + }, + }, +}; const strongRefLexicon = { lexicon: 1, @@ -97,11 +97,11 @@ const strongRefLexicon = { required: ['uri', 'cid'], properties: { uri: { type: 'string', format: 'at-uri' }, - cid: { type: 'string', format: 'cid' } - } - } - } -} + cid: { type: 'string', format: 'cid' }, + }, + }, + }, +}; export const bskyPostInputs = [ { @@ -110,8 +110,8 @@ export const bskyPostInputs = [ collection: 'app.bsky.feed.post', record: { text: 'Hello world!', - createdAt: '2024-01-15T12:00:00.000Z' - } + createdAt: '2024-01-15T12:00:00.000Z', + }, }, { name: 'e2e-post-valid-with-langs', @@ -120,8 +120,8 @@ export const bskyPostInputs = [ record: { text: 'Hello world!', createdAt: '2024-01-15T12:00:00.000Z', - langs: ['en', 'es'] - } + langs: ['en', 'es'], + }, }, { name: 'e2e-post-valid-with-tags', @@ -130,24 +130,24 @@ export const bskyPostInputs = [ record: { text: 'Check out this #atproto post!', createdAt: '2024-01-15T12:00:00.000Z', - tags: ['atproto', 'bluesky'] - } + tags: ['atproto', 'bluesky'], + }, }, { name: 'e2e-post-invalid-missing-text', lexicons: [postLexicon, strongRefLexicon], collection: 'app.bsky.feed.post', record: { - createdAt: '2024-01-15T12:00:00.000Z' - } + createdAt: '2024-01-15T12:00:00.000Z', + }, }, { name: 'e2e-post-invalid-missing-createdAt', lexicons: [postLexicon, strongRefLexicon], collection: 'app.bsky.feed.post', record: { - text: 'Hello world!' - } + text: 'Hello world!', + }, }, { name: 'e2e-post-invalid-text-too-long', @@ -155,8 +155,8 @@ export const bskyPostInputs = [ collection: 'app.bsky.feed.post', record: { text: 'a'.repeat(3001), - createdAt: '2024-01-15T12:00:00.000Z' - } + createdAt: '2024-01-15T12:00:00.000Z', + }, }, { name: 'e2e-post-invalid-bad-datetime', @@ -164,8 +164,8 @@ export const bskyPostInputs = [ collection: 'app.bsky.feed.post', record: { text: 'Hello world!', - createdAt: 'not-a-datetime' - } + createdAt: 'not-a-datetime', + }, }, { name: 'e2e-post-invalid-too-many-langs', @@ -174,7 +174,7 @@ export const bskyPostInputs = [ record: { text: 'Hello world!', createdAt: '2024-01-15T12:00:00.000Z', - langs: ['en', 'es', 'fr', 'de'] - } + langs: ['en', 'es', 'fr', 'de'], + }, }, -] +]; diff --git a/test/inputs/e2e/bsky-profile.js b/test/inputs/e2e/bsky-profile.js index 8759940..01f78a1 100644 --- a/test/inputs/e2e/bsky-profile.js +++ b/test/inputs/e2e/bsky-profile.js @@ -13,36 +13,36 @@ const profileLexicon = { displayName: { type: 'string', maxGraphemes: 64, - maxLength: 640 + maxLength: 640, }, description: { type: 'string', maxGraphemes: 256, - maxLength: 2560 + maxLength: 2560, }, avatar: { type: 'blob', accept: ['image/png', 'image/jpeg'], - maxSize: 1000000 + maxSize: 1000000, }, banner: { type: 'blob', accept: ['image/png', 'image/jpeg'], - maxSize: 1000000 + maxSize: 1000000, }, labels: { type: 'union', - refs: ['com.atproto.label.defs#selfLabels'] + refs: ['com.atproto.label.defs#selfLabels'], }, createdAt: { type: 'string', - format: 'datetime' - } - } - } - } - } -} + format: 'datetime', + }, + }, + }, + }, + }, +}; const labelDefsLexicon = { lexicon: 1, @@ -55,34 +55,34 @@ const labelDefsLexicon = { values: { type: 'array', items: { type: 'ref', ref: '#selfLabel' }, - maxLength: 10 - } - } + maxLength: 10, + }, + }, }, selfLabel: { type: 'object', required: ['val'], properties: { - val: { type: 'string', maxLength: 128 } - } - } - } -} + val: { type: 'string', maxLength: 128 }, + }, + }, + }, +}; export const bskyProfileInputs = [ { name: 'e2e-profile-valid-minimal', lexicons: [profileLexicon, labelDefsLexicon], collection: 'app.bsky.actor.profile', - record: {} + record: {}, }, { name: 'e2e-profile-valid-with-display-name', lexicons: [profileLexicon, labelDefsLexicon], collection: 'app.bsky.actor.profile', record: { - displayName: 'Alice' - } + displayName: 'Alice', + }, }, { name: 'e2e-profile-valid-with-description', @@ -90,8 +90,8 @@ export const bskyProfileInputs = [ collection: 'app.bsky.actor.profile', record: { displayName: 'Alice', - description: 'Software developer interested in decentralized social networks.' - } + description: 'Software developer interested in decentralized social networks.', + }, }, { name: 'e2e-profile-valid-with-avatar', @@ -103,9 +103,9 @@ export const bskyProfileInputs = [ $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, mimeType: 'image/jpeg', - size: 50000 - } - } + size: 50000, + }, + }, }, { name: 'e2e-profile-invalid-avatar-wrong-mime', @@ -117,16 +117,16 @@ export const bskyProfileInputs = [ $type: 'blob', ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, mimeType: 'image/gif', - size: 50000 - } - } + size: 50000, + }, + }, }, { name: 'e2e-profile-invalid-display-name-too-long', lexicons: [profileLexicon, labelDefsLexicon], collection: 'app.bsky.actor.profile', record: { - displayName: 'a'.repeat(641) - } + displayName: 'a'.repeat(641), + }, }, -] +]; diff --git a/test/inputs/e2e/index.js b/test/inputs/e2e/index.js index 5552f1e..7c9e8ba 100644 --- a/test/inputs/e2e/index.js +++ b/test/inputs/e2e/index.js @@ -1,3 +1,4 @@ // Export all end-to-end test inputs -export * from './bsky-post.js' -export * from './bsky-profile.js' +export * from './bsky-post.js'; +export * from './bsky-profile.js'; +export * from './integration.js'; diff --git a/test/inputs/e2e/integration.js b/test/inputs/e2e/integration.js new file mode 100644 index 0000000..e64f8d9 --- /dev/null +++ b/test/inputs/e2e/integration.js @@ -0,0 +1,180 @@ +// Integration test inputs + +export const integrationInputs = [ + { + name: 'integration-valid-complex-record', + lexicons: [ + { + lexicon: 1, + id: 'test.integration.complex', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + required: ['title', 'tags'], + properties: { + title: { + type: 'string', + maxLength: 100, + }, + description: { type: 'string' }, + tags: { + type: 'array', + items: { type: 'string' }, + maxLength: 10, + }, + metadata: { + type: 'object', + properties: { + views: { type: 'integer' }, + published: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.integration.complex', + record: { + title: 'My Post', + tags: ['tech', 'gleam'], + }, + }, + { + name: 'integration-invalid-missing-required', + lexicons: [ + { + lexicon: 1, + id: 'test.integration.missingreq', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + required: ['title'], + properties: { + title: { type: 'string' }, + }, + }, + }, + }, + }, + ], + collection: 'test.integration.missingreq', + record: { + description: 'No title', + }, + }, + { + name: 'integration-valid-deeply-nested', + lexicons: [ + { + lexicon: 1, + id: 'test.integration.nested', + defs: { + main: { + type: 'record', + key: 'any', + record: { + type: 'object', + properties: { + level1: { + type: 'object', + properties: { + level2: { + type: 'object', + properties: { + level3: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.integration.nested', + record: { + level1: { + level2: { + level3: 'deep value', + }, + }, + }, + }, + { + name: 'integration-valid-array-of-arrays', + lexicons: [ + { + lexicon: 1, + id: 'test.integration.matrix', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + matrix: { + type: 'array', + items: { + type: 'array', + items: { type: 'integer' }, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.integration.matrix', + record: { + matrix: [ + [1, 2], + [3, 4], + ], + }, + }, + { + name: 'integration-invalid-nested-missing-required', + lexicons: [ + { + lexicon: 1, + id: 'test.integration.nestedreq', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + title: { type: 'string' }, + metadata: { + type: 'object', + required: ['author'], + properties: { + author: { type: 'string' }, + tags: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + ], + collection: 'test.integration.nestedreq', + record: { + title: 'My Post', + metadata: { tags: 'tech' }, + }, + }, +]; diff --git a/test/inputs/format/at-uri.js b/test/inputs/format/at-uri.js index 2afcf1d..dec3b1b 100644 --- a/test/inputs/format/at-uri.js +++ b/test/inputs/format/at-uri.js @@ -3,128 +3,140 @@ export const atUriFormatInputs = [ { name: 'at-uri-valid-did-only', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur' } + record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur' }, }, { name: 'at-uri-valid-with-collection', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post' } + record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post' }, }, { name: 'at-uri-valid-with-rkey', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jui7kd54zh2y' } + record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jui7kd54zh2y' }, }, { name: 'at-uri-valid-with-handle', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: 'at://user.bsky.social/app.bsky.feed.post' } + record: { uri: 'at://user.bsky.social/app.bsky.feed.post' }, }, { name: 'at-uri-invalid-collection', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/invalid' } + record: { uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/invalid' }, }, { name: 'at-uri-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.aturi', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - uri: { type: 'string', format: 'at-uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.aturi', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + uri: { type: 'string', format: 'at-uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.aturi', - record: { uri: '' } + record: { uri: '' }, }, -] +]; diff --git a/test/inputs/format/cid.js b/test/inputs/format/cid.js index f85df81..c19e4f6 100644 --- a/test/inputs/format/cid.js +++ b/test/inputs/format/cid.js @@ -3,107 +3,117 @@ export const cidFormatInputs = [ { name: 'cid-valid-v1-base32', - lexicons: [{ - lexicon: 1, - id: 'test.format.cid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - cid: { type: 'string', format: 'cid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.cid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + cid: { type: 'string', format: 'cid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.cid', - record: { cid: 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' } + record: { cid: 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi' }, }, { name: 'cid-valid-v1-raw', - lexicons: [{ - lexicon: 1, - id: 'test.format.cid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - cid: { type: 'string', format: 'cid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.cid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + cid: { type: 'string', format: 'cid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.cid', - record: { cid: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' } + record: { cid: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, }, { name: 'cid-invalid-qmb-prefix', - lexicons: [{ - lexicon: 1, - id: 'test.format.cid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - cid: { type: 'string', format: 'cid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.cid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + cid: { type: 'string', format: 'cid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.cid', - record: { cid: 'QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR' } + record: { cid: 'QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR' }, }, { name: 'cid-invalid-too-short', - lexicons: [{ - lexicon: 1, - id: 'test.format.cid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - cid: { type: 'string', format: 'cid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.cid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + cid: { type: 'string', format: 'cid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.cid', - record: { cid: 'abc1234' } + record: { cid: 'abc1234' }, }, { name: 'cid-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.cid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - cid: { type: 'string', format: 'cid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.cid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + cid: { type: 'string', format: 'cid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.cid', - record: { cid: '' } + record: { cid: '' }, }, -] +]; diff --git a/test/inputs/format/datetime.js b/test/inputs/format/datetime.js index a04722f..97c02e6 100644 --- a/test/inputs/format/datetime.js +++ b/test/inputs/format/datetime.js @@ -3,128 +3,140 @@ export const datetimeFormatInputs = [ { name: 'datetime-valid-utc', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '2024-01-01T12:00:00Z' } + record: { createdAt: '2024-01-01T12:00:00Z' }, }, { name: 'datetime-valid-with-offset', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '2024-01-01T12:00:00+00:00' } + record: { createdAt: '2024-01-01T12:00:00+00:00' }, }, { name: 'datetime-valid-with-milliseconds', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '2024-01-01T12:00:00.123Z' } + record: { createdAt: '2024-01-01T12:00:00.123Z' }, }, { name: 'datetime-invalid-negative-zero-offset', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '2024-01-01T12:00:00-00:00' } + record: { createdAt: '2024-01-01T12:00:00-00:00' }, }, { name: 'datetime-invalid-feb-30', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '2024-02-30T12:00:00Z' } + record: { createdAt: '2024-02-30T12:00:00Z' }, }, { name: 'datetime-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.datetime', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - createdAt: { type: 'string', format: 'datetime' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.datetime', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + createdAt: { type: 'string', format: 'datetime' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.datetime', - record: { createdAt: '' } + record: { createdAt: '' }, }, -] +]; diff --git a/test/inputs/format/did.js b/test/inputs/format/did.js index a502ae1..3dbb821 100644 --- a/test/inputs/format/did.js +++ b/test/inputs/format/did.js @@ -3,86 +3,94 @@ export const didFormatInputs = [ { name: 'did-valid-plc', - lexicons: [{ - lexicon: 1, - id: 'test.format.did', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - did: { type: 'string', format: 'did' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.did', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + did: { type: 'string', format: 'did' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.did', - record: { did: 'did:plc:z72i7hdynmk6r22z27h6tvur' } + record: { did: 'did:plc:z72i7hdynmk6r22z27h6tvur' }, }, { name: 'did-valid-web', - lexicons: [{ - lexicon: 1, - id: 'test.format.did', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - did: { type: 'string', format: 'did' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.did', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + did: { type: 'string', format: 'did' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.did', - record: { did: 'did:web:example.com' } + record: { did: 'did:web:example.com' }, }, { name: 'did-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.did', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - did: { type: 'string', format: 'did' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.did', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + did: { type: 'string', format: 'did' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.did', - record: { did: '' } + record: { did: '' }, }, { name: 'did-invalid-ending-percent', - lexicons: [{ - lexicon: 1, - id: 'test.format.did', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - did: { type: 'string', format: 'did' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.did', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + did: { type: 'string', format: 'did' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.did', - record: { did: 'did:example:foo%' } + record: { did: 'did:example:foo%' }, }, -] +]; diff --git a/test/inputs/format/handle.js b/test/inputs/format/handle.js index 77abb18..6272ab1 100644 --- a/test/inputs/format/handle.js +++ b/test/inputs/format/handle.js @@ -3,107 +3,117 @@ export const handleFormatInputs = [ { name: 'handle-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.format.handle', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - handle: { type: 'string', format: 'handle' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.handle', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + handle: { type: 'string', format: 'handle' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.handle', - record: { handle: 'user.bsky.social' } + record: { handle: 'user.bsky.social' }, }, { name: 'handle-valid-subdomain', - lexicons: [{ - lexicon: 1, - id: 'test.format.handle', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - handle: { type: 'string', format: 'handle' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.handle', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + handle: { type: 'string', format: 'handle' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.handle', - record: { handle: 'alice.example.com' } + record: { handle: 'alice.example.com' }, }, { name: 'handle-invalid-local-tld', - lexicons: [{ - lexicon: 1, - id: 'test.format.handle', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - handle: { type: 'string', format: 'handle' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.handle', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + handle: { type: 'string', format: 'handle' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.handle', - record: { handle: 'user.local' } + record: { handle: 'user.local' }, }, { name: 'handle-invalid-localhost-tld', - lexicons: [{ - lexicon: 1, - id: 'test.format.handle', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - handle: { type: 'string', format: 'handle' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.handle', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + handle: { type: 'string', format: 'handle' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.handle', - record: { handle: 'app.localhost' } + record: { handle: 'app.localhost' }, }, { name: 'handle-invalid-no-dot', - lexicons: [{ - lexicon: 1, - id: 'test.format.handle', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - handle: { type: 'string', format: 'handle' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.handle', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + handle: { type: 'string', format: 'handle' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.handle', - record: { handle: 'nodot' } + record: { handle: 'nodot' }, }, -] +]; diff --git a/test/inputs/format/index.js b/test/inputs/format/index.js index 071b71f..77d5a0c 100644 --- a/test/inputs/format/index.js +++ b/test/inputs/format/index.js @@ -1,11 +1,11 @@ // Export all format test inputs -export * from './datetime.js' -export * from './handle.js' -export * from './did.js' -export * from './uri.js' -export * from './at-uri.js' -export * from './cid.js' -export * from './tid.js' -export * from './nsid.js' -export * from './language.js' -export * from './record-key.js' +export * from './datetime.js'; +export * from './handle.js'; +export * from './did.js'; +export * from './uri.js'; +export * from './at-uri.js'; +export * from './cid.js'; +export * from './tid.js'; +export * from './nsid.js'; +export * from './language.js'; +export * from './record-key.js'; diff --git a/test/inputs/format/language.js b/test/inputs/format/language.js index 2ab2e2e..ba92385 100644 --- a/test/inputs/format/language.js +++ b/test/inputs/format/language.js @@ -3,86 +3,94 @@ export const languageFormatInputs = [ { name: 'language-valid-simple', - lexicons: [{ - lexicon: 1, - id: 'test.format.language', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - lang: { type: 'string', format: 'language' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.language', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + lang: { type: 'string', format: 'language' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.language', - record: { lang: 'en' } + record: { lang: 'en' }, }, { name: 'language-valid-with-region', - lexicons: [{ - lexicon: 1, - id: 'test.format.language', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - lang: { type: 'string', format: 'language' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.language', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + lang: { type: 'string', format: 'language' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.language', - record: { lang: 'en-US' } + record: { lang: 'en-US' }, }, { name: 'language-valid-with-script', - lexicons: [{ - lexicon: 1, - id: 'test.format.language', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - lang: { type: 'string', format: 'language' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.language', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + lang: { type: 'string', format: 'language' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.language', - record: { lang: 'zh-Hans-CN' } + record: { lang: 'zh-Hans-CN' }, }, { name: 'language-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.language', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - lang: { type: 'string', format: 'language' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.language', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + lang: { type: 'string', format: 'language' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.language', - record: { lang: '' } + record: { lang: '' }, }, -] +]; diff --git a/test/inputs/format/nsid.js b/test/inputs/format/nsid.js index 7bbfcc1..8bf1be2 100644 --- a/test/inputs/format/nsid.js +++ b/test/inputs/format/nsid.js @@ -3,86 +3,94 @@ export const nsidFormatInputs = [ { name: 'nsid-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.format.nsid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - nsid: { type: 'string', format: 'nsid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.nsid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + nsid: { type: 'string', format: 'nsid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.nsid', - record: { nsid: 'app.bsky.feed.post' } + record: { nsid: 'app.bsky.feed.post' }, }, { name: 'nsid-valid-three-segments', - lexicons: [{ - lexicon: 1, - id: 'test.format.nsid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - nsid: { type: 'string', format: 'nsid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.nsid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + nsid: { type: 'string', format: 'nsid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.nsid', - record: { nsid: 'com.example.record' } + record: { nsid: 'com.example.record' }, }, { name: 'nsid-invalid-two-segments', - lexicons: [{ - lexicon: 1, - id: 'test.format.nsid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - nsid: { type: 'string', format: 'nsid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.nsid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + nsid: { type: 'string', format: 'nsid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.nsid', - record: { nsid: 'example.record' } + record: { nsid: 'example.record' }, }, { name: 'nsid-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.nsid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - nsid: { type: 'string', format: 'nsid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.nsid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + nsid: { type: 'string', format: 'nsid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.nsid', - record: { nsid: '' } + record: { nsid: '' }, }, -] +]; diff --git a/test/inputs/format/record-key.js b/test/inputs/format/record-key.js index 86bf2bb..a5c6c0f 100644 --- a/test/inputs/format/record-key.js +++ b/test/inputs/format/record-key.js @@ -3,128 +3,140 @@ export const recordKeyFormatInputs = [ { name: 'record-key-valid-tid', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: '3jui7kd54zh2y' } + record: { rkey: '3jui7kd54zh2y' }, }, { name: 'record-key-valid-custom', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: 'my-custom-key' } + record: { rkey: 'my-custom-key' }, }, { name: 'record-key-valid-with-underscores', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: 'key_with_underscores' } + record: { rkey: 'key_with_underscores' }, }, { name: 'record-key-invalid-dot', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: '.' } + record: { rkey: '.' }, }, { name: 'record-key-invalid-dotdot', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: '..' } + record: { rkey: '..' }, }, { name: 'record-key-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.recordkey', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - rkey: { type: 'string', format: 'record-key' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.recordkey', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + rkey: { type: 'string', format: 'record-key' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.recordkey', - record: { rkey: '' } + record: { rkey: '' }, }, -] +]; diff --git a/test/inputs/format/tid.js b/test/inputs/format/tid.js index bccd6e7..286e93a 100644 --- a/test/inputs/format/tid.js +++ b/test/inputs/format/tid.js @@ -3,107 +3,117 @@ export const tidFormatInputs = [ { name: 'tid-valid-basic', - lexicons: [{ - lexicon: 1, - id: 'test.format.tid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tid: { type: 'string', format: 'tid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.tid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tid: { type: 'string', format: 'tid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.tid', - record: { tid: '3jui7kd54zh2y' } + record: { tid: '3jui7kd54zh2y' }, }, { name: 'tid-valid-boundary', - lexicons: [{ - lexicon: 1, - id: 'test.format.tid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tid: { type: 'string', format: 'tid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.tid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tid: { type: 'string', format: 'tid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.tid', - record: { tid: '2zzzzzzzzzzzy' } + record: { tid: '2zzzzzzzzzzzy' }, }, { name: 'tid-invalid-first-char', - lexicons: [{ - lexicon: 1, - id: 'test.format.tid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tid: { type: 'string', format: 'tid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.tid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tid: { type: 'string', format: 'tid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.tid', - record: { tid: 'kzzzzzzzzzzzz' } + record: { tid: 'kzzzzzzzzzzzz' }, }, { name: 'tid-invalid-wrong-length-short', - lexicons: [{ - lexicon: 1, - id: 'test.format.tid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tid: { type: 'string', format: 'tid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.tid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tid: { type: 'string', format: 'tid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.tid', - record: { tid: '3jui7kd54zh2' } + record: { tid: '3jui7kd54zh2' }, }, { name: 'tid-invalid-wrong-length-long', - lexicons: [{ - lexicon: 1, - id: 'test.format.tid', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - tid: { type: 'string', format: 'tid' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.tid', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + tid: { type: 'string', format: 'tid' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.tid', - record: { tid: '3jui7kd54zh2yy' } + record: { tid: '3jui7kd54zh2yy' }, }, -] +]; diff --git a/test/inputs/format/uri.js b/test/inputs/format/uri.js index e01389d..807df2e 100644 --- a/test/inputs/format/uri.js +++ b/test/inputs/format/uri.js @@ -3,86 +3,94 @@ export const uriFormatInputs = [ { name: 'uri-valid-https', - lexicons: [{ - lexicon: 1, - id: 'test.format.uri', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - url: { type: 'string', format: 'uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.uri', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.uri', - record: { url: 'https://example.com' } + record: { url: 'https://example.com' }, }, { name: 'uri-valid-with-path', - lexicons: [{ - lexicon: 1, - id: 'test.format.uri', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - url: { type: 'string', format: 'uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.uri', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.uri', - record: { url: 'http://example.com/path' } + record: { url: 'http://example.com/path' }, }, { - name: 'uri-invalid-uppercase-scheme', - lexicons: [{ - lexicon: 1, - id: 'test.format.uri', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - url: { type: 'string', format: 'uri' } - } - } - } - } - }], + name: 'uri-valid-uppercase-scheme', + lexicons: [ + { + lexicon: 1, + id: 'test.format.uri', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.uri', - record: { url: 'HTTP://example.com' } + record: { url: 'HTTP://example.com' }, }, { name: 'uri-invalid-empty', - lexicons: [{ - lexicon: 1, - id: 'test.format.uri', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - url: { type: 'string', format: 'uri' } - } - } - } - } - }], + lexicons: [ + { + lexicon: 1, + id: 'test.format.uri', + defs: { + main: { + type: 'record', + key: 'tid', + record: { + type: 'object', + properties: { + url: { type: 'string', format: 'uri' }, + }, + }, + }, + }, + }, + ], collection: 'test.format.uri', - record: { url: '' } + record: { url: '' }, }, -] +]; diff --git a/test/inputs/schema/array.js b/test/inputs/schema/array.js index 1b181b3..dd3cd46 100644 --- a/test/inputs/schema/array.js +++ b/test/inputs/schema/array.js @@ -1,5 +1,4 @@ // Test inputs for array schema validation -// Extracted from honk's array_validator_test.gleam export const arraySchemaInputs = [ { @@ -12,10 +11,10 @@ export const arraySchemaInputs = [ type: 'array', items: { type: 'string' }, minLength: 1, - maxLength: 10 - } - } - } + maxLength: 10, + }, + }, + }, }, { name: 'array-valid-object-items', @@ -28,12 +27,12 @@ export const arraySchemaInputs = [ items: { type: 'object', properties: { - name: { type: 'string' } - } - } - } - } - } + name: { type: 'string' }, + }, + }, + }, + }, + }, }, { name: 'array-valid-nested-array', @@ -45,11 +44,11 @@ export const arraySchemaInputs = [ type: 'array', items: { type: 'array', - items: { type: 'integer' } - } - } - } - } + items: { type: 'integer' }, + }, + }, + }, + }, }, { name: 'array-invalid-missing-items', @@ -59,10 +58,10 @@ export const arraySchemaInputs = [ defs: { main: { type: 'array', - maxLength: 10 - } - } - } + maxLength: 10, + }, + }, + }, }, { name: 'array-invalid-length-constraints', @@ -74,10 +73,10 @@ export const arraySchemaInputs = [ type: 'array', items: { type: 'string' }, minLength: 10, - maxLength: 5 - } - } - } + maxLength: 5, + }, + }, + }, }, { name: 'array-valid-no-constraints', @@ -87,9 +86,9 @@ export const arraySchemaInputs = [ defs: { main: { type: 'array', - items: { type: 'string' } - } - } - } + items: { type: 'string' }, + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/blob.js b/test/inputs/schema/blob.js index 05e0319..df6fb58 100644 --- a/test/inputs/schema/blob.js +++ b/test/inputs/schema/blob.js @@ -1,5 +1,4 @@ // Test inputs for blob schema validation -// Extracted from honk's blob_validator_test.gleam export const blobSchemaInputs = [ { @@ -11,10 +10,10 @@ export const blobSchemaInputs = [ main: { type: 'blob', accept: ['image/*', 'video/mp4'], - maxSize: 1048576 - } - } - } + maxSize: 1048576, + }, + }, + }, }, { name: 'blob-valid-wildcard-mime', @@ -24,10 +23,10 @@ export const blobSchemaInputs = [ defs: { main: { type: 'blob', - accept: ['*/*'] - } - } - } + accept: ['*/*'], + }, + }, + }, }, { name: 'blob-invalid-mime-no-slash', @@ -37,10 +36,10 @@ export const blobSchemaInputs = [ defs: { main: { type: 'blob', - accept: ['image'] - } - } - } + accept: ['image'], + }, + }, + }, }, { name: 'blob-invalid-partial-wildcard', @@ -50,10 +49,10 @@ export const blobSchemaInputs = [ defs: { main: { type: 'blob', - accept: ['image/jpe*'] - } - } - } + accept: ['image/jpe*'], + }, + }, + }, }, { name: 'blob-invalid-zero-maxsize', @@ -63,10 +62,10 @@ export const blobSchemaInputs = [ defs: { main: { type: 'blob', - maxSize: 0 - } - } - } + maxSize: 0, + }, + }, + }, }, { name: 'blob-valid-no-constraints', @@ -75,9 +74,9 @@ export const blobSchemaInputs = [ id: 'test.blob.noconstraints', defs: { main: { - type: 'blob' - } - } - } + type: 'blob', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/boolean.js b/test/inputs/schema/boolean.js index ff5c995..c1015d3 100644 --- a/test/inputs/schema/boolean.js +++ b/test/inputs/schema/boolean.js @@ -8,10 +8,10 @@ export const booleanSchemaInputs = [ id: 'test.boolean.basic', defs: { main: { - type: 'boolean' - } - } - } + type: 'boolean', + }, + }, + }, }, { name: 'boolean-valid-with-default', @@ -21,10 +21,10 @@ export const booleanSchemaInputs = [ defs: { main: { type: 'boolean', - default: true - } - } - } + default: true, + }, + }, + }, }, { name: 'boolean-valid-with-const', @@ -34,10 +34,10 @@ export const booleanSchemaInputs = [ defs: { main: { type: 'boolean', - const: false - } - } - } + const: false, + }, + }, + }, }, { name: 'boolean-valid-with-description', @@ -47,9 +47,9 @@ export const booleanSchemaInputs = [ defs: { main: { type: 'boolean', - description: 'A boolean flag' - } - } - } + description: 'A boolean flag', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/bytes.js b/test/inputs/schema/bytes.js index d07bd70..2171154 100644 --- a/test/inputs/schema/bytes.js +++ b/test/inputs/schema/bytes.js @@ -1,5 +1,4 @@ // Test inputs for bytes schema validation -// Extracted from honk's bytes_validator_test.gleam export const bytesSchemaInputs = [ { @@ -9,10 +8,10 @@ export const bytesSchemaInputs = [ id: 'test.bytes.basic', defs: { main: { - type: 'bytes' - } - } - } + type: 'bytes', + }, + }, + }, }, { name: 'bytes-valid-with-constraints', @@ -23,10 +22,10 @@ export const bytesSchemaInputs = [ main: { type: 'bytes', minLength: 10, - maxLength: 20 - } - } - } + maxLength: 20, + }, + }, + }, }, { name: 'bytes-valid-with-description', @@ -36,10 +35,10 @@ export const bytesSchemaInputs = [ defs: { main: { type: 'bytes', - description: 'Binary data' - } - } - } + description: 'Binary data', + }, + }, + }, }, { name: 'bytes-invalid-max-less-than-min', @@ -50,10 +49,10 @@ export const bytesSchemaInputs = [ main: { type: 'bytes', minLength: 20, - maxLength: 10 - } - } - } + maxLength: 10, + }, + }, + }, }, { name: 'bytes-invalid-negative-min', @@ -63,10 +62,10 @@ export const bytesSchemaInputs = [ defs: { main: { type: 'bytes', - minLength: -1 - } - } - } + minLength: -1, + }, + }, + }, }, { name: 'bytes-invalid-negative-max', @@ -76,9 +75,9 @@ export const bytesSchemaInputs = [ defs: { main: { type: 'bytes', - maxLength: -5 - } - } - } + maxLength: -5, + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/index.js b/test/inputs/schema/index.js index c29a0a1..b9cae23 100644 --- a/test/inputs/schema/index.js +++ b/test/inputs/schema/index.js @@ -1,13 +1,17 @@ // Export all schema test inputs -export * from './string.js' -export * from './integer.js' -export * from './boolean.js' -export * from './object.js' -export * from './array.js' -export * from './union.js' -export * from './ref.js' -export * from './blob.js' -export * from './bytes.js' -export * from './token.js' -export * from './unknown.js' -export * from './record.js' +export * from './string.js'; +export * from './integer.js'; +export * from './boolean.js'; +export * from './object.js'; +export * from './array.js'; +export * from './union.js'; +export * from './ref.js'; +export * from './blob.js'; +export * from './bytes.js'; +export * from './token.js'; +export * from './unknown.js'; +export * from './record.js'; +export * from './params.js'; +export * from './query.js'; +export * from './procedure.js'; +export * from './subscription.js'; diff --git a/test/inputs/schema/integer.js b/test/inputs/schema/integer.js index f975be0..e0219bd 100644 --- a/test/inputs/schema/integer.js +++ b/test/inputs/schema/integer.js @@ -1,5 +1,4 @@ // Test inputs for integer schema validation -// Extracted from honk's integer_validator_test.gleam export const integerSchemaInputs = [ { @@ -11,10 +10,10 @@ export const integerSchemaInputs = [ main: { type: 'integer', minimum: 0, - maximum: 100 - } - } - } + maximum: 100, + }, + }, + }, }, { name: 'integer-valid-with-enum', @@ -24,10 +23,10 @@ export const integerSchemaInputs = [ defs: { main: { type: 'integer', - enum: [1, 2, 3] - } - } - } + enum: [1, 2, 3], + }, + }, + }, }, { name: 'integer-invalid-range-constraints', @@ -38,10 +37,10 @@ export const integerSchemaInputs = [ main: { type: 'integer', minimum: 100, - maximum: 10 - } - } - } + maximum: 10, + }, + }, + }, }, { name: 'integer-valid-no-constraints', @@ -50,10 +49,10 @@ export const integerSchemaInputs = [ id: 'test.integer.noconstraints', defs: { main: { - type: 'integer' - } - } - } + type: 'integer', + }, + }, + }, }, { name: 'integer-valid-with-default', @@ -63,10 +62,10 @@ export const integerSchemaInputs = [ defs: { main: { type: 'integer', - default: 42 - } - } - } + default: 42, + }, + }, + }, }, { name: 'integer-valid-with-const', @@ -76,9 +75,9 @@ export const integerSchemaInputs = [ defs: { main: { type: 'integer', - const: 100 - } - } - } + const: 100, + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/object.js b/test/inputs/schema/object.js index a5886c6..5e9ea72 100644 --- a/test/inputs/schema/object.js +++ b/test/inputs/schema/object.js @@ -1,5 +1,4 @@ // Test inputs for object schema validation -// Extracted from honk's object_validator_test.gleam export const objectSchemaInputs = [ { @@ -12,12 +11,12 @@ export const objectSchemaInputs = [ type: 'object', properties: { title: { type: 'string' }, - count: { type: 'integer' } + count: { type: 'integer' }, }, - required: ['title'] - } - } - } + required: ['title'], + }, + }, + }, }, { name: 'object-valid-with-nullable', @@ -29,12 +28,12 @@ export const objectSchemaInputs = [ type: 'object', properties: { name: { type: 'string' }, - duration: { type: 'integer' } + duration: { type: 'integer' }, }, - nullable: ['duration'] - } - } - } + nullable: ['duration'], + }, + }, + }, }, { name: 'object-invalid-nullable-not-in-properties', @@ -45,12 +44,12 @@ export const objectSchemaInputs = [ main: { type: 'object', properties: { - name: { type: 'string' } + name: { type: 'string' }, }, - nullable: ['nonexistent'] - } - } - } + nullable: ['nonexistent'], + }, + }, + }, }, { name: 'object-valid-empty-properties', @@ -60,10 +59,10 @@ export const objectSchemaInputs = [ defs: { main: { type: 'object', - properties: {} - } - } - } + properties: {}, + }, + }, + }, }, { name: 'object-valid-nested', @@ -77,12 +76,12 @@ export const objectSchemaInputs = [ outer: { type: 'object', properties: { - inner: { type: 'string' } - } - } - } - } - } - } + inner: { type: 'string' }, + }, + }, + }, + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/params.js b/test/inputs/schema/params.js new file mode 100644 index 0000000..df51226 --- /dev/null +++ b/test/inputs/schema/params.js @@ -0,0 +1,242 @@ +// Test inputs for params schema validation + +export const paramsSchemaInputs = [ + { + name: 'params-valid-boolean-property', + lexicon: { + lexicon: 1, + id: 'test.params.boolean', + defs: { + main: { + type: 'params', + properties: { + isPublic: { + type: 'boolean', + description: 'Whether the item is public', + }, + }, + }, + }, + }, + }, + { + name: 'params-valid-multiple-types', + lexicon: { + lexicon: 1, + id: 'test.params.multiple', + defs: { + main: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + }, + cursor: { + type: 'string', + description: 'Pagination cursor', + }, + includeReplies: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + { + name: 'params-valid-with-array', + lexicon: { + lexicon: 1, + id: 'test.params.array', + defs: { + main: { + type: 'params', + properties: { + tags: { + type: 'array', + items: { + type: 'string', + maxLength: 50, + }, + }, + }, + }, + }, + }, + }, + { + name: 'params-valid-with-required', + lexicon: { + lexicon: 1, + id: 'test.params.required', + defs: { + main: { + type: 'params', + properties: { + repo: { + type: 'string', + format: 'at-identifier', + }, + collection: { + type: 'string', + format: 'nsid', + }, + }, + required: ['repo'], + }, + }, + }, + }, + { + name: 'params-valid-with-unknown', + lexicon: { + lexicon: 1, + id: 'test.params.unknown', + defs: { + main: { + type: 'params', + properties: { + metadata: { + type: 'unknown', + }, + }, + }, + }, + }, + }, + { + name: 'params-valid-array-of-integers', + lexicon: { + lexicon: 1, + id: 'test.params.intarray', + defs: { + main: { + type: 'params', + properties: { + ids: { + type: 'array', + items: { + type: 'integer', + minimum: 1, + }, + }, + }, + }, + }, + }, + }, + { + name: 'params-valid-array-of-unknown', + lexicon: { + lexicon: 1, + id: 'test.params.unknownarray', + defs: { + main: { + type: 'params', + properties: { + data: { + type: 'array', + items: { + type: 'unknown', + }, + }, + }, + }, + }, + }, + }, + { + name: 'params-invalid-object-property', + lexicon: { + lexicon: 1, + id: 'test.params.objprop', + defs: { + main: { + type: 'params', + properties: { + filter: { + type: 'object', + properties: {}, + }, + }, + }, + }, + }, + }, + { + name: 'params-invalid-blob-property', + lexicon: { + lexicon: 1, + id: 'test.params.blobprop', + defs: { + main: { + type: 'params', + properties: { + avatar: { + type: 'blob', + accept: ['image/*'], + }, + }, + }, + }, + }, + }, + { + name: 'params-invalid-required-not-in-properties', + lexicon: { + lexicon: 1, + id: 'test.params.badreq', + defs: { + main: { + type: 'params', + properties: { + limit: { + type: 'integer', + }, + }, + required: ['cursor'], + }, + }, + }, + }, + { + name: 'params-invalid-empty-property-name', + lexicon: { + lexicon: 1, + id: 'test.params.emptyname', + defs: { + main: { + type: 'params', + properties: { + '': { + type: 'string', + }, + }, + }, + }, + }, + }, + { + name: 'params-invalid-array-of-objects', + lexicon: { + lexicon: 1, + id: 'test.params.objarray', + defs: { + main: { + type: 'params', + properties: { + filters: { + type: 'array', + items: { + type: 'object', + properties: {}, + }, + }, + }, + }, + }, + }, + }, +]; diff --git a/test/inputs/schema/procedure.js b/test/inputs/schema/procedure.js new file mode 100644 index 0000000..e197255 --- /dev/null +++ b/test/inputs/schema/procedure.js @@ -0,0 +1,97 @@ +// Test inputs for procedure schema validation + +export const procedureSchemaInputs = [ + { + name: 'procedure-valid-basic', + lexicon: { + lexicon: 1, + id: 'test.procedure.basic', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['text'], + properties: { + text: { + type: 'string', + maxLength: 300, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: 'procedure-valid-with-output', + lexicon: { + lexicon: 1, + id: 'test.procedure.output', + defs: { + main: { + type: 'procedure', + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + data: { type: 'string' }, + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + uri: { type: 'string' }, + cid: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + { + name: 'procedure-valid-no-input', + lexicon: { + lexicon: 1, + id: 'test.procedure.noinput', + defs: { + main: { + type: 'procedure', + }, + }, + }, + }, + { + name: 'procedure-valid-with-parameters', + lexicon: { + lexicon: 1, + id: 'test.procedure.params', + defs: { + main: { + type: 'procedure', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + }, + }, + input: { + encoding: 'application/json', + schema: { + type: 'object', + properties: {}, + }, + }, + }, + }, + }, + }, +]; diff --git a/test/inputs/schema/query.js b/test/inputs/schema/query.js new file mode 100644 index 0000000..e6968ff --- /dev/null +++ b/test/inputs/schema/query.js @@ -0,0 +1,90 @@ +// Test inputs for query schema validation + +export const querySchemaInputs = [ + { + name: 'query-valid-basic', + lexicon: { + lexicon: 1, + id: 'test.query.basic', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + }, + cursor: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + { + name: 'query-valid-with-output', + lexicon: { + lexicon: 1, + id: 'test.query.output', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + properties: { + records: { + type: 'array', + items: { type: 'unknown' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: 'query-valid-no-parameters', + lexicon: { + lexicon: 1, + id: 'test.query.noparam', + defs: { + main: { + type: 'query', + }, + }, + }, + }, + { + name: 'query-valid-with-required', + lexicon: { + lexicon: 1, + id: 'test.query.required', + defs: { + main: { + type: 'query', + parameters: { + type: 'params', + properties: { + repo: { type: 'string' }, + }, + required: ['repo'], + }, + }, + }, + }, + }, +]; diff --git a/test/inputs/schema/record.js b/test/inputs/schema/record.js index 23feada..a5aec3c 100644 --- a/test/inputs/schema/record.js +++ b/test/inputs/schema/record.js @@ -13,12 +13,12 @@ export const recordSchemaInputs = [ record: { type: 'object', properties: { - text: { type: 'string' } - } - } - } - } - } + text: { type: 'string' }, + }, + }, + }, + }, + }, }, { name: 'record-valid-with-required', @@ -33,12 +33,12 @@ export const recordSchemaInputs = [ type: 'object', required: ['text'], properties: { - text: { type: 'string' } - } - } - } - } - } + text: { type: 'string' }, + }, + }, + }, + }, + }, }, { name: 'record-valid-any-key', @@ -52,12 +52,12 @@ export const recordSchemaInputs = [ record: { type: 'object', properties: { - value: { type: 'integer' } - } - } - } - } - } + value: { type: 'integer' }, + }, + }, + }, + }, + }, }, { name: 'record-invalid-missing-record', @@ -67,9 +67,9 @@ export const recordSchemaInputs = [ defs: { main: { type: 'record', - key: 'tid' - } - } - } + key: 'tid', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/ref.js b/test/inputs/schema/ref.js index d040702..048acb8 100644 --- a/test/inputs/schema/ref.js +++ b/test/inputs/schema/ref.js @@ -1,5 +1,20 @@ // Test inputs for ref schema validation -// Extracted from honk's reference_validator_test.gleam + +// Mock strongRef lexicon for testing global refs +const strongRefLexicon = { + lexicon: 1, + id: 'com.atproto.repo.strongRef', + defs: { + main: { + type: 'object', + required: ['uri', 'cid'], + properties: { + uri: { type: 'string', format: 'at-uri' }, + cid: { type: 'string', format: 'cid' }, + }, + }, + }, +}; export const refSchemaInputs = [ { @@ -10,16 +25,16 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: '#post' + ref: '#post', }, post: { type: 'object', properties: { - text: { type: 'string' } - } - } - } - } + text: { type: 'string' }, + }, + }, + }, + }, }, { name: 'ref-valid-global', @@ -29,10 +44,11 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: 'com.atproto.repo.strongRef#main' - } - } - } + ref: 'com.atproto.repo.strongRef#main', + }, + }, + }, + additionalLexicons: [strongRefLexicon], }, { name: 'ref-valid-global-main-implicit', @@ -42,10 +58,11 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: 'com.atproto.repo.strongRef' - } - } - } + ref: 'com.atproto.repo.strongRef', + }, + }, + }, + additionalLexicons: [strongRefLexicon], }, { name: 'ref-invalid-empty', @@ -55,10 +72,10 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: '' - } - } - } + ref: '', + }, + }, + }, }, { name: 'ref-invalid-missing-ref-field', @@ -67,10 +84,10 @@ export const refSchemaInputs = [ id: 'test.ref.noref', defs: { main: { - type: 'ref' - } - } - } + type: 'ref', + }, + }, + }, }, { name: 'ref-invalid-local-no-def-name', @@ -80,10 +97,10 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: '#' - } - } - } + ref: '#', + }, + }, + }, }, { name: 'ref-invalid-global-empty-def', @@ -93,9 +110,9 @@ export const refSchemaInputs = [ defs: { main: { type: 'ref', - ref: 'com.example.lexicon#' - } - } - } + ref: 'com.example.lexicon#', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/string.js b/test/inputs/schema/string.js index f0a91ba..2e6cb70 100644 --- a/test/inputs/schema/string.js +++ b/test/inputs/schema/string.js @@ -1,5 +1,4 @@ // Test inputs for string schema validation -// Extracted from honk's string_validator_test.gleam export const stringSchemaInputs = [ { @@ -11,10 +10,10 @@ export const stringSchemaInputs = [ main: { type: 'string', minLength: 1, - maxLength: 100 - } - } - } + maxLength: 100, + }, + }, + }, }, { name: 'string-valid-with-format', @@ -24,10 +23,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - format: 'uri' - } - } - } + format: 'uri', + }, + }, + }, }, { name: 'string-valid-with-enum', @@ -37,10 +36,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - enum: ['red', 'green', 'blue'] - } - } - } + enum: ['red', 'green', 'blue'], + }, + }, + }, }, { name: 'string-invalid-length-constraints', @@ -51,10 +50,10 @@ export const stringSchemaInputs = [ main: { type: 'string', minLength: 100, - maxLength: 10 - } - } - } + maxLength: 10, + }, + }, + }, }, { name: 'string-invalid-negative-minLength', @@ -64,10 +63,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - minLength: -1 - } - } - } + minLength: -1, + }, + }, + }, }, { name: 'string-invalid-negative-maxLength', @@ -77,10 +76,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - maxLength: -5 - } - } - } + maxLength: -5, + }, + }, + }, }, { name: 'string-invalid-negative-minGraphemes', @@ -90,10 +89,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - minGraphemes: -10 - } - } - } + minGraphemes: -10, + }, + }, + }, }, { name: 'string-invalid-negative-maxGraphemes', @@ -103,10 +102,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - maxGraphemes: -3 - } - } - } + maxGraphemes: -3, + }, + }, + }, }, { name: 'string-valid-with-const', @@ -116,10 +115,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - const: 'fixed-value' - } - } - } + const: 'fixed-value', + }, + }, + }, }, { name: 'string-valid-with-default', @@ -129,10 +128,10 @@ export const stringSchemaInputs = [ defs: { main: { type: 'string', - default: 'default-value' - } - } - } + default: 'default-value', + }, + }, + }, }, { name: 'string-valid-with-graphemes', @@ -143,10 +142,10 @@ export const stringSchemaInputs = [ main: { type: 'string', minGraphemes: 1, - maxGraphemes: 100 - } - } - } + maxGraphemes: 100, + }, + }, + }, }, { name: 'string-valid-no-constraints', @@ -155,9 +154,9 @@ export const stringSchemaInputs = [ id: 'test.string.noconstraints', defs: { main: { - type: 'string' - } - } - } + type: 'string', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/subscription.js b/test/inputs/schema/subscription.js new file mode 100644 index 0000000..864325a --- /dev/null +++ b/test/inputs/schema/subscription.js @@ -0,0 +1,91 @@ +// Test inputs for subscription schema validation + +export const subscriptionSchemaInputs = [ + { + name: 'subscription-valid-basic', + lexicon: { + lexicon: 1, + id: 'test.subscription.basic', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + cursor: { + type: 'integer', + }, + }, + }, + }, + }, + }, + }, + { + name: 'subscription-valid-with-message', + lexicon: { + lexicon: 1, + id: 'test.subscription.message', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + cursor: { type: 'integer' }, + }, + }, + message: { + schema: { + type: 'union', + refs: ['#commit', '#identity'], + }, + }, + }, + commit: { + type: 'object', + properties: { + seq: { type: 'integer' }, + }, + }, + identity: { + type: 'object', + properties: { + did: { type: 'string' }, + }, + }, + }, + }, + }, + { + name: 'subscription-valid-no-parameters', + lexicon: { + lexicon: 1, + id: 'test.subscription.noparam', + defs: { + main: { + type: 'subscription', + }, + }, + }, + }, + { + name: 'subscription-valid-with-required', + lexicon: { + lexicon: 1, + id: 'test.subscription.required', + defs: { + main: { + type: 'subscription', + parameters: { + type: 'params', + properties: { + collection: { type: 'string' }, + }, + required: ['collection'], + }, + }, + }, + }, + }, +]; diff --git a/test/inputs/schema/token.js b/test/inputs/schema/token.js index 8d77ea5..d0395fd 100644 --- a/test/inputs/schema/token.js +++ b/test/inputs/schema/token.js @@ -8,10 +8,10 @@ export const tokenSchemaInputs = [ id: 'test.token.basic', defs: { main: { - type: 'token' - } - } - } + type: 'token', + }, + }, + }, }, { name: 'token-valid-with-description', @@ -21,9 +21,9 @@ export const tokenSchemaInputs = [ defs: { main: { type: 'token', - description: 'A token type' - } - } - } + description: 'A token type', + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/union.js b/test/inputs/schema/union.js index 564b7fa..96f176d 100644 --- a/test/inputs/schema/union.js +++ b/test/inputs/schema/union.js @@ -1,5 +1,4 @@ // Test inputs for union schema validation -// Extracted from honk's union_validator_test.gleam export const unionSchemaInputs = [ { @@ -11,10 +10,10 @@ export const unionSchemaInputs = [ main: { type: 'union', refs: [], - closed: false - } - } - } + closed: false, + }, + }, + }, }, { name: 'union-invalid-closed-empty-refs', @@ -25,10 +24,10 @@ export const unionSchemaInputs = [ main: { type: 'union', refs: [], - closed: true - } - } - } + closed: true, + }, + }, + }, }, { name: 'union-invalid-missing-refs', @@ -37,10 +36,10 @@ export const unionSchemaInputs = [ id: 'test.union.norefs', defs: { main: { - type: 'union' - } - } - } + type: 'union', + }, + }, + }, }, { name: 'union-valid-with-refs', @@ -50,22 +49,22 @@ export const unionSchemaInputs = [ defs: { main: { type: 'union', - refs: ['#post', '#comment'] + refs: ['#post', '#comment'], }, post: { type: 'object', properties: { - text: { type: 'string' } - } + text: { type: 'string' }, + }, }, comment: { type: 'object', properties: { - body: { type: 'string' } - } - } - } - } + body: { type: 'string' }, + }, + }, + }, + }, }, { name: 'union-valid-closed-with-refs', @@ -76,15 +75,15 @@ export const unionSchemaInputs = [ main: { type: 'union', refs: ['#item'], - closed: true + closed: true, }, item: { type: 'object', properties: { - value: { type: 'integer' } - } - } - } - } + value: { type: 'integer' }, + }, + }, + }, + }, }, -] +]; diff --git a/test/inputs/schema/unknown.js b/test/inputs/schema/unknown.js index 6bc4785..5b951f6 100644 --- a/test/inputs/schema/unknown.js +++ b/test/inputs/schema/unknown.js @@ -8,10 +8,10 @@ export const unknownSchemaInputs = [ id: 'test.unknown.basic', defs: { main: { - type: 'unknown' - } - } - } + type: 'unknown', + }, + }, + }, }, { name: 'unknown-valid-with-description', @@ -21,9 +21,9 @@ export const unknownSchemaInputs = [ defs: { main: { type: 'unknown', - description: 'An unknown type' - } - } - } + description: 'An unknown type', + }, + }, + }, }, -] +]; diff --git a/test/oracle.js b/test/oracle.js deleted file mode 100644 index a137c3a..0000000 --- a/test/oracle.js +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Oracle wrapper for honk - the reference Gleam implementation - * Used to compare our JS implementation against honk for behavioral parity - */ - -// Import honk's bundled JS -import { createRequire } from 'module' -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import vm from 'vm' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -// Load honk bundle into a sandbox -const honkBundlePath = join(__dirname, '../../honk/dist/honk.min.js') -const honkCode = readFileSync(honkBundlePath, 'utf-8') - -// Create a sandbox context for honk with required globals -const sandbox = { - honk: null, - TextEncoder: globalThis.TextEncoder, - TextDecoder: globalThis.TextDecoder, - Uint8Array: globalThis.Uint8Array, - ArrayBuffer: globalThis.ArrayBuffer, - DataView: globalThis.DataView, - Math: globalThis.Math, - Error: globalThis.Error, - Object: globalThis.Object, - Array: globalThis.Array, - JSON: globalThis.JSON, - String: globalThis.String, - Number: globalThis.Number, - Boolean: globalThis.Boolean, - Symbol: globalThis.Symbol, - Map: globalThis.Map, - Set: globalThis.Set, - BigInt: globalThis.BigInt, - console: globalThis.console, - globalThis: null, // Will be set to sandbox itself -} -sandbox.globalThis = sandbox -vm.createContext(sandbox) -vm.runInContext(honkCode, sandbox) -const honk = sandbox.honk - -/** - * Convert Gleam Result to our format - * @param {object} result - Gleam Result (Ok or Error) - * @returns {{ ok: boolean, value: any }} - */ -function unwrapResult(result) { - if (typeof result.isOk === 'function') { - return { ok: result.isOk(), value: result[0] } - } - return { ok: true, value: result } -} - -/** - * Convert Gleam Dict (HAMT) to plain JS object - * Structure: { root: { array: [{ k: key, v: linkedList }] }, size: n } - * Where linkedList is { head: value, tail: linkedList | {} } - * @param {object} dict - Gleam Dict - * @returns {object} Plain JS object - */ -function gleamDictToObject(dict) { - const obj = {} - - if (!dict || !dict.root || !dict.root.array) { - return obj - } - - function extractList(linkedList) { - const items = [] - let current = linkedList - while (current && current.head !== undefined) { - items.push(current.head) - current = current.tail - } - return items - } - - // Walk the HAMT structure - function walkNode(node) { - if (!node) return - - if (node.array) { - for (const entry of node.array) { - if (entry && entry.k !== undefined && entry.v !== undefined) { - obj[entry.k] = extractList(entry.v) - } else if (entry && entry.array) { - // Nested node - walkNode(entry) - } - } - } - } - - walkNode(dict.root) - return obj -} - -/** - * Format honk validation error to our format - */ -function formatValidationError(err) { - if (!err) return null - - // Check if it's a Gleam error type with message property - if (err.message !== undefined) { - return { - path: err.path || '', - message: err.message - } - } - - // Fallback - stringify the error - return { - path: '', - message: typeof err === 'string' ? err : JSON.stringify(err) - } -} - -/** - * Validate lexicon schemas using honk - * @param {object[]} lexicons - Array of lexicon JSON objects - * @returns {null | Record} null if valid, errors by NSID if invalid - */ -export function honkValidateLexicons(lexicons) { - try { - // Convert each lexicon to Gleam Json type - const gleamLexicons = lexicons.map(lex => { - const parseResult = honk.parse_json_string(JSON.stringify(lex)) - const unwrapped = unwrapResult(parseResult) - if (!unwrapped.ok) { - throw new Error(`Failed to parse lexicon: ${unwrapped.value}`) - } - return unwrapped.value - }) - - // Convert to Gleam List and validate - const lexiconList = honk.toList(gleamLexicons) - const result = honk.validate(lexiconList) - const unwrapped = unwrapResult(result) - - if (unwrapped.ok) { - return null - } else { - // Convert Gleam Dict to our error format - return gleamDictToObject(unwrapped.value) - } - } catch (e) { - return { '_error': [e.message] } - } -} - -/** - * Validate a record against a lexicon schema using honk - * @param {object[]} lexicons - Array of lexicon JSON objects - * @param {string} collection - NSID of the collection - * @param {object} record - The record data to validate - * @returns {null | {path: string, message: string}} null if valid, error if invalid - */ -export function honkValidateRecord(lexicons, collection, record) { - try { - // Convert each lexicon to Gleam Json type - const gleamLexicons = lexicons.map(lex => { - const parseResult = honk.parse_json_string(JSON.stringify(lex)) - const unwrapped = unwrapResult(parseResult) - if (!unwrapped.ok) { - throw new Error(`Failed to parse lexicon: ${unwrapped.value}`) - } - return unwrapped.value - }) - - // Convert record to Gleam Json - const recordParseResult = honk.parse_json_string(JSON.stringify(record)) - const recordUnwrapped = unwrapResult(recordParseResult) - if (!recordUnwrapped.ok) { - throw new Error(`Failed to parse record: ${recordUnwrapped.value}`) - } - - // Convert to Gleam List and validate - const lexiconList = honk.toList(gleamLexicons) - const result = honk.validate_record(lexiconList, collection, recordUnwrapped.value) - const unwrapped = unwrapResult(result) - - if (unwrapped.ok) { - return null - } else { - return formatValidationError(unwrapped.value) - } - } catch (e) { - return { path: '', message: e.message } - } -} - -/** - * Validate NSID format using honk - * @param {string} nsid - NSID string to validate - * @returns {boolean} true if valid - */ -export function honkIsValidNsid(nsid) { - try { - return honk.is_valid_nsid(nsid) - } catch (e) { - return false - } -} - -// Export honk directly for any additional testing needs -export { honk } diff --git a/test/oracle.test.js b/test/oracle.test.js deleted file mode 100644 index 6e416b8..0000000 --- a/test/oracle.test.js +++ /dev/null @@ -1,100 +0,0 @@ -import { test, expect, describe } from 'vitest' -import { honkValidateLexicons, honkValidateRecord, honkIsValidNsid } from './oracle.js' - -describe('oracle', () => { - test('oracle loads and is accessible', () => { - expect(honkValidateLexicons).toBeDefined() - expect(honkValidateRecord).toBeDefined() - expect(honkIsValidNsid).toBeDefined() - }) - - test('validates valid lexicon', () => { - const lexicon = { - lexicon: 1, - id: 'test.example.record', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { type: 'string' } - } - } - } - } - } - - const result = honkValidateLexicons([lexicon]) - expect(result).toBe(null) - }) - - test('rejects invalid lexicon - missing id', () => { - const lexicon = { - lexicon: 1, - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: {} - } - } - } - } - - const result = honkValidateLexicons([lexicon]) - expect(result).not.toBe(null) - }) - - test('validates valid record', () => { - const lexicon = { - lexicon: 1, - id: 'test.example.record', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { type: 'string', minLength: 1 } - } - } - } - } - } - - const result = honkValidateRecord([lexicon], 'test.example.record', { text: 'hello' }) - expect(result).toBe(null) - }) - - test('rejects invalid record - string too short', () => { - const lexicon = { - lexicon: 1, - id: 'test.example.record', - defs: { - main: { - type: 'record', - key: 'tid', - record: { - type: 'object', - properties: { - text: { type: 'string', minLength: 10 } - } - } - } - } - } - - const result = honkValidateRecord([lexicon], 'test.example.record', { text: 'short' }) - expect(result).not.toBe(null) - }) - - test('validates NSID format', () => { - expect(honkIsValidNsid('app.bsky.feed.post')).toBe(true) - expect(honkIsValidNsid('invalid')).toBe(false) - }) -}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..266a9b4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext" + }, + "include": ["lexicon.js"], + "exclude": ["node_modules"] +} diff --git a/vitest.config.js b/vitest.config.js index 47cdb03..7382f40 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,7 +1,7 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, }, -}) +}); -- 2.51.2