@nifrajs/schema 1.0.0-beta.4 → 1.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.
package/dist/adapter.d.ts CHANGED
@@ -28,6 +28,14 @@ export type NifraSchema<T extends TSchema = TSchema> = StandardSchemaV1<Static<T
28
28
  *
29
29
  * Exposed (not just used internally by `t`) so a TypeBox schema authored directly can
30
30
  * be adapted without leaving the Standard Schema world.
31
+ *
32
+ * `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
33
+ * BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
34
+ * with a numeric field can't validate without this — it's how `t.pageQuery` yields a real `number`.
35
+ * Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
36
+ * would silently accept `"20"` where the contract said `20`.
31
37
  */
32
- export declare function fromTypeBox<T extends TSchema>(schema: T): NifraSchema<T>;
38
+ export declare function fromTypeBox<T extends TSchema>(schema: T, options?: {
39
+ readonly coerce?: boolean;
40
+ }): NifraSchema<T>;
33
41
  //# sourceMappingURL=adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiC,gBAAgB,EAAiB,MAAM,eAAe,CAAA;AACnG,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAKxD;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG;IAC9F,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;CACvB,CAAA;AAeD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAiCxE"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiC,gBAAgB,EAAiB,MAAM,eAAe,CAAA;AACnG,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAKxD;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO,IAAI,gBAAgB,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG;IAC9F,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAA;CACvB,CAAA;AAeD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,OAAO,EAC3C,MAAM,EAAE,CAAC,EACT,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACtC,WAAW,CAAC,CAAC,CAAC,CAsChB"}
package/dist/adapter.js CHANGED
@@ -30,8 +30,15 @@ function toIssue(error) {
30
30
  *
31
31
  * Exposed (not just used internally by `t`) so a TypeBox schema authored directly can
32
32
  * be adapted without leaving the Standard Schema world.
33
+ *
34
+ * `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
35
+ * BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
36
+ * with a numeric field can't validate without this — it's how `t.pageQuery` yields a real `number`.
37
+ * Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
38
+ * would silently accept `"20"` where the contract said `20`.
33
39
  */
