New:Socket for Asana Is Now Available.Learn more โ†’
Sign In

@ultimat3/query

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/query - npm Package Compare versions

Comparing version
12.0.0
to
13.0.0
+145
src/search.ts
/**
* `search()` โ€” a QUERY FACTORY over an entity's `.searchable()` columns, the shape `llm()` and
* `backfill()` already have. It returns a `query`, so a search inherits the policy, the cache tags,
* the MCP tool, the typed client, the route and its manifest row rather than becoming a ninth
* primitive. What it adds is the one thing a hand-written read gets wrong: the term never becomes
* syntax, and the tenant predicate is never optional.
*/
import { assert, type Ctx } from '@ultimat3/core';
import type { InferOutput, Shape, Simplify } from '@ultimat3/schema';
import { t } from '@ultimat3/schema';
import type { QueryPolicy } from './policy-gate';
import type { QueryCache, QueryMcp, QueryRateLimit } from './query';
import { query } from './query';
import type { SeekKey } from './shape';
import type { SqlSource, SqlText } from './source';
import { from } from './source';
/**
* What `@ultimat3/entity`'s `ReadBuilder` answers, crossed STRUCTURALLY.
*
* This package may import `@ultimat3/entity` (tier 2) and deliberately does not: nothing here does
* today, so a real dependency would be a new edge in `package.json` and a new block in `bun.lock`
* for four methods. Same trade `@ultimat3/db`'s `entity-shape.ts` makes one tier down, and the same
* discipline โ€” the shape is the contract, and a chain that does not satisfy it does not compile.
*/
export interface SearchChain<Row extends object> {
/** Appends the full-text predicate. The TERM, never a tsquery. */
search(term: string): SearchChain<Row>;
limit(rows: number): SearchChain<Row>;
all(): Promise<readonly Row[]>;
/** Only `entity` is read โ€” the name the `SqlSource` and every cache tag are keyed by. */
plan(): { readonly entity: string };
}
export interface SearchPage {
/** The largest page this read will serve. Bounded in the INPUT SCHEMA, so a client cannot ask past it. */
readonly max?: number;
readonly default?: number;
}
const DEFAULT_PAGE_MAX = 100;
const DEFAULT_PAGE_SIZE = 20;
/**
* The longest term accepted. A `tsquery` past ~1 MB is a server error, and a search box does not
* send a novel โ€” the bound belongs in the schema so it is refused before a statement exists.
*/
const DEFAULT_TERM_MAX = 200;
export interface SearchDef<S extends Shape, Row extends object> {
/** The read's own input keys, beside `q` and `limit` โ€” a tenant id, a status filter, a date. */
readonly input?: S;
readonly policy: QueryPolicy;
/**
* The chain this search runs on, WITHOUT the term: the tenancy, the filters and the ordering the
* page is served in. `search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is
* what makes the term unable to arrive any other way.
*/
in(args: { readonly input: SearchInput<S>; readonly ctx: Ctx }): SearchChain<Row>;
readonly termMax?: number;
readonly page?: SearchPage;
readonly cache?: QueryCache;
readonly mcp?: QueryMcp;
readonly rateLimit?: QueryRateLimit;
}
type SearchShape<S extends Shape> = Simplify<
S & { q: ReturnType<typeof termSchema>; limit: ReturnType<typeof limitSchema> }
>;
export type SearchInput<S extends Shape> = InferOutput<ReturnType<typeof t.object<SearchShape<S>>>>;
const termSchema = (max: number) => t.string.min(1).max(max);
const limitSchema = (max: number, fallback: number) =>
t.number.int().min(1).max(max).default(fallback);
/**
* A search serves ONE page, and asking for a second is refused rather than answered wrongly.
*
* The rows come from the entity chain, which pages by its own keyset cursor โ€” proven against a real
* server in `packages/entity/src/pg-search.live.test.ts`. That cursor cannot cross this seam: a
* `SqlSource` is handed a `SeekKey` (the previous page's sort VALUES) and the chain wants its own
* signed, plan-scoped string, and there is no way to mint one from the other here. Falling through
* to `paginate`'s in-memory slice would cut inside the one page the provider fetched and report
* `hasNextPage: false` at its edge โ€” rows served on no page at all, which is the defect 12.0.0 spent
* a release removing from the timestamp seek. So it is a refusal with the alternative in the `fix`:
* page with the entity chain's own `.search(term).after(cursor)`, or raise this read's `limit`.
*/
const onePage = <Row extends object>(base: SqlSource<Row>, entity: string): SqlSource<Row> => ({
toSQL: (): SqlText => base.toSQL(),
execute: () => base.execute(),
shape: () => base.shape(),
...(base.total === undefined ? {} : { total: () => onePage(base.total?.() ?? base, entity) }),
seek: (after: SeekKey | null, limit: number): SqlSource<Row> => {
assert(
after === null,
`search of ${entity} serves one page: a relevance-filtered read has no cursor this layer can carry`,
`db.${entity}.search(term).orderBy('<key>').after(cursor).page() # the entity chain pages this read โ€” or raise its limit`,
);
return onePage(base.seek?.(after, limit) ?? base, entity);
},
});
/**
* The factory. `live: false` and the source declares itself unpatchable, because the incremental
* matcher decides membership from `QueryShape` filters and a `tsvector` match is not one of them โ€”
* a live search would have to re-read on every write to the table.
*/
export const search = <Row extends object, S extends Shape = Record<string, never>>(
def: SearchDef<S, Row>,
) => {
const page = def.page ?? {};
const max = page.max ?? DEFAULT_PAGE_MAX;
const shape = {
...((def.input ?? {}) as S),
q: termSchema(def.termMax ?? DEFAULT_TERM_MAX),
limit: limitSchema(max, Math.min(page.default ?? DEFAULT_PAGE_SIZE, max)),
} as SearchShape<S>;
return query({
input: t.object(shape),
policy: def.policy,
live: false,
...(def.cache === undefined ? {} : { cache: def.cache }),
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
sql: (input, ctx) => {
const parsed = input as SearchInput<S> & { readonly q: string; readonly limit: number };
// Trimmed and refused HERE, before the chain exists: `websearch_to_tsquery('english', ' ')`
// is a legal empty tsquery matching nothing, so a blank box would answer "no results" as if
// it had searched. Saying so is the difference between an empty answer and an empty question.
const term = parsed.q.trim();
assert(
term.length > 0,
'a search term of only whitespace is not a search',
'guard the input before calling: if (term.trim() === "") return [] โ€” an empty box is not an empty result set',
);
const chain = def.in({ input: parsed, ctx });
const name = chain.plan().entity;
const rows = () => chain.search(term).limit(parsed.limit).all();
return onePage(from<Row>(name, rows).raw('full-text search'), name);
},
});
};
+15
-0

