@tulipes/core 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/init.js CHANGED
@@ -303,9 +303,6 @@ function renderProject(name, coreVersion) {
303
303
  ` * or CORS origins here rather than editing that file.`,
304
304
  ` */`,
305
305
  ` http: {`,
306
- ` // Passed to express.json({ limit }) — a parser without a cap is a`,
307
- ` // memory-exhaustion invitation.`,
308
- ` bodyLimit: "1mb",`,
309
306
  ` // Browser origins allowed to call this API. Add a cors() middleware`,
310
307
  ` // in the security module and feed it this list.`,
311
308
  ` corsOrigins: ["http://localhost:5173"],`,
@@ -568,7 +565,7 @@ function renderProject(name, coreVersion) {
568
565
  ` root /var/www/html;`,
569
566
  ` }`,
570
567
  ``,
571
- ` # Keep in step with config.app.ts http.bodyLimit. If nginx is stricter,`,
568
+ ` # Keep in step with the BODY_LIMIT variable. If nginx is stricter,`,
572
569
  ` # oversized requests die here as an nginx HTML page instead of the`,
573
570
  ` # API's JSON 413.`,
574
571
  ` client_max_body_size 1m;`,
@@ -802,8 +799,18 @@ function renderProject(name, coreVersion) {
802
799
  version: "0.0.0",
803
800
  private: true,
804
801
  type: "module",
802
+ // Importable by other modules as "@app/core": helpers more than one
803
+ // module needs live here rather than in a top-level lib/.
804
+ exports: { ".": "./index.ts" },
805
805
  tulipes: { tier: "sys", priority: 0 },
806
- dependencies: { "@tulipes/core": core },
806
+ dependencies: {
807
+ "@tulipes/core": core,
808
+ ioredis: "^5",
809
+ mongoose: "^8",
810
+ pino: "^9",
811
+ "pino-pretty": "^13",
812
+ "pino-roll": "^3",
813
+ },
807
814
  })],
808
815
  ["modules/core/meta.variables.json", json({
809
816
  variables: [
@@ -837,8 +844,671 @@ function renderProject(name, coreVersion) {
837
844
  description: 'Public hostname this API is served from; "localhost" means not deployed',
838
845
  default: "localhost",
839
846
  },
847
+ {
848
+ name: "LOG_LEVEL",
849
+ type: "enum",
850
+ enum: ["fatal", "error", "warn", "info", "debug", "trace"],
851
+ group: "logging",
852
+ description: "Lowest level that reaches any destination",
853
+ default: "info",
854
+ },
855
+ {
856
+ name: "LOG_CONSOLE",
857
+ type: "boolean",
858
+ group: "logging",
859
+ description: "Pretty coloured logs on stdout; turn off where a collector reads files only",
860
+ default: true,
861
+ },
862
+ {
863
+ name: "LOG_FILE",
864
+ type: "boolean",
865
+ group: "logging",
866
+ description: "Write JSON-line log files under LOG_DIR",
867
+ default: true,
868
+ },
869
+ {
870
+ name: "LOG_DIR",
871
+ type: "string",
872
+ group: "logging",
873
+ description: "Directory for log files, relative to the app root or absolute",
874
+ default: "logs",
875
+ },
876
+ {
877
+ name: "LOG_BASENAME",
878
+ type: "string",
879
+ group: "logging",
880
+ description: "Base name of the all-levels log file; the process mode and date are appended",
881
+ default: "app",
882
+ },
883
+ {
884
+ name: "LOG_ERROR_BASENAME",
885
+ type: "string",
886
+ group: "logging",
887
+ description: "Base name of the errors-only log file; the process mode and date are appended",
888
+ default: "error",
889
+ },
890
+ {
891
+ name: "LOG_ROTATION_FREQUENCY",
892
+ type: "string",
893
+ group: "logging",
894
+ description: 'Rotate on this schedule: "daily", "hourly", or milliseconds',
895
+ default: "daily",
896
+ },
897
+ {
898
+ name: "LOG_ROTATION_SIZE",
899
+ type: "string",
900
+ group: "logging",
901
+ description: 'Also rotate once a file reaches this size, e.g. "20m"',
902
+ default: "20m",
903
+ },
904
+ {
905
+ name: "LOG_RETENTION_FILES",
906
+ type: "number",
907
+ group: "logging",
908
+ description: "How many rotated files to keep per destination, besides the current one",
909
+ default: 14,
910
+ },
840
911
  ],
841
912
  })],
