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/src/context.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request context via AsyncLocalStorage.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
6
|
+
|
|
7
|
+
import type { FrappeDatabase } from "./database/database"
|
|
8
|
+
import type { QueryBuilder } from "./database/query-builder"
|
|
9
|
+
import type { Dict, FilterInput } from "./doctype/types"
|
|
10
|
+
import type { Document } from "./document/document"
|
|
11
|
+
import { ValidationError, type FrappeError } from "./errors"
|
|
12
|
+
import type { PermsNamespace } from "./permissions"
|
|
13
|
+
import type { FrappeResponse } from "./response"
|
|
14
|
+
|
|
15
|
+
export type { Dict }
|
|
16
|
+
|
|
17
|
+
export interface SessionContext {
|
|
18
|
+
user: string
|
|
19
|
+
sid?: string
|
|
20
|
+
csrfToken?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GetListArgs {
|
|
24
|
+
filters?: FilterInput
|
|
25
|
+
fields?: string[]
|
|
26
|
+
orderBy?: string
|
|
27
|
+
limit?: number
|
|
28
|
+
offset?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FrappeContext {
|
|
32
|
+
user: string
|
|
33
|
+
site: string
|
|
34
|
+
session: SessionContext
|
|
35
|
+
context: "request" | "job" | "migrate" | "install" | "test" | "console"
|
|
36
|
+
env: "development" | "production" | "test"
|
|
37
|
+
flags: Dict
|
|
38
|
+
cache: Dict
|
|
39
|
+
db: FrappeDatabase
|
|
40
|
+
perms: PermsNamespace
|
|
41
|
+
response: FrappeResponse
|
|
42
|
+
request?: Request
|
|
43
|
+
|
|
44
|
+
/** Create a QueryBuilder for the given DocType, bound to this context's database. */
|
|
45
|
+
query(doctype: string): QueryBuilder
|
|
46
|
+
|
|
47
|
+
getDoc(doctype: string, name: string): Promise<Document>
|
|
48
|
+
newDoc(doctype: string, data?: Dict): Document
|
|
49
|
+
getList(doctype: string, args?: GetListArgs): Promise<Dict[]>
|
|
50
|
+
getSingle(doctype: string, fieldOrFields?: string | string[]): Promise<unknown>
|
|
51
|
+
deleteDoc(doctype: string, name: string): Promise<void>
|
|
52
|
+
|
|
53
|
+
throw(
|
|
54
|
+
msg: string,
|
|
55
|
+
exc?: new (message: string, title?: string) => FrappeError,
|
|
56
|
+
title?: string,
|
|
57
|
+
): never
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const contextStore = new AsyncLocalStorage<FrappeContext>()
|
|
61
|
+
|
|
62
|
+
export interface CreateContextOptions {
|
|
63
|
+
user?: string
|
|
64
|
+
site?: string
|
|
65
|
+
context?: FrappeContext["context"]
|
|
66
|
+
env?: FrappeContext["env"]
|
|
67
|
+
db?: FrappeDatabase
|
|
68
|
+
sid?: string
|
|
69
|
+
csrfToken?: string
|
|
70
|
+
request?: Request
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createContext(opts: CreateContextOptions): FrappeContext {
|
|
74
|
+
const user = opts.user ?? "Guest"
|
|
75
|
+
|
|
76
|
+
// Lazy require() to avoid circular deps at module load time.
|
|
77
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
78
|
+
const docModule = require("./document/document") as typeof import("./document/document") // eslint-disable-line @typescript-eslint/no-require-imports
|
|
79
|
+
const { createQueryBuilder } =
|
|
80
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
81
|
+
require("./database/query-builder") as typeof import("./database/query-builder") // eslint-disable-line @typescript-eslint/no-require-imports
|
|
82
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
83
|
+
const { createPermsNamespace } = require("./permissions") as typeof import("./permissions") // eslint-disable-line @typescript-eslint/no-require-imports
|
|
84
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
85
|
+
const { response } = require("./response") as typeof import("./response") // eslint-disable-line @typescript-eslint/no-require-imports
|
|
86
|
+
|
|
87
|
+
const ctx: FrappeContext = {
|
|
88
|
+
user,
|
|
89
|
+
site: opts.site ?? "",
|
|
90
|
+
session: { user, sid: opts.sid, csrfToken: opts.csrfToken },
|
|
91
|
+
context: opts.context ?? "request",
|
|
92
|
+
env: (opts.env ?? process.env["NODE_ENV"] ?? "development") as FrappeContext["env"],
|
|
93
|
+
flags: {},
|
|
94
|
+
cache: {},
|
|
95
|
+
db: opts.db as FrappeDatabase,
|
|
96
|
+
response,
|
|
97
|
+
request: opts.request,
|
|
98
|
+
|
|
99
|
+
// Filled in below once ctx is fully constructed
|
|
100
|
+
perms: undefined as unknown as PermsNamespace,
|
|
101
|
+
|
|
102
|
+
query(doctype: string): QueryBuilder {
|
|
103
|
+
return createQueryBuilder(ctx.db.raw, doctype)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
getDoc(doctype, name) {
|
|
107
|
+
return docModule.getDoc(doctype, name, ctx.db, ctx.user)
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
newDoc(doctype, data) {
|
|
111
|
+
return docModule.newDoc(doctype, data, ctx.db, ctx.user)
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async getList(doctype, args) {
|
|
115
|
+
// oxlint-disable-next-line typescript/consistent-type-imports
|
|
116
|
+
const { getController } = require("./doctype/registry") as typeof import("./doctype/registry") // eslint-disable-line @typescript-eslint/no-require-imports
|
|
117
|
+
const controller = getController(doctype)
|
|
118
|
+
const scopeFn = controller?.permissions?.scope
|
|
119
|
+
|
|
120
|
+
if (scopeFn) {
|
|
121
|
+
const scopeQb = scopeFn(ctx.user) as QueryBuilder | null
|
|
122
|
+
if (scopeQb !== null) {
|
|
123
|
+
const a = args ?? {}
|
|
124
|
+
const fields = a.fields ?? ["name"]
|
|
125
|
+
const orderBy = (a.orderBy ?? "modified DESC") as string
|
|
126
|
+
const [orderField, rawDir] = orderBy.split(/\s+/, 2)
|
|
127
|
+
const orderDir = (rawDir ?? "desc").toLowerCase() as "asc" | "desc"
|
|
128
|
+
|
|
129
|
+
let qb = scopeQb.select(...fields)
|
|
130
|
+
if (a.filters) qb = qb.where(a.filters) as QueryBuilder
|
|
131
|
+
if (orderField) qb = qb.orderBy(orderField, orderDir) as QueryBuilder
|
|
132
|
+
return qb
|
|
133
|
+
.limit(a.limit ?? 20)
|
|
134
|
+
.offset(a.offset ?? 0)
|
|
135
|
+
.all<Dict>()
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return docModule.getList(doctype, args ?? {}, ctx.db)
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
async getSingle(doctype, fieldOrFields) {
|
|
143
|
+
return docModule.getSingle(doctype, fieldOrFields, ctx.db, ctx.user)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
deleteDoc(doctype, name) {
|
|
147
|
+
return docModule.deleteDoc(doctype, name, ctx.db, ctx.user)
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
throw(msg, exc, title) {
|
|
151
|
+
const ErrorClass = exc ?? ValidationError
|
|
152
|
+
throw new ErrorClass(msg, title)
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Initialise perms — uses ctx as the per-request cache key
|
|
157
|
+
ctx.perms = createPermsNamespace(user, ctx.db, ctx as object, (doctype, name) =>
|
|
158
|
+
ctx.getDoc(doctype, name),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return ctx
|
|
162
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export default doctype("Migration", {
|
|
2
|
+
naming: "field:migrationName",
|
|
3
|
+
module: "Core",
|
|
4
|
+
fields: {
|
|
5
|
+
migrationName: field.data({ required: true, unique: true }),
|
|
6
|
+
app: field.data({ required: true }),
|
|
7
|
+
phase: field.select(["before", "after"]),
|
|
8
|
+
executedAt: field.datetime(),
|
|
9
|
+
status: field.select(["Success", "Failed"]),
|
|
10
|
+
error: field.longText(),
|
|
11
|
+
},
|
|
12
|
+
permissions: [{ role: "System Manager", read: true }],
|
|
13
|
+
list: {
|
|
14
|
+
fields: ["migrationName", "app", "phase", "status", "executedAt"],
|
|
15
|
+
titleField: "migrationName",
|
|
16
|
+
},
|
|
17
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export default doctype("Session", {
|
|
2
|
+
naming: "uuid",
|
|
3
|
+
module: "Core",
|
|
4
|
+
fields: {
|
|
5
|
+
user: field.link("User", { required: true }),
|
|
6
|
+
sid: field.data({ required: true, unique: true }),
|
|
7
|
+
device: field.data(),
|
|
8
|
+
ipAddress: field.data(),
|
|
9
|
+
userAgent: field.smallText(),
|
|
10
|
+
expiresAt: field.datetime({ required: true }),
|
|
11
|
+
lastActive: field.datetime(),
|
|
12
|
+
status: field.select(["Active", "Expired", "Logged Out"], { default: "Active" }),
|
|
13
|
+
csrfToken: field.data(),
|
|
14
|
+
},
|
|
15
|
+
permissions: [{ role: "System Manager", read: true, write: true, delete: true }],
|
|
16
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export default controller("User", {
|
|
2
|
+
async validate(doc) {
|
|
3
|
+
// Email is used as the document name, so it cannot be changed after creation
|
|
4
|
+
if (!doc.isNew()) {
|
|
5
|
+
const dbEmail = await doc.getDbValue("email")
|
|
6
|
+
if (dbEmail && doc.get("email") !== dbEmail) {
|
|
7
|
+
frappe.throw("Email cannot be changed after creation")
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default doctype("User", {
|
|
2
|
+
naming: "field:email",
|
|
3
|
+
module: "Core",
|
|
4
|
+
fields: {
|
|
5
|
+
email: field.data({ options: "Email", required: true, unique: true }),
|
|
6
|
+
fullName: field.data({ required: true }),
|
|
7
|
+
enabled: field.check({ default: 1 }),
|
|
8
|
+
password: field.password(),
|
|
9
|
+
language: field.data({ default: "en" }),
|
|
10
|
+
userType: field.select(["System User", "Website User"], { default: "System User" }),
|
|
11
|
+
lastLogin: field.datetime({ readOnly: true }),
|
|
12
|
+
lastActiveAt: field.datetime({ readOnly: true }),
|
|
13
|
+
apiKey: field.data({ readOnly: true }),
|
|
14
|
+
apiSecret: field.password({ readOnly: true }),
|
|
15
|
+
},
|
|
16
|
+
permissions: [{ role: "System Manager", read: true, write: true, create: true, delete: true }],
|
|
17
|
+
list: {
|
|
18
|
+
fields: ["email", "fullName", "enabled", "userType"],
|
|
19
|
+
titleField: "fullName",
|
|
20
|
+
searchFields: ["email", "fullName"],
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core DocTypes registration via filesystem discovery.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join, dirname } from "node:path"
|
|
6
|
+
|
|
7
|
+
import { discoverDocTypes } from "../doctype/discovery"
|
|
8
|
+
import { hasDocType } from "../doctype/registry"
|
|
9
|
+
import { installGlobals } from "../globals"
|
|
10
|
+
|
|
11
|
+
/** Path to the core doctype/ directory (sibling to this file). */
|
|
12
|
+
const CORE_DOCTYPE_DIR = join(dirname(import.meta.path), "doctype")
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register all core DocTypes by scanning src/core/doctype/.
|
|
16
|
+
* Safe to call multiple times — skips if already registered.
|
|
17
|
+
*/
|
|
18
|
+
export async function registerCoreDocTypes(): Promise<void> {
|
|
19
|
+
if (hasDocType("User")) return
|
|
20
|
+
|
|
21
|
+
// Ensure ambient globals are available for schema files
|
|
22
|
+
installGlobals()
|
|
23
|
+
|
|
24
|
+
await discoverDocTypes(CORE_DOCTYPE_DIR)
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerCoreDocTypes } from "./doctypes"
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database abstraction — wraps bun:sqlite, provides the frappe.db API.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Database as BunSQLite } from "bun:sqlite"
|
|
6
|
+
import type { SQLQueryBindings } from "bun:sqlite"
|
|
7
|
+
|
|
8
|
+
import { toTableName } from "../doctype/registry"
|
|
9
|
+
import type { Dict, FilterInput } from "../doctype/types"
|
|
10
|
+
import { parseFilters } from "./filters"
|
|
11
|
+
import { QueryBuilder, emptyState } from "./query-builder"
|
|
12
|
+
import { syncSchema } from "./schema"
|
|
13
|
+
|
|
14
|
+
/** A single binding value accepted by bun:sqlite. */
|
|
15
|
+
type Binding = SQLQueryBindings
|
|
16
|
+
/** An ordered list of bindings for a parameterized query. */
|
|
17
|
+
type Bindings = Binding[]
|
|
18
|
+
|
|
19
|
+
/** Run a statement with optional bindings. Returns the number of affected rows. */
|
|
20
|
+
function stmt(db: BunSQLite, sql: string, bindings?: Bindings): number {
|
|
21
|
+
const result = bindings && bindings.length > 0 ? db.run(sql, bindings) : db.run(sql)
|
|
22
|
+
return result.changes
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GetAllArgs {
|
|
26
|
+
filters?: FilterInput
|
|
27
|
+
fields?: string[]
|
|
28
|
+
orderBy?: string
|
|
29
|
+
limit?: number
|
|
30
|
+
offset?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SeriesRow {
|
|
34
|
+
current: number
|
|
35
|
+
}
|
|
36
|
+
interface MaxIdRow {
|
|
37
|
+
maxId: number | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class FrappeDatabase {
|
|
41
|
+
private readonly db: BunSQLite
|
|
42
|
+
private readonly debugMode: boolean
|
|
43
|
+
|
|
44
|
+
constructor(dbPath: string, opts?: { debug?: boolean }) {
|
|
45
|
+
this.db = new BunSQLite(dbPath, { create: true })
|
|
46
|
+
this.db.run("PRAGMA journal_mode = WAL")
|
|
47
|
+
this.db.run("PRAGMA foreign_keys = ON")
|
|
48
|
+
this.debugMode = opts?.debug ?? false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The raw bun:sqlite instance — for advanced use only. */
|
|
52
|
+
get raw(): BunSQLite {
|
|
53
|
+
return this.db
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── High-level API (frappe.db.*) ──────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get a single field value, multiple field values, or null if not found.
|
|
60
|
+
*
|
|
61
|
+
* Single field: `getValue("User", "admin", "fullName") → unknown`
|
|
62
|
+
* Multiple fields: `getValue("User", "admin", ["name","email"]) → Dict | null`
|
|
63
|
+
* With filter: `getValue("User", { email: "a@b.com" }, "fullName")`
|
|
64
|
+
*/
|
|
65
|
+
async getValue(
|
|
66
|
+
doctype: string,
|
|
67
|
+
nameOrFilters: string | FilterInput,
|
|
68
|
+
field: string,
|
|
69
|
+
): Promise<unknown>
|
|
70
|
+
async getValue(
|
|
71
|
+
doctype: string,
|
|
72
|
+
nameOrFilters: string | FilterInput,
|
|
73
|
+
fields: string[],
|
|
74
|
+
): Promise<Dict | null>
|
|
75
|
+
async getValue(
|
|
76
|
+
doctype: string,
|
|
77
|
+
nameOrFilters: string | FilterInput,
|
|
78
|
+
fieldOrFields: string | string[],
|
|
79
|
+
): Promise<unknown> {
|
|
80
|
+
const fields = Array.isArray(fieldOrFields) ? fieldOrFields : [fieldOrFields]
|
|
81
|
+
let qb = this.qb(doctype).select(...fields)
|
|
82
|
+
|
|
83
|
+
if (typeof nameOrFilters === "string") {
|
|
84
|
+
qb = qb.where("name", nameOrFilters)
|
|
85
|
+
} else {
|
|
86
|
+
qb = qb.where(nameOrFilters)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const row = await qb.limit(1).first<Dict>()
|
|
90
|
+
if (row == null) return null
|
|
91
|
+
if (Array.isArray(fieldOrFields)) return row
|
|
92
|
+
return row[fieldOrFields as string]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get multiple documents matching optional filters.
|
|
97
|
+
*/
|
|
98
|
+
async getAll(doctype: string, args: GetAllArgs = {}): Promise<Dict[]> {
|
|
99
|
+
const { filters, fields = ["name"], orderBy = "modified DESC", limit = 20, offset = 0 } = args
|
|
100
|
+
|
|
101
|
+
let qb = this.qb(doctype)
|
|
102
|
+
.select(...fields)
|
|
103
|
+
.limit(limit)
|
|
104
|
+
.offset(offset)
|
|
105
|
+
|
|
106
|
+
if (filters) qb = qb.where(filters)
|
|
107
|
+
|
|
108
|
+
// Parse "field DIR" orderBy string
|
|
109
|
+
const [orderField, orderDir] = (orderBy as string).split(/\s+/, 2)
|
|
110
|
+
if (orderField) {
|
|
111
|
+
qb = qb.orderBy(orderField, (orderDir ?? "asc").toLowerCase() as "asc" | "desc")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return qb.all<Dict>()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check whether a document exists.
|
|
119
|
+
*/
|
|
120
|
+
async exists(doctype: string, nameOrFilters: string | FilterInput): Promise<boolean> {
|
|
121
|
+
let qb = this.qb(doctype)
|
|
122
|
+
if (typeof nameOrFilters === "string") {
|
|
123
|
+
qb = qb.where("name", nameOrFilters)
|
|
124
|
+
} else {
|
|
125
|
+
qb = qb.where(nameOrFilters)
|
|
126
|
+
}
|
|
127
|
+
return qb.exists()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Count documents matching optional filters.
|
|
132
|
+
*/
|
|
133
|
+
async count(doctype: string, filters?: FilterInput): Promise<number> {
|
|
134
|
+
let qb = this.qb(doctype)
|
|
135
|
+
if (filters) qb = qb.where(filters)
|
|
136
|
+
return qb.count()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Set one or more fields on a document by name.
|
|
141
|
+
*/
|
|
142
|
+
async setValue(doctype: string, name: string, field: string, value: unknown): Promise<void>
|
|
143
|
+
async setValue(doctype: string, name: string, updates: Dict): Promise<void>
|
|
144
|
+
async setValue(
|
|
145
|
+
doctype: string,
|
|
146
|
+
name: string,
|
|
147
|
+
fieldOrUpdates: string | Dict,
|
|
148
|
+
value?: unknown,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const updates: Dict =
|
|
151
|
+
typeof fieldOrUpdates === "string"
|
|
152
|
+
? { [fieldOrUpdates]: value, modified: new Date().toISOString() }
|
|
153
|
+
: { ...fieldOrUpdates, modified: new Date().toISOString() }
|
|
154
|
+
|
|
155
|
+
// Build SET clause via direct SQL — the QB handles SELECT/WHERE but not
|
|
156
|
+
// arbitrary multi-field UPDATE, so we use the row-level helper here.
|
|
157
|
+
this.updateRow(toTableName(doctype), name, updates)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete documents by name or filter.
|
|
162
|
+
*/
|
|
163
|
+
async delete(doctype: string, nameOrFilters: string | FilterInput): Promise<number> {
|
|
164
|
+
const tableName = toTableName(doctype)
|
|
165
|
+
const { sql: whereSql, values } =
|
|
166
|
+
typeof nameOrFilters === "string"
|
|
167
|
+
? { sql: `"name" = ?`, values: [nameOrFilters] }
|
|
168
|
+
: parseFilters(nameOrFilters)
|
|
169
|
+
return stmt(this.db, `DELETE FROM "${tableName}" WHERE ${whereSql}`, values as Bindings)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Bulk update documents matching filters.
|
|
174
|
+
*/
|
|
175
|
+
async bulkUpdate(doctype: string, args: { filters: FilterInput; update: Dict }): Promise<number> {
|
|
176
|
+
const tableName = toTableName(doctype)
|
|
177
|
+
const updates = { ...args.update, modified: new Date().toISOString() }
|
|
178
|
+
const { sql: whereSql, values: whereValues } = parseFilters(args.filters)
|
|
179
|
+
|
|
180
|
+
const setClauses = Object.keys(updates)
|
|
181
|
+
.map((k) => `"${k}" = ?`)
|
|
182
|
+
.join(", ")
|
|
183
|
+
const bindings: Bindings = [
|
|
184
|
+
...(Object.values(updates) as Binding[]),
|
|
185
|
+
...(whereValues as Binding[]),
|
|
186
|
+
]
|
|
187
|
+
return stmt(this.db, `UPDATE "${tableName}" SET ${setClauses} WHERE ${whereSql}`, bindings)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Raw SQL ──────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Execute a raw parameterized query. Returns typed rows.
|
|
194
|
+
*/
|
|
195
|
+
async sql<T extends Record<string, unknown> = Dict>(
|
|
196
|
+
query: string,
|
|
197
|
+
values?: Bindings,
|
|
198
|
+
opts?: { debug?: boolean },
|
|
199
|
+
): Promise<T[]> {
|
|
200
|
+
if (opts?.debug || this.debugMode) this.log(query, values)
|
|
201
|
+
return values && values.length > 0
|
|
202
|
+
? this.db.query<T, Bindings>(query).all(...values)
|
|
203
|
+
: this.db.query<T, []>(query).all()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Transactions ─────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async commit(): Promise<void> {
|
|
209
|
+
// SQLite in WAL mode auto-commits. No-op outside explicit transactions.
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async rollback(): Promise<void> {
|
|
213
|
+
// Rollback is handled via savepoints.
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async savepoint<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
217
|
+
this.db.run(`SAVEPOINT "${name}"`)
|
|
218
|
+
try {
|
|
219
|
+
const result = await fn()
|
|
220
|
+
this.db.run(`RELEASE SAVEPOINT "${name}"`)
|
|
221
|
+
return result
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.db.run(`ROLLBACK TO SAVEPOINT "${name}"`)
|
|
224
|
+
throw error
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
229
|
+
this.db.run("BEGIN")
|
|
230
|
+
try {
|
|
231
|
+
const result = await fn()
|
|
232
|
+
this.db.run("COMMIT")
|
|
233
|
+
return result
|
|
234
|
+
} catch (error) {
|
|
235
|
+
this.db.run("ROLLBACK")
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Schema ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Sync all registered DocType schemas to the database.
|
|
244
|
+
* Creates missing tables and adds missing columns.
|
|
245
|
+
*/
|
|
246
|
+
migrate(): void {
|
|
247
|
+
syncSchema(this.db)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Rename a column on a DocType's table.
|
|
252
|
+
* Used by before-sync migrations when a field is being renamed —
|
|
253
|
+
* must run before the auto schema-sync drops or re-adds the column.
|
|
254
|
+
*/
|
|
255
|
+
async renameField(doctype: string, oldName: string, newName: string): Promise<void> {
|
|
256
|
+
const table = toTableName(doctype)
|
|
257
|
+
this.db.run(`ALTER TABLE "${table}" RENAME COLUMN "${oldName}" TO "${newName}"`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Rename a DocType's underlying table and update Link field references
|
|
262
|
+
* in sibling tables. Called from before-sync migrations.
|
|
263
|
+
*/
|
|
264
|
+
async renameDocType(oldName: string, newName: string): Promise<void> {
|
|
265
|
+
const oldTable = toTableName(oldName)
|
|
266
|
+
const newTable = toTableName(newName)
|
|
267
|
+
this.db.run(`ALTER TABLE "${oldTable}" RENAME TO "${newTable}"`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── Query Builder factory ────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create a QueryBuilder bound to this database for the given DocType.
|
|
274
|
+
* Called internally by frappe.db.* methods and exposed via frappe.query().
|
|
275
|
+
*/
|
|
276
|
+
qb(doctype: string): QueryBuilder {
|
|
277
|
+
return new QueryBuilder(this.db, emptyState(doctype))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Row-level helpers (used by Document) ─────────────────
|
|
281
|
+
|
|
282
|
+
insertRow(tableName: string, data: Dict): void {
|
|
283
|
+
const keys = Object.keys(data)
|
|
284
|
+
const placeholders = keys.map(() => "?").join(", ")
|
|
285
|
+
const fieldList = keys.map((k) => `"${k}"`).join(", ")
|
|
286
|
+
const bindings = Object.values(data) as Bindings
|
|
287
|
+
stmt(this.db, `INSERT INTO "${tableName}" (${fieldList}) VALUES (${placeholders})`, bindings)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
updateRow(tableName: string, name: string, data: Dict): void {
|
|
291
|
+
const keys = Object.keys(data).filter((k) => k !== "name")
|
|
292
|
+
if (keys.length === 0) return
|
|
293
|
+
|
|
294
|
+
const setClauses = keys.map((k) => `"${k}" = ?`).join(", ")
|
|
295
|
+
const bindings: Bindings = [...(keys.map((k) => data[k]) as Binding[]), name]
|
|
296
|
+
stmt(this.db, `UPDATE "${tableName}" SET ${setClauses} WHERE "name" = ?`, bindings)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
deleteRow(tableName: string, name: string): void {
|
|
300
|
+
this.db.run(`DELETE FROM "${tableName}" WHERE "name" = ?`, [name])
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
deleteChildren(tableName: string, parent: string, parentField: string): void {
|
|
304
|
+
this.db.run(`DELETE FROM "${tableName}" WHERE "parent" = ? AND "parentField" = ?`, [
|
|
305
|
+
parent,
|
|
306
|
+
parentField,
|
|
307
|
+
])
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
getRow(tableName: string, name: string): Dict | null {
|
|
311
|
+
return this.db.query<Dict, [string]>(`SELECT * FROM "${tableName}" WHERE "name" = ?`).get(name)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getChildren(tableName: string, parent: string, parentField: string): Dict[] {
|
|
315
|
+
return this.db
|
|
316
|
+
.query<Dict, [string, string]>(
|
|
317
|
+
`SELECT * FROM "${tableName}" WHERE "parent" = ? AND "parentField" = ? ORDER BY "idx" ASC`,
|
|
318
|
+
)
|
|
319
|
+
.all(parent, parentField)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
getNextId(tableName: string): number {
|
|
323
|
+
const row = this.db
|
|
324
|
+
.query<MaxIdRow, []>(`SELECT MAX(CAST("name" AS INTEGER)) as maxId FROM "${tableName}"`)
|
|
325
|
+
.get()
|
|
326
|
+
return (row?.maxId ?? 0) + 1
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getNextSeries(seriesPattern: string): string {
|
|
330
|
+
const hashCount = (seriesPattern.match(/#/g) ?? []).length
|
|
331
|
+
const seriesPrefix = seriesPattern.replace(/#+$/, "")
|
|
332
|
+
|
|
333
|
+
this.db.run(
|
|
334
|
+
`CREATE TABLE IF NOT EXISTS "_Series" ("name" VARCHAR(140) PRIMARY KEY, "current" INTEGER DEFAULT 0)`,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
const existing = this.db
|
|
338
|
+
.query<SeriesRow, [string]>(`SELECT "current" FROM "_Series" WHERE "name" = ?`)
|
|
339
|
+
.get(seriesPrefix)
|
|
340
|
+
|
|
341
|
+
const next = (existing?.current ?? 0) + 1
|
|
342
|
+
|
|
343
|
+
if (existing) {
|
|
344
|
+
this.db.run(`UPDATE "_Series" SET "current" = ? WHERE "name" = ?`, [next, seriesPrefix])
|
|
345
|
+
} else {
|
|
346
|
+
this.db.run(`INSERT INTO "_Series" ("name", "current") VALUES (?, ?)`, [seriesPrefix, next])
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return `${seriesPrefix}${String(next).padStart(hashCount || 5, "0")}`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
close(): void {
|
|
353
|
+
this.db.close()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private log(sql: string, values?: Bindings): void {
|
|
357
|
+
if (this.debugMode) console.log(`[SQL] ${sql}`, values?.length ? values : "")
|
|
358
|
+
}
|
|
359
|
+
}
|