@@ -31,2 +31,3 @@ # @ultimat3/query

| `cache.ts` | the read path: the request memo, and the fill through `@ultimat3/cache`'s registered tiers |
| `search.ts` | `search()` โ€” the query FACTORY over an entity's `.searchable()` columns |
| `source.ts` | `SqlSource` contract + `from()` in-memory reference |

@@ -211,2 +212,16 @@ | `shape.ts` | shared read vocabulary (filters, ordering, seek keys) |

and a bundled app loses its error titles or throws `X_REGISTRAR_MISSING`. Never `false`.
- **`search()` is a FACTORY over `query()`, never a ninth primitive.** It owns the input schema
(`q` + a `limit` bounded in the schema, beside the read's own keys), trims and refuses a blank
term, and calls `.search(term)` on the chain the app hands it โ€” which is what makes the term
unable to arrive any other way. The chain crosses **structurally** (`SearchChain`): this package
may import `@ultimat3/entity` and nothing here does, so four methods are not worth a new
`package.json` edge and a new `bun.lock` block โ€” the trade `@ultimat3/db`'s `entity-shape.ts`
makes one tier down. It needs no row in `PRIMITIVE_FACTORIES`: that table is for a factory
returning an `action` or a `job` from OUTSIDE the primitive's own package, and this returns a
query from the query package. **It serves ONE page and refuses a second**, because a `SqlSource`
is handed a `SeekKey` and the entity chain wants its own signed, plan-scoped cursor โ€” there is no
minting one from the other here, and falling through to `paginate`'s in-memory slice would cut
inside the one page the provider fetched and report `hasNextPage: false` at its edge. Rows served
on no page at all is the defect 12.0.0 spent a release removing from the timestamp seek; the
`fix:` names the entity chain, which pages this read correctly.
- Policy runs per subscriber for live queries. Never cache a decision across actors.

@@ -213,0 +228,0 @@ - The matcher patches from `QueryShape`, never from SQL text.

+6
-6
{
"name": "@ultimat3/query",
"version": "12.0.0",
"version": "13.0.0",
"description": "The query primitive: a policy-checked read, optionally live, with cursor pagination and an incremental matcher",

@@ -38,8 +38,8 @@ "license": "MIT",

"dependencies": {
"@ultimat3/cache": "12.0.0",
"@ultimat3/core": "12.0.0",
"@ultimat3/http": "12.0.0",
"@ultimat3/policy": "12.0.0",
"@ultimat3/schema": "12.0.0"
"@ultimat3/cache": "13.0.0",
"@ultimat3/core": "13.0.0",
"@ultimat3/http": "13.0.0",
"@ultimat3/policy": "13.0.0",
"@ultimat3/schema": "13.0.0"
}
}

@@ -175,2 +175,50 @@ # @ultimat3/query ๐Ÿ”Ž

## `search()` โ€” the query factory over a searchable entity
A model call is an `action` and a sweep is a `job`; a search is a **read**, so `search()` returns a
`query`. It inherits the policy, the cache tags, the MCP tool, the typed client, the route and its
manifest row โ€” there is no ninth primitive.
```ts
import { type QueryPolicy, search, type SearchChain, t } from '@ultimat3/query';
interface PostRow {
readonly id: string;
readonly title: string;
readonly createdAt: Date;
}
// `@ultimat3/entity`'s chain, crossed STRUCTURALLY: a real `db.posts` satisfies `SearchChain`
// as written, so this package holds no dependency edge on entity.
declare const db: {
readonly posts: {
where(filter: { readonly orgId: string }): {
orderBy(column: keyof PostRow & string, direction: 'asc' | 'desc'): SearchChain<PostRow>;
};
};
};
declare const postRead: QueryPolicy;
declare const orgId: string;
export const searchPosts = search({
input: { orgId: t.uuid }, // your own keys โ€” `q` and `limit` are added
policy: postRead,
page: { max: 50, default: 20 },
// The chain WITHOUT the term: tenancy, filters, and the order the page is served in.
in: ({ input }) => db.posts.where({ orgId: input.orgId }).orderBy('createdAt', 'desc'),
});
// A query is CALLABLE โ€” there is no `.run()`; `.as()`, `.page()` and `.client()` are the rest.
await searchPosts({ orgId, q: 'cats -dogs "exact phrase"' });
```
`search()` adds `.search(q)` and `.limit(limit)` and nothing else, which is what makes the term
unable to arrive any other way. A blank term is refused rather than answered with no rows โ€” an
empty box is not an empty result set.
**It serves one page.** The rows come from the entity chain, which pages by its own signed cursor;
that cursor cannot cross the `SqlSource` seam, and slicing in memory instead would cut inside the
page the provider fetched and report `hasNextPage: false` at its edge. So a second page is
refused, and the `fix:` names the chain โ€” `db.posts.search(term).after(cursor).page()`.
## Pagination is cursor-only

@@ -177,0 +225,0 @@

@@ -136,2 +136,5 @@ /**

} from './registry';
/** The query FACTORY over an entity's searchable columns โ€” a `query`, never a ninth primitive. */
export type { SearchChain, SearchDef, SearchInput, SearchPage } from './search';
export { search } from './search';
export type { Filter, FilterOp, OrderKey, QueryShape, SeekKey } from './shape';

@@ -138,0 +141,0 @@ /**