@twasik4/pocket-service 1.0.3 → 1.0.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.
@@ -7,7 +7,7 @@ export interface ClickhouseTable<TSchema extends Record<string, any>> {
7
7
  }
8
8
 
9
9
  export interface ClickhouseConfig<
10
- TTables extends Record<string, ClickhouseTable<any>>
10
+ TTables extends Record<string, ClickhouseTable<any>>,
11
11
  > {
12
12
  url: string;
13
13
  username?: string;
@@ -16,7 +16,7 @@ export interface ClickhouseConfig<
16
16
  }
17
17
 
18
18
  export function createTypedClickhouse<
19
- TTables extends Record<string, ClickhouseTable<any>>
19
+ TTables extends Record<string, ClickhouseTable<any>>,
20
20
  >(config: ClickhouseConfig<TTables>) {
21
21
  const client = createClient({
22
22
  host: config.url,
@@ -38,7 +38,7 @@ export function createTypedClickhouse<
38
38
  const select = async (where?: Filter): Promise<Row[]> => {
39
39
  let query = `SELECT * FROM ${table.name}`;
40
40
  if (where && Object.keys(where).length > 0) {
41
- const conditions = Object.keys(where)
41
+ const conditions = Object.entries(where)
42
42
  .map(([k, value]) => {
43
43
  if (typeof value === "string") return `${k} = '${value}'`;
44
44
  return `${k} = ${value}`;
@@ -62,11 +62,11 @@ export function createTypedClickhouse<
62
62
  };
63
63
 
64
64
  return [key, { select, insert }];
65
- })
65
+ }),
66
66
  ) as {
67
67
  [K in keyof TTables]: {
68
68
  select(
69
- where?: Partial<ReturnType<TTables[K]["schema"]>>
69
+ where?: Partial<ReturnType<TTables[K]["schema"]>>,
70
70
  ): Promise<ReturnType<TTables[K]["schema"]>[]>;
71
71
  insert(rows: ReturnType<TTables[K]["schema"]>[]): Promise<void>;
72
72
  };
@@ -199,12 +199,9 @@ export class CollectionWrapper<T extends Document> {
199
199
  },
200
200
  {
201
201
  $merge: {
202
- into: {
203
- db: this.collection.dbName,
204
- col: this.revision.collectionName,
205
- on: "_id",
206
- whenNotMatched: "insert",
207
- },
202
+ into: this.revision.collectionName,
203
+ on: "_id",
204
+ whenNotMatched: "insert",
208
205
  },
209
206
  },
210
207
  ])
