@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.
Files changed (40) hide show
  1. package/README.md +26 -0
  2. package/dist/cli/legacy/qdc-sourcemaps.js +237 -0
  3. package/dist/cli/legacy/qdc-test-report.js +200 -0
  4. package/dist/cli/skills-postinstall.js +603 -0
  5. package/dist/cli/skills.js +499 -0
  6. package/dist/cli/sourcemaps.js +9 -9
  7. package/dist/cli/test-report.js +2 -2
  8. package/dist/next/config/index.js +1 -1
  9. package/dist/next/config/index.js.map +1 -1
  10. package/package.json +10 -4
  11. package/postinstall.mjs +9 -0
  12. package/skills/.gitattributes +1 -0
  13. package/skills/catalog.json +24 -0
  14. package/skills/qore-add-api-route/SKILL.md +58 -0
  15. package/skills/qore-add-connection/SKILL.next.md +149 -0
  16. package/skills/qore-add-connection/SKILL.node.md +160 -0
  17. package/skills/qore-add-db-table/SKILL.md +88 -0
  18. package/skills/qore-add-db-table/references/cross-module-reference.md +74 -0
  19. package/skills/qore-add-feature/SKILL.md +77 -0
  20. package/skills/qore-add-integration/SKILL.next.md +217 -0
  21. package/skills/qore-add-integration/SKILL.node.md +216 -0
  22. package/skills/qore-add-page/SKILL.md +53 -0
  23. package/skills/qore-author-skill/SKILL.md +136 -0
  24. package/skills/qore-debug-issue/SKILL.md +48 -0
  25. package/skills/qore-enable-auth-method/SKILL.md +90 -0
  26. package/skills/qore-observability/SKILL.md +89 -0
  27. package/skills/qore-observability/references/errors.md +87 -0
  28. package/skills/qore-observability/references/logs.md +69 -0
  29. package/skills/qore-observability/references/tracing.md +58 -0
  30. package/skills/qore-recommended-tools/SKILL.md +58 -0
  31. package/skills/qore-secure-io/SKILL.md +174 -0
  32. package/skills/qore-ship-checklist/SKILL.md +80 -0
  33. package/skills/qore-translate-strings/SKILL.md +36 -0
  34. package/skills/qore-use-addons/SKILL.md +175 -0
  35. package/skills/qore-use-database/SKILL.md +222 -0
  36. package/skills/qore-use-design-system/SKILL.md +76 -0
  37. package/skills/qore-use-mail-domain/SKILL.next.md +67 -0
  38. package/skills/qore-use-mail-domain/SKILL.node.md +62 -0
  39. package/skills/qore-use-permissions/SKILL.md +116 -0
  40. package/skills/qore-verify-user/SKILL.md +56 -0
