@usequark/quark-core 2.5.4

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 (68) hide show
  1. package/README.md +63 -0
  2. package/package.json +80 -0
  3. package/src/admin-auth.js +121 -0
  4. package/src/admin.js +352 -0
  5. package/src/admin.test.js +257 -0
  6. package/src/auth/index.js +160 -0
  7. package/src/auth/middleware.js +106 -0
  8. package/src/auth/middleware.test.js +116 -0
  9. package/src/auth/password.js +9 -0
  10. package/src/auth.test.js +235 -0
  11. package/src/authorization.js +235 -0
  12. package/src/authorization.test.js +314 -0
  13. package/src/cache.js +137 -0
  14. package/src/cache.test.js +217 -0
  15. package/src/core.js +32 -0
  16. package/src/csrf.js +122 -0
  17. package/src/csrf.test.js +169 -0
  18. package/src/db-instrumentation.js +281 -0
  19. package/src/db-instrumentation.test.js +284 -0
  20. package/src/db-metrics.js +26 -0
  21. package/src/db.js +218 -0
  22. package/src/db.test.js +365 -0
  23. package/src/email-templates.js +134 -0
  24. package/src/email-templates.test.js +124 -0
  25. package/src/email.js +314 -0
  26. package/src/email.test.js +498 -0
  27. package/src/error-reporter.js +266 -0
  28. package/src/error-reporter.test.js +236 -0
  29. package/src/errors.js +192 -0
  30. package/src/errors.test.js +128 -0
  31. package/src/exports.test.js +154 -0
  32. package/src/file-validation.js +249 -0
  33. package/src/file-validation.test.js +207 -0
  34. package/src/index.js +58 -0
  35. package/src/locale.js +21 -0
  36. package/src/logger.js +182 -0
  37. package/src/logger.test.js +287 -0
  38. package/src/mail.js +50 -0
  39. package/src/metrics.js +379 -0
  40. package/src/metrics.test.js +380 -0
  41. package/src/multipart.js +144 -0
  42. package/src/multipart.test.js +188 -0
  43. package/src/pagination.js +139 -0
  44. package/src/pagination.test.js +210 -0
  45. package/src/query-builder.js +183 -0
  46. package/src/query-builder.test.js +234 -0
  47. package/src/queue/index.js +446 -0
  48. package/src/queue/queue.test.js +365 -0
  49. package/src/rate-limiter.js +253 -0
  50. package/src/rate-limiter.test.js +130 -0
  51. package/src/redis.js +154 -0
  52. package/src/redis.test.js +150 -0
  53. package/src/request-logger.js +211 -0
  54. package/src/request-logger.test.js +286 -0
  55. package/src/sms.js +208 -0
  56. package/src/storage-s3.js +236 -0
  57. package/src/storage.js +214 -0
  58. package/src/storage.test.js +457 -0
  59. package/src/stripe.js +89 -0
  60. package/src/testing/factories.js +89 -0
  61. package/src/testing/helpers.js +266 -0
  62. package/src/testing/index.js +46 -0
  63. package/src/testing/mocks.js +480 -0
  64. package/src/testing/testing.test.js +543 -0
  65. package/src/types.js +74 -0
  66. package/src/utils.js +219 -0
  67. package/src/utils.test.js +193 -0
  68. package/src/validation.js +26 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @techstream/quark-core - Pagination Utilities