34
- export function fromTypeBox(schema) {
40
+ export function fromTypeBox(schema, options) {
41
+ const coerce = options?.coerce ?? false;
35
42
  let compiled;
36
43
  let evalFree = false;
37
44
  return {
@@ -43,6 +50,10 @@ export function fromTypeBox(schema) {
43
50
  // reachable path (not a top-level import side effect) so a production bundle can't
44
51
  // tree-shake the registration away — see ./formats.ts. Idempotent, ~free after first call.
45
52
  ensureDefaultFormats();
53
+ // Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
54
+ // compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
55
+ // integer) is left as-is and fails Check → a proper 400.
56
+ const input = coerce ? Value.Convert(schema, value) : value;
46
57
  if (compiled === undefined && !evalFree) {
47
58
  try {
48
59
  compiled = TypeCompiler.Compile(schema);
@@ -53,13 +64,13 @@ export function fromTypeBox(schema) {
53
64
  }
54
65
  }
55
66
  if (compiled !== undefined) {
56
- if (compiled.Check(value))
57
- return { value };
58
- return { issues: [...compiled.Errors(value)].map(toIssue) };
67
+ if (compiled.Check(input))
68
+ return { value: input };
69
+ return { issues: [...compiled.Errors(input)].map(toIssue) };
59
70
  }
60
- if (Value.Check(schema, value))
61
- return { value };
62
- return { issues: [...Value.Errors(schema, value)].map(toIssue) };
71
+ if (Value.Check(schema, input))
72
+ return { value: input };
73
+ return { issues: [...Value.Errors(schema, input)].map(toIssue) };
63
74
  },
64
75
  // Phantom: `types` carries no runtime value; this cast supplies the
65
76
  // compile-time `Static<T>` that nifra's `InferOutput` reads to type `c.body`.
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,YAAY,EAAE,MAAM,4BAA4B,CAAA;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAA;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAcnD;;;;;GAKG;AACH,SAAS,OAAO,CAAC,KAA0D;IACzE,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC;KACrE,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,WAAW,CAAoB,MAAS;IACtD,IAAI,QAAkC,CAAA;IACtC,IAAI,QAAQ,GAAG,KAAK,CAAA;IACpB,OAAO;QACL,WAAW,EAAE;YACX,OAAO,EAAE,CAAC;YACV,MAAM,EAAE,OAAO;YACf,QAAQ,EAAE,CAAC,KAAc,EAA6B,EAAE;gBACtD,uFAAuF;gBACvF,mFAAmF;gBACnF,2FAA2F;gBAC3F,oBAAoB,EAAE,CAAA;gBACtB,IAAI,QAAQ,KAAK,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;oBACxC,IAAI,CAAC;wBACH,QAAQ,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;oBACzC,CAAC;oBAAC,MAAM,CAAC;wBACP,8EAA8E;wBAC9E,QAAQ,GAAG,IAAI,CAAA;oBACjB,CAAC;gBACH,CAAC;gBACD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC;wBAAE,OAAO,EAAE,KAAK,EAAE,CAAA;oBAC3C,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;gBAC7D,CAAC;gBACD,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC;oBAAE,OAAO,EAAE,KAAK,EAAE,CAAA;gBAChD,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;YAClE,CAAC;YACD,oEAAoE;YACpE,8EAA8E;YAC9E,KAAK,EAAE,SAA2D;SACnE;QACD,UAAU,EAAE,MAAM;KACnB,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"adapter.js","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,YAAY,EAAE,MAAM,4BAA4B,CAAA;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAA;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AAcnD;;;;;GAKG;AACH,SAAS,OAAO,CAAC,KAA0D;IACzE,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC;KACrE,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,WAAW,CACzB,MAAS,EACT,OAAuC;IAEvC,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM,IAAI,KAAK,CAAA;IACvC,IAAI,QAAkC,CAAA;IACtC,IAAI,QAAQ,GAAG,KAAK,CAAA;IACpB,OAAO;QACL,WAAW,EAAE;YACX,OAAO,EAAE,CAAC;YACV,MAAM,EAAE,OAAO;YACf,QAAQ,EAAE,CAAC,KAAc,EAA6B,EAAE;gBACtD,uFAAuF;gBACvF,mFAAmF;gBACnF,2FAA2F;gBAC3F,oBAAoB,EAAE,CAAA;gBACtB,gGAAgG;gBAChG,kGAAkG;gBAClG,yDAAyD;gBACzD,MAAM,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;gBAC3D,IAAI,QAAQ,KAAK,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;oBACxC,IAAI,CAAC;wBACH,QAAQ,GAAG,YAAY,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;oBACzC,CAAC;oBAAC,MAAM,CAAC;wBACP,8EAA8E;wBAC9E,QAAQ,GAAG,IAAI,CAAA;oBACjB,CAAC;gBACH,CAAC;gBACD,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,IAAI,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC;wBAAE,OAAO,EAAE,KAAK,EAAE,KAAkB,EAAE,CAAA;oBAC/D,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;gBAC7D,CAAC;gBACD,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC;oBAAE,OAAO,EAAE,KAAK,EAAE,KAAkB,EAAE,CAAA;gBACpE,OAAO,EAAE,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAA;YAClE,CAAC;YACD,oEAAoE;YACpE,8EAA8E;YAC9E,KAAK,EAAE,SAA2D;SACnE;QACD,UAAU,EAAE,MAAM;KACnB,CAAA;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -9,5 +9,6 @@
9
9
  export { fromTypeBox, type NifraSchema } from "./adapter.js";
10
10
  export { registerFormat } from "./formats.js";
11
11
  export { type OpenAPIDocument, type OpenAPIInfo, toOpenAPI } from "./openapi.js";
12
+ export { decodeCursor, encodeCursor, type Page, paginate } from "./pagination.js";
12
13
  export { t } from "./t.js";
13
14
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAI5D,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,WAAW,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAE,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAI5D,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAAE,KAAK,eAAe,EAAE,KAAK,WAAW,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAEhF,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACjF,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA"}
package/dist/index.js CHANGED
@@ -12,5 +12,7 @@ export { fromTypeBox } from "./adapter.js";
12
12
  // effect of this module, which a production bundler would tree-shake away.
13
13
  export { registerFormat } from "./formats.js";
14
14
  export { toOpenAPI } from "./openapi.js";
15
+ // Cursor-pagination runtime helpers — pair with `t.paginated` / `t.pageQuery`.
16
+ export { decodeCursor, encodeCursor, paginate } from "./pagination.js";
15
17
  export { t } from "./t.js";
16
18
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAA;AAC5D,gGAAgG;AAChG,8FAA8F;AAC9F,2EAA2E;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAA0C,SAAS,EAAE,MAAM,cAAc,CAAA;AAChF,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAA;AAC5D,gGAAgG;AAChG,8FAA8F;AAC9F,2EAA2E;AAC3E,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAC7C,OAAO,EAA0C,SAAS,EAAE,MAAM,cAAc,CAAA;AAChF,+EAA+E;AAC/E,OAAO,EAAE,YAAY,EAAE,YAAY,EAAa,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACjF,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAA"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Cursor-pagination runtime helpers — an opaque cursor codec + a page builder. Pair with `t.paginated`
3
+ * (the response schema) and `t.pageQuery` (the request schema).
4
+ *
5
+ * Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
6
+ * Workers. Cursors are opaque but NOT signed — treat a decoded cursor as untrusted client input: validate
7
+ * its shape before keying a DB query off it (it's a position hint, not an authorization token).
8
+ */
9
+ /** Encode any JSON-serializable value (e.g. the last row's sort key) into an opaque cursor string. */
10
+ export declare function encodeCursor(value: unknown): string;
11
+ /** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor — treat that
12
+ * as "start from the beginning" rather than erroring on a client-supplied string. */
13
+ export declare function decodeCursor<T = unknown>(cursor: string | null | undefined): T | undefined;
14
+ /** A cursor-pagination page — matches the shape of `t.paginated(item)`. */
15
+ export interface Page<Item> {
16
+ readonly items: Item[];
17
+ readonly nextCursor: string | null;
18
+ }
19
+ /**
20
+ * Build a page from rows you fetched with `limit + 1`. If the extra row came back there are more pages:
21
+ * drop it and emit a `nextCursor` from the last KEPT row via `cursorOf`; otherwise `nextCursor` is `null`.
22
+ *
23
+ * const rows = await db.notes.after(decodeCursor(query.cursor)).take(limit + 1)
24
+ * return paginate(rows, limit, (note) => note.id)
25
+ */
26
+ export declare function paginate<Row>(rows: readonly Row[], limit: number, cursorOf: (row: Row) => unknown): Page<Row>;
27
+ //# sourceMappingURL=pagination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAiBH,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEnD;AAED;qFACqF;AACrF,wBAAgB,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,CAAC,GAAG,SAAS,CAO1F;AAED,2EAA2E;AAC3E,MAAM,WAAW,IAAI,CAAC,IAAI;IACxB,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,CAAA;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAC1B,IAAI,EAAE,SAAS,GAAG,EAAE,EACpB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,GAC9B,IAAI,CAAC,GAAG,CAAC,CAMX"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Cursor-pagination runtime helpers — an opaque cursor codec + a page builder. Pair with `t.paginated`
3
+ * (the response schema) and `t.pageQuery` (the request schema).
4
+ *
5
+ * Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
6
+ * Workers. Cursors are opaque but NOT signed — treat a decoded cursor as untrusted client input: validate
7
+ * its shape before keying a DB query off it (it's a position hint, not an authorization token).
8
+ */
9
+ /** UTF-8 → URL-safe base64 (no padding). */
10
+ function toBase64Url(input) {
11
+ const bytes = new TextEncoder().encode(input);
12
+ let binary = "";
13
+ for (const byte of bytes)
14
+ binary += String.fromCharCode(byte);
15
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
16
+ }
17
+ /** URL-safe base64 → UTF-8. Throws on malformed input (callers below catch it). */
18
+ function fromBase64Url(input) {
19
+ const binary = atob(input.replace(/-/g, "+").replace(/_/g, "/"));
20
+ const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
21
+ return new TextDecoder().decode(bytes);
22
+ }
23
+ /** Encode any JSON-serializable value (e.g. the last row's sort key) into an opaque cursor string. */
24
+ export function encodeCursor(value) {
25
+ return toBase64Url(JSON.stringify(value));
26
+ }
27
+ /** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor — treat that
28
+ * as "start from the beginning" rather than erroring on a client-supplied string. */
29
+ export function decodeCursor(cursor) {
30
+ if (cursor === null || cursor === undefined || cursor === "")
31
+ return undefined;
32
+ try {
33
+ return JSON.parse(fromBase64Url(cursor));
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Build a page from rows you fetched with `limit + 1`. If the extra row came back there are more pages:
41
+ * drop it and emit a `nextCursor` from the last KEPT row via `cursorOf`; otherwise `nextCursor` is `null`.
42
+ *
43
+ * const rows = await db.notes.after(decodeCursor(query.cursor)).take(limit + 1)
44
+ * return paginate(rows, limit, (note) => note.id)
45
+ */
46
+ export function paginate(rows, limit, cursorOf) {
47
+ const hasMore = rows.length > limit;
48
+ const items = hasMore ? rows.slice(0, limit) : [...rows];
49
+ const last = items[items.length - 1];
50
+ const nextCursor = hasMore && last !== undefined ? encodeCursor(cursorOf(last)) : null;
51
+ return { items, nextCursor };
52
+ }
53
+ //# sourceMappingURL=pagination.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination.js","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,4CAA4C;AAC5C,SAAS,WAAW,CAAC,KAAa;IAChC,MAAM,KAAK,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC7C,IAAI,MAAM,GAAG,EAAE,CAAA;IACf,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,MAAM,IAAI,MAAM,CAAC,YAAY,CAAC,IAAI,CAAC,CAAA;IAC7D,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;AAChF,CAAC;AAED,mFAAmF;AACnF,SAAS,aAAa,CAAC,KAAa;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAA;IAChE,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAA;IAC/D,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxC,CAAC;AAED,sGAAsG;AACtG,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,OAAO,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;AAC3C,CAAC;AAED;qFACqF;AACrF,MAAM,UAAU,YAAY,CAAc,MAAiC;IACzE,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,EAAE;QAAE,OAAO,SAAS,CAAA;IAC9E,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAM,CAAA;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAQD;;;;;;GAMG;AACH,MAAM,UAAU,QAAQ,CACtB,IAAoB,EACpB,KAAa,EACb,QAA+B;IAE/B,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,GAAG,KAAK,CAAA;IACnC,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IACxD,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACpC,MAAM,UAAU,GAAG,OAAO,IAAI,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACtF,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,CAAA;AAC9B,CAAC"}
package/dist/t.d.ts CHANGED
@@ -29,6 +29,21 @@ export declare const t: {
29
29
  readonly optional: <T extends TSchema>(schema: NifraSchema<T>) => NifraSchema<T extends import("@sinclair/typebox").TOptional<infer S extends TSchema> ? import("@sinclair/typebox").TOptional<S> : import("@sinclair/typebox").Ensure<import("@sinclair/typebox").TOptional<T>>>;
30
30
  readonly union: <const S extends readonly NifraSchema[]>(schemas: S) => NifraSchema<TUnion<{ -readonly [K in keyof S]: S[K]["jsonSchema"]; }>>;
31
31
  readonly record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) => NifraSchema<import("@sinclair/typebox").TRecord<import("@sinclair/typebox").TString, T>>;
32
+ /** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
33
+ readonly paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) => NifraSchema<import("@sinclair/typebox").TObject<{
34
+ items: import("@sinclair/typebox").TArray<T>;
35
+ nextCursor: TUnion<[import("@sinclair/typebox").TString, import("@sinclair/typebox").TNull]>;
36
+ }>>;
37
+ /** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
38
+ * caps `limit` — a larger value fails validation (a 400), so a client can't request an unbounded page.
39
+ * `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
40
+ * a real `number` in the handler (`c.query.limit`), not a string. */
41
+ readonly pageQuery: (options?: {
42
+ maxLimit?: number;
43
+ }) => NifraSchema<import("@sinclair/typebox").TObject<{
44
+ cursor: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TString>;
45
+ limit: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TInteger>;
46
+ }>>;
32
47
  };
33
48
  export {};
34
49
  //# sourceMappingURL=t.d.ts.map
package/dist/t.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"t.d.ts","sourceRoot":"","sources":["../src/t.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,OAAO,EACZ,KAAK,MAAM,EAEZ,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAE5D,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;AAUxC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,CAAC;gCACO,aAAa;gCACb,aAAa;iCACZ,cAAc;;;6BAGlB,CAAC,SAAS,aAAa,SAAS,CAAC;sBAMxC,CAAC,SAAS,KAAK,SAAS,CAAC,YAAY,aAAa,wDA/Bd,CAAC;IAiC9C;qFACiF;2BACnE,CAAC,SAAS,KAAK,SAAS,CAAC,YAAY,aAAa,wDAnCnB,CAAC;qBAqCtC,CAAC,SAAS,OAAO,QAAQ,WAAW,CAAC,CAAC,CAAC,YAAY,YAAY;IAEvE,qFAAqF;wBAC1E,CAAC,SAAS,OAAO,UAAU,WAAW,CAAC,CAAC,CAAC;2BAOtC,CAAC,SAAS,SAAS,WAAW,EAAE,WAAW,CAAC,KAGnD,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,GAAE,CAAC,CAAC;sBACnE,CAAC,SAAS,OAAO,SAAS,WAAW,CAAC,CAAC,CAAC,YAAY,aAAa;CAElE,CAAA"}
1
+ {"version":3,"file":"t.d.ts","sourceRoot":"","sources":["../src/t.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,OAAO,EACZ,KAAK,MAAM,EAEZ,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,cAAc,CAAA;AAE5D,KAAK,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;AAUxC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,CAAC;gCACO,aAAa;gCACb,aAAa;iCACZ,cAAc;;;6BAGlB,CAAC,SAAS,aAAa,SAAS,CAAC;sBAMxC,CAAC,SAAS,KAAK,SAAS,CAAC,YAAY,aAAa,wDA/Bd,CAAC;IAiC9C;qFACiF;2BACnE,CAAC,SAAS,KAAK,SAAS,CAAC,YAAY,aAAa,wDAnCnB,CAAC;qBAqCtC,CAAC,SAAS,OAAO,QAAQ,WAAW,CAAC,CAAC,CAAC,YAAY,YAAY;IAEvE,qFAAqF;wBAC1E,CAAC,SAAS,OAAO,UAAU,WAAW,CAAC,CAAC,CAAC;2BAOtC,CAAC,SAAS,SAAS,WAAW,EAAE,WAAW,CAAC,KAGnD,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,GAAE,CAAC,CAAC;sBACnE,CAAC,SAAS,OAAO,SAAS,WAAW,CAAC,CAAC,CAAC,YAAY,aAAa;IAM1E,+GAA+G;yBACnG,CAAC,SAAS,OAAO,QAAQ,WAAW,CAAC,CAAC,CAAC,YAAY,aAAa;;;;IAU5E;;;yEAGqE;mCAC/C;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE;;;;CAWnC,CAAA"}
package/dist/t.js CHANGED
@@ -45,5 +45,21 @@ export const t = {
45
45
  // (order/length are preserved by `map`, so the tuple shape is sound).
46
46
  union: (schemas) => fromTypeBox(Type.Union(schemas.map((schema) => schema.jsonSchema))),
47
47
  record: (value, options) => fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
48
+ // Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
49
+ // itself during inference. Cursor pagination — not OFFSET — is the production default: stable under
50
+ // concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
51
+ /** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
52
+ paginated: (item, options) => fromTypeBox(Type.Object({
53
+ items: Type.Array(item.jsonSchema),
54
+ nextCursor: Type.Union([Type.String(), Type.Null()]),
55
+ }, { additionalProperties: false, ...options })),
56
+ /** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
57
+ * caps `limit` — a larger value fails validation (a 400), so a client can't request an unbounded page.
58
+ * `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
59
+ * a real `number` in the handler (`c.query.limit`), not a string. */
60
+ pageQuery: (options) => fromTypeBox(Type.Object({
61
+ cursor: Type.Optional(Type.String()),
62
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: options?.maxLimit ?? 100 })),
63
+ }, { additionalProperties: false }), { coerce: true }),
48
64
  };
49
65
  //# sourceMappingURL=t.js.map
package/dist/t.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"t.js","sourceRoot":"","sources":["../src/t.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,IAAI,GACL,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,WAAW,EAAoB,MAAM,cAAc,CAAA;AAI5D,gFAAgF;AAChF,SAAS,MAAM,CAAkB,KAAQ;IACvC,MAAM,GAAG,GAA4B,EAAE,CAAA;IACvC,wEAAwE;IACxE,KAAK,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,UAAU,CAAA;IAC/E,OAAO,GAA6C,CAAA;AACtD,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,CAAC,GAAG;IACf,MAAM,EAAE,CAAC,OAAuB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtE,MAAM,EAAE,CAAC,OAAuB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtE,OAAO,EAAE,CAAC,OAAwB,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACzE,OAAO,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;IAC1C,IAAI,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;IACpC,OAAO,EAAE,CAAgC,KAAQ,EAAE,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;IAEtF,oGAAoG;IACpG,qGAAqG;IACrG,qGAAqG;IACrG,oGAAoG;IACpG,MAAM,EAAE,CAAkB,KAAQ,EAAE,OAAuB,EAAE,EAAE,CAC7D,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,KAAK,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACtF;qFACiF;IACjF,WAAW,EAAE,CAAkB,KAAQ,EAAE,OAAuB,EAAE,EAAE,CAClE,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,oBAAoB,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC;IACrF,KAAK,EAAE,CAAoB,IAAoB,EAAE,OAAsB,EAAE,EAAE,CACzE,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACnD,qFAAqF;IACrF,QAAQ,EAAE,CAAoB,MAAsB,EAAE,EAAE,CACtD,WAAW,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAC/C,iFAAiF;IACjF,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,sEAAsE;IACtE,KAAK,EAAE,CAAyC,OAAU,EAAE,EAAE,CAC5D,WAAW,CACT,IAAI,CAAC,KAAK,CAAE,OAAkC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CACV;IAC5E,MAAM,EAAE,CAAoB,KAAqB,EAAE,OAAuB,EAAE,EAAE,CAC5E,WAAW,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;CAC5D,CAAA"}
1
+ {"version":3,"file":"t.js","sourceRoot":"","sources":["../src/t.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/schema",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.1.0",
4
4
  "description": "Built-in schema builder `t` — TypeBox-backed (free JSON Schema + compiled validators), implements Standard Schema so nifra routes validate it natively.",
5
5
  "keywords": [
6
6
  "bun",
@@ -48,7 +48,7 @@
48
48
  "@sinclair/typebox": "^0.34.49"
49
49
  },
50
50
  "peerDependencies": {
51
- "@nifrajs/core": "1.0.0-beta.4"
51
+ "@nifrajs/core": "^1.1.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@nifrajs/core": "workspace:*",
package/src/adapter.ts CHANGED
@@ -46,8 +46,18 @@ function toIssue(error: { readonly message: string; readonly path: string }): St
46
46
  *
47
47
  * Exposed (not just used internally by `t`) so a TypeBox schema authored directly can
48
48
  * be adapted without leaving the Standard Schema world.
49
+ *
50
+ * `options.coerce` runs TypeBox's `Value.Convert` (string→number/integer/boolean, per the schema)
51
+ * BEFORE validating. Query values always arrive as strings (`?limit=20` → `"20"`), so a query schema
52
+ * with a numeric field can't validate without this — it's how `t.pageQuery` yields a real `number`.
53
+ * Leave it OFF (the default) for body/JSON schemas: a JSON number is already a number, and coercing
54
+ * would silently accept `"20"` where the contract said `20`.
49
55
  */
50
- export function fromTypeBox<T extends TSchema>(schema: T): NifraSchema<T> {
56
+ export function fromTypeBox<T extends TSchema>(
57
+ schema: T,
58
+ options?: { readonly coerce?: boolean },
59
+ ): NifraSchema<T> {
60
+ const coerce = options?.coerce ?? false
51
61
  let compiled: TypeCheck<T> | undefined
52
62
  let evalFree = false
53
63
  return {
@@ -59,6 +69,10 @@ export function fromTypeBox<T extends TSchema>(schema: T): NifraSchema<T> {
59
69
  // reachable path (not a top-level import side effect) so a production bundle can't
60
70
  // tree-shake the registration away — see ./formats.ts. Idempotent, ~free after first call.
61
71
  ensureDefaultFormats()
72
+ // Coerce first when asked (query schemas): `Value.Convert` turns "20"→20 per the schema, so the
73
+ // compiled/eval-free Check below sees the target type. A non-convertible value (e.g. "abc" for an
74
+ // integer) is left as-is and fails Check → a proper 400.
75
+ const input = coerce ? Value.Convert(schema, value) : value
62
76
  if (compiled === undefined && !evalFree) {
63
77
  try {
64
78
  compiled = TypeCompiler.Compile(schema)
@@ -68,11 +82,11 @@ export function fromTypeBox<T extends TSchema>(schema: T): NifraSchema<T> {
68
82
  }
69
83
  }
70
84
  if (compiled !== undefined) {
71
- if (compiled.Check(value)) return { value }
72
- return { issues: [...compiled.Errors(value)].map(toIssue) }
85
+ if (compiled.Check(input)) return { value: input as Static<T> }
86
+ return { issues: [...compiled.Errors(input)].map(toIssue) }
73
87
  }
74
- if (Value.Check(schema, value)) return { value }
75
- return { issues: [...Value.Errors(schema, value)].map(toIssue) }
88
+ if (Value.Check(schema, input)) return { value: input as Static<T> }
89
+ return { issues: [...Value.Errors(schema, input)].map(toIssue) }
76
90
  },
77
91
  // Phantom: `types` carries no runtime value; this cast supplies the
78
92
  // compile-time `Static<T>` that nifra's `InferOutput` reads to type `c.body`.
package/src/index.ts CHANGED
@@ -12,4 +12,6 @@ export { fromTypeBox, type NifraSchema } from "./adapter.ts"
12
12
  // effect of this module, which a production bundler would tree-shake away.
13
13
  export { registerFormat } from "./formats.ts"
14
14
  export { type OpenAPIDocument, type OpenAPIInfo, toOpenAPI } from "./openapi.ts"
15
+ // Cursor-pagination runtime helpers — pair with `t.paginated` / `t.pageQuery`.
16
+ export { decodeCursor, encodeCursor, type Page, paginate } from "./pagination.ts"
15
17
  export { t } from "./t.ts"
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Cursor-pagination runtime helpers — an opaque cursor codec + a page builder. Pair with `t.paginated`
3
+ * (the response schema) and `t.pageQuery` (the request schema).
4
+ *
5
+ * Edge-safe: `btoa`/`atob` + `TextEncoder`/`TextDecoder` (no `Buffer`), so it runs on Bun/Node/Deno/
6
+ * Workers. Cursors are opaque but NOT signed — treat a decoded cursor as untrusted client input: validate
7
+ * its shape before keying a DB query off it (it's a position hint, not an authorization token).
8
+ */
9
+
10
+ /** UTF-8 → URL-safe base64 (no padding). */
11
+ function toBase64Url(input: string): string {
12
+ const bytes = new TextEncoder().encode(input)
13
+ let binary = ""
14
+ for (const byte of bytes) binary += String.fromCharCode(byte)
15
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
16
+ }
17
+
18
+ /** URL-safe base64 → UTF-8. Throws on malformed input (callers below catch it). */
19
+ function fromBase64Url(input: string): string {
20
+ const binary = atob(input.replace(/-/g, "+").replace(/_/g, "/"))
21
+ const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0))
22
+ return new TextDecoder().decode(bytes)
23
+ }
24
+
25
+ /** Encode any JSON-serializable value (e.g. the last row's sort key) into an opaque cursor string. */
26
+ export function encodeCursor(value: unknown): string {
27
+ return toBase64Url(JSON.stringify(value))
28
+ }
29
+
30
+ /** Decode a cursor back to its value. Returns `undefined` for a null/empty/malformed cursor — treat that
31
+ * as "start from the beginning" rather than erroring on a client-supplied string. */
32
+ export function decodeCursor<T = unknown>(cursor: string | null | undefined): T | undefined {
33
+ if (cursor === null || cursor === undefined || cursor === "") return undefined
34
+ try {
35
+ return JSON.parse(fromBase64Url(cursor)) as T
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ /** A cursor-pagination page — matches the shape of `t.paginated(item)`. */
42
+ export interface Page<Item> {
43
+ readonly items: Item[]
44
+ readonly nextCursor: string | null
45
+ }
46
+
47
+ /**
48
+ * Build a page from rows you fetched with `limit + 1`. If the extra row came back there are more pages:
49
+ * drop it and emit a `nextCursor` from the last KEPT row via `cursorOf`; otherwise `nextCursor` is `null`.
50
+ *
51
+ * const rows = await db.notes.after(decodeCursor(query.cursor)).take(limit + 1)
52
+ * return paginate(rows, limit, (note) => note.id)
53
+ */
54
+ export function paginate<Row>(
55
+ rows: readonly Row[],
56
+ limit: number,
57
+ cursorOf: (row: Row) => unknown,
58
+ ): Page<Row> {
59
+ const hasMore = rows.length > limit
60
+ const items = hasMore ? rows.slice(0, limit) : [...rows]
61
+ const last = items[items.length - 1]
62
+ const nextCursor = hasMore && last !== undefined ? encodeCursor(cursorOf(last)) : null
63
+ return { items, nextCursor }
64
+ }
package/src/t.ts CHANGED
@@ -67,4 +67,34 @@ export const t = {
67
67
  ) as NifraSchema<TUnion<{ -readonly [K in keyof S]: S[K]["jsonSchema"] }>>,
68
68
  record: <T extends TSchema>(value: NifraSchema<T>, options?: ObjectOptions) =>
69
69
  fromTypeBox(Type.Record(Type.String(), value.jsonSchema, options)),
70
+
71
+ // Composed from TypeBox directly (not `t.object`/`t.array`) so the `t` literal doesn't reference
72
+ // itself during inference. Cursor pagination — not OFFSET — is the production default: stable under
73
+ // concurrent inserts and O(1) per page. Build pages with `paginate()` + `encodeCursor`/`decodeCursor`.
74
+ /** A cursor-pagination response envelope: `{ items: T[]; nextCursor: string | null }` (`null` = last page). */
75
+ paginated: <T extends TSchema>(item: NifraSchema<T>, options?: ObjectOptions) =>
76
+ fromTypeBox(
77
+ Type.Object(
78
+ {
79
+ items: Type.Array(item.jsonSchema),
80
+ nextCursor: Type.Union([Type.String(), Type.Null()]),
81
+ },
82
+ { additionalProperties: false, ...options },
83
+ ),
84
+ ),
85
+ /** A request query schema for cursor pagination: `{ cursor?: string; limit?: number }`. `maxLimit`
86
+ * caps `limit` — a larger value fails validation (a 400), so a client can't request an unbounded page.
87
+ * `coerce` is on because query values arrive as strings (`?limit=20` → `"20"`); it's what makes `limit`
88
+ * a real `number` in the handler (`c.query.limit`), not a string. */
89
+ pageQuery: (options?: { maxLimit?: number }) =>
90
+ fromTypeBox(
91
+ Type.Object(
92
+ {
93
+ cursor: Type.Optional(Type.String()),
94
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: options?.maxLimit ?? 100 })),
95
+ },
96
+ { additionalProperties: false },
97
+ ),
98
+ { coerce: true },
99
+ ),
70
100
  } as const