@@ -218,17 +215,24 @@ export class CollectionWrapper<T extends Document> {
218
215
 
219
216
  /**
220
217
  * Soft delete a document by setting the `deletedAt` field to the current date.
218
+ * If the schema has a `deletedBy` field, it is set to the provided actor or defaults to `system`.
221
219
  * If the schema does not have a `deletedAt` field, it will perform a hard delete.
222
220
  * @param filter - The filter to find documents to delete.
221
+ * @param deletedBy - Optional actor identifier for soft deletes.
223
222
  */
224
- async delete(filter: Filter<T>): Promise<void> {
223
+ async delete(filter: Filter<T>, deletedBy?: string): Promise<void> {
225
224
  try {
226
225
  if (this.hasProperty("deletedAt")) {
227
226
  const deletedAt = new Date();
227
+ const deletedByValue = deletedBy ?? "system";
228
+ const setFields: Record<string, unknown> = {
229
+ deletedAt,
230
+ ...(this.hasProperty("deletedBy")
231
+ ? { deletedBy: deletedByValue }
232
+ : {}),
233
+ };
228
234
  const update = {
229
- $set: {
230
- deletedAt,
231
- },
235
+ $set: setFields,
232
236
  } as unknown as UpdateFilter<T>;
233
237
  await this.collection.updateMany(filter, update);
234
238
  if (this.revision) {
@@ -238,16 +242,13 @@ export class CollectionWrapper<T extends Document> {
238
242
  $match: filter,
239
243
  },
240
244
  {
241
- $set: { _id: new ObjectId(), deletedAt },
245
+ $set: { _id: new ObjectId(), ...setFields },
242
246
  },
243
247
  {
244
248
  $merge: {
245
- into: {
246
- db: this.collection.dbName,
247
- col: this.revision.collectionName,
248
- on: "_id",
249
- whenNotMatched: "insert",
250
- },
249
+ into: this.revision.collectionName,
250
+ on: "_id",
251
+ whenNotMatched: "insert",
251
252
  },
252
253
  },
253
254
  ])
@@ -32,6 +32,25 @@ export type MongoIndexSpec = {
32
32
  expireAfterSeconds?: number;
33
33
  };
34
34
 
35
+ export type MongoTimeSeriesSpec = {
36
+ /**
37
+ * Name of the date field that MongoDB uses as the event time.
38
+ */
39
+ timeField: string;
40
+ /**
41
+ * Optional metadata field used to group measurements.
42
+ */
43
+ metaField?: string;
44
+ /**
45
+ * Optional bucket granularity hint used by MongoDB.
46
+ */
47
+ granularity?: "seconds" | "minutes" | "hours";
48
+ /**
49
+ * Optional retention in seconds for automatic expiry.
50
+ */
51
+ expireAfterSeconds?: number;
52
+ };
53
+
35
54
  export type MongoCollectionMethodContext<TDocument extends Document> = {
36
55
  /**
37
56
  * The MongoDB client instance
@@ -71,6 +90,10 @@ export type MongoCollectionDefinition<
71
90
  * Optional array of index specifications to create on this collection. Indexes are created idempotently on service startup, so duplicate indexes are safely ignored.
72
91
  */
73
92
  indexes?: MongoIndexSpec[];
93
+ /**
94
+ * Optional MongoDB time-series settings. When provided, the collection is created as a time-series collection if it does not already exist.
95
+ */
96
+ timeSeries?: MongoTimeSeriesSpec;
74
97
  };
75
98
 
76
99
  export type MongoCollectionsConfig = Record<
@@ -147,6 +170,10 @@ export function defineMongoCollection<
147
170
  * Optional array of index specifications to create on this collection.
148
171
  */
149
172
  indexes?: MongoIndexSpec[];
173
+ /**
174
+ * Optional MongoDB time-series settings.
175
+ */
176
+ timeSeries?: MongoTimeSeriesSpec;
150
177
  }): MongoCollectionDefinition<SchemaOutput<TSchema>, {}>;
151
178
 
152
179
  /**
@@ -177,6 +204,10 @@ export function defineMongoCollection<
177
204
  * Optional array of index specifications to create on this collection.
178
205
  */
179
206
  indexes?: MongoIndexSpec[];
207
+ /**
208
+ * Optional MongoDB time-series settings.
209
+ */
210
+ timeSeries?: MongoTimeSeriesSpec;
180
211
  }): MongoCollectionDefinition<SchemaOutput<TSchema>, TMethods>;
181
212
 
182
213
  /**
@@ -207,6 +238,10 @@ export function defineMongoCollection<
207
238
  * Optional array of index specifications to create on this collection.
208
239
  */
209
240
  indexes?: MongoIndexSpec[];