3
+ *
4
+ * Provides offset-based pagination helpers that integrate with Prisma's
5
+ * skip/take API and the project's ValidationError convention.
6
+ *
7
+ * Usage in a route handler:
8
+ *
9
+ * import { parsePaginationQuery } from "@techstream/quark-core";
10
+ *
11
+ * export async function GET(request) {
12
+ * const { searchParams } = new URL(request.url);
13
+ * const { skip, take, meta } = parsePaginationQuery(searchParams);
14
+ * const [records, total] = await Promise.all([
15
+ * prisma.post.findMany({ skip, take }),
16
+ * prisma.post.count(),
17
+ * ]);
18
+ * return NextResponse.json({ data: records, pagination: meta(total) });
19
+ * }
20
+ */
21
+
22
+ import { z } from "zod";
23
+ import { ValidationError } from "./errors.js";
24
+
25
+ /** Default maximum page size. Can be overridden per-call via options. */
26
+ const DEFAULT_MAX_LIMIT = 100;
27
+
28
+ /** Default page size when ?limit is not provided. */
29
+ const DEFAULT_LIMIT = 20;
30
+
31
+ /**
32
+ * Build a Zod schema for pagination query params.
33
+ * @param {number} maxLimit
34
+ * @returns {import("zod").ZodObject<any>}
35
+ */
36
+ function buildSchema(maxLimit) {
37
+ return z.object({
38
+ page: z.coerce.number().int().min(1).default(1),
39
+ limit: z.coerce.number().int().min(1).max(maxLimit).default(DEFAULT_LIMIT),
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Parse and validate raw pagination params from a URL search params object,
45
+ * a plain key/value object, or any iterable of [key, value] pairs.
46
+ *
47
+ * Throws ValidationError on invalid input so the existing `handleError`
48
+ * in route handlers catches it and returns a 422 automatically.
49
+ *
50
+ * @param {URLSearchParams | Record<string, string | undefined> | Iterable<[string, string]>} params
51
+ * @param {{ maxLimit?: number }} [options]
52
+ * @returns {{ page: number, limit: number }}
53
+ */
54
+ export function parsePagination(params, options = {}) {
55
+ const maxLimit = options.maxLimit ?? DEFAULT_MAX_LIMIT;
56
+ const schema = buildSchema(maxLimit);
57
+
58
+ // Normalise to a plain object regardless of input type
59
+ let raw;
60
+ if (params instanceof URLSearchParams) {
61
+ raw = { page: params.get("page"), limit: params.get("limit") };
62
+ } else if (
63
+ typeof params === "object" &&
64
+ params !== null &&
65
+ typeof params[Symbol.iterator] === "function"
66
+ ) {
67
+ raw = Object.fromEntries(params);
68
+ } else {
69
+ raw = params ?? {};
70
+ }
71
+
72
+ // Strip null/undefined keys so Zod defaults kick in
73
+ const cleaned = Object.fromEntries(
74
+ Object.entries(raw).filter(([, v]) => v != null),
75
+ );
76
+
77
+ const result = schema.safeParse(cleaned);
78
+ if (!result.success) {
79
+ throw new ValidationError(result.error.flatten());
80
+ }
81
+ return result.data;
82
+ }
83
+
84
+ /**
85
+ * Convert page + limit into Prisma's skip/take arguments.
86
+ *
87
+ * @param {{ page: number, limit: number }} pagination
88
+ * @returns {{ skip: number, take: number }}
89
+ */
90
+ export function paginationToSkip({ page, limit }) {
91
+ return {
92
+ skip: (page - 1) * limit,
93
+ take: limit,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Build the pagination metadata envelope returned in API responses.
99
+ *
100
+ * @param {{ page: number, limit: number, total: number }} params
101
+ * @returns {{ total: number, page: number, limit: number, totalPages: number, hasNext: boolean, hasPrev: boolean }}
102
+ */
103
+ export function paginationMeta({ page, limit, total }) {
104
+ const totalPages = Math.ceil(total / limit);
105
+ return {
106
+ total,
107
+ page,
108
+ limit,
109
+ totalPages,
110
+ hasNext: page < totalPages,
111
+ hasPrev: page > 1,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Convenience helper that parses pagination params and returns everything
117
+ * needed to call Prisma + build the response envelope in one shot.
118
+ *
119
+ * @param {URLSearchParams | Record<string, string | undefined>} params
120
+ * @param {{ maxLimit?: number }} [options]
121
+ * @returns {{
122
+ * page: number,
123
+ * limit: number,
124
+ * skip: number,
125
+ * take: number,
126
+ * meta: (total: number) => ReturnType<typeof paginationMeta>
127
+ * }}
128
+ */
129
+ export function parsePaginationQuery(params, options = {}) {
130
+ const { page, limit } = parsePagination(params, options);
131
+ const { skip, take } = paginationToSkip({ page, limit });
132
+ return {
133
+ page,
134
+ limit,
135
+ skip,
136
+ take,
137
+ meta: (total) => paginationMeta({ page, limit, total }),
138
+ };
139
+ }
@@ -0,0 +1,210 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { ValidationError } from "./errors.js";
4
+ import {
5
+ paginationMeta,
6
+ paginationToSkip,
7
+ parsePagination,
8
+ parsePaginationQuery,
9
+ } from "./pagination.js";
10
+
11
+ // ─── parsePagination ──────────────────────────────────────────────────────────
12
+
13
+ describe("parsePagination", () => {
14
+ describe("from URLSearchParams", () => {
15
+ it("returns defaults when no params are given", () => {
16
+ const result = parsePagination(new URLSearchParams());
17
+ assert.deepEqual(result, { page: 1, limit: 20 });
18
+ });
19
+
20
+ it("parses page and limit", () => {
21
+ const result = parsePagination(new URLSearchParams("page=3&limit=50"));
22
+ assert.deepEqual(result, { page: 3, limit: 50 });
23
+ });
24
+
25
+ it("coerces string numbers", () => {
26
+ const result = parsePagination(new URLSearchParams("page=2&limit=10"));
27
+ assert.equal(typeof result.page, "number");
28
+ assert.equal(typeof result.limit, "number");
29
+ });
30
+ });
31
+
32
+ describe("from plain object", () => {
33
+ it("accepts a plain object", () => {
34
+ const result = parsePagination({ page: "2", limit: "10" });
35
+ assert.deepEqual(result, { page: 2, limit: 10 });
36
+ });
37
+
38
+ it("uses defaults for undefined values", () => {
39
+ const result = parsePagination({});
40
+ assert.deepEqual(result, { page: 1, limit: 20 });
41
+ });
42
+
43
+ it("ignores null values and uses defaults", () => {
44
+ const result = parsePagination({ page: null, limit: null });
45
+ assert.deepEqual(result, { page: 1, limit: 20 });
46
+ });
47
+ });
48
+
49
+ describe("validation", () => {
50
+ it("throws ValidationError for page < 1", () => {
51
+ assert.throws(
52
+ () => parsePagination({ page: "0" }),
53
+ (err) => err instanceof ValidationError,
54
+ );
55
+ });
56
+
57
+ it("throws ValidationError for non-integer page", () => {
58
+ assert.throws(
59
+ () => parsePagination({ page: "1.5" }),
60
+ (err) => err instanceof ValidationError,
61
+ );
62
+ });
63
+
64
+ it("throws ValidationError for limit > maxLimit (default 100)", () => {
65
+ assert.throws(
66
+ () => parsePagination({ limit: "101" }),
67
+ (err) => err instanceof ValidationError,
68
+ );
69
+ });
70
+
71
+ it("respects custom maxLimit option", () => {
72
+ const result = parsePagination({ limit: "200" }, { maxLimit: 500 });
73
+ assert.equal(result.limit, 200);
74
+ });
75
+
76
+ it("throws when limit exceeds custom maxLimit", () => {
77
+ assert.throws(
78
+ () => parsePagination({ limit: "51" }, { maxLimit: 50 }),
79
+ (err) => err instanceof ValidationError,
80
+ );
81
+ });
82
+ });
83
+ });
84
+
85
+ // ─── paginationToSkip ─────────────────────────────────────────────────────────
86
+
87
+ describe("paginationToSkip", () => {
88
+ it("page 1 → skip 0", () => {
89
+ assert.deepEqual(paginationToSkip({ page: 1, limit: 20 }), {
90
+ skip: 0,
91
+ take: 20,
92
+ });
93
+ });
94
+
95
+ it("page 2 limit 20 → skip 20", () => {
96
+ assert.deepEqual(paginationToSkip({ page: 2, limit: 20 }), {
97
+ skip: 20,
98
+ take: 20,
99
+ });
100
+ });
101
+
102
+ it("page 3 limit 10 → skip 20", () => {
103
+ assert.deepEqual(paginationToSkip({ page: 3, limit: 10 }), {
104
+ skip: 20,
105
+ take: 10,
106
+ });
107
+ });
108
+
109
+ it("page 5 limit 100 → skip 400", () => {
110
+ assert.deepEqual(paginationToSkip({ page: 5, limit: 100 }), {
111
+ skip: 400,
112
+ take: 100,
113
+ });
114
+ });
115
+ });
116
+
117
+ // ─── paginationMeta ───────────────────────────────────────────────────────────
118
+
119
+ describe("paginationMeta", () => {
120
+ it("calculates totalPages correctly", () => {
121
+ const meta = paginationMeta({ page: 1, limit: 20, total: 100 });
122
+ assert.equal(meta.totalPages, 5);
123
+ });
124
+
125
+ it("rounds totalPages up for non-divisible totals", () => {
126
+ const meta = paginationMeta({ page: 1, limit: 20, total: 101 });
127
+ assert.equal(meta.totalPages, 6);
128
+ });
129
+
130
+ it("hasNext is true when not on last page", () => {
131
+ const meta = paginationMeta({ page: 2, limit: 10, total: 50 });
132
+ assert.equal(meta.hasNext, true);
133
+ });
134
+
135
+ it("hasNext is false on last page", () => {
136
+ const meta = paginationMeta({ page: 5, limit: 10, total: 50 });
137
+ assert.equal(meta.hasNext, false);
138
+ });
139
+
140
+ it("hasPrev is true when not on first page", () => {
141
+ const meta = paginationMeta({ page: 3, limit: 10, total: 50 });
142
+ assert.equal(meta.hasPrev, true);
143
+ });
144
+
145
+ it("hasPrev is false on first page", () => {
146
+ const meta = paginationMeta({ page: 1, limit: 10, total: 50 });
147
+ assert.equal(meta.hasPrev, false);
148
+ });
149
+
150
+ it("returns total, page, limit in metadata", () => {
151
+ const meta = paginationMeta({ page: 2, limit: 10, total: 45 });
152
+ assert.equal(meta.total, 45);
153
+ assert.equal(meta.page, 2);
154
+ assert.equal(meta.limit, 10);
155
+ });
156
+
157
+ it("handles total of 0", () => {
158
+ const meta = paginationMeta({ page: 1, limit: 20, total: 0 });
159
+ assert.equal(meta.totalPages, 0);
160
+ assert.equal(meta.hasNext, false);
161
+ assert.equal(meta.hasPrev, false);
162
+ });
163
+
164
+ it("handles single page result", () => {
165
+ const meta = paginationMeta({ page: 1, limit: 20, total: 5 });
166
+ assert.equal(meta.totalPages, 1);
167
+ assert.equal(meta.hasNext, false);
168
+ assert.equal(meta.hasPrev, false);
169
+ });
170
+ });
171
+
172
+ // ─── parsePaginationQuery ─────────────────────────────────────────────────────
173
+
174
+ describe("parsePaginationQuery", () => {
175
+ it("returns page, limit, skip, take, and meta function", () => {
176
+ const sp = new URLSearchParams("page=2&limit=15");
177
+ const result = parsePaginationQuery(sp);
178
+ assert.equal(result.page, 2);
179
+ assert.equal(result.limit, 15);
180
+ assert.equal(result.skip, 15);
181
+ assert.equal(result.take, 15);
182
+ assert.equal(typeof result.meta, "function");
183
+ });
184
+
185
+ it("meta(total) returns correct pagination envelope", () => {
186
+ const result = parsePaginationQuery(new URLSearchParams("page=2&limit=10"));
187
+ const meta = result.meta(45);
188
+ assert.deepEqual(meta, {
189
+ total: 45,
190
+ page: 2,
191
+ limit: 10,
192
+ totalPages: 5,
193
+ hasNext: true,
194
+ hasPrev: true,
195
+ });
196
+ });
197
+
198
+ it("uses defaults when no params are provided", () => {
199
+ const result = parsePaginationQuery(new URLSearchParams());
200
+ assert.equal(result.skip, 0);
201
+ assert.equal(result.take, 20);
202
+ });
203
+
204
+ it("propagates ValidationError on invalid input", () => {
205
+ assert.throws(
206
+ () => parsePaginationQuery(new URLSearchParams("page=0")),
207
+ (err) => err instanceof ValidationError,
208
+ );
209
+ });
210
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @techstream/quark-core - Query Builder
3
+ * Builds Prisma where/orderBy clauses from query parameters
4
+ * with validation and sanitization.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} QueryBuilderOptions
9
+ * @property {string[]} [searchFields] - Fields to search across
10
+ * @property {string[]} [sortableFields] - Fields that can be sorted
11
+ * @property {string[]} [filterableFields] - Fields that can be filtered
12
+ */
13
+
14
+ /**
15
+ * Supported filter operators
16
+ */
17
+ const OPERATORS = {
18
+ eq: "equals",
19
+ ne: "not",
20
+ gt: "gt",
21
+ gte: "gte",
22
+ lt: "lt",
23
+ lte: "lte",
24
+ contains: "contains",
25
+ startsWith: "startsWith",
26
+ endsWith: "endsWith",
27
+ in: "in",
28
+ };
29
+
30
+ /**
31
+ * Query builder for constructing Prisma where/orderBy clauses.
32
+ */
33
+ export class QueryBuilder {
34
+ /**
35
+ * @param {QueryBuilderOptions} options
36
+ */
37
+ constructor(options = {}) {
38
+ this.searchFields = options.searchFields || [];
39
+ this.sortableFields = options.sortableFields || [];
40
+ this.filterableFields = options.filterableFields || [];
41
+ this.filters = [];
42
+ this.searchTerm = null;
43
+ this.sortField = null;
44
+ this.sortDirection = "asc";
45
+ }
46
+
47
+ /**
48
+ * Add a filter condition.
49
+ * @param {string} field - Field name
50
+ * @param {string} operator - Operator (eq, ne, gt, gte, lt, lte, contains, startsWith, endsWith, in)
51
+ * @param {any} value - Filter value
52
+ * @returns {QueryBuilder}
53
+ */
54
+ filter(field, operator, value) {
55
+ if (!this.filterableFields.includes(field)) {
56
+ throw new Error(
57
+ `Field "${field}" is not filterable. Allowed: ${this.filterableFields.join(", ")}`,
58
+ );
59
+ }
60
+
61
+ if (!OPERATORS[operator]) {
62
+ throw new Error(
63
+ `Operator "${operator}" is not supported. Allowed: ${Object.keys(OPERATORS).join(", ")}`,
64
+ );
65
+ }
66
+
67
+ this.filters.push({ field, operator, value });
68
+ return this;
69
+ }
70
+
71
+ /**
72
+ * Add a full-text search across multiple fields.
73
+ * @param {string} term - Search term
74
+ * @returns {QueryBuilder}
75
+ */
76
+ search(term) {
77
+ if (!term || typeof term !== "string") {
78
+ return this;
79
+ }
80
+
81
+ if (this.searchFields.length === 0) {
82
+ throw new Error("No search fields configured for this query");
83
+ }
84
+
85
+ this.searchTerm = term.trim();
86
+ return this;
87
+ }
88
+
89
+ /**
90
+ * Set sort field and direction.
91
+ * @param {string} field - Field name
92
+ * @param {"asc" | "desc"} [direction="asc"] - Sort direction
93
+ * @returns {QueryBuilder}
94
+ */
95
+ sort(field, direction = "asc") {
96
+ if (!this.sortableFields.includes(field)) {
97
+ throw new Error(
98
+ `Field "${field}" is not sortable. Allowed: ${this.sortableFields.join(", ")}`,
99
+ );
100
+ }
101
+
102
+ if (direction !== "asc" && direction !== "desc") {
103
+ throw new Error('Sort direction must be "asc" or "desc"');
104
+ }
105
+
106
+ this.sortField = field;
107
+ this.sortDirection = direction;
108
+ return this;
109
+ }
110
+
111
+ /**
112
+ * Build Prisma where clause.
113
+ * @returns {Object} Prisma where object
114
+ */
115
+ toWhere() {
116
+ const conditions = [];
117
+
118
+ // Add filter conditions
119
+ for (const { field, operator, value } of this.filters) {
120
+ const prismaOp = OPERATORS[operator];
121
+
122
+ if (operator === "in") {
123
+ // Special handling for "in" operator - value should be an array
124
+ const values = Array.isArray(value) ? value : [value];
125
+ conditions.push({ [field]: { in: values } });
126
+ } else if (operator === "ne") {
127
+ // "not" operator wraps the value
128
+ conditions.push({ [field]: { not: value } });
129
+ } else {
130
+ conditions.push({ [field]: { [prismaOp]: value } });
131
+ }
132
+ }
133
+
134
+ // Add search condition
135
+ if (this.searchTerm) {
136
+ const searchConditions = this.searchFields.map((field) => ({
137
+ [field]: { contains: this.searchTerm, mode: "insensitive" },
138
+ }));
139
+ conditions.push({ OR: searchConditions });
140
+ }
141
+
142
+ // Return combined conditions
143
+ if (conditions.length === 0) {
144
+ return {};
145
+ }
146
+ if (conditions.length === 1) {
147
+ return conditions[0];
148
+ }
149
+ return { AND: conditions };
150
+ }
151
+
152
+ /**
153
+ * Build Prisma orderBy clause.
154
+ * @returns {Object | undefined} Prisma orderBy object
155
+ */
156
+ toOrderBy() {
157
+ if (!this.sortField) {
158
+ return undefined;
159
+ }
160
+ return { [this.sortField]: this.sortDirection };
161
+ }
162
+
163
+ /**
164
+ * Reset all filters, search, and sort.
165
+ * @returns {QueryBuilder}
166
+ */
167
+ reset() {
168
+ this.filters = [];
169
+ this.searchTerm = null;
170
+ this.sortField = null;
171
+ this.sortDirection = "asc";
172
+ return this;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Factory function to create a new QueryBuilder.
178
+ * @param {QueryBuilderOptions} options
179
+ * @returns {QueryBuilder}
180
+ */
181
+ export function createQueryBuilder(options) {
182
+ return new QueryBuilder(options);
183
+ }