@sentzunhat/zacatl 0.0.5 → 0.0.7

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/patterns.yaml ADDED
@@ -0,0 +1,55 @@
1
+ # Zacatl Project: Coding Patterns and Architectural Practices
2
+
3
+ origin:
4
+ - coding_standards.md
5
+ - coding_standards.pdf
6
+ - README.md (patterns section)
7
+
8
+ patterns:
9
+ - hexagonal_architecture:
10
+ description: |
11
+ Structure the application to separate core business logic from external dependencies using Ports and Adapters. Core logic is in the Domain layer, with Application, Infrastructure, and Platform layers handling entry points, persistence, and orchestration.
12
+ source: coding_standards.pdf
13
+ - object_oriented_programming:
14
+ description: |
15
+ Use classes to encapsulate state and behavior, especially for providers, repositories, and server classes. Follows OOP principles for modularity and encapsulation.
16
+ source: coding_standards.pdf
17
+ - functional_programming:
18
+ description: |
19
+ Use pure functions and higher-order functions for stateless operations and utility logic. Prefer immutability and function composition where possible.
20
+ source: coding_standards.pdf
21
+ - dependency_injection:
22
+ description: |
23
+ Use tsyringe for DI. Register all services, repositories, and handlers in the DI container. Never instantiate dependencies directly; always resolve via DI. Promotes modularity, flexibility, and testability.
24
+ source: coding_standards.pdf
25
+ - modular_server_design:
26
+ description: |
27
+ Server classes (e.g., ModularServer, FastifyServer) accept dependencies (Fastify, providers, databases, etc.) via constructor injection. Route registration and DI setup are centralized.
28
+ source: coding_standards.pdf
29
+ - centralized_configuration:
30
+ description: |
31
+ Use a DI container to register and configure dependencies (providers, repositories, Fastify instances, etc.). Server initialization is performed by resolving the server from the DI container and starting it on a specified port.
32
+ source: coding_standards.pdf
33
+ - error_handling:
34
+ description: |
35
+ All custom errors extend CustomError. Use CustomError.handle(error) in middleware or route handlers to log/format errors. Always include relevant metadata and a clear message. Let errors bubble up to Fastify’s error handler unless custom handling is needed.
36
+ source: README.md
37
+ - validation:
38
+ description: |
39
+ Use zod schemas for all request/response validation. Place validation logic in the Application layer.
40
+ source: README.md
41
+ - testing:
42
+ description: |
43
+ Use vitest for all tests. Place tests in test/, mirroring src/ structure. Write unit tests for all logic. Mock all external dependencies (DB, services) in tests. Use descriptive test names and group related tests in files named after the module under test.
44
+ source: README.md
45
+
46
+ best_practices:
47
+ - clean_code:
48
+ description: Write clean, modular, and straightforward code with meaningful names and strong typing. Use the latest language features and avoid redundancy (DRY principle).
49
+ source: coding_standards.pdf
50
+ - security_and_ethics:
51
+ description: Validate all inputs, handle errors gracefully, and never expose sensitive data. Code should be secure, efficient, and ethical.
52
+ source: coding_standards.pdf
53
+ - minimal_comments:
54
+ description: Prefer clarity in code and tests over excessive comments. Use comments only when necessary for context.
55
+ source: coding_standards.pdf
@@ -14,7 +14,9 @@ export type HttpStatusCode =
14
14
  | 502 // Bad Gateway
15
15
  | 503; // Service Unavailable;
16
16
 
17
- export type ErrorCode = Optional<HttpStatusCode | string>;
17
+ export type StatusCodeString = "invalid";
18
+
19
+ export type ErrorCode = Optional<HttpStatusCode | StatusCodeString>;
18
20
 
