@quatronic/sdk 0.6.1-rc.4 → 0.7.0-rc.6
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 +26 -0
- package/dist/cli/legacy/qdc-sourcemaps.js +237 -0
- package/dist/cli/legacy/qdc-test-report.js +200 -0
- package/dist/cli/skills-postinstall.js +603 -0
- package/dist/cli/skills.js +499 -0
- package/dist/cli/sourcemaps.js +9 -9
- package/dist/cli/test-report.js +2 -2
- package/dist/next/config/index.js +1 -1
- package/dist/next/config/index.js.map +1 -1
- package/package.json +10 -4
- package/postinstall.mjs +9 -0
- package/skills/.gitattributes +1 -0
- package/skills/catalog.json +24 -0
- package/skills/qore-add-api-route/SKILL.md +58 -0
- package/skills/qore-add-connection/SKILL.next.md +149 -0
- package/skills/qore-add-connection/SKILL.node.md +160 -0
- package/skills/qore-add-db-table/SKILL.md +88 -0
- package/skills/qore-add-db-table/references/cross-module-reference.md +74 -0
- package/skills/qore-add-feature/SKILL.md +77 -0
- package/skills/qore-add-integration/SKILL.next.md +217 -0
- package/skills/qore-add-integration/SKILL.node.md +216 -0
- package/skills/qore-add-page/SKILL.md +53 -0
- package/skills/qore-author-skill/SKILL.md +136 -0
- package/skills/qore-debug-issue/SKILL.md +48 -0
- package/skills/qore-enable-auth-method/SKILL.md +90 -0
- package/skills/qore-observability/SKILL.md +89 -0
- package/skills/qore-observability/references/errors.md +87 -0
- package/skills/qore-observability/references/logs.md +69 -0
- package/skills/qore-observability/references/tracing.md +58 -0
- package/skills/qore-recommended-tools/SKILL.md +58 -0
- package/skills/qore-secure-io/SKILL.md +174 -0
- package/skills/qore-ship-checklist/SKILL.md +80 -0
- package/skills/qore-translate-strings/SKILL.md +36 -0
- package/skills/qore-use-addons/SKILL.md +175 -0
- package/skills/qore-use-database/SKILL.md +222 -0
- package/skills/qore-use-design-system/SKILL.md +76 -0
- package/skills/qore-use-mail-domain/SKILL.next.md +67 -0
- package/skills/qore-use-mail-domain/SKILL.node.md +62 -0
- package/skills/qore-use-permissions/SKILL.md +116 -0
- package/skills/qore-verify-user/SKILL.md +56 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-ship-checklist
|
|
3
|
+
description: The quality gate to run before opening a pull request — translate the requirement into acceptance criteria, cover it with tests, get all automated checks green, and run an independent review. Use before committing a finished change or opening a PR.
|
|
4
|
+
allowed-tools: Bash(pnpm:*), Bash(git add:*), Bash(git commit:*), Read, Edit
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Ship checklist
|
|
8
|
+
|
|
9
|
+
Run this before opening a PR. It encodes Quatronic's definition of done.
|
|
10
|
+
|
|
11
|
+
## 1. Acceptance criteria
|
|
12
|
+
|
|
13
|
+
Restate the requirement as concrete, checkable acceptance criteria — what must
|
|
14
|
+
be true for this to be "done". Keep them in the PR description.
|
|
15
|
+
|
|
16
|
+
## 2. Tests for those criteria
|
|
17
|
+
|
|
18
|
+
- **Unit** (`*.test.ts`) for pure logic and schemas — see /qore-add-feature.
|
|
19
|
+
{{#if db}}- **Integration** (`*.integration.test.ts`) for anything whose behaviour lives
|
|
20
|
+
in the database (constraints, upserts, joins).{{#if auth}} Mirror
|
|
21
|
+
`src/features/profile/server/queries.integration.test.ts`; run with
|
|
22
|
+
`pnpm db:up` then `pnpm test:integration`.
|
|
23
|
+
{{else}} Run with
|
|
24
|
+
`pnpm db:up` then `pnpm test:integration`.
|
|
25
|
+
{{/if}}{{else}}- (No database module: integration tests apply once the app gains stateful behaviour.)
|
|
26
|
+
{{/if}}- Write the test first where you can; watch it fail, then make it pass.
|
|
27
|
+
|
|
28
|
+
## 3. All automated checks green
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
{{#if db}}pnpm db:generate && {{/if}}pnpm lint && pnpm format:check && pnpm typecheck && pnpm test:run && pnpm build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
These are exactly what CI runs — `.github/workflows/ci.yml` calls Quatronic's
|
|
35
|
+
shared pipeline, which invokes these same scripts. If any fail locally, the
|
|
36
|
+
PR will be red — fix before pushing.
|
|
37
|
+
|
|
38
|
+
{{#if db}}CI runs `pnpm db:generate` as a drift check and fails when it writes
|
|
39
|
+
anything, so commit every migration it produces under `src/db/migrations`.
|
|
40
|
+
|
|
41
|
+
{{/if}}Also check by hand (the automated gates don't catch everything): no
|
|
42
|
+
third-party UI library imports, no `style={{ ... }}`, no raw/arbitrary color classes
|
|
43
|
+
(`bg-blue-500`, `text-[#1234ff]`) — use semantic tokens
|
|
44
|
+
(`text-destructive`, `bg-muted`, `border-border`) from `src/styles/theme.css`
|
|
45
|
+
instead.
|
|
46
|
+
|
|
47
|
+
If the change calls an external API, check after it deploys that the portal's
|
|
48
|
+
Integrations tab lists every endpoint you call. One missing means its alias or
|
|
49
|
+
path is not written as a literal at the call site — see
|
|
50
|
+
/qore-add-integration step 4.
|
|
51
|
+
|
|
52
|
+
## 4. Independent review
|
|
53
|
+
|
|
54
|
+
- Run `/code-review` and resolve real findings.
|
|
55
|
+
- {{#if auth}}Run `/security-review` — this app handles Keycloak sessions and per-user
|
|
56
|
+
data, so treat anything touching auth, roles, or user-scoped tables as
|
|
57
|
+
high-value.{{else}}Run `/security-review` — treat anything touching external input or
|
|
58
|
+
stored data as high-value.{{/if}} Resolve all critical/high findings before merge.
|
|
59
|
+
{{#if auth=keycloak}}- Added or renamed a role? Say in the PR who should get it on staging and
|
|
60
|
+
production — nobody holds a new role there until someone grants it in the
|
|
61
|
+
portal's Access tab.
|
|
62
|
+
{{/if}}
|
|
63
|
+
|
|
64
|
+
## 5. Observability
|
|
65
|
+
|
|
66
|
+
Walk the new code against `/qore-observability`:
|
|
67
|
+
|
|
68
|
+
- Unexpected failures are left to throw (captured automatically, so no extra capture), no
|
|
69
|
+
error is swallowed without `Sentry.captureException`, and no capture of expected failures
|
|
70
|
+
(validation, not found, denied).
|
|
71
|
+
- Business and audit events logged once with `Sentry.logger`; audit events carry
|
|
72
|
+
`qore.audit.event`{{#if auth}} (login/logout, authorization denials, mutations){{/if}}.
|
|
73
|
+
- Custom attributes are `qore.*` and hold ids, never personal data or secrets.
|
|
74
|
+
- No `console` in runtime code. `pnpm lint` is green.
|
|
75
|
+
|
|
76
|
+
## 6. Commit & PR
|
|
77
|
+
|
|
78
|
+
Small, focused commits on a feature branch. Open a PR (never push to
|
|
79
|
+
`main`/`production` directly) so the change is reviewable, traceable, and
|
|
80
|
+
revertable.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-translate-strings
|
|
3
|
+
description: Add or change user-facing strings the bilingual (NL/EN) way — message keys in both catalogs, resolved with next-intl. Use whenever you write or edit visible copy.
|
|
4
|
+
allowed-tools: Bash(pnpm:*), Edit, Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Translate strings
|
|
8
|
+
|
|
9
|
+
This app is bilingual (Dutch + English) out of the box. Default locale is `nl`;
|
|
10
|
+
the locale lives in a `locale` cookie (no `[locale]` in the URL). Never hardcode
|
|
11
|
+
user-facing copy — every visible string is a key in `messages/nl.json` and
|
|
12
|
+
`messages/en.json`.
|
|
13
|
+
|
|
14
|
+
## Adding or changing a string
|
|
15
|
+
|
|
16
|
+
1. Pick a namespace + key (e.g. `profile.displayName`). Reuse generic keys in
|
|
17
|
+
`common` (`save`, `saved`, `cancel`) instead of duplicating.
|
|
18
|
+
2. Add the SAME key to BOTH `messages/nl.json` and `messages/en.json`. The
|
|
19
|
+
vitest parity test fails if the key sets differ.
|
|
20
|
+
3. Use it:
|
|
21
|
+
- **Client or server component:** `const t = useTranslations("profile")` then
|
|
22
|
+
`t("displayName")`. (`useTranslations` works in both in next-intl v4.)
|
|
23
|
+
- **Dynamic / full-path key** (e.g. a value coming from a server action):
|
|
24
|
+
`const tRoot = useTranslations()` then `tRoot(fullKey)`.
|
|
25
|
+
- **Metadata / outside the render tree:** `getTranslations` from
|
|
26
|
+
`next-intl/server`.
|
|
27
|
+
4. **Zod validation messages:** store the full message key as the zod message
|
|
28
|
+
(`.min(1, "profile.displayNameRequired")`) and resolve it where the form
|
|
29
|
+
shows `state.message` with a root translator. Mirror `src/features/profile/`.
|
|
30
|
+
|
|
31
|
+
## Verify
|
|
32
|
+
|
|
33
|
+
- `pnpm test:run` — runs the parity test (`src/i18n/messages.test.ts`).
|
|
34
|
+
- `pnpm typecheck` — typed messages (`global.d.ts`) catch mistyped keys.
|
|
35
|
+
|
|
36
|
+
Both run as part of /qore-ship-checklist.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-use-addons
|
|
3
|
+
description: Read the credentials of an addon (Postgres, Redis, object storage) that THIS module provisions in the portal. Use when your code needs to talk to a database, cache, or S3 bucket that you added to this module — covers the exact env-var names and how to read them.
|
|
4
|
+
allowed-tools: Bash(pnpm:*), Edit, Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Use an addon
|
|
8
|
+
|
|
9
|
+
An **addon** is infrastructure the portal provisions for *this* module — a
|
|
10
|
+
Postgres database, a Redis cache, or an S3-compatible object store. You add it
|
|
11
|
+
in the portal (the module's Resources tab); the portal then binds its
|
|
12
|
+
credentials into the module's secret group for every environment, so the values
|
|
13
|
+
land in `process.env` at runtime. You never copy a secret by hand.
|
|
14
|
+
Adding or removing a resource also restarts the module automatically, so the
|
|
15
|
+
env vars are live right away.
|
|
16
|
+
|
|
17
|
+
The credentials arrive under **stable, addon-independent env-var names** (the
|
|
18
|
+
portal aliases Northflank's raw keys to these). Read these names — not the
|
|
19
|
+
`NF_*` keys you see in the Northflank dashboard:
|
|
20
|
+
|
|
21
|
+
| Addon | Env var(s) your code reads |
|
|
22
|
+
|-------|----------------------------|
|
|
23
|
+
| **Postgres** | `DATABASE_URL` |
|
|
24
|
+
| **Redis** | `REDIS_URL` |
|
|
25
|
+
| **Object storage** (S3 / MinIO) | `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
|
|
26
|
+
|
|
27
|
+
> These keys only exist once you've added the addon to this module in the
|
|
28
|
+
> portal. Until then they're absent — that's why every read below fails fast
|
|
29
|
+
> with a clear message instead of a mystery `undefined`.
|
|
30
|
+
>
|
|
31
|
+
> Need credentials for an addon owned by **another** module? That's a
|
|
32
|
+
> connection, not an addon — use `/qore-add-connection` (the keys are
|
|
33
|
+
> `CONN_<ALIAS>_*` and you read them via the SDK), not this skill.
|
|
34
|
+
|
|
35
|
+
## 1. Declare the env keys
|
|
36
|
+
|
|
37
|
+
Add the keys for the addons you use to the server schema in
|
|
38
|
+
`src/config/env.ts`, so a missing binding is a clear startup error:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// inside serverSchema = z.object({ ... })
|
|
42
|
+
DATABASE_URL: z.string().min(1), // Postgres
|
|
43
|
+
REDIS_URL: z.string().url(), // Redis
|
|
44
|
+
S3_ENDPOINT: z.string().url(), // Object storage
|
|
45
|
+
S3_ACCESS_KEY_ID: z.string().min(1),
|
|
46
|
+
S3_SECRET_ACCESS_KEY: z.string().min(1),
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`DATABASE_URL` is already declared when this app was generated with a database.
|
|
50
|
+
Add the others only for the addons you actually provisioned.
|
|
51
|
+
|
|
52
|
+
## 2. Postgres — use the existing client
|
|
53
|
+
|
|
54
|
+
{{#if db}}The database client already reads `DATABASE_URL` for you. Don't build a new
|
|
55
|
+
pool; use the shared one:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { getDb } from "@/lib/db"
|
|
59
|
+
|
|
60
|
+
const db = getDb() // Drizzle over a lazily-created pg.Pool on DATABASE_URL
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To add a table, use `/qore-add-db-table`.
|
|
64
|
+
{{else}}(This app has no Postgres module — if you add the Postgres addon here, read it
|
|
65
|
+
via `DATABASE_URL` with your own client.)
|
|
66
|
+
{{/if}}
|
|
67
|
+
|
|
68
|
+
## 3. Redis
|
|
69
|
+
|
|
70
|
+
Install a client (`pnpm add ioredis`), then connect lazily — never at module
|
|
71
|
+
scope, or `next build` (which imports modules without runtime env) breaks:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// src/lib/redis.ts
|
|
75
|
+
import Redis from "ioredis"
|
|
76
|
+
import { serverEnv } from "@/config/env"
|
|
77
|
+
|
|
78
|
+
let _redis: Redis | undefined
|
|
79
|
+
|
|
80
|
+
export function getRedis(): Redis {
|
|
81
|
+
if (!_redis) _redis = new Redis(serverEnv().REDIS_URL)
|
|
82
|
+
return _redis
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Before you can run this locally**, `REDIS_URL` doesn't exist yet — the
|
|
87
|
+
generator can't scaffold it into `.env.example` because the addon is added
|
|
88
|
+
later, in the portal. Do this now, not as an afterthought:
|
|
89
|
+
|
|
90
|
+
- Run a Redis yourself (e.g. `docker run -p 6379:6379 redis`), then add
|
|
91
|
+
`REDIS_URL=redis://localhost:6379` to `.env.local`.
|
|
92
|
+
- Or, if you won't run one locally, declare the key as `.optional()` in
|
|
93
|
+
`env.ts` and guard the code path instead — the schema is all-or-nothing, so
|
|
94
|
+
a *required* key that's absent locally crashes every `serverEnv()` call,
|
|
95
|
+
not just the addon feature.
|
|
96
|
+
|
|
97
|
+
## 4. Object storage (S3 / MinIO)
|
|
98
|
+
|
|
99
|
+
The store is S3-compatible, so use the AWS SDK pointed at `S3_ENDPOINT`
|
|
100
|
+
(`pnpm add @aws-sdk/client-s3`). MinIO needs `forcePathStyle: true`:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// src/lib/storage.ts
|
|
104
|
+
import { CreateBucketCommand, S3Client } from "@aws-sdk/client-s3"
|
|
105
|
+
import { serverEnv } from "@/config/env"
|
|
106
|
+
|
|
107
|
+
// The MinIO addon is exclusive to this module+environment and ships WITHOUT a
|
|
108
|
+
// bucket, so we create one on first use under a fixed name. No S3_BUCKET env
|
|
109
|
+
// var exists or is needed.
|
|
110
|
+
export const BUCKET = "app"
|
|
111
|
+
|
|
112
|
+
let _s3: S3Client | undefined
|
|
113
|
+
let _ensured: Promise<void> | undefined
|
|
114
|
+
|
|
115
|
+
export function getS3(): S3Client {
|
|
116
|
+
if (!_s3) {
|
|
117
|
+
const env = serverEnv()
|
|
118
|
+
_s3 = new S3Client({
|
|
119
|
+
endpoint: env.S3_ENDPOINT,
|
|
120
|
+
region: "us-east-1", // ignored by MinIO, required by the SDK
|
|
121
|
+
forcePathStyle: true,
|
|
122
|
+
credentials: {
|
|
123
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
124
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
return _s3
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ensureBucket(): Promise<void> {
|
|
132
|
+
if (!_ensured) {
|
|
133
|
+
_ensured = getS3()
|
|
134
|
+
.send(new CreateBucketCommand({ Bucket: BUCKET }))
|
|
135
|
+
.then(() => undefined)
|
|
136
|
+
.catch((err: unknown) => {
|
|
137
|
+
const name = (err as { name?: string })?.name
|
|
138
|
+
if (name === "BucketAlreadyOwnedByYou" || name === "BucketAlreadyExists") return
|
|
139
|
+
_ensured = undefined // allow retry on a real failure
|
|
140
|
+
throw err
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return _ensured
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Await `ensureBucket()` before the first storage operation, then use the fixed
|
|
148
|
+
bucket:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { PutObjectCommand } from "@aws-sdk/client-s3"
|
|
152
|
+
import { BUCKET, ensureBucket, getS3 } from "@/lib/storage"
|
|
153
|
+
|
|
154
|
+
await ensureBucket()
|
|
155
|
+
await getS3().send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body }))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Before you can run this locally**, same story as Redis — `S3_ENDPOINT` /
|
|
159
|
+
`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` aren't in `.env.example`. Run a
|
|
160
|
+
MinIO yourself (e.g. `docker run -p 9000:9000 quay.io/minio/minio server /data`) and
|
|
161
|
+
add the three keys to `.env.local`, or declare them `.optional()` and guard
|
|
162
|
+
the code path if you won't run one locally.
|
|
163
|
+
|
|
164
|
+
## Local development recap
|
|
165
|
+
|
|
166
|
+
{{#if db}}- **Postgres** — `pnpm dev` uses the Postgres from `docker compose up -d`;
|
|
167
|
+
`DATABASE_URL` is already in `.env.example`.
|
|
168
|
+
{{/if}}- **Redis / object storage** — see the "before you can run this locally" note
|
|
169
|
+
in steps 3/4 above — set the matching keys in `.env.local`, they are not
|
|
170
|
+
scaffolded for you.
|
|
171
|
+
|
|
172
|
+
## Ship it
|
|
173
|
+
|
|
174
|
+
Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run &&
|
|
175
|
+
pnpm build` — then `/qore-ship-checklist` before a PR.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-use-database
|
|
3
|
+
description: How this module's Postgres database works — where DATABASE_URL comes from, running the database locally, adding a client (pg or Drizzle), and applying migrations. Use when reading or writing data, adding a table, or debugging a connection error.
|
|
4
|
+
allowed-tools: Bash(pnpm:*), Bash(docker:*), Read, Edit, Write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Using this module's database
|
|
8
|
+
|
|
9
|
+
This module owns a Postgres database. The platform provisions it and injects the
|
|
10
|
+
connection string; the module brings its own client. Nothing is pre-wired — this
|
|
11
|
+
skill is the whole contract.
|
|
12
|
+
|
|
13
|
+
## 1. Where `DATABASE_URL` comes from
|
|
14
|
+
|
|
15
|
+
`src/config/env.ts` requires `DATABASE_URL` (`z.string().min(1)`), so the service
|
|
16
|
+
refuses to boot without it.
|
|
17
|
+
|
|
18
|
+
| Where | How it arrives |
|
|
19
|
+
|-------|----------------|
|
|
20
|
+
| **Local** | `pnpm bootstrap` (or `qore env pull`) fetches it from the portal into `.env`. It points at the docker-compose database below on this module's own port: `postgresql://postgres:postgres@localhost:<DB_PORT>/app`, with `DB_PORT` written alongside it |
|
|
21
|
+
| **staging / prod** | The portal injects the Northflank Postgres addon's own connection string per environment. Different host, database and credentials than local — never assume the local values. |
|
|
22
|
+
|
|
23
|
+
Rules: never hardcode a connection string, never commit `.env` (it is gitignored),
|
|
24
|
+
never read `process.env.DATABASE_URL` directly — import `env` from
|
|
25
|
+
`./config/env.js` so a missing value fails fast at boot instead of at first query.
|
|
26
|
+
|
|
27
|
+
If `pnpm dev` dies on `DATABASE_URL: Required`, your `.env` is stale: re-run
|
|
28
|
+
`qore env pull` (or `qore run -- pnpm dev` to skip the file).
|
|
29
|
+
|
|
30
|
+
## 2. Running the database locally
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pnpm db:up # start Postgres (docker compose up -d)
|
|
34
|
+
pnpm db:down # stop it, keep the data
|
|
35
|
+
pnpm db:reset # destroy the volume and start clean
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`docker-compose.yml` creates database `app` with user/password `postgres`.
|
|
39
|
+
`pnpm bootstrap` already runs `db:up`. The host port is `DB_PORT` from `.env`:
|
|
40
|
+
the portal assigns each module its own, so two modules with a database never
|
|
41
|
+
fight over a port. `ECONNREFUSED 127.0.0.1:<DB_PORT>` usually
|
|
42
|
+
means the container is not running — `pnpm db:up` — but `db:up` is `docker
|
|
43
|
+
compose up -d` with no health wait, so the same error is also normal for a
|
|
44
|
+
second or two right after starting; check `docker compose ps` / the compose
|
|
45
|
+
healthcheck, or just retry.
|
|
46
|
+
|
|
47
|
+
Don't change `DB_PORT` or the port in `DATABASE_URL` by hand: both are
|
|
48
|
+
portal-managed, and `qore env pull` overwrites them with the assigned values.
|
|
49
|
+
If the port is taken anyway, something else is listening on it — stop that, or
|
|
50
|
+
ask for the module's local ports to be reassigned in the portal.
|
|
51
|
+
|
|
52
|
+
A psql shell into the running container:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
docker compose exec db psql -U postgres -d app
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 3. Adding a client
|
|
59
|
+
|
|
60
|
+
Pick one and stay with it. Both are traced automatically: the SDK's Sentry
|
|
61
|
+
register instruments `pg`, so every query becomes a span — Drizzle included.
|
|
62
|
+
|
|
63
|
+
### Option A — `pg` (default: no ORM, plain SQL)
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
pnpm add pg && pnpm add -D @types/pg
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Create `src/lib/db.ts` — one pool per process, created at module load;
|
|
70
|
+
connections are opened on demand as queries need them. Never open a client
|
|
71
|
+
per request:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { Pool, type QueryResultRow } from "pg"
|
|
75
|
+
import { resolvePostgresConfig } from "@quatronic/sdk/node/db"
|
|
76
|
+
import { env } from "../config/env.js"
|
|
77
|
+
|
|
78
|
+
// One pool per process. Never configure `ssl` by hand here — always spread
|
|
79
|
+
// resolvePostgresConfig(): it owns the TLS decision (no TLS for local
|
|
80
|
+
// docker-compose, encrypted-but-unverified for Northflank's managed Postgres,
|
|
81
|
+
// which terminates TLS with its own private CA, and full certificate
|
|
82
|
+
// verification for any external database) and it strips the ssl params out of
|
|
83
|
+
// the URL, without which `pg` silently overrides whatever `ssl` you pass —
|
|
84
|
+
// it parses the connection string after the client options.
|
|
85
|
+
const pool = new Pool({ ...resolvePostgresConfig(env.DATABASE_URL), max: 10 })
|
|
86
|
+
|
|
87
|
+
export function query<T extends QueryResultRow>(
|
|
88
|
+
sql: string,
|
|
89
|
+
params: unknown[] = [],
|
|
90
|
+
): Promise<T[]> {
|
|
91
|
+
return pool.query<T>(sql, params).then((r) => r.rows)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Run several statements on one connection inside a transaction. */
|
|
95
|
+
export async function transaction<T>(fn: (c: import("pg").PoolClient) => Promise<T>): Promise<T> {
|
|
96
|
+
const client = await pool.connect()
|
|
97
|
+
try {
|
|
98
|
+
await client.query("BEGIN")
|
|
99
|
+
const out = await fn(client)
|
|
100
|
+
await client.query("COMMIT")
|
|
101
|
+
return out
|
|
102
|
+
} catch (err) {
|
|
103
|
+
await client.query("ROLLBACK")
|
|
104
|
+
throw err
|
|
105
|
+
} finally {
|
|
106
|
+
client.release()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function closePool(): Promise<void> {
|
|
111
|
+
return pool.end()
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Always parameterise — `query("select * from widgets where id = $1", [id])`.
|
|
116
|
+
String-interpolating a value into SQL is an injection bug; there are no
|
|
117
|
+
exceptions to this.
|
|
118
|
+
|
|
119
|
+
Close the pool on shutdown, in `src/index.ts`:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { closePool } from "./lib/db.js"
|
|
123
|
+
|
|
124
|
+
process.on("SIGTERM", () => {
|
|
125
|
+
server.close(() => {
|
|
126
|
+
void closePool().finally(() => process.exit(0))
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Option B — Drizzle (typed schema + generated migrations)
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
pnpm add drizzle-orm pg && pnpm add -D drizzle-kit @types/pg tsx
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Then follow the frontend convention: `src/db/schema.ts` defines tables on
|
|
138
|
+
`pgSchema("app")`, `drizzle.config.ts` points `out` at `src/db/migrations`, and
|
|
139
|
+
`drizzle-kit generate` writes the SQL. Commit the generated SQL and `meta/` and
|
|
140
|
+
never hand-edit them — change `schema.ts` and regenerate.
|
|
141
|
+
|
|
142
|
+
Drizzle runs on a `pg` pool, so build it exactly like option A —
|
|
143
|
+
`new Pool({ ...resolvePostgresConfig(env.DATABASE_URL), max: 10 })` — and pass
|
|
144
|
+
that pool to `drizzle()`. The same rule applies: never set `ssl` yourself.
|
|
145
|
+
|
|
146
|
+
If the service dies at boot with `Error: self-signed certificate`, this is what
|
|
147
|
+
was skipped: something is verifying Northflank's private CA, which can never
|
|
148
|
+
succeed.
|
|
149
|
+
|
|
150
|
+
## 4. Migrations
|
|
151
|
+
|
|
152
|
+
There is no migration runner in this module; that is deliberate — you choose the
|
|
153
|
+
tool. Whatever you pick, the rules are the same:
|
|
154
|
+
|
|
155
|
+
- Migrations are **files in the repo**, applied automatically at boot. Do not
|
|
156
|
+
create tables by hand in a psql shell: staging and prod have databases you
|
|
157
|
+
cannot reach.
|
|
158
|
+
- Forward-only. Never edit a migration that has already run in an environment —
|
|
159
|
+
add a new one.
|
|
160
|
+
|
|
161
|
+
Minimal plain-SQL runner if you took option A. Add `src/db/migrations/0001_init.sql`,
|
|
162
|
+
then `src/lib/migrate.ts`:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
166
|
+
import { join } from "node:path"
|
|
167
|
+
import * as Sentry from "@sentry/node"
|
|
168
|
+
import { transaction } from "./db.js"
|
|
169
|
+
|
|
170
|
+
// dist/lib/migrate.js runs from dist/, and tsc never copies .sql files — the
|
|
171
|
+
// build script must copy src/db/migrations into dist/db/migrations, e.g.:
|
|
172
|
+
// "build": "tsc -p tsconfig.json && cp -R src/db/migrations dist/db/migrations"
|
|
173
|
+
// Without that step this throws ENOENT, in local dev (`pnpm dev`/`pnpm start`
|
|
174
|
+
// run dist/) and in the container alike (the Dockerfile copies dist/ wholesale).
|
|
175
|
+
const MIGRATIONS_DIR = join(import.meta.dirname, "../db/migrations")
|
|
176
|
+
|
|
177
|
+
export async function runMigrations(): Promise<void> {
|
|
178
|
+
const files = (await readdir(MIGRATIONS_DIR)).filter((f) => f.endsWith(".sql")).sort()
|
|
179
|
+
await transaction(async (client) => {
|
|
180
|
+
// Serialise concurrent boots (two replicas start at once) on one advisory
|
|
181
|
+
// lock, taken BEFORE the table it protects — otherwise two replicas can
|
|
182
|
+
// still race the create-table statement itself.
|
|
183
|
+
await client.query("select pg_advisory_xact_lock(hashtext('schema_migrations'))")
|
|
184
|
+
await client.query(
|
|
185
|
+
"create table if not exists schema_migrations (name text primary key, applied_at timestamptz not null default now())",
|
|
186
|
+
)
|
|
187
|
+
const { rows } = await client.query<{ name: string }>("select name from schema_migrations")
|
|
188
|
+
const applied = new Set(rows.map((r) => r.name))
|
|
189
|
+
for (const name of files.filter((f) => !applied.has(f))) {
|
|
190
|
+
await client.query(await readFile(join(MIGRATIONS_DIR, name), "utf8"))
|
|
191
|
+
await client.query("insert into schema_migrations (name) values ($1)", [name])
|
|
192
|
+
Sentry.logger.info("migration applied", { "qore.migration.name": name })
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
This runner wraps *all* pending migrations in a single transaction, so a
|
|
199
|
+
statement that cannot run inside a transaction block (e.g. `CREATE INDEX
|
|
200
|
+
CONCURRENTLY`, or `ALTER TYPE … ADD VALUE` on older Postgres versions) will
|
|
201
|
+
fail. Such a migration needs to run outside the shared transaction — give it
|
|
202
|
+
its own connection/transaction rather than adding it to the loop above.
|
|
203
|
+
|
|
204
|
+
Call it in `src/index.ts` **before** `server.listen` and let a failure crash the
|
|
205
|
+
process — a service running against an unmigrated database is worse than one that
|
|
206
|
+
does not start:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
await runMigrations()
|
|
210
|
+
server.listen(env.PORT, () => { /* ... */ })
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## 5. Sharing this database with another module
|
|
214
|
+
|
|
215
|
+
Do not hand your `DATABASE_URL` to another module. The portal models this as a
|
|
216
|
+
connection: the consumer gets `CONN_<ALIAS>_URI` / `_HOST` / `_USER` / … for your
|
|
217
|
+
resource. See the `qore-add-connection` skill.
|
|
218
|
+
|
|
219
|
+
## 6. Before you ship
|
|
220
|
+
|
|
221
|
+
`pnpm lint && pnpm typecheck && pnpm test:run && pnpm build`, and check that a fresh clone works:
|
|
222
|
+
`pnpm db:reset && pnpm bootstrap && pnpm build && pnpm dev`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-use-design-system
|
|
3
|
+
description: Use this app's house component kit and design tokens. Use whenever building or restyling UI — pages, forms, tables, dialogs — so the app stays visually consistent.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Use the design system
|
|
7
|
+
|
|
8
|
+
This app ships a house component kit in `src/components/ui/` (built on shadcn/ui
|
|
9
|
+
+ Radix primitives) and a token canon in `src/styles/theme.css`. Build UI on
|
|
10
|
+
top of it instead of reaching for raw colors or one-off markup.
|
|
11
|
+
|
|
12
|
+
## Rules
|
|
13
|
+
|
|
14
|
+
1. **Import UI from `@/components/ui`** — the barrel — never a deep path like
|
|
15
|
+
`@/components/ui/Button`. (`eslint-plugin-no-restricted-imports` enforces
|
|
16
|
+
this: only the kit's own files are exempt.) Never import a UI library
|
|
17
|
+
directly in pages/features either.
|
|
18
|
+
2. **Use canon tokens only.** The semantic set from `theme.css`: `background`/
|
|
19
|
+
`foreground`, `card`(+`-foreground`), `popover`(+`-foreground`), `primary`
|
|
20
|
+
(+`-foreground`), `secondary`(+`-foreground`), `muted`(+`-foreground`),
|
|
21
|
+
`accent`(+`-foreground`), `destructive`(+`-foreground`), `border`, `input`,
|
|
22
|
+
`ring`, `radius` — e.g. `bg-card`, `text-muted-foreground`,
|
|
23
|
+
`border-border`. Never raw palette colors (`text-red-500`, `bg-[#...]`) and
|
|
24
|
+
never the `style={{}}` prop — both are ESLint errors. Layout/spacing
|
|
25
|
+
utilities (`flex`, `gap-4`, `p-6`, …) are fine everywhere.
|
|
26
|
+
3. **Reach for layout primitives first** — `PageContainer`, `PageHeader`,
|
|
27
|
+
`PageHero`, `EmptyState`, `ErrorState`, `LoadingState`, `SectionCard` —
|
|
28
|
+
before composing something new.
|
|
29
|
+
4. **Re-theme by editing `src/styles/theme.css`** — the single swap-point
|
|
30
|
+
(`:root` + `.dark`) — not by touching component internals or sprinkling
|
|
31
|
+
colors into features.
|
|
32
|
+
5. **Feature-specific tokens** (status colors, severity, etc.) go in a
|
|
33
|
+
co-located `tokens.ts`, imported — never duplicated inline.
|
|
34
|
+
|
|
35
|
+
## Missing a component?
|
|
36
|
+
|
|
37
|
+
If the kit lacks a wrapper you need, add it to `src/components/ui/` in the
|
|
38
|
+
same thin-wrapper style (with a co-located `*.test.tsx`) and export it from
|
|
39
|
+
`index.ts` — don't drop a raw UI library import into a feature. The kit is
|
|
40
|
+
yours to grow; keep features importing only from the barrel.
|
|
41
|
+
|
|
42
|
+
## React-Aria-style compat props
|
|
43
|
+
|
|
44
|
+
Several components preserve React-Aria-style prop names on top of their
|
|
45
|
+
native/Radix API for continuity: `onPress`, `isDisabled`, `isLoading`,
|
|
46
|
+
`isInvalid`, `isRequired`, `onSelectionChange`, `isOpen`/`onOpenChange`. Prefer
|
|
47
|
+
these where a component supports them instead of mixing in native DOM handler
|
|
48
|
+
names.
|
|
49
|
+
|
|
50
|
+
Note the split on overlays: `Modal` is controlled via `isOpen`/`onOpenChange`,
|
|
51
|
+
while `Sheet` uses Radix's native `open`/`onOpenChange` — don't mix them up.
|
|
52
|
+
|
|
53
|
+
## Available components
|
|
54
|
+
|
|
55
|
+
Form/controls: `Button` · `Field`/`Input`/`Textarea` · `Select` · `Checkbox`/
|
|
56
|
+
`CheckboxGroup` · `SearchInput` · `FilterPills`.
|
|
57
|
+
|
|
58
|
+
Data display: `Badge` · `StatCard` · `DataTable`/`DataRow` · `Table` · `Tabs` ·
|
|
59
|
+
`Card`/`CardHeader`/`CardFooter`/`CardTitle`/`CardAction`/`CardDescription`/
|
|
60
|
+
`CardContent` · `Avatar`/`AvatarImage`/`AvatarFallback` · `Skeleton` ·
|
|
61
|
+
`Spinner` · `Link`.
|
|
62
|
+
|
|
63
|
+
Overlays: `Modal` · `Sheet` (+ `SheetTrigger`/`SheetClose`/`SheetContent`/
|
|
64
|
+
`SheetHeader`/`SheetFooter`/`SheetTitle`/`SheetDescription`) · `DropdownMenu`
|
|
65
|
+
(+ `DropdownMenuTrigger`/`Content`/`Group`/`Label`/`Item`/`CheckboxItem`/
|
|
66
|
+
`RadioGroup`/`RadioItem`/`Separator`/`Shortcut`/`Sub`/`SubTrigger`/
|
|
67
|
+
`SubContent`/`Portal`) · `Popover` (+ `PopoverAnchor`/`PopoverContent`/
|
|
68
|
+
`PopoverDescription`/`PopoverHeader`/`PopoverTitle`/`PopoverTrigger`) ·
|
|
69
|
+
`Tooltip` (+ `TooltipProvider`/`TooltipRoot`/`TooltipTrigger`/
|
|
70
|
+
`TooltipContent`).
|
|
71
|
+
|
|
72
|
+
Layout: `PageContainer` · `PageHeader` · `PageHero` · `SectionCard` ·
|
|
73
|
+
`EmptyState` · `ErrorState` · `LoadingState`.
|
|
74
|
+
|
|
75
|
+
All exported from `src/components/ui/index.ts` — check there for the
|
|
76
|
+
authoritative, up-to-date list.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: qore-use-mail-domain
|
|
3
|
+
description: Send email through a mail domain linked to this module in the portal, using MAIL_API_KEY/MAIL_SENDING_DOMAIN/MAIL_FROM. Use when this app needs to send its own business email (notifications, receipts, alerts) — not auth email, which next-betterauth already handles separately.
|
|
4
|
+
allowed-tools: Bash(pnpm:*), Edit, Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Use a linked mail domain
|
|
8
|
+
|
|
9
|
+
A **mail domain** is a sending domain the tenant links to *this* module in the
|
|
10
|
+
portal (Module → Mail tab — pick a sending domain, a local part, and a sender
|
|
11
|
+
name). Linking pushes three env vars into this module's `stag`/`prod`
|
|
12
|
+
environments:
|
|
13
|
+
|
|
14
|
+
| Env var | What it is |
|
|
15
|
+
|---------|------------|
|
|
16
|
+
| `MAIL_API_KEY` | a domain-scoped token for sending mail |
|
|
17
|
+
| `MAIL_SENDING_DOMAIN` | the domain, e.g. `acme.nl` — informational only, already baked into `MAIL_FROM` |
|
|
18
|
+
| `MAIL_FROM` | the full from-header, e.g. `Acme Support <support@acme.nl>` |
|
|
19
|
+
|
|
20
|
+
> These are system-managed: they never appear in `.env.example`, never show up
|
|
21
|
+
> in this module's Secrets tab, and you cannot set them yourself. Until a
|
|
22
|
+
> domain is linked they're simply absent.
|
|
23
|
+
|
|
24
|
+
## Hard rule: never present in `local` or preview
|
|
25
|
+
|
|
26
|
+
Unlike an addon (Redis, S3 — see `/qore-use-addons`) there is no way to
|
|
27
|
+
stand up a real sending domain on your laptop or in a preview deploy.
|
|
28
|
+
`MAIL_*` is **pushed into every deployed environment (today: `stag` and
|
|
29
|
+
`prod`) — never `local`, never preview**. Don't go looking for a way to test
|
|
30
|
+
a real send outside deployed environments — `sendMail` already handles this
|
|
31
|
+
by falling back to a warning log, and that fallback is correct behaviour,
|
|
32
|
+
not a placeholder to replace later.
|
|
33
|
+
|
|
34
|
+
## Send mail
|
|
35
|
+
|
|
36
|
+
Use `sendMail` from `src/lib/mail.ts`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { sendMail } from "@/lib/mail"
|
|
40
|
+
|
|
41
|
+
await sendMail({
|
|
42
|
+
to: user.email,
|
|
43
|
+
subject: "Your export is ready",
|
|
44
|
+
html: "<p>...</p>",
|
|
45
|
+
text: "...",
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
It reads `MAIL_API_KEY`/`MAIL_FROM` from `process.env` and calls Resend's send
|
|
50
|
+
API. When `MAIL_API_KEY` is absent it logs `mail skipped` via
|
|
51
|
+
`Sentry.logger.warn` (no recipient, subject or body) and returns, so
|
|
52
|
+
local/preview dev works without a real domain.
|
|
53
|
+
|
|
54
|
+
Worked example: `docs/examples/mail.example.ts`.
|
|
55
|
+
|
|
56
|
+
## This is not auth email
|
|
57
|
+
|
|
58
|
+
If this app has Better Auth (`next-betterauth`), verification and magic-link
|
|
59
|
+
email already flow through `src/lib/email/send.ts` and its own
|
|
60
|
+
`RESEND_API_KEY`/`EMAIL_FROM` — a separate mechanism tied to the portal's
|
|
61
|
+
platform sender, not to a per-module linked domain. Don't merge the two;
|
|
62
|
+
`sendMail` here is for this module's own business email.
|
|
63
|
+
|
|
64
|
+
## Ship it
|
|
65
|
+
|
|
66
|
+
Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run
|
|
67
|
+
&& pnpm build` — then `/qore-ship-checklist` before a PR.
|