@ultimat3/query 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The `query` primitive: a policy-checked read, optionally live, declared once.
3
+ * Row types are inferred from the `SqlSource` the `sql:` function returns, so a
4
+ * component gets the real row shape without a second declaration. Every projection
5
+ * in this package (live subscription, MCP tool, typed client) reads this
6
+ * declaration through `read.ts` — none of them re-declare it.
7
+ */
8
+
9
+ import type { CacheTag } from '@ultimat3/cache';
10
+ import type { Actor, Ctx } from '@ultimat3/core';
11
+ import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
12
+ import type { QueryClientMethod, QueryClientOptions } from './client';
13
+ import { facadeFor } from './facade';
14
+ import type { LiveQuery, ToLiveOptions } from './live';
15
+ import type { QueryToolDescriptor } from './mcp-tool';
16
+ import type { Page, PaginateArgs } from './pagination';
17
+ import type { QueryPolicy, QuerySurface } from './policy-gate';
18
+ import { policyCapability } from './policy-gate';
19
+ import { hasDef, queryName, runQuery, stashDef } from './read';
20
+ import type { SqlSource } from './source';
21
+ import { fingerprint } from './stable';
22
+ import { tagKeys } from './tags';
23
+
24
+ export interface QueryCache {
25
+ /** Tags this read depends on. An action's `invalidates` drops exactly these keys. */
26
+ readonly tags: readonly CacheTag[];
27
+ readonly ttlMs?: number;
28
+ }
29
+
30
+ export interface QueryMcp {
31
+ /** Opt-in: a read reaches an agent only when it says so. Silence exposes nothing. */
32
+ readonly expose: boolean;
33
+ /** Contract text, not UI text — see `ActionMcp.description` for why it stays outside `t()`. */
34
+ readonly description?: string;
35
+ /**
36
+ * Roles that may SEE the projected tool — a catalog audience, not an authz rule; the `policy`
37
+ * still decides every call. A caller whose role is not named gets ToolNotFound, never
38
+ * Forbidden. See `ActionMcp.visibleTo`, which this mirrors exactly.
39
+ */
40
+ readonly visibleTo?: readonly string[];
41
+ }
42
+
43
+ export interface QueryDef<TInput extends StandardSchemaV1, TRow extends object> {
44
+ readonly input: TInput;
45
+ readonly policy: QueryPolicy;
46
+ /** `true` makes the read subscribable — see `toLiveQuery`. */
47
+ readonly live?: boolean;
48
+ sql(input: InferOutput<TInput>, ctx: Ctx): SqlSource<TRow>;
49
+ readonly cache?: QueryCache;
50
+ readonly mcp?: QueryMcp;
51
+ }
52
+
53
+ export interface QueryOptions {
54
+ readonly ctx?: Ctx;
55
+ readonly surface?: QuerySurface;
56
+ /** Skips the cache tiers for this call. Live fanout always reads fresh. */
57
+ readonly fresh?: boolean;
58
+ /**
59
+ * Run as someone else. Omitted keeps the context's own actor; `null` is the
60
+ * signed-out caller. The rest of the context is untouched, so impersonation
61
+ * stays on the one read path instead of forking a second one.
62
+ */
63
+ readonly actor?: Actor | null;
64
+ }
65
+
66
+ export interface SourceOptions extends QueryOptions {
67
+ /**
68
+ * `false` for developer tooling (`explain`, admin-gated) and for the shared, subject-less
69
+ * window a sync node builds once per `(query, input)` — see `ToLiveOptions.enforce`. Both are
70
+ * reads with no subscriber to decide about; every other caller leaves it alone.
71
+ */
72
+ readonly enforce?: boolean;
73
+ }
74
+
75
+ export interface QueryDescriptor {
76
+ readonly kind: 'query';
77
+ readonly name: string;
78
+ readonly live: boolean;
79
+ readonly capability: string;
80
+ readonly tags: readonly string[];
81
+ readonly ttlMs: number | null;
82
+ }
83
+
84
+ /**
85
+ * Schema-erased view of a definition, held only by `read.ts`'s private store —
86
+ * never reachable from a query. Method syntax is load-bearing: bivariant
87
+ * parameters are what make the erasure assignable.
88
+ */
89
+ export interface AnyQueryDef {
90
+ readonly input: StandardSchemaV1;
91
+ readonly policy: QueryPolicy;
92
+ readonly live?: boolean;
93
+ sql(input: unknown, ctx: Ctx): SqlSource<object>;
94
+ readonly cache?: QueryCache;
95
+ readonly mcp?: QueryMcp;
96
+ }
97
+
98
+ export interface AnyQuery {
99
+ readonly kind: 'query';
100
+ readonly name: string;
101
+ /** Declared `live: true`. The subscription itself is `live()`. */
102
+ readonly isLive: boolean;
103
+ /** The declaration, minus `sql`: readable, and never a way to run it. */
104
+ readonly input: StandardSchemaV1;
105
+ readonly policy: QueryPolicy;
106
+ readonly cache?: QueryCache;
107
+ readonly mcp?: QueryMcp;
108
+ describe(): QueryDescriptor;
109
+ /** A twin under another name. Registration names through `named`. */
110
+ named(name: string): AnyQuery;
111
+ /** Read as this actor. Same read path, only the context's actor changes. */
112
+ as(actor: Actor | null, input: unknown, options?: QueryOptions): Promise<readonly object[]>;
113
+ /** One bounded page plus the signed cursor that continues it. There is no `offset`. */
114
+ page(input: unknown, args: PaginateArgs): Promise<Page<object>>;
115
+ live(input: unknown, options?: ToLiveOptions): Promise<LiveQuery>;
116
+ tool(): QueryToolDescriptor;
117
+ }
118
+
119
+ export interface Query<
120
+ TInput extends StandardSchemaV1 = StandardSchemaV1,
121
+ TRow extends object = Record<string, unknown>,
122
+ > extends AnyQuery {
123
+ /** Callable server-side with the same types the client and the MCP tool see. */
124
+ (input: InferInput<TInput>, options?: QueryOptions): Promise<readonly TRow[]>;
125
+ readonly input: TInput;
126
+ named(name: string): Query<TInput, TRow>;
127
+ as(
128
+ actor: Actor | null,
129
+ input: InferInput<TInput>,
130
+ options?: QueryOptions,
131
+ ): Promise<readonly TRow[]>;
132
+ page(input: InferInput<TInput>, args: PaginateArgs): Promise<Page<TRow>>;
133
+ live(input: InferInput<TInput>, options?: ToLiveOptions): Promise<LiveQuery>;
134
+ /**
135
+ * Typed against this query's input and row type, which is the whole point of it —
136
+ * so it lives here and not on the schema-erased `AnyQuery` view.
137
+ */
138
+ client(options: QueryClientOptions): QueryClientMethod<TInput, TRow>;
139
+ }
140
+
141
+ /** The fluent half of a query: lifted declaration plus one method per projection. */
142
+ export type QueryFacade<TInput extends StandardSchemaV1, TRow extends object> = Pick<
143
+ Query<TInput, TRow>,
144
+ 'input' | 'policy' | 'cache' | 'mcp' | 'as' | 'page' | 'live' | 'tool' | 'client'
145
+ >;
146
+
147
+ export function query<TInput extends StandardSchemaV1, TRow extends object>(
148
+ def: QueryDef<TInput, TRow>,
149
+ ): Query<TInput, TRow> {
150
+ return build(def, '');
151
+ }
152
+
153
+ /**
154
+ * Structural, not nominal: an object only counts as a query if `query()` built it,
155
+ * because only then does a declaration exist for `sourceFor` to read. A look-alike
156
+ * with `kind: 'query'` never reaches the registry or a projection.
157
+ */
158
+ export function isQuery(value: unknown): value is AnyQuery {
159
+ return (
160
+ typeof value === 'function' && (value as { kind?: unknown }).kind === 'query' && hasDef(value)
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Stamp the export name onto the query the app declared, rather than handing back a
166
+ * differently-named copy of it. `import { liveFeed } from './live'` is then the query
167
+ * that projects — `liveFeed.tool()` after boot, with nothing to remember. The same
168
+ * rule `nameAction` follows; naming twice is the one case that still needs a twin.
169
+ */
170
+ export function nameQuery<Q extends AnyQuery>(target: Q, name: string): Q {
171
+ if (target.name === name) return target;
172
+ if (target.name.length > 0) return target.named(name) as Q;
173
+ Object.defineProperty(target, 'name', { value: name, configurable: true });
174
+ return target;
175
+ }
176
+
177
+ function build<TInput extends StandardSchemaV1, TRow extends object>(
178
+ def: QueryDef<TInput, TRow>,
179
+ name: string,
180
+ ): Query<TInput, TRow> {
181
+ const callable = (
182
+ input: InferInput<TInput>,
183
+ options: QueryOptions = {},
184
+ ): Promise<readonly TRow[]> => runQuery(self, input, options);
185
+
186
+ const self: Query<TInput, TRow> = Object.assign(callable, {
187
+ kind: 'query' as const,
188
+ isLive: def.live === true,
189
+ describe: (): QueryDescriptor => describeQuery(self),
190
+ named: (next: string): Query<TInput, TRow> => build(def, next),
191
+ ...facadeFor(def, () => self),
192
+ });
193
+ // `name` on a function is non-writable, so Object.assign cannot set it.
194
+ Object.defineProperty(self, 'name', { value: name, configurable: true });
195
+ // The declaration goes to `read.ts` and stays there: `sql` has no other reader.
196
+ stashDef(self, def);
197
+ return self;
198
+ }
199
+
200
+ export function describeQuery(target: AnyQuery): QueryDescriptor {
201
+ return {
202
+ kind: 'query',
203
+ name: queryName(target),
204
+ live: target.isLive,
205
+ capability: policyCapability(target.policy),
206
+ tags: tagKeys(target.cache?.tags ?? []),
207
+ ttlMs: target.cache?.ttlMs ?? null,
208
+ };
209
+ }
210
+
211
+ /** Stable identity of a query + its arguments. Used for cursors and live keys. */
212
+ export function queryHash(name: string, input: unknown): string {
213
+ return `${name}:${fingerprint(input)}`;
214
+ }
215
+
216
+ /**
217
+ * The read path lives in `read.ts`, next to the declaration store it needs; the
218
+ * primitive stays the package's one front door for it, so a sibling projection
219
+ * never has to know where the store happens to sit.
220
+ */
221
+ export { queryName, runQuery, sourceFor } from './read';
package/src/read.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The one read path: parse input, evaluate policy, build the source, execute it.
3
+ * The declaration lives in this module's private store, so `sql` is unreachable
4
+ * from anywhere else — HTTP, MCP, live, pagination and `.as()` hand `sourceFor` a
5
+ * payload, and none of them can become a second read path or a second authz path.
6
+ */
7
+
8
+ import type { Ctx } from '@ultimat3/core';
9
+ import {
10
+ anonymousActor,
11
+ createContext,
12
+ runWithContext,
13
+ tryUseContext,
14
+ useContext,
15
+ withChildContext,
16
+ withSpan,
17
+ } from '@ultimat3/core';
18
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
19
+ import { formatPath, validateAsync } from '@ultimat3/schema';
20
+ import { cacheKeyFor, readThrough } from './cache';
21
+ import { QueryForeignError, QueryInputInvalidError, QueryUnregisteredError } from './errors';
22
+ import { actorOf, guard } from './policy-gate';
23
+ import type { AnyQuery, AnyQueryDef, Query, QueryOptions, SourceOptions } from './query';
24
+ import type { SqlSource } from './source';
25
+
26
+ /**
27
+ * Private on purpose. `@ultimat3/query` exports no way to read this back, which is
28
+ * what makes "the only way to reach `sql` is `sourceFor`" structural rather than a
29
+ * rule someone has to remember.
30
+ */
31
+ const DECLARATIONS = new WeakMap<object, AnyQueryDef>();
32
+
33
+ /** Called once per built query, by `query()` and by every rename it produces. */
34
+ export function stashDef(target: object, def: AnyQueryDef): void {
35
+ DECLARATIONS.set(target, def);
36
+ }
37
+
38
+ /** True only for objects this package built — `isQuery` leans on it. */
39
+ export function hasDef(target: object): boolean {
40
+ return DECLARATIONS.has(target);
41
+ }
42
+
43
+ /** Internal read of the declaration. Never re-exported from `src/index.ts`. */
44
+ export function defOf(target: AnyQuery): AnyQueryDef {
45
+ const def = DECLARATIONS.get(target);
46
+ if (def === undefined) throw new QueryForeignError(target.name);
47
+ return def;
48
+ }
49
+
50
+ /** Projections need a stable name; an unregistered query has none yet. */
51
+ export function queryName(target: AnyQuery): string {
52
+ if (target.name.length === 0) throw new QueryUnregisteredError();
53
+ return target.name;
54
+ }
55
+
56
+ /** Validate, authorize, then read — the same three steps on every surface. */
57
+ export function runQuery<TInput extends StandardSchemaV1, TRow extends object>(
58
+ target: Query<TInput, TRow>,
59
+ raw: unknown,
60
+ options: QueryOptions = {},
61
+ ): Promise<readonly TRow[]> {
62
+ return asActor(options, (ctx) => readRows(target, raw, ctx, options));
63
+ }
64
+
65
+ /**
66
+ * Validated, authorized `SqlSource` without executing it. `live`, `paginate`,
67
+ * `explain` and the MCP tool all build on this, so none of them re-implement the
68
+ * front half and none of them can skip the policy while doing it.
69
+ */
70
+ export function sourceFor(
71
+ target: AnyQuery,
72
+ raw: unknown,
73
+ options: SourceOptions = {},
74
+ ): Promise<SqlSource<object>> {
75
+ return asActor(options, (ctx) => buildSource(target, raw, ctx, options));
76
+ }
77
+
78
+ /**
79
+ * Impersonation, in one place: keep the surrounding context whole — services,
80
+ * clock, locale, trace — and swap only the actor. Policy models "nobody" as null;
81
+ * core models it as the anonymous actor. Omitting `actor` touches no context at all.
82
+ */
83
+ function asActor<T>(options: QueryOptions, run: (ctx: Ctx) => Promise<T>): Promise<T> {
84
+ if (options.actor === undefined) return run(options.ctx ?? useContext());
85
+ const patch = { actor: options.actor ?? anonymousActor() };
86
+ const inChild = (): Promise<T> => run(useContext());
87
+ const base = options.ctx ?? tryUseContext();
88
+ return base === undefined
89
+ ? runWithContext(createContext(patch), inChild)
90
+ : runWithContext(base, () => withChildContext(patch, inChild));
91
+ }
92
+
93
+ async function readRows<TInput extends StandardSchemaV1, TRow extends object>(
94
+ target: Query<TInput, TRow>,
95
+ raw: unknown,
96
+ ctx: Ctx,
97
+ options: QueryOptions,
98
+ ): Promise<readonly TRow[]> {
99
+ const def = defOf(target);
100
+ const name = queryName(target);
101
+ const source = await buildSource(target, raw, ctx, options);
102
+ const read = (): Promise<readonly object[]> => withSpan(`query.${name}`, () => source.execute());
103
+ // The source came from this query's own `sql()`, so its rows are TRow.
104
+ if (options.fresh === true || def.cache === undefined) {
105
+ return (await read()) as readonly TRow[];
106
+ }
107
+ const key = cacheKeyFor(name, raw, def.cache.tags);
108
+ const rows = await readThrough(ctx, key, def.cache.ttlMs ?? null, read);
109
+ return rows as readonly TRow[];
110
+ }
111
+
112
+ async function buildSource(
113
+ target: AnyQuery,
114
+ raw: unknown,
115
+ ctx: Ctx,
116
+ options: SourceOptions,
117
+ ): Promise<SqlSource<object>> {
118
+ const def = defOf(target);
119
+ const name = queryName(target);
120
+ const input = await validate(def.input, raw, name);
121
+ if (options.enforce !== false) {
122
+ guard(
123
+ def.policy,
124
+ { actor: actorOf(ctx), input, ctx, query: name },
125
+ options.surface ?? 'server',
126
+ );
127
+ }
128
+ return def.sql(input, ctx);
129
+ }
130
+
131
+ async function validate(schema: StandardSchemaV1, raw: unknown, name: string): Promise<unknown> {
132
+ const result = await validateAsync(schema, raw);
133
+ if (result.issues !== undefined) {
134
+ const detail = result.issues
135
+ .map((issue) => {
136
+ const path = formatPath(issue.path);
137
+ return path === '' ? issue.message : `${path}: ${issue.message}`;
138
+ })
139
+ .join('; ');
140
+ throw new QueryInputInvalidError(name, detail);
141
+ }
142
+ return result.value;
143
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The query registry. Names come from export names, so the manifest, the live
3
+ * subscription protocol and the `/_x` dashboard all address a read by the same
4
+ * identifier the source file uses.
5
+ */
6
+ import { registerPrimitiveRegistrar } from '@ultimat3/core';
7
+ import { QueryDuplicateError, QueryPolicyMissingError } from './errors';
8
+ import type { AnyQuery, QueryDescriptor } from './query';
9
+ import { isQuery, nameQuery } from './query';
10
+
11
+ const registry = new Map<string, AnyQuery>();
12
+
13
+ /**
14
+ * Register one query under an explicit name. The name lands on the query you passed,
15
+ * so the module's own export is projectable after boot and there is no "use the
16
+ * return value instead" rule to forget.
17
+ */
18
+ export function registerQuery<Q extends AnyQuery>(name: string, target: Q): Q {
19
+ const seated = registry.get(name);
20
+ if (seated !== undefined) {
21
+ // Re-registering the SAME object under the SAME name is one registration seen twice, not a
22
+ // collision: `defineApi` registers a feature module at boot and the framework's module scan
23
+ // reaches the same declaration file directly, so both arrive at the identical query. Only a
24
+ // DIFFERENT query under a taken name is the ambiguity `X_QUERY_DUPLICATE` exists to refuse.
25
+ if (seated !== (target as AnyQuery)) throw new QueryDuplicateError(name);
26
+ return target;
27
+ }
28
+ if (target.policy === undefined || target.policy === null) {
29
+ throw new QueryPolicyMissingError(name);
30
+ }
31
+ const named = nameQuery(target, name);
32
+ registry.set(name, named);
33
+ return named;
34
+ }
35
+
36
+ /** `registerQueries(await import('./live'))` — export names become query names. */
37
+ export function registerQueries(module: Readonly<Record<string, unknown>>): readonly AnyQuery[] {
38
+ const registered: AnyQuery[] = [];
39
+ for (const name of Object.keys(module).sort()) {
40
+ const value = module[name];
41
+ if (isQuery(value)) registered.push(registerQuery(name, value));
42
+ }
43
+ return registered;
44
+ }
45
+
46
+ // `defineApi` lives in `@ultimat3/action`, which sits on this tier and so cannot import this
47
+ // file. Announcing the registrar in core's table is what lets one `defineApi({ queries })` call
48
+ // register a read without a sideways import — importing the module you pass is what loads this.
49
+ registerPrimitiveRegistrar('query', registerQueries);
50
+
51
+ export function getQuery(name: string): AnyQuery | undefined {
52
+ return registry.get(name);
53
+ }
54
+
55
+ /** Sorted by name — manifest output must not depend on import order. */
56
+ export function listQueries(): readonly AnyQuery[] {
57
+ return [...registry.entries()]
58
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
59
+ .map(([, value]) => value);
60
+ }
61
+
62
+ export function describeQueries(): readonly QueryDescriptor[] {
63
+ return listQueries().map((target) => target.describe());
64
+ }
65
+
66
+ /** Test-only. Production registers once at boot. */
67
+ export function resetRegistry(): void {
68
+ registry.clear();
69
+ }
package/src/shape.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The read vocabulary shared by the matcher, the SQL sources, pagination and the
3
+ * live descriptor. Types only plus two pure predicates — no I/O lives here.
4
+ */
5
+ import { QueryNotPageableError } from './errors';
6
+ import { columnOf } from './stable';
7
+
8
+ export type FilterOp = '=' | '!=' | 'in' | '>' | '>=' | '<' | '<=';
9
+
10
+ export interface Filter {
11
+ readonly column: string;
12
+ readonly op: FilterOp;
13
+ readonly value: unknown;
14
+ }
15
+
16
+ export interface OrderKey {
17
+ readonly column: string;
18
+ readonly direction: 'asc' | 'desc';
19
+ }
20
+
21
+ /**
22
+ * The statically-known shape of a read. The incremental matcher works from this,
23
+ * never from SQL text — parsing SQL back is how frameworks get patches wrong.
24
+ */
25
+ export interface QueryShape {
26
+ readonly entity: string;
27
+ readonly filters: readonly Filter[];
28
+ readonly orderBy: readonly OrderKey[];
29
+ readonly limit: number | null;
30
+ /** Features present in the query that the matcher cannot patch incrementally. */
31
+ readonly unsupported: readonly string[];
32
+ }
33
+
34
+ /** Where a page resumes: the sort-key values of the last row plus its id tiebreak. */
35
+ export interface SeekKey {
36
+ readonly key: readonly unknown[];
37
+ readonly id: string;
38
+ }
39
+
40
+ /**
41
+ * Sort-key values of a row under an ordering, with its id as the final tiebreak.
42
+ *
43
+ * A row with no `id` is refused rather than stringified: `String(undefined)` is `"undefined"`,
44
+ * which every row in the result set then matches, so the cursor names a position that is both
45
+ * signed and meaningless. The tiebreak is what makes the order total — without it two rows
46
+ * sharing a sort value straddle a page boundary and one of them is lost.
47
+ */
48
+ export function seekKeyOf(
49
+ row: object,
50
+ shape: { readonly orderBy: readonly OrderKey[]; readonly entity?: string },
51
+ ): SeekKey {
52
+ const id = columnOf(row, 'id');
53
+ if (id === undefined || id === null) throw new QueryNotPageableError(shape.entity);
54
+ return {
55
+ key: shape.orderBy.map((order) => columnOf(row, order.column)),
56
+ id: typeof id === 'string' ? id : String(id),
57
+ };
58
+ }
59
+
60
+ export function matchesFilters(row: object, filters: readonly Filter[]): boolean {
61
+ return filters.every((filter) => matchesFilter(row, filter));
62
+ }
63
+
64
+ export function matchesFilter(row: object, filter: Filter): boolean {
65
+ const actual = columnOf(row, filter.column);
66
+ switch (filter.op) {
67
+ case '=':
68
+ return same(actual, filter.value);
69
+ case '!=':
70
+ return !same(actual, filter.value);
71
+ case 'in':
72
+ return Array.isArray(filter.value) && filter.value.some((item) => same(actual, item));
73
+ case '>':
74
+ return compareValues(actual, filter.value) > 0;
75
+ case '>=':
76
+ return compareValues(actual, filter.value) >= 0;
77
+ case '<':
78
+ return compareValues(actual, filter.value) < 0;
79
+ case '<=':
80
+ return compareValues(actual, filter.value) <= 0;
81
+ default:
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /** Dates compare by instant, everything else by value. No coercion across types. */
87
+ export function compareValues(a: unknown, b: unknown): number {
88
+ const left = normalize(a);
89
+ const right = normalize(b);
90
+ if (typeof left === 'number' && typeof right === 'number') return left - right;
91
+ const l = String(left);
92
+ const r = String(right);
93
+ return l < r ? -1 : l > r ? 1 : 0;
94
+ }
95
+
96
+ function normalize(value: unknown): unknown {
97
+ return value instanceof Date ? value.getTime() : value;
98
+ }
99
+
100
+ function same(a: unknown, b: unknown): boolean {
101
+ return compareValues(a, b) === 0 && typeof normalize(a) === typeof normalize(b);
102
+ }
103
+
104
+ /** Row ordering under an `orderBy` list. Stable, and total when an id key is last. */
105
+ export function compareRows(a: object, b: object, orderBy: readonly OrderKey[]): number {
106
+ for (const key of orderBy) {
107
+ const result = compareValues(columnOf(a, key.column), columnOf(b, key.column));
108
+ if (result !== 0) return key.direction === 'asc' ? result : -result;
109
+ }
110
+ return 0;
111
+ }