241
+ /**
242
+ * Optional MongoDB time-series settings.
243
+ */
244
+ timeSeries?: MongoTimeSeriesSpec;
210
245
  }): MongoCollectionDefinition<SchemaOutput<TSchema>, TMethods> {
211
246
  return {
212
247
  name: config.name,
@@ -218,6 +253,7 @@ export function defineMongoCollection<
218
253
  */
219
254
  methods: config.methods,
220
255
  indexes: config.indexes,
256
+ timeSeries: config.timeSeries,
221
257
  /**
222
258
  * Whether to automatically track revisions of documents in this collection. If enabled, every update will insert a copy of the updated document into a separate revision collection, allowing you to keep a history of changes. The revision collection will have the same name as the main collection with `_revisions` appended to it (e.g. `users` -> `users_revisions`).
223
259
  */
@@ -236,6 +272,7 @@ export function createMongo<TCollections extends MongoCollectionsConfig>(
236
272
  const db = client.db(dbName);
237
273
 
238
274
  const wrapped = {} as InferMongoCollections<TCollections>;
275
+ const setupFns: Array<() => Promise<void>> = [];
239
276
 
240
277
  for (const key in config) {
241
278
  const def = config[key];
@@ -257,16 +294,78 @@ export function createMongo<TCollections extends MongoCollectionsConfig>(
257
294
  base,
258
295
  }) || {};
259
296
 
260
- if (def.indexes && def.indexes.length > 0) {
261
- for (const indexSpec of def.indexes) {
262
- const { key, ...options } = indexSpec;
263
- collection.createIndex(key, options).catch((err) => {
264
- console.error(
265
- `Failed to create index on ${collectionName}: ${err.message}`,
266
- );
267
- });
297
+ setupFns.push(async () => {
298
+ if (def.timeSeries) {
299
+ const existingCollection = await db
300
+ .listCollections({ name: collectionName }, { nameOnly: false })
301
+ .next();
302
+
303
+ if (!existingCollection) {
304
+ await db.createCollection(collectionName, {
305
+ timeseries: {
306
+ timeField: def.timeSeries.timeField,
307
+ ...(def.timeSeries.metaField
308
+ ? { metaField: def.timeSeries.metaField }
309
+ : {}),
310
+ ...(def.timeSeries.granularity
311
+ ? { granularity: def.timeSeries.granularity }
312
+ : {}),
313
+ },
314
+ ...(typeof def.timeSeries.expireAfterSeconds === "number"
315
+ ? { expireAfterSeconds: def.timeSeries.expireAfterSeconds }
316
+ : {}),
317
+ });
318
+ } else {
319
+ const hasTimeSeriesOptions =
320
+ existingCollection.type === "timeseries" ||
321
+ Boolean((existingCollection.options as any)?.timeseries);
322
+
323
+ if (!hasTimeSeriesOptions) {
324
+ throw new Error(
325
+ `Collection ${collectionName} already exists and is not a time-series collection. Migration is required before enabling timeSeries.`,
326
+ );
327
+ }
328
+
329
+ const existingTimeSeries = (existingCollection.options as any)
330
+ ?.timeseries as
331
+ | {
332
+ timeField?: string;
333
+ metaField?: string;
334
+ }
335
+ | undefined;
336
+
337
+ if (
338
+ existingTimeSeries?.timeField &&
339
+ existingTimeSeries.timeField !== def.timeSeries.timeField
340
+ ) {
341
+ throw new Error(
342
+ `Collection ${collectionName} has timeField '${existingTimeSeries.timeField}' but config expects '${def.timeSeries.timeField}'.`,
343
+ );
344
+ }
345
+
346
+ if (
347
+ def.timeSeries.metaField &&
348
+ existingTimeSeries?.metaField &&
349
+ existingTimeSeries.metaField !== def.timeSeries.metaField
350
+ ) {
351
+ throw new Error(
352
+ `Collection ${collectionName} has metaField '${existingTimeSeries.metaField}' but config expects '${def.timeSeries.metaField}'.`,
353
+ );
354
+ }
355
+ }
268
356
  }
269
- }
357
+
358
+ if (def.indexes && def.indexes.length > 0) {
359
+ for (const indexSpec of def.indexes) {
360
+ const { key, ...indexOptions } = indexSpec;
361
+ await collection.createIndex(key, indexOptions).catch((err) => {
362
+ console.error(
363
+ `Failed to create index on ${collectionName}: ${err.message}`,
364
+ );
365
+ });
366
+ }
367
+ }
368
+ });
270
369
 
271
370
  wrapped[key] = Object.assign(
272
371
  base,
@@ -274,12 +373,29 @@ export function createMongo<TCollections extends MongoCollectionsConfig>(
274
373
  ) as InferMongoCollections<TCollections>[typeof key];
275
374
  }
276
375
 
376
+ let setupPromise: Promise<void> | null = null;
377
+
378
+ const ensureSetup = async () => {
379
+ if (!setupPromise) {
380
+ setupPromise = (async () => {
381
+ for (const setup of setupFns) {
382
+ await setup();
383
+ }
384
+ })();
385
+ }
386
+ return setupPromise;
387
+ };
388
+
277
389
  return {
278
390
  client,
279
391
  db,
280
392
  collections: wrapped,
281
393
  ...wrapped,
282
394
  disconnect: async () => client.close(),
283
- connect: async () => client.connect(),
395
+ connect: async () => {
396
+ await client.connect();
397
+ await ensureSetup();
398
+ return client;
399
+ },
284
400
  } as MongoDatabase<TCollections>;
285
401
  }
@@ -0,0 +1,150 @@
1
+ /// <reference types="vitest/globals" />
2
+ import type { Request } from "express";
3
+ import { SignJWT } from "jose";
4
+ import {
5
+ createJoseJwtVerifier,
6
+ createJwtAuthStrategy,
7
+ createMtlsAuthStrategy,
8
+ } from "../src/workers/auth/strategies";
9
+ import type { RouteDefinition } from "../src/workers/express-types";
10
+
11
+ const route: RouteDefinition<any, any, boolean> = {
12
+ method: "GET",
13
+ fullPath: "/secure",
14
+ requireAuth: true,
15
+ };
16
+
17
+ describe("Auth strategies", () => {
18
+ it("maps JWT claims to authenticated user using Bearer token", async () => {
19
+ const strategy = createJwtAuthStrategy({
20
+ verifyToken: async () => ({
21
+ sub: "user-1",
22
+ team: "core",
23
+ flags: ["a", "b"],
24
+ ignoredObject: { x: 1 },
25
+ }),
26
+ });
27
+
28
+ const req = {
29
+ headers: { authorization: "Bearer signed-token" },
30
+ } as unknown as Request;
31
+
32
+ await expect(strategy.authenticate(req, route)).resolves.toEqual({
33
+ userId: "user-1",
34
+ team: "core",
35
+ flags: ["a", "b"],
36
+ });
37
+ });
38
+
39
+ it("returns null when token prefix does not match", async () => {
40
+ const verifyToken = vi.fn();
41
+ const strategy = createJwtAuthStrategy({
42
+ verifyToken,
43
+ });
44
+
45
+ const req = {
46
+ headers: { authorization: "Token signed-token" },
47
+ } as unknown as Request;
48
+
49
+ await expect(strategy.authenticate(req, route)).resolves.toBeNull();
50
+ expect(verifyToken).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("supports custom payload mapping", async () => {
54
+ const strategy = createJwtAuthStrategy({
55
+ verifyToken: async () => ({ sub: "user-2", role: "admin" }),
56
+ mapPayloadToUser: async (claims) => ({
57
+ userId: String(claims.sub),
58
+ role: String(claims.role),
59
+ }),
60
+ });
61
+
62
+ const req = {
63
+ headers: { authorization: "Bearer signed-token" },
64
+ } as unknown as Request;
65
+
66
+ await expect(strategy.authenticate(req, route)).resolves.toEqual({
67
+ userId: "user-2",
68
+ role: "admin",
69
+ });
70
+ });
71
+
72
+ it("verifies HS256 JWT using jose verifier", async () => {
73
+ const secret = "test-secret";
74
+ const token = await new SignJWT({ scope: "api:read" })
75
+ .setProtectedHeader({ alg: "HS256" })
76
+ .setSubject("subject-1")
77
+ .setIssuer("issuer-a")
78
+ .setAudience("audience-a")
79
+ .setIssuedAt()
80
+ .setExpirationTime("10m")
81
+ .sign(new TextEncoder().encode(secret));
82
+
83
+ const verify = createJoseJwtVerifier({
84
+ secret,
85
+ issuer: "issuer-a",
86
+ audience: "audience-a",
87
+ requiredClaims: ["sub", "scope"],
88
+ });
89
+
90
+ await expect(verify(token, {} as Request, route)).resolves.toMatchObject({
91
+ sub: "subject-1",
92
+ scope: "api:read",
93
+ });
94
+ });
95
+
96
+ it("returns null when required JWT claims are missing", async () => {
97
+ const secret = "test-secret";
98
+ const token = await new SignJWT({ role: "reader" })
99
+ .setProtectedHeader({ alg: "HS256" })
100
+ .setSubject("subject-2")
101
+ .setIssuedAt()
102
+ .setExpirationTime("10m")
103
+ .sign(new TextEncoder().encode(secret));
104
+
105
+ const verify = createJoseJwtVerifier({
106
+ secret,
107
+ requiredClaims: ["sub", "scope"],
108
+ });
109
+
110
+ await expect(verify(token, {} as Request, route)).resolves.toBeNull();
111
+ });
112
+
113
+ it("extracts mTLS identity from URI SAN", async () => {
114
+ const strategy = createMtlsAuthStrategy();
115
+ const req = {
116
+ socket: {
117
+ authorized: true,
118
+ getPeerCertificate: () => ({
119
+ subjectaltname:
120
+ "DNS:service.local, URI:spiffe://cluster/ns/default/sa/api",
121
+ subject: { CN: "subject-cn" },
122
+ issuer: { CN: "issuer-cn" },
123
+ fingerprint256: "fingerprint",
124
+ serialNumber: "serial",
125
+ }),
126
+ },
127
+ } as unknown as Request;
128
+
129
+ await expect(strategy.authenticate(req, route)).resolves.toMatchObject({
130
+ userId: "spiffe://cluster/ns/default/sa/api",
131
+ certSubjectCn: "subject-cn",
132
+ certIssuerCn: "issuer-cn",
133
+ certFingerprint256: "fingerprint",
134
+ certSerialNumber: "serial",
135
+ mtlsAuthorized: true,
136
+ });
137
+ });
138
+
139
+ it("rejects unauthorized mTLS request by default", async () => {
140
+ const strategy = createMtlsAuthStrategy();
141
+ const req = {
142
+ socket: {
143
+ authorized: false,
144
+ getPeerCertificate: () => ({ subject: { CN: "x" } }),
145
+ },
146
+ } as unknown as Request;
147
+
148
+ await expect(strategy.authenticate(req, route)).resolves.toBeNull();
149
+ });
150
+ });
@@ -0,0 +1,102 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const commandMock = vi.fn();
5
+ const queryMock = vi.fn();
6
+ const insertMock = vi.fn();
7
+ const closeMock = vi.fn();
8
+
9
+ vi.mock("@clickhouse/client", () => ({
10
+ createClient: vi.fn(() => ({
11
+ command: commandMock,
12
+ query: queryMock,
13
+ insert: insertMock,
14
+ close: closeMock,
15
+ })),
16
+ }));
17
+
18
+ import { createTypedClickhouse } from "../src/workers/db/clickhouse";
19
+
20
+ describe("Clickhouse wrapper", () => {
21
+ beforeEach(() => {
22
+ commandMock.mockReset();
23
+ queryMock.mockReset();
24
+ insertMock.mockReset();
25
+ closeMock.mockReset();
26
+ });
27
+
28
+ it("executes createSQL for every configured table", async () => {
29
+ const clickhouse = createTypedClickhouse({
30
+ url: "http://localhost:8123",
31
+ tables: {
32
+ events: {
33
+ name: "events",
34
+ createSQL: "CREATE TABLE events (...)",
35
+ schema: () => ({ id: "", type: "" }),
36
+ },
37
+ metrics: {
38
+ name: "metrics",
39
+ createSQL: "CREATE TABLE metrics (...)",
40
+ schema: () => ({ id: "", value: 0 }),
41
+ },
42
+ },
43
+ });
44
+
45
+ await clickhouse.ensureTables();
46
+
47
+ expect(commandMock).toHaveBeenCalledTimes(2);
48
+ expect(commandMock).toHaveBeenNthCalledWith(1, {
49
+ query: "CREATE TABLE events (...)",
50
+ });
51
+ expect(commandMock).toHaveBeenNthCalledWith(2, {
52
+ query: "CREATE TABLE metrics (...)",
53
+ });
54
+ });
55
+
56
+ it("builds SELECT query with filters and returns parsed rows", async () => {
57
+ queryMock.mockResolvedValue({
58
+ json: async () => ({
59
+ data: [{ id: "1", type: "login", severity: 2 }],
60
+ }),
61
+ });
62
+
63
+ const clickhouse = createTypedClickhouse({
64
+ url: "http://localhost:8123",
65
+ tables: {
66
+ events: {
67
+ name: "events",
68
+ createSQL: "CREATE TABLE events (...)",
69
+ schema: () => ({ id: "", type: "", severity: 0 }),
70
+ },
71
+ },
72
+ });
73
+
74
+ const rows = await clickhouse.events.select({ type: "login", severity: 2 });
75
+
76
+ expect(queryMock).toHaveBeenCalledWith({
77
+ query: "SELECT * FROM events WHERE type = 'login' AND severity = 2",
78
+ });
79
+ expect(rows).toEqual([{ id: "1", type: "login", severity: 2 }]);
80
+ });
81
+
82
+ it("inserts rows using JSONEachRow format", async () => {
83
+ const clickhouse = createTypedClickhouse({
84
+ url: "http://localhost:8123",
85
+ tables: {
86
+ events: {
87
+ name: "events",
88
+ createSQL: "CREATE TABLE events (...)",
89
+ schema: () => ({ id: "", type: "" }),
90
+ },
91
+ },
92
+ });
93
+
94
+ await clickhouse.events.insert([{ id: "1", type: "login" }]);
95
+
96
+ expect(insertMock).toHaveBeenCalledWith({
97
+ table: "events",
98
+ values: [{ id: "1", type: "login" }],
99
+ format: "JSONEachRow",
100
+ });
101
+ });
102
+ });