@twasik4/pocket-service 1.0.2 → 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.
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "@twasik4/pocket-service",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
7
7
  "peerDependencies": {
8
- "@clickhouse/client": "^1.12.1",
8
+ "@clickhouse/client": "^1.12.1"
9
+ },
10
+ "dependencies": {
9
11
  "cors": "^2.8.5",
10
12
  "express": "^5.1.0",
11
13
  "jose": "^6.2.3",
@@ -21,16 +23,8 @@
21
23
  "@types/express": "^5.0.3",
22
24
  "@types/node": "^24.5.2",
23
25
  "@types/winston": "^2.4.4",
24
- "cors": "^2.8.5",
25
- "express": "^5.1.0",
26
- "jose": "^6.2.3",
27
- "mongodb": "^6.20.0",
28
26
  "mongodb-memory-server": "^11.0.1",
29
- "redis": "^5.8.2",
30
- "vitest": "^3.2.4",
31
- "winston": "^3.17.0",
32
- "winston-transport": "^4.9.0",
33
- "zod": "^4.1.12"
27
+ "vitest": "^3.2.4"
34
28
  },
35
29
  "scripts": {
36
30
  "build": "tsup",
@@ -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
  }
@@ -55,6 +55,7 @@ export class Service<TCollections extends MongoCollectionsConfig = {}> {
55
55
  asJson: true,
56
56
  customMiddleware: [],
57
57
  };
58
+ private withoutExpressFlag: boolean = false;
58
59
 