913
+ ["modules/core/helpers/cache/types.ts", [
914
+ `/**`,
915
+ ` * Storage-agnostic cache contract. Implementations serialize values`,
916
+ ` * themselves; callers work with plain objects. Keep it small — add methods`,
917
+ ` * only when a real use case needs them.`,
918
+ ` */`,
919
+ `export interface Cache {`,
920
+ ` /** Returns the cached value, or \`null\` if absent/expired. */`,
921
+ ` get<T>(key: string): Promise<T | null>;`,
922
+ ` /** Stores a value, optionally expiring after \`ttlSeconds\`. */`,
923
+ ` set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;`,
924
+ ` /** Removes a key (no-op if absent). */`,
925
+ ` del(key: string): Promise<void>;`,
926
+ ` /**`,
927
+ ` * Returns the cached value, or computes it with \`producer\`, caches it for`,
928
+ ` * \`ttlSeconds\`, and returns it (read-through).`,
929
+ ` */`,
930
+ ` wrap<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T>;`,
931
+ `}`,
932
+ ``,
933
+ `/**`,
934
+ ` * Shared read-through logic so drivers only implement storage. A driver that`,
935
+ ` * can do it atomically (e.g. redis with locks) is free to override.`,
936
+ ` */`,
937
+ `export abstract class BaseCache implements Cache {`,
938
+ ` abstract get<T>(key: string): Promise<T | null>;`,
939
+ ` abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;`,
940
+ ` abstract del(key: string): Promise<void>;`,
941
+ ``,
942
+ ` async wrap<T>(key: string, ttlSeconds: number, producer: () => Promise<T>): Promise<T> {`,
943
+ ` const cached = await this.get<T>(key);`,
944
+ ` if (cached !== null) return cached;`,
945
+ ``,
946
+ ` const fresh = await producer();`,
947
+ ` await this.set(key, fresh, ttlSeconds);`,
948
+ ` return fresh;`,
949
+ ` }`,
950
+ `}`,
951
+ ``,
952
+ ].join("\n")],
953
+ ["modules/core/helpers/cache/memory-cache.ts", [
954
+ `import { BaseCache } from "./types.js";`,
955
+ ``,
956
+ `interface Entry {`,
957
+ ` value: unknown;`,
958
+ ` /** Epoch ms when this entry expires, or \`null\` for no expiry. */`,
959
+ ` expiresAt: number | null;`,
960
+ `}`,
961
+ ``,
962
+ `/**`,
963
+ ` * In-process cache backed by a \`Map\`. For tests and local runs without`,
964
+ ` * Redis — not shared across processes (each worker has its own store).`,
965
+ ` *`,
966
+ ` * Bounded: past \`maxEntries\` the least-recently-used key is evicted. LRU`,
967
+ ` * order rides on Map insertion order — hits re-insert their key at the tail.`,
968
+ ` */`,
969
+ `export class MemoryCache extends BaseCache {`,
970
+ ` readonly #store = new Map<string, Entry>();`,
971
+ ` readonly #maxEntries: number;`,
972
+ ``,
973
+ ` constructor(maxEntries = 10_000) {`,
974
+ ` super();`,
975
+ ` this.#maxEntries = maxEntries;`,
976
+ ` }`,
977
+ ``,
978
+ ` async get<T>(key: string): Promise<T | null> {`,
979
+ ` const entry = this.#store.get(key);`,
980
+ ` if (!entry) return null;`,
981
+ ` if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {`,
982
+ ` this.#store.delete(key);`,
983
+ ` return null;`,
984
+ ` }`,
985
+ ` // Refresh recency: delete + re-set moves the key to the Map's tail.`,
986
+ ` this.#store.delete(key);`,
987
+ ` this.#store.set(key, entry);`,
988
+ ` return entry.value as T;`,
989
+ ` }`,
990
+ ``,
991
+ ` async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {`,
992
+ ` const expiresAt =`,
993
+ ` ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;`,
994
+ ` this.#store.delete(key);`,
995
+ ` this.#store.set(key, { value, expiresAt });`,
996
+ ``,
997
+ ` while (this.#store.size > this.#maxEntries) {`,
998
+ ` // Oldest key = head of the Map's insertion order.`,
999
+ ` this.#store.delete(this.#store.keys().next().value!);`,
1000
+ ` }`,
1001
+ ` }`,
1002
+ ``,
1003
+ ` async del(key: string): Promise<void> {`,
1004
+ ` this.#store.delete(key);`,
1005
+ ` }`,
1006
+ `}`,
1007
+ ``,
1008
+ ].join("\n")],
1009
+ ["modules/core/helpers/cache/redis-cache.ts", [
1010
+ `import type { Redis } from "ioredis";`,
1011
+ `import { BaseCache } from "./types.js";`,
1012
+ ``,
1013
+ `/**`,
1014
+ ` * Redis-backed cache. Values are JSON-serialized; keys are namespaced by`,
1015
+ ` * \`prefix\` so cache keys never collide with other Redis users in the same`,
1016
+ ` * db (BullMQ especially) and can be cleared without a FLUSHDB.`,
1017
+ ` */`,
1018
+ `export class RedisCache extends BaseCache {`,
1019
+ ` readonly #redis: Redis;`,
1020
+ ` readonly #prefix: string;`,
1021
+ ``,
1022
+ ` constructor(redis: Redis, prefix = "cache:") {`,
1023
+ ` super();`,
1024
+ ` this.#redis = redis;`,
1025
+ ` this.#prefix = prefix;`,
1026
+ ` }`,
1027
+ ``,
1028
+ ` #key(key: string): string {`,
1029
+ ` return \`\${this.#prefix}\${key}\`;`,
1030
+ ` }`,
1031
+ ``,
1032
+ ` async get<T>(key: string): Promise<T | null> {`,
1033
+ ` const raw = await this.#redis.get(this.#key(key));`,
1034
+ ` return raw === null ? null : (JSON.parse(raw) as T);`,
1035
+ ` }`,
1036
+ ``,
1037
+ ` async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {`,
1038
+ ` const raw = JSON.stringify(value);`,
1039
+ ` if (ttlSeconds && ttlSeconds > 0) {`,
1040
+ ` await this.#redis.set(this.#key(key), raw, "EX", ttlSeconds);`,
1041
+ ` } else {`,
1042
+ ` await this.#redis.set(this.#key(key), raw);`,
1043
+ ` }`,
1044
+ ` }`,
1045
+ ``,
1046
+ ` async del(key: string): Promise<void> {`,
1047
+ ` await this.#redis.del(this.#key(key));`,
1048
+ ` }`,
1049
+ `}`,
1050
+ ``,
1051
+ ].join("\n")],
1052
+ ["modules/core/helpers/cache/index.ts", [
1053
+ `import { Redis } from "ioredis";`,
1054
+ `import type { Environment } from "@tulipes/core/env";`,
1055
+ ``,
1056
+ `import { MemoryCache } from "./memory-cache.js";`,
1057
+ `import { RedisCache } from "./redis-cache.js";`,
1058
+ `import type { Cache } from "./types.js";`,
1059
+ ``,
1060
+ `export type { Cache } from "./types.js";`,
1061
+ `export { BaseCache } from "./types.js";`,
1062
+ `export { MemoryCache } from "./memory-cache.js";`,
1063
+ `export { RedisCache } from "./redis-cache.js";`,
1064
+ ``,
1065
+ `let active: { cache: Cache; redis?: Redis } | undefined;`,
1066
+ ``,
1067
+ `/**`,
1068
+ ` * The app's one cache, picked from the environment: redis when REDIS_URL is`,
1069
+ ` * declared, in-process memory otherwise (tests, minimal setups). Lazily`,
1070
+ ` * created, process-wide singleton.`,
1071
+ ` */`,
1072
+ `export function appCache(environment: Environment): Cache {`,
1073
+ ` if (active) return active.cache;`,
1074
+ ``,
1075
+ ` if (environment.store.has("REDIS_URL")) {`,
1076
+ ` const redis = new Redis(String(environment.get("REDIS_URL")));`,
1077
+ ` active = { cache: new RedisCache(redis), redis };`,
1078
+ ` } else {`,
1079
+ ` active = { cache: new MemoryCache() };`,
1080
+ ` }`,
1081
+ ` return active.cache;`,
1082
+ `}`,
1083
+ ``,
1084
+ `/** Call from a module's onShutdown — the redis driver holds a connection. */`,
1085
+ `export async function closeCache(): Promise<void> {`,
1086
+ ` await active?.redis?.quit();`,
1087
+ ` active = undefined;`,
1088
+ `}`,
1089
+ ``,
1090
+ ].join("\n")],
1091
+ ["modules/core/helpers/mongoose/plugins/types.ts", [
1092
+ `import type { Document, ObjectId, Types } from "mongoose";`,
1093
+ ``,
1094
+ `/**`,
1095
+ ` * Who performed a soft delete — a polymorphic reference. \`model\` is the`,
1096
+ ` * actor's Mongoose model name (e.g. "User") and \`id\` its \`_id\`; together`,
1097
+ ` * they let \`.populate("deleted_by.id")\` resolve the actor via \`refPath\`.`,
1098
+ ` */`,
1099
+ `export interface Actor {`,
1100
+ ` model: string;`,
1101
+ ` id: Types.ObjectId;`,
1102
+ `}`,
1103
+ ``,
1104
+ `/** Timestamp fields added by \`timestampsPlugin\`. */`,
1105
+ `export interface Timestamps {`,
1106
+ ` created_at: Date;`,
1107
+ ` updated_at: Date;`,
1108
+ `}`,
1109
+ ``,
1110
+ `/** Fields added by \`softDeletePlugin\`. */`,
1111
+ `export interface SoftDeleteFields {`,
1112
+ ` is_deleted: boolean;`,
1113
+ ` deleted_at: Date | null;`,
1114
+ ` deleted_by: Actor | null;`,
1115
+ `}`,
1116
+ ``,
1117
+ `/** Instance methods added by \`softDeletePlugin\`. */`,
1118
+ `export interface SoftDeleteMethods {`,
1119
+ ` /** Flag this document as deleted (optionally recording the actor) and save. */`,
1120
+ ` softDelete(by?: Actor): Promise<this>;`,
1121
+ ` /** Clear the soft-delete flags and save. */`,
1122
+ ` restore(): Promise<this>;`,
1123
+ `}`,
1124
+ ``,
1125
+ `/**`,
1126
+ ` * Everything the base plugin contributes to a document. Model interfaces`,
1127
+ ` * extend this to inherit the fields and methods, typed. The \`_id\` type`,
1128
+ ` * defaults to \`ObjectId\` but can be overridden:`,
1129
+ ` *`,
1130
+ ` * export interface IUser extends BaseDocument { name: string }`,
1131
+ ` * export interface IApiKey extends BaseDocument<string> { name: string }`,
1132
+ ` */`,
1133
+ `export type BaseDocument<T = ObjectId> = Document<T> &`,
1134
+ ` Timestamps &`,
1135
+ ` SoftDeleteFields &`,
1136
+ ` SoftDeleteMethods;`,
1137
+ ``,
1138
+ ].join("\n")],
1139
+ ["modules/core/helpers/mongoose/plugins/timestamps.plugin.ts", [
1140
+ `import type { Schema } from "mongoose";`,
1141
+ ``,
1142
+ `/**`,
1143
+ ` * Mongoose-managed \`created_at\` / \`updated_at\` (snake_case, matching the`,
1144
+ ` * API's wire format). Mongoose populates them on create/update — no manual`,
1145
+ ` * defaults or hooks needed.`,
1146
+ ` */`,
1147
+ `export function timestampsPlugin(schema: Schema): void {`,
1148
+ ` schema.set("timestamps", {`,
1149
+ ` createdAt: "created_at",`,
1150
+ ` updatedAt: "updated_at",`,
1151
+ ` });`,
1152
+ `}`,
1153
+ ``,
1154
+ ].join("\n")],
1155
+ ["modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts", [
1156
+ `import {`,
1157
+ ` Schema,`,
1158
+ ` type Aggregate,`,
1159
+ ` type HydratedDocument,`,
1160
+ ` type Model,`,
1161
+ ` type Query,`,
1162
+ `} from "mongoose";`,
1163
+ ``,
1164
+ `import type { Actor, SoftDeleteFields } from "./types.js";`,
1165
+ ``,
1166
+ `/** Option carried on a query/aggregation to opt out of the soft-delete filter. */`,
1167
+ `interface SoftDeleteOptions {`,
1168
+ ` withDeleted?: boolean;`,
1169
+ `}`,
1170
+ ``,
1171
+ `const deletedPatch = (by?: Actor) => ({`,
1172
+ ` is_deleted: true,`,
1173
+ ` deleted_at: new Date(),`,
1174
+ ` deleted_by: by ?? null,`,
1175
+ `});`,
1176
+ ``,
1177
+ `const restoredPatch = () => ({`,
1178
+ ` is_deleted: false,`,
1179
+ ` deleted_at: null,`,
1180
+ ` deleted_by: null,`,
1181
+ `});`,
1182
+ ``,
1183
+ `/**`,
1184
+ ` * Hides soft-deleted documents from reads and updates. Skipped when the`,
1185
+ ` * caller opted out via \`.withDeleted()\` or already targeted \`is_deleted\``,
1186
+ ` * explicitly. Uses \`$ne: true\` (not \`=== false\`) so legacy documents`,
1187
+ ` * missing the field are still treated as live.`,
1188
+ ` */`,
1189
+ `function excludeDeleted(this: Query<unknown, unknown>) {`,
1190
+ ` const { withDeleted } = this.getOptions() as SoftDeleteOptions;`,
1191
+ ` if (withDeleted) return;`,
1192
+ ` if (this.getFilter().is_deleted !== undefined) return;`,
1193
+ ` this.where({ is_deleted: { $ne: true } });`,
1194
+ `}`,
1195
+ ``,
1196
+ `/** The aggregation-pipeline equivalent of {@link excludeDeleted}. */`,
1197
+ `function excludeDeletedFromAggregate(this: Aggregate<unknown[]>) {`,
1198
+ ` const { withDeleted } = (this.options ?? {}) as SoftDeleteOptions;`,
1199
+ ` if (withDeleted) return;`,
1200
+ ` this.pipeline().unshift({ $match: { is_deleted: { $ne: true } } });`,
1201
+ `}`,
1202
+ ``,
1203
+ `/**`,
1204
+ ` * The \`deleted_by\` actor as a nested sub-schema (no own \`_id\`) so the whole`,
1205
+ ` * object can be \`null\` while the document is live. \`refPath\` makes \`id\` a`,
1206
+ ` * polymorphic reference resolved against the model named in`,
1207
+ ` * \`deleted_by.model\`.`,
1208
+ ` */`,
1209
+ `const deletedBySchema = new Schema<Actor>(`,
1210
+ ` {`,
1211
+ ` model: { type: String, required: true },`,
1212
+ ` id: { type: Schema.Types.ObjectId, required: true, refPath: "deleted_by.model" },`,
1213
+ ` },`,
1214
+ ` { _id: false },`,
1215
+ `);`,
1216
+ ``,
1217
+ `/**`,
1218
+ ` * Soft deletion: an \`is_deleted\` flag plus \`deleted_at\` and a polymorphic`,
1219
+ ` * \`deleted_by\` actor. Deleted documents are transparently excluded from`,
1220
+ ` * queries unless explicitly included.`,
1221
+ ` *`,
1222
+ ` * read incl. deleted: Model.find().withDeleted() (or setOptions)`,
1223
+ ` * read only deleted: Model.find().onlyDeleted()`,
1224
+ ` * delete one: await doc.softDelete(actor)`,
1225
+ ` * delete many: await Model.softDelete(filter, actor)`,
1226
+ ` * restore: await doc.restore() / await Model.restore(filter)`,
1227
+ ` */`,
1228
+ `export function softDeletePlugin(schema: Schema): void {`,
1229
+ ` schema.add({`,
1230
+ ` is_deleted: { type: Boolean, default: false, index: true },`,
1231
+ ` deleted_at: { type: Date, default: null },`,
1232
+ ` deleted_by: { type: deletedBySchema, default: null },`,
1233
+ ` });`,
1234
+ ``,
1235
+ ` // Covers find*, count*, distinct, update*, replace*. Hard deletes`,
1236
+ ` // (deleteOne/deleteMany) stay literal on purpose — when code says`,
1237
+ ` // delete, the framework must not silently downgrade it.`,
1238
+ ` schema.pre(/^(count|find|distinct|update|replace)/, excludeDeleted);`,
1239
+ ` schema.pre("aggregate", excludeDeletedFromAggregate);`,
1240
+ ``,
1241
+ ` const query = schema.query as Record<string, (...args: never[]) => unknown>;`,
1242
+ ` query.withDeleted = function (this: Query<unknown, unknown>) {`,
1243
+ ` return this.setOptions({ withDeleted: true } as SoftDeleteOptions);`,
1244
+ ` };`,
1245
+ ` query.onlyDeleted = function (this: Query<unknown, unknown>) {`,
1246
+ ` return this.setOptions({ withDeleted: true } as SoftDeleteOptions).where({`,
1247
+ ` is_deleted: true,`,
1248
+ ` });`,
1249
+ ` };`,
1250
+ ``,
1251
+ ` schema.methods.softDelete = function (`,
1252
+ ` this: HydratedDocument<SoftDeleteFields>,`,
1253
+ ` by?: Actor,`,
1254
+ ` ) {`,
1255
+ ` this.set(deletedPatch(by));`,
1256
+ ` return this.save();`,
1257
+ ` };`,
1258
+ ` schema.methods.restore = function (this: HydratedDocument<SoftDeleteFields>) {`,
1259
+ ` this.set(restoredPatch());`,
1260
+ ` return this.save();`,
1261
+ ` };`,
1262
+ ``,
1263
+ ` schema.statics.softDelete = function (`,
1264
+ ` this: Model<unknown>,`,
1265
+ ` filter: Record<string, unknown>,`,
1266
+ ` by?: Actor,`,
1267
+ ` ) {`,
1268
+ ` return this.updateMany(filter, { $set: deletedPatch(by) });`,
1269
+ ` };`,
1270
+ ` schema.statics.restore = function (`,
1271
+ ` this: Model<unknown>,`,
1272
+ ` filter: Record<string, unknown>,`,
1273
+ ` ) {`,
1274
+ ` // withDeleted so the guard doesn't filter out the very docs we restore.`,
1275
+ ` return this.updateMany(filter, { $set: restoredPatch() }).setOptions({`,
1276
+ ` withDeleted: true,`,
1277
+ ` } as SoftDeleteOptions);`,
1278
+ ` };`,
1279
+ `}`,
1280
+ ``,
1281
+ ].join("\n")],
1282
+ ["modules/core/helpers/mongoose/plugins/base.plugin.ts", [
1283
+ `import type { Schema } from "mongoose";`,
1284
+ ``,
1285
+ `import { softDeletePlugin } from "./soft-delete.plugin.js";`,
1286
+ `import { timestampsPlugin } from "./timestamps.plugin.js";`,
1287
+ ``,
1288
+ `/**`,
1289
+ ` * The base plugin every model gets. Composes the focused plugins so each`,
1290
+ ` * stays single-responsibility; new cross-cutting behaviour (audit trail,`,
1291
+ ` * versioning, …) slots in here without touching call sites.`,
1292
+ ` */`,
1293
+ `export function baseModelPlugin(schema: Schema): void {`,
1294
+ ` schema.plugin(timestampsPlugin);`,
1295
+ ` schema.plugin(softDeletePlugin);`,
1296
+ `}`,
1297
+ ``,
1298
+ ].join("\n")],
1299
+ ["modules/core/helpers/mongoose/register.ts", [
1300
+ `import mongoose from "mongoose";`,
1301
+ ``,
1302
+ `import { baseModelPlugin } from "./plugins/base.plugin.js";`,
1303
+ ``,
1304
+ `let registered = false;`,
1305
+ ``,
1306
+ `/**`,
1307
+ ` * Registers the base plugin globally so EVERY schema gets timestamps + soft`,
1308
+ ` * delete. Global plugins only affect models compiled after registration, so`,
1309
+ ` * call this before boot() — it's the first line of app.ts and worker.ts.`,
1310
+ ` *`,
1311
+ ` * An explicit function, deliberately not an import side effect: the old`,
1312
+ ` * framework's side-effect register module made correctness depend on import`,
1313
+ ` * order, which is exactly the class of bug this framework exists to kill.`,
1314
+ ` */`,
1315
+ `export function registerBasePlugins(): void {`,
1316
+ ` if (registered) return;`,
1317
+ ` registered = true;`,
1318
+ ` mongoose.plugin(baseModelPlugin);`,
1319
+ `}`,
1320
+ ``,
1321
+ ].join("\n")],
1322
+ ["modules/core/helpers/mongoose/index.ts", [
1323
+ `import type { SchemaOptions } from "mongoose";`,
1324
+ ``,
1325
+ `export { baseModelPlugin } from "./plugins/base.plugin.js";`,
1326
+ `export { softDeletePlugin } from "./plugins/soft-delete.plugin.js";`,
1327
+ `export { timestampsPlugin } from "./plugins/timestamps.plugin.js";`,
1328
+ `export { registerBasePlugins } from "./register.js";`,
1329
+ `export type {`,
1330
+ ` Actor,`,
1331
+ ` BaseDocument,`,
1332
+ ` SoftDeleteFields,`,
1333
+ ` SoftDeleteMethods,`,
1334
+ ` Timestamps,`,
1335
+ `} from "./plugins/types.js";`,
1336
+ ``,
1337
+ `/**`,
1338
+ ` * Serialization conventions every model spreads into its schema options.`,
1339
+ ` * Timestamps are NOT set here — the base plugin owns them (snake_case).`,
1340
+ ` */`,
1341
+ `export const baseSchemaOptions: SchemaOptions = {`,
1342
+ ` toJSON: {`,
1343
+ ` versionKey: false,`,
1344
+ ` transform: (_doc, ret: Record<string, unknown>) => {`,
1345
+ ` delete ret._id;`,
1346
+ ` return ret;`,
1347
+ ` },`,
1348
+ ` },`,
1349
+ `};`,
1350
+ ``,
1351
+ ].join("\n")],
1352
+ ["modules/core/helpers/logger/index.ts", [
1353
+ `import { join } from "node:path";`,
1354
+ `// Default import, not the named one: pino.transport lives on the`,
1355
+ `// namespace that only the default export carries.`,
1356
+ `import pino from "pino";`,
1357
+ `import type { Logger, TransportTargetOptions } from "pino";`,
1358
+ `import type { ProcessMode } from "@tulipes/core/boot";`,
1359
+ `import type { Environment } from "@tulipes/core/env";`,
1360
+ ``,
1361
+ `/**`,
1362
+ ` * The app's logger, shared by every module.`,
1363
+ ` *`,
1364
+ ` * Two destinations at once, because they answer different questions:`,
1365
+ ` *`,
1366
+ ` * console pretty and coloured, for a human watching a terminal`,
1367
+ ` * files JSON lines, for grep/jq/Loki/CloudWatch — one object per`,
1368
+ ` * line with a stable shape, which is what makes logs queryable`,
1369
+ ` *`,
1370
+ ` * Files are named per process mode. Backend and worker run at the same`,
1371
+ ` * time, and two processes rotating one file race: both notice the size`,
1372
+ ` * limit, both rename, and one of them loses entries. Separate files cost`,
1373
+ ` * nothing and remove the failure entirely.`,
1374
+ ` */`,
1375
+ `let instance: Logger | undefined;`,
1376
+ ``,
1377
+ `export function logger(environment: Environment, mode: ProcessMode = "backend"): Logger {`,
1378
+ ` if (instance) return instance;`,
1379
+ ``,
1380
+ ` const level = String(environment.get("LOG_LEVEL"));`,
1381
+ ` const targets: TransportTargetOptions[] = [];`,
1382
+ ``,
1383
+ ` if (Boolean(environment.get("LOG_CONSOLE"))) {`,
1384
+ ` targets.push({`,
1385
+ ` target: "pino-pretty",`,
1386
+ ` level,`,
1387
+ ` options: {`,
1388
+ ` colorize: true,`,
1389
+ ` translateTime: "SYS:HH:MM:ss.l",`,
1390
+ ` // pid and hostname matter in aggregated files, not on the terminal`,
1391
+ ` // of the machine that is obviously running it.`,
1392
+ ` ignore: "pid,hostname",`,
1393
+ ` messageFormat: "{if module}[{module}] {end}{msg}",`,
1394
+ ` },`,
1395
+ ` });`,
1396
+ ` }`,
1397
+ ``,
1398
+ ` if (Boolean(environment.get("LOG_FILE"))) {`,
1399
+ ` const dir = String(environment.get("LOG_DIR"));`,
1400
+ ` const rotation = {`,
1401
+ ` // Both limits apply: whichever trips first rotates the file.`,
1402
+ ` frequency: String(environment.get("LOG_ROTATION_FREQUENCY")),`,
1403
+ ` size: String(environment.get("LOG_ROTATION_SIZE")),`,
1404
+ ` limit: { count: Number(environment.get("LOG_RETENTION_FILES")) },`,
1405
+ ` extension: ".log",`,
1406
+ ` dateFormat: "yyyy-MM-dd",`,
1407
+ ` mkdir: true,`,
1408
+ ` };`,
1409
+ ``,
1410
+ ` targets.push({`,
1411
+ ` target: "pino-roll",`,
1412
+ ` level,`,
1413
+ ` options: { ...rotation, file: join(dir, \`\${environment.get("LOG_BASENAME")}-\${mode}\`) },`,
1414
+ ` });`,
1415
+ ``,
1416
+ ` // A second copy of errors only. Everything here is also in the main`,
1417
+ ` // file; the point is a small file to open first when something breaks,`,
1418
+ ` // instead of paging through a day of request logs.`,
1419
+ ` targets.push({`,
1420
+ ` target: "pino-roll",`,
1421
+ ` level: "error",`,
1422
+ ` options: {`,
1423
+ ` ...rotation,`,
1424
+ ` file: join(dir, \`\${environment.get("LOG_ERROR_BASENAME")}-\${mode}\`),`,
1425
+ ` },`,
1426
+ ` });`,
1427
+ ` }`,
1428
+ ``,
1429
+ ` instance = targets.length`,
1430
+ ` ? pino({ level, base: { mode } }, pino.transport({ targets }))`,
1431
+ ` : // Every destination disabled: still return a working logger rather`,
1432
+ ` // than null-checking at a hundred call sites.`,
1433
+ ` pino({ level, base: { mode } });`,
1434
+ ``,
1435
+ ` return instance;`,
1436
+ `}`,
1437
+ ``,
1438
+ `/**`,
1439
+ ` * A logger tagged with the module it belongs to, so a line's origin is`,
1440
+ ` * visible in both destinations: \`[users] ready\` on the console, and a`,
1441
+ ` * queryable \`"module":"users"\` field in the file.`,
1442
+ ` */`,
1443
+ `export function moduleLogger(`,
1444
+ ` environment: Environment,`,
1445
+ ` name: string,`,
1446
+ ` mode: ProcessMode = "backend",`,
1447
+ `): Logger {`,
1448
+ ` return logger(environment, mode).child({ module: name });`,
1449
+ `}`,
1450
+ ``,
1451
+ ].join("\n")],
1452
+ ["modules/core/index.ts", [
1453
+ `/**`,
1454
+ ` * What the core module offers the rest of the app.`,
1455
+ ` *`,
1456
+ ` * Other modules import these by package name — \`@app/core\` — never by a`,
1457
+ ` * relative path climbing out of their own folder. That is what keeps the`,
1458
+ ` * dependency visible: a module that uses this has to declare @app/core,`,
1459
+ ` * so the coupling shows up in its package.json instead of hiding in an`,
1460
+ ` * import string.`,
1461
+ ` */`,
1462
+ `export {`,
1463
+ ` appCache,`,
1464
+ ` closeCache,`,
1465
+ ` BaseCache,`,
1466
+ ` MemoryCache,`,
1467
+ ` RedisCache,`,
1468
+ ` type Cache,`,
1469
+ `} from "./helpers/cache/index.js";`,
1470
+ ``,
1471
+ `export { logger, moduleLogger } from "./helpers/logger/index.js";`,
1472
+ ``,
1473
+ `export {`,
1474
+ ` baseSchemaOptions,`,
1475
+ ` baseModelPlugin,`,
1476
+ ` softDeletePlugin,`,
1477
+ ` timestampsPlugin,`,
1478
+ ` registerBasePlugins,`,
1479
+ ` type Actor,`,
1480
+ ` type BaseDocument,`,
1481
+ ` type SoftDeleteFields,`,
1482
+ ` type SoftDeleteMethods,`,
1483
+ ` type Timestamps,`,
1484
+ `} from "./helpers/mongoose/index.js";`,
1485
+ ``,
1486
+ ].join("\n")],
1487
+ ["modules/core/module.config.ts", [
1488
+ `import type { Ctx } from "@tulipes/core/boot";`,
1489
+ ``,
1490
+ `import { closeCache } from "./helpers/cache/index.js";`,
1491
+ `import { registerBasePlugins } from "./helpers/mongoose/index.js";`,
1492
+ ``,
1493
+ `/**`,
1494
+ ` * Registering here — phase 6, before models compile in phase 8 — is`,
1495
+ ` * what makes the base plugins apply in EVERY mode: backend, worker`,
1496
+ ` * and script alike. Doing it in app.ts instead would leave scripts`,
1497
+ ` * compiling models without timestamps or the soft-delete guard, so a`,
1498
+ ` * script would happily return rows the API considers deleted.`,
1499
+ ` */`,
1500
+ `registerBasePlugins();`,
1501
+ ``,
1502
+ `export default function coreConfig(_ctx: Ctx) {`,
1503
+ ` return {};`,
1504
+ `}`,
1505
+ ``,
1506
+ `/** The sys core module owns app-wide infra glue lifecycles. */`,
1507
+ `export async function onShutdown(): Promise<void> {`,
1508
+ ` await closeCache();`,
1509
+ `}`,
1510
+ ``,
1511
+ ].join("\n")],
842
1512
  ["modules/core/module.acl.ts", [
843
1513
  `import type { AclBuilder } from "@tulipes/core/acl";`,
844
1514
  ``,
@@ -877,27 +1547,26 @@ function renderProject(name, coreVersion) {
877
1547
  version: "0.0.0",
878
1548
  private: true,
879
1549
  type: "module",
880
- tulipes: { tier: "sys", priority: 10 },
1550
+ // dependsOn "core" for the shared logger it imports from @app/core.
1551
+ tulipes: { tier: "sys", priority: 10, dependsOn: ["core"] },
881
1552
  dependencies: {
882
1553
  "@tulipes/core": core,
1554
+ "@app/core": "workspace:*",
883
1555
  cors: "^2",
884
1556
  express: "^5",
885
1557
  helmet: "^8",
886
- pino: "^9",
887
1558
  "pino-http": "^10",
888
- "pino-pretty": "^13",
889
1559
  },
890
1560
  devDependencies: { "@types/cors": "^2" },
891
1561
  })],
