diff --git a/.agents/architecture.md b/.agents/architecture.md index fc0ae1092..110b24714 100644 --- a/.agents/architecture.md +++ b/.agents/architecture.md @@ -29,7 +29,7 @@ URLEncoder (@rapiq/codec-url-simple) The `Query` AST is an **intermediate representation (IR)**. Every package plays exactly one role around it: 1. **Define & interact** — client-side construction (plan 012): `defineQuery(QueryBuildInput)` + per-parameter `define*` fragment factories desugar typed input (scalars → `eq`, bare arrays → `in` with `null` legal, `$`-operator objects, condition-helper trees) straight to the AST — schema-free, no parsing. Condition helpers (`parameter/filters/helpers/`, one per `FilterFieldOperator`; `in` → `inArray` since `in` is reserved) build `Filter`/`Filters` nodes directly. Queries compose immutably via `mergeQueries` (left-priority; fields/relations/sorts keyed by name, pagination per-property) and the `Filters` combinators: `merge()` = per-field replace, flat root-AND only (typed `MergeError`, `ErrorCode.FILTERS_NOT_FLAT`); `and()`/`or()` = wrap & inject (server scoping — injected conditions can't be displaced by later merges). `$and`/`$or` object keys stay reserved for the mongo parser dialect (`@rapiq/parser-mongo`). `QueryBuilder` was removed — `defineQuery` replaces it. -2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. +2. **Parse to IR** — parsers transform *dialect* input (a spec for how parameters are written: "simple" object shapes, "expression" strings) into the IR, validated against a `Schema`. The `filters.validate` hook runs on every resolved/coerced leaf and may synchronously or asynchronously accept, replace or reject it without flattening compound structure. `parse()` keeps a strictly synchronous return type and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` on a Promise/thenable; `parseAsync()` awaits validators sequentially in tree order. Defaults apply if validation removes every leaf. Parsers are **transport-agnostic**: they read only the canonical `Parameter` keys (`fields`, `filters`, `pagination`, `relations`, `sort`) and know nothing about how the input crossed a process boundary. 3. **Consume the IR** — either interpret/walk it directly (`@rapiq/sql`, `@rapiq/typeorm` via visitors; `@rapiq/memory` compiles it into plain functions to evaluate in-memory objects/arrays), or… 4. **Transport the IR between application boundaries via a codec** — `@rapiq/codec-url-simple` is *one* such codec (HTTP URI scheme). The codec owns the complete wire format: the parameter wire names (`URLParameter`: `filter`, `page`, `include`, …) live **only** there, and `URLDecoder` is the boundary adapter — it accepts a raw query string *or* a pre-parsed query object (express `req.query`), maps wire names to canonical parameters and delegates to a schema-aware `SimpleParser`. App2 then works with the same IR. `@rapiq/codec-url-expression` is the sibling codec for the expression dialect (nested filter compounds in a single `filter=and(...)` param; the other four parameters share the simple wire machinery). @@ -37,7 +37,7 @@ Codec rules settled during plan 007 (2026-07): - **Subset law**: each dialect expresses only a subset of the IR — within it `decode(encode(q)) ≍ q` *modulo scalar type normalization* (the wire is untyped: `'5'` → `5`, `'true'` → `true`); outside it `encode` throws typed `FEATURE_UNSUPPORTED`/`OPERATOR_UNSUPPORTED` instead of silently changing semantics. The simple encoder enforces this pointwise: every emitted wire token is re-parsed and must decode back to the operator it came from. - **Codec identity is in-band** (reverses the earlier out-of-band-only stance): `@rapiq/codec-url` ships `URLCodecRegistry` — encoding through it stamps a reserved `codec` parameter; decoding dispatches on it (absent → default simple, so plain clients keep working; unregistered name → typed `CodecError`, never a silent mis-decode). Each codec package also exports its identifier constant for out-of-band negotiation. -- **Schema-aware encode** validates by piping the plain-encoded output through the schema-bound decoder and re-encoding — parser-exact semantics by construction (drop by default, schema `throwOnFailure` opts into throwing); parameters absent from the input query are masked so schema defaults don't materialize onto the wire. +- **Schema-aware encode** validates by piping the plain-encoded output through the schema-bound decoder and re-encoding — parser-exact semantics by construction (drop by default, schema `throwOnFailure` opts into throwing); parameters absent from the input query are masked so schema defaults don't materialize onto the wire. The URL codecs and registry mirror the parser split with `encodeAsync()` / `decodeAsync()`; registry codec async hooks are optional so sync-only third-party codecs remain compatible. - The shared filter-value wire grammar (`parseFilterScalar`/`parseFilterValue`/`parseFilterWireValue`/`serializeFilterValue`) lives in `@rapiq/parser-simple` (`parameter/filters/value.ts`) — the single source for scalar coercion and operator-marker parsing used by both parsers and the simple codec. Placement rules that follow (settled during plan 006, don't re-litigate): @@ -58,7 +58,7 @@ A `Schema` declares what a client *may* request per parameter (`allowed` ### 3. Dialects as small option objects, not subclasses -`@rapiq/sql` is database-agnostic; per-database behavior is injected via `DialectOptions` callbacks (`escapeField`, `paramPlaceholder`, `regexp`). Presets live in `packages/sql/src/dialect/`. +`@rapiq/sql` is database-agnostic; per-database behavior is injected via `DialectOptions` callbacks (`escapeField`, `paramPlaceholder`, `regexp`). Presets live in `packages/sql/src/dialect/`. Regex strings pass through unchanged for the database engine to interpret and validate; JavaScript `RegExp` values contribute their `source` and `ignoreCase` flag. ## Key Abstractions @@ -163,7 +163,7 @@ type DialectOptions = { }; ``` -`@rapiq/typeorm`: `TypeormAdapter` mirrors the SQL adapter but mutates a TypeORM query builder. The builder is bound at construction (`new TypeormAdapter({ queryBuilder: qb })`); `adapter.execute(query)` then walks the query and applies the accumulated state to that builder in a single call. +`@rapiq/typeorm`: `TypeormAdapter` mirrors the SQL adapter but mutates a TypeORM query builder. The builder is bound at construction (`new TypeormAdapter({ queryBuilder: qb })`); `adapter.execute(query)` then walks the query and applies the accumulated state to that builder in a single call. Filters use `andWhere`, preserving application-owned tenant/auth predicates already present on the builder. Relation aliases come from @rapiq/sql's shared, injective length-prefixed `buildRelationAlias` derivation; fields, filters, sorts and joins must all use that same function. `@rapiq/memory`: compile-once functional visitors — the core visitor interfaces implemented with `R = compiled function` (`IFiltersVisitor`, `ISortsVisitor`, `IFieldsVisitor`, `IPaginationVisitor`, `IQueryVisitor`): `compileFilters(condition)` → `(input) => boolean`, `applyQuery(query, data)` → `{ data, total, pagination }`. The semantics contract (SQL parity for positive operators, complement law for negations — `ne`/`nin`/`not*` match null/missing —, same-element join-row binding for dotted paths over arrays, keep-tree projection where relations widen a sparse field selection) is settled in `.agents/plans/014-memory.md`; do not re-litigate decisions recorded there. diff --git a/.agents/conventions.md b/.agents/conventions.md index 919f54d87..92a011820 100644 --- a/.agents/conventions.md +++ b/.agents/conventions.md @@ -74,8 +74,11 @@ No AI-attribution trailers in commits, issues, or PRs (see AGENTS.md). ## Release Process -- **release-please** (`release-please-config.json`, manifest-driven) manages versions and changelogs across workspaces; currently in `prerelease: true` mode with `alpha` versioning, and it bumps internal peer dependency ranges automatically. -- Release workflow: `.github/workflows/release.yml`; CI runs on `develop`, `master`, `next`, `beta`, `alpha`. +- **release-please** (`release-please-config.json`, manifest-driven) manages all ten public workspaces as one linked version group. It is currently in `prerelease: true` / `beta` mode, emits component-qualified tags, and updates internal peer dependency ranges through the node-workspace plugin. +- `release-as: 2.0.0-beta.0` bootstraps the first v2 beta. Remove that one-time override immediately after the beta release PR is merged so subsequent betas increment normally. +- Every public package publishes with npm access `public` and dist-tag `beta`; a prerelease must never update npm's `latest` tag. +- The private `@rapiq/docs` workspace keeps its internal `@rapiq/*` build inputs in `devDependencies` with `*` ranges. This ensures clean installs always link the current workspaces across major/prerelease bumps without adding docs tooling to the production audit. +- Release workflow: `.github/workflows/release.yml` runs on `master`. After release-please creates releases it installs, builds, lints, runs coverage, uploads coverage, and only then publishes. General CI runs on `develop`, `master`, `next`, `beta`, `alpha`. - Do not bump versions or edit `CHANGELOG.md` manually. ## References diff --git a/.agents/migration-notes.md b/.agents/migration-notes.md index 34e3f989b..576c9b387 100644 --- a/.agents/migration-notes.md +++ b/.agents/migration-notes.md @@ -26,7 +26,14 @@ Resolution semantics: 10. Alias mappings whose target contains dots (`mapping: { realmName: 'realm.name' }`) resolve through relation traversal: every segment is walked (per-level relations gate + registry/schemaMapping lookup) and the leaf validates against the *related* schema instead of the root allow-list — matching what direct dotted input always did. The emitted field keeps the full mapped path. In the fields parser such aliases are requeued through the relation machinery (child `execute()` semantics apply) and duplicate field nodes are deduplicated by name. Relation traversal is bounded (32 levels): cyclic mapping/schemaMapping configurations yield a `schemaUnresolvable` verdict (or `keyPathInvalid` under `throwOnFailure`) instead of unbounded recursion. 11. `SimpleParser.parse` / `ExpressionParser.parse` without a schema no longer bind the parameter parsers to a manufactured empty schema — schemaless parsing is uniformly unconstrained (dotted keys survive), consistent with `URLDecoder`. -Known pre-existing issue (out of scope here, plan 006): `BaseParser.expandObject` self-references its accumulator for genuinely nested object input (e.g. filters `{ user: { name: 'x' } }`), causing infinite recursion; flat dotted keys are unaffected. +`BaseParser.expandObject` now creates a fresh child object for nested input. Earlier v2 builds self-referenced the parent accumulator for input such as `{ user: { name: 'x' } }`, causing infinite recursion. + +## 2.0.0-beta hardening + +- Filter schema `validate` hooks are synchronous and run for every parsed leaf in the simple, expression and Mongo dialects. They may return the original/replacement filter or `undefined` to reject it; compound structure is retained and defaults apply when every leaf is rejected. +- Expression filters reject every unmatched source character, preserve leading underscores and cap recursive compounds/negations at 32 levels. The Mongo parser applies the same traversal cap. +- `regex(field, pattern)` accepts `RegExp` or string consistently in memory and SQL backends; invalid string patterns throw typed `AdapterError`. Oracle now renders `REGEXP_LIKE` with `:n` placeholders instead of inherited PostgreSQL syntax. +- TypeORM filters append with `andWhere`, preserving caller-owned predicates. The default relation alias is now the injective length-prefixed `buildRelationAlias` (`realm` → `r5_realm`, `role.realm` → `r4_role_5_realm`) so underscores in relation names cannot collide with path separators. ## Public-API triage (plan 008, items 1+2) @@ -62,5 +69,5 @@ Known pre-existing issue (out of scope here, plan 006): `BaseParser.expandObject - **Empty `in`/`nin` lists**: `in(field, [])` renders `1 = 0` (matches nothing) and `nin(field, [])` renders `1 = 1` — previously the invalid SQL `field in()`. - **sqlite preset** no longer inherits mysql's `regexp` callback (stock SQLite has no `REGEXP` function): anchored operators fall back to `LIKE`, the `regex` operator throws a typed `AdapterError`. - `FiltersVisitor`: `visitFilterNotEndsWith`/`visitFilterNotContains` signatures used wrong operator type parameters (copy-paste); in/nin and the six anchored-operator methods now share `whereIn`/`whereAnchored` helpers. -- **Literal matching for anchored operators** (from PR #742 review): `createFilterRegexPattern` escapes regex metacharacters — the input is a filter value, not a regex. Previously `contains(name, 'a.b')` matched `axb` on regexp dialects (while the LIKE fallback matched literally) and values like `'('` threw a raw `SyntaxError`. The `regex` operator is unaffected (takes a real `RegExp`). +- **Literal matching for anchored operators** (from PR #742 review): `createFilterRegexPattern` escapes regex metacharacters — the input is a filter value, not a regex. Previously `contains(name, 'a.b')` matched `axb` on regexp dialects (while the LIKE fallback matched literally) and values like `'('` threw a raw `SyntaxError`. The `regex` operator is unaffected and interprets a `RegExp` or string as a real pattern. - `notStartsWith` on regexp dialects now matches the empty string (`^(?!foo).*`, was `.+`), consistent with `NOT LIKE 'foo%'`. diff --git a/.agents/references/typeorm-extension.md b/.agents/references/typeorm-extension.md index 21cb5affd..df8df25c2 100644 --- a/.agents/references/typeorm-extension.md +++ b/.agents/references/typeorm-extension.md @@ -9,7 +9,7 @@ The de-facto v1 server-side integration: both authup and PrivateAIM/hub call rap | `src/query/module.ts` `applyQuery(qb, input, options)` | `parseQuery` (rapiq v1) + apply each parse output to the query builder; returns parse output incl. pagination | SimpleParser.parse → `Query.accept(QueryVisitor)` → `TypeormAdapter.execute()` | | `src/query/parameter/filters/module.ts` `transformParsedFilters` | v1 `FiltersParseOutput` (FLAT array of `{key, path, operator, value}`) → `{statement, binding}` list | @rapiq/sql `FiltersVisitor` + `FiltersBaseAdapter` | | `applyFiltersTransformed` | one `Brackets` with `andWhere` per condition — **AND-only, no OR trees in v1** | v2 `Filters` compound (and/or) — strictly more expressive | -| `src/query/parameter/relations/module.ts` | `leftJoinAndSelect` per relation + `onJoin(key, alias, qb)` hook; nested keys via `parts.slice(-2)` | @rapiq/typeorm `RelationsAdapter` (currently `innerJoinAndSelect` — **join-type divergence**, see below) | +| `src/query/parameter/relations/module.ts` | `leftJoinAndSelect` per relation + `onJoin(key, alias, qb)` hook; nested keys via `parts.slice(-2)` | @rapiq/typeorm `RelationsAdapter` (left join by default, configurable `joinType`, `onJoin` supported) | | `src/query/parameter/fields/module.ts` + `src/query/utils/alias.ts` | `query.select(fields.map(...))` with alias resolved per field via `getAliasForPath(relationsOutput, field.path)` | @rapiq/typeorm `FieldsAdapter` + shared `RelationsAdapter` (same cross-parameter alias idea) | | `bindingKey?: (key) => string` option | customizable parameter binding names (`filter_user_id`) | no v2 equivalent (placeholder indexer only) | @@ -18,7 +18,7 @@ The de-facto v1 server-side integration: both authup and PrivateAIM/hub call rap 1. **Secure-by-default opt-out**: `applyQuery` sets `options.fields/filters/relations/sort = false` (parameter fully disabled) unless `allowed`/`default` is explicitly defined (`isQueryOptionDefined`). rapiq v2 schemas treat *undefined* `allowed` as "any syntactically valid property" — **less restrictive**. Migrating consumers who omit a parameter's schema would silently open it up. v2 needs either matching opt-out semantics or a loud migration callout. 2. **Null semantics live in the adapter**: `value === null` → `IS NULL` / `IS NOT NULL`; a `null` inside an IN-array is spliced out and rewritten as `(key IN (...) OR key IS NULL)` (AND/NOT variant for NOT_IN). This is what makes authup's `filter: { realm_id: [id, null] }` realm-scoping pattern work. v2's sql/typeorm filter visitors must implement equivalent null handling or the single most common consumer filter breaks. 3. **v1 `~` LIKE is starts-with**: `transformParsedFilters` appends only a trailing `%` (`filter.value += '%'`). Client `~text` therefore means `text%`. v2's distinct STARTS_WITH/CONTAINS operators are richer, but codec/migration mapping of `~` must preserve starts-with semantics. -4. **Join type**: relations are applied with `leftJoinAndSelect`; current @rapiq/typeorm `RelationsAdapter` uses `innerJoinAndSelect`/`innerJoin` — inner joins drop rows with absent relations, a silent result-set change for every migrated endpoint. -5. **`onJoin` hook**: consumers (8+ hub repos, all authup repos) rely on it to `addGroupBy(`${alias}.id`)` because their root queries use `groupBy`. No v2 equivalent yet. -6. **Pagination is echoed back**: `applyQuery` returns the parse output; consumers destructure `{ pagination }` for response `meta`. v2 `execute()` currently returns nothing. -7. **Join aliasing diverges (v2 breaking fix, PR #760)**: typeorm-extension aliases nested joins by the path's *last segment* (`parts.slice(-2)`: `role.realm` joins as `realm`) — same-named relations on different branches collide. @rapiq/typeorm aliases by the *full path* with `.` → `_` (`role.realm` → `role_realm`, `buildRelationAlias` in @rapiq/sql), dedupes by that alias, and passes it to `onJoin`; custom derivations via `relations.relationAlias`. Migrated code that hard-codes leaf aliases (e.g. `andWhere('realm.name = ...')` on a nested join) must switch to the path-qualified alias. +4. **Join type**: relations are applied with `leftJoinAndSelect`; @rapiq/typeorm now preserves this as the default and exposes `relations.joinType: 'inner'` as an opt-in. +5. **`onJoin` hook**: consumers (8+ hub repos, all authup repos) rely on it to `addGroupBy(`${alias}.id`)` because their root queries use `groupBy`. @rapiq/typeorm exposes the equivalent `relations.onJoin(path, alias, queryBuilder)` hook. +6. **Pagination is echoed back**: `applyQuery` returns the parse output; consumers destructure `{ pagination }` for response `meta`. `TypeormAdapter.execute()` returns the applied `{ pagination: { limit, offset } }` shape. +7. **Join aliasing diverges (v2 breaking fix, PR #760; hardened before beta)**: typeorm-extension aliases nested joins by the path's *last segment* (`parts.slice(-2)`: `role.realm` joins as `realm`) — same-named relations on different branches collide. @rapiq/typeorm uses @rapiq/sql's injective, length-prefixed `buildRelationAlias` (`realm` → `r5_realm`, `role.realm` → `r4_role_5_realm`), dedupes by that alias, and passes it to `onJoin`; custom derivations via `relations.relationAlias`. Migrated code that hard-codes aliases must use the helper or the `onJoin` alias. diff --git a/.agents/references/typeorm.md b/.agents/references/typeorm.md index f832c9455..5ea34eb89 100644 --- a/.agents/references/typeorm.md +++ b/.agents/references/typeorm.md @@ -14,6 +14,6 @@ | `connection.options.type` (`src/data-source/DataSourceOptions.ts`) | `resolveQueryDialect()` (`packages/typeorm/src/dialect.ts`) → `resolveDialect()` (`packages/sql/src/dialect/resolve.ts`) | Connection type name → `DialectOptions` preset; postgres preset is the last-resort fallback. | | `DataSource.buildMetadatas()` (`src/data-source/DataSource.ts`, protected) | `createUnconnectedDataSource()` (`packages/typeorm/test/data/factory.ts`) | Builds entity metadata without opening a connection — enables dialect specs (incl. `mysql`, needs `mysql2` devDep for driver construction) with no live database. | | `SelectQueryBuilder.expressionMap.joinAttributes` | `RelationsAdapter.join()` dedup | Pre-existing joins are matched by `joinAttribute.alias.name`; matching joins are skipped (idempotency). | -| `SelectQueryBuilder.where()` (`src/query-builder/SelectQueryBuilder.ts`, verified on 0.3.30) | `FiltersAdapter.execute()` (`packages/typeorm/src/adapter/filters.ts`) | `where()` resets `expressionMap.wheres` **before** adding the new condition, and skips adding when the condition is falsy — so `where('', [])` clears any stale WHERE and emits no clause (valid SQL). The adapter's unconditional `where(sql, params)` call relies on this for filter-less re-runs; guarding it on non-empty sql would leak the previous run's WHERE. (TypeORM issue #9690 about invalid `WHERE ()` concerns empty *arrays/objects*, not empty strings.) | +| `SelectQueryBuilder.andWhere()` + `createWhereExpression()` (`src/query-builder/SelectQueryBuilder.ts`, verified on 0.3.30) | `FiltersAdapter.execute()` (`packages/typeorm/src/adapter/filters.ts`) | `andWhere()` records an `and` clause without clearing `expressionMap.wheres`. During rendering, the first condition is emitted as `WHERE ...` regardless of its stored conjunction; later conditions receive `AND`. The adapter therefore guards empty SQL and uses `andWhere(sql, params)`: a caller-owned tenant/auth predicate is preserved, while a filter on an otherwise empty builder still produces valid SQL. A TypeormAdapter/builder pair remains per-request; re-running onto an already-mutated builder is not a rollback operation. | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 680164398..a9b0f061d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,40 +53,23 @@ jobs: if: steps.release.outputs.releases_created == 'true' uses: ./.github/actions/build - - name: Publish - if: steps.release.outputs.releases_created == 'true' - run: npx workspaces-publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: CREATE CNAME + - name: Lint if: steps.release.outputs.releases_created == 'true' - run: | - cd ./packages/docs/.vitepress/dist/ - touch CNAME - echo "rapiq.tada5hi.net" > CNAME + run: npm run lint - - name: Build docs + - name: Test with coverage if: steps.release.outputs.releases_created == 'true' - run: | - npm run build --workspace=packages/docs - - - name: Deploy docs - if: steps.release.outputs.releases_created == 'true' - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./packages/docs/.vitepress/dist - - - name: Create coverage - if: steps.release.outputs.releases_created == 'true' - run: | - npm run test:coverage + run: npm run test:coverage - name: Upload coverage report if: steps.release.outputs.releases_created == 'true' uses: codecov/codecov-action@v7.0.0 with: token: ${{ secrets.codecov }} - directory: ./coverage/ + directory: ./packages + - name: Publish + if: steps.release.outputs.releases_created == 'true' + run: npx workspaces-publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a1b971bb6..bd3433df1 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,12 @@ { - ".": "1.0.0", - "packages/core": "1.0.0" + "packages/core": "1.0.0", + "packages/parser-simple": "1.0.0", + "packages/parser-expression": "1.0.0", + "packages/parser-mongo": "1.0.0", + "packages/codec-url-simple": "1.0.0", + "packages/codec-url-expression": "1.0.0", + "packages/codec-url": "1.0.0", + "packages/sql": "1.0.0", + "packages/typeorm": "1.0.0", + "packages/memory": "1.0.0" } diff --git a/LICENSE b/LICENSE index 4ca1d9134..5706e334a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Peter Placzek +Copyright (c) 2021-2026 Peter Placzek Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/nx.json b/nx.json index 00852c7c4..4f07c3a18 100644 --- a/nx.json +++ b/nx.json @@ -31,5 +31,6 @@ "production": [ "default" ] - } -} + }, + "analytics": false +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4fe971207..9ff940331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@rapiq/root", "version": "1.0.0", - "license": "Apache-2.0", + "license": "MIT", "workspaces": [ "packages/*" ], @@ -33,6 +33,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.0.tgz", "integrity": "sha512-kGvHfBa9oQCvZh0YXeguSToBD9GNJ+gzUZQ9KPTg+KSsM36obYcsKPoX0NnlJtPflHXu7RkMaIi44xs9meR6Zw==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -48,6 +49,7 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", @@ -58,6 +60,7 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -70,6 +73,7 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-shared": "1.17.7" @@ -83,6 +87,7 @@ "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, "license": "MIT", "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", @@ -93,6 +98,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.0.tgz", "integrity": "sha512-Zt2GjIm7vsaf7K23tk5JmtcVNc38G9p0C2L2Lrm06miyLE/NL2etHtHInvuLc1DjxTp7Y2nId4X/tzwo372K8Q==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -108,6 +114,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.0.tgz", "integrity": "sha512-7BueMuWYg/KBA2EX9zsQ+3OAleEyrJcB+SV5Al/9pLjMQq5mXB/8M5HaUPqZwN812g5kLzj9j43VThlZgWq0hg==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -123,6 +130,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.0.tgz", "integrity": "sha512-pJZIyhvUrs+B7c5Lw0iP5yP/NsqJMda7pKRYbfG4KtfGIVSMcAalZhdqL5UX8Z9DOC4KxO9tKV5RDeVjZU0VfQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -132,6 +140,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.0.tgz", "integrity": "sha512-RydkKDhx0GWTYuw0ndTXHGM8hD8hgwftKE65FfnJZb5bPc9CevOqv3qNPUQiviAwkqT9hQNH31uDGeV3yZkgfA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -147,6 +156,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.0.tgz", "integrity": "sha512-XiS7gdFq/COWiwdWXZ8+RHuewfEo03TkGESk44zU8zTc/Z6R8fm4DNmV52swJKkeB2N9iC7NKpgpM22OOkcgTw==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -162,6 +172,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.0.tgz", "integrity": "sha512-LBEJ/q+hn1nJ0aYg5IcWgLNCPjWHTahWmpHNx1qUZMho+9CyWM6LaEnhac45UHjQm/j0m374HP685VrpL133lA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -177,6 +188,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.0.tgz", "integrity": "sha512-2/9jUXKH4IcdU5qxH6cbDH46ZBe46G7xr+MrcHwgEXZcUfdAvUgLSH53MAWuMgxvw0G5yoqiWMifHc62Os0fiQ==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -192,6 +204,7 @@ "version": "1.55.0", "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.0.tgz", "integrity": "sha512-80tKsQgxXWo+jK0v4YGCHqyTEXawhAKYyr3kOdN51ElfRqUFjZNPVhZk6vRiqSqXfvrH85ytacT3cbJR6+qolA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -207,6 +220,7 @@ "version": "1.55.0", "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.0.tgz", "integrity": "sha512-4UjmAL8ywGW4rCfK6Qmgw3wIjbrO2wl2s4Eq56JTiN40L2t0XTv0HZkYAmr6nfeiXO0he/2crvZRX6SATSepag==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -222,6 +236,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.0.tgz", "integrity": "sha512-LMpJPtIkfDsHIx5Ga+baNr22ntYbY+e2wT7MSIc/FjAnu9wnBFhx1H/GfhmP/c5/IvbThDX+3ilxPRjSfCI8aA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0", @@ -237,6 +252,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.0.tgz", "integrity": "sha512-tDymJ7nFOAoUuecma3usK6o94dp8m4HYFDGh4ByYQXWkv14cpmDn+nWdylmcZO0Qvco107vqDo4+Anksnl8w1Q==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0" @@ -249,6 +265,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.0.tgz", "integrity": "sha512-6IDSB5o5dkDPQ4LdOW0Yuw/qy5MdWlO2xDHgPVZgW4YDjbxvnX5PAiV7/WWZdWyVObScZZnnHpPbiqfYs/zBLg==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0" @@ -261,6 +278,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.0.tgz", "integrity": "sha512-Yyyne4l//vDSdg4MhYJkaVne+KEPi833eCj3/T/87ernTwrvP6j9biXXZELsN8sLI/f2ndV/vugDIy2jdJQB6g==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.55.0" @@ -311,6 +329,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -320,6 +339,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -329,6 +349,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -344,6 +365,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -662,12 +684,14 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, "license": "MIT" }, "node_modules/@docsearch/js": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, "license": "MIT", "dependencies": { "@docsearch/react": "3.8.2", @@ -678,6 +702,7 @@ "version": "3.8.2", "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.17.7", @@ -945,6 +970,7 @@ "version": "1.2.86", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.86.tgz", "integrity": "sha512-t3jck5qPQuK1qy+bRn9eCoDQhIB7XSazKz1Fjp8hcan3XOAsTI5Mq/s3F0ekOKSvMQqkVORYK6ns6o6T9f5EMA==", + "dev": true, "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" @@ -954,6 +980,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui": { @@ -1125,6 +1152,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2114,6 +2142,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2127,6 +2156,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2140,6 +2170,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2153,6 +2184,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2166,6 +2198,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2179,6 +2212,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2192,6 +2226,7 @@ "cpu": [ "arm" ], + "dev": true, "libc": [ "glibc" ], @@ -2208,6 +2243,7 @@ "cpu": [ "arm" ], + "dev": true, "libc": [ "musl" ], @@ -2224,6 +2260,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -2240,6 +2277,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -2256,6 +2294,7 @@ "cpu": [ "loong64" ], + "dev": true, "libc": [ "glibc" ], @@ -2272,6 +2311,7 @@ "cpu": [ "loong64" ], + "dev": true, "libc": [ "musl" ], @@ -2288,6 +2328,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "glibc" ], @@ -2304,6 +2345,7 @@ "cpu": [ "ppc64" ], + "dev": true, "libc": [ "musl" ], @@ -2320,6 +2362,7 @@ "cpu": [ "riscv64" ], + "dev": true, "libc": [ "glibc" ], @@ -2336,6 +2379,7 @@ "cpu": [ "riscv64" ], + "dev": true, "libc": [ "musl" ], @@ -2352,6 +2396,7 @@ "cpu": [ "s390x" ], + "dev": true, "libc": [ "glibc" ], @@ -2368,6 +2413,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -2384,6 +2430,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -2400,6 +2447,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2413,6 +2461,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2426,6 +2475,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2439,6 +2489,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2452,6 +2503,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2465,6 +2517,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2475,6 +2528,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/engine-javascript": "2.5.0", @@ -2489,6 +2543,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -2500,6 +2555,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0", @@ -2510,6 +2566,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -2519,6 +2576,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/types": "2.5.0" @@ -2528,6 +2586,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -2538,6 +2597,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", @@ -2548,6 +2608,7 @@ "version": "10.0.2", "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, "license": "MIT" }, "node_modules/@sigstore/bundle": { @@ -3087,12 +3148,14 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, "license": "MIT" }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -3109,12 +3172,14 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, "license": "MIT", "dependencies": { "@types/linkify-it": "^5", @@ -3125,6 +3190,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "*" @@ -3134,13 +3200,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "25.9.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -3157,12 +3224,14 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -3449,6 +3518,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, "license": "ISC" }, "node_modules/@vitest/coverage-v8": { @@ -3599,6 +3669,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -3612,12 +3683,14 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-dom": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.38", @@ -3628,6 +3701,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.7", @@ -3645,12 +3719,14 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-ssr": { "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.38", @@ -3661,6 +3737,7 @@ "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-kit": "^7.7.9" @@ -3670,6 +3747,7 @@ "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, "license": "MIT", "dependencies": { "@vue/devtools-shared": "^7.7.9", @@ -3685,6 +3763,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -3694,12 +3773,14 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, "license": "MIT" }, "node_modules/@vue/devtools-shared": { "version": "7.7.9", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, "license": "MIT", "dependencies": { "rfdc": "^1.4.1" @@ -3709,6 +3790,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.38" @@ -3718,6 +3800,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.38", @@ -3728,6 +3811,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.38", @@ -3740,6 +3824,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.38", @@ -3753,12 +3838,14 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "dev": true, "license": "MIT" }, "node_modules/@vueuse/core": { "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/web-bluetooth": "^0.0.21", @@ -3774,6 +3861,7 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, "license": "MIT", "dependencies": { "@vueuse/core": "12.8.2", @@ -3840,6 +3928,7 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" @@ -3849,6 +3938,7 @@ "version": "12.8.2", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, "license": "MIT", "dependencies": { "vue": "^3.5.13" @@ -4271,6 +4361,7 @@ "version": "5.55.0", "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.0.tgz", "integrity": "sha512-af+rI+tUVeS9KWHPAZQHIHPOIC3StPRR6IwQu2nz1aQoTL6Gs5Ty3KsHCgbXMHOpoh9QqSjq8F3KJ8xmaCZSBA==", + "dev": true, "license": "MIT", "dependencies": { "@algolia/abtesting": "1.21.0", @@ -4388,7 +4479,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -4421,7 +4512,7 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -4733,6 +4824,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4770,13 +4862,14 @@ "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4787,6 +4880,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4927,7 +5021,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -4940,6 +5034,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -5032,6 +5127,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, "license": "MIT", "dependencies": { "is-what": "^5.2.0" @@ -5136,6 +5232,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/dayjs": { @@ -5263,7 +5360,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5283,6 +5380,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5292,7 +5390,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -5302,6 +5400,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, "license": "MIT", "dependencies": { "dequal": "^2.0.0" @@ -5433,6 +5532,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -5472,6 +5572,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5542,7 +5643,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6136,6 +6237,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, "license": "MIT", "dependencies": { "tabbable": "^6.4.0" @@ -6145,7 +6247,7 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -6212,7 +6314,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -6249,6 +6351,7 @@ "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, @@ -6497,7 +6600,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6525,6 +6628,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -6548,6 +6652,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0" @@ -6588,6 +6693,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -6980,6 +7086,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -7273,7 +7380,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "devOptional": true, + "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -7306,6 +7413,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7326,6 +7434,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7346,6 +7455,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7366,6 +7476,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7386,6 +7497,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7406,6 +7518,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -7429,6 +7542,7 @@ "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -7452,6 +7566,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -7475,6 +7590,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -7498,6 +7614,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7518,6 +7635,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7621,6 +7739,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -7682,6 +7801,7 @@ "version": "8.11.1", "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, "license": "MIT" }, "node_modules/math-intrinsics": { @@ -7697,6 +7817,7 @@ "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -7741,6 +7862,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7761,6 +7883,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7777,6 +7900,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7798,6 +7922,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7814,6 +7939,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, "funding": [ { "type": "GitHub Sponsors", @@ -7857,7 +7983,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -7867,7 +7993,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -8049,6 +8175,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, "license": "MIT" }, "node_modules/minizlib": { @@ -8068,6 +8195,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, "license": "MIT" }, "node_modules/mkdirp-classic": { @@ -8124,6 +8252,7 @@ "version": "3.3.14", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true, "funding": [ { "type": "github", @@ -8673,6 +8802,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex-xs": "^1.0.0", @@ -8954,12 +9084,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9009,6 +9141,7 @@ "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -9051,6 +9184,7 @@ "version": "10.29.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", @@ -9139,6 +9273,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9149,7 +9284,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -9289,6 +9424,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -9298,6 +9434,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, "license": "MIT", "dependencies": { "regex-utilities": "^2.3.0" @@ -9307,6 +9444,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, "license": "MIT" }, "node_modules/regexp-tree": { @@ -9413,6 +9551,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, "license": "MIT" }, "node_modules/rolldown": { @@ -9495,6 +9634,7 @@ "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -9591,6 +9731,7 @@ "version": "2.17.3", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, "license": "MIT", "peer": true }, @@ -9673,6 +9814,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, "license": "MIT", "dependencies": { "@shikijs/core": "2.5.0", @@ -9903,6 +10045,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -9912,6 +10055,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -9947,6 +10091,7 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10057,6 +10202,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, "license": "MIT", "dependencies": { "character-entities-html4": "^2.0.0", @@ -10131,6 +10277,7 @@ "version": "2.2.6", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, "license": "MIT", "dependencies": { "copy-anything": "^4" @@ -10156,6 +10303,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, "license": "MIT" }, "node_modules/tar": { @@ -10318,6 +10466,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -10492,7 +10641,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10551,13 +10700,14 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10571,6 +10721,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10584,6 +10735,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -10597,6 +10749,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10612,6 +10765,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10705,6 +10859,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10719,6 +10874,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -10811,6 +10967,7 @@ "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, "license": "MIT", "dependencies": { "@docsearch/css": "3.8.2", @@ -10855,6 +11012,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10871,6 +11029,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10887,6 +11046,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10903,6 +11063,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10919,6 +11080,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10935,6 +11097,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10951,6 +11114,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10967,6 +11131,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10983,6 +11148,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10999,6 +11165,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11015,6 +11182,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11031,6 +11199,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11047,6 +11216,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11063,6 +11233,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11079,6 +11250,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11095,6 +11267,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11111,6 +11284,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11127,6 +11301,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11143,6 +11318,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11159,6 +11335,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11175,6 +11352,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11191,6 +11369,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11207,6 +11386,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -11220,6 +11400,7 @@ "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -11233,6 +11414,7 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -11271,6 +11453,7 @@ "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -11420,6 +11603,7 @@ "version": "3.5.38", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.38", @@ -11807,6 +11991,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -11884,11 +12069,11 @@ "packages/docs": { "name": "@rapiq/docs", "version": "1.0.0", - "dependencies": { - "@rapiq/codec-url-simple": "^1.0.0", - "@rapiq/core": "^1.0.0", - "@rapiq/parser-simple": "^1.0.0", - "@rapiq/sql": "^1.0.0", + "devDependencies": { + "@rapiq/codec-url-simple": "*", + "@rapiq/core": "*", + "@rapiq/parser-simple": "*", + "@rapiq/sql": "*", "vitepress": "^1.6.4" } }, diff --git a/package.json b/package.json index 4ea49638b..24b94bfeb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "email": "contact@tada5hi.net", "url": "https://tada5hi.net" }, - "license": "Apache-2.0", + "license": "MIT", "version": "1.0.0", "description": "This package contains all rapiq related packages.", "workspaces": [ @@ -32,6 +32,7 @@ "scripts": { "build": "npx nx run-many -t build", "test": "npx nx run-many -t test", + "test:coverage": "npx nx run-many -t test:coverage", "lint": "eslint", "lint:fix": "eslint --fix", "prepare": "husky" diff --git a/packages/codec-url-expression/LICENSE b/packages/codec-url-expression/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/codec-url-expression/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/codec-url-expression/package.json b/packages/codec-url-expression/package.json index 21614dda6..98165149e 100644 --- a/packages/codec-url-expression/package.json +++ b/packages/codec-url-expression/package.json @@ -45,6 +45,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/codec-url-expression/src/decoder/module.ts b/packages/codec-url-expression/src/decoder/module.ts index 383bf6d16..1c0bde7be 100644 --- a/packages/codec-url-expression/src/decoder/module.ts +++ b/packages/codec-url-expression/src/decoder/module.ts @@ -86,6 +86,26 @@ export class URLDecoder { return this.parser.parse(mapped, options); } + async decodeAsync( + input: string | ObjectLiteral, + options: ParseQueryOptions = {}, + ) : Promise { + const parsed = typeof input === 'string' ? parse(input) : input; + if (!isObject(parsed)) { + return null; + } + + const mapped : ObjectLiteral = {}; + + this.mapParameter(parsed, mapped, URLParameter.FIELDS, Parameter.FIELDS); + this.mapParameter(parsed, mapped, URLParameter.FILTERS, Parameter.FILTERS); + this.mapParameter(parsed, mapped, URLParameter.PAGINATION, Parameter.PAGINATION); + this.mapParameter(parsed, mapped, URLParameter.RELATIONS, Parameter.RELATIONS); + this.mapParameter(parsed, mapped, URLParameter.SORT, Parameter.SORT); + + return this.parser.parseAsync(mapped, options); + } + decodeFields( input: string, options: ParseParameterOptions = {}, @@ -121,6 +141,22 @@ export class URLDecoder { return this.filters.parse(undefined, options); } + async decodeFiltersAsync( + input: string, + options: ParseParameterOptions = {}, + ) : Promise { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (isPropertySet(output, URLParameter.FILTERS)) { + return this.filters.parseAsync(output[URLParameter.FILTERS], options); + } + + return this.filters.parseAsync(undefined, options); + } + decodePagination( input: string, options: ParseParameterOptions = {}, diff --git a/packages/codec-url-expression/src/encoder/module.ts b/packages/codec-url-expression/src/encoder/module.ts index ef7ad5fe9..7e8a4eed6 100644 --- a/packages/codec-url-expression/src/encoder/module.ts +++ b/packages/codec-url-expression/src/encoder/module.ts @@ -80,6 +80,30 @@ export class URLEncoder { }); } + async encodeAsync( + input: IQuery, + options: ParseQueryOptions = {}, + ) : Promise { + const encoded = this.encodeParts(input); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = await this.decoder.decodeAsync(encoded, options); + if (!decoded) { + return null; + } + + return this.encodeParts(decoded, { + fields: input.fields.value.length > 0, + filters: input.filters.value.length > 0, + pagination: typeof input.pagination.limit !== 'undefined' || + typeof input.pagination.offset !== 'undefined', + relations: input.relations.value.length > 0, + sorts: input.sorts.value.length > 0, + }); + } + encodeFields(input: IFields, options: ParseParameterOptions = {}) { return this.simple.encodeFields(input, options); } @@ -98,6 +122,23 @@ export class URLEncoder { return this.serializeFilters(decoded); } + async encodeFiltersAsync( + input: IFilters, + options: ParseParameterOptions = {}, + ) : Promise { + const encoded = this.serializeFilters(input); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = await this.decoder.decodeFiltersAsync(encoded, options); + if (!decoded) { + return null; + } + + return this.serializeFilters(decoded); + } + encodePagination(input: IPagination, options: ParseParameterOptions = {}) { return this.simple.encodePagination(input, options); } diff --git a/packages/codec-url-expression/test/unit/encoder-schema.spec.ts b/packages/codec-url-expression/test/unit/encoder-schema.spec.ts index ef06b3d34..d5e2f0857 100644 --- a/packages/codec-url-expression/test/unit/encoder-schema.spec.ts +++ b/packages/codec-url-expression/test/unit/encoder-schema.spec.ts @@ -8,6 +8,7 @@ import { FiltersParseError, defineQuery, + defineSchema, eq, gte, or, @@ -51,4 +52,22 @@ describe('encoder (schema-aware)', () => { expect(decodeURIComponent(encoded!)).toEqual('sort=-id'); }); + + it('should await asynchronous validators in async encode methods', async () => { + const schema = defineSchema({ + filters: { + validate: async (filter) => eq( + filter.field, + String(filter.value).toUpperCase(), + ), + }, + }); + const query = defineQuery({ filters: eq('name', 'John') }); + + const encoded = await encoder.encodeAsync(query, { schema }); + const encodedFilters = await encoder.encodeFiltersAsync(query.filters, { schema }); + + expect(decodeURIComponent(encoded!)).toEqual('filter=eq(name,\'JOHN\')'); + expect(decodeURIComponent(encodedFilters!)).toEqual('filter=eq(name,\'JOHN\')'); + }); }); diff --git a/packages/codec-url-simple/LICENSE b/packages/codec-url-simple/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/codec-url-simple/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/codec-url-simple/package.json b/packages/codec-url-simple/package.json index 59c2d7dbc..df25b08c9 100644 --- a/packages/codec-url-simple/package.json +++ b/packages/codec-url-simple/package.json @@ -41,6 +41,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", @@ -59,7 +63,7 @@ "repository": { "type": "git", "url": "git+https://github.com/Tada5hi/rapiq.git", - "directory": "packages/codec" + "directory": "packages/codec-url-simple" }, "bugs": { "url": "https://github.com/Tada5hi/rapiq/issues" diff --git a/packages/codec-url-simple/src/decoder/module.ts b/packages/codec-url-simple/src/decoder/module.ts index c34732dce..e1c5d0df7 100644 --- a/packages/codec-url-simple/src/decoder/module.ts +++ b/packages/codec-url-simple/src/decoder/module.ts @@ -84,6 +84,26 @@ export class URLDecoder { return this.parser.parse(mapped, options); } + async decodeAsync( + input: string | ObjectLiteral, + options: ParseQueryOptions = {}, + ) : Promise { + const parsed = typeof input === 'string' ? parse(input) : input; + if (!isObject(parsed)) { + return null; + } + + const mapped : ObjectLiteral = {}; + + this.mapParameter(parsed, mapped, URLParameter.FIELDS, Parameter.FIELDS); + this.mapParameter(parsed, mapped, URLParameter.FILTERS, Parameter.FILTERS); + this.mapParameter(parsed, mapped, URLParameter.PAGINATION, Parameter.PAGINATION); + this.mapParameter(parsed, mapped, URLParameter.RELATIONS, Parameter.RELATIONS); + this.mapParameter(parsed, mapped, URLParameter.SORT, Parameter.SORT); + + return this.parser.parseAsync(mapped, options); + } + decodeFields( input: string, options: ParseParameterOptions = {}, @@ -116,6 +136,22 @@ export class URLDecoder { return this.filters.parse(output, options); } + async decodeFiltersAsync( + input: string, + options: ParseParameterOptions = {}, + ) : Promise { + const output = parse(input); + if (!isObject(output)) { + return null; + } + + if (output[URLParameter.FILTERS]) { + return this.filters.parseAsync(output[URLParameter.FILTERS], options); + } + + return this.filters.parseAsync(output, options); + } + decodePagination( input: string, options: ParseParameterOptions = {}, diff --git a/packages/codec-url-simple/src/encoder/module.ts b/packages/codec-url-simple/src/encoder/module.ts index d6e671b0f..f6da3fc49 100644 --- a/packages/codec-url-simple/src/encoder/module.ts +++ b/packages/codec-url-simple/src/encoder/module.ts @@ -71,6 +71,34 @@ export class URLEncoder implements IEncoder { })); } + async encodeAsync( + input: IQuery, + options: ParseQueryOptions = {}, + ) : Promise { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitQuery(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = await this.decoder.decodeAsync(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitQuery(decoded, { + fields: input.fields.value.length > 0, + filters: input.filters.value.length > 0, + pagination: typeof input.pagination.limit !== 'undefined' || + typeof input.pagination.offset !== 'undefined', + relations: input.relations.value.length > 0, + sorts: input.sorts.value.length > 0, + })); + } + encodeFields(input: IFields, options: ParseParameterOptions = {}) { this.visitor.reset(); @@ -113,6 +141,27 @@ export class URLEncoder implements IEncoder { return this.runSerializer(this.visitor.visitFilters(decoded)); } + async encodeFiltersAsync( + input: IFilters, + options: ParseParameterOptions = {}, + ) : Promise { + this.visitor.reset(); + + const encoded = this.runSerializer(this.visitor.visitFilters(input)); + if (encoded === null || !this.isSchemaAware(options)) { + return encoded; + } + + const decoded = await this.decoder.decodeFiltersAsync(encoded, options); + if (!decoded) { + return null; + } + + this.visitor.reset(); + + return this.runSerializer(this.visitor.visitFilters(decoded)); + } + encodeFilter(input: IFilter) { this.visitor.reset(); diff --git a/packages/codec-url-simple/test/unit/decoder.spec.ts b/packages/codec-url-simple/test/unit/decoder.spec.ts index d3978beef..ed84e2bad 100644 --- a/packages/codec-url-simple/test/unit/decoder.spec.ts +++ b/packages/codec-url-simple/test/unit/decoder.spec.ts @@ -117,6 +117,25 @@ describe('decoder', () => { ])); }); + it('should await asynchronous schema validation through decodeAsync', async () => { + const decoder = new URLDecoder(); + const schema = defineSchema({ + filters: { + validate: async (filter) => new Filter( + filter.operator, + filter.field, + String(filter.value).toUpperCase(), + ), + }, + }); + + const output = await decoder.decodeAsync('filter[name]=admin', { schema }); + + expect(output!.filters).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + it('should reject undeclared parameters when decoding with the strict option', () => { const decoder = new URLDecoder(); diff --git a/packages/codec-url-simple/test/unit/encoder-schema.spec.ts b/packages/codec-url-simple/test/unit/encoder-schema.spec.ts index 76b697653..31ab0ad6d 100644 --- a/packages/codec-url-simple/test/unit/encoder-schema.spec.ts +++ b/packages/codec-url-simple/test/unit/encoder-schema.spec.ts @@ -131,4 +131,22 @@ describe('encoder (schema-aware)', () => { expect(decodeURIComponent(encoded!)).toEqual('filter[name]=John'); }); + + it('should await asynchronous validators in async encode methods', async () => { + const schema = defineSchema({ + filters: { + validate: async (filter) => eq( + filter.field, + String(filter.value).toUpperCase(), + ), + }, + }); + const query = defineQuery({ filters: { name: 'John' } }); + + const encoded = await encoder.encodeAsync(query, { schema }); + const encodedFilters = await encoder.encodeFiltersAsync(query.filters, { schema }); + + expect(decodeURIComponent(encoded!)).toEqual('filter[name]=JOHN'); + expect(decodeURIComponent(encodedFilters!)).toEqual('filter[name]=JOHN'); + }); }); diff --git a/packages/codec-url/LICENSE b/packages/codec-url/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/codec-url/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/codec-url/package.json b/packages/codec-url/package.json index 8bca6269c..184dcb83e 100644 --- a/packages/codec-url/package.json +++ b/packages/codec-url/package.json @@ -43,6 +43,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/codec-url/src/module.ts b/packages/codec-url/src/module.ts index b5ed00d96..1de705f26 100644 --- a/packages/codec-url/src/module.ts +++ b/packages/codec-url/src/module.ts @@ -74,6 +74,23 @@ export class URLCodecRegistry { return `${CODEC_PARAMETER}=${codec.name}&${encoded}`; } + async encodeAsync( + input: IQuery, + options: URLCodecRegistryEncodeOptions = {}, + ) : Promise { + const { codec: name, ...parseOptions } = options; + + const codec = this.resolve(name); + const encoded = codec.encoder.encodeAsync ? + await codec.encoder.encodeAsync(input, parseOptions) : + codec.encoder.encode(input, parseOptions); + if (encoded === null) { + return null; + } + + return `${CODEC_PARAMETER}=${codec.name}&${encoded}`; + } + /** * Decode a query string or a pre-parsed query object (e.g. an * express req.query) with the codec its payload names — or the @@ -111,6 +128,34 @@ export class URLCodecRegistry { return codec.decoder.decode(payload, options); } + async decodeAsync( + input: string | ObjectLiteral, + options: ParseQueryOptions = {}, + ) : Promise { + const parsed = typeof input === 'string' ? parse(input) : input; + if (!isObject(parsed)) { + return null; + } + + let name : string | undefined; + + const value = parsed[CODEC_PARAMETER]; + if (typeof value !== 'undefined') { + if (typeof value !== 'string') { + throw CodecError.notResolvable(); + } + + name = value; + } + + const codec = this.resolve(name); + const { [CODEC_PARAMETER]: _, ...payload } = parsed; + + return codec.decoder.decodeAsync ? + codec.decoder.decodeAsync(payload, options) : + codec.decoder.decode(payload, options); + } + protected resolve(name?: string) : URLCodec { const key = name ?? this.defaultName; if (typeof key === 'undefined') { diff --git a/packages/codec-url/src/types.ts b/packages/codec-url/src/types.ts index a86920dba..9de974887 100644 --- a/packages/codec-url/src/types.ts +++ b/packages/codec-url/src/types.ts @@ -13,10 +13,14 @@ import type { export interface IURLCodecEncoder { encode(input: IQuery, options?: ParseQueryOptions): string | null; + + encodeAsync?(input: IQuery, options?: ParseQueryOptions): Promise; } export interface IURLCodecDecoder { decode(input: string | ObjectLiteral, options?: ParseQueryOptions): IQuery | null; + + decodeAsync?(input: string | ObjectLiteral, options?: ParseQueryOptions): Promise; } /** diff --git a/packages/codec-url/test/unit/registry.spec.ts b/packages/codec-url/test/unit/registry.spec.ts index 5fd55f814..56159dbcf 100644 --- a/packages/codec-url/test/unit/registry.spec.ts +++ b/packages/codec-url/test/unit/registry.spec.ts @@ -10,6 +10,7 @@ import { ErrorCode, Filters, defineQuery, + defineSchema, eq, gte, or, @@ -91,6 +92,42 @@ describe('URLCodecRegistry', () => { expect(external.decode('codec=noop')).toBeNull(); }); + it('should preserve sync-only external codecs in async dispatch', async () => { + const external = new URLCodecRegistry(); + external.register({ + name: 'noop', + encoder: { encode: () => 'x=1' }, + decoder: { decode: () => null }, + }); + + await expect(external.encodeAsync(defineQuery({ filters: { a: 1 } }))) + .resolves.toEqual('codec=noop&x=1'); + await expect(external.decodeAsync('codec=noop')).resolves.toBeNull(); + }); + + it('should dispatch bundled async codecs for asynchronous validators', async () => { + const schema = defineSchema({ + filters: { + validate: async (filter) => eq( + filter.field, + String(filter.value).toUpperCase(), + ), + }, + }); + const query = defineQuery({ filters: eq('name', 'John') }); + + const encoded = await registry.encodeAsync(query, { + codec: 'url-expression', + schema, + }); + const decoded = await registry.decodeAsync(encoded!, { schema }); + + expect(decodeURIComponent(encoded!)).toEqual( + 'codec=url-expression&filter=eq(name,\'JOHN\')', + ); + expect(decoded!.filters).toEqual(new Filters('and', [eq('name', 'JOHN')])); + }); + it('should strip the reserved parameter before delegating', () => { const seen : unknown[] = []; diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/core/package.json b/packages/core/package.json index a99fcd9c6..954309435 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", @@ -46,7 +50,8 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/Tada5hi/rapiq.git" + "url": "git+https://github.com/Tada5hi/rapiq.git", + "directory": "packages/core" }, "bugs": { "url": "https://github.com/Tada5hi/rapiq/issues" diff --git a/packages/core/src/errors/code.ts b/packages/core/src/errors/code.ts index a9a220279..ef6158e7f 100644 --- a/packages/core/src/errors/code.ts +++ b/packages/core/src/errors/code.ts @@ -33,4 +33,6 @@ export enum ErrorCode { SCHEMA_NAME_INVALID = 'schemaNameInvalid', SCHEMA_UNRESOLVABLE = 'schemaUnresolvable', + + SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER = 'schemaValidatorAsyncRequiresAsyncParser', } diff --git a/packages/core/src/errors/schema.ts b/packages/core/src/errors/schema.ts index 11b077bb7..d7e781d79 100644 --- a/packages/core/src/errors/schema.ts +++ b/packages/core/src/errors/schema.ts @@ -32,4 +32,11 @@ export class SchemaError extends BaseError { code: ErrorCode.SCHEMA_UNRESOLVABLE, }); } + + static validatorAsyncRequiresAsyncParser() { + return new this({ + message: 'Asynchronous schema validators require parseAsync().', + code: ErrorCode.SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER, + }); + } } diff --git a/packages/core/src/parameter/filters/record/types.ts b/packages/core/src/parameter/filters/record/types.ts index a4c775adc..6eb9a6923 100644 --- a/packages/core/src/parameter/filters/record/types.ts +++ b/packages/core/src/parameter/filters/record/types.ts @@ -40,7 +40,7 @@ export interface IFilterVisitor { visitFilterEndsWith?(expr: IFilter) : R; visitFilterNotEndsWith?(expr: IFilter) : R; - visitFilterRegex?(expr: IFilter) : R; + visitFilterRegex?(expr: IFilter) : R; } export interface IFilter< diff --git a/packages/core/src/parser/base.ts b/packages/core/src/parser/base.ts index c641fe7e9..9965efbf0 100644 --- a/packages/core/src/parser/base.ts +++ b/packages/core/src/parser/base.ts @@ -41,6 +41,10 @@ export abstract class BaseParser< abstract parse(input: unknown, options?: OPTIONS): OUTPUT; + async parseAsync(input: unknown, options?: OPTIONS) : Promise { + return this.parse(input, options); + } + // -------------------------------------------------- protected getBaseSchema< @@ -60,14 +64,13 @@ export abstract class BaseParser< protected expandObject( input: Record, - aggregated: Record = {}, ) { - const output : Record = aggregated || {}; + const output : Record = {}; const keys = Object.keys(input); for (const key of keys) { if (isObject(input[key])) { - setPathValue(output, key, this.expandObject(input[key], output)); + setPathValue(output, key, this.expandObject(input[key])); } else { setPathValue(output, key, input[key]); } diff --git a/packages/core/src/parser/parameter/filters/index.ts b/packages/core/src/parser/parameter/filters/index.ts index b3b74f083..900d46881 100644 --- a/packages/core/src/parser/parameter/filters/index.ts +++ b/packages/core/src/parser/parameter/filters/index.ts @@ -7,3 +7,4 @@ export * from './error'; export * from './types'; +export * from './validate'; diff --git a/packages/core/src/parser/parameter/filters/validate.ts b/packages/core/src/parser/parameter/filters/validate.ts new file mode 100644 index 000000000..1d02fdf5e --- /dev/null +++ b/packages/core/src/parser/parameter/filters/validate.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + Filters, + isFilter, + isFilters, +} from '../../../parameter'; +import type { + ICondition, + IFilter, + IFilters, +} from '../../../parameter'; +import type { FiltersSchema } from '../../../schema'; +import { SchemaError } from '../../../errors'; + +function isPromiseLike(input: unknown) : input is PromiseLike { + return ( + input !== null && + (typeof input === 'object' || typeof input === 'function') && + 'then' in input && + typeof input.then === 'function' + ); +} + +/** + * Apply a filter schema's leaf validator without flattening or otherwise + * changing the compound tree. Returning `undefined` from the validator drops + * only that leaf; replacement filters are inserted in the same position. + */ +export function applyFiltersSchemaValidation( + input: IFilter | IFilters, + schema: FiltersSchema, +) : IFilter | IFilters | undefined; +export function applyFiltersSchemaValidation( + input: ICondition, + schema: FiltersSchema, +) : ICondition | undefined; +export function applyFiltersSchemaValidation( + input: ICondition, + schema: FiltersSchema, +) : ICondition | undefined { + if (isFilter(input)) { + const output = schema.validate(input); + if (isPromiseLike(output)) { + void Promise.resolve(output).catch(() => undefined); + throw SchemaError.validatorAsyncRequiresAsyncParser(); + } + + return output || undefined; + } + + if (!isFilters(input)) { + return input; + } + + const conditions : ICondition[] = []; + for (const child of input.value) { + const validated = applyFiltersSchemaValidation(child, schema); + if (validated) { + conditions.push(validated); + } + } + + return new Filters(input.operator, conditions); +} + +/** + * Async counterpart of {@link applyFiltersSchemaValidation}. Validators are + * awaited sequentially so leaf order and observable execution order remain + * identical to the synchronous traversal. + */ +export function applyFiltersSchemaValidationAsync( + input: IFilter | IFilters, + schema: FiltersSchema, +) : Promise; +export function applyFiltersSchemaValidationAsync( + input: ICondition, + schema: FiltersSchema, +) : Promise; +export async function applyFiltersSchemaValidationAsync( + input: ICondition, + schema: FiltersSchema, +) : Promise { + if (isFilter(input)) { + return (await schema.validate(input)) || undefined; + } + + if (!isFilters(input)) { + return input; + } + + const conditions : ICondition[] = []; + for (const child of input.value) { + const validated = await applyFiltersSchemaValidationAsync(child, schema); + if (validated) { + conditions.push(validated); + } + } + + return new Filters(input.operator, conditions); +} diff --git a/packages/core/src/parser/query.ts b/packages/core/src/parser/query.ts index a647840b7..c2f75c5d2 100644 --- a/packages/core/src/parser/query.ts +++ b/packages/core/src/parser/query.ts @@ -104,6 +104,67 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseQueryOptions = {}, + ) : Promise { + const output : QueryContext = {}; + + const data : ObjectLiteral = isObject(input) ? input : {}; + + const parameterOptions : ParseParameterOptions = {}; + if (options.schema) { + parameterOptions.schema = options.schema; + } + + if (typeof options.strict !== 'undefined') { + parameterOptions.strict = options.strict; + } + + if (!this.skipParameter(options.relations)) { + const relationsInput = this.readParameter(data, Parameter.RELATIONS); + + const relations = await this.parseRelationsAsync(relationsInput, parameterOptions); + output.relations = relations; + + if (typeof relationsInput !== 'undefined') { + parameterOptions.relations = relations; + } + } + + if (!this.skipParameter(options.fields)) { + output.fields = await this.parseFieldsAsync( + this.readParameter(data, Parameter.FIELDS), + parameterOptions, + ); + } + + if (!this.skipParameter(options.filters)) { + output.filters = await this.parseFiltersAsync( + this.readParameter(data, Parameter.FILTERS), + parameterOptions, + ); + } + + if (!this.skipParameter(options.pagination)) { + output.pagination = await this.parsePaginationAsync( + this.readParameter(data, Parameter.PAGINATION), + parameterOptions, + ); + } + + if (!this.skipParameter(options.sort)) { + output.sorts = await this.parseSortAsync( + this.readParameter(data, Parameter.SORT), + parameterOptions, + ); + } + + return new Query(output); + } + // ----------------------------------------------------- /** @@ -121,6 +182,15 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseParameterOptions = {}, + ) : Promise { + return this.relationsParser.parseAsync(input, options); + } + /** * Parse fields input parameter. * @@ -136,6 +206,15 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseParameterOptions = {}, + ) : Promise { + return this.fieldsParser.parseAsync(input, options); + } + /** * Parse filter(s) input parameter. * @@ -151,6 +230,15 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseParameterOptions = {}, + ) : Promise { + return this.filtersParser.parseAsync(input, options); + } + /** * Parse pagination input parameter. * @@ -166,6 +254,15 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseParameterOptions = {}, + ) : Promise { + return this.paginationParser.parseAsync(input, options); + } + /** * Parse sort input parameter. * @@ -181,6 +278,15 @@ export abstract class BaseQueryParser extends BaseParser( + input: unknown, + options: ParseParameterOptions = {}, + ) : Promise { + return this.sortParser.parseAsync(input, options); + } + // -------------------------------------------------- /** diff --git a/packages/core/src/parser/types.ts b/packages/core/src/parser/types.ts index 12a5b7c87..cad9354a0 100644 --- a/packages/core/src/parser/types.ts +++ b/packages/core/src/parser/types.ts @@ -35,6 +35,7 @@ export type ParseQueryOptions< }; export type IParserOptions = { + /** @deprecated Call parseAsync() instead of selecting execution mode in options. */ async?: boolean, }; @@ -44,6 +45,8 @@ export interface IParser< Options extends IParserOptions = IParserOptions, > { parse(input: Input, options?: Options): Output; + + parseAsync(input: Input, options?: Options): Promise; } /** @@ -54,4 +57,8 @@ export interface IQueryParameterParser { parse< RECORD extends ObjectLiteral = ObjectLiteral, >(input: unknown, options?: ParseParameterOptions): Output; + + parseAsync< + RECORD extends ObjectLiteral = ObjectLiteral, + >(input: unknown, options?: ParseParameterOptions): Promise; } diff --git a/packages/core/test/unit/parameter/filters-validation.spec.ts b/packages/core/test/unit/parameter/filters-validation.spec.ts new file mode 100644 index 000000000..a21ba0fcc --- /dev/null +++ b/packages/core/test/unit/parameter/filters-validation.spec.ts @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026. + * Author Peter Placzek (tada5hi) + * For the full copyright and license information, + * view the LICENSE file that was distributed with this source code. + */ + +import { + ErrorCode, + Filter, + FilterCompoundOperator, + FilterFieldOperator, + Filters, + SchemaError, + applyFiltersSchemaValidation, + applyFiltersSchemaValidationAsync, + defineFiltersSchema, +} from '../../../src'; +import type { Validator } from '../../../src'; + +describe('src/parser/parameter/filters/validate.ts', () => { + it('should replace and reject leaves while preserving compounds', () => { + const input = new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'), + new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.GREATER_THAN, 'age', 18), + new Filter(FilterFieldOperator.EQUAL, 'name', 'guest'), + ]), + ]); + const schema = defineFiltersSchema({ + validate: (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }); + + expect(applyFiltersSchemaValidation(input, schema)).toEqual( + new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'GUEST'), + ]), + ]), + ); + }); + + it('should preserve a leaf when no validator is configured', () => { + const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); + + expect(applyFiltersSchemaValidation(input, defineFiltersSchema())).toBe(input); + }); + + it('should reject promise-returning validators', () => { + const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); + const validate : Validator = async () => input; + const schema = defineFiltersSchema({ validate }); + + try { + applyFiltersSchemaValidation(input, schema); + expect.fail('Expected asynchronous validation to throw.'); + } catch (error) { + expect(error).toBeInstanceOf(SchemaError); + expect((error as SchemaError).code).toBe( + ErrorCode.SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER, + ); + } + }); + + it('should consume rejected promise-returning validators', () => { + const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); + const output = Promise.reject(new Error('Validator rejected.')); + const catchMock = vi.spyOn(output, 'catch'); + const validate : Validator = () => output; + const schema = defineFiltersSchema({ validate }); + + expect(() => applyFiltersSchemaValidation(input, schema)).toThrow(SchemaError); + expect(catchMock).toHaveBeenCalledOnce(); + }); + + it('should await validators sequentially while preserving compounds', async () => { + const calls : string[] = []; + const input = new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'), + new Filter(FilterFieldOperator.GREATER_THAN, 'age', 18), + ]); + const schema = defineFiltersSchema({ + validate: async (filter) => { + calls.push(filter.field); + await Promise.resolve(); + + return filter.field === 'name' ? filter : undefined; + }, + }); + + await expect(applyFiltersSchemaValidationAsync(input, schema)).resolves.toEqual( + new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'), + ]), + ); + expect(calls).toEqual(['name', 'age']); + }); + + it('should propagate asynchronous validator failures', async () => { + const input = new Filter(FilterFieldOperator.EQUAL, 'name', 'admin'); + const schema = defineFiltersSchema({ + validate: async () => { + throw new Error('Validator rejected.'); + }, + }); + + await expect(applyFiltersSchemaValidationAsync(input, schema)) + .rejects.toThrow('Validator rejected.'); + }); +}); diff --git a/packages/docs/guide/errors.md b/packages/docs/guide/errors.md index b63339244..c27234ec4 100644 --- a/packages/docs/guide/errors.md +++ b/packages/docs/guide/errors.md @@ -75,6 +75,7 @@ The URL encoders throw these too — a codec never silently changes what a query |---|---| | `SCHEMA_NAME_INVALID` | `registry.add()` with a schema that has no `name` | | `SCHEMA_UNRESOLVABLE` | `registry.getOrFail()` for a name that isn't registered | +| `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` | `parse()` (or a synchronous codec method) encountered an async filter validator; use the corresponding `Async` method | ## Mapping to HTTP responses diff --git a/packages/docs/guide/executing-queries.md b/packages/docs/guide/executing-queries.md index 0f0c2f92c..23f91c893 100644 --- a/packages/docs/guide/executing-queries.md +++ b/packages/docs/guide/executing-queries.md @@ -29,7 +29,7 @@ const { pagination } = adapter.execute(query); const [entities, total] = await queryBuilder.getManyAndCount(); ``` -`execute` returns the applied pagination — handy for the response `meta` block. Options (join types, the `onJoin` hook, alias conventions) are on the [package page](/packages/typeorm). +`execute` returns the applied pagination — handy for the response `meta` block. Existing builder predicates are preserved and rapiq filters are appended with `AND`, so tenant or authorization scopes applied before `execute` cannot be erased. Options (join types, the `onJoin` hook, alias conventions) are on the [package page](/packages/typeorm). ## Raw SQL diff --git a/packages/docs/guide/filters.md b/packages/docs/guide/filters.md index 91608bedb..ac8f09985 100644 --- a/packages/docs/guide/filters.md +++ b/packages/docs/guide/filters.md @@ -144,7 +144,7 @@ defineSchema({ allowed: ['id', 'name', 'age'], mapping: { aliasId: 'id' }, default: eq('status', 'active'), - validate: (filter) => { /* inspect / replace / reject a parsed Filter */ }, + validate: async (filter) => { /* inspect / replace / reject a parsed Filter */ }, }, }); ``` @@ -154,9 +154,21 @@ defineSchema({ | `allowed` | Filterable field names. Omit to allow all; `[]` blocks the parameter. | | `default` | Condition applied when the client sends no filters. | | `mapping` | Alias → field translation applied before validation. | -| `validate` | Per-filter hook — inspect/replace a parsed `Filter`, or reject it. | +| `validate` | Sync or async per-filter hook — inspect/replace a parsed `Filter`, or reject it. | | `caseSensitive` | Fields whose equality comparisons stay exact instead of the [case-insensitive default](#case-sensitivity). | +`validate` runs after key resolution, mapping and value coercion. Return the original filter to accept it, another `Filter` to replace it, or `undefined` to reject that leaf. The return value may also be a Promise of any of those results. + +Use the synchronous `parse()` / `decode()` / schema-aware `encode()` methods when every validator is synchronous. Use their `Async` counterparts when a validator may be asynchronous: + +```typescript +const query = await parser.parseAsync(input, { schema }); +const decoded = await decoder.decodeAsync(req.query, { schema }); +const encoded = await encoder.encodeAsync(query, { schema }); +``` + +The async path awaits validators sequentially in filter-tree order. Calling a synchronous method when a validator returns a Promise/thenable throws a `SchemaError` with `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER`. Compound `and`/`or` structure is preserved; if every submitted leaf is rejected, the schema default is applied. + ## On violation Disallowed or invalid filter input is dropped silently; with [`throwOnFailure`](/guide/schemas#failure-behavior-drop-vs-throw) it throws a `FiltersParseError` instead. Grammar errors in the expression and MongoDB-style dialects always throw — see [Error Handling](/guide/errors). diff --git a/packages/docs/guide/migration-typeorm-extension.md b/packages/docs/guide/migration-typeorm-extension.md index e2bbe2365..bd4a9c0bd 100644 --- a/packages/docs/guide/migration-typeorm-extension.md +++ b/packages/docs/guide/migration-typeorm-extension.md @@ -33,7 +33,7 @@ typeorm-extension used inner joins for relations; `@rapiq/typeorm` defaults to * ### Join aliases are path-qualified -typeorm-extension aliased joins by the relation path's **last segment** (`role.realm` joined as `realm`), so relation paths ending in the same segment collided. `@rapiq/typeorm` aliases by the **full path** with `.` replaced by `_` (`role.realm` → `role_realm`) — see the [alias convention](/packages/typeorm#options). This only surfaces in code that references join aliases directly, e.g. hand-written `andWhere` clauses on nested relations; `onJoin` hooks keep working unchanged, since the `alias` argument they receive is already path-qualified. A custom derivation can be injected via `relations: { relationAlias }`, but it must stay collision-free. +typeorm-extension aliased joins by the relation path's **last segment** (`role.realm` joined as `realm`), so relation paths ending in the same segment collided. `@rapiq/typeorm` uses the collision-free `buildRelationAlias(path)` helper (`realm` → `r5_realm`, `role.realm` → `r4_role_5_realm`) — see the [alias convention](/packages/typeorm#options). This surfaces in code that references join aliases directly, e.g. hand-written `andWhere` clauses; `onJoin` hooks keep working because they receive the derived alias. A custom derivation can be injected via `relations: { relationAlias }`, but it must stay collision-free. ### Defaults that carried over diff --git a/packages/docs/package.json b/packages/docs/package.json index 3176ecfe7..0305302f1 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -2,11 +2,11 @@ "name": "@rapiq/docs", "version": "1.0.0", "private": true, - "dependencies": { - "@rapiq/codec-url-simple": "^1.0.0", - "@rapiq/core": "^1.0.0", - "@rapiq/parser-simple": "^1.0.0", - "@rapiq/sql": "^1.0.0", + "devDependencies": { + "@rapiq/codec-url-simple": "*", + "@rapiq/core": "*", + "@rapiq/parser-simple": "*", + "@rapiq/sql": "*", "vitepress": "^1.6.4" }, "scripts": { diff --git a/packages/docs/packages/codec-url-expression.md b/packages/docs/packages/codec-url-expression.md index 9a9acad78..6256b6025 100644 --- a/packages/docs/packages/codec-url-expression.md +++ b/packages/docs/packages/codec-url-expression.md @@ -30,6 +30,8 @@ const decoder = new URLDecoder(registry); const query = decoder.decode(req.query, { schema: 'user' }); ``` +For schemas with asynchronous filter validators, use `encodeAsync()` / `encodeFiltersAsync()` and `decodeAsync()` / `decodeFiltersAsync()`. + ## The expressible subset Wider than the simple dialect's: nested compounds, several conditions on the same field and comma-containing strings all round-trip (values are quoted, `''` escapes a quote). Still outside it — and loudly rejected on encode: diff --git a/packages/docs/packages/codec-url-simple.md b/packages/docs/packages/codec-url-simple.md index a74b25fd8..175bef2ad 100644 --- a/packages/docs/packages/codec-url-simple.md +++ b/packages/docs/packages/codec-url-simple.md @@ -59,6 +59,8 @@ const encoder = new URLEncoder(registry); // SchemaRegistry, like URLDecoder encoder.encode(query, { schema: 'user' }); ``` +If `filters.validate` may return a Promise, use `await encoder.encodeAsync(query, { schema })`; use `encodeFiltersAsync()` for a filter-only schema pass. The synchronous methods remain synchronous and reject async validator results with a typed `SchemaError`. + - Disallowed fields/filters/relations/sort keys are **dropped** by default; a schema with `throwOnFailure: true` throws instead. - Schema `mapping` aliases resolve to their canonical names on the wire. - `pagination.maxLimit` clamps the emitted limit. @@ -86,6 +88,8 @@ app.get('/users', (req, res) => { Per-parameter variants exist as well: `decodeFields`, `decodeFilters`, `decodePagination`, `decodeRelations`, `decodeSort` — each also accepting `{ schema }` options. +Use `await decodeAsync(input, options)` for an asynchronous filter validator. The filter-only equivalent is `decodeFiltersAsync()`. + ## Related - [@rapiq/codec-url-expression](/packages/codec-url-expression) — wider filter subset (nested compounds) on the same wire machinery. diff --git a/packages/docs/packages/codec-url.md b/packages/docs/packages/codec-url.md index 8f4c0e3a8..44b21fc9c 100644 --- a/packages/docs/packages/codec-url.md +++ b/packages/docs/packages/codec-url.md @@ -23,6 +23,8 @@ codecs.decode('codec=url-expression&filter=or(...)', { schema: 'user' }); codecs.decode('filter[name]=John'); // no stamp → default codec (simple) ``` +When a schema may run asynchronous filter validators, use `await codecs.encodeAsync(...)` and `await codecs.decodeAsync(...)`. Custom codecs may optionally implement `encodeAsync` / `decodeAsync`; the registry falls back to their synchronous methods when those hooks are absent. + `createURLCodecRegistry()` bundles the two built-in dialects with `url-simple` as the default. ## Dispatch rules diff --git a/packages/docs/packages/parser-expression.md b/packages/docs/packages/parser-expression.md index 1fe260b9f..9615e8fb3 100644 --- a/packages/docs/packages/parser-expression.md +++ b/packages/docs/packages/parser-expression.md @@ -53,7 +53,7 @@ const query = parser.parse({ Only the `filters` parameter uses the expression language — fields, relations, pagination and sort accept the same input as the [simple parser](/packages/parser-simple), and the whole thing returns the same [`Query`](/guide/query-ast). -There is also a standalone `parseFilters(input, options)` returning just the `Filters` node. +There is also a standalone `parseFilters(input, options)` returning just the `Filters` node. For schemas with asynchronous filter validators, use `parseAsync()` / `parseFiltersAsync()` on the query parser, or `parseAsync()` / `parseExactAsync()` on `ExpressionFiltersParser`. ## Errors diff --git a/packages/docs/packages/parser-mongo.md b/packages/docs/packages/parser-mongo.md index eb794cde6..ef12bb300 100644 --- a/packages/docs/packages/parser-mongo.md +++ b/packages/docs/packages/parser-mongo.md @@ -85,7 +85,7 @@ const query = parser.parse({ Only the `filters` parameter uses the mongo dialect — fields, relations, pagination and sort accept the same input as the [simple parser](/packages/parser-simple), and the whole thing returns the same [`Query`](/guide/query-ast). -There is also a standalone `MongoFiltersParser` returning just the `Filters` node; its `parseTyped(input, options)` accepts a `MongoFiltersParserInput`, so field keys and operator values are type-checked against the record type. +There is also a standalone `MongoFiltersParser` returning just the `Filters` node; its `parseTyped(input, options)` accepts a `MongoFiltersParserInput`, so field keys and operator values are type-checked against the record type. For schemas with asynchronous filter validators, use `parseAsync()` or `parseTypedAsync()`. ## Failure model diff --git a/packages/docs/packages/parser-simple.md b/packages/docs/packages/parser-simple.md index e514c0b78..0f97b1188 100644 --- a/packages/docs/packages/parser-simple.md +++ b/packages/docs/packages/parser-simple.md @@ -43,6 +43,8 @@ const query = parser.parse({ | `strict` | Override the schema's [strict mode](/guide/schemas#strict-mode) for this call. | | `fields` / `filters` / `relations` / `pagination` / `sort` | Set to `false` to skip a parameter entirely. | +If `filters.validate` may return a Promise, use `await parser.parseAsync(input, options)`. `parse()` remains synchronous for schemas whose validators are synchronous and throws `SCHEMA_VALIDATOR_ASYNC_REQUIRES_ASYNC_PARSER` if it encounters an async result. + ## Schema defaults A parameter absent from the input is still parsed, so schema defaults always apply: `fields.default` (or, without one, the `fields.allowed` selection), `filters.default`, `sort.default` and `pagination.maxLimit` shape the resulting `Query` even when the input is empty. @@ -59,7 +61,7 @@ Per-parameter input shapes and the wire operator syntax are documented on the pa ## Per-parameter parsers -Each parameter also has a standalone parser class — `SimpleFieldsParser`, `SimpleFiltersParser`, `SimplePaginationParser`, `SimpleRelationsParser`, `SimpleSortParser` — with the same `(input, { schema })` signature, returning that parameter's AST node. Useful when only one parameter comes from user input. +Each parameter also has a standalone parser class — `SimpleFieldsParser`, `SimpleFiltersParser`, `SimplePaginationParser`, `SimpleRelationsParser`, `SimpleSortParser` — with the same `(input, { schema })` signature, returning that parameter's AST node. Useful when only one parameter comes from user input. Every parser exposes `parseAsync()`; `SimpleFiltersParser` also exposes `parseTypedAsync()` alongside `parseTyped()`. ## Errors diff --git a/packages/docs/packages/sql.md b/packages/docs/packages/sql.md index 395cf952a..b50939366 100644 --- a/packages/docs/packages/sql.md +++ b/packages/docs/packages/sql.md @@ -48,7 +48,7 @@ const adapter = new Adapter({ ...pg, rootAlias: 'user' }); const fragments = adapter.execute(query); // { -// columns: ['"user"."id"', '"user"."name"', '"realm"."name"'], +// columns: ['"user"."id"', '"user"."name"', '"r5_realm"."name"'], // where: '("user"."age" >= $1 and ...)', // params: [18, ...], // orderBy: ['"user"."age" DESC'], @@ -63,7 +63,7 @@ Construct the `Adapter` **per request** — it accumulates per-call state; the s `@rapiq/sql` deliberately stops at fragments: composing the final `SELECT` statement — in particular `FROM`/`JOIN` conditions, which require knowledge of the table layout — is the job of the caller or a backend adapter. That's exactly what [`@rapiq/typeorm`](/packages/typeorm) does for TypeORM. ::: warning Alias convention -Fragments reference joined columns via the relation path's **path-qualified alias**: the path with `.` replaced by `_` (e.g. `realm.name` → `"realm"."name"`, `role.realm.name` → `"role_realm"."name"`), so same-named relations on different branches never collide. When rendering `JOIN` clauses from `relations`, derive each alias with the exported `buildRelationAlias(path)` helper — or inject your own convention via the `relationAlias` adapter option, keeping it collision-free and within your database's identifier length limit. +Fragments reference joined columns through the exported `buildRelationAlias(path)` derivation. It length-prefixes every path segment (`realm` → `r5_realm`, `role.realm` → `r4_role_5_realm`), so `role_realm` and `role.realm` cannot collapse onto one alias. Use the same helper when rendering `JOIN` clauses from `relations`, or inject one convention through the `relationAlias` adapter option. Keep a custom derivation collision-free and within your database's identifier length limit. ::: ## Rendering filters standalone @@ -109,7 +109,7 @@ Negated operators are **exact complements** of their positive twins: a record th ### String matching -The `contains` / `startsWith` / `endsWith` operators (and their negations) match their value **literally** on every dialect: regex metacharacters are escaped on regex-capable dialects, LIKE wildcards are escaped on the LIKE fallback. Only the `regex` operator interprets its value as a pattern. +The `contains` / `startsWith` / `endsWith` operators (and their negations) match their value **literally** on every dialect: regex metacharacters are escaped on regex-capable dialects, LIKE wildcards are escaped on the LIKE fallback. Only the `regex` operator interprets its `RegExp` or string value as a pattern. A JavaScript `RegExp` contributes its `source` and `ignoreCase` flag; a string is passed through unchanged so the selected database regex engine owns its syntax and validation. The negations match rows whose column is `NULL` (complement law, see above) — on the LIKE fallback they render `(field NOT LIKE ? ESCAPE '\' OR field IS NULL)`. diff --git a/packages/docs/packages/typeorm.md b/packages/docs/packages/typeorm.md index 258e8f562..2f0e286f0 100644 --- a/packages/docs/packages/typeorm.md +++ b/packages/docs/packages/typeorm.md @@ -25,6 +25,8 @@ const [entities, total] = await queryBuilder.getManyAndCount(); The `queryBuilder` (the builder to write into) is bound at construction; `execute(query)` then walks the parsed `Query`, collects the state into its sub-adapters, and applies it to that builder — returning the applied pagination (e.g. for the response `meta` block). +Any `WHERE` conditions already present on the builder are preserved. Rapiq appends its filter tree with `AND`, so an application-owned tenant or authorization predicate remains the baseline even when the client sends no filters. + Construct the adapter **per request**, just like the `SelectQueryBuilder` you hand it — it holds per-call state. The shareable, long-lived part is your config, which you spread into the per-request options: ```typescript @@ -60,12 +62,12 @@ new TypeormAdapter({ | `relations.joinAndSelect` | Join **and select** (hydrate the related entities) instead of joining for filtering/sorting only. | | `relations.joinType` | `'left'` (default) or `'inner'`. Left joins keep records whose relation is absent. | | `relations.onJoin` | Invoked as `(path, alias, queryBuilder)` for every join the adapter applies — e.g. to `addGroupBy` per join when the root query is grouped. Skipped (pre-existing) joins don't trigger it. | -| `relations.relationAlias` | Derive the join alias for a relation path (default: the path with `.` replaced by `_`, e.g. `role.realm` → `role_realm`). Filter/sort/field references resolve against the same derivation. | +| `relations.relationAlias` | Derive the join alias for a relation path (default: collision-free length-prefixed segments, e.g. `role.realm` → `r4_role_5_realm`). Filter/sort/field references resolve against the same derivation. | Relations are validated against the entity metadata of the attached query builder — a requested relation that doesn't exist on the entity is ignored. Joins are applied idempotently: relations already joined on the query builder (by the adapter or by your own code, matched by alias) are skipped, so applying a query twice does not duplicate joins. ::: warning Alias convention -Joins are aliased by the **full relation path**, with `.` replaced by `_`: `realm` joins as alias `realm`, `role.realm` as `role_realm` — the same convention filter/sort/field references resolve against, so same-named relations on different branches never collide. Pre-existing joins are matched by that alias: joins you apply yourself under a different alias (e.g. `role.realm` as `realm`) are not recognized — either use the path-qualified alias or inject your own convention via `relations.relationAlias`. Make sure a custom derivation stays collision-free and within your database's identifier length limit. +The exported `buildRelationAlias(path)` helper length-prefixes every segment: `realm` becomes `r5_realm`, and `role.realm` becomes `r4_role_5_realm`. This remains distinct even from a relation literally named `role_realm`. Fields, filters, sorts and joins all use the same derivation. Pre-existing joins are matched by that alias; use the helper for joins you apply yourself, or inject one convention via `relations.relationAlias`. Keep a custom derivation collision-free and within your database's identifier length limit. ::: ## Dialect detection diff --git a/packages/memory/LICENSE b/packages/memory/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/memory/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/memory/package.json b/packages/memory/package.json index b30b3cd39..603ee9d5f 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -38,6 +38,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/memory/src/parameter/filters/compiler.ts b/packages/memory/src/parameter/filters/compiler.ts index 625fd87bc..7a7ebe87b 100644 --- a/packages/memory/src/parameter/filters/compiler.ts +++ b/packages/memory/src/parameter/filters/compiler.ts @@ -175,7 +175,7 @@ export class FiltersCompiler implements IFiltersVisitor, return this.leaf(expr.field, (value) => !test(value)); } - visitFilterRegex(expr: IFilter) : FilterCompileResult { + visitFilterRegex(expr: IFilter) : FilterCompileResult { return this.leaf(expr.field, this.buildRegexTest(this.buildRegex(expr.value))); } diff --git a/packages/parser-expression/LICENSE b/packages/parser-expression/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/parser-expression/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/parser-expression/package.json b/packages/parser-expression/package.json index ccf253fa2..9614ec511 100644 --- a/packages/parser-expression/package.json +++ b/packages/parser-expression/package.json @@ -37,6 +37,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/parser-expression/src/parameter/filters/constants.ts b/packages/parser-expression/src/parameter/filters/constants.ts index 6a17b8fdf..61a3617d1 100644 --- a/packages/parser-expression/src/parameter/filters/constants.ts +++ b/packages/parser-expression/src/parameter/filters/constants.ts @@ -28,5 +28,6 @@ export enum FilterTokenType { LPAREN = 'LPAREN', RPAREN = 'RPAREN', COMMA = 'COMMA', + DOT = 'DOT', EOF = 'EOF', } diff --git a/packages/parser-expression/src/parameter/filters/module.ts b/packages/parser-expression/src/parameter/filters/module.ts index e3241c78c..d6d7ba853 100644 --- a/packages/parser-expression/src/parameter/filters/module.ts +++ b/packages/parser-expression/src/parameter/filters/module.ts @@ -24,6 +24,8 @@ import { FiltersParseError, Parameter, ResolutionScope, + applyFiltersSchemaValidation, + applyFiltersSchemaValidationAsync, isFilters, } from '@rapiq/core'; import { parseFilterScalar } from '@rapiq/parser-simple'; @@ -32,6 +34,12 @@ import type { FilterToken } from './types'; type FiltersScope = ResolutionScope<`${Parameter.FILTERS}`>; +/** + * Keep recursive expression parsing below the JavaScript call-stack limit and + * aligned with the schema resolver and Mongo parser traversal caps. + */ +const MAX_DEPTH = 32; + /** * @see https://www.jsonapi.net/usage/reading/filtering.html */ @@ -72,6 +80,30 @@ export class ExpressionFiltersParser extends BaseParser< return new Filters(FilterCompoundOperator.AND, [expr]); } + override async parseAsync( + input: unknown, + options: FiltersParseOptions = {}, + ) : Promise { + if (input === undefined || input === null) { + const scope = ResolutionScope.for(this.registry, Parameter.FILTERS, options.schema, { relations: options.relations }) as FiltersScope; + + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + const expr = await this.parseExactAsync(input, options); + if ( + isFilters(expr, FilterCompoundOperator.AND) || + isFilters(expr, FilterCompoundOperator.OR) + ) { + return expr; + } + + return new Filters(FilterCompoundOperator.AND, [expr]); + } + parseExact( input: unknown, options: FiltersParseOptions = {}, @@ -100,7 +132,59 @@ export class ExpressionFiltersParser extends BaseParser< throw FiltersParseError.syntaxInvalid(`Unexpected token: ${this.peek().type}`); } - return expr; + if (!scope) { + return expr; + } + + const validated = applyFiltersSchemaValidation(expr, scope.schema); + if (!validated || (isFilters(validated) && validated.value.length === 0)) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + return validated; + } + + async parseExactAsync( + input: unknown, + options: FiltersParseOptions = {}, + ) : Promise { + if (typeof input !== 'string') { + throw FiltersParseError.inputInvalid(); + } + + this.pos = 0; + this.tokens = this.tokenize(input); + + let scope : FiltersScope | undefined; + if (options.schema || options.strict) { + scope = ResolutionScope.for(this.registry, Parameter.FILTERS, options.schema, { + relations: options.relations, + throwOnFailure: true, + strict: options.strict, + }) as FiltersScope; + } + + const expr = this.parseFilterExpression(scope); + if (this.peek().type !== FilterTokenType.EOF) { + throw FiltersParseError.syntaxInvalid(`Unexpected token: ${this.peek().type}`); + } + + if (!scope) { + return expr; + } + + const validated = await applyFiltersSchemaValidationAsync(expr, scope.schema); + if (!validated || (isFilters(validated) && validated.value.length === 0)) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + return validated; } // --------------------------------------------------------- @@ -137,12 +221,18 @@ export class ExpressionFiltersParser extends BaseParser< // keywords are classified from whole identifiers (switch below) — // matching them in the regex would split identifiers that merely // start with a keyword (e.g. "order" -> "or" + "der"). - const regex = /\s+|\(|\)|,|'(?:''|[^'])*'|[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9])?/g; + const regex = /\s+|\(|\)|,|\.|'(?:''|[^'])*'|[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?/g; let match: RegExpExecArray | null; - + let cursor = 0; + while ((match = regex.exec(input))) { + if (match.index !== cursor) { + throw FiltersParseError.syntaxInvalid(`Unexpected character at position ${cursor}.`); + } + const value = match[0]; + cursor = regex.lastIndex; if (/^\s+$/.test(value)) continue; switch (value) { @@ -163,6 +253,7 @@ export class ExpressionFiltersParser extends BaseParser< case '(': tokens.push({ type: FilterTokenType.LPAREN }); break; case ')': tokens.push({ type: FilterTokenType.RPAREN }); break; case ',': tokens.push({ type: FilterTokenType.COMMA }); break; + case '.': tokens.push({ type: FilterTokenType.DOT }); break; default: if ( value.startsWith('\'') && @@ -175,21 +266,30 @@ export class ExpressionFiltersParser extends BaseParser< } } - tokens.push({ type: 'EOF' }); + if (cursor !== input.length) { + throw FiltersParseError.syntaxInvalid(`Unexpected character at position ${cursor}.`); + } + + tokens.push({ type: FilterTokenType.EOF }); return tokens; } private parseFilterExpression( scope?: FiltersScope, negation: boolean = false, + depth: number = 0, ) : Filters | Filter { + if (depth > MAX_DEPTH) { + throw FiltersParseError.syntaxInvalid('The maximum nesting depth was exceeded.'); + } + const token = this.peek(); switch (token.type) { case FilterTokenType.NOT: - return this.parseNotExpression(scope, negation); + return this.parseNotExpression(scope, negation, depth); case FilterTokenType.AND: case FilterTokenType.OR: - return this.parseLogicalExpression(scope, negation); + return this.parseLogicalExpression(scope, negation, depth); case FilterTokenType.EQUAL: case FilterTokenType.GREATER_THAN: case FilterTokenType.GREATER_OR_EQUAL: @@ -211,10 +311,11 @@ export class ExpressionFiltersParser extends BaseParser< private parseNotExpression( scope?: FiltersScope, negation: boolean = false, + depth: number = 0, ): Filters | Filter { this.consume(FilterTokenType.NOT); this.consume(FilterTokenType.LPAREN); - const expr = this.parseFilterExpression(scope, !negation); + const expr = this.parseFilterExpression(scope, !negation, depth + 1); this.consume(FilterTokenType.RPAREN); return expr; @@ -223,6 +324,7 @@ export class ExpressionFiltersParser extends BaseParser< private parseLogicalExpression( scope?: FiltersScope, negation: boolean = false, + depth: number = 0, ): Filters | Filter { let op = this.consume().type; // AND / OR if (op !== FilterTokenType.AND && op !== FilterTokenType.OR) { @@ -230,10 +332,10 @@ export class ExpressionFiltersParser extends BaseParser< } this.consume(FilterTokenType.LPAREN); - const expressions: (Filter | Filters)[] = [this.parseFilterExpression(scope, negation)]; + const expressions: (Filter | Filters)[] = [this.parseFilterExpression(scope, negation, depth + 1)]; while (this.peek().type === FilterTokenType.COMMA) { this.consume(FilterTokenType.COMMA); - expressions.push(this.parseFilterExpression(scope, negation)); + expressions.push(this.parseFilterExpression(scope, negation, depth + 1)); } this.consume(FilterTokenType.RPAREN); @@ -454,7 +556,8 @@ export class ExpressionFiltersParser extends BaseParser< const parts = [token.value!]; - while (this.peek().type === FilterTokenType.FIELD) { + while (this.peek().type === FilterTokenType.DOT) { + this.consume(FilterTokenType.DOT); parts.push(this.consume(FilterTokenType.FIELD).value!); } diff --git a/packages/parser-expression/test/unit/parser/filters.spec.ts b/packages/parser-expression/test/unit/parser/filters.spec.ts index 0751ee65c..f06b320b8 100644 --- a/packages/parser-expression/test/unit/parser/filters.spec.ts +++ b/packages/parser-expression/test/unit/parser/filters.spec.ts @@ -170,6 +170,38 @@ describe('filters/expr-parser', () => { expect(output).toEqual(new Filter(FilterFieldOperator.GREATER_THAN, 'inventory', 5)); }); + it('should preserve a leading underscore in a field name', () => { + const output = parser.parseExact('eq(_id, \'value\')'); + + expect(output).toEqual(new Filter(FilterFieldOperator.EQUAL, '_id', 'value')); + }); + + it.each([ + '!eq(id, \'value\')', + 'eq(id, \'value\')!', + 'eq(id, \'value\') @', + ])('should reject unmatched source characters in %s', (input) => { + expect(() => parser.parseExact(input)).toThrow(FiltersParseError); + + try { + parser.parseExact(input); + } catch (error) { + expect((error as FiltersParseError).code).toEqual(ErrorCode.SYNTAX_INVALID); + } + }); + + it('should reject excessive nesting with a typed syntax error', () => { + const input = `${'not('.repeat(40)}eq(id, 'value')${')'.repeat(40)}`; + + expect(() => parser.parseExact(input)).toThrow(FiltersParseError); + + try { + parser.parseExact(input); + } catch (error) { + expect((error as FiltersParseError).code).toEqual(ErrorCode.SYNTAX_INVALID); + } + }); + it('should throw a typed error on invalid syntax', () => { let error : unknown; try { @@ -192,6 +224,50 @@ describe('filters/expr-parser', () => { ])); }); + it('should apply the schema validator without changing compound structure', () => { + const schema = defineFiltersSchema({ + validate: (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }); + + const output = parser.parse('or(eq(name, \'admin\'), eq(age, \'18\'))', { schema }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + + it('should await an asynchronous schema validator through parseAsync', async () => { + const schema = defineFiltersSchema({ + validate: async (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }); + + const output = await parser.parseAsync( + 'or(eq(name, \'admin\'), eq(age, \'18\'))', + { schema }, + ); + + expect(output).toEqual(new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + + it('should apply schema defaults when validation rejects every filter', () => { + const schema = defineFiltersSchema({ + default: new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + validate: () => undefined, + }); + + const output = parser.parse('eq(name, \'admin\')', { schema }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + ])); + }); + it('should treat an empty string as invalid input, not as absent', () => { let error : unknown; try { diff --git a/packages/parser-mongo/LICENSE b/packages/parser-mongo/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/parser-mongo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/parser-mongo/package.json b/packages/parser-mongo/package.json index b3ac4bf8b..029598340 100644 --- a/packages/parser-mongo/package.json +++ b/packages/parser-mongo/package.json @@ -37,6 +37,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/parser-mongo/src/parameter/filters/module.ts b/packages/parser-mongo/src/parameter/filters/module.ts index 1532ee10c..6d2909afa 100644 --- a/packages/parser-mongo/src/parameter/filters/module.ts +++ b/packages/parser-mongo/src/parameter/filters/module.ts @@ -24,6 +24,8 @@ import { Parameter, ParseError, ResolutionScope, + applyFiltersSchemaValidation, + applyFiltersSchemaValidationAsync, isFilters, isObject, } from '@rapiq/core'; @@ -111,14 +113,78 @@ export class MongoFiltersParser extends BaseParser< ); } - // an explicit root compound is returned as-is; - // everything else wraps in a root AND. + // An explicit root compound is returned as-is; everything else wraps + // in a root AND. Validation runs over that final tree so replacement + // and rejection semantics are identical across parser dialects. const [first] = conditions; - if (conditions.length === 1 && first && isFilters(first)) { - return first; + const parsed = conditions.length === 1 && first && isFilters(first) ? + first : + new Filters(FilterCompoundOperator.AND, conditions); + + const validated = applyFiltersSchemaValidation(parsed, scope.schema); + if (!validated || (isFilters(validated) && validated.value.length === 0)) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + return validated as IFilters; + } + + override async parseAsync( + input: unknown, + options: FiltersParseOptions = {}, + ) : Promise { + const scope = ResolutionScope.for(this.registry, Parameter.FILTERS, options.schema, { + relations: options.relations, + throwOnFailure: options.throwOnFailure, + strict: options.strict, + }) as FiltersScope; + + if (typeof input === 'undefined' || input === null) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + if (!isPlainObject(input)) { + throw FiltersParseError.inputInvalid(); + } + + if ( + !scope.schema.allowedIsUndefined && + scope.schema.allowed.length === 0 + ) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + const conditions = this.parseDocument(input, scope, false, 0); + if (conditions.length === 0) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); + } + + const [first] = conditions; + const parsed = conditions.length === 1 && first && isFilters(first) ? + first : + new Filters(FilterCompoundOperator.AND, conditions); + + const validated = await applyFiltersSchemaValidationAsync(parsed, scope.schema); + if (!validated || (isFilters(validated) && validated.value.length === 0)) { + return new Filters( + FilterCompoundOperator.AND, + this.buildDefaults(scope.schema), + ); } - return new Filters(FilterCompoundOperator.AND, conditions); + return validated as IFilters; } parseTyped( @@ -128,6 +194,13 @@ export class MongoFiltersParser extends BaseParser< return this.parse(input, options); } + parseTypedAsync( + input: MongoFiltersParserInput, + options: FiltersParseOptions = {}, + ) : Promise { + return this.parseAsync(input, options); + } + // --------------------------------------------------------- protected buildDefaults(schema: FiltersSchema) : ICondition[] { diff --git a/packages/parser-mongo/test/unit/parser/filters.spec.ts b/packages/parser-mongo/test/unit/parser/filters.spec.ts index 96e129ff3..45a56901c 100644 --- a/packages/parser-mongo/test/unit/parser/filters.spec.ts +++ b/packages/parser-mongo/test/unit/parser/filters.spec.ts @@ -810,6 +810,52 @@ describe('filters/mongo-parser', () => { }); }); + describe('schema validation', () => { + it('should replace and reject leaves without changing compound structure', () => { + const schema = defineFiltersSchema({ + validate: (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }); + + const output = parser.parse({ $or: [{ name: 'admin' }, { age: 18 }] }, { schema }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + + it('should await an asynchronous schema validator through parseAsync', async () => { + const schema = defineFiltersSchema({ + validate: async (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }); + + const output = await parser.parseAsync( + { $or: [{ name: 'admin' }, { age: 18 }] }, + { schema }, + ); + + expect(output).toEqual(new Filters(FilterCompoundOperator.OR, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + + it('should apply schema defaults when validation rejects every filter', () => { + const schema = defineFiltersSchema({ + default: new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + validate: () => undefined, + }); + + const output = parser.parse({ name: 'admin' }, { schema }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + ])); + }); + }); + describe('strict mode', () => { it('should drop any key when parsing schemaless with the strict option', () => { const output = parser.parse({ name: 'x' }, { strict: true }); diff --git a/packages/parser-simple/LICENSE b/packages/parser-simple/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/parser-simple/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/parser-simple/package.json b/packages/parser-simple/package.json index d81fb503a..d8091c41f 100644 --- a/packages/parser-simple/package.json +++ b/packages/parser-simple/package.json @@ -35,6 +35,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", @@ -53,7 +57,7 @@ "repository": { "type": "git", "url": "git+https://github.com/Tada5hi/rapiq.git", - "directory": "packages/codec" + "directory": "packages/parser-simple" }, "bugs": { "url": "https://github.com/Tada5hi/rapiq/issues" diff --git a/packages/parser-simple/src/parameter/fields/module.ts b/packages/parser-simple/src/parameter/fields/module.ts index 805f1237f..c5c3f829e 100644 --- a/packages/parser-simple/src/parameter/fields/module.ts +++ b/packages/parser-simple/src/parameter/fields/module.ts @@ -38,6 +38,15 @@ export class SimpleFieldsParser extends BaseParser( + input: unknown, + options: SimpleFieldsParseOptions = {}, + ) : Promise { + return this.parse(input, options); + } + protected parseWithScope< RECORD extends ObjectLiteral = ObjectLiteral, >(input: unknown, scope: ResolutionScope<`${Parameter.FIELDS}`, RECORD>) : IFields { diff --git a/packages/parser-simple/src/parameter/filters/module.ts b/packages/parser-simple/src/parameter/filters/module.ts index 9468bc9c4..605453f13 100644 --- a/packages/parser-simple/src/parameter/filters/module.ts +++ b/packages/parser-simple/src/parameter/filters/module.ts @@ -14,6 +14,8 @@ import { FiltersParseError, Parameter, ResolutionScope, + applyFiltersSchemaValidation, + applyFiltersSchemaValidationAsync, isObject, parseKey, stringifyKey, @@ -50,6 +52,39 @@ export class SimpleFiltersParser extends BaseParser< let items: ICondition[] = this.run(input, scope); + if (items.length > 0) { + items = items + .map((item) => applyFiltersSchemaValidation(item, scope.schema)) + .filter((item): item is ICondition => typeof item !== 'undefined'); + } + + if (items.length === 0) { + items = this.buildDefaults(scope.schema); + } + + return new Filters(FilterCompoundOperator.AND, items); + } + + override async parseAsync( + input: unknown, + options: FiltersParseOptions = {}, + ) : Promise { + const scope = ResolutionScope.for(this.registry, Parameter.FILTERS, options.schema, { + relations: options.relations, + throwOnFailure: options.throwOnFailure, + strict: options.strict, + }); + + let items: ICondition[] = []; + const parsed = this.run(input, scope); + + for (const item of parsed) { + const validated = await applyFiltersSchemaValidationAsync(item, scope.schema); + if (validated) { + items.push(validated); + } + } + if (items.length === 0) { items = this.buildDefaults(scope.schema); } @@ -64,6 +99,13 @@ export class SimpleFiltersParser extends BaseParser< return this.parse(input, options); } + parseTypedAsync( + input: SimpleFiltersParserInput, + options: FiltersParseOptions = {}, + ) : Promise { + return this.parseAsync(input, options); + } + protected run( input: unknown, scope: ResolutionScope<`${Parameter.FILTERS}`, RECORD>, diff --git a/packages/parser-simple/src/parameter/pagination/module.ts b/packages/parser-simple/src/parameter/pagination/module.ts index 7a61a2d1c..04a0dbab8 100644 --- a/packages/parser-simple/src/parameter/pagination/module.ts +++ b/packages/parser-simple/src/parameter/pagination/module.ts @@ -74,6 +74,15 @@ export class SimplePaginationParser< return this.finalizePagination(output, schema, throwOnFailure); } + override async parseAsync< + RECORD extends ObjectLiteral = ObjectLiteral, + >( + input: unknown, + options: PaginationParseOptions = {}, + ) : Promise { + return this.parse(input, options); + } + protected finalizePagination( data: Pagination, schema: PaginationSchema, diff --git a/packages/parser-simple/src/parameter/relations/module.ts b/packages/parser-simple/src/parameter/relations/module.ts index 169a6b432..4b388fc8b 100644 --- a/packages/parser-simple/src/parameter/relations/module.ts +++ b/packages/parser-simple/src/parameter/relations/module.ts @@ -42,6 +42,15 @@ export class SimpleRelationsParser extends BaseParser< return this.parseWithScope(input, scope); } + override async parseAsync< + RECORD extends ObjectLiteral = ObjectLiteral, + >( + input: unknown, + options: RelationsParseOptions = {}, + ) : Promise { + return this.parse(input, options); + } + protected parseWithScope< RECORD extends ObjectLiteral = ObjectLiteral, >(input: unknown, scope: ResolutionScope<`${Parameter.RELATIONS}`, RECORD>) : Relations { diff --git a/packages/parser-simple/src/parameter/sorts/module.ts b/packages/parser-simple/src/parameter/sorts/module.ts index c47466d67..4b4d0a0e9 100644 --- a/packages/parser-simple/src/parameter/sorts/module.ts +++ b/packages/parser-simple/src/parameter/sorts/module.ts @@ -37,6 +37,15 @@ export class SimpleSortParser extends BaseParser { return this.parseWithScope(input, scope); } + override async parseAsync< + RECORD extends ObjectLiteral = ObjectLiteral, + >( + input: unknown, + options: SortParseOptions = {}, + ) : Promise { + return this.parse(input, options); + } + protected parseWithScope< RECORD extends ObjectLiteral = ObjectLiteral, >(input: unknown, scope: ResolutionScope<`${Parameter.SORT}`, RECORD>) : Sorts { diff --git a/packages/parser-simple/test/unit/parser/filters.spec.ts b/packages/parser-simple/test/unit/parser/filters.spec.ts index ce955de9d..7f4f918f1 100644 --- a/packages/parser-simple/test/unit/parser/filters.spec.ts +++ b/packages/parser-simple/test/unit/parser/filters.spec.ts @@ -84,6 +84,47 @@ describe('src/filter/index.ts', () => { ); }); + it('should apply the schema validator to parsed filters', () => { + const output = parseFlat({ name: 'admin', age: 18 }, { + schema: defineFiltersSchema({ + validate: (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }), + }); + + expect(output).toEqual( + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ); + }); + + it('should await an asynchronous schema validator through parseAsync', async () => { + const output = await parser.parseAsync({ name: 'admin', age: 18 }, { + schema: defineFiltersSchema({ + validate: async (filter) => filter.field === 'name' ? + new Filter(filter.operator, filter.field, String(filter.value).toUpperCase()) : + undefined, + }), + }); + + expect(output).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + + it('should apply schema defaults when validation rejects every filter', () => { + const output = parseFlat({ name: 'admin' }, { + schema: defineFiltersSchema({ + default: new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + validate: () => undefined, + }), + }); + + expect(output).toEqual( + new Filter(FilterFieldOperator.EQUAL, 'status', 'active'), + ); + }); + it('should keep the full path of a dotted mapping target', async () => { // the alias expands to a relation path — the leaf validates // against the related (realm) schema and keeps its full path. @@ -105,6 +146,14 @@ describe('src/filter/index.ts', () => { expect(output).toEqual(new Filter(FilterFieldOperator.EQUAL, 'name', 'admin')); }); + it('should parse nested objects as dotted field paths', () => { + const output = parseFlat({ realm: { name: 'master' } }); + + expect(output).toEqual( + new Filter(FilterFieldOperator.EQUAL, 'realm.name', 'master'), + ); + }); + it('should not parse with non matching name', async () => { // filter wrong allowed const output = parseFlat({ id: 1 }, { schema: defineFiltersSchema({ allowed: ['name'] }) }); diff --git a/packages/parser-simple/test/unit/parser/parser.spec.ts b/packages/parser-simple/test/unit/parser/parser.spec.ts index a2d192459..d0d7622c9 100644 --- a/packages/parser-simple/test/unit/parser/parser.spec.ts +++ b/packages/parser-simple/test/unit/parser/parser.spec.ts @@ -66,6 +66,25 @@ describe('src/parser', () => { expect(output.pagination.offset).toEqual(0); }); + it('should await asynchronous filter validation in full-query parsing', async () => { + const parser = new SimpleParser(); + const schema = defineSchema({ + filters: { + validate: async (filter) => new Filter( + filter.operator, + filter.field, + String(filter.value).toUpperCase(), + ), + }, + }); + + const output = await parser.parseAsync({ filters: { name: 'admin' } }, { schema }); + + expect(output.filters).toEqual(new Filters(FilterCompoundOperator.AND, [ + new Filter(FilterFieldOperator.EQUAL, 'name', 'ADMIN'), + ])); + }); + describe('schema defaults', () => { const registry = new SchemaRegistry(); registry.add(defineSchema({ diff --git a/packages/sql/LICENSE b/packages/sql/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/sql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/sql/package.json b/packages/sql/package.json index ab56a9cd2..a16fdafe1 100644 --- a/packages/sql/package.json +++ b/packages/sql/package.json @@ -35,6 +35,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [ "query", "json", diff --git a/packages/sql/src/adapter/module.ts b/packages/sql/src/adapter/module.ts index 6e50b306e..fbe11997a 100644 --- a/packages/sql/src/adapter/module.ts +++ b/packages/sql/src/adapter/module.ts @@ -25,7 +25,8 @@ export type AdapterOptions = DialectOptions & { /** * Derive the join alias for a relation path - * (default: `path.replace('.', '_')`, e.g. `role.realm` -> `role_realm`). + * (default: length-prefixed segments, e.g. `role.realm` -> + * `r4_role_5_realm`). */ relationAlias?: RelationAliasFn, }; diff --git a/packages/sql/src/adapter/relations/base.ts b/packages/sql/src/adapter/relations/base.ts index fa8f28e29..aba2be363 100644 --- a/packages/sql/src/adapter/relations/base.ts +++ b/packages/sql/src/adapter/relations/base.ts @@ -38,7 +38,8 @@ export abstract class RelationsBaseAdapter implements IRelationsAdapter { // ----------------------------------------------------------- /** - * Join alias for a relation path (e.g. `role.realm` -> `role_realm`). + * Join alias for a relation path (e.g. `role.realm` -> + * `r4_role_5_realm`). * The single derivation point shared by join application and the * field references built by the fields/filters/sort adapters. */ diff --git a/packages/sql/src/adapter/relations/types.ts b/packages/sql/src/adapter/relations/types.ts index ffeee5bc6..93e416d9d 100644 --- a/packages/sql/src/adapter/relations/types.ts +++ b/packages/sql/src/adapter/relations/types.ts @@ -27,7 +27,7 @@ export type RelationsAdapterBaseOptions = { /** * Derive the join alias for a relation path - * (e.g. `role.realm` -> `role_realm`). + * (e.g. `role.realm` -> `r4_role_5_realm`). * Field references in filters/sort/fields resolve against the * same derivation, so it must be injected once, on the relations * adapter shared by all sub-adapters. diff --git a/packages/sql/src/dialect/oracle.ts b/packages/sql/src/dialect/oracle.ts index 3be0267d9..9a3d9ada7 100644 --- a/packages/sql/src/dialect/oracle.ts +++ b/packages/sql/src/dialect/oracle.ts @@ -8,10 +8,9 @@ import type { DialectOptions } from './types'; export const oracle : DialectOptions = { - regexp: (field, placeholder, ignoreCase) => { - const operator = ignoreCase ? '~*' : '~'; - return `${field} ${operator} ${placeholder}`; - }, + regexp: (field, placeholder, ignoreCase) => ignoreCase ? + `regexp_like(${field}, ${placeholder}, 'i')` : + `regexp_like(${field}, ${placeholder})`, escapeField: (field: string) => `"${field}"`, - paramPlaceholder: (index) => `$${index}`, + paramPlaceholder: (index) => `:${index}`, }; diff --git a/packages/sql/src/dialect/pg.ts b/packages/sql/src/dialect/pg.ts index e48abec48..dd7cab3fb 100644 --- a/packages/sql/src/dialect/pg.ts +++ b/packages/sql/src/dialect/pg.ts @@ -5,7 +5,13 @@ * view the LICENSE file that was distributed with this source code. */ -import { oracle } from './oracle'; import type { DialectOptions } from './types'; -export const pg : DialectOptions = { ...oracle }; +export const pg : DialectOptions = { + regexp: (field, placeholder, ignoreCase) => { + const operator = ignoreCase ? '~*' : '~'; + return `${field} ${operator} ${placeholder}`; + }, + escapeField: (field: string) => `"${field}"`, + paramPlaceholder: (index) => `$${index}`, +}; diff --git a/packages/sql/src/helpers/relation-alias.ts b/packages/sql/src/helpers/relation-alias.ts index caa6ea52c..7911f11b0 100644 --- a/packages/sql/src/helpers/relation-alias.ts +++ b/packages/sql/src/helpers/relation-alias.ts @@ -8,10 +8,11 @@ export type RelationAliasFn = (path: string) => string; /** - * Default join-alias derivation: the full relation path with `.` - * replaced by `_` (e.g. `role.realm` -> `role_realm`), so relation - * paths ending in the same segment never share an alias. + * Default join-alias derivation: every path segment is length-prefixed + * (e.g. `role.realm` -> `r4_role_5_realm`). Unlike replacing dots with + * underscores, this is injective even when relation names contain `_`. */ export function buildRelationAlias(path: string) : string { - return path.replace(/\./g, '_'); + const segments = path.split('.'); + return `r${segments.map((segment) => `${segment.length}_${segment}`).join('_')}`; } diff --git a/packages/sql/src/visitor/filters.ts b/packages/sql/src/visitor/filters.ts index 2fa35025b..5a427eb36 100644 --- a/packages/sql/src/visitor/filters.ts +++ b/packages/sql/src/visitor/filters.ts @@ -147,14 +147,18 @@ export class FiltersVisitor implements IFiltersVisitor, return this.whereAnchored(expr.field, expr.value, FilterRegexFlag.CONTAINS | FilterRegexFlag.NEGATION); } - visitFilterRegex(expr: Filter): IFiltersAdapter { + visitFilterRegex(expr: Filter): IFiltersAdapter { + const isRegExp = expr.value instanceof RegExp; + const source = isRegExp ? expr.value.source : expr.value; + const ignoreCase = isRegExp ? expr.value.ignoreCase : false; + const sql = this.adapter.regexp( this.adapter.buildField(expr.field), this.adapter.buildParamPlaceholder(), - expr.value.ignoreCase, + ignoreCase, ); - return this.adapter.whereRaw(sql, expr.value.source); + return this.adapter.whereRaw(sql, source); } visitFilters(expr: Filters): IFiltersAdapter { diff --git a/packages/sql/test/unit/adapter.spec.ts b/packages/sql/test/unit/adapter.spec.ts index 3bfd23d22..971652b5a 100644 --- a/packages/sql/test/unit/adapter.spec.ts +++ b/packages/sql/test/unit/adapter.spec.ts @@ -58,28 +58,28 @@ describe('src/adapter/module.ts', () => { // case-sensitive (pg, oracle, sqlite); mysql/mssql default collations // already compare case-insensitively, so their presets skip it. ['pg', pg, { - columns: ['"user"."id"', '"user"."name"', '"realm"."name"'], - where: '("user"."age" >= $1 and (lower("realm"."id") in(lower($2)) or "realm"."id" is null))', + columns: ['"user"."id"', '"user"."name"', '"r5_realm"."name"'], + where: '("user"."age" >= $1 and (lower("r5_realm"."id") in(lower($2)) or "r5_realm"."id" is null))', orderBy: ['"user"."age" DESC'], }], ['oracle', oracle, { - columns: ['"user"."id"', '"user"."name"', '"realm"."name"'], - where: '("user"."age" >= $1 and (lower("realm"."id") in(lower($2)) or "realm"."id" is null))', + columns: ['"user"."id"', '"user"."name"', '"r5_realm"."name"'], + where: '("user"."age" >= :1 and (lower("r5_realm"."id") in(lower(:2)) or "r5_realm"."id" is null))', orderBy: ['"user"."age" DESC'], }], ['mysql', mysql, { - columns: ['`user`.`id`', '`user`.`name`', '`realm`.`name`'], - where: '(`user`.`age` >= ? and (`realm`.`id` in(?) or `realm`.`id` is null))', + columns: ['`user`.`id`', '`user`.`name`', '`r5_realm`.`name`'], + where: '(`user`.`age` >= ? and (`r5_realm`.`id` in(?) or `r5_realm`.`id` is null))', orderBy: ['`user`.`age` DESC'], }], ['sqlite', sqlite, { - columns: ['`user`.`id`', '`user`.`name`', '`realm`.`name`'], - where: '(`user`.`age` >= ? and (lower(`realm`.`id`) in(lower(?)) or `realm`.`id` is null))', + columns: ['`user`.`id`', '`user`.`name`', '`r5_realm`.`name`'], + where: '(`user`.`age` >= ? and (lower(`r5_realm`.`id`) in(lower(?)) or `r5_realm`.`id` is null))', orderBy: ['`user`.`age` DESC'], }], ['mssql', mssql, { - columns: ['[user].[id]', '[user].[name]', '[realm].[name]'], - where: '([user].[age] >= ? and ([realm].[id] in(?) or [realm].[id] is null))', + columns: ['[user].[id]', '[user].[name]', '[r5_realm].[name]'], + where: '([user].[age] >= ? and ([r5_realm].[id] in(?) or [r5_realm].[id] is null))', orderBy: ['[user].[age] DESC'], }], ]; @@ -102,8 +102,8 @@ describe('src/adapter/module.ts', () => { const adapter = new Adapter(pg); const fragments = adapter.execute(buildQuery()); - expect(fragments.columns).toEqual(['"id"', '"name"', '"realm"."name"']); - expect(fragments.where).toEqual('("age" >= $1 and (lower("realm"."id") in(lower($2)) or "realm"."id" is null))'); + expect(fragments.columns).toEqual(['"id"', '"name"', '"r5_realm"."name"']); + expect(fragments.where).toEqual('("age" >= $1 and (lower("r5_realm"."id") in(lower($2)) or "r5_realm"."id" is null))'); }); it('should drop excluded fields from the columns', () => { @@ -154,12 +154,29 @@ describe('src/adapter/module.ts', () => { const fragments = adapter.execute(query); - expect(fragments.columns).toEqual(['"realm"."name"', '"role_realm"."name"']); - expect(fragments.where).toEqual('lower("role_realm"."name") = lower($1)'); - expect(fragments.orderBy).toEqual(['"role_realm"."name" ASC']); + expect(fragments.columns).toEqual(['"r5_realm"."name"', '"r4_role_5_realm"."name"']); + expect(fragments.where).toEqual('lower("r4_role_5_realm"."name") = lower($1)'); + expect(fragments.orderBy).toEqual(['"r4_role_5_realm"."name" ASC']); expect(fragments.relations).toEqual(['realm', 'role', 'role.realm']); }); + it('should not collide when a relation name contains underscores', () => { + const adapter = new Adapter({ ...pg, rootAlias: 'user' }); + const query = new Query({ + fields: new Fields([ + new Field('role_realm.name'), + new Field('role.realm.name'), + ]), + }); + + const fragments = adapter.execute(query); + + expect(fragments.columns).toEqual([ + '"r10_role_realm"."name"', + '"r4_role_5_realm"."name"', + ]); + }); + it('should derive relation aliases via a custom function', () => { const adapter = new Adapter({ ...pg, diff --git a/packages/sql/test/unit/interpreters/elem-match.spec.ts b/packages/sql/test/unit/interpreters/elem-match.spec.ts index 74f6cd157..a51caa8f4 100644 --- a/packages/sql/test/unit/interpreters/elem-match.spec.ts +++ b/packages/sql/test/unit/interpreters/elem-match.spec.ts @@ -41,7 +41,7 @@ describe('elemMatch', () => { const [sql, params] = adapter.getQueryAndParameters(); - expect(sql).toEqual('"projects"."active" = $1'); + expect(sql).toEqual('"r8_projects"."active" = $1'); expect(params).toStrictEqual([true]); }); @@ -59,7 +59,7 @@ describe('elemMatch', () => { const [sql, params] = adapter.getQueryAndParameters(); - expect(sql).toEqual('"items_parts"."id" = $1'); + expect(sql).toEqual('"r5_items_5_parts"."id" = $1'); expect(params).toStrictEqual([7]); // the inner interior binds relative to the OUTER element — @@ -80,7 +80,7 @@ describe('elemMatch', () => { const [sql, params] = adapter.getQueryAndParameters(); - expect(sql).toEqual('("projects"."count" > $1 and "projects"."count" < $2)'); + expect(sql).toEqual('("r8_projects"."count" > $1 and "r8_projects"."count" < $2)'); expect(params).toStrictEqual([5, 10]); }); }); diff --git a/packages/sql/test/unit/interpreters/regex.spec.ts b/packages/sql/test/unit/interpreters/regex.spec.ts index eb869f2b9..98d9f9d3b 100644 --- a/packages/sql/test/unit/interpreters/regex.spec.ts +++ b/packages/sql/test/unit/interpreters/regex.spec.ts @@ -35,7 +35,31 @@ describe('regex', () => { expect(params).toStrictEqual([condition.value.source]); }); - it('generates posix operator for Oracle', () => { + it('passes a string regex pattern through for PostgresSQL', () => { + const adapter = new FiltersAdapter(new RelationsAdapter(), pg); + const visitor = new FiltersVisitor(adapter); + + new Filter('regex', 'email', '@example\\.com$').accept(visitor); + + expect(adapter.getQueryAndParameters()).toEqual([ + '"email" ~ $1', + ['@example\\.com$'], + ]); + }); + + it('leaves string regex validation to the database', () => { + const adapter = new FiltersAdapter(new RelationsAdapter(), pg); + const visitor = new FiltersVisitor(adapter); + + new Filter('regex', 'email', '(').accept(visitor); + + expect(adapter.getQueryAndParameters()).toEqual([ + '"email" ~ $1', + ['('], + ]); + }); + + it('generates REGEXP_LIKE for Oracle', () => { const relationsAdapter = new RelationsAdapter(); const adapter = new FiltersAdapter( relationsAdapter, @@ -48,10 +72,22 @@ describe('regex', () => { const [sql, params] = adapter.getQueryAndParameters(); - expect(sql).toEqual('"email" ~ $1'); + expect(sql).toEqual('regexp_like("email", :1)'); expect(params).toStrictEqual([condition.value.source]); }); + it('passes the case-insensitive match parameter to Oracle', () => { + const adapter = new FiltersAdapter(new RelationsAdapter(), oracle); + const visitor = new FiltersVisitor(adapter); + + new Filter('regex', 'email', /@/i).accept(visitor); + + expect(adapter.getQueryAndParameters()).toEqual([ + 'regexp_like("email", :1, \'i\')', + ['@'], + ]); + }); + it('generates call to `REGEXP` function for MySQL', () => { const relationsAdapter = new RelationsAdapter(); const adapter = new FiltersAdapter( diff --git a/packages/sql/test/unit/interpreters/relation-nested.spec.ts b/packages/sql/test/unit/interpreters/relation-nested.spec.ts index 36e961687..1fd58a7bc 100644 --- a/packages/sql/test/unit/interpreters/relation-nested.spec.ts +++ b/packages/sql/test/unit/interpreters/relation-nested.spec.ts @@ -48,8 +48,8 @@ describe('auto join', () => { const [sql] = adapter.getQueryAndParameters(); - expect(sql).toEqual('lower("projects_user"."name") = lower($1)'); - expect(options.escapeField).toHaveBeenCalledWith('projects_user'); + expect(sql).toEqual('lower("r8_projects_4_user"."name") = lower($1)'); + expect(options.escapeField).toHaveBeenCalledWith('r8_projects_4_user'); spy.restore(options, 'escapeField'); }); }); diff --git a/packages/sql/test/unit/interpreters/relation.spec.ts b/packages/sql/test/unit/interpreters/relation.spec.ts index d2dc3e182..a873befa4 100644 --- a/packages/sql/test/unit/interpreters/relation.spec.ts +++ b/packages/sql/test/unit/interpreters/relation.spec.ts @@ -47,7 +47,7 @@ describe('auto join', () => { const [sql] = adapter.getQueryAndParameters(); - expect(sql).toEqual('lower("projects"."name") = lower($1)'); - expect(options.escapeField).toHaveBeenCalledWith('projects'); + expect(sql).toEqual('lower("r8_projects"."name") = lower($1)'); + expect(options.escapeField).toHaveBeenCalledWith('r8_projects'); }); }); diff --git a/packages/typeorm/LICENSE b/packages/typeorm/LICENSE new file mode 100644 index 000000000..5706e334a --- /dev/null +++ b/packages/typeorm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2026 Peter Placzek + +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/packages/typeorm/package.json b/packages/typeorm/package.json index 86c402a29..470f3d4b6 100644 --- a/packages/typeorm/package.json +++ b/packages/typeorm/package.json @@ -47,6 +47,10 @@ "url": "https://github.com/tada5hi" }, "license": "MIT", + "publishConfig": { + "access": "public", + "tag": "beta" + }, "keywords": [], "repository": { "type": "git", diff --git a/packages/typeorm/src/adapter/filters.ts b/packages/typeorm/src/adapter/filters.ts index c3c5bef87..c97e170f3 100644 --- a/packages/typeorm/src/adapter/filters.ts +++ b/packages/typeorm/src/adapter/filters.ts @@ -147,6 +147,11 @@ export class FiltersAdapter extends FiltersBaseAdapter { execute() { const [sql, params] = this.getQueryAndParameters(); - this.queryBuilder.where(sql, params); + if (sql) { + // The builder may already carry an application-owned predicate + // (for example a tenant or authorization scope). Rapiq filters + // narrow that query; they must never replace its baseline WHERE. + this.queryBuilder.andWhere(sql, params); + } } } diff --git a/packages/typeorm/test/unit/acceptance.spec.ts b/packages/typeorm/test/unit/acceptance.spec.ts index 195bbfef7..5b4c72f6f 100644 --- a/packages/typeorm/test/unit/acceptance.spec.ts +++ b/packages/typeorm/test/unit/acceptance.spec.ts @@ -123,7 +123,7 @@ describe('acceptance: authup-style repository port (M2 gate)', () => { // relations join left (not inner) and honor the groupBy hook expect(sql).toContain('LEFT JOIN'); expect(sql).not.toContain('INNER JOIN'); - expect(sql).toContain('GROUP BY "user"."id", "role"."id"'); + expect(sql).toContain('GROUP BY "user"."id", "r4_role"."id"'); // email is selected only because the client opted in expect(sql).toContain('"user"."email"'); diff --git a/packages/typeorm/test/unit/adapter/module.spec.ts b/packages/typeorm/test/unit/adapter/module.spec.ts index 7858cbda1..9e23e0760 100644 --- a/packages/typeorm/test/unit/adapter/module.spec.ts +++ b/packages/typeorm/test/unit/adapter/module.spec.ts @@ -69,10 +69,11 @@ describe('src/adapter/module.ts', () => { expect(queryBuilder.expressionMap.skip).toBeUndefined(); }); - it('should reset a stale where on a re-run whose query drops filters', () => { + it('should preserve a caller-owned where clause when applying filters', () => { const queryBuilder = dataSource .getRepository(User) - .createQueryBuilder('user'); + .createQueryBuilder('user') + .where('user.id = :actorId', { actorId: 1 }); const adapter = new TypeormAdapter({ queryBuilder }); @@ -81,12 +82,22 @@ describe('src/adapter/module.ts', () => { new Filter(FilterFieldOperator.EQUAL, 'age', 18), ]), })); - expect(queryBuilder.getSql()).toContain('WHERE'); - // the unconditional where('') call is what resets the builder here: - // typeorm clears expressionMap.wheres before adding a condition and - // skips empty ones, so no dangling WHERE is emitted either + const sql = queryBuilder.getSql(); + expect(sql).toContain('WHERE "user"."id" = 1 AND'); + expect(sql).toContain('"user"."age" = 18'); + }); + + it('should preserve a caller-owned where clause for an empty query', () => { + const queryBuilder = dataSource + .getRepository(User) + .createQueryBuilder('user') + .where('user.id = :actorId', { actorId: 1 }); + + const adapter = new TypeormAdapter({ queryBuilder }); + adapter.execute(new Query()); - expect(queryBuilder.getSql()).not.toContain('WHERE'); + + expect(queryBuilder.getSql()).toContain('WHERE "user"."id" = 1'); }); }); diff --git a/packages/typeorm/test/unit/adapter/relations.spec.ts b/packages/typeorm/test/unit/adapter/relations.spec.ts index cf8260fe0..7cc373ba2 100644 --- a/packages/typeorm/test/unit/adapter/relations.spec.ts +++ b/packages/typeorm/test/unit/adapter/relations.spec.ts @@ -66,7 +66,7 @@ describe('src/adapter/relations.ts', () => { expect(queryBuilder.getSql()).toContain('LEFT JOIN'); expect( queryBuilder.expressionMap.selects.some( - (select) => select.selection === 'realm', + (select) => select.selection === 'r5_realm', ), ).toBeTruthy(); }); @@ -83,7 +83,7 @@ describe('src/adapter/relations.ts', () => { it('should skip pre-existing joins', () => { const { queryBuilder, adapter } = setup(); - queryBuilder.leftJoinAndSelect('user.realm', 'realm'); + queryBuilder.leftJoinAndSelect('user.realm', 'r5_realm'); visit(adapter, 'realm'); adapter.relations.execute(); @@ -103,8 +103,8 @@ describe('src/adapter/relations.ts', () => { (join) => [join.entityOrProperty, join.alias.name], ); expect(joins).toEqual([ - ['user.role', 'role'], - ['role.detail', 'role_detail'], + ['user.role', 'r4_role'], + ['r4_role.detail', 'r4_role_6_detail'], ]); }); @@ -121,9 +121,9 @@ describe('src/adapter/relations.ts', () => { (join) => [join.entityOrProperty, join.alias.name], ); expect(joins).toEqual([ - ['user.realm', 'realm'], - ['user.role', 'role'], - ['role.realm', 'role_realm'], + ['user.realm', 'r5_realm'], + ['user.role', 'r4_role'], + ['r4_role.realm', 'r4_role_5_realm'], ]); }); @@ -162,8 +162,8 @@ describe('src/adapter/relations.ts', () => { adapter.relations.execute(); expect(calls).toEqual([ - ['role', 'role'], - ['role.detail', 'role_detail'], + ['role', 'r4_role'], + ['role.detail', 'r4_role_6_detail'], ]); expect(queryBuilder.getSql()).toContain('GROUP BY'); }); @@ -176,7 +176,7 @@ describe('src/adapter/relations.ts', () => { calls.push(path); }, }); - queryBuilder.leftJoin('user.realm', 'realm'); + queryBuilder.leftJoin('user.realm', 'r5_realm'); visit(adapter, 'realm'); adapter.relations.execute(); diff --git a/release-please-config.json b/release-please-config.json index 8062d4a12..dc7f6a567 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,20 +1,44 @@ { - "include-component-in-tag": false, + "include-component-in-tag": true, "prerelease": true, - "prerelease-type": "alpha", + "prerelease-type": "beta", + "release-as": "2.0.0-beta.0", "release-type": "node", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "packages": { - ".": { "component": "root" }, - "packages/core": { "component": "core" } + "packages/core": { "component": "core" }, + "packages/parser-simple": { "component": "parser-simple" }, + "packages/parser-expression": { "component": "parser-expression" }, + "packages/parser-mongo": { "component": "parser-mongo" }, + "packages/codec-url-simple": { "component": "codec-url-simple" }, + "packages/codec-url-expression": { "component": "codec-url-expression" }, + "packages/codec-url": { "component": "codec-url" }, + "packages/sql": { "component": "sql" }, + "packages/typeorm": { "component": "typeorm" }, + "packages/memory": { "component": "memory" } }, "plugins": [ { "type": "node-workspace", "updatePeerDependencies": true, - "merge": false, - "updateAllPackages": true + "merge": false + }, + { + "type": "linked-versions", + "groupName": "rapiq", + "components": [ + "core", + "parser-simple", + "parser-expression", + "parser-mongo", + "codec-url-simple", + "codec-url-expression", + "codec-url", + "sql", + "typeorm", + "memory" + ] } ], "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"