frappebun 0.0.0
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 +72 -0
- package/package.json +59 -0
- package/src/api/auth.ts +76 -0
- package/src/api/index.ts +10 -0
- package/src/api/resource.ts +177 -0
- package/src/api/route.ts +301 -0
- package/src/app/index.ts +6 -0
- package/src/app/loader.ts +218 -0
- package/src/auth/auth.ts +247 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/args.ts +40 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/commands/add-api.ts +32 -0
- package/src/cli/commands/add-doctype.ts +43 -0
- package/src/cli/commands/add-page.ts +33 -0
- package/src/cli/commands/add-user.ts +96 -0
- package/src/cli/commands/dev.ts +71 -0
- package/src/cli/commands/drop-site.ts +27 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +110 -0
- package/src/cli/commands/new-site.ts +61 -0
- package/src/cli/commands/routes.ts +56 -0
- package/src/cli/commands/use.ts +30 -0
- package/src/cli/index.ts +73 -0
- package/src/cli/log.ts +13 -0
- package/src/cli/scaffold/templates.ts +189 -0
- package/src/context.ts +162 -0
- package/src/core/doctype/migration/migration.ts +17 -0
- package/src/core/doctype/role/role.ts +7 -0
- package/src/core/doctype/session/session.ts +16 -0
- package/src/core/doctype/user/user.controller.ts +11 -0
- package/src/core/doctype/user/user.ts +22 -0
- package/src/core/doctype/user_role/user_role.ts +9 -0
- package/src/core/doctypes.ts +25 -0
- package/src/core/index.ts +1 -0
- package/src/database/database.ts +359 -0
- package/src/database/filters.ts +131 -0
- package/src/database/index.ts +30 -0
- package/src/database/query-builder.ts +1118 -0
- package/src/database/schema.ts +188 -0
- package/src/doctype/define.ts +45 -0
- package/src/doctype/discovery.ts +57 -0
- package/src/doctype/field.ts +160 -0
- package/src/doctype/index.ts +20 -0
- package/src/doctype/layout.ts +62 -0
- package/src/doctype/query-builder-stub.ts +16 -0
- package/src/doctype/registry.ts +106 -0
- package/src/doctype/types.ts +407 -0
- package/src/document/document.ts +593 -0
- package/src/document/index.ts +6 -0
- package/src/document/naming.ts +56 -0
- package/src/errors.ts +53 -0
- package/src/frappe.d.ts +128 -0
- package/src/globals.ts +72 -0
- package/src/index.ts +112 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/runner.ts +256 -0
- package/src/permissions/index.ts +265 -0
- package/src/response.ts +100 -0
- package/src/server.ts +210 -0
- package/src/site.ts +126 -0
- package/src/ssr/handler.ts +56 -0
- package/src/ssr/index.ts +11 -0
- package/src/ssr/page-loader.ts +200 -0
- package/src/ssr/renderer.ts +94 -0
- package/src/ssr/use-context.ts +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frappebun 🍞⚡
|
|
2
|
+
|
|
3
|
+
[](https://github.com/netchampfaris/frappebun/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/netchampfaris/frappebun/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
## Quick Start
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun install frappebun
|
|
10
|
+
|
|
11
|
+
frappe init todo
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Motivation
|
|
16
|
+
|
|
17
|
+
I've been working with Frappe Framework for a long time. There is a lot I love about it — DocType-driven development, multi-tenancy, the idea that defining your data should give you most of what you need. But there's also a lot of baggage: Python, Redis, MariaDB, a hook system held together by strings, and an API surface that grew organically over many years without much care.
|
|
18
|
+
|
|
19
|
+
This is my imagination of Frappe Framework if we designed it fresh today, with TypeScript and Bun, and design each API with taste and care.
|
|
20
|
+
|
|
21
|
+
## The Core Idea
|
|
22
|
+
|
|
23
|
+
**Define your data, get everything for free.**
|
|
24
|
+
|
|
25
|
+
A single DocType definition should be enough to generate the database schema, REST API, forms, list views, permissions, and search — with little to no glue code. The framework should feel inevitable to read. Controllers should be pure logic. Imports should be minimal. There should be one obvious way to do each common thing.
|
|
26
|
+
|
|
27
|
+
A Frappe developer should feel at home immediately. A non-Frappe developer should understand most files without reading docs.
|
|
28
|
+
|
|
29
|
+
## Progress
|
|
30
|
+
|
|
31
|
+
| Area | Status | Notes |
|
|
32
|
+
|------|--------|-------|
|
|
33
|
+
| Runtime & globals | ✅ Done | Bun HTTP server, `AsyncLocalStorage` context, ambient `frappe` global |
|
|
34
|
+
| DocType definition | ✅ Done | `doctype()`, full field library, layout helpers |
|
|
35
|
+
| Query builder | ✅ Done | Fluent `frappe.query()`, link-field dot-notation, filters |
|
|
36
|
+
| Database | ✅ Done | `frappe.db.*` API, SQLite adapter |
|
|
37
|
+
| Auth | ✅ Done | Session auth, `User` / `Session` DocTypes, API key auth |
|
|
38
|
+
| Document CRUD | ✅ Done | Lifecycle hooks, validation, naming strategies |
|
|
39
|
+
| REST API | ✅ Done | Opt-in `rest: true`, explicit `route()` registration |
|
|
40
|
+
| Permissions | ✅ Done | Role-based, `scope()`, per-action checks |
|
|
41
|
+
| App loading | ✅ Done | Auto-discovery, multi-site |
|
|
42
|
+
| CLI | ✅ Done | `frappe init`, `frappe dev`, `frappe new-site`, `frappe migrate` |
|
|
43
|
+
| Migrations | ✅ Done | Schema sync, data migrations, patch runner |
|
|
44
|
+
| Frontend SSR (minimal) | 🔄 In progress | Basic Vue SSR, `<script context>`, hydration |
|
|
45
|
+
| Hooks & extensions | | App extension system, doc event listeners |
|
|
46
|
+
| Background jobs | | Job queue, scheduled tasks |
|
|
47
|
+
| Frontend SSR (full) | | File-based routing, full SSR pipeline |
|
|
48
|
+
| Admin desk | | Auto-generated forms and list views |
|
|
49
|
+
| Frontend composables | | `useDoc`, `useDocList`, realtime bindings |
|
|
50
|
+
| Realtime | | WebSocket pub/sub, document subscriptions |
|
|
51
|
+
| Visual builder | | Two-way `.ts` sync with a GUI DocType editor |
|
|
52
|
+
| Email | | Sending, templates, incoming email |
|
|
53
|
+
| Files | | Upload handling, S3/local adapters |
|
|
54
|
+
|
|
55
|
+
## Why frappebun
|
|
56
|
+
|
|
57
|
+
- **Fast** — Bun startup is near-instant, SQLite has no network hop, no Redis in the critical path
|
|
58
|
+
- **One process** — HTTP, WebSocket, background jobs, and asset bundling in one `bun` process, no daemons
|
|
59
|
+
- **Two file types** — `.ts` for server code and schema, `.vue` for UI; no Python, no Jinja, no `requirements.txt`
|
|
60
|
+
- **SQLite by default** — no setup to start; switch to Postgres when you need it
|
|
61
|
+
- **Single app per project** — one project, one app, one namespace; no multi-app overhead
|
|
62
|
+
- **Auto-migration in dev** — add a field, save, it's in the database; no `bench migrate` in dev
|
|
63
|
+
- **Ambient globals** — `frappe`, `doctype`, `controller`, `field` are global; controllers have zero imports
|
|
64
|
+
- **Schema and logic are separate** — `doctype()` files are restricted to literals and field helpers; no logic leaks in
|
|
65
|
+
- **Typed hooks** — hooks are typed function references, not strings; your editor can navigate and refactor them
|
|
66
|
+
- **TypeScript end-to-end** — one language across server, schema, and frontend
|
|
67
|
+
|
|
68
|
+
## Start Here
|
|
69
|
+
|
|
70
|
+
- **[spec/goals.md](./spec/goals.md)** — Product direction, philosophy, and design taste
|
|
71
|
+
- **[spec/api-reference.md](./spec/api-reference.md)** — The intended `frappe` API surface
|
|
72
|
+
- **[spec/spec-plan.md](./spec/spec-plan.md)** — Spec index, layered plan, and build order
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "frappebun",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "A clean, TypeScript-native Frappe-style framework for Bun",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"frappe",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"typescript",
|
|
10
|
+
"sqlite"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/netchampfaris/frappebun",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/netchampfaris/frappebun.git"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"frappe": "src/cli/bin.ts"
|
|
23
|
+
},
|
|
24
|
+
"main": "src/index.ts",
|
|
25
|
+
"types": "src/frappe.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./cli": "./src/cli/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src/",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"prepare": "git config core.hooksPath .githooks",
|
|
37
|
+
"prepublishOnly": "bun run check && bun test",
|
|
38
|
+
"test": "bun test",
|
|
39
|
+
"test:coverage": "bun test --coverage",
|
|
40
|
+
"lint": "oxlint src tests",
|
|
41
|
+
"lint:fix": "oxlint src tests --fix",
|
|
42
|
+
"format": "oxfmt src tests",
|
|
43
|
+
"format:check": "oxfmt src tests --check",
|
|
44
|
+
"check": "bun run lint && bun run format:check"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@vue/compiler-sfc": "^3.5.32",
|
|
48
|
+
"@vue/server-renderer": "^3.5.32",
|
|
49
|
+
"vue": "^3.5.32"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "latest",
|
|
53
|
+
"oxfmt": "^0.43.0",
|
|
54
|
+
"oxlint": "^1.58.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"typescript": "^5.0.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/api/auth.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/auth — login, logout, and CSRF token endpoints.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { contextStore } from "../context"
|
|
6
|
+
import { response } from "../response"
|
|
7
|
+
import { route } from "./route"
|
|
8
|
+
|
|
9
|
+
// POST /api/auth/login
|
|
10
|
+
export const login = route({
|
|
11
|
+
path: "/api/auth/login",
|
|
12
|
+
auth: "public",
|
|
13
|
+
method: "POST",
|
|
14
|
+
async handler({ email, password }: { email?: unknown; password?: unknown }) {
|
|
15
|
+
if (!email || !password) return response.error("email and password are required", 400)
|
|
16
|
+
|
|
17
|
+
const ctx = contextStore.getStore()!
|
|
18
|
+
const { login: loginFn } = await import("../auth/auth")
|
|
19
|
+
const result = await loginFn(String(email), String(password), ctx.db, ctx.request)
|
|
20
|
+
|
|
21
|
+
if (!result) return response.unauthorized("Invalid email or password")
|
|
22
|
+
|
|
23
|
+
// Return Response directly to control the Set-Cookie header
|
|
24
|
+
return new Response(
|
|
25
|
+
JSON.stringify({ user: result.user, fullName: result.fullName, csrfToken: result.csrfToken }),
|
|
26
|
+
{ status: 200, headers: { "Content-Type": "application/json", "Set-Cookie": result.cookie } },
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// POST /api/auth/logout
|
|
32
|
+
export const logout = route({
|
|
33
|
+
path: "/api/auth/logout",
|
|
34
|
+
auth: "public",
|
|
35
|
+
method: "POST",
|
|
36
|
+
async handler() {
|
|
37
|
+
const ctx = contextStore.getStore()!
|
|
38
|
+
const req = ctx.request
|
|
39
|
+
|
|
40
|
+
if (req) {
|
|
41
|
+
const sid = parseCookie(req.headers.get("Cookie") ?? "", "sid")
|
|
42
|
+
if (sid) {
|
|
43
|
+
const { logout: logoutFn } = await import("../auth/auth")
|
|
44
|
+
const clearCookie = await logoutFn(sid, ctx.db)
|
|
45
|
+
// Return Response directly to control the Set-Cookie header
|
|
46
|
+
return new Response(JSON.stringify({ message: "Logged out" }), {
|
|
47
|
+
status: 200,
|
|
48
|
+
headers: { "Content-Type": "application/json", "Set-Cookie": clearCookie },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return response.ok({ message: "Logged out" })
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// GET /api/auth/csrf
|
|
58
|
+
export const csrf = route({
|
|
59
|
+
path: "/api/auth/csrf",
|
|
60
|
+
auth: "public",
|
|
61
|
+
method: "GET",
|
|
62
|
+
async handler() {
|
|
63
|
+
const ctx = contextStore.getStore()
|
|
64
|
+
return response.ok({ csrfToken: ctx?.session.csrfToken ?? null })
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ─── helpers ──────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function parseCookie(header: string, name: string): string | undefined {
|
|
71
|
+
for (const part of header.split(";")) {
|
|
72
|
+
const eq = part.indexOf("=")
|
|
73
|
+
if (eq === -1) continue
|
|
74
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/resource — auto-generated DocType REST CRUD.
|
|
3
|
+
*
|
|
4
|
+
* One route() export per operation. The server dispatcher selects the right
|
|
5
|
+
* one based on HTTP method and URL shape, then calls invokeRoute() with the
|
|
6
|
+
* extracted path params ({ doctype, name }).
|
|
7
|
+
*
|
|
8
|
+
* All resource endpoints require authentication (auth: "required").
|
|
9
|
+
* For public/guest access to a DocType, write an explicit route() endpoint
|
|
10
|
+
* with auth: "public" in your app's api/ directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { contextStore } from "../context"
|
|
14
|
+
import { getDocTypeMeta } from "../doctype/registry"
|
|
15
|
+
import type { Dict, FilterInput, RestConfig } from "../doctype/types"
|
|
16
|
+
import { hasRolePermission } from "../permissions"
|
|
17
|
+
import { response } from "../response"
|
|
18
|
+
import { route } from "./route"
|
|
19
|
+
|
|
20
|
+
// ─── REST flag ────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
type RestOp = "list" | "read" | "create" | "update" | "delete" | "submit" | "cancel"
|
|
23
|
+
|
|
24
|
+
function isRestEnabled(doctype: string, op: RestOp): boolean {
|
|
25
|
+
const rest = getDocTypeMeta(doctype)?.schema.rest
|
|
26
|
+
if (!rest) return false
|
|
27
|
+
if (rest === true) return true
|
|
28
|
+
return (rest as RestConfig)[op] === true
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Shared access guard ──────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function deny(doctype: string, op: RestOp): Response | null {
|
|
34
|
+
if (!isRestEnabled(doctype, op))
|
|
35
|
+
return response.forbidden(`REST ${op} not enabled for ${doctype}`)
|
|
36
|
+
const ctx = contextStore.getStore()!
|
|
37
|
+
const permAction = op === "list" || op === "read" ? "read" : op === "update" ? "write" : op
|
|
38
|
+
if (!hasRolePermission(doctype, permAction, ctx.user, ctx.db, ctx)) return response.forbidden()
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── GET /api/resource/:doctype ───────────────────────────
|
|
43
|
+
|
|
44
|
+
export const list = route({
|
|
45
|
+
path: "/api/resource/:doctype",
|
|
46
|
+
auth: "required",
|
|
47
|
+
method: "GET",
|
|
48
|
+
async handler({
|
|
49
|
+
doctype,
|
|
50
|
+
filters,
|
|
51
|
+
fields,
|
|
52
|
+
order_by,
|
|
53
|
+
limit,
|
|
54
|
+
offset,
|
|
55
|
+
}: {
|
|
56
|
+
doctype: string
|
|
57
|
+
filters?: string
|
|
58
|
+
fields?: string
|
|
59
|
+
order_by?: string
|
|
60
|
+
limit?: string
|
|
61
|
+
offset?: string
|
|
62
|
+
}) {
|
|
63
|
+
const err = deny(doctype, "list")
|
|
64
|
+
if (err) return err
|
|
65
|
+
|
|
66
|
+
const ctx = contextStore.getStore()!
|
|
67
|
+
const rows = await ctx.getList(doctype, {
|
|
68
|
+
filters: filters ? (JSON.parse(filters) as FilterInput) : undefined,
|
|
69
|
+
fields: fields ? (JSON.parse(fields) as string[]) : ["name"],
|
|
70
|
+
orderBy: order_by,
|
|
71
|
+
limit: limit ? parseInt(limit) : 20,
|
|
72
|
+
offset: offset ? parseInt(offset) : 0,
|
|
73
|
+
})
|
|
74
|
+
return response.ok(rows)
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ─── GET /api/resource/:doctype/:name ─────────────────────
|
|
79
|
+
|
|
80
|
+
export const read = route({
|
|
81
|
+
path: "/api/resource/:doctype/:name",
|
|
82
|
+
auth: "required",
|
|
83
|
+
method: "GET",
|
|
84
|
+
async handler({ doctype, name }: { doctype: string; name: string }) {
|
|
85
|
+
const err = deny(doctype, "read")
|
|
86
|
+
if (err) return err
|
|
87
|
+
|
|
88
|
+
const doc = await contextStore.getStore()!.getDoc(doctype, name)
|
|
89
|
+
return response.ok(doc.asDict())
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// ─── POST /api/resource/:doctype ──────────────────────────
|
|
94
|
+
|
|
95
|
+
export const create = route({
|
|
96
|
+
path: "/api/resource/:doctype",
|
|
97
|
+
auth: "required",
|
|
98
|
+
method: "POST",
|
|
99
|
+
async handler({ doctype, ...body }: { doctype: string } & Dict) {
|
|
100
|
+
const err = deny(doctype, "create")
|
|
101
|
+
if (err) return err
|
|
102
|
+
|
|
103
|
+
const doc = contextStore.getStore()!.newDoc(doctype, body as Dict)
|
|
104
|
+
await doc.insert()
|
|
105
|
+
return response.created(doc.asDict())
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ─── PUT /api/resource/:doctype/:name ─────────────────────
|
|
110
|
+
|
|
111
|
+
export const update = route({
|
|
112
|
+
path: "/api/resource/:doctype/:name",
|
|
113
|
+
auth: "required",
|
|
114
|
+
method: "PUT",
|
|
115
|
+
async handler({ doctype, name, ...body }: { doctype: string; name: string } & Dict) {
|
|
116
|
+
const err = deny(doctype, "update")
|
|
117
|
+
if (err) return err
|
|
118
|
+
|
|
119
|
+
const ctx = contextStore.getStore()!
|
|
120
|
+
const doc = await ctx.getDoc(doctype, name)
|
|
121
|
+
for (const [k, v] of Object.entries(body)) {
|
|
122
|
+
if (k !== "doctype") doc.set(k, v)
|
|
123
|
+
}
|
|
124
|
+
await doc.save()
|
|
125
|
+
return response.ok(doc.asDict())
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ─── DELETE /api/resource/:doctype/:name ──────────────────
|
|
130
|
+
|
|
131
|
+
export const remove = route({
|
|
132
|
+
path: "/api/resource/:doctype/:name",
|
|
133
|
+
auth: "required",
|
|
134
|
+
method: "DELETE",
|
|
135
|
+
async handler({ doctype, name }: { doctype: string; name: string }) {
|
|
136
|
+
const err = deny(doctype, "delete")
|
|
137
|
+
if (err) return err
|
|
138
|
+
|
|
139
|
+
await contextStore.getStore()!.deleteDoc(doctype, name)
|
|
140
|
+
return response.ok({ message: "ok" })
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── POST /api/resource/:doctype/:name/submit ─────────────
|
|
145
|
+
|
|
146
|
+
export const submit = route({
|
|
147
|
+
path: "/api/resource/:doctype/:name/submit",
|
|
148
|
+
auth: "required",
|
|
149
|
+
method: "POST",
|
|
150
|
+
async handler({ doctype, name }: { doctype: string; name: string }) {
|
|
151
|
+
const err = deny(doctype, "submit")
|
|
152
|
+
if (err) return err
|
|
153
|
+
|
|
154
|
+
const doc = await contextStore.getStore()!.getDoc(doctype, name)
|
|
155
|
+
await doc.submit()
|
|
156
|
+
return response.ok(doc.asDict())
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// ─── POST /api/resource/:doctype/:name/cancel ─────────────
|
|
161
|
+
|
|
162
|
+
export const cancel = route({
|
|
163
|
+
path: "/api/resource/:doctype/:name/cancel",
|
|
164
|
+
auth: "required",
|
|
165
|
+
method: "POST",
|
|
166
|
+
async handler({ doctype, name }: { doctype: string; name: string }) {
|
|
167
|
+
const err = deny(doctype, "cancel")
|
|
168
|
+
if (err) return err
|
|
169
|
+
|
|
170
|
+
const doc = await contextStore.getStore()!.getDoc(doctype, name)
|
|
171
|
+
await doc.cancel()
|
|
172
|
+
return response.ok(doc.asDict())
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─── helpers ──────────────────────────────────────────────
|
|
177
|
+
// resource.ts has no local helpers — all response construction uses frappe.response
|