@aotter/mantle 0.2.1 → 0.2.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/routes.d.ts +3 -1
- package/dist/auth/routes.js +15 -2
- package/dist/build/compiler.d.ts +1 -0
- package/dist/build/compiler.js +1 -1
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/bun/assets.d.ts +3 -1
- package/dist/bun/assets.js +4 -1
- package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/public.js +1 -1
- package/dist/domain/generated-tools.js +24 -9
- package/dist/infrastructure/bundle.js +30 -1
- package/dist/infrastructure/inputs.js +3 -2
- package/dist/infrastructure/wrangler.js +4 -3
- package/dist/kernel/authorization.d.ts +1 -0
- package/dist/kernel/authorization.js +12 -5
- package/dist/kernel/mcp-call.d.ts +3 -3
- package/dist/kernel/mcp-call.js +4 -4
- package/dist/kernel/mcp-handler.js +4 -3
- package/dist/kernel/mcp.d.ts +12 -10
- package/dist/kernel/mcp.js +1 -1
- package/dist/kernel/media-fetch.js +109 -0
- package/dist/kernel/media.d.ts +15 -2
- package/dist/kernel/media.js +138 -16
- package/dist/kernel/r2-media.d.ts +3 -2
- package/dist/kernel/r2-media.js +12 -8
- package/dist/kernel/result-meta.d.ts +10 -0
- package/dist/kernel/result-meta.js +1 -0
- package/dist/kernel/site-types.d.ts +54 -6
- package/dist/kernel/site.d.ts +1 -1
- package/dist/usecase/generate.js +2 -2
- package/dist/usecase/mcp-media.js +17 -5
- package/dist/usecase/mcp.js +6 -5
- package/dist/usecase/site.js +4 -2
- package/docs/examples/README.md +5 -1
- package/docs/examples/app-shell/README.md +5 -1
- package/docs/examples/app-shell/package.json +9 -9
- package/docs/examples/app-shell/src/jwks.ts +22 -0
- package/docs/examples/app-shell/src/service.ts +12 -1
- package/docs/examples/app-shell/wrangler.jsonc +1 -1
- package/docs/examples/club-events/README.md +10 -3
- package/docs/examples/club-events/manifests/club-events.yaml +42 -11
- package/docs/examples/club-events/smoke.mjs +31 -1
- package/docs/examples/club-events/src/handlers.ts +9 -8
- package/docs/examples/frontend-mcp-app/README.md +23 -0
- package/docs/examples/frontend-mcp-app/app.tsx +50 -0
- package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
- package/docs/examples/purchasing/README.md +9 -0
- package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
- package/docs/examples/purchasing/smoke.mjs +5 -0
- package/docs/examples/purchasing/wrangler.jsonc +1 -1
- package/docs/examples/recipes/wrangler.jsonc +1 -1
- package/docs/handbook/cloudflare/authentication.md +51 -0
- package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
- package/docs/handbook/cloudflare/media-r2.md +5 -2
- package/docs/handbook/cloudflare/service-entry.md +28 -7
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/mcp-apps.md +94 -18
- package/docs/handbook/guides/native-admin.md +29 -0
- package/docs/handbook/guides/performance.md +5 -1
- package/docs/handbook/guides/revisions.md +1 -1
- package/docs/handbook/guides/sql-patterns.md +33 -0
- package/docs/handbook/reference/authorization.md +1 -1
- package/docs/handbook/reference/describe.md +5 -0
- package/docs/handbook/reference/schema.md +19 -0
- package/docs/handbook/reference/site-config.md +2 -0
- package/docs/handbook/reference/surface.md +66 -10
- package/docs/handbook/releases/index.md +24 -1
- package/package.json +10 -10
- package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
package/docs/examples/README.md
CHANGED
|
@@ -9,6 +9,8 @@ take the pattern into your own manifests and `src/`. Do not copy an example dire
|
|
|
9
9
|
| [App shell](./app-shell/README.md) | Better Auth (email OTP, `admin` + `user`), one Worker fetch, Admin, MCP, REST, local acceptance kit. No business manifests. |
|
|
10
10
|
| [Recipes](./recipes/README.md) | Publishing (draft → publish), translations, visitor pages with SEO, JSON-LD, `llms.txt`, Markdown and hreflang alternates, R2 media. |
|
|
11
11
|
| [Club events](./club-events/README.md) | Members who see and change only their own rows, no duplicates, capacity that never oversells, a handler that checks before writing, a member MCP tool, members-only sign-up. |
|
|
12
|
+
| [Posts and revisions](../handbook/guides/revisions.md) | A focused recipe for live editing without downtime: editable revisions, frozen preview, atomic selection, immutable history and rollback. |
|
|
13
|
+
| [Frontend MCP App](./frontend-mcp-app/README.md) | An application-owned interactive MCP App with its own frontend entry and authorized tools. |
|
|
12
14
|
| [Purchasing](./purchasing/README.md) | An internal workflow: guarded status transitions, admin-only approval (Admin and admin MCP), optimistic concurrency, several writes in one transaction, an external API with a signed callback, email and cron. |
|
|
13
15
|
| [Generated website](./generated-web/README.md) | The smallest public site: list and detail pages, REST, `llms.txt`, sitemap, Markdown alternates. |
|
|
14
16
|
|
|
@@ -28,6 +30,8 @@ take the pattern into your own manifests and `src/`. Do not copy an example dire
|
|
|
28
30
|
| A record with its children in one read | club-events' `event-roster`: a JSON array per row ("A row with its children in one read") |
|
|
29
31
|
| The latest row per group, rankings | `row_number() OVER (PARTITION BY ...)` ("The latest row of each group") |
|
|
30
32
|
| A third-party API that answers later (webhooks, callbacks) | purchasing's `src/routes.ts`: verify the raw request, then call a Procedure |
|
|
33
|
+
| Edit published content without downtime, preview the exact revision and roll back | [Posts and revisions](../handbook/guides/revisions.md): a stable publishing parent, operational revisions, a same-row seal and atomic pointer switch |
|
|
34
|
+
| An interactive frontend inside an MCP host | [Frontend MCP App](./frontend-mcp-app/README.md): an app-owned renderer and authorized tools |
|
|
31
35
|
| Public, multilingual, search- and AI-friendly pages | recipes' `src/routes.ts` |
|
|
32
36
|
| Invite-only or members-only sign-up | club-events' `members` allowlist + `src/signup.ts` |
|
|
33
37
|
| Notifications and reminders | purchasing's `EmailSender` call and `src/schedule.ts` |
|
|
@@ -39,5 +43,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
|
|
|
39
43
|
|
|
40
44
|
Every example uses two roles: `admin` (the admin layer) and `user`.
|
|
41
45
|
|
|
42
|
-
[Reference service](https://github.com/aotter/mantle/tree/v0.2.1/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
|
|
46
|
+
[Reference service](https://github.com/aotter/mantle/tree/v0.2.2-alpha.1/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
|
|
43
47
|
Projects on 0.1.x move with [the upgrade guide](../handbook/releases/upgrade-0.1-to-0.2.md).
|
|
@@ -73,11 +73,15 @@ on one version.
|
|
|
73
73
|
| `src/signup.ts` | `allowSignUp(env, email)`: who may create an account (allow-all by default). `auth.ts` calls it before Better Auth creates a user; `ADMIN_EMAIL` is always allowed. |
|
|
74
74
|
| `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
|
|
75
75
|
| `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
|
|
76
|
+
| `public/` | Optional static files. `mantle bundle` copies them into `dist/client`: `public/assets/*` is served at `/assets/*` by the edge without running the Worker; other files (for example `public/favicon.ico`) go through the Worker, which serves them from `ASSETS`. `public/admin` is reserved. Cloudflare's default is `Cache-Control: max-age=0, must-revalidate` with an ETag; for content-hashed files add a `public/_headers` rule with `immutable`, and keep the `/admin/assets/*` rule the build prints. `wrangler dev` rebuilds on changes under `src` and `generated` only: after editing `public/`, save a file in `src` or restart it. |
|
|
76
77
|
| `accept.mjs` | Independent acceptance setup: one URL per role that carries its session, plus a minimal MCP client. Add a fixtures module for realistic data. |
|
|
77
78
|
|
|
78
79
|
`src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
|
|
79
80
|
chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
|
|
80
|
-
HTTP-only Procedures will keep generating with a warning.
|
|
81
|
+
HTTP-only Procedures will keep generating with a warning. `src/jwks.ts` (installed by `service.ts`) is a temporary
|
|
82
|
+
Workers workaround: it answers the app's own JWKS URL in process when access tokens are verified; delete it with the
|
|
83
|
+
MCP / OAuth blocks, and once Better Auth can take an in-process key source
|
|
84
|
+
([Verifying access tokens on Workers](../../handbook/cloudflare/authentication.md#verifying-access-tokens-on-workers)).
|
|
81
85
|
|
|
82
86
|
Search for `AGENT:` to find what an app must fill in.
|
|
83
87
|
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
"accept": "node accept.mjs"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@aotter/mantle": "0.2.1",
|
|
19
|
-
"@aotter/mantle-ui": "0.2.1",
|
|
20
|
-
"@better-auth/cimd": "1.7.
|
|
21
|
-
"@better-auth/core": "1.7.
|
|
22
|
-
"@better-auth/mcp": "1.7.
|
|
23
|
-
"@better-auth/oauth-provider": "1.7.
|
|
24
|
-
"@better-auth/utils": "0.
|
|
18
|
+
"@aotter/mantle": "0.2.2-alpha.1",
|
|
19
|
+
"@aotter/mantle-ui": "0.2.2-alpha.1",
|
|
20
|
+
"@better-auth/cimd": "1.7.12",
|
|
21
|
+
"@better-auth/core": "1.7.12",
|
|
22
|
+
"@better-auth/mcp": "1.7.12",
|
|
23
|
+
"@better-auth/oauth-provider": "1.7.12",
|
|
24
|
+
"@better-auth/utils": "0.5.0",
|
|
25
25
|
"@modelcontextprotocol/ext-apps": "2.0.0",
|
|
26
26
|
"@modelcontextprotocol/server": "2.3.1",
|
|
27
|
-
"better-auth": "1.7.
|
|
28
|
-
"@aotter/mantle-admin": "0.2.1",
|
|
27
|
+
"better-auth": "1.7.12",
|
|
28
|
+
"@aotter/mantle-admin": "0.2.2-alpha.1",
|
|
29
29
|
"react": "19.3.0",
|
|
30
30
|
"react-dom": "19.3.0"
|
|
31
31
|
},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answers this Worker's own JWKS URL in process (aotter/mantle#1932). `resolveCaller` verifies OAuth access tokens
|
|
3
|
+
* with Better Auth's resource client, which fetches `<baseURL>/jwks` by URL: on Workers that is a public-network
|
|
4
|
+
* round trip back into this same Worker (which then reads D1) before most MCP calls, because Better Auth's key cache
|
|
5
|
+
* lives per isolate. Only a GET of exactly that URL is answered here, from `auth.api.getJwks()` (public keys only,
|
|
6
|
+
* the jwt plugin's own rotation and grace rules); Better Auth still verifies the token and its DPoP binding and still
|
|
7
|
+
* caches the keys by that URL, and Mantle still checks the grant. Any other request, or a URL that does not match
|
|
8
|
+
* exactly, uses the real `fetch`. The call skips Better Auth's HTTP router, so it shares no rate-limit bucket and needs
|
|
9
|
+
* no ExecutionContext. A failed key read fails verification as a failed network fetch did.
|
|
10
|
+
* Delete this module once Better Auth's resource client accepts an in-process key source (better-auth/better-auth#11604).
|
|
11
|
+
*/
|
|
12
|
+
export function withInProcessJwks(outer: typeof fetch, url: string, keys: () => Promise<unknown>): typeof fetch {
|
|
13
|
+
const inProcess = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
14
|
+
const request = input instanceof Request ? input : undefined;
|
|
15
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
16
|
+
return method === "GET" && (request ? request.url : String(input)) === url
|
|
17
|
+
? keys().then(body => Response.json(body))
|
|
18
|
+
: outer(input, init);
|
|
19
|
+
};
|
|
20
|
+
// keeps whatever else the platform's fetch carries (a runtime may add properties to it)
|
|
21
|
+
return Object.assign(inProcess, outer);
|
|
22
|
+
}
|
|
@@ -17,6 +17,7 @@ import { mcpAppCatalogues } from "../generated/admin-descriptors.js";
|
|
|
17
17
|
import type { Handlers } from "../generated/generated.js";
|
|
18
18
|
import { handlers } from "./handlers.js";
|
|
19
19
|
import { appRoutes } from "./routes.js";
|
|
20
|
+
import { withInProcessJwks } from "./jwks.js";
|
|
20
21
|
|
|
21
22
|
let auth: ReturnType<typeof authLoader<ReturnType<typeof import("./auth.js").createAuth>>> | undefined;
|
|
22
23
|
const authorities = new WeakMap<Request, () => Promise<Caller>>();
|
|
@@ -26,7 +27,12 @@ let serveMcpHandler: ReturnType<typeof serveMcp<Env, Handle, Handlers>> | undefi
|
|
|
26
27
|
const serveRest = rest(options);
|
|
27
28
|
|
|
28
29
|
export async function fetchService(request: Request, env: Env, context: ExecutionContext): Promise<Response> {
|
|
29
|
-
|
|
30
|
+
if (!auth) {
|
|
31
|
+
auth = authLoader(() => import("./auth.js").then(m => m.createAuth(env)));
|
|
32
|
+
// Once per isolate: Better Auth's JWKS fetch for verifying access tokens stays in process (see src/jwks.ts).
|
|
33
|
+
const loader = auth;
|
|
34
|
+
globalThis.fetch = withInProcessJwks(globalThis.fetch, `${env.PUBLIC_ORIGIN}/api/auth/jwks`, async () => (await loader.get()).api.getJwks());
|
|
35
|
+
}
|
|
30
36
|
const headers = new Headers();
|
|
31
37
|
let authority: Promise<Caller> | undefined;
|
|
32
38
|
// One Caller per request, resolved on first use; every surface below shares it.
|
|
@@ -66,6 +72,11 @@ export async function fetchService(request: Request, env: Env, context: Executio
|
|
|
66
72
|
if (response) return respond(response);
|
|
67
73
|
const shell = await adminShell(request, path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, url), request)));
|
|
68
74
|
if (shell) return shell;
|
|
75
|
+
// public/ files outside /assets/* (for example /favicon.ico) reach the Worker first; serve them from ASSETS.
|
|
76
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
77
|
+
const file = await env.ASSETS.fetch(request);
|
|
78
|
+
if (file.status !== 404) return file;
|
|
79
|
+
}
|
|
69
80
|
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
70
81
|
} catch (error) { return surfaceFailure(error); }
|
|
71
82
|
}
|
|
@@ -6,8 +6,8 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
|
|
|
6
6
|
|
|
7
7
|
## Capabilities shown
|
|
8
8
|
|
|
9
|
-
- **Member-owned rows**: `restrict: { registrations: "author_id = auth.uid()" }` on the `my-registrations` View
|
|
10
|
-
|
|
9
|
+
- **Member-owned rows**: `restrict: { registrations: "author_id = auth.uid()" }` on the `my-registrations` View
|
|
10
|
+
(`manifests/club-events.yaml`); `cancel-registration` pins the caller's row inline instead (see the waiting list). Registrations have no field `scope`, which would hide
|
|
11
11
|
rows from officers too; Admin lists all of them.
|
|
12
12
|
- **No duplicates**: `uniqueIndexes: [[eventId, member]]`. Native `authorId` is not allowed in a unique index, so the
|
|
13
13
|
`register` handler writes `member` from the verified caller (`src/handlers.ts`); a second registration is 409.
|
|
@@ -26,6 +26,11 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
|
|
|
26
26
|
- **An event with its attendees in one read**: the admin View `event-roster` returns each event with an `attendees`
|
|
27
27
|
JSON array built by `json_group_array(json_set('{}', ...))` in a correlated subquery, so an event nobody joined has
|
|
28
28
|
`[]`. PostgreSQL writes it with `jsonb_agg(jsonb_build_object(...))`; see `mantle help sql-patterns`.
|
|
29
|
+
- **A waiting list**: `register` with `waitlist: true` on a full event writes the member's own row with
|
|
30
|
+
`waitlisted: true` (`waitlist-write`), which takes no seat. `cancel-registration` has no `restrict`, because
|
|
31
|
+
promoting the earliest waiter changes another member's row; each statement pins the caller's row with
|
|
32
|
+
`author_id = auth.uid()` inline, and the final own-row DELETE makes another member's id 409 and rolls back the rest.
|
|
33
|
+
See `mantle help sql-patterns` ("Change another member's row").
|
|
29
34
|
- **Member MCP tool**: the `register-mcp` Trigger (`surface: public`) exposes `register` on `/mcp`, so the member's AI
|
|
30
35
|
registers with the member's own sign-in.
|
|
31
36
|
|
|
@@ -60,6 +65,8 @@ its sign-in is 403 and creates no user row. The officer adds the members in Admi
|
|
|
60
65
|
they sign in. An officer creates events in Admin. A member registers (seats 3 → 2); registering twice is 409
|
|
61
66
|
and takes no seat; a closed event is 409 and a missing one 404. Another member cannot see or cancel the registration
|
|
62
67
|
(cancel is 409; the row and seats stay). Four members race for a 1-seat event: exactly one succeeds, the rest are 409,
|
|
63
|
-
no seat is left and only one row exists. Cancelling returns the seat. A member registers through the MCP tool.
|
|
68
|
+
no seat is left and only one row exists. Cancelling returns the seat. A member registers through the MCP tool. On a full event, registering is 409 unless the
|
|
69
|
+
member asks for the waiting list; cancelling a seat promotes the earliest waiter (another member's cancel is still 409
|
|
70
|
+
and promotes nobody), leaving the waiting list frees nothing, and with nobody waiting the seat returns.
|
|
64
71
|
The officer sees every registration, with the member's name, through Admin's API; a member gets 403 on Admin.
|
|
65
72
|
Admin cannot write `seats_taken` or lower `capacity` below the seats taken.
|
|
@@ -37,10 +37,12 @@ spec:
|
|
|
37
37
|
schema:
|
|
38
38
|
type: object
|
|
39
39
|
readOnly: true
|
|
40
|
-
required: [eventId, member]
|
|
40
|
+
required: [eventId, member, waitlisted]
|
|
41
41
|
properties:
|
|
42
42
|
eventId: { type: string, x-mantle-ref: events }
|
|
43
43
|
member: { type: string }
|
|
44
|
+
# true: on the waiting list, holding no seat. Cancelling a seat promotes the earliest waiter.
|
|
45
|
+
waitlisted: { type: boolean, title: Waiting }
|
|
44
46
|
# A snapshot of the member's name at sign-up, so officers see who registered (member is an opaque id).
|
|
45
47
|
memberName: { type: string, maxLength: 200 }
|
|
46
48
|
---
|
|
@@ -74,7 +76,7 @@ spec:
|
|
|
74
76
|
sql: |
|
|
75
77
|
SELECT id, title, startsAt AS "startsAt", capacity - seats_taken AS seats_left, closed FROM events ORDER BY startsAt, id
|
|
76
78
|
---
|
|
77
|
-
# Only the caller's own rows: restrict narrows registrations to author_id = the caller.
|
|
79
|
+
# Only the caller's own rows, seats and waiting places: restrict narrows registrations to author_id = the caller.
|
|
78
80
|
apiVersion: cms.mantle.aotter.net/v2
|
|
79
81
|
kind: View
|
|
80
82
|
metadata: { name: my-registrations }
|
|
@@ -84,7 +86,7 @@ spec:
|
|
|
84
86
|
requires: { auth: { all: [ctx.user] } }
|
|
85
87
|
restrict: { registrations: "author_id = auth.uid()" }
|
|
86
88
|
sql: |
|
|
87
|
-
SELECT r.id, r.eventId AS "eventId", e.title, e.startsAt AS "startsAt"
|
|
89
|
+
SELECT r.id, r.eventId AS "eventId", e.title, e.startsAt AS "startsAt", r.waitlisted
|
|
88
90
|
FROM registrations r JOIN events e ON e.id = r.eventId
|
|
89
91
|
ORDER BY r.created_at DESC, r.id DESC
|
|
90
92
|
---
|
|
@@ -100,7 +102,7 @@ spec:
|
|
|
100
102
|
requires: { auth: { all: [ctx.admin] } }
|
|
101
103
|
sql: |
|
|
102
104
|
SELECT e.id, e.title, e.startsAt AS "startsAt", e.seats_taken AS "seatsTaken",
|
|
103
|
-
(SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id) AS attendees
|
|
105
|
+
(SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id AND NOT r.waitlisted) AS attendees
|
|
104
106
|
FROM events e ORDER BY e.startsAt, e.id
|
|
105
107
|
---
|
|
106
108
|
# The member-facing entry: a ref handler checks the event, then runs register-write.
|
|
@@ -109,7 +111,7 @@ kind: Procedure
|
|
|
109
111
|
metadata: { name: register }
|
|
110
112
|
spec:
|
|
111
113
|
title: Register for an event
|
|
112
|
-
description: Take one seat at a club event for the signed-in member. Fails when the event is closed, full or already registered.
|
|
114
|
+
description: Take one seat at a club event for the signed-in member, or with waitlist true a place on its waiting list when it is full. Fails when the event is closed, full (without waitlist) or already registered.
|
|
113
115
|
requires: { auth: { all: [ctx.user] } }
|
|
114
116
|
input:
|
|
115
117
|
type: object
|
|
@@ -117,6 +119,7 @@ spec:
|
|
|
117
119
|
additionalProperties: false
|
|
118
120
|
properties:
|
|
119
121
|
eventId: { type: string }
|
|
122
|
+
waitlist: { type: boolean, description: "When the event is full, join its waiting list instead of failing." }
|
|
120
123
|
output: { type: object }
|
|
121
124
|
handler: { ref: register }
|
|
122
125
|
---
|
|
@@ -136,18 +139,38 @@ spec:
|
|
|
136
139
|
output: { type: object }
|
|
137
140
|
handler:
|
|
138
141
|
sql: |
|
|
139
|
-
INSERT INTO registrations (eventId, member, memberName) VALUES (input.eventId, input.member, input.memberName) RETURNING id, eventId AS "eventId";
|
|
142
|
+
INSERT INTO registrations (eventId, member, memberName, waitlisted) VALUES (input.eventId, input.member, input.memberName, false) RETURNING id, eventId AS "eventId";
|
|
140
143
|
UPDATE events SET seats_taken = seats_taken + 1 WHERE id = input.eventId RETURNING capacity - seats_taken AS seats_left
|
|
141
144
|
---
|
|
142
|
-
#
|
|
145
|
+
# Internal: a place on the waiting list takes no seat. The member's own insert, so author_id is the member.
|
|
146
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
147
|
+
kind: Procedure
|
|
148
|
+
metadata: { name: waitlist-write }
|
|
149
|
+
spec:
|
|
150
|
+
requires: { auth: { all: [ctx.user] } }
|
|
151
|
+
input:
|
|
152
|
+
type: object
|
|
153
|
+
required: [eventId, member]
|
|
154
|
+
properties:
|
|
155
|
+
eventId: { type: string }
|
|
156
|
+
member: { type: string }
|
|
157
|
+
memberName: { type: string, maxLength: 200 }
|
|
158
|
+
output: { type: object }
|
|
159
|
+
handler:
|
|
160
|
+
sql: |
|
|
161
|
+
INSERT INTO registrations (eventId, member, memberName, waitlisted) VALUES (input.eventId, input.member, input.memberName, true) RETURNING id, eventId AS "eventId", waitlisted
|
|
162
|
+
---
|
|
163
|
+
# No restrict: promoting the next waiter must see and change another member's row. Every statement instead pins the
|
|
164
|
+
# caller's own row inline (author_id = auth.uid()). The DELETE must remove exactly one row, so another member's id is
|
|
165
|
+
# CONFLICT and the whole program rolls back. The seat goes back only when nobody waits; otherwise it passes to the
|
|
166
|
+
# earliest waiter. Both are decided before the promotion changes the waiting list. D1 runs the program serialized.
|
|
143
167
|
apiVersion: cms.mantle.aotter.net/v2
|
|
144
168
|
kind: Procedure
|
|
145
169
|
metadata: { name: cancel-registration }
|
|
146
170
|
spec:
|
|
147
171
|
title: Cancel a registration
|
|
148
|
-
description: Cancel one of the signed-in member's registrations
|
|
172
|
+
description: Cancel one of the signed-in member's registrations or waiting places. A freed seat goes to the first member on the waiting list, or back to the event.
|
|
149
173
|
requires: { auth: { all: [ctx.user] } }
|
|
150
|
-
restrict: { registrations: "author_id = auth.uid()" }
|
|
151
174
|
input:
|
|
152
175
|
type: object
|
|
153
176
|
required: [id]
|
|
@@ -157,8 +180,16 @@ spec:
|
|
|
157
180
|
output: { type: object }
|
|
158
181
|
handler:
|
|
159
182
|
sql: |
|
|
160
|
-
UPDATE events SET seats_taken = seats_taken - 1
|
|
161
|
-
|
|
183
|
+
UPDATE events SET seats_taken = seats_taken - 1
|
|
184
|
+
WHERE id = (SELECT r.eventId FROM registrations r WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted)
|
|
185
|
+
AND NOT EXISTS (SELECT 1 FROM registrations w WHERE w.eventId = events.id AND w.waitlisted)
|
|
186
|
+
RETURNING capacity - seats_taken AS seats_left;
|
|
187
|
+
UPDATE registrations SET waitlisted = false
|
|
188
|
+
WHERE id = (SELECT w.id FROM registrations w JOIN registrations r ON r.eventId = w.eventId
|
|
189
|
+
WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted AND w.waitlisted
|
|
190
|
+
ORDER BY w.created_at, w.id LIMIT 1)
|
|
191
|
+
RETURNING id AS promoted;
|
|
192
|
+
DELETE FROM registrations WHERE id = input.id AND author_id = auth.uid() RETURNING id
|
|
162
193
|
---
|
|
163
194
|
apiVersion: cms.mantle.aotter.net/v2
|
|
164
195
|
kind: Trigger
|
|
@@ -76,7 +76,7 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
|
|
|
76
76
|
assert.equal(foreign.status, 409, JSON.stringify(foreign.body));
|
|
77
77
|
assert.equal((await sql(`SELECT count(*) AS n FROM registrations WHERE id = '${aliceHike.id}'`))[0].n, 1);
|
|
78
78
|
assert.equal(await seatsOf(hike.id), 2);
|
|
79
|
-
step("another member neither sees nor cancels it: cancel is 409, the row and seats_left are unchanged (
|
|
79
|
+
step("another member neither sees nor cancels it: cancel is 409, the row and seats_left are unchanged (author_id = auth.uid())");
|
|
80
80
|
|
|
81
81
|
const racers = [];
|
|
82
82
|
for (const name of ["carol", "dave", "erin", "frank"]) racers.push(await signIn(`${name}@example.test`));
|
|
@@ -124,4 +124,34 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
|
|
|
124
124
|
assert.equal(shrunk.status, 409, JSON.stringify(shrunk.body));
|
|
125
125
|
assert.equal(await seatsOf(hike.id), before);
|
|
126
126
|
step("officers edit an event with the whole record; the server-maintained seats_taken is ignored, and capacity below the seats taken is 409");
|
|
127
|
+
|
|
128
|
+
await create("Talk", 1);
|
|
129
|
+
const { Talk: talk } = await events(alice);
|
|
130
|
+
const carol = racers[0], register = (cookie, body) => call("POST", "/api/registrations", { cookie, body: { eventId: talk.id, ...body } });
|
|
131
|
+
const cancel = (cookie, id) => call("POST", "/api/registrations/cancel", { cookie, body: { id } });
|
|
132
|
+
const place = async cookie => (await mine(cookie)).find(r => r.eventId === talk.id);
|
|
133
|
+
assert.equal((await register(alice)).status, 200);
|
|
134
|
+
assert.equal((await register(bob)).status, 409);
|
|
135
|
+
for (const cookie of [bob, carol]) assert.equal((await register(cookie, { waitlist: true })).status, 200);
|
|
136
|
+
assert.equal(await seatsOf(talk.id), 0);
|
|
137
|
+
assert.equal(!!(await place(bob)).waitlisted, true);
|
|
138
|
+
step("a full event is 409, unless the member asks for the waiting list: then a place that takes no seat");
|
|
139
|
+
|
|
140
|
+
const seat = await place(alice), bobPlace = await place(bob), carolPlace = await place(carol);
|
|
141
|
+
assert.equal((await cancel(carol, seat.id)).status, 409);
|
|
142
|
+
assert.equal(!!(await place(bob)).waitlisted, true);
|
|
143
|
+
assert.equal((await place(alice)).id, seat.id);
|
|
144
|
+
step("another member cancelling the seat is still 409 and nobody is promoted: the whole program rolled back");
|
|
145
|
+
|
|
146
|
+
assert.equal((await cancel(alice, seat.id)).status, 200);
|
|
147
|
+
assert.equal(!!(await place(bob)).waitlisted, false);
|
|
148
|
+
assert.equal(!!(await place(carol)).waitlisted, true);
|
|
149
|
+
assert.equal(await seatsOf(talk.id), 0);
|
|
150
|
+
step("cancelling a seat promotes the earliest waiter; the seat passes on and seats_left stays 0");
|
|
151
|
+
|
|
152
|
+
assert.equal((await cancel(carol, carolPlace.id)).status, 200);
|
|
153
|
+
assert.equal(await seatsOf(talk.id), 0);
|
|
154
|
+
assert.equal((await cancel(bob, bobPlace.id)).status, 200);
|
|
155
|
+
assert.equal(await seatsOf(talk.id), 1);
|
|
156
|
+
step("leaving the waiting list frees no seat; cancelling with nobody waiting returns the seat");
|
|
127
157
|
});
|
|
@@ -3,23 +3,24 @@ import { userOf } from "@aotter/mantle/auth";
|
|
|
3
3
|
import type { Handlers } from "../generated/generated.js";
|
|
4
4
|
|
|
5
5
|
export const handlers: Handlers = {
|
|
6
|
-
register: async ({ caller, store }, { eventId }) => {
|
|
6
|
+
register: async ({ caller, store }, { eventId, waitlist }) => {
|
|
7
7
|
if (caller.kind !== "user") throw new ContractError("AUTH_DENIED", "Sign in as a member to register.", "procedures/register");
|
|
8
8
|
// Check before writing: a clear refusal for a missing or closed event. Seats are NOT checked here;
|
|
9
9
|
// the `seats_taken <= capacity` check in register-write is what holds under concurrent requests.
|
|
10
10
|
const event = await store.collection("events").findById(eventId);
|
|
11
11
|
if (!event) throw new ContractError("NOT_FOUND", "No such event.", "procedures/register");
|
|
12
12
|
if (event.closed) throw new ContractError("CONFLICT", "Registration for this event is closed.", "procedures/register");
|
|
13
|
+
// `member` comes from the verified caller, never from request input; uniqueIndexes [eventId, member] refuses a second seat.
|
|
14
|
+
// userOf: the Better Auth user record the caller was resolved from (any credential), so officers see who registered.
|
|
15
|
+
const user = userOf(caller), row = { eventId, member: caller.subject, memberName: user?.name || user?.email || caller.subject };
|
|
13
16
|
try {
|
|
14
|
-
|
|
15
|
-
// userOf: the Better Auth user record the caller was resolved from (any credential), so officers see who registered.
|
|
16
|
-
const user = userOf(caller);
|
|
17
|
-
return await store.procedures["register-write"]({ eventId, member: caller.subject, memberName: user?.name || user?.email || caller.subject });
|
|
17
|
+
return await store.procedures["register-write"](row);
|
|
18
18
|
} catch (error) {
|
|
19
19
|
// register-write's only Schema check is the capacity one.
|
|
20
|
-
if (error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "events")
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
if (!(error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "events")) throw error;
|
|
21
|
+
if (!waitlist) throw new ContractError("CONFLICT", "The event is full.", "procedures/register");
|
|
22
|
+
// ponytail: a seat freed between the two calls stays unused until the next cancel promotes a waiter.
|
|
23
|
+
return await store.procedures["waitlist-write"](row);
|
|
23
24
|
}
|
|
24
25
|
},
|
|
25
26
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Application-owned frontend MCP App
|
|
2
|
+
|
|
3
|
+
This small React entry uses the official ext-apps `useApp` and optional `mantle-ui` presentation. It imports no `mantle-admin`, Provider, Admin hooks or Admin API. The application owns its build and domain page. Keep React and React DOM pinned to one exact version; install the version-matched Core, optional UI, official ext-apps and its peers. Bundle `app.tsx` into a single-file HTML document containing `<div id="root"></div>` with your browser build tool.
|
|
4
|
+
|
|
5
|
+
Generate the accompanying manifest with `mantle generate <target> generated manifest.yaml`. Register the built HTML through the existing Core contributor, on the frontend endpoint:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { mcp } from "./generated/mcp.js";
|
|
9
|
+
const handle = mcp({
|
|
10
|
+
db: env => env.DB,
|
|
11
|
+
caller: request => verifiedCaller(request),
|
|
12
|
+
apps: { public: { resources: [{
|
|
13
|
+
uri: "ui://my-tasks/build-1", name: "my-tasks", renders: ["my_tasks"],
|
|
14
|
+
html: builtHtml,
|
|
15
|
+
}] } },
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`verifiedCaller` is the application's real authentication integration; never derive authority from tool arguments, page state or a user-supplied owner ID. There are no credentials in the HTML. Change the resource URI for a new UI build and update registration consistently. Core owns resource/tool registration and caller enforcement; it neither builds this UI nor imports React. Official ext-apps owns the connection. UI only renders data and invokes callbacks.
|
|
20
|
+
|
|
21
|
+
`public` names the frontend surface, **not anonymous permission**. `my-tasks` requires a verified user, and the Schema scope limits SQL rows to `auth.uid()`. Admin tools are a separate surface. UI visibility grants no access. The example's load-more calls the same declared View through the host; it does not fetch `/admin/api`. Opening it performs no write. The application reads its consumed row fields and cursor from structured output or a JSON text result; malformed results show an error, a fresh valid result restores the page, and a pending older page cannot append to a newer result. Protocol metadata remains the official client’s responsibility.
|
|
22
|
+
|
|
23
|
+
Repository acceptance: `bun test packages/mantle/tests/frontend-mcp-app.test.ts` checks generated SQL scope, anonymous/member denials, owner paging and resource linkage. `MANTLE_BROWSER_TESTS=1 MANTLE_TEST_CHROMIUM=/path/to/chrome pnpm --filter @aotter/mantle-admin exec vitest run test/frontend-mcp-app-browser.test.ts` builds this entry without Admin runtime code and renders it in the official AppBridge. The latter command reuses repository browser tooling only; Admin is not an application dependency. This is not an independently installed packed consumer or a real ChatGPT/Claude acceptance result; those remain separate deployment checks.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { useApp } from "@modelcontextprotocol/ext-apps/react";
|
|
4
|
+
import { EntityPreview } from "@aotter/mantle-ui";
|
|
5
|
+
|
|
6
|
+
interface Rows { rows: { id: string; title: string; version: number }[]; nextCursor?: string }
|
|
7
|
+
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
function readRows(result: { isError?: boolean; structuredContent?: unknown; content: readonly { type: string; text?: string }[] }): Rows {
|
|
9
|
+
if (result.isError) throw new Error("Unable to read tasks");
|
|
10
|
+
const output: unknown = result.structuredContent ?? JSON.parse(result.content.find(block => block.type === "text")?.text ?? "");
|
|
11
|
+
if (!record(output) || !Array.isArray(output.rows) || (output.nextCursor != null && (typeof output.nextCursor !== "string" || !output.nextCursor))) throw new Error("Invalid task result");
|
|
12
|
+
const rows = output.rows.map((row: unknown) => {
|
|
13
|
+
if (!record(row) || typeof row.id !== "string" || typeof row.title !== "string" || typeof row.version !== "number" || !Number.isSafeInteger(row.version) || row.version < 1) throw new Error("Invalid task row");
|
|
14
|
+
return { id: row.id, title: row.title, version: row.version };
|
|
15
|
+
});
|
|
16
|
+
return { rows, ...(typeof output.nextCursor === "string" ? { nextCursor: output.nextCursor } : {}) };
|
|
17
|
+
}
|
|
18
|
+
function Frontend() {
|
|
19
|
+
const generation = useRef(0);
|
|
20
|
+
const [page, setPage] = useState<Rows>({ rows: [] });
|
|
21
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
22
|
+
const [busy, setBusy] = useState(false);
|
|
23
|
+
const { app, isConnected, error } = useApp({
|
|
24
|
+
appInfo: { name: "my-tasks", version: "1" }, capabilities: {},
|
|
25
|
+
onAppCreated: app => { app.ontoolresult = result => {
|
|
26
|
+
// This application consumes its declared View result, not Admin metadata.
|
|
27
|
+
generation.current++;
|
|
28
|
+
try { setPage(readRows(result)); setFailure(null); }
|
|
29
|
+
catch { setFailure("Unable to read tasks. Ask for the task list again."); }
|
|
30
|
+
}; },
|
|
31
|
+
});
|
|
32
|
+
if (error || failure) return <p role="alert">{error?.message ?? failure}</p>;
|
|
33
|
+
if (!isConnected) return <p>Connecting…</p>;
|
|
34
|
+
const more = async () => {
|
|
35
|
+
if (!app || busy || !page.nextCursor) return;
|
|
36
|
+
const opened = generation.current;
|
|
37
|
+
setBusy(true);
|
|
38
|
+
try {
|
|
39
|
+
const result = await app.callServerTool({ name: "my_tasks", arguments: { limit: 1, cursor: page.nextCursor } });
|
|
40
|
+
const next = readRows(result);
|
|
41
|
+
if (opened !== generation.current) return;
|
|
42
|
+
setPage(current => ({ rows: [...current.rows, ...next.rows], ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}) }));
|
|
43
|
+
} catch { if (opened === generation.current) setFailure("Unable to load more tasks"); }
|
|
44
|
+
finally { setBusy(false); }
|
|
45
|
+
};
|
|
46
|
+
return <main aria-busy={busy}><h1>My tasks</h1>{page.rows.map(row => <EntityPreview key={row.id} entry={{ id: row.id, version: row.version, data: { title: row.title } }} />)}
|
|
47
|
+
{page.nextCursor && app?.getHostCapabilities()?.serverTools ? <button disabled={busy} onClick={() => { void more(); }}>Load more</button> : null}
|
|
48
|
+
</main>;
|
|
49
|
+
}
|
|
50
|
+
createRoot(document.getElementById("root")!).render(<Frontend />);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
2
|
+
kind: Schema
|
|
3
|
+
metadata: {name: tasks}
|
|
4
|
+
spec:
|
|
5
|
+
title: Tasks
|
|
6
|
+
lifecycle: operational
|
|
7
|
+
scope: owner = auth.uid()
|
|
8
|
+
indexes: [[owner, id]]
|
|
9
|
+
schema:
|
|
10
|
+
type: object
|
|
11
|
+
required: [owner, title]
|
|
12
|
+
properties:
|
|
13
|
+
owner: {type: string}
|
|
14
|
+
title: {type: string}
|
|
15
|
+
---
|
|
16
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
17
|
+
kind: View
|
|
18
|
+
metadata: {name: my-tasks}
|
|
19
|
+
spec:
|
|
20
|
+
surface: public
|
|
21
|
+
requires: {auth: {all: [ctx.user]}}
|
|
22
|
+
sql: SELECT id, title, version FROM tasks ORDER BY id
|
|
23
|
+
---
|
|
24
|
+
apiVersion: cms.mantle.aotter.net/v2
|
|
25
|
+
kind: View
|
|
26
|
+
metadata: {name: administration}
|
|
27
|
+
spec:
|
|
28
|
+
surface: admin
|
|
29
|
+
requires: {auth: {all: [ctx.admin]}}
|
|
30
|
+
sql: SELECT id, title, version FROM tasks ORDER BY id
|
|
@@ -8,6 +8,11 @@ that have been waiting too long.
|
|
|
8
8
|
|
|
9
9
|
## Capabilities shown
|
|
10
10
|
|
|
11
|
+
- **Declared relationships**: each request stores a read-only `budgetId` reference to its budget. Each ledger row references
|
|
12
|
+
both its budget and its request; `uniqueIndexes: [[requestId]]` permits at most one ledger row per request. The ER diagram
|
|
13
|
+
shows budget-to-request and budget-to-ledger as one-to-many. Department names remain readable snapshots; spending joins
|
|
14
|
+
stable budget IDs. These references are model metadata, not database foreign keys. Requests and ledger remain available
|
|
15
|
+
as standalone Admin collections, with the ledger also grouped under its request.
|
|
11
16
|
- **Status workflow with guarded transitions**: the write Procedures use
|
|
12
17
|
`UPDATE requests SET requestStatus = 'approved' ... WHERE id = input.id AND requestStatus = 'submitted'`. A match on
|
|
13
18
|
0 rows is a `CONFLICT` (409). See `manifests/purchasing.yaml` (`approve-request-write`, `reject-request-write`,
|
|
@@ -65,6 +70,10 @@ scheme.
|
|
|
65
70
|
|
|
66
71
|
## Run it
|
|
67
72
|
|
|
73
|
+
Existing copies need a native migration for the new `budgetId` fields on requests
|
|
74
|
+
and ledger: backfill them from the existing department budget and request,
|
|
75
|
+
then enforce NOT NULL. Regeneration alone does not migrate an occupied database.
|
|
76
|
+
|
|
68
77
|
This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
|
|
69
78
|
|
|
70
79
|
```sh
|
|
@@ -25,21 +25,22 @@ metadata: { name: requests }
|
|
|
25
25
|
spec:
|
|
26
26
|
title: Purchase requests
|
|
27
27
|
lifecycle: operational
|
|
28
|
-
indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id]]
|
|
28
|
+
indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id], [budgetId, createdAt, id]]
|
|
29
29
|
searchableFields: [title, department]
|
|
30
30
|
# Sidebar: in the Approvals group (src/admin.tsx), after the queue of requests waiting for a decision.
|
|
31
|
-
uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { group: approvals, order: 2 } }
|
|
31
|
+
uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { standalone: true, group: approvals, order: 2 } }
|
|
32
32
|
schema:
|
|
33
33
|
type: object
|
|
34
34
|
# Admin reads requests but cannot edit them generically: a generic edit could move requestStatus back to
|
|
35
35
|
# 'submitted' and spend the budget twice. Every change goes through the guarded Procedures below.
|
|
36
36
|
readOnly: true
|
|
37
37
|
additionalProperties: false
|
|
38
|
-
required: [title, amount, department, requestStatus]
|
|
38
|
+
required: [title, amount, department, budgetId, requestStatus]
|
|
39
39
|
properties:
|
|
40
40
|
title: { type: string, minLength: 1 }
|
|
41
41
|
amount: { type: integer, minimum: 1 }
|
|
42
42
|
department: { type: string }
|
|
43
|
+
budgetId: { type: string, x-mantle-ref: budgets, readOnly: true }
|
|
43
44
|
requestStatus: { type: string, enum: [submitted, approved, rejected, ordered, confirmed] }
|
|
44
45
|
supplierOrderId: { type: string }
|
|
45
46
|
---
|
|
@@ -49,18 +50,19 @@ metadata: { name: ledger }
|
|
|
49
50
|
spec:
|
|
50
51
|
title: Budget ledger
|
|
51
52
|
lifecycle: operational
|
|
52
|
-
indexes: [[createdAt, id]]
|
|
53
|
+
indexes: [[createdAt, id], [budgetId, createdAt, id]]
|
|
53
54
|
# One row per spending request.
|
|
54
55
|
uniqueIndexes: [[requestId]]
|
|
55
56
|
searchableFields: [department]
|
|
56
|
-
uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
|
|
57
|
+
uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] }, nav: { parentField: requestId, standalone: true } }
|
|
57
58
|
schema:
|
|
58
59
|
type: object
|
|
59
60
|
readOnly: true # written only by approve-request-write and submit-request
|
|
60
61
|
additionalProperties: false
|
|
61
|
-
required: [requestId, department, amount, remainingAfter]
|
|
62
|
+
required: [requestId, budgetId, department, amount, remainingAfter]
|
|
62
63
|
properties:
|
|
63
|
-
requestId: { type: string }
|
|
64
|
+
requestId: { type: string, x-mantle-ref: requests }
|
|
65
|
+
budgetId: { type: string, x-mantle-ref: budgets }
|
|
64
66
|
department: { type: string }
|
|
65
67
|
amount: { type: integer }
|
|
66
68
|
remainingAfter: { type: integer }
|
|
@@ -140,17 +142,17 @@ spec:
|
|
|
140
142
|
# overspend. A pending request matches nothing there, which a set form (IN (SELECT ...), INSERT ... SELECT) allows.
|
|
141
143
|
handler:
|
|
142
144
|
sql: |
|
|
143
|
-
INSERT INTO requests (title, amount, department, requestStatus)
|
|
144
|
-
SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END
|
|
145
|
+
INSERT INTO requests (title, amount, department, requestStatus, budgetId)
|
|
146
|
+
SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END, b.id
|
|
145
147
|
FROM budgets b WHERE b.department = input.department
|
|
146
148
|
RETURNING id, version, requestStatus AS "requestStatus";
|
|
147
|
-
UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.
|
|
149
|
+
UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.budgetId = budgets.id
|
|
148
150
|
AND r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
|
|
149
151
|
AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id))
|
|
150
|
-
WHERE
|
|
152
|
+
WHERE id IN (SELECT r.budgetId FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
|
|
151
153
|
AND r.requestStatus = 'approved' AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id));
|
|
152
|
-
INSERT INTO ledger (requestId, department, amount, remainingAfter)
|
|
153
|
-
SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.
|
|
154
|
+
INSERT INTO ledger (requestId, budgetId, department, amount, remainingAfter)
|
|
155
|
+
SELECT r.id, r.budgetId, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.id = r.budgetId
|
|
154
156
|
WHERE r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
|
|
155
157
|
AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id)
|
|
156
158
|
---
|
|
@@ -220,9 +222,9 @@ spec:
|
|
|
220
222
|
WHERE id = input.id AND version = input.expectedVersion AND requestStatus = 'submitted'
|
|
221
223
|
RETURNING id, title, amount, author_id AS "authorId";
|
|
222
224
|
UPDATE budgets SET remaining = remaining - (SELECT r.amount FROM requests r WHERE r.id = input.id)
|
|
223
|
-
WHERE
|
|
224
|
-
INSERT INTO ledger (requestId, department, amount, remainingAfter)
|
|
225
|
-
SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.
|
|
225
|
+
WHERE id = (SELECT r.budgetId FROM requests r WHERE r.id = input.id);
|
|
226
|
+
INSERT INTO ledger (requestId, budgetId, department, amount, remainingAfter)
|
|
227
|
+
SELECT r.id, r.budgetId, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.id = r.budgetId
|
|
226
228
|
WHERE r.id = input.id
|
|
227
229
|
---
|
|
228
230
|
apiVersion: cms.mantle.aotter.net/v2
|
|
@@ -185,6 +185,11 @@ try {
|
|
|
185
185
|
assert.equal((await ledgerRows()).length, ledgerBefore);
|
|
186
186
|
assert.equal((await query("SELECT count(*) AS n FROM requests"))[0].n, requestsBefore);
|
|
187
187
|
step("CASE approves a request of 100 or less at submission: the status, the budget and the ledger row commit together, and one that would overspend is refused with nothing written");
|
|
188
|
+
const engineeringBudget = (await query("SELECT id FROM budgets WHERE department = 'Engineering'"))[0].id;
|
|
189
|
+
assert.equal((await query(`SELECT budgetid FROM requests WHERE id = '${laptop}'`))[0].budgetid, engineeringBudget);
|
|
190
|
+
assert.equal((await query(`SELECT budgetid FROM ledger WHERE requestid = '${pens.id}'`))[0].budgetid, engineeringBudget);
|
|
191
|
+
assert.equal((await query("SELECT COUNT(*) AS n FROM ledger l JOIN requests r ON r.id = l.requestid WHERE l.budgetid <> r.budgetid"))[0].n, 0);
|
|
192
|
+
step("requests and ledger rows reference the same stable budget, with at most one ledger row per request");
|
|
188
193
|
}, { vars: { EMPLOYEE_DOMAIN: "example.test", SUPPLIER_API_URL: `http://127.0.0.1:${supplier.address().port}/orders`, SUPPLIER_WEBHOOK_SECRET: SECRET } });
|
|
189
194
|
} finally {
|
|
190
195
|
supplier.close();
|