@tulipes/cli 0.1.1 → 0.2.0-rc.2
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/README.md +10 -7
- package/dist/agent.d.ts +12 -0
- package/dist/agent.js +29 -0
- package/dist/agent.js.map +1 -0
- package/dist/app-core.d.ts +8 -6
- package/dist/app-core.js +23 -14
- package/dist/app-core.js.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/bin.js.map +1 -1
- package/dist/config-check.d.ts +8 -0
- package/dist/config-check.js +43 -0
- package/dist/config-check.js.map +1 -0
- package/dist/config-import.d.ts +20 -0
- package/dist/config-import.js +133 -0
- package/dist/config-import.js.map +1 -0
- package/dist/dev.d.ts +4 -4
- package/dist/dev.js +7 -10
- package/dist/dev.js.map +1 -1
- package/dist/init.d.ts +6 -11
- package/dist/init.js +11 -6422
- package/dist/init.js.map +1 -1
- package/dist/inspection.d.ts +9 -8
- package/dist/inspection.js +10 -11
- package/dist/inspection.js.map +1 -1
- package/dist/main.js +82 -11
- package/dist/main.js.map +1 -1
- package/dist/migrate.d.ts +30 -0
- package/dist/migrate.js +772 -0
- package/dist/migrate.js.map +1 -0
- package/dist/new-module.js +84 -43
- package/dist/new-module.js.map +1 -1
- package/dist/routes.d.ts +3 -5
- package/dist/routes.js +4 -6
- package/dist/routes.js.map +1 -1
- package/dist/spec.d.ts +2 -5
- package/dist/spec.js +13 -6
- package/dist/spec.js.map +1 -1
- package/dist/sync.d.ts +3 -11
- package/dist/sync.js +81 -20
- package/dist/sync.js.map +1 -1
- package/dist/template.d.ts +58 -0
- package/dist/template.js +244 -0
- package/dist/template.js.map +1 -0
- package/dist/update.d.ts +8 -3
- package/dist/update.js +111 -38
- package/dist/update.js.map +1 -1
- package/package.json +17 -16
- package/templates/AGENTS.md +68 -31
- package/templates/CLAUDE.md +5 -4
- package/templates/app/.envs/.env.development +18 -0
- package/templates/app/.envs/.env.production +19 -0
- package/templates/app/.envs/.env.staging +19 -0
- package/templates/app/README.md +327 -0
- package/templates/app/app.ts +3 -0
- package/templates/app/config/app.config.ts +73 -0
- package/templates/app/docs/agent-manifest.json +724 -0
- package/templates/app/ecosystem.config.cjs +60 -0
- package/templates/app/modules/agent/controllers/agent.controllers.ts +31 -0
- package/templates/app/modules/agent/i18n/en.json +5 -0
- package/templates/app/modules/agent/i18n/fr.json +5 -0
- package/templates/app/modules/agent/module.acl.ts +14 -0
- package/templates/app/modules/agent/package.json +21 -0
- package/templates/app/modules/agent/routes/agent.routes.ts +29 -0
- package/templates/app/modules/agent/schemas/agent.schemas.ts +9 -0
- package/templates/app/modules/auth/bootstrap/credentials.bootstrap.ts +26 -0
- package/templates/app/modules/auth/bootstrap/seed-admin.bootstrap.ts +44 -0
- package/templates/app/modules/auth/controllers/auth.controllers.ts +312 -0
- package/templates/app/modules/auth/helpers/access-check.ts +42 -0
- package/templates/app/modules/auth/helpers/auth.ts +48 -0
- package/templates/app/modules/auth/helpers/documents.ts +57 -0
- package/templates/app/modules/auth/helpers/passport.ts +180 -0
- package/templates/app/modules/auth/helpers/passwords.ts +58 -0
- package/templates/app/modules/auth/helpers/session-cache.ts +67 -0
- package/templates/app/modules/auth/helpers/tokens.ts +329 -0
- package/templates/app/modules/auth/i18n/en.json +19 -0
- package/templates/app/modules/auth/i18n/fr.json +19 -0
- package/templates/app/modules/auth/lib/index.ts +12 -0
- package/templates/app/modules/auth/models/credential.model.ts +31 -0
- package/templates/app/modules/auth/models/revoked-token.model.ts +20 -0
- package/templates/app/modules/auth/models/session.model.ts +42 -0
- package/templates/app/modules/auth/module.acl.ts +41 -0
- package/templates/app/modules/auth/module.config.ts +30 -0
- package/templates/app/modules/auth/package.json +61 -0
- package/templates/app/modules/auth/routes/auth.routes.ts +94 -0
- package/templates/app/modules/auth/schemas/auth.schemas.ts +37 -0
- package/templates/app/modules/auth/tests/auth.test.ts +1079 -0
- package/templates/app/modules/config/controllers/config.controllers.ts +89 -0
- package/templates/app/modules/config/helpers/audit.ts +41 -0
- package/templates/app/modules/config/helpers/describe.ts +72 -0
- package/templates/app/modules/config/i18n/en.json +6 -0
- package/templates/app/modules/config/i18n/fr.json +6 -0
- package/templates/app/modules/config/models/config-audit.model.ts +26 -0
- package/templates/app/modules/config/module.acl.ts +12 -0
- package/templates/app/modules/config/package.json +23 -0
- package/templates/app/modules/config/routes/config.routes.ts +37 -0
- package/templates/app/modules/config/schemas/config.schemas.ts +13 -0
- package/templates/app/modules/config/tests/config.test.ts +126 -0
- package/templates/app/modules/core/bootstrap/cache.bootstrap.ts +12 -0
- package/templates/app/modules/core/helpers/cache/index.ts +60 -0
- package/templates/app/modules/core/helpers/cache/memory-cache.ts +53 -0
- package/templates/app/modules/core/helpers/cache/redis-cache.ts +68 -0
- package/templates/app/modules/core/helpers/cache/types.ts +37 -0
- package/templates/app/modules/core/helpers/lazy.ts +13 -0
- package/templates/app/modules/core/helpers/logger/index.ts +115 -0
- package/templates/app/modules/core/helpers/mongoose/index.ts +27 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/base.plugin.ts +14 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/soft-delete.plugin.ts +124 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/timestamps.plugin.ts +13 -0
- package/templates/app/modules/core/helpers/mongoose/plugins/types.ts +45 -0
- package/templates/app/modules/core/helpers/mongoose/register.ts +20 -0
- package/templates/app/modules/core/lib/index.ts +34 -0
- package/templates/app/modules/core/module.config.ts +24 -0
- package/templates/app/modules/core/package.json +80 -0
- package/templates/app/modules/core/routes/core.routes.ts +24 -0
- package/templates/app/modules/core/tests/core.test.ts +19 -0
- package/templates/app/modules/hello/agent/hello.agent.ts +33 -0
- package/templates/app/modules/hello/bootstrap/seed-greetings.bootstrap.ts +27 -0
- package/templates/app/modules/hello/controllers/greetings.controllers.ts +43 -0
- package/templates/app/modules/hello/controllers/hello.controllers.ts +50 -0
- package/templates/app/modules/hello/helpers/greeting.ts +8 -0
- package/templates/app/modules/hello/i18n/en.json +8 -0
- package/templates/app/modules/hello/i18n/fr.json +8 -0
- package/templates/app/modules/hello/models/greeting.model.ts +24 -0
- package/templates/app/modules/hello/module.acl.ts +16 -0
- package/templates/app/modules/hello/module.config.ts +14 -0
- package/templates/app/modules/hello/package.json +24 -0
- package/templates/app/modules/hello/queues/hello.queues.ts +38 -0
- package/templates/app/modules/hello/routes/greetings.routes.ts +25 -0
- package/templates/app/modules/hello/routes/hello.routes.ts +44 -0
- package/templates/app/modules/hello/sockets/hello.sockets.ts +21 -0
- package/templates/app/modules/hello/tests/hello.test.ts +19 -0
- package/templates/app/modules/security/bootstrap/rate-limit.bootstrap.ts +8 -0
- package/templates/app/modules/security/helpers/hardening.ts +65 -0
- package/templates/app/modules/security/helpers/proxy.ts +16 -0
- package/templates/app/modules/security/helpers/rate-limit.ts +150 -0
- package/templates/app/modules/security/module.config.ts +59 -0
- package/templates/app/modules/security/package.json +26 -0
- package/templates/app/modules/security/routes/security.routes.ts +42 -0
- package/templates/app/modules/security/tests/security.test.ts +174 -0
- package/templates/app/modules/settings/bootstrap/log-streams.bootstrap.ts +12 -0
- package/templates/app/modules/settings/controllers/settings.controllers.ts +58 -0
- package/templates/app/modules/settings/helpers/access-lease.ts +99 -0
- package/templates/app/modules/settings/helpers/log-stream.ts +272 -0
- package/templates/app/modules/settings/helpers/log-tail.ts +253 -0
- package/templates/app/modules/settings/i18n/en.json +3 -0
- package/templates/app/modules/settings/i18n/fr.json +3 -0
- package/templates/app/modules/settings/module.acl.ts +12 -0
- package/templates/app/modules/settings/package.json +21 -0
- package/templates/app/modules/settings/routes/settings.routes.ts +33 -0
- package/templates/app/modules/settings/schemas/settings.schemas.ts +8 -0
- package/templates/app/modules/settings/tests/settings.test.ts +409 -0
- package/templates/app/modules/users/controllers/users.controllers.ts +113 -0
- package/templates/app/modules/users/i18n/en.json +14 -0
- package/templates/app/modules/users/i18n/fr.json +14 -0
- package/templates/app/modules/users/models/user.model.ts +18 -0
- package/templates/app/modules/users/module.acl.ts +10 -0
- package/templates/app/modules/users/module.config.ts +11 -0
- package/templates/app/modules/users/package.json +24 -0
- package/templates/app/modules/users/queues/users.queues.ts +31 -0
- package/templates/app/modules/users/routes/users.routes.ts +60 -0
- package/templates/app/modules/users/schemas/users.schemas.ts +30 -0
- package/templates/app/modules/users/sockets/users.sockets.ts +18 -0
- package/templates/app/nginx/development.conf +44 -0
- package/templates/app/nginx/production.conf +75 -0
- package/templates/app/nginx/staging.conf +51 -0
- package/templates/app/package.json +58 -0
- package/templates/app/scripts/count-greetings.ts +29 -0
- package/templates/app/scripts/deploy-nginx.ts +143 -0
- package/templates/app/scripts/list-users.ts +34 -0
- package/templates/app/tests/app.test.ts +28 -0
- package/templates/app/tests/support.ts +36 -0
- package/templates/app/tsconfig.json +28 -0
- package/templates/app/vitest.config.ts +6 -0
- package/templates/app/worker.ts +6 -0
- package/templates/claude/skills/tulipes-agent-tool/SKILL.md +87 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +11 -5
- package/templates/claude/skills/tulipes-config/SKILL.md +74 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +38 -25
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +54 -26
- package/templates/claude/skills/tulipes-model/SKILL.md +11 -4
- package/templates/claude/skills/tulipes-module/SKILL.md +11 -10
- package/templates/claude/skills/tulipes-queue/SKILL.md +20 -6
- package/templates/claude/skills/tulipes-settings/SKILL.md +49 -100
- package/templates/claude/skills/tulipes-spec/SKILL.md +10 -12
- package/templates/minimal/README.md +21 -0
- package/templates/minimal/app.ts +3 -0
- package/templates/minimal/config/app.config.ts +16 -0
- package/templates/minimal/modules/agent/module.acl.ts +3 -0
- package/templates/minimal/modules/agent/package.json +14 -0
- package/templates/minimal/modules/agent/routes/agent.routes.ts +20 -0
- package/templates/minimal/modules/core/module.acl.ts +4 -0
- package/templates/minimal/modules/core/package.json +21 -0
- package/templates/minimal/modules/hello/i18n/en.json +3 -0
- package/templates/minimal/modules/hello/module.acl.ts +2 -0
- package/templates/minimal/modules/hello/package.json +11 -0
- package/templates/minimal/modules/hello/routes/health.routes.ts +9 -0
- package/templates/minimal/modules/hello/routes/hello.routes.ts +10 -0
- package/templates/minimal/modules/security/package.json +15 -0
- package/templates/minimal/modules/security/routes/security.routes.ts +8 -0
- package/templates/minimal/package.json +38 -0
- package/templates/minimal/tests/app.test.ts +52 -0
- package/templates/minimal/tsconfig.json +22 -0
- package/templates/minimal/vitest.config.ts +5 -0
- package/templates/overlays/no-queues/ecosystem.config.cjs +52 -0
- package/templates/overlays/no-queues/modules/hello/controllers/greetings.controllers.ts +41 -0
- package/templates/overlays/no-queues/modules/users/controllers/users.controllers.ts +107 -0
- package/templates/versions.json +11 -0
- package/dist/minimal.d.ts +0 -2
- package/dist/minimal.js +0 -2200
- package/dist/minimal.js.map +0 -1
- /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
|
+
}
|