@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,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.
|