@absolutejs/eden 0.1.0 → 0.2.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/README.md CHANGED
@@ -28,6 +28,29 @@ try {
28
28
  - `apiErrorStatus(error)` — the HTTP status carried by an error, if any.
29
29
  - `isPaywallError(error)` — true for a 402 Payment Required.
30
30
 
31
+ ## Pagination
32
+
33
+ List endpoints can share one bounded request/response contract without hiding
34
+ their concrete database query:
35
+
36
+ ```ts
37
+ import {
38
+ normalizeOffsetPage,
39
+ type OffsetPage,
40
+ } from "@absolutejs/eden";
41
+
42
+ const { limit, offset } = normalizeOffsetPage(request, {
43
+ defaultLimit: 25,
44
+ maxLimit: 100,
45
+ });
46
+
47
+ const response: OffsetPage<User> = { data: rows, total };
48
+ ```
49
+
50
+ `CursorPage<T>` is the corresponding envelope for stable keyset pagination.
51
+ `getPageCount`, `clampPageIndex`, and `getPageRange` keep client pagination math
52
+ consistent with the server contract.
53
+
31
54
  Zero dependencies; works in the browser and on the server.
32
55
 
33
56
  Apache-2.0
package/dist/index.d.ts CHANGED
@@ -25,3 +25,30 @@ export declare const apiErrorStatus: (error: unknown) => number | undefined;
25
25
  /** True when a thrown error is a 402 Payment Required (paywall) — distinct from
26
26
  * a real failure, so a locked premium surface can render an upgrade nudge. */
27
27
  export declare const isPaywallError: (error: unknown) => boolean;
28
+ /** Standard response for offset-paginated APIs. */
29
+ export type OffsetPage<T> = {
30
+ data: T[];
31
+ total: number;
32
+ };
33
+ /** Standard response for stable cursor/keyset-paginated APIs. */
34
+ export type CursorPage<T, Cursor = string> = {
35
+ data: T[];
36
+ nextCursor: Cursor | null;
37
+ };
38
+ export type OffsetPageRequest = {
39
+ limit?: number;
40
+ offset?: number;
41
+ };
42
+ export type PaginationBounds = {
43
+ defaultLimit: number;
44
+ maxLimit: number;
45
+ };
46
+ /** Clamp untrusted list-query input to safe integer bounds. */
47
+ export declare const normalizeOffsetPage: (request: OffsetPageRequest, bounds: PaginationBounds) => Required<OffsetPageRequest>;
48
+ export declare const getPageCount: (total: number, pageSize: number) => number;
49
+ export declare const clampPageIndex: (pageIndex: number, total: number, pageSize: number) => number;
50
+ /** One-based visible range; an empty result is `{ start: 0, end: 0 }`. */
51
+ export declare const getPageRange: (pageIndex: number, pageSize: number, rowCount: number, total: number) => {
52
+ end: number;
53
+ start: number;
54
+ };
package/dist/index.js CHANGED
@@ -36,12 +36,37 @@ var apiErrorStatus = (error) => {
36
36
  return readNumberField(error, "status");
37
37
  };
38
38
  var isPaywallError = (error) => apiErrorStatus(error) === HTTP_PAYMENT_REQUIRED;
39
+ var positiveInteger = (value, fallback) => Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
40
+ var normalizeOffsetPage = (request, bounds) => {
41
+ const maxLimit = positiveInteger(bounds.maxLimit, 1);
42
+ const defaultLimit = Math.min(positiveInteger(bounds.defaultLimit, maxLimit), maxLimit);
43
+ const requestedLimit = positiveInteger(request.limit ?? defaultLimit, defaultLimit);
44
+ const requestedOffset = request.offset ?? 0;
45
+ return {
46
+ limit: Math.min(requestedLimit, maxLimit),
47
+ offset: Number.isFinite(requestedOffset) && requestedOffset > 0 ? Math.floor(requestedOffset) : 0
48
+ };
49
+ };
50
+ var getPageCount = (total, pageSize) => Math.max(1, Math.ceil(Math.max(0, total) / positiveInteger(pageSize, 1)));
51
+ var clampPageIndex = (pageIndex, total, pageSize) => Math.max(0, Math.min(Math.floor(Math.max(0, pageIndex)), getPageCount(total, pageSize) - 1));
52
+ var getPageRange = (pageIndex, pageSize, rowCount, total) => {
53
+ if (total <= 0 || rowCount <= 0)
54
+ return { end: 0, start: 0 };
55
+ const safePageSize = positiveInteger(pageSize, 1);
56
+ const safePage = clampPageIndex(pageIndex, total, safePageSize);
57
+ const start = safePage * safePageSize + 1;
58
+ return { end: Math.min(start + rowCount - 1, total), start };
59
+ };
39
60
  export {
61
+ normalizeOffsetPage,
40
62
  isPaywallError,
63
+ getPageRange,
64
+ getPageCount,
65
+ clampPageIndex,
41
66
  apiErrorStatus,
42
67
  apiErrorMessage,
43
68
  apiError
44
69
  };
45
70
 
46
- //# debugId=7B3D4B24F776793364756E2164756E21
71
+ //# debugId=C2658C62716FB0BA64756E2164756E21
47
72
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * @absolutejs/eden — error helpers for Eden Treaty clients.\n *\n * Eden surfaces a failed call as `{ status, value }` (or throws an arbitrary\n * value). Most call sites `throw error.value`, which drops the HTTP status —\n * so a 402 paywall, a 409 conflict, and a 500 all look the same downstream.\n * These helpers normalize any thrown value into a real `Error` that KEEPS the\n * status, plus a robust message extractor and a couple of status predicates.\n *\n * Zero dependencies — works in the browser and on the server.\n */\n\nconst readStringField = (value: object, key: string): string | null => {\n const field: unknown = Reflect.get(value, key);\n\n return typeof field === \"string\" && field.trim() ? field : null;\n};\n\nconst readNumberField = (value: object, key: string): number | undefined => {\n const field: unknown = Reflect.get(value, key);\n\n return typeof field === \"number\" ? field : undefined;\n};\n\n/** An Error carrying the originating HTTP status, when known. */\nexport type ApiError = Error & { status?: number };\n\nconst HTTP_PAYMENT_REQUIRED = 402;\n\n// Eden treaty surfaces failures as `{ status, value }` — `value` is the body\n// (the message), `status` the HTTP code.\nconst isEdenError = (\n error: unknown,\n): error is { status: number; value: unknown } =>\n typeof error === \"object\" &&\n error !== null &&\n \"value\" in error &&\n readNumberField(error, \"status\") !== undefined;\n\n// Non-recursive core: turn an arbitrary thrown value into an Error. Kept\n// separate from `apiError` so the eden-unwrap path doesn't self-reference it.\nconst toError = (error: unknown, fallback?: string): ApiError => {\n const base: ApiError =\n error instanceof Error && error.message\n ? error\n : new Error(apiErrorMessage(error, fallback));\n\n return base;\n};\n\n/**\n * Normalize any thrown value (or an Eden `{ status, value }`) into an `Error`\n * that keeps the HTTP status. Throw this instead of `error.value`.\n */\nexport const apiError = (error: unknown, fallback?: string): ApiError => {\n if (isEdenError(error)) {\n const built = toError(error.value, fallback);\n built.status = error.status;\n\n return built;\n }\n\n return toError(error, fallback);\n};\n\n/** Best-effort human message from any thrown value. */\nexport const apiErrorMessage = (\n error: unknown,\n fallback = \"Request failed\",\n): string => {\n if (error instanceof Error && error.message) return error.message;\n if (typeof error === \"string\" && error.trim()) return error;\n if (error === null || typeof error !== \"object\") return fallback;\n\n return (\n readStringField(error, \"message\") ??\n readStringField(error, \"summary\") ??\n readStringField(error, \"error\") ??\n readStringField(error, \"status\") ??\n fallback\n );\n};\n\n/** The HTTP status carried by an error, if any (Error.status or an Eden shape). */\nexport const apiErrorStatus = (error: unknown): number | undefined => {\n if (error === null || typeof error !== \"object\") return undefined;\n\n return readNumberField(error, \"status\");\n};\n\n/** True when a thrown error is a 402 Payment Required (paywall) — distinct from\n * a real failure, so a locked premium surface can render an upgrade nudge. */\nexport const isPaywallError = (error: unknown): boolean =>\n apiErrorStatus(error) === HTTP_PAYMENT_REQUIRED;\n"
5
+ "/**\n * @absolutejs/eden — error helpers for Eden Treaty clients.\n *\n * Eden surfaces a failed call as `{ status, value }` (or throws an arbitrary\n * value). Most call sites `throw error.value`, which drops the HTTP status —\n * so a 402 paywall, a 409 conflict, and a 500 all look the same downstream.\n * These helpers normalize any thrown value into a real `Error` that KEEPS the\n * status, plus a robust message extractor and a couple of status predicates.\n *\n * Zero dependencies — works in the browser and on the server.\n */\n\nconst readStringField = (value: object, key: string): string | null => {\n const field: unknown = Reflect.get(value, key);\n\n return typeof field === \"string\" && field.trim() ? field : null;\n};\n\nconst readNumberField = (value: object, key: string): number | undefined => {\n const field: unknown = Reflect.get(value, key);\n\n return typeof field === \"number\" ? field : undefined;\n};\n\n/** An Error carrying the originating HTTP status, when known. */\nexport type ApiError = Error & { status?: number };\n\nconst HTTP_PAYMENT_REQUIRED = 402;\n\n// Eden treaty surfaces failures as `{ status, value }` — `value` is the body\n// (the message), `status` the HTTP code.\nconst isEdenError = (\n error: unknown,\n): error is { status: number; value: unknown } =>\n typeof error === \"object\" &&\n error !== null &&\n \"value\" in error &&\n readNumberField(error, \"status\") !== undefined;\n\n// Non-recursive core: turn an arbitrary thrown value into an Error. Kept\n// separate from `apiError` so the eden-unwrap path doesn't self-reference it.\nconst toError = (error: unknown, fallback?: string): ApiError => {\n const base: ApiError =\n error instanceof Error && error.message\n ? error\n : new Error(apiErrorMessage(error, fallback));\n\n return base;\n};\n\n/**\n * Normalize any thrown value (or an Eden `{ status, value }`) into an `Error`\n * that keeps the HTTP status. Throw this instead of `error.value`.\n */\nexport const apiError = (error: unknown, fallback?: string): ApiError => {\n if (isEdenError(error)) {\n const built = toError(error.value, fallback);\n built.status = error.status;\n\n return built;\n }\n\n return toError(error, fallback);\n};\n\n/** Best-effort human message from any thrown value. */\nexport const apiErrorMessage = (\n error: unknown,\n fallback = \"Request failed\",\n): string => {\n if (error instanceof Error && error.message) return error.message;\n if (typeof error === \"string\" && error.trim()) return error;\n if (error === null || typeof error !== \"object\") return fallback;\n\n return (\n readStringField(error, \"message\") ??\n readStringField(error, \"summary\") ??\n readStringField(error, \"error\") ??\n readStringField(error, \"status\") ??\n fallback\n );\n};\n\n/** The HTTP status carried by an error, if any (Error.status or an Eden shape). */\nexport const apiErrorStatus = (error: unknown): number | undefined => {\n if (error === null || typeof error !== \"object\") return undefined;\n\n return readNumberField(error, \"status\");\n};\n\n/** True when a thrown error is a 402 Payment Required (paywall) — distinct from\n * a real failure, so a locked premium surface can render an upgrade nudge. */\nexport const isPaywallError = (error: unknown): boolean =>\n apiErrorStatus(error) === HTTP_PAYMENT_REQUIRED;\n\n// --- Pagination ------------------------------------------------------------\n\n/** Standard response for offset-paginated APIs. */\nexport type OffsetPage<T> = {\n data: T[];\n total: number;\n};\n\n/** Standard response for stable cursor/keyset-paginated APIs. */\nexport type CursorPage<T, Cursor = string> = {\n data: T[];\n nextCursor: Cursor | null;\n};\n\nexport type OffsetPageRequest = {\n limit?: number;\n offset?: number;\n};\n\nexport type PaginationBounds = {\n defaultLimit: number;\n maxLimit: number;\n};\n\nconst positiveInteger = (value: number, fallback: number) =>\n Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;\n\n/** Clamp untrusted list-query input to safe integer bounds. */\nexport const normalizeOffsetPage = (\n request: OffsetPageRequest,\n bounds: PaginationBounds,\n): Required<OffsetPageRequest> => {\n const maxLimit = positiveInteger(bounds.maxLimit, 1);\n const defaultLimit = Math.min(\n positiveInteger(bounds.defaultLimit, maxLimit),\n maxLimit,\n );\n const requestedLimit = positiveInteger(request.limit ?? defaultLimit, defaultLimit);\n const requestedOffset = request.offset ?? 0;\n\n return {\n limit: Math.min(requestedLimit, maxLimit),\n offset:\n Number.isFinite(requestedOffset) && requestedOffset > 0\n ? Math.floor(requestedOffset)\n : 0,\n };\n};\n\nexport const getPageCount = (total: number, pageSize: number) =>\n Math.max(\n 1,\n Math.ceil(Math.max(0, total) / positiveInteger(pageSize, 1)),\n );\n\nexport const clampPageIndex = (\n pageIndex: number,\n total: number,\n pageSize: number,\n) =>\n Math.max(\n 0,\n Math.min(Math.floor(Math.max(0, pageIndex)), getPageCount(total, pageSize) - 1),\n );\n\n/** One-based visible range; an empty result is `{ start: 0, end: 0 }`. */\nexport const getPageRange = (\n pageIndex: number,\n pageSize: number,\n rowCount: number,\n total: number,\n) => {\n if (total <= 0 || rowCount <= 0) return { end: 0, start: 0 };\n const safePageSize = positiveInteger(pageSize, 1);\n const safePage = clampPageIndex(pageIndex, total, safePageSize);\n const start = safePage * safePageSize + 1;\n\n return { end: Math.min(start + rowCount - 1, total), start };\n};\n"
6
6
  ],
7
- "mappings": ";AAYA,IAAM,kBAAkB,CAAC,OAAe,QAA+B;AAAA,EACrE,MAAM,QAAiB,QAAQ,IAAI,OAAO,GAAG;AAAA,EAE7C,OAAO,OAAO,UAAU,YAAY,MAAM,KAAK,IAAI,QAAQ;AAAA;AAG7D,IAAM,kBAAkB,CAAC,OAAe,QAAoC;AAAA,EAC1E,MAAM,QAAiB,QAAQ,IAAI,OAAO,GAAG;AAAA,EAE7C,OAAO,OAAO,UAAU,WAAW,QAAQ;AAAA;AAM7C,IAAM,wBAAwB;AAI9B,IAAM,cAAc,CAClB,UAEA,OAAO,UAAU,YACjB,UAAU,SACV,WAAW,UACX,gBAAgB,OAAO,QAAQ,MAAM;AAIvC,IAAM,UAAU,CAAC,OAAgB,aAAgC;AAAA,EAC/D,MAAM,OACJ,iBAAiB,SAAS,MAAM,UAC5B,QACA,IAAI,MAAM,gBAAgB,OAAO,QAAQ,CAAC;AAAA,EAEhD,OAAO;AAAA;AAOF,IAAM,WAAW,CAAC,OAAgB,aAAgC;AAAA,EACvE,IAAI,YAAY,KAAK,GAAG;AAAA,IACtB,MAAM,QAAQ,QAAQ,MAAM,OAAO,QAAQ;AAAA,IAC3C,MAAM,SAAS,MAAM;AAAA,IAErB,OAAO;AAAA,EACT;AAAA,EAEA,OAAO,QAAQ,OAAO,QAAQ;AAAA;AAIzB,IAAM,kBAAkB,CAC7B,OACA,WAAW,qBACA;AAAA,EACX,IAAI,iBAAiB,SAAS,MAAM;AAAA,IAAS,OAAO,MAAM;AAAA,EAC1D,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK;AAAA,IAAG,OAAO;AAAA,EACtD,IAAI,UAAU,QAAQ,OAAO,UAAU;AAAA,IAAU,OAAO;AAAA,EAExD,OACE,gBAAgB,OAAO,SAAS,KAChC,gBAAgB,OAAO,SAAS,KAChC,gBAAgB,OAAO,OAAO,KAC9B,gBAAgB,OAAO,QAAQ,KAC/B;AAAA;AAKG,IAAM,iBAAiB,CAAC,UAAuC;AAAA,EACpE,IAAI,UAAU,QAAQ,OAAO,UAAU;AAAA,IAAU;AAAA,EAEjD,OAAO,gBAAgB,OAAO,QAAQ;AAAA;AAKjC,IAAM,iBAAiB,CAAC,UAC7B,eAAe,KAAK,MAAM;",
8
- "debugId": "7B3D4B24F776793364756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "C2658C62716FB0BA64756E2164756E21",
9
9
  "names": []
10
10
  }
package/package.json CHANGED
@@ -20,10 +20,11 @@
20
20
  "files": ["dist", "README.md"],
21
21
  "scripts": {
22
22
  "build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap && tsc --emitDeclarationOnly --project tsconfig.json",
23
+ "test": "bun test",
23
24
  "typecheck": "tsc --noEmit"
24
25
  },
25
26
  "types": "./dist/index.d.ts",
26
- "version": "0.1.0",
27
+ "version": "0.2.0",
27
28
  "devDependencies": {
28
29
  "@types/bun": "^1.3.14",
29
30
  "typescript": "^6.0.3"