@tulipes/cli 0.1.0 → 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +11 -8
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js +29 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/app-core.d.ts +8 -6
  6. package/dist/app-core.js +23 -14
  7. package/dist/app-core.js.map +1 -1
  8. package/dist/bin.js +3 -3
  9. package/dist/bin.js.map +1 -1
  10. package/dist/config-check.d.ts +8 -0
  11. package/dist/config-check.js +43 -0
  12. package/dist/config-check.js.map +1 -0
  13. package/dist/config-import.d.ts +20 -0
  14. package/dist/config-import.js +133 -0
  15. package/dist/config-import.js.map +1 -0
  16. package/dist/dev.d.ts +4 -4
  17. package/dist/dev.js +7 -10
  18. package/dist/dev.js.map +1 -1
  19. package/dist/init.d.ts +6 -11
  20. package/dist/init.js +17 -6420
  21. package/dist/init.js.map +1 -1
  22. package/dist/inspection.d.ts +9 -8
  23. package/dist/inspection.js +10 -11
  24. package/dist/inspection.js.map +1 -1
  25. package/dist/main.js +82 -11
  26. package/dist/main.js.map +1 -1
  27. package/dist/migrate.d.ts +30 -0
  28. package/dist/migrate.js +772 -0
  29. package/dist/migrate.js.map +1 -0
  30. package/dist/new-module.js +87 -46
  31. package/dist/new-module.js.map +1 -1
  32. package/dist/routes.d.ts +3 -5
  33. package/dist/routes.js +4 -6
  34. package/dist/routes.js.map +1 -1
  35. package/dist/spec.d.ts +2 -5
  36. package/dist/spec.js +13 -6
  37. package/dist/spec.js.map +1 -1
  38. package/dist/sync.d.ts +3 -11
  39. package/dist/sync.js +81 -20
  40. package/dist/sync.js.map +1 -1
  41. package/dist/template.d.ts +58 -0
  42. package/dist/template.js +244 -0
  43. package/dist/template.js.map +1 -0
  44. package/dist/update.js +4 -4
  45. package/package.json +17 -16
  46. package/templates/AGENTS.md +162 -0
  47. package/templates/CLAUDE.md +11 -118
  48. package/templates/app/.envs/.env.development +18 -0
  49. package/templates/app/.envs/.env.production +19 -0
  50. package/templates/app/.envs/.env.staging +19 -0
  51. package/templates/app/README.md +327 -0
  52. package/templates/app/app.ts +3 -0
  53. package/templates/app/config/app.config.ts +73 -0
  54. package/templates/app/docs/agent-manifest.json +724 -0
  55. package/templates/app/ecosystem.config.cjs +60 -0
  56. package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
  57. package/templates/app/modules/agent/i18n/en.json +5 -0
  58. package/templates/app/modules/agent/i18n/fr.json +5 -0
  59. package/templates/app/modules/agent/module.acl.ts +14 -0
  60. package/templates/app/modules/agent/package.json +21 -0
  61. package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
  62. package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
  63. package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
  64. package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
  65. package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
  66. package/templates/app/modules/auth/helpers/access-check.ts +42 -0
  67. package/templates/app/modules/auth/helpers/auth.ts +48 -0
  68. package/templates/app/modules/auth/helpers/documents.ts +57 -0
  69. package/templates/app/modules/auth/helpers/passport.ts +180 -0
  70. package/templates/app/modules/auth/helpers/passwords.ts +58 -0
  71. package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
  72. package/templates/app/modules/auth/helpers/tokens.ts +329 -0
  73. package/templates/app/modules/auth/i18n/en.json +19 -0
  74. package/templates/app/modules/auth/i18n/fr.json +19 -0
  75. package/templates/app/modules/auth/lib/index.ts +12 -0
  76. package/templates/app/modules/auth/models/credential.model.ts +31 -0
  77. package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
  78. package/templates/app/modules/auth/models/session.model.ts +42 -0
  79. package/templates/app/modules/auth/module.acl.ts +41 -0
  80. package/templates/app/modules/auth/module.config.ts +30 -0
  81. package/templates/app/modules/auth/package.json +61 -0
  82. package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
  83. package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
  84. package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
  85. package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
  86. package/templates/app/modules/config/helpers/audit.ts +41 -0
  87. package/templates/app/modules/config/helpers/describe.ts +72 -0
  88. package/templates/app/modules/config/i18n/en.json +6 -0
  89. package/templates/app/modules/config/i18n/fr.json +6 -0
  90. package/templates/app/modules/config/models/config-audit.model.ts +26 -0
  91. package/templates/app/modules/config/module.acl.ts +12 -0
  92. package/templates/app/modules/config/package.json +23 -0
  93. package/templates/app/modules/config/routes/config.routes.ts +37 -0
  94. package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
  95. package/templates/app/modules/config/tests/config.test.ts +126 -0
  96. package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
  97. package/templates/app/modules/core/helpers/cache/index.ts +60 -0
  98. package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
  99. package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
  100. package/templates/app/modules/core/helpers/cache/types.ts +37 -0
  101. package/templates/app/modules/core/helpers/lazy.ts +13 -0
  102. package/templates/app/modules/core/helpers/logger/index.ts +115 -0
  103. package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
  104. package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
  105. package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
  106. package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
  107. package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
  108. package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
  109. package/templates/app/modules/core/lib/index.ts +34 -0
  110. package/templates/app/modules/core/module.config.ts +24 -0
  111. package/templates/app/modules/core/package.json +80 -0
  112. package/templates/app/modules/core/routes/core.routes.ts +24 -0
  113. package/templates/app/modules/core/tests/core.test.ts +19 -0
  114. package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
  115. package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
  116. package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
  117. package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
  118. package/templates/app/modules/hello/helpers/greeting.ts +8 -0
  119. package/templates/app/modules/hello/i18n/en.json +8 -0
  120. package/templates/app/modules/hello/i18n/fr.json +8 -0
  121. package/templates/app/modules/hello/models/greeting.model.ts +24 -0
  122. package/templates/app/modules/hello/module.acl.ts +16 -0
  123. package/templates/app/modules/hello/module.config.ts +14 -0
  124. package/templates/app/modules/hello/package.json +24 -0
  125. package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
  126. package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
  127. package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
  128. package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
  129. package/templates/app/modules/hello/tests/hello.test.ts +19 -0
  130. package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
  131. package/templates/app/modules/security/helpers/hardening.ts +65 -0
  132. package/templates/app/modules/security/helpers/proxy.ts +16 -0
  133. package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
  134. package/templates/app/modules/security/module.config.ts +59 -0
  135. package/templates/app/modules/security/package.json +26 -0
  136. package/templates/app/modules/security/routes/security.routes.ts +42 -0
  137. package/templates/app/modules/security/tests/security.test.ts +174 -0
  138. package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
  139. package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
  140. package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
  141. package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
  142. package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
  143. package/templates/app/modules/settings/i18n/en.json +3 -0
  144. package/templates/app/modules/settings/i18n/fr.json +3 -0
  145. package/templates/app/modules/settings/module.acl.ts +12 -0
  146. package/templates/app/modules/settings/package.json +21 -0
  147. package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
  148. package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
  149. package/templates/app/modules/settings/tests/settings.test.ts +409 -0
  150. package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
  151. package/templates/app/modules/users/i18n/en.json +14 -0
  152. package/templates/app/modules/users/i18n/fr.json +14 -0
  153. package/templates/app/modules/users/models/user.model.ts +18 -0
  154. package/templates/app/modules/users/module.acl.ts +10 -0
  155. package/templates/app/modules/users/module.config.ts +11 -0
  156. package/templates/app/modules/users/package.json +24 -0
  157. package/templates/app/modules/users/queues/users.queues.ts +31 -0
  158. package/templates/app/modules/users/routes/users.routes.ts +60 -0
  159. package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
  160. package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
  161. package/templates/app/nginx/development.conf +44 -0
  162. package/templates/app/nginx/production.conf +75 -0
  163. package/templates/app/nginx/staging.conf +51 -0
  164. package/templates/app/package.json +58 -0
  165. package/templates/app/scripts/count-greetings.ts +29 -0
  166. package/templates/app/scripts/deploy-nginx.ts +143 -0
  167. package/templates/app/scripts/list-users.ts +34 -0
  168. package/templates/app/tests/app.test.ts +28 -0
  169. package/templates/app/tests/support.ts +36 -0
  170. package/templates/app/tsconfig.json +28 -0
  171. package/templates/app/vitest.config.ts +6 -0
  172. package/templates/app/worker.ts +6 -0
  173. package/templates/claude/skills/tulipes-agent-tool/SKILL.md +83 -0
  174. package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
  175. package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
  176. package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
  177. package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
  178. package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
  179. package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
  180. package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
  181. package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
  182. package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
  183. package/templates/minimal/README.md +21 -0
  184. package/templates/minimal/app.ts +3 -0
  185. package/templates/minimal/config/app.config.ts +16 -0
  186. package/templates/minimal/modules/agent/module.acl.ts +3 -0
  187. package/templates/minimal/modules/agent/package.json +14 -0
  188. package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
  189. package/templates/minimal/modules/core/module.acl.ts +4 -0
  190. package/templates/minimal/modules/core/package.json +21 -0
  191. package/templates/minimal/modules/hello/i18n/en.json +3 -0
  192. package/templates/minimal/modules/hello/module.acl.ts +2 -0
  193. package/templates/minimal/modules/hello/package.json +11 -0
  194. package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
  195. package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
  196. package/templates/minimal/modules/security/package.json +15 -0
  197. package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
  198. package/templates/minimal/package.json +38 -0
  199. package/templates/minimal/tests/app.test.ts +52 -0
  200. package/templates/minimal/tsconfig.json +22 -0
  201. package/templates/minimal/vitest.config.ts +5 -0
  202. package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
  203. package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
  204. package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
  205. package/templates/versions.json +11 -0
  206. package/dist/minimal.d.ts +0 -2
  207. package/dist/minimal.js +0 -2200
  208. package/dist/minimal.js.map +0 -1
  209. /package/templates/{browser-auth.md → app/docs/browser-auth.md} +0 -0
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Storage-agnostic cache contract. Implementations serialize values
3
+ * themselves; callers work with plain objects. Keep it small — add methods
4
+ * only when a real use case needs them.
5
+ */
6
+ export interface Cache {
7
+ /** Returns the cached value, or `null` if absent/expired. */
8
+ get<T>(key: string): Promise<T | null>;
9
+ /** Stores a value, optionally expiring after `ttlSeconds`. */
10
+ set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
11
+ /** Removes a key (no-op if absent). */
12
+ del(key: string): Promise<void>;
13
+ /**
14
+ * Returns the cached value, or computes it with `producer`, caches it for
15
+ * `ttlSeconds`, and returns it (read-through).
16
+ */
17
+ wrap<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T>;
18
+ }
19
+
20
+ /**
21
+ * Shared read-through logic so drivers only implement storage. A driver that
22
+ * can do it atomically (e.g. redis with locks) is free to override.
23
+ */
24
+ export abstract class BaseCache implements Cache {
25
+ abstract get<T>(key: string): Promise<T | null>;
26
+ abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
27
+ abstract del(key: string): Promise<void>;
28
+
29
+ async wrap<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {
30
+ const cached = await this.get<T>(key);
31
+ if (cached !== null) return cached;
32
+
33
+ const fresh = await producer();
34
+ await this.set(key, fresh, ttlSeconds);
35
+ return fresh;
36
+ }
37
+ }
@@ -0,0 +1,13 @@
1
+ import type { RequestHandler } from "express";
2
+
3
+ /**
4
+ * Middleware built on its first request rather than while the routes
5
+ * file runs. Inspection (`tulipes routes`, `tulipes spec`) builds every
6
+ * router without resolving the environment or acquiring a service, so a
7
+ * middleware that reads a variable or opens a client is built here, once,
8
+ * when a real request arrives.
9
+ */
10
+ export function lazy(build: () => RequestHandler): RequestHandler {
11
+ let handler: RequestHandler | undefined;
12
+ return (req, res, next) => (handler ??= build())(req, res, next);
13
+ }
@@ -0,0 +1,115 @@
1
+ import { join } from "node:path";
2
+ // Default import, not the named one: pino.transport lives on the
3
+ // namespace that only the default export carries.
4
+ import pino from "pino";
5
+ import type { Logger, TransportTargetOptions } from "pino";
6
+ import type { ProcessMode } from "@tulipes/core/boot";
7
+ import type { Environment } from "@tulipes/core/env";
8
+
9
+ /**
10
+ * The app's logger, shared by every module.
11
+ *
12
+ * Two destinations at once, because they answer different questions:
13
+ *
14
+ * console pretty and coloured, for a human watching a terminal
15
+ * files JSON lines, for grep/jq/Loki/CloudWatch — one object per
16
+ * line with a stable shape, which is what makes logs queryable
17
+ *
18
+ * Files are named per process mode. Backend and worker run at the same
19
+ * time, and two processes rotating one file race: both notice the size
20
+ * limit, both rename, and one of them loses entries. Separate files cost
21
+ * nothing and remove the failure entirely.
22
+ */
23
+ let instance: Logger | undefined;
24
+
25
+ /**
26
+ * How the files are named and rotated. These never needed to differ per
27
+ * deployment, so they are constants rather than environment variables;
28
+ * the settings module's log stream reads the same names.
29
+ */
30
+ export const LOG_FILES = Object.freeze({
31
+ /** Base name of the all-levels file; the process mode and date are appended. */
32
+ basename: "app",
33
+ /** Base name of the errors-only file. */
34
+ errorBasename: "error",
35
+ /** Rotate on this schedule: "daily", "hourly", or milliseconds. */
36
+ rotationFrequency: "daily",
37
+ /** Also rotate once a file reaches this size. */
38
+ rotationSize: "20m",
39
+ /** How many rotated files to keep per destination, besides the current one. */
40
+ retentionFiles: 14,
41
+ });
42
+
43
+ export function logger(environment: Environment, mode: ProcessMode = "backend"): Logger {
44
+ if (instance) return instance;
45
+
46
+ const level = String(environment.get("LOG_LEVEL"));
47
+ const targets: TransportTargetOptions[] = [];
48
+
49
+ if (Boolean(environment.get("LOG_CONSOLE"))) {
50
+ targets.push({
51
+ target: "pino-pretty",
52
+ level,
53
+ options: {
54
+ colorize: true,
55
+ translateTime: "SYS:HH:MM:ss.l",
56
+ // pid and hostname matter in aggregated files, not on the terminal
57
+ // of the machine that is obviously running it.
58
+ ignore: "pid,hostname",
59
+ messageFormat: "{if module}[{module}] {end}{msg}",
60
+ },
61
+ });
62
+ }
63
+
64
+ if (Boolean(environment.get("LOG_FILE"))) {
65
+ const dir = String(environment.get("LOG_DIR"));
66
+ const rotation = {
67
+ // Both limits apply: whichever trips first rotates the file.
68
+ frequency: LOG_FILES.rotationFrequency,
69
+ size: LOG_FILES.rotationSize,
70
+ limit: { count: LOG_FILES.retentionFiles },
71
+ extension: ".log",
72
+ dateFormat: "yyyy-MM-dd",
73
+ mkdir: true,
74
+ };
75
+
76
+ targets.push({
77
+ target: "pino-roll",
78
+ level,
79
+ options: { ...rotation, file: join(dir, `${LOG_FILES.basename}-${mode}`) },
80
+ });
81
+
82
+ // A second copy of errors only. Everything here is also in the main
83
+ // file; the point is a small file to open first when something breaks,
84
+ // instead of paging through a day of request logs.
85
+ targets.push({
86
+ target: "pino-roll",
87
+ level: "error",
88
+ options: {
89
+ ...rotation,
90
+ file: join(dir, `${LOG_FILES.errorBasename}-${mode}`),
91
+ },
92
+ });
93
+ }
94
+
95
+ instance = targets.length
96
+ ? pino({ level, base: { mode } }, pino.transport({ targets }))
97
+ : // Every destination disabled: still return a working logger rather
98
+ // than null-checking at a hundred call sites.
99
+ pino({ level, base: { mode } });
100
+
101
+ return instance;
102
+ }
103
+
104
+ /**
105
+ * A logger tagged with the module it belongs to, so a line's origin is
106
+ * visible in both destinations: `[users] ready` on the console, and a
107
+ * queryable `"module":"users"` field in the file.
108
+ */
109
+ export function moduleLogger(
110
+ environment: Environment,
111
+ name: string,
112
+ mode: ProcessMode = "backend",
113
+ ): Logger {
114
+ return logger(environment, mode).child({ module: name });
115
+ }
@@ -0,0 +1,27 @@
1
+ import type { SchemaOptions } from "mongoose";
2
+
3
+ export { baseModelPlugin } from "./plugins/base.plugin.ts";
4
+ export { softDeletePlugin } from "./plugins/soft-delete.plugin.ts";
5
+ export { timestampsPlugin } from "./plugins/timestamps.plugin.ts";
6
+ export { registerBasePlugins } from "./register.ts";
7
+ export type {
8
+ Actor,
9
+ BaseDocument,
10
+ SoftDeleteFields,
11
+ SoftDeleteMethods,
12
+ Timestamps,
13
+ } from "./plugins/types.ts";
14
+
15
+ /**
16
+ * Serialization conventions every model spreads into its schema options.
17
+ * Timestamps are NOT set here — the base plugin owns them (snake_case).
18
+ */
19
+ export const baseSchemaOptions: SchemaOptions = {
20
+ toJSON: {
21
+ versionKey: false,
22
+ transform: (_doc, ret: Record<string, unknown>) => {
23
+ delete ret._id;
24
+ return ret;
25
+ },
26
+ },
27
+ };
@@ -0,0 +1,14 @@
1
+ import type { Schema } from "mongoose";
2
+
3
+ import { softDeletePlugin } from "./soft-delete.plugin.ts";
4
+ import { timestampsPlugin } from "./timestamps.plugin.ts";
5
+
6
+ /**
7
+ * The base plugin every model gets. Composes the focused plugins so each
8
+ * stays single-responsibility; new cross-cutting behaviour (audit trail,
9
+ * versioning, …) slots in here without touching call sites.
10
+ */
11
+ export function baseModelPlugin(schema: Schema): void {
12
+ schema.plugin(timestampsPlugin);
13
+ schema.plugin(softDeletePlugin);
14
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ Schema,
3
+ type Aggregate,
4
+ type HydratedDocument,
5
+ type Model,
6
+ type Query,
7
+ } from "mongoose";
8
+
9
+ import type { Actor, SoftDeleteFields } from "./types.ts";
10
+
11
+ /** Option carried on a query/aggregation to opt out of the soft-delete filter. */
12
+ interface SoftDeleteOptions {
13
+ withDeleted?: boolean;
14
+ }
15
+
16
+ const deletedPatch = (by?: Actor) => ({
17
+ is_deleted: true,
18
+ deleted_at: new Date(),
19
+ deleted_by: by ?? null,
20
+ });
21
+
22
+ const restoredPatch = () => ({
23
+ is_deleted: false,
24
+ deleted_at: null,
25
+ deleted_by: null,
26
+ });
27
+
28
+ /**
29
+ * Hides soft-deleted documents from reads and updates. Skipped when the
30
+ * caller opted out via `.withDeleted()` or already targeted `is_deleted`
31
+ * explicitly. Uses `$ne: true` (not `=== false`) so legacy documents
32
+ * missing the field are still treated as live.
33
+ */
34
+ function excludeDeleted(this: Query<unknown, unknown>) {
35
+ const { withDeleted } = this.getOptions() as SoftDeleteOptions;
36
+ if (withDeleted) return;
37
+ if (this.getFilter().is_deleted !== undefined) return;
38
+ this.where({ is_deleted: { $ne: true } });
39
+ }
40
+
41
+ /** The aggregation-pipeline equivalent of {@link excludeDeleted}. */
42
+ function excludeDeletedFromAggregate(this: Aggregate<unknown[]>) {
43
+ const { withDeleted } = (this.options ?? {}) as SoftDeleteOptions;
44
+ if (withDeleted) return;
45
+ this.pipeline().unshift({ $match: { is_deleted: { $ne: true } } });
46
+ }
47
+
48
+ /**
49
+ * The `deleted_by` actor as a nested sub-schema (no own `_id`) so the whole
50
+ * object can be `null` while the document is live. `refPath` makes `id` a
51
+ * polymorphic reference resolved against the model named in
52
+ * `deleted_by.model`.
53
+ */
54
+ const deletedBySchema = new Schema<Actor>(
55
+ {
56
+ model: { type: String, required: true },
57
+ id: { type: Schema.Types.ObjectId, required: true, refPath: "deleted_by.model" },
58
+ },
59
+ { _id: false },
60
+ );
61
+
62
+ /**
63
+ * Soft deletion: an `is_deleted` flag plus `deleted_at` and a polymorphic
64
+ * `deleted_by` actor. Deleted documents are transparently excluded from
65
+ * queries unless explicitly included.
66
+ *
67
+ * read incl. deleted: Model.find().withDeleted() (or setOptions)
68
+ * read only deleted: Model.find().onlyDeleted()
69
+ * delete one: await doc.softDelete(actor)
70
+ * delete many: await Model.softDelete(filter, actor)
71
+ * restore: await doc.restore() / await Model.restore(filter)
72
+ */
73
+ export function softDeletePlugin(schema: Schema): void {
74
+ schema.add({
75
+ is_deleted: { type: Boolean, default: false, index: true },
76
+ deleted_at: { type: Date, default: null },
77
+ deleted_by: { type: deletedBySchema, default: null },
78
+ });
79
+
80
+ // Covers find*, count*, distinct, update*, replace*. Hard deletes
81
+ // (deleteOne/deleteMany) stay literal on purpose — when code says
82
+ // delete, the framework must not silently downgrade it.
83
+ schema.pre(/^(count|find|distinct|update|replace)/, excludeDeleted);
84
+ schema.pre("aggregate", excludeDeletedFromAggregate);
85
+
86
+ const query = schema.query as Record<string, (...args: never[]) => unknown>;
87
+ query.withDeleted = function (this: Query<unknown, unknown>) {
88
+ return this.setOptions({ withDeleted: true } as SoftDeleteOptions);
89
+ };
90
+ query.onlyDeleted = function (this: Query<unknown, unknown>) {
91
+ return this.setOptions({ withDeleted: true } as SoftDeleteOptions).where({
92
+ is_deleted: true,
93
+ });
94
+ };
95
+
96
+ schema.methods.softDelete = function (
97
+ this: HydratedDocument<SoftDeleteFields>,
98
+ by?: Actor,
99
+ ) {
100
+ this.set(deletedPatch(by));
101
+ return this.save();
102
+ };
103
+ schema.methods.restore = function (this: HydratedDocument<SoftDeleteFields>) {
104
+ this.set(restoredPatch());
105
+ return this.save();
106
+ };
107
+
108
+ schema.statics.softDelete = function (
109
+ this: Model<unknown>,
110
+ filter: Record<string, unknown>,
111
+ by?: Actor,
112
+ ) {
113
+ return this.updateMany(filter, { $set: deletedPatch(by) });
114
+ };
115
+ schema.statics.restore = function (
116
+ this: Model<unknown>,
117
+ filter: Record<string, unknown>,
118
+ ) {
119
+ // withDeleted so the guard doesn't filter out the very docs we restore.
120
+ return this.updateMany(filter, { $set: restoredPatch() }).setOptions({
121
+ withDeleted: true,
122
+ } as SoftDeleteOptions);
123
+ };
124
+ }
@@ -0,0 +1,13 @@
1
+ import type { Schema } from "mongoose";
2
+
3
+ /**
4
+ * Mongoose-managed `created_at` / `updated_at` (snake_case, matching the
5
+ * API's wire format). Mongoose populates them on create/update — no manual
6
+ * defaults or hooks needed.
7
+ */
8
+ export function timestampsPlugin(schema: Schema): void {
9
+ schema.set("timestamps", {
10
+ createdAt: "created_at",
11
+ updatedAt: "updated_at",
12
+ });
13
+ }
@@ -0,0 +1,45 @@
1
+ import type { Document, ObjectId, Types } from "mongoose";
2
+
3
+ /**
4
+ * Who performed a soft delete — a polymorphic reference. `model` is the
5
+ * actor's Mongoose model name (e.g. "User") and `id` its `_id`; together
6
+ * they let `.populate("deleted_by.id")` resolve the actor via `refPath`.
7
+ */
8
+ export interface Actor {
9
+ model: string;
10
+ id: Types.ObjectId;
11
+ }
12
+
13
+ /** Timestamp fields added by `timestampsPlugin`. */
14
+ export interface Timestamps {
15
+ created_at: Date;
16
+ updated_at: Date;
17
+ }
18
+
19
+ /** Fields added by `softDeletePlugin`. */
20
+ export interface SoftDeleteFields {
21
+ is_deleted: boolean;
22
+ deleted_at: Date | null;
23
+ deleted_by: Actor | null;
24
+ }
25
+
26
+ /** Instance methods added by `softDeletePlugin`. */
27
+ export interface SoftDeleteMethods {
28
+ /** Flag this document as deleted (optionally recording the actor) and save. */
29
+ softDelete(by?: Actor): Promise<this>;
30
+ /** Clear the soft-delete flags and save. */
31
+ restore(): Promise<this>;
32
+ }
33
+
34
+ /**
35
+ * Everything the base plugin contributes to a document. Model interfaces
36
+ * extend this to inherit the fields and methods, typed. The `_id` type
37
+ * defaults to `ObjectId` but can be overridden:
38
+ *
39
+ * export interface IUser extends BaseDocument { name: string }
40
+ * export interface IApiKey extends BaseDocument<string> { name: string }
41
+ */
42
+ export type BaseDocument<T = ObjectId> = Document<T> &
43
+ Timestamps &
44
+ SoftDeleteFields &
45
+ SoftDeleteMethods;
@@ -0,0 +1,20 @@
1
+ import mongoose from "mongoose";
2
+
3
+ import { baseModelPlugin } from "./plugins/base.plugin.ts";
4
+
5
+ let registered = false;
6
+
7
+ /**
8
+ * Registers the base plugin globally so EVERY schema gets timestamps + soft
9
+ * delete. Global plugins only affect models compiled after registration, so
10
+ * call this before boot() — it's the first line of app.ts and worker.ts.
11
+ *
12
+ * An explicit function, deliberately not an import side effect: the old
13
+ * framework's side-effect register module made correctness depend on import
14
+ * order, which is exactly the class of bug this framework exists to kill.
15
+ */
16
+ export function registerBasePlugins(): void {
17
+ if (registered) return;
18
+ registered = true;
19
+ mongoose.plugin(baseModelPlugin);
20
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What the core module offers the rest of the app.
3
+ *
4
+ * Other modules import these by package name — `@app/core` — never by a
5
+ * relative path climbing out of their own folder. That is what keeps the
6
+ * dependency visible: a module that uses this has to declare @app/core,
7
+ * so the coupling shows up in its package.json instead of hiding in an
8
+ * import string.
9
+ */
10
+ export {
11
+ appCache,
12
+ closeCache,
13
+ BaseCache,
14
+ MemoryCache,
15
+ RedisCache,
16
+ type Cache,
17
+ } from "../helpers/cache/index.ts";
18
+
19
+ export { LOG_FILES, logger, moduleLogger } from "../helpers/logger/index.ts";
20
+
21
+ export { lazy } from "../helpers/lazy.ts";
22
+
23
+ export {
24
+ baseSchemaOptions,
25
+ baseModelPlugin,
26
+ softDeletePlugin,
27
+ timestampsPlugin,
28
+ registerBasePlugins,
29
+ type Actor,
30
+ type BaseDocument,
31
+ type SoftDeleteFields,
32
+ type SoftDeleteMethods,
33
+ type Timestamps,
34
+ } from "../helpers/mongoose/index.ts";
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from "@tulipes/core/config";
2
+ import { z } from "zod/v4";
3
+
4
+ import { registerBasePlugins } from "./helpers/mongoose/index.ts";
5
+
6
+ /**
7
+ * Declaration time (boot phase 4) is before models compile (phase 5), which
8
+ * is exactly when a global mongoose plugin has to exist: registering here
9
+ * makes the base plugins apply in EVERY mode — backend, worker and script
10
+ * alike — so a script can never read rows the API considers deleted.
11
+ */
12
+ registerBasePlugins();
13
+
14
+ /**
15
+ * The sys core module's own settings. The environment keeps only what boot
16
+ * needs before the store answers (the port, the connection strings, the log
17
+ * destinations) and the secrets; everything else is a setting like this one.
18
+ */
19
+ export default defineConfig({
20
+ schema: z.object({
21
+ publicDomain: z.string().min(1).default("localhost")
22
+ .describe("Public hostname this API is served from; \"localhost\" means not deployed. The nginx deploy script and the generated API documents read it"),
23
+ }),
24
+ });
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@app/core",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "tulipes": {
7
+ "tier": "sys",
8
+ "priority": 0,
9
+ "env": [
10
+ {
11
+ "name": "PORT",
12
+ "type": "number",
13
+ "group": "http",
14
+ "description": "HTTP port the backend process listens on",
15
+ "default": 3000
16
+ },
17
+ {
18
+ "name": "MONGO_URI",
19
+ "type": "url",
20
+ "group": "database",
21
+ "required": true,
22
+ "description": "MongoDB connection string"
23
+ },
24
+ {
25
+ "name": "REDIS_URL",
26
+ "type": "url",
27
+ "group": "redis",
28
+ "required": true,
29
+ "description": "Redis connection string (queues, cache)"
30
+ },
31
+ {
32
+ "name": "LOG_LEVEL",
33
+ "type": "enum",
34
+ "enum": [
35
+ "fatal",
36
+ "error",
37
+ "warn",
38
+ "info",
39
+ "debug",
40
+ "trace"
41
+ ],
42
+ "group": "logging",
43
+ "description": "Lowest level that reaches any destination",
44
+ "default": "info"
45
+ },
46
+ {
47
+ "name": "LOG_CONSOLE",
48
+ "type": "boolean",
49
+ "group": "logging",
50
+ "description": "Pretty coloured logs on stdout; turn off where a collector reads files only",
51
+ "default": true
52
+ },
53
+ {
54
+ "name": "LOG_FILE",
55
+ "type": "boolean",
56
+ "group": "logging",
57
+ "description": "Write JSON-line log files under LOG_DIR",
58
+ "default": true
59
+ },
60
+ {
61
+ "name": "LOG_DIR",
62
+ "type": "string",
63
+ "group": "logging",
64
+ "description": "Directory for log files, relative to the app root or absolute",
65
+ "default": "logs"
66
+ }
67
+ ]
68
+ },
69
+ "dependencies": {
70
+ "@tulipes/core": "workspace:*",
71
+ "ioredis": "^5",
72
+ "mongoose": "^8",
73
+ "pino": "^9",
74
+ "pino-pretty": "^13",
75
+ "pino-roll": "^3"
76
+ },
77
+ "exports": {
78
+ ".": "./lib/index.ts"
79
+ }
80
+ }
@@ -0,0 +1,24 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RequestHandler } from "express";
3
+ import type { BackendCtx } from "@tulipes/core/boot";
4
+
5
+ import { appCache } from "../helpers/cache/index.ts";
6
+ import { lazy } from "../helpers/lazy.ts";
7
+
8
+ /**
9
+ * The sys core module loads first, so what it mounts on `ctx.app` runs on
10
+ * every request of every later module: this is the global middleware slot.
11
+ */
12
+ export default function coreRoutes({ app, Environment }: BackendCtx): void {
13
+ // The cache client opens on first use; built on the first request so
14
+ // inspection never touches Redis.
15
+ app.use(lazy(() => {
16
+ const cache = appCache(Environment);
17
+ return (async (_req, res, next) => {
18
+ res.setHeader("X-Request-Id", randomUUID());
19
+ const served = await cache.wrap("stats:served-sample", 10, async () => Date.now());
20
+ res.setHeader("X-Stats-Sample", String(served));
21
+ next();
22
+ }) satisfies RequestHandler;
23
+ }));
24
+ }
@@ -0,0 +1,19 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { fileURLToPath } from "node:url";
3
+ import { z } from "zod/v4";
4
+ import { afterAll, beforeAll, expect, test, vi } from "vitest";
5
+ import { boot } from "@tulipes/core/boot";
6
+ import { useTestApp } from "@tulipes/testing";
7
+
8
+ import { testEnv, testSettings } from "../../../tests/support.ts";
9
+
10
+ const rootDir = fileURLToPath(new URL("../../../", import.meta.url));
11
+ const app = useTestApp({ rootDir, env: testEnv(), settings: testSettings() }, { beforeAll, afterAll });
12
+
13
+
14
+ test("unknown endpoints return the error envelope", async () => {
15
+ const response = await fetch(app.base + "/not-a-route");
16
+
17
+ expect(response.status).toBe(404);
18
+ expect(await response.json()).toMatchObject({ success: false, data: null });
19
+ });
@@ -0,0 +1,33 @@
1
+ import { action, defineAgent } from "@tulipes/core/agent";
2
+ import { requireModels } from "@tulipes/core/boot";
3
+ import { z } from "zod/v4";
4
+
5
+ /**
6
+ * What an agent sees of this module. Every route is a tool already — its
7
+ * rai() gives the name, description, input schema and permission — so this
8
+ * file adds what cannot be derived: the summary, which routes to expose,
9
+ * and an action that is not an endpoint. A module without this file
10
+ * exposes every route; `pnpm tulipes agent manifest` prints the result.
11
+ */
12
+ export default defineAgent({
13
+ summary: "Greets callers and counts how often each greeting is used.",
14
+ tools: [
15
+ { route: "hello:greet", description: "A greeting in the caller's language" },
16
+ { route: "hello:greet-name" },
17
+ { route: "hello:list-greetings", description: "The stored greetings, with how often each was used" },
18
+ { route: "hello:use-greeting" },
19
+
20
+ // An action: the id is a permission like any route's, granted to
21
+ // admin through hello:* in the auth module's ACL. The ACL decides,
22
+ // then the schema validates, then this runs.
23
+ action({
24
+ id: "hello:reset-counts",
25
+ description: "Reset the usage counter of every greeting to zero",
26
+ input: z.object({ confirm: z.literal(true).describe("Set to true to confirm the reset") }),
27
+ handler: async (ctx) => {
28
+ const { modifiedCount } = await requireModels(ctx).get("Greeting").updateMany({}, { times_used: 0 });
29
+ return { reset: modifiedCount };
30
+ },
31
+ }),
32
+ ],
33
+ });
@@ -0,0 +1,27 @@
1
+ import type { DatabaseCtx } from "@tulipes/core/boot";
2
+
3
+ /**
4
+ * Runs after settings, translations and ACL and before the server accepts
5
+ * traffic — the place for indexes, seed rows and defaults.
6
+ *
7
+ * MUST be idempotent: it runs on every boot of every process, backend and
8
+ * worker alike. Upsert; never blind-insert.
9
+ */
10
+ export default async function seedGreetings({ models, mode }: DatabaseCtx): Promise<void> {
11
+ if (mode === "script") return; // a one-off script reads; it does not seed
12
+ const Greeting = models.get("Greeting");
13
+
14
+ try {
15
+ await Greeting.updateOne(
16
+ { name: "world" },
17
+ { $setOnInsert: { name: "world", message: "Hello, world!" } },
18
+ { upsert: true },
19
+ );
20
+ } catch (error) {
21
+ // Bootstrap tasks run in EVERY process, and backend and worker
22
+ // start together — concurrent upserts on a unique index race by
23
+ // design, and the loser gets a duplicate-key error. The row
24
+ // exists either way, which is all this task wanted.
25
+ if ((error as { code?: number }).code !== 11000) throw error;
26
+ }
27
+ }