@adonis-agora/filter 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/dist/commands/main.d.ts +17 -0
  4. package/dist/commands/main.d.ts.map +1 -0
  5. package/dist/commands/main.js +17 -0
  6. package/dist/commands/main.js.map +1 -0
  7. package/dist/commands/make_filter_client.d.ts +35 -0
  8. package/dist/commands/make_filter_client.d.ts.map +1 -0
  9. package/dist/commands/make_filter_client.js +85 -0
  10. package/dist/commands/make_filter_client.js.map +1 -0
  11. package/dist/src/apply_from_request.d.ts +69 -0
  12. package/dist/src/apply_from_request.d.ts.map +1 -0
  13. package/dist/src/apply_from_request.js +115 -0
  14. package/dist/src/apply_from_request.js.map +1 -0
  15. package/dist/src/cursor.d.ts +92 -0
  16. package/dist/src/cursor.d.ts.map +1 -0
  17. package/dist/src/cursor.js +107 -0
  18. package/dist/src/cursor.js.map +1 -0
  19. package/dist/src/escape-like.d.ts +10 -0
  20. package/dist/src/escape-like.d.ts.map +1 -0
  21. package/dist/src/escape-like.js +15 -0
  22. package/dist/src/escape-like.js.map +1 -0
  23. package/dist/src/field_aliases.d.ts +28 -0
  24. package/dist/src/field_aliases.d.ts.map +1 -0
  25. package/dist/src/field_aliases.js +61 -0
  26. package/dist/src/field_aliases.js.map +1 -0
  27. package/dist/src/filter_spec.d.ts +146 -0
  28. package/dist/src/filter_spec.d.ts.map +1 -0
  29. package/dist/src/filter_spec.js +140 -0
  30. package/dist/src/filter_spec.js.map +1 -0
  31. package/dist/src/generate_client.d.ts +107 -0
  32. package/dist/src/generate_client.d.ts.map +1 -0
  33. package/dist/src/generate_client.js +154 -0
  34. package/dist/src/generate_client.js.map +1 -0
  35. package/dist/src/index.d.ts +28 -0
  36. package/dist/src/index.d.ts.map +1 -0
  37. package/dist/src/index.js +18 -0
  38. package/dist/src/index.js.map +1 -0
  39. package/dist/src/lucid_adapter.d.ts +213 -0
  40. package/dist/src/lucid_adapter.d.ts.map +1 -0
  41. package/dist/src/lucid_adapter.js +303 -0
  42. package/dist/src/lucid_adapter.js.map +1 -0
  43. package/dist/src/normalizer.d.ts +21 -0
  44. package/dist/src/normalizer.d.ts.map +1 -0
  45. package/dist/src/normalizer.js +61 -0
  46. package/dist/src/normalizer.js.map +1 -0
  47. package/dist/src/operators.d.ts +37 -0
  48. package/dist/src/operators.d.ts.map +1 -0
  49. package/dist/src/operators.js +43 -0
  50. package/dist/src/operators.js.map +1 -0
  51. package/dist/src/parse_request.d.ts +22 -0
  52. package/dist/src/parse_request.d.ts.map +1 -0
  53. package/dist/src/parse_request.js +107 -0
  54. package/dist/src/parse_request.js.map +1 -0
  55. package/dist/src/runner.d.ts +46 -0
  56. package/dist/src/runner.d.ts.map +1 -0
  57. package/dist/src/runner.js +190 -0
  58. package/dist/src/runner.js.map +1 -0
  59. package/dist/src/source_resolver.d.ts +18 -0
  60. package/dist/src/source_resolver.d.ts.map +1 -0
  61. package/dist/src/source_resolver.js +64 -0
  62. package/dist/src/source_resolver.js.map +1 -0
  63. package/dist/src/spatie_parser.d.ts +38 -0
  64. package/dist/src/spatie_parser.d.ts.map +1 -0
  65. package/dist/src/spatie_parser.js +130 -0
  66. package/dist/src/spatie_parser.js.map +1 -0
  67. package/dist/src/testing.d.ts +62 -0
  68. package/dist/src/testing.d.ts.map +1 -0
  69. package/dist/src/testing.js +113 -0
  70. package/dist/src/testing.js.map +1 -0
  71. package/dist/src/types.d.ts +130 -0
  72. package/dist/src/types.d.ts.map +1 -0
  73. package/dist/src/types.js +2 -0
  74. package/dist/src/types.js.map +1 -0
  75. package/dist/src/validate-column-filter.d.ts +30 -0
  76. package/dist/src/validate-column-filter.d.ts.map +1 -0
  77. package/dist/src/validate-column-filter.js +148 -0
  78. package/dist/src/validate-column-filter.js.map +1 -0
  79. package/dist/src/value-shape.d.ts +18 -0
  80. package/dist/src/value-shape.d.ts.map +1 -0
  81. package/dist/src/value-shape.js +36 -0
  82. package/dist/src/value-shape.js.map +1 -0
  83. package/package.json +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Davi Carvalho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # `@adonis-agora/filter`
