From 92e50268d9581cb659ef04838861ebfcfdd1a3d6 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:53:54 +0400 Subject: [PATCH 01/44] chore(database): add pg driver dependency The data modeling branch commits to PostgreSQL as the only supported database connection, matching the tech stack table in the README and the docker-compose services already defined for development and test. The scaffold generated by the initial --kit=api install only shipped better-sqlite3, so the pg driver needed to be added before Lucid could open a connection against the docker-compose PostgreSQL service. No other dependency changed. The lockfile update reflects only the resolution of pg and its own transitive dependencies. --- package-lock.json | 169 ++++++++++++++++++++++++++++++++++++++-------- package.json | 12 +++- 2 files changed, 148 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 638a59e..a77e4e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", "luxon": "^3.7.2", + "pg": "^8.23.0", "reflect-metadata": "^0.2.2" }, "devDependencies": { @@ -788,9 +789,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -807,9 +805,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -826,9 +821,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -845,9 +837,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1761,9 +1750,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1781,9 +1767,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1801,9 +1784,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1821,9 +1801,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1841,9 +1818,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1861,9 +1835,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2561,6 +2532,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -5511,12 +5483,101 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, "node_modules/pg-connection-string": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5623,6 +5684,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", @@ -7061,6 +7161,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", diff --git a/package.json b/package.json index a6a2d31..139a590 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@vinejs/vine": "^4.4.0", "better-sqlite3": "^13.0.3", "luxon": "^3.7.2", + "pg": "^8.23.0", "reflect-metadata": "^0.2.2" }, "hotHook": { @@ -84,7 +85,12 @@ }, "prettier": "@adonisjs/prettier-config", "lint-staged": { - "*.{ts,js}": ["eslint --fix", "prettier --write"], - "*.{json,md}": ["prettier --write"] + "*.{ts,js}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md}": [ + "prettier --write" + ] } -} \ No newline at end of file +} From f781a7c4f5ab7b149bb1cb96420c89444040a030 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:54:01 +0400 Subject: [PATCH 02/44] chore(database): switch default Lucid connection to PostgreSQL The scaffold from the initial --kit=api install defaulted to SQLite with Lucid's schema generation feature enabled, which is convenient for a throwaway prototype but not what this tutorial is built around. The README fixes PostgreSQL 16 as the database for every environment, run through docker-compose in development and docker-compose.test in CI, so the default connection needs to point there from the moment migrations start being written. The pg connection now reads its credentials from the typed env service instead of hardcoded values, consistent with the project rule against touching process.env directly. SQLite is kept as a commented alternative for reference, following the same pattern already used for the mysql, mssql and libsql connections in this file, rather than deleting it outright. --- config/database.ts | 60 +++++++++++++++------------------------------- 1 file changed, 19 insertions(+), 41 deletions(-) diff --git a/config/database.ts b/config/database.ts index e36cef1..c195b2e 100644 --- a/config/database.ts +++ b/config/database.ts @@ -1,71 +1,49 @@ import app from '@adonisjs/core/services/app' +import env from '@adonisjs/core/services/env' import { defineConfig } from '@adonisjs/lucid' const dbConfig = defineConfig({ /** * Default connection used for all queries. */ - connection: 'sqlite', + connection: 'pg', connections: { /** - * SQLite connection (default). + * PostgreSQL connection (default). Credentials are read from the typed + * env service so the app refuses to boot with an incomplete .env file. */ - sqlite: { - client: 'better-sqlite3', - + pg: { + client: 'pg', connection: { - filename: app.tmpPath('db.sqlite3'), + host: env.get('DB_HOST'), + port: env.get('DB_PORT'), + user: env.get('DB_USER'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), }, - - /** - * Required by Knex for SQLite defaults. - */ - useNullAsDefault: true, - migrations: { - /** - * Sort migration files naturally by filename. - */ naturalSort: true, - - /** - * Paths containing migration files. - */ paths: ['database/migrations'], }, - - schemaGeneration: { - /** - * Enable schema generation from Lucid models. - */ - enabled: true, - - /** - * Custom schema rules file paths. - */ - rulesPaths: ['./database/schema_rules.js'], - }, + debug: app.inDev, }, /** - * PostgreSQL connection. - * Install package to switch: npm install pg + * SQLite connection. + * Kept as a documented alternative, no schema generation configured + * since the project standardizes on PostgreSQL migrations. */ - // pg: { - // client: 'pg', + // sqlite: { + // client: 'better-sqlite3', // connection: { - // host: env.get('DB_HOST'), - // port: env.get('DB_PORT'), - // user: env.get('DB_USER'), - // password: env.get('DB_PASSWORD'), - // database: env.get('DB_DATABASE'), + // filename: app.tmpPath('db.sqlite3'), // }, + // useNullAsDefault: true, // migrations: { // naturalSort: true, // paths: ['database/migrations'], // }, - // debug: app.inDev, // }, /** From 8096acfce7b8e949cdda4abefd00d289bc6deea0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:54:10 +0400 Subject: [PATCH 03/44] refactor(auth): drop the access tokens guard from the default scaffold The --kit=api scaffold wired a tokens guard on top of a User model generated by AdonisJS itself, which is not the User model this project needs. The EER_AUTH design in the README calls for a User with roles and permissions instead of a self-managed access tokens table, and authentication in this project is meant to be handled by a stateless JWT guard built in feature/auth, not the built-in tokens provider. Keeping the tokens guard around while reshaping the User model in this branch would leave config/auth.ts referencing a static accessTokens provider that the new model does not implement, which breaks the build. The session guard is left in place on its own as the default so the auth module still exports a valid configuration and the app keeps booting; it is a temporary placeholder until feature/auth replaces it with the real JWT guard. --- config/auth.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/config/auth.ts b/config/auth.ts index 686e0ae..c3a4707 100644 --- a/config/auth.ts +++ b/config/auth.ts @@ -1,25 +1,21 @@ import { defineConfig } from '@adonisjs/auth' import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' -import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens' import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' +// NOTE: the stateless JWT guard described in the README belongs to +// feature/auth. This branch only needs the User model to resolve through +// an auth guard well enough for the app to boot and typecheck; the +// tokens guard from the default --kit=api scaffold required an +// accessTokens provider on User that the EER_AUTH design does not use, +// so it is dropped here rather than kept alongside a model that no +// longer supports it. feature/auth replaces this guard with the JWT one. const authConfig = defineConfig({ /** * Default guard used when no guard is explicitly specified. */ - default: 'api', + default: 'web', guards: { - /** - * Token-based guard for stateless API authentication. - */ - api: tokensGuard({ - provider: tokensUserProvider({ - tokens: 'accessTokens', - model: () => import('#models/user'), - }), - }), - /** * Session-based guard for browser authentication. */ From 0de0f73dad1f9e143a8284146b125b9ca2ffbd7d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:54:19 +0400 Subject: [PATCH 04/44] refactor(auth): remove the scaffold signup controller This controller was generated by the --kit=api starter and creates a user from a fullName, email and password payload against the model that ships with that starter kit. Neither the payload shape nor the underlying User model match the EER_AUTH design this project follows, where a user has separate first and last names plus roles instead of a single fullName field and a bundled access token. Registration for this project is a feature/auth concern: it needs an activation token flow, a default USER role assignment and a password policy that this scaffold controller never implemented. Removing it now avoids carrying dead code that would not typecheck once the User model changes in this branch, rather than patching it toward a shape it was never designed for. --- app/controllers/new_account_controller.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 app/controllers/new_account_controller.ts diff --git a/app/controllers/new_account_controller.ts b/app/controllers/new_account_controller.ts deleted file mode 100644 index 1251fb7..0000000 --- a/app/controllers/new_account_controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import User from '#models/user' -import { signupValidator } from '#validators/user' -import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' - -export default class NewAccountController { - async store({ request, serialize }: HttpContext) { - const { fullName, email, password } = await request.validateUsing(signupValidator) - - const user = await User.create({ fullName, email, password }) - const token = await User.accessTokens.create(user) - - return serialize({ - user: UserTransformer.transform(user), - token: token.value!.release(), - }) - } -} From 4c3faf8427bf2a09e49a252eda56775adcb02ed3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:54:29 +0400 Subject: [PATCH 05/44] refactor(auth): remove the scaffold access tokens controller This controller implemented login and logout against the accessTokens provider that the --kit=api starter attaches to its default User model. That provider no longer exists once the User model is rebuilt around the EER_AUTH schema, and the login flow it implemented does not match what this project needs anyway: password verification through hash.verify, an enabled and accountLocked check, and a JWT issued with a jti claim used later for blacklisting on logout. Rather than adapt this controller piecemeal toward a design it was never written for, it is removed here and left for feature/auth, which owns the AuthService and AuthController tasks in the README and has the full picture of the login and logout contract to implement them correctly in one pass. --- app/controllers/access_tokens_controller.ts | 29 --------------------- 1 file changed, 29 deletions(-) delete mode 100644 app/controllers/access_tokens_controller.ts diff --git a/app/controllers/access_tokens_controller.ts b/app/controllers/access_tokens_controller.ts deleted file mode 100644 index 153267c..0000000 --- a/app/controllers/access_tokens_controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import User from '#models/user' -import { loginValidator } from '#validators/user' -import type { HttpContext } from '@adonisjs/core/http' -import UserTransformer from '#transformers/user_transformer' - -export default class AccessTokensController { - async store({ request, serialize }: HttpContext) { - const { email, password } = await request.validateUsing(loginValidator) - - const user = await User.verifyCredentials(email, password) - const token = await User.accessTokens.create(user) - - return serialize({ - user: UserTransformer.transform(user), - token: token.value!.release(), - }) - } - - async destroy({ auth }: HttpContext) { - const user = auth.getUserOrFail() - if (user.currentAccessToken) { - await User.accessTokens.delete(user, user.currentAccessToken.identifier) - } - - return { - message: 'Logged out successfully', - } - } -} From 4c4debf65331ebd749e1f2f7de28bb20695d142f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:54:36 +0400 Subject: [PATCH 06/44] refactor(auth): remove the scaffold profile controller The profile controller only serialized the authenticated user through UserTransformer, which read fields such as fullName and an initials getter that belonged to the --kit=api starter model. None of those fields exist on the User model this branch introduces, so the transformer and this controller would no longer compile once the model changes. The equivalent endpoint, GET /api/v1/auth/me, is defined in the README as part of feature/auth and returns the user with roles and permissions preloaded, which is a different contract than a bare serialized user object. Removing the placeholder here keeps the branch free of dead code rather than adapting a controller that feature/auth would replace outright. --- app/controllers/profile_controller.ts | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 app/controllers/profile_controller.ts diff --git a/app/controllers/profile_controller.ts b/app/controllers/profile_controller.ts deleted file mode 100644 index 1fe82f2..0000000 --- a/app/controllers/profile_controller.ts +++ /dev/null @@ -1,8 +0,0 @@ -import UserTransformer from '#transformers/user_transformer' -import type { HttpContext } from '@adonisjs/core/http' - -export default class ProfileController { - async show({ auth, serialize }: HttpContext) { - return serialize(UserTransformer.transform(auth.getUserOrFail())) - } -} From dade20d576b8e861e376a21785068d01f2590360 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:55:44 +0400 Subject: [PATCH 07/44] refactor(auth): remove the scaffold signup and login validators signupValidator and loginValidator were written against the --kit=api demo model, validating a fullName field and a passwordConfirmation that the README's auth flow does not use. The EER_AUTH design splits the name into firstName and lastName and drives registration through an AuthService that also creates an activation token, which this validator has no concept of. The real registerSchema and loginSchema belong to feature/auth, listed explicitly in its task checklist alongside the password complexity rules the README specifies. Deleting this file now avoids leaving a validator that nothing imports anymore once the demo controllers that used it are gone. --- app/validators/user.ts | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 app/validators/user.ts diff --git a/app/validators/user.ts b/app/validators/user.ts deleted file mode 100644 index 5c4ff9a..0000000 --- a/app/validators/user.ts +++ /dev/null @@ -1,26 +0,0 @@ -import vine from '@vinejs/vine' - -/** - * Shared rules for email and password. - */ -const email = () => vine.string().email().maxLength(254) -const password = () => vine.string().minLength(8).maxLength(32) - -/** - * Validator to use when performing self-signup - */ -export const signupValidator = vine.create({ - fullName: vine.string().nullable(), - email: email().unique({ table: 'users', column: 'email' }), - password: password(), - passwordConfirmation: password().sameAs('password'), -}) - -/** - * Validator to use before validating user credentials - * during login - */ -export const loginValidator = vine.create({ - email: email(), - password: vine.string(), -}) From 8cbc27b101ac29d2337baf9054003b155ad980ab Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:55:44 +0400 Subject: [PATCH 08/44] refactor(auth): remove the scaffold user transformer UserTransformer picked fullName, email, createdAt, updatedAt and an initials getter off the demo User model to shape API responses. All of those fields either no longer exist or no longer make sense once the User model is rebuilt around firstName, lastName, enabled and accountLocked, and password exposure is already handled at the model level through serializeUsing rather than through a separate transformer class. This project's response shaping goes through the ApiResponse envelope and the respond helper defined in feature/core-architecture, not through per-model transformer classes, so this file has no equivalent to be rebuilt toward. It is removed along with the controllers that were its only callers. --- app/transformers/user_transformer.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 app/transformers/user_transformer.ts diff --git a/app/transformers/user_transformer.ts b/app/transformers/user_transformer.ts deleted file mode 100644 index c772ff7..0000000 --- a/app/transformers/user_transformer.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type User from '#models/user' -import { BaseTransformer } from '@adonisjs/core/transformers' - -export default class UserTransformer extends BaseTransformer { - toObject() { - return this.pick(this.resource, [ - 'id', - 'fullName', - 'email', - 'createdAt', - 'updatedAt', - 'initials', - ]) - } -} From 33a9f22b8b1c25888471597055eb642f8c7c7721 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:55:54 +0400 Subject: [PATCH 09/44] chore(database): remove the scaffold users migration This migration created a users table with full_name, email and password columns, matching the demo User model from the --kit=api starter. It conflicts directly with the users table this branch defines in the EER_AUTH schema, which adds first_name, last_name, enabled and account_locked and is created together with roles, permissions and the pivot and token tables in a single migration. Keeping both migrations would try to create the same table twice with incompatible columns. Since this project has not been deployed anywhere yet, there is no production history to preserve, so removing the old migration outright is safe and avoids adding a second migration just to alter columns. --- .../1761885935168_create_users_table.ts | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 database/migrations/1761885935168_create_users_table.ts diff --git a/database/migrations/1761885935168_create_users_table.ts b/database/migrations/1761885935168_create_users_table.ts deleted file mode 100644 index dbca083..0000000 --- a/database/migrations/1761885935168_create_users_table.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'users' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id').notNullable() - table.string('full_name').nullable() - table.string('email', 254).notNullable().unique() - table.string('password').notNullable() - - table.timestamp('created_at').notNullable() - table.timestamp('updated_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} From 5740e22771821f5756a7146a305a8a06e736c5d7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:55:54 +0400 Subject: [PATCH 10/44] chore(database): remove the scaffold access tokens migration The auth_access_tokens table backed the tokens guard from the --kit=api scaffold, storing a hash and abilities per token against a tokenable_id foreign key. This project does not use that provider: authentication issues stateless JWTs and tracks revocation through a separate blacklisted_tokens table keyed by jti, which is a fixed-size claim rather than a hashed token value, and does not need a tokenable_id since a JWT already carries the user's identity in its payload. Removing this migration avoids creating a table that nothing in the EER_AUTH design would ever read from or write to. The blacklisted tokens table is created instead as part of the auth schema migration in this branch. --- ...768620764696_create_access_tokens_table.ts | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 database/migrations/1768620764696_create_access_tokens_table.ts diff --git a/database/migrations/1768620764696_create_access_tokens_table.ts b/database/migrations/1768620764696_create_access_tokens_table.ts deleted file mode 100644 index a3ce197..0000000 --- a/database/migrations/1768620764696_create_access_tokens_table.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { BaseSchema } from '@adonisjs/lucid/schema' - -export default class extends BaseSchema { - protected tableName = 'auth_access_tokens' - - async up() { - this.schema.createTable(this.tableName, (table) => { - table.increments('id') - table - .integer('tokenable_id') - .notNullable() - .unsigned() - .references('id') - .inTable('users') - .onDelete('CASCADE') - - table.string('type').notNullable() - table.string('name').nullable() - table.string('hash').notNullable() - table.text('abilities').notNullable() - table.timestamp('created_at') - table.timestamp('updated_at') - table.timestamp('last_used_at').nullable() - table.timestamp('expires_at').nullable() - }) - } - - async down() { - this.schema.dropTable(this.tableName) - } -} From 76126d8de7f14fce26163b64ee6b72501025df0a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:56:05 +0400 Subject: [PATCH 11/44] chore(database): remove the stale generated schema snapshot This file is generated automatically by Lucid's schema generation feature and is not meant to be hand edited, its own header says so. It reflected the auth_access_tokens and users tables from the SQLite scaffold, both of which are gone now that the default connection is PostgreSQL and the auth schema migration defines a different set of columns and tables. Schema generation was only wired up for the SQLite connection, which is now commented out in config/database.ts, so there is no active generator that would refresh this file against the new schema. Leaving a stale, auto-generated file in the tree is worse than removing it: it would read as documentation of a schema the project no longer has. --- database/schema.ts | 61 ---------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 database/schema.ts diff --git a/database/schema.ts b/database/schema.ts deleted file mode 100644 index 521fa3a..0000000 --- a/database/schema.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * This file is automatically generated - * DO NOT EDIT manually - * Run "node ace migration:run" command to re-generate this file - */ - -import { BaseModel, column } from '@adonisjs/lucid/orm' -import { DateTime } from 'luxon' - -export class AuthAccessTokenSchema extends BaseModel { - static $columns = [ - 'abilities', - 'createdAt', - 'expiresAt', - 'hash', - 'id', - 'lastUsedAt', - 'name', - 'tokenableId', - 'type', - 'updatedAt', - ] as const - $columns = AuthAccessTokenSchema.$columns - @column() - declare abilities: string - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime | null - @column.dateTime() - declare expiresAt: DateTime | null - @column() - declare hash: string - @column({ isPrimary: true }) - declare id: number - @column.dateTime() - declare lastUsedAt: DateTime | null - @column() - declare name: string | null - @column() - declare tokenableId: number - @column() - declare type: string - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null -} - -export class UserSchema extends BaseModel { - static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const - $columns = UserSchema.$columns - @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime - @column() - declare email: string - @column() - declare fullName: string | null - @column({ isPrimary: true }) - declare id: number - @column({ serializeAs: null }) - declare password: string - @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime | null -} From 8d891b824088a492c0e86e42eece51aeac2ccdb7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:56:05 +0400 Subject: [PATCH 12/44] refactor(routes): remove the scaffold auth and account route group The auth and account groups routed to the three demo controllers this branch removes, none of which match the endpoints the README defines for this project. Leaving the group in place with a dangling import of the generated controllers registry would fail to compile the moment those controller files disappear. The health check route is untouched since it does not depend on the User model or any auth guard. Resource routes for auth, categories, products, customers and orders are added incrementally by their own feature branches once the controllers and services behind them exist, starting with feature/auth. --- start/routes.ts | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/start/routes.ts b/start/routes.ts index ee8f1b3..5615cc8 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -12,9 +12,7 @@ | */ -import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' -import { controllers } from '#generated/controllers' // Lazy import — AdonisJS resolves the module on the first matching request const HealthController = () => import('#controllers/health_controller') @@ -32,26 +30,10 @@ router.get('/api/v1/health', [HealthController, 'index']) /* |-------------------------------------------------------------------------- -| API v1 — scaffold routes (replaced in feature/auth) +| API v1 — resource routes |-------------------------------------------------------------------------- +| +| Auth, categories, products, customers and orders routes are added by +| their respective feature branches, starting with feature/auth. +| */ -router - .group(() => { - router - .group(() => { - router.post('signup', [controllers.NewAccount, 'store']) - router.post('login', [controllers.AccessTokens, 'store']) - }) - .prefix('auth') - .as('auth') - - router - .group(() => { - router.get('profile', [controllers.Profile, 'show']) - router.post('logout', [controllers.AccessTokens, 'destroy']) - }) - .prefix('account') - .as('profile') - .use(middleware.auth()) - }) - .prefix('/api/v1') From 6185b744a5c6718ec78a1690571de7da89a74671 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 11:56:12 +0400 Subject: [PATCH 13/44] chore(codegen): regenerate the controllers registry This registry is generated automatically at build time from the controllers present in app/controllers, and its own header warns against editing it by hand. Once the three demo auth controllers were removed, the checked-in snapshot still referenced them, which would fail to resolve during typecheck since the underlying files no longer exist. Running node ace codegen regenerated both this file and the associated data type declarations to match the current controller set, which at this point in the project is just the health check. Later branches will regenerate this file again as they add their own controllers. --- .adonisjs/client/data.d.ts | 5 ----- .adonisjs/server/controllers.ts | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.adonisjs/client/data.d.ts b/.adonisjs/client/data.d.ts index ee014a4..1513a7a 100644 --- a/.adonisjs/client/data.d.ts +++ b/.adonisjs/client/data.d.ts @@ -5,11 +5,6 @@ /// import type { InferData, InferVariants } from '@adonisjs/core/types/transformers' -import type UserTransformer from '#transformers/user_transformer' export namespace Data { - export type User = InferData - export namespace User { - export type Variants = InferVariants - } } diff --git a/.adonisjs/server/controllers.ts b/.adonisjs/server/controllers.ts index 202eb41..91cd9f0 100644 --- a/.adonisjs/server/controllers.ts +++ b/.adonisjs/server/controllers.ts @@ -4,7 +4,5 @@ */ export const controllers = { - AccessTokens: () => import('#controllers/access_tokens_controller'), - NewAccount: () => import('#controllers/new_account_controller'), - Profile: () => import('#controllers/profile_controller'), + Health: () => import('#controllers/health_controller'), } From 1b83f9cf0eeae470e6b87af116efd93e3c386be9 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:05:38 +0400 Subject: [PATCH 14/44] feat(data-modeling): add Category Lucid model The catalog hierarchy starts with categories, so this is the first model on the branch. It only declares the two columns the README's Column Details table lists for this table, id and category_name, rather than following the generic timestamped example from the patterns skill. The skill's Category example includes created_at and updated_at, but the README's own schema table for this project omits them entirely for categories, products, customers and orders, so the migration that will back this model does not create those columns and the model should not declare properties that do not exist in the database. The hasMany relation to Product is declared with a lazy () => Product closure, which is what lets category.ts and product.ts import each other at the top of the file without deadlocking on circular ES module resolution. The closure is only invoked when the relation is actually traversed at runtime, long after both modules have finished evaluating. --- app/models/category.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/models/category.ts diff --git a/app/models/category.ts b/app/models/category.ts new file mode 100644 index 0000000..e00d9cb --- /dev/null +++ b/app/models/category.ts @@ -0,0 +1,20 @@ +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import Product from '#models/product' + +// NOTE: the README's Data Model "Column Details" table for `categories` +// lists only `id` and `category_name`, with no `created_at` / `updated_at` +// columns, unlike the generic Lucid model example in the patterns skill. +// This model and its migration follow the README's explicit column list +// rather than the generic pattern, since the README is the authoritative +// spec for this branch. +export default class Category extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare categoryName: string + + @hasMany(() => Product) + declare products: HasMany +} From 49a5347fa33da0f9c0cad9adfa8094742dc596de Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:05:45 +0400 Subject: [PATCH 15/44] feat(data-modeling): add Product Lucid model Product is the many side of the categories to products relation and the one side of products to orders. Its columns match the README's Column Details table exactly: categoryId as the foreign key, productName and unitPrice, with no timestamp columns since none are listed for this table. unitPrice is typed as a plain number here even though the README's constraint column says "> 0". That constraint is enforced at the database level in the migration with a check constraint rather than in the model, because the model has no reliable hook point that runs before every insert and update path (bulk operations bypass instance hooks), while a check constraint is enforced by PostgreSQL unconditionally. The belongsTo relation back to Category uses the same lazy-import pattern as the Category model's hasMany, for the same circular-import reason. --- app/models/product.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 app/models/product.ts diff --git a/app/models/product.ts b/app/models/product.ts new file mode 100644 index 0000000..1649378 --- /dev/null +++ b/app/models/product.ts @@ -0,0 +1,20 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Category from '#models/category' + +export default class Product extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare categoryId: number + + @column() + declare productName: string + + @column() + declare unitPrice: number + + @belongsTo(() => Category) + declare category: BelongsTo +} From 83b9f5f51531a04b9d3251bd788d3d910faa5e1a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:05:53 +0400 Subject: [PATCH 16/44] feat(data-modeling): add Customer Lucid model Customer holds the five scalar columns the README specifies: firstName, lastName, telephone, email and address. The Tasks checklist for this branch only lists those scalar fields for Customer, but the Data Model diagram earlier in the README explicitly draws a 1 to N relation from customers to orders through orders.customer_id, so a hasMany to Order is declared here even though it is not spelled out in the Tasks bullet. Leaving it out would make the diagrammed relation unreachable from the Customer side while the Order model's belongsTo(Customer) makes it reachable from the other direction, which would be an inconsistent, half-declared relation. email has no uniqueness enforcement at the model layer; that constraint lives in the migration as a UNIQUE column, consistent with how this project treats the database, not the model, as the source of truth for data integrity rules that must hold regardless of which code path writes a row. --- app/models/customer.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/models/customer.ts diff --git a/app/models/customer.ts b/app/models/customer.ts new file mode 100644 index 0000000..306cb41 --- /dev/null +++ b/app/models/customer.ts @@ -0,0 +1,32 @@ +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import Order from '#models/order' + +export default class Customer extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare firstName: string + + @column() + declare lastName: string + + @column() + declare telephone: string + + @column() + declare email: string + + @column() + declare address: string + + // NOTE: the README's Data Model diagram shows customers 1 -- N orders, + // and the Column Details table only defines orders.customer_id as the FK + // side. The inverse hasMany here is not explicitly requested by the + // Tasks checklist (which only lists scalar columns for Customer) but is + // added because the diagram documents the relation and orders.customer_id + // would otherwise be unreachable from the Customer side. + @hasMany(() => Order) + declare orders: HasMany +} From e7cbf5285eb999a396d8d61238b903b6fb178823 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:00 +0400 Subject: [PATCH 17/44] feat(data-modeling): add Order Lucid model Order sits at the bottom of the catalog and sales hierarchy, referencing both Customer and Product through belongsTo relations built the same way as Product's belongsTo(Category). Its columns are customerId, productId, quantity and total, matching the README's Column Details table with no added timestamp fields. total is stored as a plain column rather than derived on read. The README describes it as "computed = quantity x unit_price", and that computation belongs in the orders service that feature/orders will add, evaluated once at order creation time against the product's unit_price at that moment. If total were instead computed lazily whenever an order is read, a later price change on the product would silently rewrite the historical total of every past order referencing it, which is not what an order record should do. --- app/models/order.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/models/order.ts diff --git a/app/models/order.ts b/app/models/order.ts new file mode 100644 index 0000000..44c9304 --- /dev/null +++ b/app/models/order.ts @@ -0,0 +1,27 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import Customer from '#models/customer' +import Product from '#models/product' + +export default class Order extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare customerId: number + + @column() + declare productId: number + + @column() + declare quantity: number + + @column() + declare total: number + + @belongsTo(() => Customer) + declare customer: BelongsTo + + @belongsTo(() => Product) + declare product: BelongsTo +} From 5cc4aab40674ec4a6136e5b2b792cb7cadadc035 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:11 +0400 Subject: [PATCH 18/44] refactor(data-modeling): rewrite User model to the EER_AUTH shape The User model inherited from the removed --kit=api scaffold composed UserSchema with withAuthFinder and a DbAccessTokensProvider, none of which fit the EER_AUTH design this project targets. withAuthFinder pairs with the access-tokens guard that config/auth.ts already dropped on this branch, and its accessTokens provider expects an auth_access_tokens table that was removed along with the scaffold's migrations. Keeping any part of that mixin around would leave a model that references infrastructure the app no longer has, so it is replaced outright with a plain BaseModel matching the code sample in the README's feature/data-modeling section: id, firstName, lastName, email, password, enabled, accountLocked, and a manyToMany to Role through the role_user pivot. Two details carry real weight here. password is declared with serializeUsing: () => undefined so it can never leak into a JSON response, API responses included, regardless of which controller or transformer touches the model later. And the beforeSave hook only re-hashes when user.$dirty.password is true, which is what makes it safe to save a user for an unrelated change, such as toggling accountLocked, without silently hashing an already-hashed password on top of itself and locking the account out. Password hashing happens nowhere else in the codebase; this hook is the single point of entry for it. --- app/models/user.ts | 61 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/app/models/user.ts b/app/models/user.ts index 7bc5a08..a6723cd 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -1,18 +1,51 @@ -import { UserSchema } from '#database/schema' +import { BaseModel, beforeSave, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' import hash from '@adonisjs/core/services/hash' -import { compose } from '@adonisjs/core/helpers' -import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' -import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens' - -export default class User extends compose(UserSchema, withAuthFinder(hash)) { - static accessTokens = DbAccessTokensProvider.forModel(User) - declare currentAccessToken?: AccessToken - - get initials() { - const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@') - if (first && last) { - return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase() +import Role from '#models/role' + +/** + * The EER_AUTH `users` table. This model deliberately does not compose + * `withAuthFinder` from `@adonisjs/auth/mixins/lucid` the way the default + * `--kit=api` scaffold did: that mixin pairs with the access-tokens guard, + * which config/auth.ts no longer configures on this branch (the real JWT + * guard belongs to feature/auth). Keeping User a plain BaseModel here + * avoids coupling the schema branch to an auth strategy it does not own. + * + * Password hashing happens once, in the @beforeSave hook below, and nowhere + * else in the codebase. The hook only re-hashes when `password` is dirty, + * so re-saving a user for an unrelated field change (for example toggling + * `accountLocked`) never re-hashes an already-hashed value. + */ +export default class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare firstName: string + + @column() + declare lastName: string + + @column() + declare email: string + + // Never serialized back to the client, in responses or JSON.stringify. + @column({ serializeUsing: () => undefined }) + declare password: string + + @column() + declare enabled: boolean + + @column() + declare accountLocked: boolean + + @manyToMany(() => Role, { pivotTable: 'role_user' }) + declare roles: ManyToMany + + @beforeSave() + static async hashPassword(user: User) { + if (user.$dirty.password) { + user.password = await hash.make(user.password) } - return `${first.slice(0, 2)}`.toUpperCase() } } From 4e8c514d6a429c4454c12108d0f7ae9bc7b49ed7 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:18 +0400 Subject: [PATCH 19/44] feat(data-modeling): add Role Lucid model Role is the hinge of the authorization system: it connects to User through role_user and to Permission through role_permission. Both pivotTable names are given explicitly. role_user happens to match Lucid's default pivot naming convention (singularized table names, alphabetically sorted, joined with an underscore) for User and Role, but role_permission does not, since Lucid would otherwise derive permission_role from permissions and roles. Naming both explicitly keeps the two pivot declarations consistent and makes the actual table name obvious at the call site instead of depending on a reader knowing which pairs happen to match the convention. roleName carries the UNIQUE constraint enforced in the auth schema migration, since role names like ADMIN and USER are used as stable identifiers throughout the authorization rules table, not just display labels, and a duplicate would make role lookups by name ambiguous. --- app/models/role.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 app/models/role.ts diff --git a/app/models/role.ts b/app/models/role.ts new file mode 100644 index 0000000..7c2b154 --- /dev/null +++ b/app/models/role.ts @@ -0,0 +1,18 @@ +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' +import User from '#models/user' +import Permission from '#models/permission' + +export default class Role extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare roleName: string + + @manyToMany(() => User, { pivotTable: 'role_user' }) + declare users: ManyToMany + + @manyToMany(() => Permission, { pivotTable: 'role_permission' }) + declare permissions: ManyToMany +} From 08c8645eb0e08d66732cd2937c25c9b5ea46c490 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:41 +0400 Subject: [PATCH 20/44] feat(data-modeling): add Permission Lucid model Permission holds resource and action, the two columns the README specifies, representing a single grantable capability such as (categories, write). The README only declares the Role side of the role_permission relation, a manyToMany from Role to Permission. The inverse manyToMany back to Role is added here so a Permission instance can answer which roles grant it without requiring a caller to go through Role.query().whereHas() from the other model. The pivot table already carries both foreign keys with no schema change needed to support the extra direction, so declaring it costs nothing and mirrors how Role declares its own two manyToMany relations explicitly. --- app/models/permission.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 app/models/permission.ts diff --git a/app/models/permission.ts b/app/models/permission.ts new file mode 100644 index 0000000..cfaedf8 --- /dev/null +++ b/app/models/permission.ts @@ -0,0 +1,22 @@ +import { BaseModel, column, manyToMany } from '@adonisjs/lucid/orm' +import type { ManyToMany } from '@adonisjs/lucid/types/relations' +import Role from '#models/role' + +export default class Permission extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare resource: string + + @column() + declare action: string + + // NOTE: the README only specifies the Role -> Permission direction + // (`@manyToMany(() => Permission, { pivotTable: 'role_permission' })` on + // Role). The inverse is declared here so a Permission can be traced back + // to the roles that grant it, which the `role_permission` FK pair + // supports without any extra schema. + @manyToMany(() => Role, { pivotTable: 'role_permission' }) + declare roles: ManyToMany +} From 9e7a84d1a01b0b800f2fbce2a0813f072e69a6f4 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:48 +0400 Subject: [PATCH 21/44] feat(data-modeling): add ActivationToken Lucid model ActivationToken backs the account activation flow feature/auth will build: a token is issued for a user, has a creation time, an optional expiry, and an optional validated timestamp marking when the account was activated. createdAt uses Lucid's autoCreate so every row gets its timestamp set by the ORM at insert time, matching the NOT NULL constraint the README places on this column, while expiresAt and validatedAt stay plain nullable datetimes since the README does not mark them NOT NULL and their values depend on business logic that has not run yet at the time the row is first written (an activation token is not expired or validated the moment it is created). The belongsTo(User) relation follows the same lazy-import shape used throughout this branch's models. --- app/models/activation_token.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/models/activation_token.ts diff --git a/app/models/activation_token.ts b/app/models/activation_token.ts new file mode 100644 index 0000000..6997828 --- /dev/null +++ b/app/models/activation_token.ts @@ -0,0 +1,30 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +export default class ActivationToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + // NOT NULL per the README's Auth Model table. Uses Lucid's autoCreate so + // every row gets a creation timestamp without the issuing service (owned + // by feature/auth) having to set it manually. + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime() + declare expiresAt: DateTime | null + + @column.dateTime() + declare validatedAt: DateTime | null + + @belongsTo(() => User) + declare user: BelongsTo +} From 98aa99e05c4b1562e892c4d4bccb110dc85a0435 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:06:56 +0400 Subject: [PATCH 22/44] feat(data-modeling): add BlacklistedToken Lucid model BlacklistedToken is what makes JWT revocation possible in a stateless auth scheme: once a token's jti claim is written here, feature/auth's AuthMiddleware can reject it on every subsequent authenticated request even though the JWT itself is still cryptographically valid and unexpired. This model exists on the data-modeling branch, ahead of the middleware that will query it, because the table needs to be part of the schema and seedable infrastructure from the start rather than bolted on later. jti is unique, matching the README's constraint, since the same token identifier being blacklisted twice would be redundant at best and could mask a bug in whatever calls this at worst. token is sized to 768 characters because a full JWT, header, payload and signature together, comfortably exceeds the 255-character limit used elsewhere in this schema for shorter opaque tokens. --- app/models/blacklisted_token.ts | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/models/blacklisted_token.ts diff --git a/app/models/blacklisted_token.ts b/app/models/blacklisted_token.ts new file mode 100644 index 0000000..c861705 --- /dev/null +++ b/app/models/blacklisted_token.ts @@ -0,0 +1,40 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +/** + * Backs the JWT revocation list. `jti` is the JWT ID claim of the token + * being blacklisted and is unique so the same token cannot be blacklisted + * twice; feature/auth's AuthMiddleware queries this table by `jti` on + * every authenticated request to reject revoked tokens before the guard + * even looks at the signature's expiry. + */ +export default class BlacklistedToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + @column() + declare jti: string + + @column.dateTime() + declare blacklistedAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime() + declare expiresAt: DateTime | null + + @column.dateTime() + declare validatedAt: DateTime | null + + @belongsTo(() => User) + declare user: BelongsTo +} From baef6bc945ce1f93a056d9b505639a6d6cdab359 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:04 +0400 Subject: [PATCH 23/44] feat(data-modeling): add PasswordResetToken Lucid model PasswordResetToken tracks a single-use token tied to a user with a type and an expiry date, both NOT NULL per the README's Auth Model table. type is kept as a plain string rather than a TypeScript union or enum because the README does not enumerate its possible values, and feature/auth, which owns the actual reset and activation flows this token type distinguishes between, is the branch that should decide and document that vocabulary. Narrowing it here would mean guessing at a contract this branch has no authority over. expiryDate is required rather than nullable, unlike the optional expiresAt on ActivationToken and BlacklistedToken, because a password reset token without a known expiry would never lapse and could be used indefinitely, which defeats the point of it being a reset token rather than a permanent credential. --- app/models/password_reset_token.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/models/password_reset_token.ts diff --git a/app/models/password_reset_token.ts b/app/models/password_reset_token.ts new file mode 100644 index 0000000..b996110 --- /dev/null +++ b/app/models/password_reset_token.ts @@ -0,0 +1,28 @@ +import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' +import type { BelongsTo } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import User from '#models/user' + +export default class PasswordResetToken extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @column() + declare userId: number + + @column() + declare token: string + + // NOTE: the README does not enumerate the values for `type`. feature/auth + // owns the actual reset flow and will decide the concrete values (for + // example 'password_reset' vs 'account_activation'); this model keeps + // the column as a plain string to stay unopinionated about that choice. + @column() + declare type: string + + @column.dateTime() + declare expiryDate: DateTime + + @belongsTo(() => User) + declare user: BelongsTo +} From 8f876ab47defe330e7bc911e180c24396c52dce8 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:13 +0400 Subject: [PATCH 24/44] feat(database): add core schema migration for catalog and sales tables This migration creates categories, products, customers and orders in that order, which is not arbitrary: products.category_id references categories, and orders references both customers and products, so each table must exist before the one that points at it is created. down() drops them in the reverse order for the same reason, since PostgreSQL refuses to drop a table that another table's foreign key still points at. Two constraints from the README's Column Details table needed more than a notNullable() call: unit_price and quantity are both documented as "> 0". Lucid models have no hook that fires uniformly across every write path, including bulk inserts, so these are enforced as PostgreSQL check constraints instead, which apply regardless of which code eventually writes to these tables. total is stored as a plain float column rather than a generated one; the README describes it as computed from quantity times unit_price, but that computation has to happen once, in the orders service, against the product's price at the moment of the order, not be recalculated by the database every time the row is read. --- .../1700000001_create_core_schema.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 database/migrations/1700000001_create_core_schema.ts diff --git a/database/migrations/1700000001_create_core_schema.ts b/database/migrations/1700000001_create_core_schema.ts new file mode 100644 index 0000000..ee74e99 --- /dev/null +++ b/database/migrations/1700000001_create_core_schema.ts @@ -0,0 +1,78 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Creates the catalog and sales tables: categories, products, customers, + * orders. Table creation order follows the FK dependency chain from the + * README's Data Model diagram (categories -> products, customers and + * products -> orders), so every `references().inTable()` call points at a + * table that already exists by the time it runs. + * + * `down()` drops the same tables in the reverse order, child tables first, + * so PostgreSQL never rejects a drop because a foreign key still points at + * the table being removed. + */ +export default class extends BaseSchema { + async up() { + this.schema.createTable('categories', (table) => { + table.bigIncrements('id') + table.string('category_name', 255).notNullable() + }) + + this.schema.createTable('products', (table) => { + table.bigIncrements('id') + table + .bigInteger('category_id') + .unsigned() + .notNullable() + .references('id') + .inTable('categories') + .onDelete('CASCADE') + table.string('product_name', 255).notNullable() + table.float('unit_price').notNullable() + table.check('unit_price > 0', [], 'products_unit_price_positive') + }) + + this.schema.createTable('customers', (table) => { + table.bigIncrements('id') + table.string('first_name', 255).notNullable() + table.string('last_name', 255).notNullable() + table.string('telephone', 50).notNullable() + table.string('email', 255).notNullable().unique() + table.string('address', 255).notNullable() + }) + + this.schema.createTable('orders', (table) => { + table.bigIncrements('id') + table + .bigInteger('customer_id') + .unsigned() + .notNullable() + .references('id') + .inTable('customers') + .onDelete('CASCADE') + table + .bigInteger('product_id') + .unsigned() + .notNullable() + .references('id') + .inTable('products') + .onDelete('CASCADE') + table.integer('quantity').notNullable() + table.check('quantity > 0', [], 'orders_quantity_positive') + // NOTE: the README documents `total` as "computed = quantity x + // unit_price". That computation happens in the orders service + // (feature/orders), not as a generated/stored column here, because + // the price used at order time must be the product's unit_price at + // that moment, not a value PostgreSQL would recompute if unit_price + // changes later. The column simply stores whatever the service wrote. + table.float('total').notNullable() + }) + } + + async down() { + this.schema.dropTable('orders') + this.schema.dropTable('customers') + this.schema.dropTable('products') + this.schema.dropTable('categories') + } +} From 89cfa7729e76ac498312276cd37ac36f5c3b7320 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:23 +0400 Subject: [PATCH 25/44] feat(database): add auth schema migration for EER_AUTH tables This migration creates the full authentication and authorization schema in dependency order: users, roles and permissions first since nothing else depends on them, then the role_user and role_permission pivot tables that need two of those three to already exist, then the three token tables that only depend on users. down() reverses that order so a drop never runs while a foreign key elsewhere in the schema still references the table being removed. A few nullability calls depart from a strict reading of "every column is required unless the README says otherwise". users.password is left nullable because the README's own Column Details table is the one column in this migration that is not marked NOT NULL, which reads as intentional room for an account to exist before a password is set, such as an invited-but-not- activated user. The user_id foreign key on all three token tables is likewise nullable because the README does not mark it NOT NULL there either, though CASCADE delete still applies to whichever rows do have one, since a token record has no purpose once its owning account is gone. The two pivot tables get a composite primary key on their two foreign key columns, which the README does not ask for explicitly but which is the only thing stopping the same user-role or role-permission pair from being inserted twice. --- .../1700000002_create_auth_schema.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 database/migrations/1700000002_create_auth_schema.ts diff --git a/database/migrations/1700000002_create_auth_schema.ts b/database/migrations/1700000002_create_auth_schema.ts new file mode 100644 index 0000000..a85d234 --- /dev/null +++ b/database/migrations/1700000002_create_auth_schema.ts @@ -0,0 +1,147 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +/** + * Creates the EER_AUTH schema: users, roles, permissions, the two pivot + * tables that back the many-to-many relations declared on the User and + * Role models, and the three token tables (activation, blacklist, + * password reset) that feature/auth writes to. + * + * Table order mirrors the FK dependency graph: users/roles/permissions + * have no dependencies and are created first, the pivot tables depend on + * two of those three, and the token tables depend only on users. `down()` + * reverses that order so no drop ever runs while a table still references + * it. + */ +export default class extends BaseSchema { + async up() { + this.schema.createTable('users', (table) => { + table.bigIncrements('id') + table.string('first_name', 50).notNullable() + table.string('last_name', 100).notNullable() + table.string('email', 100).notNullable().unique() + // NOTE: the README's column table does not mark `password` NOT NULL, + // unlike every other column here. Left nullable so an account created + // ahead of activation (invited but not yet set a password) is + // representable; the @beforeSave hook on User only hashes when a + // password is actually set. + table.string('password', 255).nullable() + table.boolean('enabled').notNullable() + table.boolean('account_locked').notNullable() + }) + + this.schema.createTable('roles', (table) => { + table.bigIncrements('id') + table.string('role_name', 50).notNullable().unique() + }) + + this.schema.createTable('permissions', (table) => { + table.bigIncrements('id') + table.string('resource', 50).notNullable() + table.string('action', 50).notNullable() + }) + + this.schema.createTable('role_user', (table) => { + table + .bigInteger('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table + .bigInteger('role_id') + .unsigned() + .notNullable() + .references('id') + .inTable('roles') + .onDelete('CASCADE') + // NOTE: the README lists only user_id and role_id for this pivot, + // no surrogate id. A composite primary key is added here (Lucid's + // manyToMany does not require a dedicated id column on the pivot) + // so the same user cannot be assigned the same role twice. + table.primary(['user_id', 'role_id']) + }) + + this.schema.createTable('role_permission', (table) => { + table + .bigInteger('role_id') + .unsigned() + .notNullable() + .references('id') + .inTable('roles') + .onDelete('CASCADE') + table + .bigInteger('permission_id') + .unsigned() + .notNullable() + .references('id') + .inTable('permissions') + .onDelete('CASCADE') + table.primary(['role_id', 'permission_id']) + }) + + this.schema.createTable('activation_tokens', (table) => { + table.bigIncrements('id') + // NOTE: the README does not mark activation_tokens.user_id NOT NULL, + // so the FK is nullable here. CASCADE still applies to rows that do + // have a user_id: an activation token has no purpose once its owning + // account is gone. + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 255).nullable() + table.timestamp('created_at').notNullable() + table.timestamp('expires_at').nullable() + table.timestamp('validated_at').nullable() + }) + + this.schema.createTable('blacklisted_tokens', (table) => { + table.bigIncrements('id') + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 768).notNullable() + // jti is unique so the same JWT cannot be blacklisted twice; the + // AuthMiddleware built in feature/auth looks up incoming tokens by + // jti on every authenticated request. + table.string('jti', 255).unique() + table.timestamp('blacklisted_at').nullable() + table.timestamp('created_at').notNullable() + table.timestamp('expires_at').nullable() + table.timestamp('validated_at').nullable() + }) + + this.schema.createTable('password_reset_tokens', (table) => { + table.bigIncrements('id') + table + .bigInteger('user_id') + .unsigned() + .nullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('token', 255).notNullable() + table.string('type', 255).notNullable() + table.timestamp('expiry_date').notNullable() + }) + } + + async down() { + this.schema.dropTable('password_reset_tokens') + this.schema.dropTable('blacklisted_tokens') + this.schema.dropTable('activation_tokens') + this.schema.dropTable('role_permission') + this.schema.dropTable('role_user') + this.schema.dropTable('permissions') + this.schema.dropTable('roles') + this.schema.dropTable('users') + } +} From e43c5eef1bc05107a276e25ac293eaf6b8b2833d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:31 +0400 Subject: [PATCH 26/44] feat(data-modeling): expose test seed credentials in typed env schema UserSeeder needs to read TEST_ADMIN_EMAIL, TEST_ADMIN_PASSWORD, TEST_USER_EMAIL and TEST_USER_PASSWORD without touching process.env directly, per this project's hard rule that all environment access goes through the typed env service. Those four variables already exist in .env.test but not in .env.development, so they are declared optional here rather than required: making them required would mean the app refuses to boot in development, since Env.create validates against whichever .env.${NODE_ENV} file is loaded, and .env.development legitimately has no reason to define test-only credentials. UserSeeder falls back to literal defaults when these are absent, and those defaults are the same values already committed to .env.test, so seeding behaves identically whether it runs against the test database or a developer's local one. --- start/env.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/start/env.ts b/start/env.ts index 7d4b8e3..3463eb7 100644 --- a/start/env.ts +++ b/start/env.ts @@ -43,4 +43,14 @@ export default await Env.create(new URL('../', import.meta.url), { LIMITER_STORE: Env.schema.enum(['database', 'redis'] as const), THROTTLE_AUTH_MAX: Env.schema.number(), THROTTLE_AUTH_WINDOW: Env.schema.number(), + + // Test seed credentials + // NOTE: declared optional (not required) so the app still boots against + // .env.development, which does not define them. UserSeeder falls back to + // the same values .env.test hardcodes when these are absent, so seeding + // works the same way in development as it does in the test database. + TEST_ADMIN_EMAIL: Env.schema.string.optional(), + TEST_ADMIN_PASSWORD: Env.schema.string.optional(), + TEST_USER_EMAIL: Env.schema.string.optional(), + TEST_USER_PASSWORD: Env.schema.string.optional(), }) From 8d9e59c29e0f4b3f77cd2b2aac94fc9ba1567afb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:39 +0400 Subject: [PATCH 27/44] feat(database): add CategorySeeder with sample catalog categories Seeds five realistic categories that ProductSeeder will attach products to. updateOrCreateMany is used instead of createMany, keyed on categoryName, because this project's documented setup command is plain node ace db:seed with no --files flag, which runs every seeder file under database/seeders on each invocation rather than only the ones a caller cherry-picks. That means this file can execute on its own and also get invoked a second time through MainSeeder in the same db:seed run, so it has to tolerate running more than once without violating anything or duplicating rows. categoryName has no database-level uniqueness constraint, but using it as the idempotency key here is still correct since the seeded category names are fixed and known in advance. --- database/seeders/category_seeder.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 database/seeders/category_seeder.ts diff --git a/database/seeders/category_seeder.ts b/database/seeders/category_seeder.ts new file mode 100644 index 0000000..f807c48 --- /dev/null +++ b/database/seeders/category_seeder.ts @@ -0,0 +1,23 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Category from '#models/category' + +/** + * Seeds a small, realistic catalog of categories. + * + * `updateOrCreateMany` keys on `categoryName` so this seeder is idempotent: + * running `node ace db:seed` re-runs every file in database/seeders on + * every invocation (there is no --files flag in the README's documented + * workflow), so CategorySeeder must tolerate being executed more than once + * without producing duplicate rows or throwing on a second run. + */ +export default class CategorySeeder extends BaseSeeder { + async run() { + await Category.updateOrCreateMany('categoryName', [ + { categoryName: 'Electronics' }, + { categoryName: 'Books' }, + { categoryName: 'Clothing' }, + { categoryName: 'Home & Kitchen' }, + { categoryName: 'Sports & Outdoors' }, + ]) + } +} From fe80ed61c25a08f549b4eae500d3f4822660e204 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:47 +0400 Subject: [PATCH 28/44] feat(database): add ProductSeeder with sample products per category Seeds eight products distributed across the categories CategorySeeder creates, looked up by categoryName rather than assumed IDs. Primary keys are not something a seeder should hardcode, since the same category can land on a different id depending on what already exists in the database when the seeder runs; querying by name is the only reliable way to link a product to the right category regardless of run history. The requireCategory helper throws if a category is missing rather than silently skipping the product or inserting a null category_id, which the schema would reject anyway since category_id is NOT NULL. This turns a missing dependency into an immediate, readable error at seed time instead of a confusing foreign key violation from the database. In the documented node ace db:seed workflow this dependency is satisfied by CategorySeeder running first, either through alphabetical file ordering or through MainSeeder's explicit sequencing; the guard exists for the case where someone runs this seeder in isolation before that has happened. --- database/seeders/product_seeder.ts | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 database/seeders/product_seeder.ts diff --git a/database/seeders/product_seeder.ts b/database/seeders/product_seeder.ts new file mode 100644 index 0000000..5af6554 --- /dev/null +++ b/database/seeders/product_seeder.ts @@ -0,0 +1,44 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Category from '#models/category' +import Product from '#models/product' + +/** + * Seeds sample products linked to the categories CategorySeeder creates. + * + * Products are looked up by category name rather than assuming fixed IDs, + * because seeder execution order is not guaranteed to assign the same + * primary keys across environments (a fresh database vs. one that already + * has rows from a previous seed run). `updateOrCreateMany` on `productName` + * makes this idempotent for the same reason CategorySeeder is: `db:seed` + * runs every seeder file in the directory on each invocation. + */ +export default class ProductSeeder extends BaseSeeder { + async run() { + const categories = await Category.query().exec() + const categoryIdByName = new Map(categories.map((category) => [category.categoryName, category.id])) + + const requireCategory = (name: string) => { + const id = categoryIdByName.get(name) + if (!id) { + // NOTE: this seeder depends on CategorySeeder having run first. In + // the documented `node ace db:seed` workflow the alphabetical file + // order (category_seeder before product_seeder) and MainSeeder's + // explicit ordering both guarantee that, so this is a defensive + // guard rather than the primary ordering mechanism. + throw new Error(`ProductSeeder requires the "${name}" category to exist. Run CategorySeeder first.`) + } + return id + } + + await Product.updateOrCreateMany('productName', [ + { productName: 'Wireless Mouse', unitPrice: 19.99, categoryId: requireCategory('Electronics') }, + { productName: 'Mechanical Keyboard', unitPrice: 79.99, categoryId: requireCategory('Electronics') }, + { productName: 'The Pragmatic Programmer', unitPrice: 34.99, categoryId: requireCategory('Books') }, + { productName: 'Clean Code', unitPrice: 29.99, categoryId: requireCategory('Books') }, + { productName: "Men's Denim Jacket", unitPrice: 59.99, categoryId: requireCategory('Clothing') }, + { productName: 'Cotton T-Shirt', unitPrice: 14.99, categoryId: requireCategory('Clothing') }, + { productName: 'Stainless Steel Cookware Set', unitPrice: 129.99, categoryId: requireCategory('Home & Kitchen') }, + { productName: 'Yoga Mat', unitPrice: 24.99, categoryId: requireCategory('Sports & Outdoors') }, + ]) + } +} From 8242f0450ca485e908232b9c79c88bb208b9ba1c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:07:53 +0400 Subject: [PATCH 29/44] feat(database): add CustomerSeeder with sample customers Seeds four customers spanning a few different locales, since the customers table is what feature/customers and feature/orders will exercise in their functional tests. Keyed on email through updateOrCreateMany for the same idempotency reason as CategorySeeder: db:seed runs every seeder file on every invocation, so re-running this one must not attempt to insert the same email twice and hit the UNIQUE constraint the core schema migration places on customers.email. --- database/seeders/customer_seeder.ts | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 database/seeders/customer_seeder.ts diff --git a/database/seeders/customer_seeder.ts b/database/seeders/customer_seeder.ts new file mode 100644 index 0000000..4577473 --- /dev/null +++ b/database/seeders/customer_seeder.ts @@ -0,0 +1,42 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import Customer from '#models/customer' + +/** + * Seeds sample customers. Keyed on `email` via `updateOrCreateMany` for the + * same idempotency reason as CategorySeeder: `db:seed` re-runs every + * seeder file on each invocation. + */ +export default class CustomerSeeder extends BaseSeeder { + async run() { + await Customer.updateOrCreateMany('email', [ + { + firstName: 'Alice', + lastName: 'Johnson', + telephone: '+1-202-555-0143', + email: 'alice.johnson@example.com', + address: '742 Evergreen Terrace, Springfield, IL', + }, + { + firstName: 'Marcus', + lastName: 'Lee', + telephone: '+1-415-555-0198', + email: 'marcus.lee@example.com', + address: '1600 Amphitheatre Pkwy, Mountain View, CA', + }, + { + firstName: 'Sofia', + lastName: 'Garcia', + telephone: '+34-91-555-0176', + email: 'sofia.garcia@example.com', + address: 'Calle Gran Via 28, Madrid, Spain', + }, + { + firstName: 'Noah', + lastName: 'Williams', + telephone: '+44-20-7946-0958', + email: 'noah.williams@example.com', + address: '221B Baker Street, London, UK', + }, + ]) + } +} From 180bab01f54cd6e3de296fa4a334dcbc43b0a2b0 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:08:08 +0400 Subject: [PATCH 30/44] feat(database): add UserSeeder with ADMIN and USER test accounts Creates the two accounts every subsequent CRUD branch authenticates as in its functional tests: an ADMIN and a plain USER, with credentials read through the typed env service rather than hardcoded, and .env.test already defining the four TEST_* variables this reads. This is the reason the branch does not have to wait on feature/auth to be merged before other branches can write authenticated tests against real accounts. The README's seeder file list has no dedicated RoleSeeder, so the ADMIN and USER roles are created inline here with updateOrCreateMany before the two accounts, since role_user assignment needs both roles to already exist and adding a sixth seeder file the README never mentions felt like the wrong tradeoff versus a few extra lines in the file that actually needs the roles. sync([roleId], false) is used for the role assignment specifically because the false argument tells Lucid not to detach any existing pivot rows, which is what keeps this idempotent across repeated db:seed runs the same way the other seeders' updateOrCreateMany calls do. --- database/seeders/user_seeder.ts | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 database/seeders/user_seeder.ts diff --git a/database/seeders/user_seeder.ts b/database/seeders/user_seeder.ts new file mode 100644 index 0000000..a28d8ad --- /dev/null +++ b/database/seeders/user_seeder.ts @@ -0,0 +1,69 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import env from '#start/env' +import User from '#models/user' +import Role from '#models/role' + +/** + * Seeds the two accounts every subsequent CRUD branch's functional tests + * authenticate as: an ADMIN and a plain USER. Credentials come from the + * typed env service (TEST_ADMIN_EMAIL / TEST_ADMIN_PASSWORD / TEST_USER_EMAIL + * / TEST_USER_PASSWORD) rather than being hardcoded here, so tests/functional + * specs and this seeder share a single source of truth in .env.test. + * + * Those four variables are declared optional in start/env.ts because + * .env.development does not define them. When they are absent (i.e. this + * seeder is run against the development database) the literal fallbacks + * below are used, and they are intentionally identical to the values + * already committed in .env.test, so behaviour does not change across + * environments. + * + * NOTE: the README's seeder file list (main_seeder, category_seeder, + * product_seeder, customer_seeder, user_seeder) has no dedicated + * RoleSeeder file. Since role_user assignment requires the ADMIN and USER + * roles to exist first, this seeder creates them inline with + * `updateOrCreateMany` before creating the two accounts, rather than adding + * a sixth seeder file the README does not list. + */ +export default class UserSeeder extends BaseSeeder { + async run() { + const [adminRole, userRole] = await Role.updateOrCreateMany('roleName', [ + { roleName: 'ADMIN' }, + { roleName: 'USER' }, + ]) + + const adminEmail = env.get('TEST_ADMIN_EMAIL') ?? 'admin@example.com' + const adminPassword = env.get('TEST_ADMIN_PASSWORD') ?? 'Admin1234!' + const userEmail = env.get('TEST_USER_EMAIL') ?? 'user@example.com' + const userPassword = env.get('TEST_USER_PASSWORD') ?? 'User1234!' + + const admin = await User.updateOrCreate( + { email: adminEmail }, + { + firstName: 'Admin', + lastName: 'User', + email: adminEmail, + password: adminPassword, + enabled: true, + accountLocked: false, + } + ) + + const user = await User.updateOrCreate( + { email: userEmail }, + { + firstName: 'Regular', + lastName: 'User', + email: userEmail, + password: userPassword, + enabled: true, + accountLocked: false, + } + ) + + // `sync(ids, false)` attaches the role without detaching any existing + // pivot rows, which keeps this call idempotent across repeated + // `node ace db:seed` runs. + await admin.related('roles').sync([adminRole.id], false) + await user.related('roles').sync([userRole.id], false) + } +} From 761ef89650b1b8f95021112662dd43bb6e9e3355 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:08:24 +0400 Subject: [PATCH 31/44] feat(database): add MainSeeder orchestrating all domain seeders Runs CategorySeeder, ProductSeeder, CustomerSeeder and UserSeeder in that explicit order, which is the true dependency order: products need categories to exist, and users need the roles they create for themselves before role assignment, but nothing else in this list depends on order. This class matters less for what node ace db:seed actually does by default and more as documentation and an explicit entry point. The documented setup command has no --files flag, so the seeder runner discovers and executes every file in database/seeders on its own, meaning the four domain seeders already run independently of this orchestration. Every one of them is written to be idempotent for exactly that reason, so calling them again here, in this specific order, never conflicts with their independent runs elsewhere in the same db:seed invocation. What MainSeeder adds is a single file that states the intended order in code, and a target for node ace db:seed --files when someone wants a single deterministic pass instead of relying on directory listing order. --- database/seeders/main_seeder.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 database/seeders/main_seeder.ts diff --git a/database/seeders/main_seeder.ts b/database/seeders/main_seeder.ts new file mode 100644 index 0000000..f4eff70 --- /dev/null +++ b/database/seeders/main_seeder.ts @@ -0,0 +1,30 @@ +import { BaseSeeder } from '@adonisjs/lucid/seeders' +import CategorySeeder from '#database/seeders/category_seeder' +import ProductSeeder from '#database/seeders/product_seeder' +import CustomerSeeder from '#database/seeders/customer_seeder' +import UserSeeder from '#database/seeders/user_seeder' + +/** + * Orchestrates every domain seeder in dependency order: categories before + * products (products reference a category), then customers, then users + * (which also creates the ADMIN/USER roles it needs before assigning them). + * + * NOTE: `node ace db:seed`, the command this README's setup instructions + * use, runs every seeder file under database/seeders on its own, not just + * this one, so CategorySeeder/ProductSeeder/CustomerSeeder/UserSeeder also + * execute independently of this orchestration. Each of them is written to + * be idempotent (updateOrCreate / updateOrCreateMany keyed on a natural + * unique column) specifically so that running them a second time here, or + * out of this explicit order, never produces duplicate rows or a unique + * constraint violation. This class still exists because it documents the + * intended dependency order and because `node ace db:seed --files` can + * target it alone to seed everything in one deterministic pass. + */ +export default class MainSeeder extends BaseSeeder { + async run() { + await new CategorySeeder(this.client).run() + await new ProductSeeder(this.client).run() + await new CustomerSeeder(this.client).run() + await new UserSeeder(this.client).run() + } +} From 8121fa9dfce0968559a093f3b9dd8a3e06ba14f3 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:09:23 +0400 Subject: [PATCH 32/44] fix(database): correct the typed env import path The pg connection block was written against @adonisjs/core/services/env, which does not exist in the installed @adonisjs/core version and would have made every node ace command fail to boot, including migrations and seeders. Every other config file in this project imports the typed env instance from the #start/env alias instead, which is where Env.create actually runs and where the DB_HOST, DB_PORT and related schema entries are validated. This fixes a mistake introduced in the earlier commit that switched the default connection to PostgreSQL, caught while reviewing the backend agent's report that migrations could not run against the branch as committed. --- config/database.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/database.ts b/config/database.ts index c195b2e..428a7f7 100644 --- a/config/database.ts +++ b/config/database.ts @@ -1,5 +1,5 @@ import app from '@adonisjs/core/services/app' -import env from '@adonisjs/core/services/env' +import env from '#start/env' import { defineConfig } from '@adonisjs/lucid' const dbConfig = defineConfig({ From a94b38b466ec02a7f4643cb44d4019c5645ba245 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:11:28 +0400 Subject: [PATCH 33/44] chore(database): commit the regenerated schema snapshot This file is produced by node ace migration:run itself, its header says not to edit it by hand, and the project has committed it as a snapshot since the first migration was added in feature/core-architecture. Running the two migrations from this branch against a real PostgreSQL instance regenerated it automatically, now describing the twelve tables that make up the core and auth schemas instead of the SQLite scaffold's users and auth_access_tokens tables. Verifying migrations against a live database also confirmed the up and down methods are symmetric: migration:run created all twelve tables cleanly, migration:rollback --batch=0 reverted both files without error, and running the migrations again reproduced the same schema, which is the guarantee the README's migration rollback test task is checking for. --- database/schema.ts | 172 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 database/schema.ts diff --git a/database/schema.ts b/database/schema.ts new file mode 100644 index 0000000..76665a1 --- /dev/null +++ b/database/schema.ts @@ -0,0 +1,172 @@ +/** + * This file is automatically generated + * DO NOT EDIT manually + * Run "node ace migration:run" command to re-generate this file + */ + +import { BaseModel, column } from '@adonisjs/lucid/orm' +import { DateTime } from 'luxon' + +export class ActivationTokenSchema extends BaseModel { + static $columns = ['createdAt', 'expiresAt', 'id', 'token', 'userId', 'validatedAt'] as const + $columns = ActivationTokenSchema.$columns + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime() + declare expiresAt: DateTime | null + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare token: string | null + @column() + declare userId: bigint | number | null + @column.dateTime() + declare validatedAt: DateTime | null +} + +export class BlacklistedTokenSchema extends BaseModel { + static $columns = ['blacklistedAt', 'createdAt', 'expiresAt', 'id', 'jti', 'token', 'userId', 'validatedAt'] as const + $columns = BlacklistedTokenSchema.$columns + @column.dateTime() + declare blacklistedAt: DateTime | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column.dateTime() + declare expiresAt: DateTime | null + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare jti: string | null + @column() + declare token: string + @column() + declare userId: bigint | number | null + @column.dateTime() + declare validatedAt: DateTime | null +} + +export class CategorySchema extends BaseModel { + static $columns = ['categoryName', 'id'] as const + $columns = CategorySchema.$columns + @column() + declare categoryName: string + @column({ isPrimary: true }) + declare id: bigint | number +} + +export class CustomerSchema extends BaseModel { + static $columns = ['address', 'email', 'firstName', 'id', 'lastName', 'telephone'] as const + $columns = CustomerSchema.$columns + @column() + declare address: string + @column() + declare email: string + @column() + declare firstName: string + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare lastName: string + @column() + declare telephone: string +} + +export class OrderSchema extends BaseModel { + static $columns = ['customerId', 'id', 'productId', 'quantity', 'total'] as const + $columns = OrderSchema.$columns + @column() + declare customerId: bigint | number + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare productId: bigint | number + @column() + declare quantity: number + @column() + declare total: number +} + +export class PasswordResetTokenSchema extends BaseModel { + static $columns = ['expiryDate', 'id', 'token', 'type', 'userId'] as const + $columns = PasswordResetTokenSchema.$columns + @column.dateTime() + declare expiryDate: DateTime + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare token: string + @column() + declare type: string + @column() + declare userId: bigint | number | null +} + +export class PermissionSchema extends BaseModel { + static $columns = ['action', 'id', 'resource'] as const + $columns = PermissionSchema.$columns + @column() + declare action: string + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare resource: string +} + +export class ProductSchema extends BaseModel { + static $columns = ['categoryId', 'id', 'productName', 'unitPrice'] as const + $columns = ProductSchema.$columns + @column() + declare categoryId: bigint | number + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare productName: string + @column() + declare unitPrice: number +} + +export class RolePermissionSchema extends BaseModel { + static $columns = ['permissionId', 'roleId'] as const + $columns = RolePermissionSchema.$columns + @column() + declare permissionId: bigint | number + @column({ isPrimary: true }) + declare roleId: bigint | number +} + +export class RoleUserSchema extends BaseModel { + static $columns = ['roleId', 'userId'] as const + $columns = RoleUserSchema.$columns + @column() + declare roleId: bigint | number + @column({ isPrimary: true }) + declare userId: bigint | number +} + +export class RoleSchema extends BaseModel { + static $columns = ['id', 'roleName'] as const + $columns = RoleSchema.$columns + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare roleName: string +} + +export class UserSchema extends BaseModel { + static $columns = ['accountLocked', 'email', 'enabled', 'firstName', 'id', 'lastName', 'password'] as const + $columns = UserSchema.$columns + @column() + declare accountLocked: boolean + @column() + declare email: string + @column() + declare enabled: boolean + @column() + declare firstName: string + @column({ isPrimary: true }) + declare id: bigint | number + @column() + declare lastName: string + @column({ serializeAs: null }) + declare password: string | null +} From e8790d4db95671ee551da97d02bfbea54e4e810c Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:38:39 +0400 Subject: [PATCH 34/44] test(category): verify hasMany products relation loads associated rows The Category model declares @hasMany(() => Product) but nothing in the branch had exercised it end to end against the real schema. This spec creates a category with two products and a second unrelated category with its own product, then calls category.load('products') and asserts the loaded relation contains exactly the two products scoped to that category, each with the matching category_id foreign key. A second test covers the empty case, loading products on a category that has none, so the relation resolves to an empty array instead of throwing or leaving the property undefined. Both behaviors needed direct verification because the categories service in a later branch will rely on preload('products') working correctly for the paginated list endpoint, and a misconfigured foreign key or local key on the relation would otherwise only surface once that endpoint is built. --- tests/unit/models/category.spec.ts | 68 ++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/unit/models/category.spec.ts diff --git a/tests/unit/models/category.spec.ts b/tests/unit/models/category.spec.ts new file mode 100644 index 0000000..869b23b --- /dev/null +++ b/tests/unit/models/category.spec.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for the Category model's `@hasMany(() => Product)` relation. + * + * The Tasks checklist for feature/data-modeling asks specifically for proof + * that `hasMany` returns the associated products, not just that the decorator + * compiles. These tests exercise `category.load('products')` against the real + * test database so a mismatched `category_id` foreign key or a misconfigured + * `localKey` / `foreignKey` on the relation would fail loudly instead of only + * showing up once the categories service starts using `preload()` in a later + * branch. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import Category from '#models/category' +import Product from '#models/product' + +test.group('Category model - hasMany products', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('loading products returns every product scoped to that category', async ({ assert }) => { + const category = await Category.create({ categoryName: 'Electronics' }) + const otherCategory = await Category.create({ categoryName: 'Groceries' }) + + const laptop = await Product.create({ + categoryId: category.id, + productName: 'Laptop', + unitPrice: 999.99, + }) + const mouse = await Product.create({ + categoryId: category.id, + productName: 'Mouse', + unitPrice: 19.99, + }) + await Product.create({ + categoryId: otherCategory.id, + productName: 'Bread', + unitPrice: 2.5, + }) + + await category.load('products') + + assert.lengthOf(category.products, 2) + assert.sameMembers( + category.products.map((product) => product.productName), + [laptop.productName, mouse.productName] + ) + category.products.forEach((product) => { + assert.equal(product.categoryId, category.id) + }) + }).timeout(10000) + + test('a category with no products loads an empty relation instead of throwing', async ({ + assert, + }) => { + const category = await Category.create({ categoryName: 'Empty Category' }) + + await category.load('products') + + assert.isArray(category.products) + assert.lengthOf(category.products, 0) + }).timeout(10000) +}) From 91c465fbf4c58b31a6d0a643b0a5fac517fa2ba5 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:38:47 +0400 Subject: [PATCH 35/44] test(order): verify total column round trips through storage and queries The orders migration stores total as a plain float written by the service rather than a generated column, precisely so it can freeze the product's unit_price at the moment of purchase instead of letting PostgreSQL recompute it later. That design only holds if the exact value written comes back out unchanged, with no coercion or precision loss between the column mapping and the numeric type in the database. This spec creates an order with a decimal total computed from quantity times unit_price and checks it on the model instance returned by create, then repeats the check by fetching the same row through a brand new query builder call so the assertion is not just reading back an in-memory value that was never actually persisted. Verifying this now protects the feature/orders branch, which will depend on this same column behaving predictably when it computes and stores totals from real product prices. --- tests/unit/models/order.spec.ts | 93 +++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/unit/models/order.spec.ts diff --git a/tests/unit/models/order.spec.ts b/tests/unit/models/order.spec.ts new file mode 100644 index 0000000..4993f77 --- /dev/null +++ b/tests/unit/models/order.spec.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for the Order model's `total` column. + * + * The migration stores `total` as a plain `float` written by the orders + * service (not a generated column), specifically so it can freeze the + * product's unit_price at the moment of purchase. That design choice only + * holds up if the value that gets written is the exact value that comes back + * out, with no silent coercion or precision loss between the `@column()` + * mapping and PostgreSQL's numeric type. These tests write an Order with a + * decimal total and read it back both from the in-memory model instance and + * from a fresh query, so a regression in the column type or the model's + * property mapping would be caught here rather than surfacing as a + * mispriced order in feature/orders. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import Category from '#models/category' +import Product from '#models/product' +import Customer from '#models/customer' +import Order from '#models/order' + +test.group('Order model - total column', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('total is stored and retrieved as the same numeric value', async ({ assert }) => { + const category = await Category.create({ categoryName: 'Books' }) + const product = await Product.create({ + categoryId: category.id, + productName: 'Novel', + unitPrice: 15.5, + }) + const customer = await Customer.create({ + firstName: 'Ada', + lastName: 'Lovelace', + telephone: '555-0100', + email: 'ada@example.com', + address: '1 Analytical Engine Way', + }) + + const quantity = 3 + const total = quantity * product.unitPrice + + const order = await Order.create({ + customerId: customer.id, + productId: product.id, + quantity, + total, + }) + + assert.isNumber(order.total) + assert.equal(order.total, total) + }).timeout(10000) + + test('total survives a round trip through a fresh query, independent of the created instance', async ({ + assert, + }) => { + const category = await Category.create({ categoryName: 'Furniture' }) + const product = await Product.create({ + categoryId: category.id, + productName: 'Desk', + unitPrice: 149.75, + }) + const customer = await Customer.create({ + firstName: 'Grace', + lastName: 'Hopper', + telephone: '555-0101', + email: 'grace@example.com', + address: '2 Compiler Street', + }) + + const total = 2 * product.unitPrice + + const created = await Order.create({ + customerId: customer.id, + productId: product.id, + quantity: 2, + total, + }) + + const found = await Order.query().where('id', created.id).firstOrFail() + + assert.equal(found.total, total) + assert.equal(found.quantity, 2) + assert.equal(found.customerId, customer.id) + assert.equal(found.productId, product.id) + }).timeout(10000) +}) From 61c336ed679ad41099c0160017d79d91601d8d4a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:38:56 +0400 Subject: [PATCH 36/44] test(user): verify beforeSave hook hashes password only when it changes The project's hard rule against manual password hashing puts all the weight on the User model's @beforeSave hook: every write path has to end up with a hashed password without the caller remembering to call hash.make() itself. That guarantee has two halves that both needed direct proof, since only testing one would leave the other silently broken. The hook has to hash a brand new plain text password on insert, and it has to hash again when an existing user's password is reassigned and saved. The third test in this file covers the half that is easy to get wrong: saving a user after changing an unrelated column, like locking the account, must not re-hash an already-hashed password. The hook relies on checking user.$dirty.password before hashing, and without a passing test for the untouched case a future edit could hash the hash on every save, silently corrupting stored passwords and locking every user out at their next login. Each test verifies the real behavior against the hash service's own verify method and, for the untouched case, against a freshly queried row, not just the in-memory instance. --- tests/unit/models/user.spec.ts | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/unit/models/user.spec.ts diff --git a/tests/unit/models/user.spec.ts b/tests/unit/models/user.spec.ts new file mode 100644 index 0000000..54aa59a --- /dev/null +++ b/tests/unit/models/user.spec.ts @@ -0,0 +1,89 @@ +/** + * Unit tests for the `@beforeSave` password hashing hook on the User model. + * + * The hook's whole reason for existing is the "no manual password hashing" + * hard rule: every write path (registration, password reset, an admin + * editing a profile) must end up with a hashed password without the caller + * remembering to call `hash.make()` itself. That only works safely if the + * hook hashes on insert, hashes again when an existing user's password is + * reassigned, and, just as importantly, leaves an already-hashed password + * alone when some unrelated column changes. Without that last guarantee, a + * save triggered by toggling `accountLocked` would hash an already-hashed + * value, corrupting the password for the next login. + * + * These tests check `user.$dirty.password` behaviour indirectly, by + * asserting on the actual hash produced against the real hash service and + * database, since the hook is only meaningful once the full round trip + * insert -> read -> save works end to end. + */ +import { test } from '@japa/runner' +import db from '@adonisjs/lucid/services/db' +import hash from '@adonisjs/core/services/hash' +import User from '#models/user' + +test.group('User model - beforeSave password hashing hook', (group) => { + group.setup(async () => { + await db.beginGlobalTransaction() + }) + + group.teardown(async () => { + await db.rollbackGlobalTransaction() + }) + + test('hashes the plain text password before insert', async ({ assert }) => { + const plainPassword = 'Sup3rSecret!' + + const user = await User.create({ + firstName: 'Katherine', + lastName: 'Johnson', + email: 'katherine@example.com', + password: plainPassword, + enabled: true, + accountLocked: false, + }) + + assert.notEqual(user.password, plainPassword) + assert.isTrue(await hash.verify(user.password, plainPassword)) + }).timeout(10000) + + test('re-hashes the password when it is reassigned on an existing user', async ({ assert }) => { + const user = await User.create({ + firstName: 'Alan', + lastName: 'Turing', + email: 'alan@example.com', + password: 'FirstPassword1!', + enabled: true, + accountLocked: false, + }) + + const firstHash = user.password + + user.password = 'SecondPassword2!' + await user.save() + + assert.notEqual(user.password, firstHash) + assert.isTrue(await hash.verify(user.password, 'SecondPassword2!')) + }).timeout(10000) + + test('does not re-hash the password when an unrelated column changes', async ({ assert }) => { + const user = await User.create({ + firstName: 'Grace', + lastName: 'Hopper', + email: 'grace.hopper@example.com', + password: 'OriginalPass1!', + enabled: true, + accountLocked: false, + }) + + const originalHash = user.password + + user.accountLocked = true + await user.save() + + assert.equal(user.password, originalHash) + + const reloaded = await User.findOrFail(user.id) + assert.equal(reloaded.password, originalHash) + assert.isTrue(reloaded.accountLocked) + }).timeout(10000) +}) From 69dcc1ca4a50b85ba1b6120859addb9fdcf873bf Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 12:39:05 +0400 Subject: [PATCH 37/44] test(database): verify migrations run and roll back without error Nothing in the branch had actually exercised the down() side of either migration file against the real test database, only migration:status showing them applied. A schema that only ever migrates up is not proven safe to maintain, since the first time someone actually needs to roll back a bad migration in development is the worst time to discover the down() method was never correct. This spec drives the MigrationRunner class directly, the same class the migration:run and migration:rollback ace commands call internally, first rolling back the last batch and asserting every table from both the core and auth migration files is actually gone from PostgreSQL's catalog, then running the migrations back up and asserting every table exists again. It deliberately avoids the db.beginGlobalTransaction() pattern used by the model specs in this branch, since DDL statements and the migrator's own bookkeeping do not compose safely with an outer transaction fixture, and it never calls the migrator's close() method, which was found to tear down the shared connection pool that every other unit test file in the process depends on. A catch block re-runs the up direction before rethrowing any assertion failure, so the database is left in the same migrated, runnable state it was found in even when the test itself fails. --- tests/unit/database/migrations.spec.ts | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/unit/database/migrations.spec.ts diff --git a/tests/unit/database/migrations.spec.ts b/tests/unit/database/migrations.spec.ts new file mode 100644 index 0000000..c11fb9f --- /dev/null +++ b/tests/unit/database/migrations.spec.ts @@ -0,0 +1,100 @@ +/** + * Verifies that the branch's migrations run and roll back without error + * against the real test database. + * + * This deliberately does not use `db.beginGlobalTransaction()` / + * `rollbackGlobalTransaction()` the way the model specs in this branch do. + * Schema changes (`CREATE TABLE`, `DROP TABLE`) are DDL, and migrations run + * their own transaction handling internally through the Lucid migrator, so + * wrapping them in an outer global transaction would either hide real + * failures or fight with the migrator's own commits. Instead this test + * drives the `MigrationRunner` class directly, the same class the + * `migration:run` / `migration:rollback` ace commands use internally, and + * asserts against actual PostgreSQL catalog state (`to_regclass`) before and + * after each direction so a broken `up()` or `down()` in either migration + * file fails the test instead of silently leaving the schema half-applied. + * + * Because rollback drops every table from both core and auth migrations, + * the test always re-runs migrations afterward, including from inside a + * catch block, so the database is left in the same migrated, runnable state + * it was found in even if an assertion in the middle of the test fails. + * + * Note this test deliberately never calls `MigrationRunner#close()`. That + * method calls `db.manager.closeAll(true)`, which releases every connection + * from the shared `db` service, not a private one scoped to the migrator. + * Since this file runs inside the same process as every other unit test + * file, closing the manager here would tear down the connection pool the + * rest of the suite depends on. + */ +import { test } from '@japa/runner' +import app from '@adonisjs/core/services/app' +import db from '@adonisjs/lucid/services/db' +import { MigrationRunner } from '@adonisjs/lucid/migration' + +async function tableExists(tableName: string): Promise { + const result = await db.rawQuery(`select to_regclass('public.${tableName}') as reg`) + return result.rows[0].reg !== null +} + +test.group('Database migrations - run and rollback', () => { + test('rolling back and re-running the migrations leaves the schema intact', async ({ + assert, + }) => { + const coreTables = ['categories', 'products', 'customers', 'orders'] + const authTables = [ + 'users', + 'roles', + 'permissions', + 'role_user', + 'role_permission', + 'activation_tokens', + 'blacklisted_tokens', + 'password_reset_tokens', + ] + const allTables = [...coreTables, ...authTables] + + // Sanity check: the tables should exist before this test does anything, + // since the migrations were already applied ahead of the test run. + for (const tableName of allTables) { + assert.isTrue(await tableExists(tableName), `expected "${tableName}" to exist before rollback`) + } + + try { + const rollback = new MigrationRunner(db, app, { + direction: 'down', + connectionName: 'pg', + }) + await rollback.run() + + assert.isNull(rollback.error, rollback.error?.message) + assert.equal(rollback.status, 'completed') + + for (const tableName of allTables) { + assert.isFalse(await tableExists(tableName), `expected "${tableName}" to be dropped by rollback`) + } + + const rerun = new MigrationRunner(db, app, { + direction: 'up', + connectionName: 'pg', + }) + await rerun.run() + + assert.isNull(rerun.error, rerun.error?.message) + assert.equal(rerun.status, 'completed') + + for (const tableName of allTables) { + assert.isTrue(await tableExists(tableName), `expected "${tableName}" to exist again after re-running`) + } + } catch (error) { + // Best-effort recovery: make sure the schema is back in place even if + // an assertion above threw mid-way, so later tests and any local + // developer database are not left without tables. + const recovery = new MigrationRunner(db, app, { + direction: 'up', + connectionName: 'pg', + }) + await recovery.run().catch(() => {}) + throw error + } + }).timeout(30000) +}) From 1bb5fc2945e7719706ed02dfccfc3c562a686693 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:26:54 +0400 Subject: [PATCH 38/44] chore(database): add pg type declarations Adding the pg type parser to config/database.ts brought in a bare import of the pg package for its types module, and this project's installed pg version ships no bundled TypeScript declarations, only an ESM entry point tsc cannot type on its own. Without a declaration file, npm run typecheck fails immediately on that import, which is a required step in both CI workflows. This is a devDependency only, it has no effect on the runtime build, it exists purely so tsc can resolve the shape of the pg module during type checking. --- package-lock.json | 13 +++++++++++++ package.json | 1 + 2 files changed, 14 insertions(+) diff --git a/package-lock.json b/package-lock.json index a77e4e6..9a92682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@poppinss/ts-exec": "^1.4.4", "@types/luxon": "^3.7.4", "@types/node": "~26.2.0", + "@types/pg": "^8.23.1", "eslint": "^10.8.1", "hot-hook": "^1.0.0", "pino-pretty": "^13.1.3", @@ -2052,6 +2053,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/pluralize": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", diff --git a/package.json b/package.json index 139a590..f04437f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@poppinss/ts-exec": "^1.4.4", "@types/luxon": "^3.7.4", "@types/node": "~26.2.0", + "@types/pg": "^8.23.1", "eslint": "^10.8.1", "hot-hook": "^1.0.0", "pino-pretty": "^13.1.3", From e3f0bc7eed8511e4a2bb1e6c4ca45c941979456a Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:01 +0400 Subject: [PATCH 39/44] fix(auth): use serializeAs instead of serializeUsing on User.password The column option written for hiding the password field does not exist on this project's installed Lucid version, the correct option is serializeAs, set to null to drop the field entirely from a serialized user rather than replacing it with an undefined value. Running npm run typecheck against the branch as originally committed failed on this line, since serializeUsing is simply not a member of the ColumnOptions type here. The fix mirrors what Lucid's own schema generator writes for the same column, confirming serializeAs is the version-correct option, and caught while running the code review's verification pass rather than trusting the property name from memory. --- app/models/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/user.ts b/app/models/user.ts index a6723cd..e96ac67 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -30,7 +30,7 @@ export default class User extends BaseModel { declare email: string // Never serialized back to the client, in responses or JSON.stringify. - @column({ serializeUsing: () => undefined }) + @column({ serializeAs: null }) declare password: string @column() From 930e2c3b090b4c0de9470cb226da5ec4fc869cdb Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:10 +0400 Subject: [PATCH 40/44] fix(database): parse BIGINT columns as numbers Every model in this branch types its bigIncrements primary key and its bigInteger foreign keys as number, following the README's own User model example, but node-postgres returns BIGINT columns as strings by default since a bigint can exceed Number.MAX_SAFE_INTEGER. Verified directly against the live test database: creating a category and reading its id back gave the string "1", not the number 1, despite the model declaring id as a number. Registering a type parser for OID 20 at the top of config/database.ts, before any connection opens, converts these values to numbers as soon as they leave the driver, so every model and every future service built on top of these ids can rely on the TypeScript type actually matching the runtime value. This project's row counts stay well under Number.MAX_SAFE_INTEGER, so parseInt here is a safe and intentional tradeoff against carrying bigint or string ids through the whole codebase for values this tutorial will never reach. --- config/database.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config/database.ts b/config/database.ts index 428a7f7..e23fe99 100644 --- a/config/database.ts +++ b/config/database.ts @@ -1,7 +1,16 @@ import app from '@adonisjs/core/services/app' import env from '#start/env' +import pg from 'pg' import { defineConfig } from '@adonisjs/lucid' +// NOTE: node-postgres returns BIGINT (OID 20) columns as strings by +// default, since a bigint can exceed Number.MAX_SAFE_INTEGER. Every model +// in this project types its bigIncrements primary keys and bigInteger +// foreign keys as `number`, matching the README's own examples, so this +// parser is registered here, before any pg connection is opened, to keep +// ids and foreign keys as actual numbers rather than strings at runtime. +pg.types.setTypeParser(20, (value: string) => Number.parseInt(value, 10)) + const dbConfig = defineConfig({ /** * Default connection used for all queries. From aa90950201d4323a29e7296788411c4eab607f97 Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:20 +0400 Subject: [PATCH 41/44] ci: run migrations before the test step This branch is the first to add tests that query real tables, and the CI Postgres service starts every run from an empty database. Without a migration step between install and test, npm test has nothing to query against. Verified this exact failure locally by rolling every migration back to batch zero, the same starting state as a fresh CI container, which drops the suite from ten passing tests to two. The migration step runs with --force so it does not prompt for confirmation in production mode, matching how the rest of this workflow already sets NODE_ENV to test through its top level env block. --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef1e0f4..9a3fc45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,5 +65,8 @@ jobs: - name: Lint run: npm run lint + - name: Run migrations + run: node ace migration:run --force + - name: Run tests run: npm test From 5b4e5a8215a52a114a606ac125391b77d295d34b Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:20 +0400 Subject: [PATCH 42/44] ci: run migrations before the test step Mirrors the same fix applied to ci.yml. pr-checks.yml runs on every pull request against develop and shares the identical Postgres service container setup, so it has the same empty-database gap: functional and unit tests here would fail the moment a spec touches a table this branch defines, since nothing had applied the migrations first. --- .github/workflows/pr-checks.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1beaa51..21ae17e 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -72,6 +72,12 @@ jobs: - name: Lint run: npm run lint + # Migrations must run against the fresh service container before any + # test touches the database, unit specs under tests/unit/database and + # tests/unit/models assume the schema already exists. + - name: Run migrations + run: node ace migration:run --force + # Functional tests start the AdonisJS HTTP server and issue real requests. # Future browser / E2E suites will use @japa/browser-client with Playwright # in headless-chromium mode; install steps will be added when that suite From c6dff4aebaebff96a88a66fd47e850d9ceac3c8d Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:27 +0400 Subject: [PATCH 43/44] style(database): format the product seeder with Prettier The file as originally written did not match this project's Prettier configuration, npm run lint reported eight formatting errors on it, which is a required step in both CI workflows and would have failed every check run on this branch. No logic changed, only line wrapping and spacing. --- database/seeders/product_seeder.ts | 44 ++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/database/seeders/product_seeder.ts b/database/seeders/product_seeder.ts index 5af6554..fa71d90 100644 --- a/database/seeders/product_seeder.ts +++ b/database/seeders/product_seeder.ts @@ -15,7 +15,9 @@ import Product from '#models/product' export default class ProductSeeder extends BaseSeeder { async run() { const categories = await Category.query().exec() - const categoryIdByName = new Map(categories.map((category) => [category.categoryName, category.id])) + const categoryIdByName = new Map( + categories.map((category) => [category.categoryName, category.id]) + ) const requireCategory = (name: string) => { const id = categoryIdByName.get(name) @@ -25,20 +27,46 @@ export default class ProductSeeder extends BaseSeeder { // order (category_seeder before product_seeder) and MainSeeder's // explicit ordering both guarantee that, so this is a defensive // guard rather than the primary ordering mechanism. - throw new Error(`ProductSeeder requires the "${name}" category to exist. Run CategorySeeder first.`) + throw new Error( + `ProductSeeder requires the "${name}" category to exist. Run CategorySeeder first.` + ) } return id } await Product.updateOrCreateMany('productName', [ - { productName: 'Wireless Mouse', unitPrice: 19.99, categoryId: requireCategory('Electronics') }, - { productName: 'Mechanical Keyboard', unitPrice: 79.99, categoryId: requireCategory('Electronics') }, - { productName: 'The Pragmatic Programmer', unitPrice: 34.99, categoryId: requireCategory('Books') }, + { + productName: 'Wireless Mouse', + unitPrice: 19.99, + categoryId: requireCategory('Electronics'), + }, + { + productName: 'Mechanical Keyboard', + unitPrice: 79.99, + categoryId: requireCategory('Electronics'), + }, + { + productName: 'The Pragmatic Programmer', + unitPrice: 34.99, + categoryId: requireCategory('Books'), + }, { productName: 'Clean Code', unitPrice: 29.99, categoryId: requireCategory('Books') }, - { productName: "Men's Denim Jacket", unitPrice: 59.99, categoryId: requireCategory('Clothing') }, + { + productName: "Men's Denim Jacket", + unitPrice: 59.99, + categoryId: requireCategory('Clothing'), + }, { productName: 'Cotton T-Shirt', unitPrice: 14.99, categoryId: requireCategory('Clothing') }, - { productName: 'Stainless Steel Cookware Set', unitPrice: 129.99, categoryId: requireCategory('Home & Kitchen') }, - { productName: 'Yoga Mat', unitPrice: 24.99, categoryId: requireCategory('Sports & Outdoors') }, + { + productName: 'Stainless Steel Cookware Set', + unitPrice: 129.99, + categoryId: requireCategory('Home & Kitchen'), + }, + { + productName: 'Yoga Mat', + unitPrice: 24.99, + categoryId: requireCategory('Sports & Outdoors'), + }, ]) } } From d66a9024616d4e2fe82f51e1f301a51eef4fc95f Mon Sep 17 00:00:00 2001 From: EdgarEldy Date: Mon, 24 Aug 2026 13:27:27 +0400 Subject: [PATCH 44/44] style(database): format the migrations spec with Prettier Three lines in this file did not match the project's Prettier configuration, caught by the same npm run lint pass that flagged the product seeder. No assertions or test behavior changed, only formatting. --- tests/unit/database/migrations.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/unit/database/migrations.spec.ts b/tests/unit/database/migrations.spec.ts index c11fb9f..488a4e9 100644 --- a/tests/unit/database/migrations.spec.ts +++ b/tests/unit/database/migrations.spec.ts @@ -56,7 +56,10 @@ test.group('Database migrations - run and rollback', () => { // Sanity check: the tables should exist before this test does anything, // since the migrations were already applied ahead of the test run. for (const tableName of allTables) { - assert.isTrue(await tableExists(tableName), `expected "${tableName}" to exist before rollback`) + assert.isTrue( + await tableExists(tableName), + `expected "${tableName}" to exist before rollback` + ) } try { @@ -70,7 +73,10 @@ test.group('Database migrations - run and rollback', () => { assert.equal(rollback.status, 'completed') for (const tableName of allTables) { - assert.isFalse(await tableExists(tableName), `expected "${tableName}" to be dropped by rollback`) + assert.isFalse( + await tableExists(tableName), + `expected "${tableName}" to be dropped by rollback` + ) } const rerun = new MigrationRunner(db, app, { @@ -83,7 +89,10 @@ test.group('Database migrations - run and rollback', () => { assert.equal(rerun.status, 'completed') for (const tableName of allTables) { - assert.isTrue(await tableExists(tableName), `expected "${tableName}" to exist again after re-running`) + assert.isTrue( + await tableExists(tableName), + `expected "${tableName}" to exist again after re-running` + ) } } catch (error) { // Best-effort recovery: make sure the schema is back in place even if