19
21
  export interface CustomErrorsArgs {
20
22
  message: string;
@@ -42,7 +44,6 @@ export class CustomError extends Error {
42
44
 
43
45
  constructor({ message, code, reason, metadata, error }: CustomErrorArgs) {
44
46
  super(message);
45
-
46
47
  this.id = uuidv4();
47
48
  this.custom = true;
48
49
  this.name = this.constructor.name;
@@ -1,11 +1,10 @@
1
- import { z } from "zod";
2
1
  import { FastifyReply } from "fastify";
3
2
 
4
3
  import { Request } from "./request";
5
4
 
6
5
  export type Handler<
7
6
  TBody,
8
- TQuerystring = z.ZodSchema<Record<string, string>>,
7
+ TQuerystring = Record<string, string>,
9
8
  TParams = void
10
9
  > = (
11
10
  request: Request<TBody, TQuerystring, TParams>,
@@ -5,6 +5,20 @@ import { HTTPMethods, FastifySchema, FastifyReply } from "fastify";
5
5
  import { RouteHandler } from "./route-handler";
6
6
  import { Request } from "../common/request";
7
7
 
8
+ export type RouteSchema<
9
+ TBody = void,
10
+ TQuerystring = Record<string, string>,
11
+ TParams = Record<string, string>,
12
+ THeaders = Record<string, string>,
13
+ TResponse = void
14
+ > = {
15
+ body?: z.ZodSchema<TBody>;
16
+ querystring?: z.ZodSchema<TQuerystring>;
17
+ params?: z.ZodSchema<TParams>;
18
+ headers?: z.ZodSchema<THeaders>;
19
+ response?: z.ZodSchema<TResponse>;
20
+ };
21
+
8
22
  export type AbstractRouteHandlerConstructor = {
9
23
  url: string;
10
24
  method: HTTPMethods;
@@ -15,9 +29,9 @@ export type HandlerOutput<TResponse> = TResponse;
15
29
 
16
30
  export abstract class AbstractRouteHandler<
17
31
  TBody = void,
18
- TQuerystring = z.ZodSchema<Record<string, string>>,
32
+ TQuerystring = Record<string, string>,
19
33
  TResponse = void,
20
- TParams = void
34
+ TParams = Record<string, string>
21
35
  > implements RouteHandler<TBody, TQuerystring, TParams>
22
36
  {
23
37
  public url: string;
@@ -1,6 +1,4 @@
1
- import { z } from "zod";
2
1
  import { FastifySchema } from "fastify";
3
-
4
2
  import { AbstractRouteHandler } from "./abstract";
5
3
 
6
4
  export type GetRouteHandlerConstructor = {
@@ -9,9 +7,9 @@ export type GetRouteHandlerConstructor = {
9
7
  };
10
8
 
11
9
  export abstract class GetRouteHandler<
12
- TBody = never,
13
- TResponse = never,
14
- TQuerystring = z.ZodSchema<Record<string, string>>,
10
+ TBody = void,
11
+ TQuerystring = Record<string, string>,
12
+ TResponse = void,
15
13
  TParams = void
16
14
  > extends AbstractRouteHandler<TBody, TQuerystring, TResponse, TParams> {
17
15
  constructor(args: GetRouteHandlerConstructor) {
@@ -1,6 +1,4 @@
1
- import { z } from "zod";
2
1
  import { FastifySchema } from "fastify";
3
-
4
2
  import { AbstractRouteHandler } from "./abstract";
5
3
 
6
4
  export type PostRouteHandlerConstructor = {
@@ -9,10 +7,10 @@ export type PostRouteHandlerConstructor = {
9
7
  };
10
8
 
11
9
  export abstract class PostRouteHandler<
12
- TBody = never,
13
- TResponse = never,
14
- TQuerystring = z.ZodSchema<Record<string, string>>,
15
- TParams = void
10
+ TBody = unknown,
11
+ TResponse = unknown,
12
+ TQuerystring = Record<string, string>,
13
+ TParams = Record<string, string>
16
14
  > extends AbstractRouteHandler<TBody, TQuerystring, TResponse, TParams> {
17
15
  constructor(args: PostRouteHandlerConstructor) {
18
16
  super({
@@ -1,11 +1,10 @@
1
- import { z } from "zod";
2
1
  import { FastifySchema, HTTPMethods } from "fastify";
3
2
 
4
3
  import { Handler } from "../common/handler";
5
4
 
6
5
  export type RouteHandler<
7
6
  TBody = void,
8
- TQuerystring = z.ZodSchema<Record<string, string>>,
7
+ TQuerystring = Record<string, string>,
9
8
  TParams = void
10
9
  > = {
11
10
  url: string;
@@ -1,8 +1,13 @@
1
1
  import importedMongoose, {
2
2
  connection,
3
+ IfAny,
3
4
  Model,
4
5
  Mongoose,
6
+ Document,
5
7
  Schema,
8
+ Default__v,
9
+ Require_id,
10
+ ObjectId,
6
11
  } from "mongoose";
7
12
  import { v4 as uuidv4 } from "uuid";
8
13
  import { container } from "tsyringe";
@@ -12,17 +17,46 @@ export type BaseRepositoryConfig<D> = {
12
17
  schema: Schema<D>;
13
18
  };
14
19
 
20
+ export type WithMongooseMeta<T> = Default__v<Require_id<T>>;
21
+
22
+ export type MongooseDocument<Db> = Document<
23
+ ObjectId,
24
+ {},
25
+ Db,
26
+ Record<string, string>
27
+ > &
28
+ WithMongooseMeta<Db>;
29
+
30
+ export type LeanWithMeta<T> = WithMongooseMeta<T> & {
31
+ id: string;
32
+ createdAt: Date;
33
+ updatedAt: Date;
34
+ };
35
+
15
36
  export type LeanDocument<T> = T & {
16
37
  id: string;
17
38
  createdAt: Date;
18
39
  updatedAt: Date;
19
40
  };
20
41
 
42
+ export type MongooseDoc<Db> = IfAny<
43
+ MongooseDocument<Db>,
44
+ MongooseDocument<Db>,
45
+ MongooseDocument<Db>
46
+ >;
47
+
48
+ export type ToLeanInput<D, T> =
49
+ | MongooseDoc<D>
50
+ | LeanDocument<T>
51
+ | null
52
+ | undefined;
53
+
21
54
  // D - Document, meant for Database representation
22
55
  // T - Type, meant for TypeScript representation
23
56
  export type Repository<D, T> = {
24
57
  model: Model<D>;
25
58
 
59
+ toLean(input: ToLeanInput<D, T>): LeanDocument<T> | null;
26
60
  findById(id: string): Promise<LeanDocument<T> | null>;
27
61
  create(entity: D): Promise<LeanDocument<T>>;
28
62
  update(id: string, update: Partial<D>): Promise<LeanDocument<T> | null>;
@@ -31,13 +65,15 @@ export type Repository<D, T> = {
31
65
 
32
66
  export abstract class BaseRepository<D, T> implements Repository<D, T> {
33
67
  public readonly model: Model<D>;
68
+ private readonly config: BaseRepositoryConfig<D>;
34
69
 
35
70
  constructor(config: BaseRepositoryConfig<D>) {
71
+ this.config = config;
36
72
  const mongoose = connection.db?.databaseName
37
73
  ? importedMongoose
38
74
  : container.resolve<Mongoose>(Mongoose);
39
75
 
40
- const { name, schema } = config;
76
+ const { name, schema } = this.config;
41
77
 
42
78
  if (name) {
43
79
  this.model = mongoose.model<D>(name, schema);
@@ -50,23 +86,71 @@ export abstract class BaseRepository<D, T> implements Repository<D, T> {
50
86
  this.model.init();
51
87
  }
52
88
 
89
+ /**
90
+ * Ensures the returned document has the correct id, createdAt, and updatedAt fields.
91
+ * Accepts a Mongoose document, a plain object, or a result from .lean().
92
+ */
93
+ public toLean(input: ToLeanInput<D, T>): LeanDocument<T> | null {
94
+ if (!input) {
95
+ return null;
96
+ }
97
+
98
+ let base: LeanWithMeta<T>;
99
+
100
+ if (typeof (input as MongooseDoc<D>).toObject === "function") {
101
+ base = (input as MongooseDoc<D>).toObject<LeanDocument<T>>({
102
+ virtuals: true,
103
+ });
104
+ } else {
105
+ base = input as LeanWithMeta<T>;
106
+ }
107
+
108
+ const result: LeanDocument<T> = {
109
+ ...(base as T), // trust only known keys from T
110
+ id:
111
+ typeof base.id === "string"
112
+ ? base.id
113
+ : typeof base._id === "string"
114
+ ? base._id
115
+ : base._id !== undefined
116
+ ? String(base._id)
117
+ : "",
118
+ createdAt:
119
+ base.createdAt instanceof Date
120
+ ? base.createdAt
121
+ : base.createdAt
122
+ ? new Date(base.createdAt as string | number)
123
+ : new Date(),
124
+ updatedAt:
125
+ base.updatedAt instanceof Date
126
+ ? base.updatedAt
127
+ : base.updatedAt
128
+ ? new Date(base.updatedAt as string | number)
129
+ : new Date(),
130
+ };
131
+
132
+ return result;
133
+ }
134
+
53
135
  async findById(id: string): Promise<LeanDocument<T> | null> {
54
136
  const entity = await this.model
55
137
  .findById(id)
56
138
  .lean<LeanDocument<T>>({ virtuals: true })
57
139
  .exec();
58
140
 
59
- if (!entity) {
60
- return null;
61
- }
62
-
63
- return entity;
141
+ return this.toLean(entity);
64
142
  }
65
143
 
66
144
  async create(entity: D): Promise<LeanDocument<T>> {
67
- const doc = await this.model.create(entity);
145
+ const document = await this.model.create<D>(entity);
68
146
 
69
- return doc.toObject<LeanDocument<T>>({ virtuals: true });
147
+ const leanDocument = this.toLean(document as MongooseDoc<D>);
148
+
149
+ if (!leanDocument) {
150
+ throw new Error("failed to create document");
151
+ }
152
+
153
+ return leanDocument;
70
154
  }
71
155
 
72
156
  async update(
@@ -78,11 +162,7 @@ export abstract class BaseRepository<D, T> implements Repository<D, T> {
78
162
  .lean<LeanDocument<T>>({ virtuals: true })
79
163
  .exec();
80
164
 
81
- if (!entity) {
82
- return null;
83
- }
84
-
85
- return entity;
165
+ return this.toLean(entity);
86
166
  }
87
167
 
88
168
  async delete(id: string): Promise<LeanDocument<T> | null> {
@@ -91,10 +171,6 @@ export abstract class BaseRepository<D, T> implements Repository<D, T> {
91
171
  .lean<LeanDocument<T>>({ virtuals: true })
92
172
  .exec();
93
173
 
94
- if (!entity) {
95
- return null;
96
- }
97
-
98
- return entity;
174
+ return this.toLean(entity);
99
175
  }
100
176
  }
@@ -206,6 +206,10 @@ export class Service implements ServicePort {
206
206
  }
207
207
 
208
208
  public async configureDatabases(): Promise<void> {
209
+ if (!this.databases || this.databases.length === 0) {
210
+ return; // No databases to configure
211
+ }
212
+
209
213
  for (const database of this.databases) {
210
214
  const strategy = strategiesForDatabaseVendor[database.vendor];
211
215
 
package/start.md ADDED
@@ -0,0 +1,34 @@
1
+ # Zacatl Project Onboarding & Usage
2
+
3
+ Welcome to Zacatl! This project uses structured YAML documentation for all context, architecture, coding standards, and best practices.
4
+
5
+ ## Getting Started
6
+
7
+ - **Project context, architecture, and component summaries:**
8
+ See [`context.yaml`](./context.yaml)
9
+ - **Coding standards, naming conventions, and best practices:**
10
+ See [`guidelines.yaml`](./guidelines.yaml)
11
+ - **Design and usage patterns:**
12
+ See [`patterns.yaml`](./patterns.yaml)
13
+ - **MongoDB schema design guidelines:**
14
+ See [`mongodb.yaml`](./mongodb.yaml)
15
+
16
+ ## Setup
17
+
18
+ 1. Install dependencies:
19
+ ```zsh
20
+ npm install
21
+ ```
22
+ 2. Run tests:
23
+ ```zsh
24
+ npm test
25
+ ```
26
+ 3. Explore the codebase, starting from `src/`.
27
+
28
+ ## Contributing
29
+
30
+ - Follow the guidelines in `guidelines.yaml` and `patterns.yaml`.
31
+ - Update `context.yaml`, `guidelines.yaml`, `patterns.yaml`, or `mongodb.yaml` with any new patterns or conventions.
32
+ - Place all tests in the `test/` directory, mirroring the `src/` structure.
33
+
34
+ For any questions, refer to the YAML documentation or contact the maintainers.
@@ -16,7 +16,14 @@ class DummyHookHandler implements HookHandler {
16
16
  async execute(_: FastifyRequest): Promise<void> {}
17
17
  }
18
18
 
19
- class DummyRouteHandler extends AbstractRouteHandler {
19
+ import { FastifyReply } from "fastify";
20
+
21
+ class DummyRouteHandler extends AbstractRouteHandler<
22
+ void, // Body
23
+ Record<string, string>, // Querystring
24
+ void, // Params
25
+ void // Response
26
+ > {
20
27
  constructor() {
21
28
  super({
22
29
  url: "/",
@@ -25,7 +32,17 @@ class DummyRouteHandler extends AbstractRouteHandler {
25
32
  });
26
33
  }
27
34
 
28
- handler(_: Request<void, {}>): void | Promise<void> {}
35
+ handler(
36
+ _: Request<
37
+ void, // Body
38
+ Record<string, string>, // Querystring
39
+ void // Params
40
+ >,
41
+ __: FastifyReply
42
+ ): void | Promise<void> {
43
+ // Dummy implementation
44
+ return;
45
+ }
29
46
  }
30
47
 
31
48
  const fakeConfig: ConfigApplication = {
@@ -10,9 +10,29 @@ import {
10
10
  Request,
11
11
  } from "../../../../../../../../src/micro-service/architecture/application";
12
12
 
13
- class TestRouteHandler extends AbstractRouteHandler<unknown, string, unknown> {
14
- async handler(_: unknown, __: unknown): Promise<string> {
15
- return "Test Data";
13
+ class TestRouteHandler extends AbstractRouteHandler<
14
+ void, // Body
15
+ Record<string, string>, // Querystring
16
+ void, // Params
17
+ void // Response
18
+ > {
19
+ constructor() {
20
+ super({
21
+ url: "/",
22
+ schema: {},
23
+ method: "GET",
24
+ });
25
+ }
26
+
27
+ handler(
28
+ _: Request<
29
+ void, // Body
30
+ Record<string, string>, // Querystring
31
+ void // Params
32
+ >
33
+ ): void | Promise<void> {
34
+ // Dummy implementation
35
+ return;
16
36
  }
17
37
  }
18
38
 
@@ -20,15 +40,15 @@ describe("AbstractRouteHandler", () => {
20
40
  it("executes the handler and sends the proper response", async () => {
21
41
  vi.spyOn(i18n, "__").mockReturnValue("Default success");
22
42
 
23
- const fakeRequest = createFakeFastifyRequest() as Request<unknown, string>;
43
+ const fakeRequest = createFakeFastifyRequest() as Request<
44
+ void, // Body
45
+ Record<string, string>, // Querystring
46
+ void // Params
47
+ >;
24
48
 
25
49
  const fakeReply: any = createFakeFastifyReply();
26
50
 
27
- const testHandler = new TestRouteHandler({
28
- url: "/test",
29
- method: "GET",
30
- schema: {},
31
- });
51
+ const testHandler = new TestRouteHandler();
32
52
 
33
53
  await testHandler.execute(fakeRequest, fakeReply);
34
54
 
@@ -36,7 +56,7 @@ describe("AbstractRouteHandler", () => {
36
56
  expect(fakeReply.send).toHaveBeenCalledWith({
37
57
  ok: true,
38
58
  message: "Default success",
39
- data: "Test Data",
59
+ data: undefined,
40
60
  });
41
61
  });
42
62
  });
@@ -10,9 +10,17 @@ import {
10
10
  createFakeFastifyRequest,
11
11
  } from "../../../../../../helpers/common/common";
12
12
 
13
- class TestPostRouteHandler extends PostRouteHandler<unknown, string, unknown> {
14
- async handler(_: unknown, __: unknown): Promise<string> {
15
- return "Test POST response";
13
+ class TestPostRouteHandler extends PostRouteHandler<{}, {}, {}, {}> {
14
+ constructor() {
15
+ super({
16
+ url: "/post-test",
17
+ schema: {}, // Use an empty schema for test purposes.
18
+ });
19
+ }
20
+
21
+ handler(_: Request<{}, {}, {}>): {} | Promise<{}> {
22
+ // Dummy implementation
23
+ return {};
16
24
  }
17
25
  }
18
26
 
@@ -20,12 +28,9 @@ describe("PostRouteHandler", () => {
20
28
  it("executes POST handler and sends proper response", async () => {
21
29
  vi.spyOn(i18n, "__").mockReturnValue("Default success POST");
22
30
 
23
- const testHandler = new TestPostRouteHandler({
24
- url: "/post-test",
25
- schema: {}, // Use an empty schema for test purposes.
26
- });
31
+ const testHandler = new TestPostRouteHandler();
27
32
 
28
- const fakeRequest = createFakeFastifyRequest() as Request<unknown, string>;
33
+ const fakeRequest = createFakeFastifyRequest() as Request<{}, {}, {}>;
29
34
  const fakeReply = createFakeFastifyReply();
30
35
 
31
36
  await testHandler.execute(fakeRequest, fakeReply);
@@ -34,7 +39,7 @@ describe("PostRouteHandler", () => {
34
39
  expect(fakeReply.send).toHaveBeenCalledWith({
35
40
  ok: true,
36
41
  message: "Default success POST",
37
- data: "Test POST response",
42
+ data: {},
38
43
  });
39
44
  });
40
45
  });
@@ -13,7 +13,7 @@ const schemaUserTest = new Schema<UserTest>({
13
13
  });
14
14
 
15
15
  @singleton()
16
- class UserRepository extends BaseRepository<UserTest> {
16
+ class UserRepository extends BaseRepository<UserTest, UserTest> {
17
17
  constructor() {
18
18
  super({ name: "User", schema: schemaUserTest });
19
19
  }
@@ -46,13 +46,11 @@ describe("BaseRepository", () => {
46
46
 
47
47
  it("should call findById() and return the user document", async () => {
48
48
  const user = await repository.create({ name: "Alice" });
49
-
50
49
  const spyFunction = vi.spyOn(repository.model, "findById");
51
-
52
50
  const result = await repository.findById(user.id);
53
-
54
51
  expect(spyFunction).toHaveBeenNthCalledWith(1, user.id);
55
- expect(result).toEqual(user);
52
+ expect(result).toMatchObject({ name: user.name });
53
+ expect(result?.id).toBe(user.id);
56
54
  });
57
55
 
58
56
  it("should call update() and return the updated user document", async () => {
@@ -72,12 +70,11 @@ describe("BaseRepository", () => {
72
70
 
73
71
  it("should call delete() and return the deleted user document", async () => {
74
72
  const user = await repository.create({ name: "Alice" });
75
-
76
73
  const spyFunction = vi.spyOn(repository.model, "findByIdAndDelete");
77
-
78
74
  const result = await repository.delete(user.id);
79
-
80
75
  expect(spyFunction).toHaveBeenNthCalledWith(1, user.id);
81
- expect(result).toEqual(user);
76
+ // Patch: allow for id only (ignore _id)
77
+ expect(result).toMatchObject({ name: user.name });
78
+ expect(result?.id).toBe(user.id);
82
79
  });
83
80
  });
@@ -146,6 +146,8 @@ describe("Service", () => {
146
146
  const port = 3000;
147
147
  const service = new Service(config);
148
148
 
149
+ await service.configureDatabases();
150
+
149
151
  await service.start({ port });
150
152
 
151
153
  expect(
@@ -184,15 +186,13 @@ describe("Service", () => {
184
186
  });
185
187
 
186
188
  it("should throw an error if database configuration fails", async () => {
187
- const port = 3000;
188
-
189
189
  strategiesForDatabaseVendor[DatabaseVendor.MONGOOSE] = originalDbStrategy;
190
190
 
191
191
  fakeMongoose.connect.mockRejectedValue(new Error("db connect failed"));
192
192
 
193
193
  const service = new Service(config);
194
194
 
195
- await expect(service.start({ port })).rejects.toThrow(
195
+ await expect(service.configureDatabases()).rejects.toThrow(
196
196
  "failed to configure database for service"
197
197
  );
198
198
  });