@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.
- package/README.md +63 -0
- package/package.json +80 -0
- package/src/admin-auth.js +121 -0
- package/src/admin.js +352 -0
- package/src/admin.test.js +257 -0
- package/src/auth/index.js +160 -0
- package/src/auth/middleware.js +106 -0
- package/src/auth/middleware.test.js +116 -0
- package/src/auth/password.js +9 -0
- package/src/auth.test.js +235 -0
- package/src/authorization.js +235 -0
- package/src/authorization.test.js +314 -0
- package/src/cache.js +137 -0
- package/src/cache.test.js +217 -0
- package/src/core.js +32 -0
- package/src/csrf.js +122 -0
- package/src/csrf.test.js +169 -0
- package/src/db-instrumentation.js +281 -0
- package/src/db-instrumentation.test.js +284 -0
- package/src/db-metrics.js +26 -0
- package/src/db.js +218 -0
- package/src/db.test.js +365 -0
- package/src/email-templates.js +134 -0
- package/src/email-templates.test.js +124 -0
- package/src/email.js +314 -0
- package/src/email.test.js +498 -0
- package/src/error-reporter.js +266 -0
- package/src/error-reporter.test.js +236 -0
- package/src/errors.js +192 -0
- package/src/errors.test.js +128 -0
- package/src/exports.test.js +154 -0
- package/src/file-validation.js +249 -0
- package/src/file-validation.test.js +207 -0
- package/src/index.js +58 -0
- package/src/locale.js +21 -0
- package/src/logger.js +182 -0
- package/src/logger.test.js +287 -0
- package/src/mail.js +50 -0
- package/src/metrics.js +379 -0
- package/src/metrics.test.js +380 -0
- package/src/multipart.js +144 -0
- package/src/multipart.test.js +188 -0
- package/src/pagination.js +139 -0
- package/src/pagination.test.js +210 -0
- package/src/query-builder.js +183 -0
- package/src/query-builder.test.js +234 -0
- package/src/queue/index.js +446 -0
- package/src/queue/queue.test.js +365 -0
- package/src/rate-limiter.js +253 -0
- package/src/rate-limiter.test.js +130 -0
- package/src/redis.js +154 -0
- package/src/redis.test.js +150 -0
- package/src/request-logger.js +211 -0
- package/src/request-logger.test.js +286 -0
- package/src/sms.js +208 -0
- package/src/storage-s3.js +236 -0
- package/src/storage.js +214 -0
- package/src/storage.test.js +457 -0
- package/src/stripe.js +89 -0
- package/src/testing/factories.js +89 -0
- package/src/testing/helpers.js +266 -0
- package/src/testing/index.js +46 -0
- package/src/testing/mocks.js +480 -0
- package/src/testing/testing.test.js +543 -0
- package/src/types.js +74 -0
- package/src/utils.js +219 -0
- package/src/utils.test.js +193 -0
- package/src/validation.js +26 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
authorization,
|
|
5
|
+
createAuthorization,
|
|
6
|
+
defaultPolicy,
|
|
7
|
+
requireRole,
|
|
8
|
+
withAuthorization,
|
|
9
|
+
} from "./authorization.js";
|
|
10
|
+
import { ForbiddenError } from "./errors.js";
|
|
11
|
+
|
|
12
|
+
describe("authorization", () => {
|
|
13
|
+
/** @type {ReturnType<typeof createAuthorization>} */
|
|
14
|
+
let auth;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
auth = createAuthorization();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("can()", () => {
|
|
21
|
+
it("returns true for admin with wildcard permissions", () => {
|
|
22
|
+
const user = { id: "u1", role: "admin" };
|
|
23
|
+
assert.equal(auth.can(user, "create", "post"), true);
|
|
24
|
+
assert.equal(auth.can(user, "delete", "user"), true);
|
|
25
|
+
assert.equal(auth.can(user, "anything", "anywhere"), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns true for editor with post actions", () => {
|
|
29
|
+
const user = { id: "u2", role: "editor" };
|
|
30
|
+
assert.equal(auth.can(user, "create", "post"), true);
|
|
31
|
+
assert.equal(auth.can(user, "update", "post"), true);
|
|
32
|
+
assert.equal(auth.can(user, "delete", "post"), true);
|
|
33
|
+
assert.equal(auth.can(user, "read", "post"), true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns true for editor reading users", () => {
|
|
37
|
+
const user = { id: "u2", role: "editor" };
|
|
38
|
+
assert.equal(auth.can(user, "read", "user"), true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns false for editor trying to delete users", () => {
|
|
42
|
+
const user = { id: "u2", role: "editor" };
|
|
43
|
+
assert.equal(auth.can(user, "delete", "user"), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns false for viewer trying to write", () => {
|
|
47
|
+
const user = { id: "u3", role: "viewer" };
|
|
48
|
+
assert.equal(auth.can(user, "create", "post"), false);
|
|
49
|
+
assert.equal(auth.can(user, "update", "post"), false);
|
|
50
|
+
assert.equal(auth.can(user, "delete", "post"), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns true for viewer reading posts and users", () => {
|
|
54
|
+
const user = { id: "u3", role: "viewer" };
|
|
55
|
+
assert.equal(auth.can(user, "read", "post"), true);
|
|
56
|
+
assert.equal(auth.can(user, "read", "user"), true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("owner can update their own resource", () => {
|
|
60
|
+
const user = { id: "u3", role: "viewer" };
|
|
61
|
+
const context = { ownerId: "u3" };
|
|
62
|
+
assert.equal(auth.can(user, "update", "post", context), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("owner can delete their own resource", () => {
|
|
66
|
+
const user = { id: "u3", role: "viewer" };
|
|
67
|
+
const context = { ownerId: "u3" };
|
|
68
|
+
assert.equal(auth.can(user, "delete", "post", context), true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("non-owner cannot update another user's resource", () => {
|
|
72
|
+
const user = { id: "u3", role: "viewer" };
|
|
73
|
+
const context = { ownerId: "u999" };
|
|
74
|
+
assert.equal(auth.can(user, "update", "post", context), false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ownership does not apply for non-owner actions", () => {
|
|
78
|
+
const user = { id: "u3", role: "viewer" };
|
|
79
|
+
const context = { ownerId: "u3" };
|
|
80
|
+
// "create" is not in ownerActions by default
|
|
81
|
+
assert.equal(auth.can(user, "create", "post", context), false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("applies defaultRole when user has no role", () => {
|
|
85
|
+
const user = { id: "u4" };
|
|
86
|
+
// defaultRole is "viewer" - can read but not create
|
|
87
|
+
assert.equal(auth.can(user, "read", "post"), true);
|
|
88
|
+
assert.equal(auth.can(user, "create", "post"), false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("authorize()", () => {
|
|
93
|
+
it("throws ForbiddenError when denied", () => {
|
|
94
|
+
const user = { id: "u3", role: "viewer" };
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => auth.authorize(user, "create", "post"),
|
|
97
|
+
(err) => err instanceof ForbiddenError,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not throw when allowed", () => {
|
|
102
|
+
const user = { id: "u1", role: "admin" };
|
|
103
|
+
assert.doesNotThrow(() => auth.authorize(user, "create", "post"));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("includes user and action information in error message", () => {
|
|
107
|
+
const user = { id: "u3", role: "viewer" };
|
|
108
|
+
assert.throws(() => auth.authorize(user, "create", "post"), {
|
|
109
|
+
message: "User u3 is not allowed to create post",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("hasPermission()", () => {
|
|
115
|
+
it("returns true for exact match", () => {
|
|
116
|
+
assert.equal(auth.hasPermission("viewer", "read", "post"), true);
|
|
117
|
+
assert.equal(auth.hasPermission("viewer", "read", "user"), true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns false for no match", () => {
|
|
121
|
+
assert.equal(auth.hasPermission("viewer", "create", "post"), false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns true with resource wildcard", () => {
|
|
125
|
+
assert.equal(auth.hasPermission("admin", "create", "anything"), true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns true with action wildcard", () => {
|
|
129
|
+
assert.equal(auth.hasPermission("editor", "delete", "post"), true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns false for unknown role", () => {
|
|
133
|
+
assert.equal(auth.hasPermission("unknown", "read", "post"), false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("getRolePermissions()", () => {
|
|
138
|
+
it("returns permissions for a valid role", () => {
|
|
139
|
+
const perms = auth.getRolePermissions("viewer");
|
|
140
|
+
assert.deepStrictEqual(perms, [
|
|
141
|
+
{ resource: "post", action: "read" },
|
|
142
|
+
{ resource: "user", action: "read" },
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns empty array for unknown role", () => {
|
|
147
|
+
const perms = auth.getRolePermissions("nonexistent");
|
|
148
|
+
assert.deepStrictEqual(perms, []);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("addRole() and removeRole()", () => {
|
|
153
|
+
it("addRole() makes new role available", () => {
|
|
154
|
+
auth.addRole("moderator", [
|
|
155
|
+
{ resource: "post", action: "update" },
|
|
156
|
+
{ resource: "post", action: "delete" },
|
|
157
|
+
]);
|
|
158
|
+
const user = { id: "u5", role: "moderator" };
|
|
159
|
+
assert.equal(auth.can(user, "update", "post"), true);
|
|
160
|
+
assert.equal(auth.can(user, "delete", "post"), true);
|
|
161
|
+
assert.equal(auth.can(user, "create", "post"), false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("removeRole() removes a role", () => {
|
|
165
|
+
auth.removeRole("editor");
|
|
166
|
+
assert.deepStrictEqual(auth.getRolePermissions("editor"), []);
|
|
167
|
+
assert.equal(auth.hasPermission("editor", "read", "post"), false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("extendPolicy()", () => {
|
|
172
|
+
it("merges new roles into existing policy", () => {
|
|
173
|
+
auth.extendPolicy({
|
|
174
|
+
roles: {
|
|
175
|
+
supermod: {
|
|
176
|
+
permissions: [{ resource: "*", action: "delete" }],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const user = { id: "u6", role: "supermod" };
|
|
182
|
+
assert.equal(auth.can(user, "delete", "post"), true);
|
|
183
|
+
assert.equal(auth.can(user, "delete", "user"), true);
|
|
184
|
+
assert.equal(auth.can(user, "create", "post"), false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("preserves existing roles when extending", () => {
|
|
188
|
+
auth.extendPolicy({
|
|
189
|
+
roles: {
|
|
190
|
+
custom: {
|
|
191
|
+
permissions: [{ resource: "report", action: "read" }],
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
// Original roles still work
|
|
196
|
+
assert.equal(auth.hasPermission("admin", "create", "post"), true);
|
|
197
|
+
assert.equal(auth.hasPermission("viewer", "read", "post"), true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("can override defaultRole", () => {
|
|
201
|
+
auth.extendPolicy({ defaultRole: "editor" });
|
|
202
|
+
const user = { id: "u7" };
|
|
203
|
+
assert.equal(auth.can(user, "create", "post"), true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("requireRole()", () => {
|
|
208
|
+
it("does not throw when session user has an allowed role", () => {
|
|
209
|
+
const guard = requireRole("admin", "editor");
|
|
210
|
+
const session = { user: { id: "u1", role: "admin" } };
|
|
211
|
+
assert.doesNotThrow(() => guard(session));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("throws ForbiddenError when session user role is not allowed", () => {
|
|
215
|
+
const guard = requireRole("admin");
|
|
216
|
+
const session = { user: { id: "u2", role: "viewer" } };
|
|
217
|
+
assert.throws(
|
|
218
|
+
() => guard(session),
|
|
219
|
+
(err) => err instanceof ForbiddenError,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("throws ForbiddenError when session has no role", () => {
|
|
224
|
+
const guard = requireRole("admin");
|
|
225
|
+
const session = { user: { id: "u2" } };
|
|
226
|
+
assert.throws(
|
|
227
|
+
() => guard(session),
|
|
228
|
+
(err) => err instanceof ForbiddenError,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("throws ForbiddenError when session has no user", () => {
|
|
233
|
+
const guard = requireRole("admin");
|
|
234
|
+
assert.throws(
|
|
235
|
+
() => guard({}),
|
|
236
|
+
(err) => err instanceof ForbiddenError,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("withAuthorization()", () => {
|
|
242
|
+
it("calls handler when authorized", async () => {
|
|
243
|
+
let called = false;
|
|
244
|
+
const handler = (_req, _session) => {
|
|
245
|
+
called = true;
|
|
246
|
+
return "ok";
|
|
247
|
+
};
|
|
248
|
+
const wrapped = withAuthorization(handler, {
|
|
249
|
+
action: "read",
|
|
250
|
+
resource: "post",
|
|
251
|
+
});
|
|
252
|
+
const session = { user: { id: "u1", role: "admin" } };
|
|
253
|
+
const result = await wrapped({}, session);
|
|
254
|
+
assert.equal(called, true);
|
|
255
|
+
assert.equal(result, "ok");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("throws ForbiddenError when not authorized", async () => {
|
|
259
|
+
const handler = () => "ok";
|
|
260
|
+
const wrapped = withAuthorization(handler, {
|
|
261
|
+
action: "create",
|
|
262
|
+
resource: "post",
|
|
263
|
+
});
|
|
264
|
+
const session = { user: { id: "u3", role: "viewer" } };
|
|
265
|
+
await assert.rejects(() => wrapped({}, session), ForbiddenError);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("throws ForbiddenError when no user in session", async () => {
|
|
269
|
+
const handler = () => "ok";
|
|
270
|
+
const wrapped = withAuthorization(handler, {
|
|
271
|
+
action: "read",
|
|
272
|
+
resource: "post",
|
|
273
|
+
});
|
|
274
|
+
await assert.rejects(() => wrapped({}, {}), ForbiddenError);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("passes context from getContext to authorization", async () => {
|
|
278
|
+
const handler = () => "ok";
|
|
279
|
+
const wrapped = withAuthorization(handler, {
|
|
280
|
+
action: "update",
|
|
281
|
+
resource: "post",
|
|
282
|
+
getContext: () => ({ ownerId: "owner1" }),
|
|
283
|
+
});
|
|
284
|
+
// viewer can update own resource via ownership
|
|
285
|
+
const session = { user: { id: "owner1", role: "viewer" } };
|
|
286
|
+
const result = await wrapped({}, session);
|
|
287
|
+
assert.equal(result, "ok");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("default instance", () => {
|
|
292
|
+
it("exports a working default authorization instance", () => {
|
|
293
|
+
assert.equal(
|
|
294
|
+
authorization.can({ id: "u1", role: "admin" }, "create", "post"),
|
|
295
|
+
true,
|
|
296
|
+
);
|
|
297
|
+
assert.equal(
|
|
298
|
+
authorization.can({ id: "u1", role: "viewer" }, "create", "post"),
|
|
299
|
+
false,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("defaultPolicy", () => {
|
|
305
|
+
it("is exported and has expected structure", () => {
|
|
306
|
+
assert.ok(defaultPolicy.roles.admin);
|
|
307
|
+
assert.ok(defaultPolicy.roles.editor);
|
|
308
|
+
assert.ok(defaultPolicy.roles.viewer);
|
|
309
|
+
assert.equal(defaultPolicy.defaultRole, "viewer");
|
|
310
|
+
assert.equal(defaultPolicy.ownershipField, "ownerId");
|
|
311
|
+
assert.deepStrictEqual(defaultPolicy.ownerActions, ["update", "delete"]);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache utility for Redis-based caching with TTL and invalidation.
|
|
3
|
+
* Designed to be used with any Redis client (ioredis, redis, etc.)
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```js
|
|
7
|
+
* import Redis from "ioredis";
|
|
8
|
+
* import { createCache } from "@quark/core";
|
|
9
|
+
*
|
|
10
|
+
* const redis = new Redis();
|
|
11
|
+
* const cache = createCache(redis, { prefix: "app:", defaultTTL: 600 });
|
|
12
|
+
*
|
|
13
|
+
* await cache.set("user:1", { name: "Alice" });
|
|
14
|
+
* const user = await cache.get("user:1");
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Creates a cache instance backed by a Redis client.
|
|
20
|
+
*
|
|
21
|
+
* @param {import("ioredis").Redis} redisClient - any Redis client with get/set/del/keys/expire methods
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {string} [options.prefix="cache:"] - key prefix for namespacing
|
|
24
|
+
* @param {number} [options.defaultTTL=300] - default TTL in seconds (5 minutes)
|
|
25
|
+
* @returns {ReturnType<typeof createCache>}
|
|
26
|
+
*/
|
|
27
|
+
export function createCache(redisClient, options = {}) {
|
|
28
|
+
const { prefix = "cache:", defaultTTL = 300 } = options;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* Get a cached value.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} key
|
|
35
|
+
* @returns {Promise<any|null>} Parsed value or null if not found
|
|
36
|
+
*/
|
|
37
|
+
async get(key) {
|
|
38
|
+
const raw = await redisClient.get(`${prefix}${key}`);
|
|
39
|
+
if (raw === null || raw === undefined) return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(raw);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Set a cached value with optional TTL (atomic SET + EX).
|
|
49
|
+
*
|
|
50
|
+
* @param {string} key
|
|
51
|
+
* @param {any} value - will be JSON.stringified
|
|
52
|
+
* @param {number} [ttl] - TTL in seconds, defaults to defaultTTL
|
|
53
|
+
*/
|
|
54
|
+
async set(key, value, ttl) {
|
|
55
|
+
const prefixedKey = `${prefix}${key}`;
|
|
56
|
+
const serialized = JSON.stringify(value);
|
|
57
|
+
await redisClient.set(prefixedKey, serialized, "EX", ttl ?? defaultTTL);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Delete a cached key.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} key
|
|
64
|
+
*/
|
|
65
|
+
async del(key) {
|
|
66
|
+
await redisClient.del(`${prefix}${key}`);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Delete all keys matching a pattern using SCAN (non-blocking, production-safe).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} pattern - e.g., "user:*"
|
|
73
|
+
*/
|
|
74
|
+
async invalidate(pattern) {
|
|
75
|
+
const matchPattern = `${prefix}${pattern}`;
|
|
76
|
+
let cursor = "0";
|
|
77
|
+
do {
|
|
78
|
+
const [nextCursor, keys] = await redisClient.scan(
|
|
79
|
+
cursor,
|
|
80
|
+
"MATCH",
|
|
81
|
+
matchPattern,
|
|
82
|
+
"COUNT",
|
|
83
|
+
100,
|
|
84
|
+
);
|
|
85
|
+
cursor = nextCursor;
|
|
86
|
+
if (keys.length > 0) {
|
|
87
|
+
await Promise.all(keys.map((k) => redisClient.del(k)));
|
|
88
|
+
}
|
|
89
|
+
} while (cursor !== "0");
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get-or-set pattern: returns cached value if it exists,
|
|
94
|
+
* otherwise calls factory, caches the result, and returns it.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} key
|
|
97
|
+
* @param {Function} factory - async function that produces the value
|
|
98
|
+
* @param {number} [ttl] - TTL in seconds, defaults to defaultTTL
|
|
99
|
+
* @returns {Promise<any>}
|
|
100
|
+
*/
|
|
101
|
+
async getOrSet(key, factory, ttl) {
|
|
102
|
+
const cached = await this.get(key);
|
|
103
|
+
if (cached !== null) return cached;
|
|
104
|
+
|
|
105
|
+
const value = await factory();
|
|
106
|
+
await this.set(key, value, ttl);
|
|
107
|
+
return value;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wrap a function with caching. Returns a new function that
|
|
112
|
+
* caches results based on argument serialisation.
|
|
113
|
+
*
|
|
114
|
+
* @param {Function} fn - the function to wrap
|
|
115
|
+
* @param {Object} [wrapOptions]
|
|
116
|
+
* @param {string} [wrapOptions.keyPrefix] - prefix for generated cache keys
|
|
117
|
+
* @param {number} [wrapOptions.ttl] - TTL in seconds
|
|
118
|
+
* @param {Function} [wrapOptions.keyGenerator] - custom key generator `(...args) => string`
|
|
119
|
+
* @returns {Function}
|
|
120
|
+
*/
|
|
121
|
+
wrap(fn, wrapOptions = {}) {
|
|
122
|
+
const {
|
|
123
|
+
keyPrefix = fn.name || "wrapped",
|
|
124
|
+
ttl,
|
|
125
|
+
keyGenerator,
|
|
126
|
+
} = wrapOptions;
|
|
127
|
+
|
|
128
|
+
return async (...args) => {
|
|
129
|
+
const cacheKey = keyGenerator
|
|
130
|
+
? `${keyPrefix}:${keyGenerator(...args)}`
|
|
131
|
+
: `${keyPrefix}:${JSON.stringify(args)}`;
|
|
132
|
+
|
|
133
|
+
return this.getOrSet(cacheKey, () => fn(...args), ttl);
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { beforeEach, describe, it } from "node:test";
|
|
3
|
+
import { createCache } from "./cache.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a mock Redis client backed by a Map.
|
|
7
|
+
* Provides get, set, del, keys, and expire methods.
|
|
8
|
+
*/
|
|
9
|
+
function createMockRedis() {
|
|
10
|
+
const store = new Map();
|
|
11
|
+
const expires = new Map();
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
store,
|
|
15
|
+
expires,
|
|
16
|
+
|
|
17
|
+
async get(key) {
|
|
18
|
+
return store.has(key) ? store.get(key) : null;
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async set(key, value, ...args) {
|
|
22
|
+
store.set(key, value);
|
|
23
|
+
// Support atomic SET key value EX seconds
|
|
24
|
+
if (args[0] === "EX" && args[1] != null) {
|
|
25
|
+
expires.set(key, args[1]);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async del(key) {
|
|
30
|
+
store.delete(key);
|
|
31
|
+
expires.delete(key);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async scan(_cursor, ...args) {
|
|
35
|
+
// Simple mock: return all matching keys in one batch
|
|
36
|
+
const matchIdx = args.indexOf("MATCH");
|
|
37
|
+
const pattern = matchIdx !== -1 ? args[matchIdx + 1] : "*";
|
|
38
|
+
const regex = new RegExp(
|
|
39
|
+
`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*")}$`,
|
|
40
|
+
);
|
|
41
|
+
const keys = [...store.keys()].filter((k) => regex.test(k));
|
|
42
|
+
return ["0", keys];
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async expire(key, seconds) {
|
|
46
|
+
expires.set(key, seconds);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("createCache", () => {
|
|
52
|
+
let redis;
|
|
53
|
+
let cache;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
redis = createMockRedis();
|
|
57
|
+
cache = createCache(redis, { prefix: "test:", defaultTTL: 60 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("get", () => {
|
|
61
|
+
it("returns null for a missing key", async () => {
|
|
62
|
+
const result = await cache.get("nonexistent");
|
|
63
|
+
assert.equal(result, null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns parsed JSON for an existing key", async () => {
|
|
67
|
+
redis.store.set("test:hello", JSON.stringify({ a: 1 }));
|
|
68
|
+
const result = await cache.get("hello");
|
|
69
|
+
assert.deepEqual(result, { a: 1 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null for invalid JSON", async () => {
|
|
73
|
+
redis.store.set("test:bad", "not-json{");
|
|
74
|
+
const result = await cache.get("bad");
|
|
75
|
+
assert.equal(result, null);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("set", () => {
|
|
80
|
+
it("round-trips JSON values through set/get", async () => {
|
|
81
|
+
const data = { users: [1, 2, 3], active: true };
|
|
82
|
+
await cache.set("data", data);
|
|
83
|
+
const result = await cache.get("data");
|
|
84
|
+
assert.deepEqual(result, data);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("passes TTL to Redis expire", async () => {
|
|
88
|
+
await cache.set("key", "value", 120);
|
|
89
|
+
assert.equal(redis.expires.get("test:key"), 120);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("uses defaultTTL when no TTL is provided", async () => {
|
|
93
|
+
await cache.set("key", "value");
|
|
94
|
+
assert.equal(redis.expires.get("test:key"), 60);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("del", () => {
|
|
99
|
+
it("removes a key", async () => {
|
|
100
|
+
await cache.set("gone", "bye");
|
|
101
|
+
await cache.del("gone");
|
|
102
|
+
const result = await cache.get("gone");
|
|
103
|
+
assert.equal(result, null);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("invalidate", () => {
|
|
108
|
+
it("deletes all keys matching a pattern", async () => {
|
|
109
|
+
await cache.set("user:1", "alice");
|
|
110
|
+
await cache.set("user:2", "bob");
|
|
111
|
+
await cache.set("post:1", "hello");
|
|
112
|
+
|
|
113
|
+
await cache.invalidate("user:*");
|
|
114
|
+
|
|
115
|
+
assert.equal(await cache.get("user:1"), null);
|
|
116
|
+
assert.equal(await cache.get("user:2"), null);
|
|
117
|
+
assert.notEqual(await cache.get("post:1"), null);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does nothing when no keys match", async () => {
|
|
121
|
+
await cache.set("a", 1);
|
|
122
|
+
await cache.invalidate("zzz:*");
|
|
123
|
+
assert.deepEqual(await cache.get("a"), 1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("getOrSet", () => {
|
|
128
|
+
it("calls factory on cache miss and caches the result", async () => {
|
|
129
|
+
let called = 0;
|
|
130
|
+
const factory = async () => {
|
|
131
|
+
called++;
|
|
132
|
+
return { fresh: true };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const result = await cache.getOrSet("miss", factory);
|
|
136
|
+
assert.deepEqual(result, { fresh: true });
|
|
137
|
+
assert.equal(called, 1);
|
|
138
|
+
|
|
139
|
+
// Value should now be cached
|
|
140
|
+
const cached = await cache.get("miss");
|
|
141
|
+
assert.deepEqual(cached, { fresh: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("returns cached value without calling factory on hit", async () => {
|
|
145
|
+
await cache.set("hit", { cached: true });
|
|
146
|
+
|
|
147
|
+
let called = 0;
|
|
148
|
+
const factory = async () => {
|
|
149
|
+
called++;
|
|
150
|
+
return { fresh: true };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const result = await cache.getOrSet("hit", factory);
|
|
154
|
+
assert.deepEqual(result, { cached: true });
|
|
155
|
+
assert.equal(called, 0);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("wrap", () => {
|
|
160
|
+
it("creates a cached function", async () => {
|
|
161
|
+
let calls = 0;
|
|
162
|
+
const expensive = async (x) => {
|
|
163
|
+
calls++;
|
|
164
|
+
return x * 2;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const cachedFn = cache.wrap(expensive, { keyPrefix: "double" });
|
|
168
|
+
|
|
169
|
+
const first = await cachedFn(5);
|
|
170
|
+
assert.equal(first, 10);
|
|
171
|
+
assert.equal(calls, 1);
|
|
172
|
+
|
|
173
|
+
const second = await cachedFn(5);
|
|
174
|
+
assert.equal(second, 10);
|
|
175
|
+
assert.equal(calls, 1); // Cache hit - factory not called again
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("uses keyGenerator when provided", async () => {
|
|
179
|
+
let _calls = 0;
|
|
180
|
+
const fn = async (a, b) => {
|
|
181
|
+
_calls++;
|
|
182
|
+
return a + b;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const cachedFn = cache.wrap(fn, {
|
|
186
|
+
keyPrefix: "sum",
|
|
187
|
+
keyGenerator: (a, b) => `${a}+${b}`,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const result = await cachedFn(2, 3);
|
|
191
|
+
assert.equal(result, 5);
|
|
192
|
+
|
|
193
|
+
// Verify the custom key was used
|
|
194
|
+
const storedKey = "test:sum:2+3";
|
|
195
|
+
assert.ok(redis.store.has(storedKey));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("caches different args separately", async () => {
|
|
199
|
+
let calls = 0;
|
|
200
|
+
const fn = async (x) => {
|
|
201
|
+
calls++;
|
|
202
|
+
return x * 3;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const cachedFn = cache.wrap(fn, { keyPrefix: "triple" });
|
|
206
|
+
|
|
207
|
+
assert.equal(await cachedFn(1), 3);
|
|
208
|
+
assert.equal(await cachedFn(2), 6);
|
|
209
|
+
assert.equal(calls, 2);
|
|
210
|
+
|
|
211
|
+
// Both should now be cached
|
|
212
|
+
assert.equal(await cachedFn(1), 3);
|
|
213
|
+
assert.equal(await cachedFn(2), 6);
|
|
214
|
+
assert.equal(calls, 2);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/core.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @techstream/quark-core - Core Barrel (Lightweight Utilities)
|
|
3
|
+
*
|
|
4
|
+
* A smaller entry-point that exposes only the modules with zero or
|
|
5
|
+
* minimal transitive dependencies: errors, logger, validation, utils,
|
|
6
|
+
* pagination, csrf, rate-limiter, file-validation, and db.
|
|
7
|
+
*
|
|
8
|
+
* Downstream projects that only need these utilities can import from
|
|
9
|
+
* "@techstream/quark-core/core" to avoid pulling in heavier modules
|
|
10
|
+
* like email, storage, queue, or metrics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// CSRF — depends on node:crypto + errors
|
|
14
|
+
export * from "./csrf.js";
|
|
15
|
+
// Database utilities — depends on the optional `pg` peer (loaded lazily by
|
|
16
|
+
// pingDatabase); createPrismaClient takes the app's Prisma client class.
|
|
17
|
+
export * from "./db.js";
|
|
18
|
+
// Errors — zero external deps
|
|
19
|
+
export * from "./errors.js";
|
|
20
|
+
// File validation — zero external deps
|
|
21
|
+
export * from "./file-validation.js";
|
|
22
|
+
// Logger — zero external deps
|
|
23
|
+
export * from "./logger.js";
|
|
24
|
+
// Pagination — depends on zod + errors
|
|
25
|
+
export * from "./pagination.js";
|
|
26
|
+
|
|
27
|
+
// Rate limiter — zero external deps (Redis optional at runtime)
|
|
28
|
+
export * from "./rate-limiter.js";
|
|
29
|
+
// Utilities — zero external deps
|
|
30
|
+
export * from "./utils.js";
|
|
31
|
+
// Validation — depends on zod
|
|
32
|
+
export * from "./validation.js";
|