59
60
  private getAuthResolver(): AuthResolver {
60
61
  if (this.authResolverOverride) {
@@ -81,6 +82,11 @@ export class Service<TCollections extends MongoCollectionsConfig = {}> {
81
82
  }
82
83
 
83
84
  get api(): Express {
85
+ if (this.withoutExpressFlag) {
86
+ throw new Error(
87
+ "Express server is disabled. Service built without Express.",
88
+ );
89
+ }
84
90
  if (!this.dependencies.has("expressApp")) {
85
91
  this.dependencies.set("expressApp", express());
86
92
  }
@@ -326,76 +332,82 @@ export class Service<TCollections extends MongoCollectionsConfig = {}> {
326
332
  }
327
333
  });
328
334
 
329
- if (this.expressOptions.asJson) {
330
- this.log.info("Configuring Express to parse JSON bodies");
331
- this.api.use(express.json());
332
- }
335
+ if (this.withoutExpressFlag) {
336
+ this.log.warn(
337
+ "Express server is disabled. Service built without Express.",
338
+ );
339
+ } else {
340
+ if (this.expressOptions.asJson) {
341
+ this.log.info("Configuring Express to parse JSON bodies");
342
+ this.api.use(express.json());
343
+ }
333
344
 
334
- for (const mw of this.expressOptions.customMiddleware || []) {
335
- this.log.info("Adding custom middleware to Express");
336
- this.api.use(mw);
337
- }
345
+ for (const mw of this.expressOptions.customMiddleware || []) {
346
+ this.log.info("Adding custom middleware to Express");
347
+ this.api.use(mw);
348
+ }
349
+
350
+ if ("corsWhitelist" in this.expressOptions) {
351
+ this.log.info(
352
+ "Configuring CORS with whitelist: " +
353
+ this.expressOptions.corsWhitelist,
354
+ );
355
+ this.api.use(
356
+ cors({
357
+ origin: this.expressOptions.corsWhitelist,
358
+ credentials: this.expressOptions.credentials,
359
+ }),
360
+ );
361
+ } else if ("corsFn" in this.expressOptions) {
362
+ this.log.info("Configuring CORS with function");
363
+ this.api.use(
364
+ cors({
365
+ origin: this.expressOptions.corsFn,
366
+ credentials: this.expressOptions.credentials,
367
+ }),
368
+ );
369
+ }
338
370
 
339
- if ("corsWhitelist" in this.expressOptions) {
371
+ // routers
372
+ if (!this.expressOptions.omitDefaultRoutes) {
373
+ this.registerDefaultRoutes();
374
+ }
375
+ this.log.info(`Registering ${this.routers.length} routers`);
376
+ this.routers.forEach(({ base, router }) => {
377
+ this.api.use(base, router);
378
+ });
340
379
  this.log.info(
341
- "Configuring CORS with whitelist: " +
342
- this.expressOptions.corsWhitelist,
380
+ "Routers registered:" +
381
+ this.routers
382
+ .map((rt) => {
383
+ const maxMethodLength = rt.routes
384
+ ? Math.max(...rt.routes?.map((r) => r.method.length))
385
+ : 0;
386
+ const maxPathLength = rt.routes
387
+ ? Math.max(...rt.routes?.map((r) => r.fullPath.length))
388
+ : 0;
389
+ const maxAuthLength = rt.routes
390
+ ? Math.max(...rt.routes?.map((r) => (r.requireAuth ? 6 : 8)))
391
+ : 0;
392
+
393
+ return `\nRouter ${rt.base}: ${
394
+ rt.routes?.length || 0
395
+ } routes registered.\n${
396
+ rt.routes
397
+ ?.map(
398
+ (r) =>
399
+ `\t${("[" + r.method + "]").padEnd(maxMethodLength + 2)} ${(r.requireAuth ? "[Auth]" : "[Public]").padEnd(maxAuthLength)} ${r.fullPath.padEnd(maxPathLength)} | ${r.meta?.description || ""}`,
400
+ )
401
+ .join("\n") || ""
402
+ }`;
403
+ })
404
+ .join("\n"),
343
405
  );
344
- this.api.use(
345
- cors({
346
- origin: this.expressOptions.corsWhitelist,
347
- credentials: this.expressOptions.credentials,
348
- }),
349
- );
350
- } else if ("corsFn" in this.expressOptions) {
351
- this.log.info("Configuring CORS with function");
352
- this.api.use(
353
- cors({
354
- origin: this.expressOptions.corsFn,
355
- credentials: this.expressOptions.credentials,
356
- }),
357
- );
358
- }
359
406
 
360
- // routers
361
- if (!this.expressOptions.omitDefaultRoutes) {
362
- this.registerDefaultRoutes();
407
+ this.api.listen(this.port, () => {
408
+ this.log.info(`API listening at ${this.fullUrl}`);
409
+ });
363
410
  }
364
- this.log.info(`Registering ${this.routers.length} routers`);
365
- this.routers.forEach(({ base, router }) => {
366
- this.api.use(base, router);
367
- });
368
- this.log.info(
369
- "Routers registered:" +
370
- this.routers
371
- .map((rt) => {
372
- const maxMethodLength = rt.routes
373
- ? Math.max(...rt.routes?.map((r) => r.method.length))
374
- : 0;
375
- const maxPathLength = rt.routes
376
- ? Math.max(...rt.routes?.map((r) => r.fullPath.length))
377
- : 0;
378
- const maxAuthLength = rt.routes
379
- ? Math.max(...rt.routes?.map((r) => (r.requireAuth ? 6 : 8)))
380
- : 0;
381
-
382
- return `\nRouter ${rt.base}: ${
383
- rt.routes?.length || 0
384
- } routes registered.\n${
385
- rt.routes
386
- ?.map(
387
- (r) =>
388
- `\t${("[" + r.method + "]").padEnd(maxMethodLength + 2)} ${(r.requireAuth ? "[Auth]" : "[Public]").padEnd(maxAuthLength)} ${r.fullPath.padEnd(maxPathLength)} | ${r.meta?.description || ""}`,
389
- )
390
- .join("\n") || ""
391
- }`;
392
- })
393
- .join("\n"),
394
- );
395
-
396
- this.api.listen(this.port, () => {
397
- this.log.info(`API listening at ${this.fullUrl}`);
398
- });
399
411
 
400
412
  this._status = "running";
401
413
  return this;
@@ -852,22 +864,51 @@ export class Service<TCollections extends MongoCollectionsConfig = {}> {
852
864
  return this;
853
865
  }
854
866
 
867
+ withoutExpress() {
868
+ this.withoutExpressFlag = true;
869
+ return this;
870
+ }
871
+
855
872
  withExpressOptions(options: Partial<ExpressOptions>) {
873
+ if (this.withoutExpressFlag) {
874
+ this.log.warn(
875
+ "Express server is disabled. Express options will not be applied.",
876
+ );
877
+ return this;
878
+ }
856
879
  this.expressOptions = { ...this.expressOptions, ...options };
857
880
  return this;
858
881
  }
859
882
 
860
883
  withAuthStrategy(strategy: AuthStrategy) {
884
+ if (this.withoutExpressFlag) {
885
+ this.log.warn(
886
+ "Express server is disabled. Auth strategies will not be applied.",
887
+ );
888
+ return this;
889
+ }
861
890
  this.authStrategies.push(strategy);
862
891
  return this;
863
892
  }
864
893
 
865
894
  withAuthStrategies(strategies: AuthStrategy[]) {
895
+ if (this.withoutExpressFlag) {
896
+ this.log.warn(
897
+ "Express server is disabled. Auth strategies will not be applied.",
898
+ );
899
+ return this;
900
+ }
866
901
  this.authStrategies.push(...strategies);
867
902
  return this;
868
903
  }
869
904
 
870
905
  withAuthResolver(resolver: AuthResolver) {
906
+ if (this.withoutExpressFlag) {
907
+ this.log.warn(
908
+ "Express server is disabled. Auth resolver will not be applied.",
909
+ );
910
+ return this;
911
+ }
871
912
  this.authResolverOverride = resolver;
872
913
  return this;
873
914
  }
@@ -898,6 +939,12 @@ export class Service<TCollections extends MongoCollectionsConfig = {}> {
898
939
  router: Router,
899
940
  routes: RouteDefinition<any, any, boolean>[] = [],
900
941
  ) {
942
+ if (this.withoutExpressFlag) {
943
+ this.log.warn(
944
+ "Express server is disabled. Routers will not be registered.",
945
+ );
946
+ return this;
947
+ }
901
948
  if (base === "/") {
902
949
  this.log.warn("Base path '/' is not allowed. Skipping.");
903
950
  return this;