2
+
3
+ Server-side query filtering, sorting, and pagination for AdonisJS. Parses the
4
+ Spatie/JSON:API query the `@agora/filter-client` builder emits, applies it to a
5
+ Lucid query through a field allow-list, and resolves offset or keyset (cursor)
6
+ pagination.
7
+
8
+ ```ts
9
+ import { parseFilterRequest, applyFilter } from '@adonis-agora/filter'
10
+
11
+ const input = parseFilterRequest(ctx.request.qs())
12
+ const query = User.query()
13
+ const { page, size } = applyFilter(query, input, {
14
+ allowed: ['name', 'email', 'age'],
15
+ searchable: ['name', 'email'],
16
+ })
17
+ return query.paginate(page, size)
18
+ ```
19
+
20
+ Operators: equals/notEquals, contains/iContains/startsWith/endsWith, gt/gte/lt/lte,
21
+ between/notBetween, in/notIn/isAnyOf, isNull/isNotNull/isEmpty/isNotEmpty, plus
22
+ AND/OR composition. The `allowed`/`sortable`/`searchable` lists are the security
23
+ boundary. The Lucid binding is structural (`QueryBuilderLike`) so any Lucid query
24
+ builder works and the adapter is unit-testable.
25
+
26
+ See the [repository README](https://github.com/DavideCarvalho/adonis-filter).
27
+
28
+ ## License
29
+
30
+ MIT © Davi Carvalho
@@ -0,0 +1,17 @@
1
+ import { ListLoader } from '@adonisjs/core/ace';
2
+ import MakeFilterClient from './make_filter_client.js';
3
+ /**
4
+ * The commands barrel for `@adonis-agora/filter`. An app registers it in its
5
+ * `adonisrc` via `rcFile.addCommand('@adonis-agora/filter/commands')`. A
6
+ * {@link ListLoader} exposes the `make:filter-client` command's metadata and
7
+ * constructor to the ace kernel.
8
+ *
9
+ * `@adonisjs/core` is an *optional* peer of this otherwise framework-free core —
10
+ * only this `./commands` subpath imports it, so the main entrypoint stays
11
+ * dependency-free.
12
+ */
13
+ declare const loader: ListLoader<typeof MakeFilterClient>;
14
+ export declare const getMetaData: () => Promise<import("@adonisjs/core/types/ace").CommandMetaData[]>;
15
+ export declare const getCommand: (metaData: import("@adonisjs/core/types/ace").CommandMetaData) => Promise<typeof MakeFilterClient | null>;
16
+ export default loader;
17
+ //# sourceMappingURL=main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,gBAAgB,MAAM,yBAAyB,CAAC;AAEvD;;;;;;;;;GASG;AACH,QAAA,MAAM,MAAM,qCAAqC,CAAC;AAElD,eAAO,MAAM,WAAW,qEAAkC,CAAC;AAC3D,eAAO,MAAM,UAAU,2GAAiC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { ListLoader } from '@adonisjs/core/ace';
2
+ import MakeFilterClient from './make_filter_client.js';
3
+ /**
4
+ * The commands barrel for `@adonis-agora/filter`. An app registers it in its
5
+ * `adonisrc` via `rcFile.addCommand('@adonis-agora/filter/commands')`. A
6
+ * {@link ListLoader} exposes the `make:filter-client` command's metadata and
7
+ * constructor to the ace kernel.
8
+ *
9
+ * `@adonisjs/core` is an *optional* peer of this otherwise framework-free core —
10
+ * only this `./commands` subpath imports it, so the main entrypoint stays
11
+ * dependency-free.
12
+ */
13
+ const loader = new ListLoader([MakeFilterClient]);
14
+ export const getMetaData = loader.getMetaData.bind(loader);
15
+ export const getCommand = loader.getCommand.bind(loader);
16
+ export default loader;
17
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"main.js","sourceRoot":"","sources":["../../commands/main.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,gBAAgB,MAAM,yBAAyB,CAAC;AAEvD;;;;;;;;;GASG;AACH,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC;AAElD,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC3D,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAe,MAAM,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { BaseCommand } from '@adonisjs/core/ace';
2
+ import type { CommandOptions } from '@adonisjs/core/types/ace';
3
+ /**
4
+ * `node ace make:filter-client [entrypoint]` — the idiomatic AdonisJS equivalent
5
+ * of the NestJS `nestjs-filter` codegen bin.
6
+ *
7
+ * Where NestJS ships a standalone `reflect-metadata` bin that reads decorator
8
+ * metadata off entity classes, the Adonis filter declaration is already a plain
9
+ * runtime {@link FilterClientManifest} (`defineFilter(...)` specs), so this is a
10
+ * thin IO wrapper: it loads the app's declared `filters` manifest, hands it to
11
+ * the pure {@link generateFilterClients} core, and writes each emitted typed
12
+ * client to disk. All generation logic lives in `../src/generate_client.ts` and
13
+ * is unit-tested there — this command only does the file IO.
14
+ *
15
+ * The entrypoint module (default `config/filter.js`) must export a `filters`
16
+ * manifest (named export, or default):
17
+ *
18
+ * ```ts
19
+ * // config/filter.ts
20
+ * import { defineFilter } from '@adonis-agora/filter'
21
+ * export const filters = {
22
+ * people: { spec: defineFilter({ filterable: ['name', 'age'] }), fieldTypes: { age: { kind: 'number' } } },
23
+ * }
24
+ * ```
25
+ */
26
+ export default class MakeFilterClient extends BaseCommand {
27
+ #private;
28
+ static commandName: string;
29
+ static description: string;
30
+ static options: CommandOptions;
31
+ entrypoint: string;
32
+ output: string;
33
+ run(): Promise<void>;
34
+ }
35
+ //# sourceMappingURL=make_filter_client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"make_filter_client.d.ts","sourceRoot":"","sources":["../../commands/make_filter_client.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAe,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAI/D;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,OAAO,OAAO,gBAAiB,SAAQ,WAAW;;IACvD,OAAgB,WAAW,SAAwB;IACnD,OAAgB,WAAW,SAC4C;IACvE,OAAgB,OAAO,EAAE,cAAc,CAAsB;IAOrD,UAAU,EAAE,MAAM,CAAC;IAMnB,MAAM,EAAE,MAAM,CAAC;IAER,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;CAsCpC"}
@@ -0,0 +1,85 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { mkdir, writeFile } from 'node:fs/promises';
8
+ import { isAbsolute, join } from 'node:path';
9
+ import { BaseCommand, args, flags } from '@adonisjs/core/ace';
10
+ import { generateFilterClients } from '../src/generate_client.js';
11
+ /**
12
+ * `node ace make:filter-client [entrypoint]` — the idiomatic AdonisJS equivalent
13
+ * of the NestJS `nestjs-filter` codegen bin.
14
+ *
15
+ * Where NestJS ships a standalone `reflect-metadata` bin that reads decorator
16
+ * metadata off entity classes, the Adonis filter declaration is already a plain
17
+ * runtime {@link FilterClientManifest} (`defineFilter(...)` specs), so this is a
18
+ * thin IO wrapper: it loads the app's declared `filters` manifest, hands it to
19
+ * the pure {@link generateFilterClients} core, and writes each emitted typed
20
+ * client to disk. All generation logic lives in `../src/generate_client.ts` and
21
+ * is unit-tested there — this command only does the file IO.
22
+ *
23
+ * The entrypoint module (default `config/filter.js`) must export a `filters`
24
+ * manifest (named export, or default):
25
+ *
26
+ * ```ts
27
+ * // config/filter.ts
28
+ * import { defineFilter } from '@adonis-agora/filter'
29
+ * export const filters = {
30
+ * people: { spec: defineFilter({ filterable: ['name', 'age'] }), fieldTypes: { age: { kind: 'number' } } },
31
+ * }
32
+ * ```
33
+ */
34
+ export default class MakeFilterClient extends BaseCommand {
35
+ static commandName = 'make:filter-client';
36
+ static description = "Generate typed filter clients from the app's declared filter specs";
37
+ static options = { startApp: true };
38
+ async run() {
39
+ const manifest = await this.#loadManifest();
40
+ if (!manifest)
41
+ return;
42
+ const outDir = isAbsolute(this.output) ? this.output : this.app.makePath(this.output);
43
+ await mkdir(outDir, { recursive: true });
44
+ const generated = generateFilterClients(manifest);
45
+ for (const client of generated) {
46
+ const path = join(outDir, client.filename);
47
+ await writeFile(path, client.code, 'utf-8');
48
+ this.logger.action(`create ${path}`).succeeded();
49
+ }
50
+ this.logger.success(`Generated ${generated.length} filter client(s) into ${this.output}.`);
51
+ }
52
+ /** Import the entrypoint module and read its `filters` manifest (named or default export). */
53
+ async #loadManifest() {
54
+ const url = this.app.makeURL(this.entrypoint).href;
55
+ let mod;
56
+ try {
57
+ mod = (await import(url));
58
+ }
59
+ catch (error) {
60
+ this.logger.error(`Could not import filter manifest "${this.entrypoint}": ${String(error)}`);
61
+ this.exitCode = 1;
62
+ return undefined;
63
+ }
64
+ const manifest = (mod.filters ?? mod.default);
65
+ if (!manifest || Object.keys(manifest).length === 0) {
66
+ this.logger.warning(`No \`filters\` manifest exported from "${this.entrypoint}" — nothing to generate.`);
67
+ return undefined;
68
+ }
69
+ return manifest;
70
+ }
71
+ }
72
+ __decorate([
73
+ args.string({
74
+ description: 'Module exporting a `filters` manifest',
75
+ required: false,
76
+ default: 'config/filter.js',
77
+ })
78
+ ], MakeFilterClient.prototype, "entrypoint", void 0);
79
+ __decorate([
80
+ flags.string({
81
+ description: 'Output directory for the generated clients',
82
+ default: 'app/generated/filters',
83
+ })
84
+ ], MakeFilterClient.prototype, "output", void 0);
85
+ //# sourceMappingURL=make_filter_client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"make_filter_client.js","sourceRoot":"","sources":["../../commands/make_filter_client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ import type { ResolvedCursor } from './cursor.js';
2
+ import { type FilterSpec } from './filter_spec.js';
3
+ import { type QueryBuilderLike } from './lucid_adapter.js';
4
+ import { type ResolvedPagination } from './runner.js';
5
+ import type { FilterInput } from './types.js';
6
+ /**
7
+ * The structural slice of an AdonisJS `HttpContext` these helpers read. Only
8
+ * `request.qs()` (the decoded query string) is used to source the raw input;
9
+ * the whole ctx is also handed to a spec's tenant resolver. Declared
10
+ * structurally so the package never hard-imports `@adonisjs/core` — a real
11
+ * `HttpContext` satisfies it, and tests can pass a plain object.
12
+ */
13
+ export interface FilterRequestContext {
14
+ request?: {
15
+ qs?: () => Record<string, unknown>;
16
+ };
17
+ [key: string]: any;
18
+ }
19
+ /** Options shared by the request-driven helpers. */
20
+ export interface ApplyFromRequestOptions {
21
+ /**
22
+ * Pre-parsed input to use instead of reading `ctx.request.qs()`. Useful for
23
+ * non-HTTP callers and tests; when omitted the query string is parsed via
24
+ * {@link parseFilterRequest}.
25
+ */
26
+ input?: FilterInput;
27
+ /**
28
+ * Query embedding for pgvector embedding-similarity ranking (distinct from the
29
+ * text `search`). The idiomatic path: the controller computes it from an
30
+ * embedding service and passes it here (rather than shipping a large float
31
+ * array through the query string). Ignored unless the spec declares a
32
+ * `vectorSimilarity` column; merged over any `input.vectorSimilarity`.
33
+ */
34
+ vectorSimilarity?: readonly number[];
35
+ }
36
+ /** Options for {@link applyCursorFromRequest}. */
37
+ export interface ApplyCursorFromRequestOptions extends ApplyFromRequestOptions {
38
+ /** Primary-key column appended to the keyset as a stable tiebreaker. Default `'id'`. */
39
+ primaryKey?: string;
40
+ }
41
+ /**
42
+ * Apply a {@link FilterSpec} to a Lucid query from a request context, returning
43
+ * the resolved offset pagination — the single explicit call an AdonisJS
44
+ * controller makes (the idiomatic replacement for the NestJS
45
+ * `ApplyFilterInterceptor` + `@ApplyFilter` param decorator).
46
+ *
47
+ * It resolves the request input from `ctx.request.qs()`, injects the server
48
+ * scope (tenant + default filters) that is never exposed to the allow-list, and
49
+ * delegates the allow-listed filter/sort/search + pagination resolution to
50
+ * {@link applyFilter}.
51
+ *
52
+ * ```ts
53
+ * const spec = defineFilter({ filterable: ['name', 'age'], tenant: { column: 'tenantId', resolve: (ctx) => ctx.auth.user.tenantId } })
54
+ *
55
+ * // in a controller:
56
+ * const query = User.query()
57
+ * const { page, size } = applyFilterFromRequest(query, spec, ctx)
58
+ * return query.paginate(page, size)
59
+ * ```
60
+ */
61
+ export declare function applyFilterFromRequest(query: QueryBuilderLike, spec: FilterSpec, ctx: FilterRequestContext | undefined, options?: ApplyFromRequestOptions): ResolvedPagination;
62
+ /**
63
+ * The keyset (cursor) counterpart of {@link applyFilterFromRequest}. Sources the
64
+ * request filters and the `after`/`before`/`first`/`last` cursor params from ctx,
65
+ * injects the same server scope, and delegates to {@link applyCursor}. Feed the
66
+ * fetched rows and the returned {@link ResolvedCursor} to `buildCursorPage`.
67
+ */
68
+ export declare function applyCursorFromRequest(query: QueryBuilderLike, spec: FilterSpec, ctx: FilterRequestContext | undefined, options?: ApplyCursorFromRequestOptions): ResolvedCursor;
69
+ //# sourceMappingURL=apply_from_request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply_from_request.d.ts","sourceRoot":"","sources":["../../src/apply_from_request.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,KAAK,UAAU,EAAsB,MAAM,kBAAkB,CAAC;AACvE,OAAO,EAAsB,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAG/E,OAAO,EAAqB,KAAK,kBAAkB,EAA4B,MAAM,aAAa,CAAC;AACnG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;GAMG;AACH,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;IAEjD,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAED,oDAAoD;AACpD,MAAM,WAAW,uBAAuB;IACtC;;;;OAIG;IACH,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACtC;AAED,kDAAkD;AAClD,MAAM,WAAW,6BAA8B,SAAQ,uBAAuB;IAC5E,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA6CD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,oBAAoB,GAAG,SAAS,EACrC,OAAO,GAAE,uBAA4B,GACpC,kBAAkB,CAQpB;AA6BD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,gBAAgB,EACvB,IAAI,EAAE,UAAU,EAChB,GAAG,EAAE,oBAAoB,GAAG,SAAS,EACrC,OAAO,GAAE,6BAAkC,GAC1C,cAAc,CAahB"}
@@ -0,0 +1,115 @@
1
+ import { specToFilterConfig } from './filter_spec.js';
2
+ import { applyColumnFilters } from './lucid_adapter.js';
3
+ import { parseFilterRequest } from './parse_request.js';
4
+ import { applyCursor, applyFilter } from './runner.js';
5
+ /** Read the decoded query string off the ctx (empty object when absent). */
6
+ function rawQs(ctx) {
7
+ const qs = ctx?.request?.qs;
8
+ if (typeof qs === 'function')
9
+ return qs.call(ctx?.request) ?? {};
10
+ return {};
11
+ }
12
+ /**
13
+ * Apply the server-side, non-client-tamperable scope to the query BEFORE the
14
+ * allow-listed request filters: the tenant constraint (when the spec declares
15
+ * one and a tenant id resolves from ctx) and any `defaultFilters`. These bypass
16
+ * the allow-list on purpose — they are trusted server policy, mirroring how the
17
+ * NestJS runner applied `@TenantScoped` via the adapter's auto-field path.
18
+ */
19
+ function applyServerScope(query, spec, ctx) {
20
+ const serverFilters = [];
21
+ if (spec.tenant) {
22
+ const tenantId = spec.tenant.resolve(ctx);
23
+ if (tenantId !== undefined && tenantId !== null) {
24
+ serverFilters.push({ field: spec.tenant.column, operator: 'equals', value: tenantId });
25
+ }
26
+ }
27
+ if (spec.defaultFilters.length > 0) {
28
+ serverFilters.push(...spec.defaultFilters);
29
+ }
30
+ if (serverFilters.length > 0) {
31
+ applyColumnFilters(query, serverFilters);
32
+ }
33
+ }
34
+ /** Fall back to the spec's `defaultSort` when the request supplied no sort. */
35
+ function withDefaultSort(input, spec) {
36
+ if ((!input.sort || input.sort.length === 0) && spec.defaultSort.length > 0) {
37
+ return { ...input, sort: [...spec.defaultSort] };
38
+ }
39
+ return input;
40
+ }
41
+ /**
42
+ * Apply a {@link FilterSpec} to a Lucid query from a request context, returning
43
+ * the resolved offset pagination — the single explicit call an AdonisJS
44
+ * controller makes (the idiomatic replacement for the NestJS
45
+ * `ApplyFilterInterceptor` + `@ApplyFilter` param decorator).
46
+ *
47
+ * It resolves the request input from `ctx.request.qs()`, injects the server
48
+ * scope (tenant + default filters) that is never exposed to the allow-list, and
49
+ * delegates the allow-listed filter/sort/search + pagination resolution to
50
+ * {@link applyFilter}.
51
+ *
52
+ * ```ts
53
+ * const spec = defineFilter({ filterable: ['name', 'age'], tenant: { column: 'tenantId', resolve: (ctx) => ctx.auth.user.tenantId } })
54
+ *
55
+ * // in a controller:
56
+ * const query = User.query()
57
+ * const { page, size } = applyFilterFromRequest(query, spec, ctx)
58
+ * return query.paginate(page, size)
59
+ * ```
60
+ */
61
+ export function applyFilterFromRequest(query, spec, ctx, options = {}) {
62
+ const parsed = options.input ?? parseFilterRequest(rawQs(ctx));
63
+ const withVector = options.vectorSimilarity !== undefined
64
+ ? { ...parsed, vectorSimilarity: options.vectorSimilarity }
65
+ : parsed;
66
+ applyServerScope(query, spec, ctx);
67
+ return applyFilter(query, withDefaultSort(withVector, spec), specToFilterConfig(spec));
68
+ }
69
+ /** Parse cursor (keyset) params from a decoded query string (Spatie `page[...]` shapes). */
70
+ function parseCursorParams(qs) {
71
+ const out = {};
72
+ const page = qs.page != null && typeof qs.page === 'object' && !Array.isArray(qs.page)
73
+ ? qs.page
74
+ : undefined;
75
+ const after = qs.after ?? page?.after;
76
+ const before = qs.before ?? page?.before;
77
+ if (typeof after === 'string' && after.length > 0)
78
+ out.after = after;
79
+ else if (typeof before === 'string' && before.length > 0)
80
+ out.before = before;
81
+ const first = qs.first ?? page?.first;
82
+ const last = qs.last ?? page?.last;
83
+ const toInt = (v) => {
84
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
85
+ return Number.isFinite(n) ? n : undefined;
86
+ };
87
+ const f = toInt(first);
88
+ const l = toInt(last);
89
+ if (f !== undefined)
90
+ out.first = f;
91
+ if (l !== undefined)
92
+ out.last = l;
93
+ return out;
94
+ }
95
+ /**
96
+ * The keyset (cursor) counterpart of {@link applyFilterFromRequest}. Sources the
97
+ * request filters and the `after`/`before`/`first`/`last` cursor params from ctx,
98
+ * injects the same server scope, and delegates to {@link applyCursor}. Feed the
99
+ * fetched rows and the returned {@link ResolvedCursor} to `buildCursorPage`.
100
+ */
101
+ export function applyCursorFromRequest(query, spec, ctx, options = {}) {
102
+ const qs = rawQs(ctx);
103
+ const parsed = options.input ?? parseFilterRequest(qs);
104
+ const full = {
105
+ ...withDefaultSort(parsed, spec),
106
+ ...parseCursorParams(qs),
107
+ };
108
+ applyServerScope(query, spec, ctx);
109
+ const config = {
110
+ ...specToFilterConfig(spec),
111
+ ...(options.primaryKey !== undefined && { primaryKey: options.primaryKey }),
112
+ };
113
+ return applyCursor(query, full, config);
114
+ }
115
+ //# sourceMappingURL=apply_from_request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply_from_request.js","sourceRoot":"","sources":["../../src/apply_from_request.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACvE,OAAO,EAAE,kBAAkB,EAAyB,MAAM,oBAAoB,CAAC;AAE/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAA8C,WAAW,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAwCnG,4EAA4E;AAC5E,SAAS,KAAK,CAAC,GAAqC;IAClD,MAAM,EAAE,GAAG,GAAG,EAAE,OAAO,EAAE,EAAE,CAAC;IAC5B,IAAI,OAAO,EAAE,KAAK,UAAU;QAAE,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC;IACjE,OAAO,EAAE,CAAC;AACZ,CAAC;AAED;;;;;;GAMG;AACH,SAAS,gBAAgB,CACvB,KAAuB,EACvB,IAAgB,EAChB,GAAqC;IAErC,MAAM,aAAa,GAAmB,EAAE,CAAC;IAEzC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC1C,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YAChD,aAAa,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;QACzF,CAAC;IACH,CAAC;IACD,IAAI,IAAI,CAAC,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACnC,aAAa,CAAC,IAAI,CAAC,GAAI,IAAI,CAAC,cAAiC,CAAC,CAAC;IACjE,CAAC;IACD,IAAI,aAAa,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC7B,kBAAkB,CAAC,KAAK,EAAE,aAAa,CAAC,CAAC;IAC3C,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,SAAS,eAAe,CAAC,KAAkB,EAAE,IAAgB;IAC3D,IAAI,CAAC,CAAC,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,IAAI,IAAI,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5E,OAAO,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC;IACnD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,sBAAsB,CACpC,KAAuB,EACvB,IAAgB,EAChB,GAAqC,EACrC,UAAmC,EAAE;IAErC,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,IAAI,kBAAkB,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;IAC/D,MAAM,UAAU,GACd,OAAO,CAAC,gBAAgB,KAAK,SAAS;QACpC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,EAAE;QAC3D,CAAC,CAAC,MAAM,CAAC;IACb,gBAAgB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;IACnC,OAAO,WAAW,CAAC,KAAK,EAAE,eAAe,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC;AACzF,CAAC;AAED,4FAA4F;AAC5F,SAAS,iBAAiB,CAAC,EAA2B;IACpD,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,MAAM,IAAI,GACR,EAAE,CAAC,IAAI,IAAI,IAAI,IAAI,OAAO,EAAE,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,IAAI,CAAC;QACvE,CAAC,CAAE,EAAE,CAAC,IAAgC;QACtC,CAAC,CAAC,SAAS,CAAC;IAEhB,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,IAAI,IAAI,EAAE,KAAK,CAAC;IACtC,MAAM,MAAM,GAAG,EAAE,CAAC,MAAM,IAAI,IAAI,EAAE,MAAM,CAAC;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,GAAG,CAAC,KAAK,GAAG,KAAK,CAAC;SAChE,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC;IAE9E,MAAM,KAAK,GAAG,EAAE,CAAC,KAAK,IAAI,IAAI,EAAE,KAAK,CAAC;IACtC,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,IAAI,IAAI,EAAE,IAAI,CAAC;IACnC,MAAM,KAAK,GAAG,CAAC,CAAU,EAAsB,EAAE;QAC/C,MAAM,CAAC,GAAG,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QACjG,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5C,CAAC,CAAC;IACF,MAAM,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;IACvB,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC;IACtB,IAAI,CAAC,KAAK,SAAS;QAAE,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC;IACnC,IAAI,CAAC,KAAK,SAAS;QAAE,GAAG,CAAC,IAAI,GAAG,CAAC,CAAC;IAElC,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,sBAAsB,CACpC,KAAuB,EACvB,IAAgB,EAChB,GAAqC,EACrC,UAAyC,EAAE;IAE3C,MAAM,EAAE,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;IACtB,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,IAAI,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACvD,MAAM,IAAI,GAA+B;QACvC,GAAG,eAAe,CAAC,MAAM,EAAE,IAAI,CAAC;QAChC,GAAG,iBAAiB,CAAC,EAAE,CAAC;KACzB,CAAC;IACF,gBAAgB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAC;IACnC,MAAM,MAAM,GAAiB;QAC3B,GAAG,kBAAkB,CAAC,IAAI,CAAC;QAC3B,GAAG,CAAC,OAAO,CAAC,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC;KAC5E,CAAC;IACF,OAAO,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;AAC1C,CAAC"}
@@ -0,0 +1,92 @@
1
+ import type { SortItem } from './types.js';
2
+ /**
3
+ * A decoded keyset cursor: the ordered values of the keyset columns (the active
4
+ * sort columns plus a stable primary-key tiebreaker) captured from a boundary
5
+ * row. The values line up positionally with the keyset's {@link SortItem}[].
6
+ */
7
+ export type CursorValues = unknown[];
8
+ /**
9
+ * Cursor (keyset) pagination parameters, as parsed from a request:
10
+ *
11
+ * - `after` — return the page immediately following this opaque cursor (forward).
12
+ * - `before` — return the page immediately preceding this opaque cursor (backward).
13
+ * - `first` / `last` — page size for forward / backward paging respectively.
14
+ *
15
+ * `after` and `before` are mutually exclusive; if both are given, `after` wins.
16
+ */
17
+ export interface CursorParams {
18
+ after?: string;
19
+ before?: string;
20
+ first?: number;
21
+ last?: number;
22
+ }
23
+ /**
24
+ * A single page of keyset-paginated results assembled by {@link buildCursorPage}.
25
+ *
26
+ * - `items` — the rows for this page, in the requested order.
27
+ * - `nextCursor` — opaque cursor for the next forward page, or `null` when this
28
+ * is the last page.
29
+ * - `prevCursor` — opaque cursor for the previous (backward) page, or `null`
30
+ * when this is the first page.
31
+ * - `hasNext` / `hasPrev` — convenience booleans mirroring the cursors.
32
+ */
33
+ export interface CursorPage<T> {
34
+ items: T[];
35
+ nextCursor: string | null;
36
+ prevCursor: string | null;
37
+ hasNext: boolean;
38
+ hasPrev: boolean;
39
+ }
40
+ /**
41
+ * Encodes keyset cursor values into a compact, URL-safe opaque string
42
+ * (base64url of a JSON array). The shape is intentionally opaque to clients —
43
+ * only this module reads it back.
44
+ *
45
+ * `Date` values are encoded as `{ $d: <iso> }` so they round-trip to `Date`
46
+ * instances on decode (plain JSON would yield a string and break date keyset
47
+ * comparisons).
48
+ */
49
+ export declare function encodeCursor(values: CursorValues): string;
50
+ /**
51
+ * Decodes an opaque cursor string back into its keyset values. Returns `null`
52
+ * when the cursor is malformed (bad base64, bad JSON, or not an array) so the
53
+ * caller can ignore an invalid cursor instead of crashing.
54
+ */
55
+ export declare function decodeCursor(cursor: string): CursorValues | null;
56
+ /**
57
+ * Builds the keyset {@link SortItem}[] for cursor pagination: the caller's
58
+ * effective sorts, with a stable primary-key tiebreaker appended if it is not
59
+ * already present. The tiebreaker inherits the direction of the last sort column
60
+ * so the overall ordering stays monotonic (important for a correct
61
+ * `(cols, pk) > (...)` comparison).
62
+ */
63
+ export declare function buildKeyset(sorts: SortItem[], primaryKey: string): SortItem[];
64
+ /** Flips every keyset column's direction (for backward cursor paging). */
65
+ export declare function reverseKeyset(keyset: SortItem[]): SortItem[];
66
+ /**
67
+ * Extracts the keyset values from a fetched row, in keyset column order.
68
+ * Supports dotted relation paths (e.g. `author.name`) by walking the object.
69
+ */
70
+ export declare function extractCursorValues(row: Record<string, unknown>, keyset: SortItem[]): CursorValues;
71
+ /** The keyset shape {@link applyCursor} resolves and hands to {@link buildCursorPage}. */
72
+ export interface ResolvedCursor {
73
+ /** The base keyset (effective sort + primary-key tiebreaker), in forward order. */
74
+ keyset: SortItem[];
75
+ /** Effective page size — the number of rows to keep for this page. */
76
+ size: number;
77
+ /** True when paging backward (a `before` cursor was supplied). */
78
+ backward: boolean;
79
+ /** True when a cursor (`after`/`before`) was supplied — the opposite page is then known to exist. */
80
+ hasCursor: boolean;
81
+ }
82
+ /**
83
+ * Assembles a {@link CursorPage} from the rows fetched for a keyset query.
84
+ *
85
+ * The query is expected to have been built by {@link applyCursor}, which fetches
86
+ * one extra row (`limit = size + 1`) so we can detect a further page. For
87
+ * backward paging the rows come back reversed and are flipped here to restore
88
+ * the caller's requested order. Boundary cursors are computed from the base
89
+ * keyset so they round-trip regardless of paging direction.
90
+ */
91
+ export declare function buildCursorPage<T extends Record<string, unknown>>(rows: T[], resolved: ResolvedCursor): CursorPage<T>;
92
+ //# sourceMappingURL=cursor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor.d.ts","sourceRoot":"","sources":["../../src/cursor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,OAAO,EAAE,CAAC;AAErC;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAOzD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAahE;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,QAAQ,EAAE,CAK7E;AAED,0EAA0E;AAC1E,wBAAgB,aAAa,CAAC,MAAM,EAAE,QAAQ,EAAE,GAAG,QAAQ,EAAE,CAK5D;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,MAAM,EAAE,QAAQ,EAAE,GACjB,YAAY,CAUd;AAED,0FAA0F;AAC1F,MAAM,WAAW,cAAc;IAC7B,mFAAmF;IACnF,MAAM,EAAE,QAAQ,EAAE,CAAC;IACnB,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAC;IAClB,qGAAqG;IACrG,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,IAAI,EAAE,CAAC,EAAE,EACT,QAAQ,EAAE,cAAc,GACvB,UAAU,CAAC,CAAC,CAAC,CAwBf"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Encodes keyset cursor values into a compact, URL-safe opaque string
3
+ * (base64url of a JSON array). The shape is intentionally opaque to clients —
4
+ * only this module reads it back.
5
+ *
6
+ * `Date` values are encoded as `{ $d: <iso> }` so they round-trip to `Date`
7
+ * instances on decode (plain JSON would yield a string and break date keyset
8
+ * comparisons).
9
+ */
10
+ export function encodeCursor(values) {
11
+ // Pre-map Date values to a tagged form. We cannot detect dates in the
12
+ // JSON.stringify replacer because Date.toJSON() has already converted them to
13
+ // strings by the time the replacer sees the value.
14
+ const tagged = values.map((v) => (v instanceof Date ? { $d: v.toISOString() } : v));
15
+ const json = JSON.stringify(tagged);
16
+ return Buffer.from(json, 'utf8').toString('base64url');
17
+ }
18
+ /**
19
+ * Decodes an opaque cursor string back into its keyset values. Returns `null`
20
+ * when the cursor is malformed (bad base64, bad JSON, or not an array) so the
21
+ * caller can ignore an invalid cursor instead of crashing.
22
+ */
23
+ export function decodeCursor(cursor) {
24
+ try {
25
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
26
+ const parsed = JSON.parse(json, (_key, value) => {
27
+ if (value && typeof value === 'object' && typeof value.$d === 'string') {
28
+ return new Date(value.$d);
29
+ }
30
+ return value;
31
+ });
32
+ return Array.isArray(parsed) ? parsed : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Builds the keyset {@link SortItem}[] for cursor pagination: the caller's
40
+ * effective sorts, with a stable primary-key tiebreaker appended if it is not
41
+ * already present. The tiebreaker inherits the direction of the last sort column
42
+ * so the overall ordering stays monotonic (important for a correct
43
+ * `(cols, pk) > (...)` comparison).
44
+ */
45
+ export function buildKeyset(sorts, primaryKey) {
46
+ const hasPk = sorts.some((s) => s.field === primaryKey);
47
+ if (hasPk)
48
+ return sorts;
49
+ const lastDirection = sorts[sorts.length - 1]?.direction ?? 'asc';
50
+ return [...sorts, { field: primaryKey, direction: lastDirection }];
51
+ }
52
+ /** Flips every keyset column's direction (for backward cursor paging). */
53
+ export function reverseKeyset(keyset) {
54
+ return keyset.map((s) => ({
55
+ field: s.field,
56
+ direction: s.direction === 'asc' ? 'desc' : 'asc',
57
+ }));
58
+ }
59
+ /**
60
+ * Extracts the keyset values from a fetched row, in keyset column order.
61
+ * Supports dotted relation paths (e.g. `author.name`) by walking the object.
62
+ */
63
+ export function extractCursorValues(row, keyset) {
64
+ return keyset.map((s) => {
65
+ if (!s.field.includes('.'))
66
+ return row[s.field];
67
+ let current = row;
68
+ for (const segment of s.field.split('.')) {
69
+ if (current == null || typeof current !== 'object')
70
+ return undefined;
71
+ current = current[segment];
72
+ }
73
+ return current;
74
+ });
75
+ }
76
+ /**
77
+ * Assembles a {@link CursorPage} from the rows fetched for a keyset query.
78
+ *
79
+ * The query is expected to have been built by {@link applyCursor}, which fetches
80
+ * one extra row (`limit = size + 1`) so we can detect a further page. For
81
+ * backward paging the rows come back reversed and are flipped here to restore
82
+ * the caller's requested order. Boundary cursors are computed from the base
83
+ * keyset so they round-trip regardless of paging direction.
84
+ */
85
+ export function buildCursorPage(rows, resolved) {
86
+ const { keyset, size, backward, hasCursor } = resolved;
87
+ const hasExtra = rows.length > size;
88
+ let pageRows = hasExtra ? rows.slice(0, size) : rows;
89
+ if (backward)
90
+ pageRows = pageRows.slice().reverse();
91
+ const firstRow = pageRows[0];
92
+ const lastRow = pageRows[pageRows.length - 1];
93
+ const startCursor = firstRow ? encodeCursor(extractCursorValues(firstRow, keyset)) : null;
94
+ const endCursor = lastRow ? encodeCursor(extractCursorValues(lastRow, keyset)) : null;
95
+ // With a cursor present, the opposite-direction page is known to exist; the
96
+ // same-direction page exists iff we saw the extra row.
97
+ const hasNext = backward ? hasCursor : hasExtra;
98
+ const hasPrev = backward ? hasExtra : hasCursor;
99
+ return {
100
+ items: pageRows,
101
+ nextCursor: hasNext ? endCursor : null,
102
+ prevCursor: hasPrev ? startCursor : null,
103
+ hasNext,
104
+ hasPrev,
105
+ };
106
+ }
107
+ //# sourceMappingURL=cursor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor.js","sourceRoot":"","sources":["../../src/cursor.ts"],"names":[],"mappings":"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"}