892
1562
  ["modules/security/meta.variables.json", json({
893
1563
  variables: [
894
1564
  {
895
- name: "LOG_LEVEL",
896
- type: "enum",
897
- enum: ["fatal", "error", "warn", "info", "debug", "trace"],
898
- group: "logging",
899
- description: "Minimum pino log level",
900
- default: "info",
1565
+ name: "BODY_LIMIT",
1566
+ type: "string",
1567
+ group: "http",
1568
+ description: 'Largest JSON request body express will parse, e.g. "1mb"; nginx client_max_body_size must be at least this',
1569
+ default: "1mb",
901
1570
  },
902
1571
  {
903
1572
  name: "CORS_ORIGINS",
@@ -979,33 +1648,6 @@ function renderProject(name, coreVersion) {
979
1648
  `}`,
980
1649
  ``,
981
1650
  ].join("\n")],
982
- ["modules/security/helpers/logger.ts", [
983
- `import { pino, type Logger } from "pino";`,
984
- `import type { Environment } from "@tulipes/core/env";`,
985
- ``,
986
- `let instance: Logger | undefined;`,
987
- ``,
988
- `/**`,
989
- ` * The app's pino logger — JSON lines in production, pretty-printed in`,
990
- ` * development. Level comes from LOG_LEVEL so a deploy can turn on`,
991
- ` * debug without a code change.`,
992
- ` */`,
993
- `export function logger(environment: Environment): Logger {`,
994
- ` if (instance) return instance;`,
995
- ``,
996
- ` instance = pino({`,
997
- ` level: String(environment.get("LOG_LEVEL")),`,
998
- ` ...(environment.appEnv === "development" && {`,
999
- ` transport: {`,
1000
- ` target: "pino-pretty",`,
1001
- ` options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },`,
1002
- ` },`,
1003
- ` }),`,
1004
- ` });`,
1005
- ` return instance;`,
1006
- `}`,
1007
- ``,
1008
- ].join("\n")],
1009
1651
  ["modules/security/routes/security.routes.ts", [
1010
1652
  `import cors from "cors";`,
1011
1653
  `import express from "express";`,
@@ -1014,12 +1656,12 @@ function renderProject(name, coreVersion) {
1014
1656
  `import type { Ctx } from "@tulipes/core/boot";`,
1015
1657
  ``,
1016
1658
  `import { corsOptions, helmetOptions } from "../helpers/hardening.js";`,
1017
- `import { logger } from "../helpers/logger.js";`,
1659
+ `import { logger } from "@app/core";`,
1018
1660
  ``,
1019
1661
  `/**`,
1020
1662
  ` * The app's request-hardening stack. Sys tier, so this mounts ahead of`,
1021
- ` * every app-tier router — the framework core mounts no middleware of`,
1022
- ` * its own; this module IS the pipeline's head.`,
1663
+ ` * every app-tier router — the framework core mounts no middleware of its`,
1664
+ ` * own; this module IS the pipeline's head.`,
1023
1665
  ` *`,
1024
1666
  ` * Order matters:`,
1025
1667
  ` * helmet — headers on every response, including errors and preflights`,
@@ -1027,15 +1669,21 @@ function renderProject(name, coreVersion) {
1027
1669
  ` * pino — logs the request once the two above have had their say`,
1028
1670
  ` * json — parsing last, so a rejected origin never reaches the parser`,
1029
1671
  ` */`,
1030
- `export default function securityRoutes({ app, Environment, config }: Ctx): void {`,
1672
+ `export default function securityRoutes(ctx: Ctx): void {`,
1673
+ ` const { app, Environment, config, mode } = ctx`,
1674
+ ``,
1031
1675
  ` app!.use(helmet(helmetOptions(Environment)));`,
1032
1676
  ` app!.use(cors(corsOptions(Environment)));`,
1033
1677
  ``,
1034
1678
  ` app!.use(`,
1035
1679
  ` pinoHttp({`,
1036
- ` logger: logger(Environment),`,
1037
- ` // The core module (priority 0) stamps X-Request-Id before this`,
1038
- ` // runs reuse it so logs and response headers tell one story.`,
1680
+ ` logger: logger(Environment, mode),`,
1681
+ ` // Without this a 500 is logged at info, so the errors-only log file`,
1682
+ ` // stays empty exactly when you need it.`,
1683
+ ` customLogLevel: (_req, res, err) =>`,
1684
+ ` err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info",`,
1685
+ ` // The core module (priority 0) stamps X-Request-Id before this runs —`,
1686
+ ` // reuse it so log lines and response headers tell one story.`,
1039
1687
  ` genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),`,
1040
1688
  ` serializers: {`,
1041
1689
  ` req: (req: { method: string; url: string }) => ({`,
@@ -1047,9 +1695,11 @@ function renderProject(name, coreVersion) {
1047
1695
  ` }),`,
1048
1696
  ` );`,
1049
1697
  ``,
1050
- ` // Body-size cap is a security control too — a parser without a limit`,
1051
- ` // is a memory-exhaustion invitation. Tuned from config/app.config.ts.`,
1052
- ` app!.use(express.json({ limit: config.http?.bodyLimit ?? "1mb" }));`,
1698
+ ` // Body-size cap is a security control too — a parser without a limit is`,
1699
+ ` // a memory-exhaustion invitation. Declared by this module, because it`,
1700
+ ` // varies per deployment: an upload-heavy API raises it without touching`,
1701
+ ` // code.`,
1702
+ ` app!.use(express.json({ limit: String(Environment.get("BODY_LIMIT")) }));`,
1053
1703
  `}`,
1054
1704
  ``,
1055
1705
  ].join("\n")],
@@ -1058,9 +1708,10 @@ function renderProject(name, coreVersion) {
1058
1708
  version: "0.0.0",
1059
1709
  private: true,
1060
1710
  type: "module",
1061
- tulipes: { tier: "app", priority: 100, dependsOn: [] },
1711
+ tulipes: { tier: "app", priority: 100, dependsOn: ["core"] },
1062
1712
  dependencies: {
1063
1713
  "@tulipes/core": core,
1714
+ "@app/core": "workspace:*",
1064
1715
  express: "^5",
1065
1716
  mongoose: "^8",
1066
1717
  },
@@ -1226,6 +1877,10 @@ function renderProject(name, coreVersion) {
1226
1877
  `import { Schema } from "mongoose";`,
1227
1878
  `import type { ModelDef } from "@tulipes/core/db";`,
1228
1879
  ``,
1880
+ `// Cross-module import by package name, never a relative path out`,
1881
+ `// of the module — the dependency is declared in package.json.`,
1882
+ `import { baseSchemaOptions } from "@app/core";`,
1883
+ ``,
1229
1884
  `/**`,
1230
1885
  ` * A model file is a DECLARATION, never a registration: the framework`,
1231
1886
  ` * compiles the schema on its own connection and puts it in the model`,
@@ -1239,7 +1894,7 @@ function renderProject(name, coreVersion) {
1239
1894
  ` message: { type: String, required: true },`,
1240
1895
  ` timesUsed: { type: Number, default: 0, min: 0 },`,
1241
1896
  ` },`,
1242
- ` { timestamps: true },`,
1897
+ ` baseSchemaOptions,`,
1243
1898
  `);`,
1244
1899
  ``,
1245
1900
  `export default { name: "Greeting", schema: greetingSchema } satisfies ModelDef;`,
@@ -1257,17 +1912,27 @@ function renderProject(name, coreVersion) {
1257
1912
  `export default async function seedGreetings({ models }: Ctx): Promise<void> {`,
1258
1913
  ` const Greeting = models!.get("Greeting");`,
1259
1914
  ``,
1260
- ` await Greeting.updateOne(`,
1261
- ` { name: "world" },`,
1262
- ` { $setOnInsert: { name: "world", message: "Hello, world!" } },`,
1263
- ` { upsert: true },`,
1264
- ` );`,
1915
+ ` try {`,
1916
+ ` await Greeting.updateOne(`,
1917
+ ` { name: "world" },`,
1918
+ ` { $setOnInsert: { name: "world", message: "Hello, world!" } },`,
1919
+ ` { upsert: true },`,
1920
+ ` );`,
1921
+ ` } catch (error) {`,
1922
+ ` // Bootstrap tasks run in EVERY process, and backend and worker`,
1923
+ ` // start together — concurrent upserts on a unique index race by`,
1924
+ ` // design, and the loser gets a duplicate-key error. The row`,
1925
+ ` // exists either way, which is all this task wanted.`,
1926
+ ` if ((error as { code?: number }).code !== 11000) throw error;`,
1927
+ ` }`,
1265
1928
  `}`,
1266
1929
  ``,
1267
1930
  ].join("\n")], ["modules/hello/queues/hello.queues.ts", [
1268
1931
  `import type { Ctx } from "@tulipes/core/boot";`,
1269
1932
  `import type { QueueRegistry } from "@tulipes/core/queues";`,
1270
1933
  ``,
1934
+ `import { moduleLogger } from "@app/core";`,
1935
+ ``,
1271
1936
  `/**`,
1272
1937
  ` * ONE file describes both sides. The backend process registers the queue`,
1273
1938
  ` * so routes can produce into it; \`yarn worker\` turns the processor`,
@@ -1275,7 +1940,12 @@ function renderProject(name, coreVersion) {
1275
1940
  ` *`,
1276
1941
  ` * Queue names use "." — BullMQ reserves ":" as its redis separator.`,
1277
1942
  ` */`,
1278
- `export default function helloQueues({ models }: Ctx, queues: QueueRegistry): void {`,
1943
+ `export default function helloQueues(ctx: Ctx, queues: QueueRegistry): void {`,
1944
+ ` const { models } = ctx;`,
1945
+ ` // The same logger the backend uses. In the worker process it writes`,
1946
+ ` // to app-worker.log, so the two processes never share a file.`,
1947
+ ` const log = moduleLogger(ctx.Environment, "hello", ctx.mode);`,
1948
+ ``,
1279
1949
  ` queues.define("hello.count-greeting");`,
1280
1950
  ``,
1281
1951
  ` queues.process("hello.count-greeting", async (job) => {`,
@@ -1283,6 +1953,7 @@ function renderProject(name, coreVersion) {
1283
1953
  ` // job.data and re-read state here rather than shipping documents.`,
1284
1954
  ` const { name } = job.data as { name: string };`,
1285
1955
  ` await models!.get("Greeting").updateOne({ name }, { $inc: { timesUsed: 1 } });`,
1956
+ ` log.info({ name }, "counted greeting");`,
1286
1957
  ` return { counted: name };`,
1287
1958
  ` });`,
1288
1959
  `}`,
@@ -1378,6 +2049,35 @@ function renderProject(name, coreVersion) {
1378
2049
  `| \`yarn tulipes new module <name>\` | scaffold a module |`,
1379
2050
  `| \`yarn tulipes update\` | upgrade the framework everywhere it is declared |`,
1380
2051
  ``,
2052
+ `## Logging`,
2053
+ ``,
2054
+ `The logger lives in the core module and is shared by every other one:`,
2055
+ ``,
2056
+ "```ts",
2057
+ `import { moduleLogger } from "@app/core";`,
2058
+ ``,
2059
+ `const log = moduleLogger(ctx.Environment, "billing", ctx.mode);`,
2060
+ `log.info({ invoiceId }, "invoice sent");`,
2061
+ "```",
2062
+ ``,
2063
+ `It writes to two destinations at once, because they answer different`,
2064
+ `questions: a **coloured, human-readable** stream on the console, and`,
2065
+ `**JSON lines** in \`logs/\` — one object per line, which is what makes`,
2066
+ `them greppable with jq or ingestible by a collector. Errors are`,
2067
+ `additionally copied to their own file, so the first thing to open when`,
2068
+ `something breaks is small.`,
2069
+ ``,
2070
+ `Files carry the process mode — \`app-backend.log\`, \`app-worker.log\` —`,
2071
+ `because backend and worker run at the same time and two processes`,
2072
+ `rotating one file will race.`,
2073
+ ``,
2074
+ `Everything is configured from \`modules/core/meta.variables.json\`:`,
2075
+ `\`LOG_LEVEL\`, \`LOG_CONSOLE\`, \`LOG_FILE\`, \`LOG_DIR\`, \`LOG_BASENAME\`,`,
2076
+ `\`LOG_ERROR_BASENAME\`, and rotation via \`LOG_ROTATION_FREQUENCY\`,`,
2077
+ `\`LOG_ROTATION_SIZE\` and \`LOG_RETENTION_FILES\` (how many rotated files`,
2078
+ `to keep). Rotation happens on whichever of frequency or size trips`,
2079
+ `first.`,
2080
+ ``,
1381
2081
  `## Scripts`,
1382
2082
  ``,
1383
2083
  `\`scripts/\` holds one-off tasks — backfills, exports, admin chores.`,