@@ -0,0 +1,24 @@
1
+ {
2
+ "skills": [
3
+ {"name": "qore-add-api-route", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-add-api-route"]},
4
+ {"name": "qore-add-connection", "kind": "contract", "legacyNames": ["quatronic-add-connection"]},
5
+ {"name": "qore-add-db-table", "kind": "convenience", "requires": ["runtime=next", "db"], "legacyNames": ["quatronic-add-db-table"]},
6
+ {"name": "qore-add-feature", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-add-feature"]},
7
+ {"name": "qore-add-integration", "kind": "contract", "legacyNames": ["quatronic-add-integration"]},
8
+ {"name": "qore-add-page", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-add-page"]},
9
+ {"name": "qore-author-skill", "kind": "convenience", "legacyNames": ["quatronic-author-skill"]},
10
+ {"name": "qore-debug-issue", "kind": "convenience"},
11
+ {"name": "qore-enable-auth-method", "kind": "convenience", "requires": ["auth=betterauth"], "legacyNames": ["quatronic-enable-auth-method"]},
12
+ {"name": "qore-observability", "kind": "contract", "legacyNames": ["quatronic-observe"]},
13
+ {"name": "qore-recommended-tools", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-recommended-tools"]},
14
+ {"name": "qore-secure-io", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-secure-io"]},
15
+ {"name": "qore-ship-checklist", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-ship-checklist"]},
16
+ {"name": "qore-translate-strings", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-translate-strings"]},
17
+ {"name": "qore-use-addons", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-use-addons"]},
18
+ {"name": "qore-use-database", "kind": "contract", "requires": ["runtime=node", "db"], "legacyNames": ["quatronic-use-database"]},
19
+ {"name": "qore-use-design-system", "kind": "convenience", "requires": ["runtime=next"], "legacyNames": ["quatronic-use-design-system"]},
20
+ {"name": "qore-use-mail-domain", "kind": "convenience", "legacyNames": ["quatronic-use-mail-domain"]},
21
+ {"name": "qore-use-permissions", "kind": "contract", "requires": ["auth=keycloak"], "legacyNames": ["quatronic-use-permissions"]},
22
+ {"name": "qore-verify-user", "kind": "convenience", "requires": ["runtime=node"], "legacyNames": ["quatronic-verify-user"]}
23
+ ]
24
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: qore-add-api-route
3
+ description: Add a business HTTP route under src/app/api/ for external callers{{#if auth}}, protected with the SDK{{/if}}. Use for API endpoints — internal mutations are Server Actions, not routes.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ {{#if auth}}> Gating access by role/permission? See /qore-use-permissions.{{#if auth=keycloak}} Added a role key? Run `qore roles sync` yourself, then ask before `qore roles use` (§3 there).{{/if}}
8
+
9
+
10
+ {{/if}}# Add an API route
11
+
12
+ Internal mutations use Server Actions (see /qore-add-feature). Use an API
13
+ route only for **external** callers.
14
+
15
+ 1. Create `src/app/api/<name>/route.ts`, following the layout of the existing
16
+ routes — segment config first, then imports, then the handler:
17
+ {{#if auth}} ```ts
18
+ export const runtime = "nodejs"
19
+ export const dynamic = "force-dynamic" // set for any non-trivial handler (POST, reads request, must not be cached)
20
+
21
+ import { NextResponse } from "next/server"
22
+ import { getRequireAuth } from "@/lib/auth"
23
+
24
+ export async function GET() {
25
+ const requireAuth = await getRequireAuth()
26
+ const user = await requireAuth()
27
+ return NextResponse.json({ id: user.id })
28
+ }
29
+ ```
30
+ {{else}} ```ts
31
+ export const runtime = "nodejs"
32
+ export const dynamic = "force-dynamic" // set for any non-trivial handler
33
+
34
+ import { NextResponse } from "next/server"
35
+
36
+ export async function GET() {
37
+ return NextResponse.json({ ok: true })
38
+ }
39
+ ```
40
+ (No auth module is installed — protect external routes with your own token
41
+ check or add auth via the portal before exposing sensitive data.)
42
+ {{/if}}
43
+ 2. Keep data access in a feature slice's `server/` dir, not inline in the route.
44
+
45
+ > API responses are data, not localized UI, so they generally need no
46
+ > translation. If a route returns human-readable text meant for end users,
47
+ > treat it like any UI string (see /qore-translate-strings).
48
+
49
+ 3. Let unexpected failures throw — they are captured automatically. Use
50
+ `Sentry.captureException(err, { tags })` only for a failure you handle that
51
+ should never happen, never for expected ones (validation, not found, denied).
52
+ Add a `Sentry.logger.info` line for the business event (no PII); request
53
+ tracing is automatic. See /qore-observability.
54
+ 4. Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run
55
+ && pnpm build` — then /qore-ship-checklist before a PR.
56
+
57
+ {{#if auth}}> Never touch the auth catch-all (`src/app/api/auth/[...qdc]/route.ts`) — it is SDK-owned.
58
+ {{/if}}
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: qore-add-connection
3
+ description: Wire a module-to-module connection — declare its env, guard an incoming route with requireConnection, call out with connection().fetch, and fail-fast at startup with assertConnection. Use when this app talks to another Quatronic module.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ # Add a connection
8
+
9
+ A connection links two Quatronic modules. The portal injects the env on both
10
+ sides when the connection is wired (`CONN_<ALIAS>_URL`, `CONN_<ALIAS>_TOKEN` for
11
+ a service; `CONN_<ALIAS>_URI`/`_HOST`/… for a resource). `<ALIAS>` is the
12
+ provider slug uppercased, non-alphanumerics collapsed to `_`. The SDK reads only
13
+ this env — it never calls the portal at runtime.
14
+
15
+ > A single-module app has no connections. Only follow this once the portal shows
16
+ > a wired connection for this module (its env keys exist in the Secrets tab).
17
+
18
+ ## Kinds of connection
19
+
20
+ `connection(alias)` returns one handle with four methods — which one you call
21
+ depends on what the alias was wired to in the portal:
22
+
23
+ - **`.fetch(path, init?)`** — the alias is another **module** (a service
24
+ connection). This is module-to-module talking directly over the network via
25
+ its own API, authenticated with a shared-secret token in the
26
+ `X-QDC-Connection-Token` header. No addon involved.
27
+ - **`.postgres()` / `.redis()` / `.s3()`** — the alias is a **resource** (an
28
+ addon: Postgres, Redis, or object storage) this module was granted access
29
+ to. These return typed connection material (URI, host, credentials, …), not
30
+ an HTTP client — you pass it to your own DB/cache/S3 client.
31
+
32
+ `assertConnection(alias, kind)` takes the matching `kind` too: `"service"` for
33
+ `.fetch()`, `"postgres"`/`"redis"`/`"s3"` for the resource accessors.
34
+ `requireConnection` (step 4) is provider-side only — a resource connection has
35
+ no inbound side, so it's only ever used for service connections.
36
+
37
+ ## 1. Declare the connection's env (consumer side only)
38
+
39
+ **Only do this when THIS module calls out** via `.fetch()`/`.postgres()`/
40
+ `.redis()`/`.s3()` (steps 2-3). If this module is only the **provider** guarding
41
+ an inbound route (step 4), skip this step entirely — see the note there.
42
+
43
+ Add the keys to the server schema in `src/config/env.ts` so a missing wire is a
44
+ clear startup error, not a mystery 500. For a service connection to `billing-api`
45
+ (alias `BILLING_API`):
46
+
47
+ ```ts
48
+ // inside serverSchema = z.object({ ... })
49
+ CONN_BILLING_API_URL: z.string().url(),
50
+ CONN_BILLING_API_TOKEN: z.string().min(1),
51
+ ```
52
+
53
+ ## 2. Fail-fast at startup
54
+
55
+ In a module-scoped init path (e.g. a server-only module imported by your feature
56
+ code), assert the wire once:
57
+
58
+ ```ts
59
+ import { assertConnection } from "@quatronic/sdk/next/connections"
60
+ import { serverEnv } from "@/config/env"
61
+
62
+ assertConnection("billing-api", "service", { env: serverEnv() as Record<string, string> })
63
+ ```
64
+
65
+ ## 3. Call out to the other module
66
+
67
+ ```ts
68
+ import { connection } from "@quatronic/sdk/next/connections"
69
+
70
+ const res = await connection("billing-api").fetch("/invoices", { method: "GET" })
71
+ // connection() reads process.env; the X-QDC-Connection-Token header is added
72
+ // from CONN_BILLING_API_TOKEN automatically. Default 10s timeout.
73
+ const invoices = await res.json()
74
+ ```
75
+
76
+ For a resource connection, use the typed accessor instead of `.fetch`:
77
+
78
+ ```ts
79
+ const { uri } = connection("main-db").postgres() // { uri, host?, port?, user?, password?, db? }
80
+ const { url } = connection("cache").redis()
81
+ const { endpoint, bucket, accessKey, secretKey } = connection("media").s3()
82
+ ```
83
+
84
+ ## 4. Guard an incoming connection route
85
+
86
+ When THIS module is the provider, protect the receiving route. The guard is
87
+ edge-safe, so it also works in `middleware.ts` to protect a whole route group.
88
+
89
+ **No `env.ts` schema entry needed for this** — unlike step 1, `requireConnection`
90
+ doesn't take or need an alias: it scans `process.env` for every `CONN_*_TOKEN`
91
+ key present and matches the incoming request's token against all of them, so
92
+ it works with whatever alias the portal happened to derive on the consumer's
93
+ side. Also note the portal never gives a provider its own `CONN_<ALIAS>_URL` —
94
+ only a consumer gets a `_URL` — so requiring `CONN_<ALIAS>_URL` in this
95
+ module's `env.ts` when it's provider-only will always fail (crash-loop) since
96
+ that key can never exist.
97
+
98
+ ```ts
99
+ // src/app/api/internal/<name>/route.ts
100
+ export const runtime = "nodejs"
101
+ export const dynamic = "force-dynamic"
102
+
103
+ import { NextResponse } from "next/server"
104
+ import { requireConnection, ConnectionError } from "@quatronic/sdk/next/connections"
105
+
106
+ export async function POST(req: Request) {
107
+ try {
108
+ const alias = await requireConnection(req) // returns the matched <ALIAS>
109
+ // ... handle the trusted request; `alias` tells you which consumer wired in
110
+ return NextResponse.json({ ok: true })
111
+ } catch (e) {
112
+ if (e instanceof ConnectionError) {
113
+ return NextResponse.json({ error: e.code }, { status: e.status })
114
+ }
115
+ throw e
116
+ }
117
+ }
118
+ ```
119
+
120
+ `requireConnection` is `async` (it uses Web Crypto for a constant-time token
121
+ compare) — always `await` it. It throws `ConnectionError` with code `not_wired`
122
+ / `missing_token` / `invalid_token` (status 401).
123
+
124
+ ### Fail-fast that an inbound connection is wired (optional)
125
+
126
+ To assert at startup that *some* connection is wired to this provider, use the
127
+ alias-agnostic `assertInboundConnection` (the provider doesn't know its callers'
128
+ aliases, so `assertConnection` from step 2 doesn't apply):
129
+
130
+ ```ts
131
+ import { assertInboundConnection } from "@quatronic/sdk/next/connections"
132
+
133
+ assertInboundConnection()
134
+ // throws not_wired (500) if no CONN_*_TOKEN is present in process.env
135
+ ```
136
+
137
+ Do **not** implement this by calling `requireConnection` with a tokenless
138
+ `Request`. `requireConnection` always logs a `qore.connection.rejected` /
139
+ `missing_token` warning before it throws, so a tokenless probe emits a
140
+ misleading rejection on **every healthy boot**. `assertInboundConnection` reads
141
+ the env directly and stays silent when a connection is wired.
142
+
143
+ ## 5. Ship it
144
+
145
+ Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run &&
146
+ pnpm build` — then /qore-ship-checklist before a PR.
147
+
148
+ > A worked, non-wired reference lives at `src/examples/connection.example.ts`.
149
+ > Don't hand-edit the auth/SDK boundary (`src/lib/auth.ts`, `src/proxy.ts`).
@@ -0,0 +1,160 @@
1
+ ---
2
+ name: qore-add-connection
3
+ description: Wire a module-to-module connection — declare its env, guard an incoming request with requireConnection, call out with connection().fetch, and fail-fast at startup with assertConnection. Use when this backend talks to another Quatronic module.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ # Add a connection
8
+
9
+ A connection links two Quatronic modules. The portal injects the env on both
10
+ sides when the connection is wired (`CONN_<ALIAS>_URL`, `CONN_<ALIAS>_TOKEN` for
11
+ a service; `CONN_<ALIAS>_URI`/`_HOST`/… for a resource). `<ALIAS>` is the
12
+ provider slug uppercased, non-alphanumerics collapsed to `_`. The SDK reads only
13
+ this env — it never calls the portal at runtime.
14
+
15
+ > A single-module app has no connections. Only follow this once the portal shows
16
+ > a wired connection for this module (its env keys exist in the Secrets tab).
17
+
18
+ This module is a plain `node:http` service (no framework), so the provider side
19
+ needs one small adapter — step 4 covers it.
20
+
21
+ ## Kinds of connection
22
+
23
+ `connection(alias)` returns one handle with four methods — which one you call
24
+ depends on what the alias was wired to in the portal:
25
+
26
+ - **`.fetch(path, init?)`** — the alias is another **module** (a service
27
+ connection). This is module-to-module talking directly over the network via
28
+ its own API, authenticated with a shared-secret token in the
29
+ `X-QDC-Connection-Token` header. No addon involved.
30
+ - **`.postgres()` / `.redis()` / `.s3()`** — the alias is a **resource** (an
31
+ addon: Postgres, Redis, or object storage) this module was granted access
32
+ to. These return typed connection material (URI, host, credentials, …), not
33
+ an HTTP client — you pass it to your own DB/cache/S3 client.
34
+
35
+ `assertConnection(alias, kind)` takes the matching `kind` too: `"service"` for
36
+ `.fetch()`, `"postgres"`/`"redis"`/`"s3"` for the resource accessors.
37
+ `requireConnection` (step 4) is provider-side only — a resource connection has
38
+ no inbound side, so it's only ever used for service connections.
39
+
40
+ ## 1. Declare the connection's env (consumer side only)
41
+
42
+ **Only do this when THIS module calls out** via `.fetch()`/`.postgres()`/
43
+ `.redis()`/`.s3()` (steps 2-3). If this module is only the **provider** guarding
44
+ an inbound request (step 4), skip this step entirely — see the note there.
45
+
46
+ Add the keys to the schema in `src/config/env.ts` so a missing wire is a clear
47
+ startup error, not a mystery failure. For a service connection to `billing-api`
48
+ (alias `BILLING_API`):
49
+
50
+ ```ts
51
+ // inside `const schema = z.object({ ... })`
52
+ CONN_BILLING_API_URL: z.string().url(),
53
+ CONN_BILLING_API_TOKEN: z.string().min(1),
54
+ ```
55
+
56
+ ## 2. Fail-fast at startup
57
+
58
+ In `src/index.ts`, before `server.listen(...)`, assert every wire this module
59
+ declares:
60
+
61
+ ```ts
62
+ import { assertConnection } from "@quatronic/sdk/node/connections"
63
+
64
+ assertConnection("billing-api", "service") // reads process.env by default
65
+ ```
66
+
67
+ ## 3. Call out to the other module
68
+
69
+ ```ts
70
+ import { connection } from "@quatronic/sdk/node/connections"
71
+
72
+ const res = await connection("billing-api").fetch("/invoices", { method: "GET" })
73
+ // connection() reads process.env; the X-QDC-Connection-Token header is added
74
+ // from CONN_BILLING_API_TOKEN automatically. Default 10s timeout.
75
+ const invoices = await res.json()
76
+ ```
77
+
78
+ For a resource connection, use the typed accessor instead of `.fetch`:
79
+
80
+ ```ts
81
+ const { uri } = connection("main-db").postgres() // { uri, host?, port?, user?, password?, db? }
82
+ const { url } = connection("cache").redis()
83
+ const { endpoint, bucket, accessKey, secretKey } = connection("media").s3()
84
+ ```
85
+
86
+ ## 4. Guard an incoming connection request
87
+
88
+ When THIS module is the provider, protect the receiving handler.
89
+ **No `env.ts` schema entry needed for this** — unlike step 1, `requireConnection`
90
+ doesn't take or need an alias: it scans `process.env` for every `CONN_*_TOKEN`
91
+ key present and matches the incoming request's token against all of them, so
92
+ it works with whatever alias the portal happened to derive on the consumer's
93
+ side. Also note the portal never gives a provider its own `CONN_<ALIAS>_URL` —
94
+ only a consumer gets a `_URL` — so requiring `CONN_<ALIAS>_URL` in this
95
+ module's `env.ts` when it's provider-only will always fail (crash-loop) since
96
+ that key can never exist.
97
+
98
+ `requireConnection` reads a Web-standard `Headers` object — `node:http` gives
99
+ you a plain header map, so adapt it with `Headers` (already global in Node 18+):
100
+
101
+ ```ts
102
+ // src/index.ts (or a handler module it dispatches to)
103
+ import { requireConnection, ConnectionError } from "@quatronic/sdk/node/connections"
104
+ import type { IncomingMessage, ServerResponse } from "node:http"
105
+
106
+ function toWebHeaders(raw: IncomingMessage["headers"]): Headers {
107
+ const headers = new Headers()
108
+ for (const [key, value] of Object.entries(raw)) {
109
+ if (value === undefined) continue
110
+ headers.set(key, Array.isArray(value) ? value.join(", ") : value)
111
+ }
112
+ return headers
113
+ }
114
+
115
+ async function handleInbound(req: IncomingMessage, res: ServerResponse): Promise<void> {
116
+ try {
117
+ const alias = await requireConnection({ headers: toWebHeaders(req.headers) } as Request)
118
+ // ... handle the trusted request; `alias` tells you which consumer wired in
119
+ res.writeHead(200, { "content-type": "application/json" })
120
+ res.end(JSON.stringify({ ok: true, from: alias }))
121
+ } catch (e) {
122
+ if (e instanceof ConnectionError) {
123
+ res.writeHead(e.status, { "content-type": "application/json" })
124
+ res.end(JSON.stringify({ error: e.code }))
125
+ return
126
+ }
127
+ throw e
128
+ }
129
+ }
130
+ ```
131
+
132
+ `requireConnection` is `async` (it uses Web Crypto for a constant-time token
133
+ compare) — always `await` it. It throws `ConnectionError` with code `not_wired`
134
+ / `missing_token` / `invalid_token` (status 401).
135
+
136
+ ### Fail-fast that an inbound connection is wired (optional)
137
+
138
+ A provider-only module can assert at boot that *some* connection is wired to it.
139
+ Because the provider doesn't know its callers' aliases, `assertConnection` (step
140
+ 2, alias-specific) doesn't apply — use the alias-agnostic `assertInboundConnection`:
141
+
142
+ ```ts
143
+ // src/index.ts, before server.listen(...)
144
+ import { assertInboundConnection } from "@quatronic/sdk/node/connections"
145
+
146
+ assertInboundConnection() // throws not_wired (500) if no CONN_*_TOKEN is present
147
+ ```
148
+
149
+ Do **not** implement this by calling `requireConnection` with a tokenless
150
+ `Request`. `requireConnection` always logs a `qore.connection.rejected` /
151
+ `missing_token` warning before it throws, so a tokenless probe emits a
152
+ misleading rejection on **every healthy boot**. `assertInboundConnection` reads
153
+ the env directly and stays silent when a connection is wired.
154
+
155
+ ## 5. Ship it
156
+
157
+ Run `pnpm build` (type-checks and compiles) before a PR.
158
+
159
+ > A worked, non-wired reference lives at `src/examples/connection.example.ts`.
160
+ > The service is started by the SDK's Sentry register (`--import @quatronic/sdk/node/sentry/register`); don't change the start command.
@@ -0,0 +1,88 @@
1
+ ---
2
+ name: qore-add-db-table
3
+ description: Add a database table or column with Drizzle, including the migration. Use whenever a feature needs to persist new data, when a column is added to an existing table, and especially when a column will hold an id, slug or key of something that already exists somewhere (a user, an account, a spot, an order, a product) — the skill works out where that identifier comes from and records it. Use it even when the user only says "add a table", "store X per user" or "save which Y it belongs to". App tables live on the app schema; the SDK owns the auth schema.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read, Glob, Grep
5
+ ---
6
+
7
+ # Add a DB table + migration
8
+
9
+ 1. Define the table in `src/db/schema.ts` on `appSchema = pgSchema("app")`
10
+ (the SDK owns the separate `auth` schema — never put app tables there):
11
+ ```ts
12
+ export const widgets = appSchema.table("widgets", {
13
+ id: uuid("id").primaryKey().defaultRandom(),
14
+ keycloakSub: text("keycloak_sub").notNull(),
15
+ name: text("name").notNull(),
16
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
17
+ })
18
+ export type Widget = typeof widgets.$inferSelect
19
+ export type NewWidget = typeof widgets.$inferInsert
20
+ ```
21
+ 2. For every column that identifies something — an id, a slug, a key, "who",
22
+ "which" — work out where it comes from before you write it. See
23
+ [Columns that identify something](#columns-that-identify-something) below.
24
+ This is the step that is easy to skip and expensive to skip.
25
+ 3. Generate the migration:
26
+ ```
27
+ pnpm db:generate
28
+ ```
29
+ This writes `src/db/migrations/NNNN_*.sql` and updates `meta/`. **Commit both.**
30
+ 4. Do NOT hand-edit generated SQL or `src/db/migrations/meta/`. To change a
31
+ table, edit `schema.ts` and re-run `pnpm db:generate`. (A *custom* migration
32
+ you create yourself for a cross-module reference is different — see below.)
33
+ 5. Migrations apply automatically at boot: `src/instrumentation.ts` calls
34
+ `runAppMigrations` (defined in `src/lib/migrate.ts`) — there is no separate
35
+ migrate command.
36
+ 6. Run the full local check loop — `pnpm lint && pnpm typecheck && pnpm test:run
37
+ && pnpm build` — then /qore-ship-checklist before a PR. To wire the
38
+ table into a form and Server Action, see /qore-add-feature.
39
+
40
+ ## Columns that identify something
41
+
42
+ A column that holds an id, a slug or a key points at something, and that
43
+ something often lives outside this table. Postgres only knows about the link
44
+ when it is a foreign key inside this database. For every other case the
45
+ knowledge is in your head now and nowhere later: `qore db erd` cannot draw the
46
+ link, and the next developer cannot tell `account_id` from a random uuid.
47
+
48
+ The user will rarely tell you where it comes from — to them "a spot" is just a
49
+ spot. So look it up instead of guessing. Search for the *concept*, not only the
50
+ literal word: "strand" is `spots`, "melding" is `sightings`, "wie" or "gebruiker"
51
+ is the signed-in user. Three cases, checked in this order:
52
+
53
+ 1. **This database.** Grep this module's `src/db/schema.ts` for the table.
54
+ Found → a real foreign key: `uuid("spot_id").notNull().references(() => spots.id, { onDelete: "cascade" })`
55
+ (pick the delete rule the feature needs). This is the only case where Postgres
56
+ guards the link, so prefer it whenever the table is here.
57
+
58
+ 2. **The signed-in user.** "wie", "gebruiker", "account", "eigenaar", "created
59
+ by", "per user" — and no `accounts`/`users` table of our own anywhere in the
60
+ app → the identity is Keycloak's `sub`. Store it exactly as `user_profiles`
61
+ does: `keycloakSub: text("keycloak_sub").notNull()`, no foreign key (the IdP
62
+ owns it; tooling recognises this column by its name). Do not invent an
63
+ `account_id` uuid for this; there is no accounts table to point at.
64
+
65
+ 3. **Another module of this app.** The modules of an app are cloned side by side:
66
+ this module's siblings are the directories next to it, under the folder that
67
+ holds `qore.workspace.json`. Search them for the table or resource:
68
+ - Drizzle schemas: `*/src/db/schema.ts`
69
+ - plain SQL migrations: `*/src/db/migrations/*.sql`, `*/migrations/*.sql`
70
+ - API routes when a module has no database of its own: `*/src/api/routes.ts`,
71
+ `*/src/app/api/**/route.ts`
72
+ Also grep this module for `connection("<alias>")`: a value that arrives through
73
+ a connection is by definition from that module, and the alias names it.
74
+
75
+ Found → a **cross-module reference**: an ordinary column of the target's type
76
+ (uuid for an `id`, text for a slug) with the link declared next to it, so that
77
+ the database carries the knowledge. Follow
78
+ [references/cross-module-reference.md](references/cross-module-reference.md)
79
+ — it is a custom migration with one `COMMENT ON COLUMN`, not a foreign key,
80
+ because a foreign key cannot cross databases. Name the column after the
81
+ target's key (`spot_id` when the other module exposes ids, `spot_slug` when
82
+ its stable key is a slug).
83
+
84
+ When a name exists both here and in a sibling (both the frontend and the backend
85
+ have `sightings`), this module's own table wins unless the user clearly means the
86
+ other one; say which one you took. When none of the three cases applies and the
87
+ request does not say what the column identifies, ask one question and name the
88
+ candidates you found — do not pick one silently.
@@ -0,0 +1,74 @@
1
+ # Declaring a cross-module reference
2
+
3
+ A column that stores the id (or slug) of a row that lives in another module's
4
+ database cannot be a foreign key: Postgres cannot look into another database.
5
+ The link still has to be recorded somewhere the database itself carries, so that
6
+ `qore db erd` draws it, the next developer sees it, and it survives a rewrite of
7
+ the application code. That place is a comment on the column.
8
+
9
+ ## The comment
10
+
11
+ One line, starting with an arrow, then the module and the target:
12
+
13
+ ```
14
+ -> <module-slug>:<schema>.<table>.<column>
15
+ ```
16
+
17
+ - `<module-slug>` is the module as the portal knows it — the same name as the
18
+ connection alias (`connection("zeevonk-backend")` → `zeevonk-backend`) and as
19
+ the sibling directory in the workspace.
20
+ - When the other module has no database and the thing is an API resource, point
21
+ at the endpoint and the field instead:
22
+ `-> zeevonk-backend:GET /beaches .slug`
23
+ - Free text may follow the target, e.g. `(no FK: other database)`.
24
+
25
+ Tooling reads only the part up to the first space after the target; keep it
26
+ exact (`public.spots.id`, not `spots`).
27
+
28
+ ## Writing it: a custom migration
29
+
30
+ Drizzle cannot express column comments (there is no `.comment()` on a column and
31
+ `drizzle-kit generate` never emits `COMMENT ON`), so the comment goes into a
32
+ migration you write yourself. That is not the same as hand-editing generated
33
+ SQL: a custom migration is a separate journal entry that drizzle-kit creates for
34
+ you and never touches again, and `pnpm db:generate` stays clean afterwards (the
35
+ CI drift check keeps passing).
36
+
37
+ Order matters: the column must exist before the comment can be set, so generate
38
+ the schema migration first.
39
+
40
+ ```sh
41
+ pnpm db:generate # the column itself
42
+ pnpm db:generate --custom --name=favourite_spots_spot_id_ref
43
+ ```
44
+
45
+ The second command writes an empty `src/db/migrations/NNNN_favourite_spots_spot_id_ref.sql`
46
+ and registers it in `meta/_journal.json`. Put the comment in that file:
47
+
48
+ ```sql
49
+ COMMENT ON COLUMN "app"."favourite_spots"."spot_id" IS '-> zeevonk-backend:public.spots.id (no FK: other database)';
50
+ ```
51
+
52
+ Commit the SQL file together with `meta/`. Migrations run at boot in journal
53
+ order, so the comment lands right after the column.
54
+
55
+ In `schema.ts`, give the column a short comment with the same target, so a reader
56
+ of the code sees it without opening the migration:
57
+
58
+ ```ts
59
+ // `spot_id` is `public.spots.id` in zeevonk-backend; no FK across databases.
60
+ spotId: uuid("spot_id").notNull(),
61
+ ```
62
+
63
+ ## When the SDK provides `crossRef`
64
+
65
+ Check whether `@quatronic/sdk/next/db` exports `crossRef`
66
+ (`node_modules/@quatronic/sdk/dist/next/db/index.d.ts`). If it does, use it
67
+ instead of the custom migration:
68
+
69
+ ```ts
70
+ spotId: crossRef("spot_id", "zeevonk-backend", "public.spots.id").notNull(),
71
+ ```
72
+
73
+ It defines the same uuid column and the SDK writes the comment at boot, right
74
+ after the migrations. Until then, the custom migration above is the way.
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: qore-add-feature
3
+ description: Add a vertical feature slice (schema, server queries/mutations, server action, components){{#if auth}} mirroring src/features/profile/{{/if}}. Use when adding app functionality that has its own data and forms.
4
+ allowed-tools: Bash(pnpm:*), Edit, Read
5
+ ---
6
+
7
+ {{#if auth}}> Gating access by role/permission? See /qore-use-permissions.{{#if auth=keycloak}} Added a role key? Run `qore roles sync` yourself, then ask before `qore roles use` (§3 there).{{/if}}
8
+
9
+
10
+ {{/if}}Import UI from `@/components/ui` (the house kit) — see the qore-use-design-system skill.
11
+
12
+ # Add a feature slice
13
+
14
+ A feature is a vertical slice: its own data access, server logic, and UI,
15
+ under `src/features/<name>/`:
16
+
17
+ ```
18
+ src/features/<name>/
19
+ components/ # feature-scoped React components
20
+ server/ # queries, mutations, server actions — this feature's data logic
21
+ hooks/ # feature-scoped React hooks
22
+ index.ts # the feature's ONLY public surface
23
+ ```
24
+
25
+ Not every feature needs all three subfolders — only create `components/`,
26
+ `server/`, or `hooks/` if the feature actually has that kind of code.
27
+ `index.ts` is required for every feature: other code (other features,
28
+ `src/app/**`, `src/components/**`, …) may only import a feature through its
29
+ `index.ts`, never by reaching into `components/`, `server/`, or `hooks/`
30
+ directly. This is enforced by `eslint-plugin-boundaries`
31
+ (`boundaries/no-private`, `allowUncles: false`) — see
32
+ `src/features/README.md` for the full rule set, including:
33
+
34
+ - `feature -> app` imports are forbidden (a feature must never import from
35
+ `src/app/**`).
36
+ - `shared -> feature` and `shared -> app` are forbidden too — `src/{components,hooks,lib,config,styles,i18n}`
37
+ must stay free of feature- and app-specific knowledge.
38
+ - Cross-feature access is only ever through the other feature's `index.ts`.
39
+
40
+ {{#if auth}}Mirror `src/features/profile/`:
41
+ {{else}}Create the slice under `src/features/<name>/` (this app starts without
42
+ feature slices — this layout is the convention):
43
+ {{/if}}
44
+
45
+ - `schema.ts` — zod schemas for the feature's inputs.
46
+ - `types.ts` — derived types + an action-state discriminated union
47
+ (`{ status: "idle" | "success" | "error", message?: string }`).
48
+ {{#if db}}- `server/queries.ts` — Drizzle reads.
49
+ - `server/mutations.ts` — Drizzle writes.
50
+ {{else}}- `server/` — data access for the feature (no database module is installed; talk to your data source here).
51
+ {{/if}}- `actions.ts` — `"use server"` entry points. {{#if auth}}Authenticate with
52
+ `getRequireAuth()` first, validate{{else}}Validate{{/if}} `FormData` with the zod schema via
53
+ `safeParse`, return the typed action-state union on failure, call a mutation,
54
+ then `revalidatePath(...)`. Server actions are traced automatically; let
55
+ unexpected failures throw.
56
+ - `components/` — client components driving the actions with `useActionState`.
57
+ - `index.ts` — re-export exactly what other code is allowed to use
58
+ (components, server actions, types — not raw queries/mutations unless
59
+ they're meant to be called directly).
60
+ - All user-facing strings go through next-intl (`useTranslations`), with keys
61
+ in BOTH `messages/nl.json` and `messages/en.json` — see
62
+ /qore-translate-strings. Make zod validation messages message keys
63
+ (e.g. `"profile.displayNameRequired"`) and resolve them at the display site
64
+ with a root `useTranslations()` translator{{#if auth}}, mirroring `profile/`{{/if}}.
65
+
66
+ {{#if db}}Add any new table to `src/db/schema.ts` on `appSchema = pgSchema("app")`, then
67
+ `pnpm db:generate` and commit the generated migration (see
68
+ /qore-add-db-table). {{/if}}Add a unit test next to the schema (`*.test.ts`) like
69
+ {{#if auth}}`profile/schema.test.ts`{{else}}`<feature>/schema.test.ts`{{/if}}.
70
+
71
+ Before you finish, walk the decision table in /qore-observability for each step of the feature.
72
+ Most steps need nothing; add only what answers a production question.
73
+
74
+ Finish with the full local check loop — `pnpm lint && pnpm typecheck &&
75
+ pnpm test:run && pnpm build` — then run /qore-ship-checklist before opening
76
+ a PR. Every feature needs a unit test for its schema/logic, and an integration
77
+ test (`*.integration.test.ts`) when its behaviour is in the database.