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.
Files changed (66) hide show
  1. package/README.md +72 -0
  2. package/package.json +59 -0
  3. package/src/api/auth.ts +76 -0
  4. package/src/api/index.ts +10 -0
  5. package/src/api/resource.ts +177 -0
  6. package/src/api/route.ts +301 -0
  7. package/src/app/index.ts +6 -0
  8. package/src/app/loader.ts +218 -0
  9. package/src/auth/auth.ts +247 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/cli/args.ts +40 -0
  12. package/src/cli/bin.ts +12 -0
  13. package/src/cli/commands/add-api.ts +32 -0
  14. package/src/cli/commands/add-doctype.ts +43 -0
  15. package/src/cli/commands/add-page.ts +33 -0
  16. package/src/cli/commands/add-user.ts +96 -0
  17. package/src/cli/commands/dev.ts +71 -0
  18. package/src/cli/commands/drop-site.ts +27 -0
  19. package/src/cli/commands/init.ts +98 -0
  20. package/src/cli/commands/migrate.ts +110 -0
  21. package/src/cli/commands/new-site.ts +61 -0
  22. package/src/cli/commands/routes.ts +56 -0
  23. package/src/cli/commands/use.ts +30 -0
  24. package/src/cli/index.ts +73 -0
  25. package/src/cli/log.ts +13 -0
  26. package/src/cli/scaffold/templates.ts +189 -0
  27. package/src/context.ts +162 -0
  28. package/src/core/doctype/migration/migration.ts +17 -0
  29. package/src/core/doctype/role/role.ts +7 -0
  30. package/src/core/doctype/session/session.ts +16 -0
  31. package/src/core/doctype/user/user.controller.ts +11 -0
  32. package/src/core/doctype/user/user.ts +22 -0
  33. package/src/core/doctype/user_role/user_role.ts +9 -0
  34. package/src/core/doctypes.ts +25 -0
  35. package/src/core/index.ts +1 -0
  36. package/src/database/database.ts +359 -0
  37. package/src/database/filters.ts +131 -0
  38. package/src/database/index.ts +30 -0
  39. package/src/database/query-builder.ts +1118 -0
  40. package/src/database/schema.ts +188 -0
  41. package/src/doctype/define.ts +45 -0
  42. package/src/doctype/discovery.ts +57 -0
  43. package/src/doctype/field.ts +160 -0
  44. package/src/doctype/index.ts +20 -0
  45. package/src/doctype/layout.ts +62 -0
  46. package/src/doctype/query-builder-stub.ts +16 -0
  47. package/src/doctype/registry.ts +106 -0
  48. package/src/doctype/types.ts +407 -0
  49. package/src/document/document.ts +593 -0
  50. package/src/document/index.ts +6 -0
  51. package/src/document/naming.ts +56 -0
  52. package/src/errors.ts +53 -0
  53. package/src/frappe.d.ts +128 -0
  54. package/src/globals.ts +72 -0
  55. package/src/index.ts +112 -0
  56. package/src/migrations/index.ts +11 -0
  57. package/src/migrations/runner.ts +256 -0
  58. package/src/permissions/index.ts +265 -0
  59. package/src/response.ts +100 -0
  60. package/src/server.ts +210 -0
  61. package/src/site.ts +126 -0
  62. package/src/ssr/handler.ts +56 -0
  63. package/src/ssr/index.ts +11 -0
  64. package/src/ssr/page-loader.ts +200 -0
  65. package/src/ssr/renderer.ts +94 -0
  66. 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,7 @@
1
+ export default doctype("Role", {
2
+ naming: "field:roleName",
3
+ module: "Core",
4
+ fields: {
5
+ roleName: field.data({ required: true, unique: true }),
6
+ },
7
+ })
@@ -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,9 @@
1
+ export default doctype("UserRole", {
2
+ naming: "autoincrement",
3
+ module: "Core",
4
+ isChild: true,
5
+ fields: {
6
+ user: field.link("User", { required: true }),
7
+ role: field.link("Role", { required: true }),
8
+ },
9
+ })
@@ -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
+ }