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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Ambient globals for the frappe framework.
3
+ *
4
+ * This file is a pure TypeScript script (no top-level imports, no exports).
5
+ * Top-level `declare` statements in a script file are automatically global —
6
+ * no `declare global {}` wrapper is needed. Inline `import()` types are used
7
+ * to reference types from other modules without making this file a module.
8
+ *
9
+ * Consumer projects add:
10
+ * /// <reference path="./node_modules/frappebun/src/frappe.d.ts" />
11
+ * in their `frappe-env.d.ts` to activate these globals.
12
+ */
13
+
14
+ // ─── Request context ──────────────────────────────────────
15
+
16
+ /** The frappe context — available in request handlers, controllers, and scripts. */
17
+ declare var frappe: import("./context").FrappeContext & {
18
+ response: import("./response").FrappeResponse
19
+ }
20
+
21
+ // ─── DocType definition ───────────────────────────────────
22
+
23
+ /** Define a DocType schema. */
24
+ declare function doctype(
25
+ name: string,
26
+ schema: import("./doctype/types").DocTypeSchema,
27
+ ): import("./doctype/types").DocTypeDefinition
28
+
29
+ /** Define a controller for a DocType. */
30
+ declare function controller(
31
+ name: string,
32
+ hooks: import("./doctype/types").ControllerHooks,
33
+ ): import("./doctype/types").ControllerDefinition
34
+
35
+ // ─── Field factory ────────────────────────────────────────
36
+
37
+ /** Field factory — creates FieldDefinition objects. */
38
+ declare var field: {
39
+ data(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
40
+ int(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
41
+ float(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
42
+ currency(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
43
+ percent(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
44
+ check(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
45
+ rating(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
46
+ text(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
47
+ smallText(
48
+ opts?: import("./doctype/types").FieldOptions,
49
+ ): import("./doctype/types").FieldDefinition
50
+ longText(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
51
+ textEditor(
52
+ opts?: import("./doctype/types").FieldOptions,
53
+ ): import("./doctype/types").FieldDefinition
54
+ markdownEditor(
55
+ opts?: import("./doctype/types").FieldOptions,
56
+ ): import("./doctype/types").FieldDefinition
57
+ code(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
58
+ htmlEditor(
59
+ opts?: import("./doctype/types").FieldOptions,
60
+ ): import("./doctype/types").FieldDefinition
61
+ json(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
62
+ date(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
63
+ datetime(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
64
+ time(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
65
+ duration(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
66
+ select(
67
+ options: string[],
68
+ opts?: import("./doctype/types").FieldOptions,
69
+ ): import("./doctype/types").FieldDefinition
70
+ link(
71
+ doctype: string,
72
+ opts?: import("./doctype/types").FieldOptions,
73
+ ): import("./doctype/types").FieldDefinition
74
+ dynamicLink(
75
+ linkField: string,
76
+ opts?: import("./doctype/types").FieldOptions,
77
+ ): import("./doctype/types").FieldDefinition
78
+ tableMultiSelect(
79
+ doctype: string,
80
+ opts?: import("./doctype/types").FieldOptions,
81
+ ): import("./doctype/types").FieldDefinition
82
+ table(
83
+ doctypeOrFields: string | Record<string, import("./doctype/types").FieldDefinition>,
84
+ opts?: import("./doctype/types").FieldOptions,
85
+ ): import("./doctype/types").FieldDefinition
86
+ attach(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
87
+ attachImage(
88
+ opts?: import("./doctype/types").FieldOptions,
89
+ ): import("./doctype/types").FieldDefinition
90
+ image(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
91
+ color(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
92
+ signature(
93
+ opts?: import("./doctype/types").FieldOptions,
94
+ ): import("./doctype/types").FieldDefinition
95
+ geolocation(
96
+ opts?: import("./doctype/types").FieldOptions,
97
+ ): import("./doctype/types").FieldDefinition
98
+ password(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
99
+ readOnly(opts?: import("./doctype/types").FieldOptions): import("./doctype/types").FieldDefinition
100
+ htmlField(
101
+ opts?: import("./doctype/types").FieldOptions,
102
+ ): import("./doctype/types").FieldDefinition
103
+ }
104
+
105
+ // ─── Layout helpers ───────────────────────────────────────
106
+
107
+ /** Layout helper — creates a section. */
108
+ // oxlint-disable-next-line typescript/no-explicit-any
109
+ declare function section(...args: any[]): import("./doctype/types").LayoutSection
110
+
111
+ /** Layout helper — creates a column. */
112
+ declare function column(fields: string[]): import("./doctype/types").LayoutColumn
113
+
114
+ /** Layout helper — creates a tab. */
115
+ declare function tab(
116
+ label: string,
117
+ sections: import("./doctype/types").LayoutSection[],
118
+ ): import("./doctype/types").LayoutTab
119
+
120
+ // ─── API ──────────────────────────────────────────────────
121
+
122
+ /** Mark a function or config as an HTTP endpoint. */
123
+ declare var route: typeof import("./api/route").route
124
+
125
+ // ─── SSR composables ──────────────────────────────────────
126
+
127
+ /** Read the server-provided page context inside a Vue SFC `<script setup>`. */
128
+ declare function useContext<T = Record<string, unknown>>(): T
package/src/globals.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Ambient `frappe` global — a Proxy that delegates to the current AsyncLocalStorage context.
3
+ *
4
+ * Also installs ambient globals: defineDocType, controller, field, section, column, tab
5
+ */
6
+
7
+ import { route } from "./api/route"
8
+ import { contextStore, type FrappeContext } from "./context"
9
+ import { q } from "./database/query-builder"
10
+ import { doctype, controller } from "./doctype/define"
11
+ import { field } from "./doctype/field"
12
+ import { section, column, tab } from "./doctype/layout"
13
+ import { useContext } from "./ssr/use-context"
14
+
15
+ export function createFrappeProxy(): FrappeContext {
16
+ return new Proxy({} as FrappeContext, {
17
+ get(_, prop: string) {
18
+ const ctx = contextStore.getStore()
19
+ if (!ctx) {
20
+ throw new Error(
21
+ `frappe.${prop} is not available outside a request context. ` +
22
+ `Use frappe.initSite("sitename", async (frappe) => { ... }) for scripts.`,
23
+ )
24
+ }
25
+ const value = ctx[prop as keyof FrappeContext]
26
+ if (typeof value === "function") {
27
+ return value.bind(ctx)
28
+ }
29
+ return value
30
+ },
31
+ // oxlint-disable-next-line typescript/no-explicit-any -- Proxy set trap requires `any`
32
+ set(_, prop: string, value: any) {
33
+ const ctx = contextStore.getStore()
34
+ if (!ctx) {
35
+ throw new Error(`frappe.${prop} cannot be set outside a request context.`)
36
+ }
37
+ // oxlint-disable-next-line typescript/no-explicit-any
38
+ ;(ctx as any)[prop] = value
39
+ return true
40
+ },
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Install all ambient globals on globalThis.
46
+ */
47
+ export function installGlobals(): FrappeContext {
48
+ const proxy = createFrappeProxy()
49
+ const g = globalThis as any // oxlint-disable-line typescript/no-explicit-any -- globalThis extension
50
+
51
+ // The main frappe global
52
+ g.frappe = proxy
53
+
54
+ // DocType definition globals
55
+ g.doctype = doctype
56
+ g.controller = controller
57
+ g.field = field
58
+ g.section = section
59
+ g.column = column
60
+ g.tab = tab
61
+
62
+ // Query builder helpers
63
+ g.q = q
64
+
65
+ // API route marker
66
+ g.route = route
67
+
68
+ // SSR composables — available inside Vue SFC <script setup> blocks
69
+ g.useContext = useContext
70
+
71
+ return proxy
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Main entry point — re-exports all runtime modules.
3
+ */
4
+
5
+ // Layer 0: Foundation
6
+ export * from "./errors"
7
+ export * from "./context"
8
+ export { response } from "./response"
9
+ export type { FrappeResponse } from "./response"
10
+ export { installGlobals, createFrappeProxy } from "./globals"
11
+ export * from "./site"
12
+ export { createServer, initSiteDb, closeAllDbs } from "./server"
13
+ export type { ServerOptions } from "./server"
14
+
15
+ // Layer 1: DocType
16
+ export {
17
+ doctype,
18
+ controller,
19
+ field,
20
+ section,
21
+ column,
22
+ tab,
23
+ registerDocType,
24
+ registerController,
25
+ getDocTypeMeta,
26
+ getController,
27
+ getAllDocTypes,
28
+ hasDocType,
29
+ clearRegistry,
30
+ toTableName,
31
+ discoverDocTypes,
32
+ } from "./doctype"
33
+ export type {
34
+ DocTypeSchema,
35
+ DocTypeDefinition,
36
+ FieldDefinition,
37
+ FieldType,
38
+ FieldOptions,
39
+ ControllerHooks,
40
+ ControllerDefinition,
41
+ NamingStrategy,
42
+ PermissionRule,
43
+ PermissionsBlock,
44
+ PermissionDenial,
45
+ PermCheckResult,
46
+ RestConfig,
47
+ LayoutSection,
48
+ LayoutColumn,
49
+ LayoutTab,
50
+ LayoutEntry,
51
+ ListConfig,
52
+ SearchConfig,
53
+ WorkflowConfig,
54
+ } from "./doctype"
55
+
56
+ // Layer 1: Database
57
+ export { FrappeDatabase } from "./database"
58
+ export type { GetAllArgs } from "./database"
59
+ export { QueryBuilder, FnExpr, OnCondition, Paginator, fn, q, createQueryBuilder } from "./database"
60
+ export type { BuilderState, ConnectedWhere, WhereNode, OrderByNode, Connector } from "./database"
61
+
62
+ // Layer 2: Permissions
63
+ export {
64
+ hasRolePermission,
65
+ checkDocPermission,
66
+ checkCreatePermission,
67
+ getUserRoles,
68
+ deny,
69
+ createPermsNamespace,
70
+ } from "./permissions"
71
+ export type { PermsNamespace } from "./permissions"
72
+
73
+ // Layer 2: API routes
74
+ export {
75
+ route,
76
+ isRouteDefinition,
77
+ inferMethod,
78
+ camelToKebab,
79
+ resolveRoutePath,
80
+ invokeRoute,
81
+ routeRegistry,
82
+ } from "./api"
83
+ export type { RouteConfig, RouteDefinition, AuthMode, HttpMethod, RouteHandler } from "./api"
84
+
85
+ // Layer 1: Document
86
+ export { Document, newDoc, getDoc, getList, deleteDoc, getSingle, generateName } from "./document"
87
+
88
+ // Layer 1: Auth
89
+ export { login, logout, hashPassword, resolveAuth, verifyCsrf, ensureAdminUser } from "./auth"
90
+
91
+ // Layer 1: Core DocTypes
92
+ export { registerCoreDocTypes } from "./core/doctypes"
93
+
94
+ // Layer 2: App loading
95
+ export { loadApp, readAppMetadata, findProjectRoot, appNameFromPackage } from "./app"
96
+ export type { LoadedApp, AppMetadata } from "./app"
97
+
98
+ // Layer 3: Migrations
99
+ export { runMigrations, discoverMigrations } from "./migrations"
100
+ export type { MigrationPhase, MigrationResult, DiscoveredMigration } from "./migrations"
101
+
102
+ // Layer 2: SSR
103
+ export {
104
+ loadPages,
105
+ relPathToRoute,
106
+ renderPage,
107
+ renderComponent,
108
+ useContext,
109
+ handlePageRequest,
110
+ createPageMatcher,
111
+ } from "./ssr"
112
+ export type { LoadedPage, GetContextFn, GetContextArgs, RenderOptions, PageMatcher } from "./ssr"
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Migrations — three-phase schema/data migration runner.
3
+ */
4
+
5
+ export { runMigrations, discoverMigrations, loadExecutedIds, partitionByPhase } from "./runner"
6
+ export type {
7
+ MigrationPhase,
8
+ DiscoveredMigration,
9
+ MigrationRecord,
10
+ MigrationResult,
11
+ } from "./runner"
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Migration runner — three-phase execution:
3
+ *
4
+ * 1. before-sync migrations (data patches; old schema still in place)
5
+ * 2. schema sync (auto: CREATE TABLE / ADD COLUMN)
6
+ * 3. after-sync migrations (data patches that need new schema)
7
+ *
8
+ * Migration files live in two conventional locations:
9
+ * <moduleDir>/migrations/
10
+ * <moduleDir>/doctype/<name>/migrations/
11
+ *
12
+ * Ordering is strictly filename-alphabetical within each location. Use
13
+ * numeric or timestamp prefixes (`001_...`, `20260405_...`).
14
+ *
15
+ * State is tracked in the core `Migration` DocType so each migration runs
16
+ * at most once per site.
17
+ */
18
+
19
+ import { existsSync, readdirSync, statSync } from "node:fs"
20
+ import { join } from "node:path"
21
+
22
+ import type { FrappeDatabase } from "../database/database"
23
+
24
+ // ─── Types ────────────────────────────────────────────────
25
+
26
+ export type MigrationPhase = "before" | "after"
27
+
28
+ export interface DiscoveredMigration {
29
+ /** Unique id: `<app>:<relPath>` — stable across runs. */
30
+ id: string
31
+ /** Absolute file path for dynamic import. */
32
+ absPath: string
33
+ /** Sort key — the filename. */
34
+ filename: string
35
+ /** Source: "app" (cross-cutting) or DocType directory name. */
36
+ source: string
37
+ }
38
+
39
+ interface MigrationModule {
40
+ default: () => Promise<void> | void
41
+ phase?: MigrationPhase
42
+ }
43
+
44
+ export interface MigrationRecord {
45
+ id: string
46
+ phase: MigrationPhase
47
+ executedAt: string
48
+ status: "Success" | "Failed"
49
+ error?: string
50
+ }
51
+
52
+ export interface MigrationResult {
53
+ before: MigrationRecord[]
54
+ after: MigrationRecord[]
55
+ schemaChanges: number
56
+ }
57
+
58
+ // ─── Discovery ────────────────────────────────────────────
59
+
60
+ /**
61
+ * Discover all migration files under an app module directory.
62
+ * Returns them in the order they should be considered for execution
63
+ * (filename-alphabetical within each source).
64
+ */
65
+ export function discoverMigrations(moduleDir: string, appName: string): DiscoveredMigration[] {
66
+ const discovered: DiscoveredMigration[] = []
67
+
68
+ // 1. App-level: <moduleDir>/migrations/
69
+ const appMigrationsDir = join(moduleDir, "migrations")
70
+ if (existsSync(appMigrationsDir)) {
71
+ for (const file of listMigrationFiles(appMigrationsDir)) {
72
+ discovered.push({
73
+ id: `${appName}:${file}`,
74
+ absPath: join(appMigrationsDir, file),
75
+ filename: file,
76
+ source: "app",
77
+ })
78
+ }
79
+ }
80
+
81
+ // 2. DocType-level: <moduleDir>/doctype/<name>/migrations/
82
+ const doctypeDir = join(moduleDir, "doctype")
83
+ if (existsSync(doctypeDir)) {
84
+ const doctypes = readdirSync(doctypeDir)
85
+ .filter((d) => statSync(join(doctypeDir, d)).isDirectory())
86
+ .sort()
87
+ for (const dt of doctypes) {
88
+ const dtMigrations = join(doctypeDir, dt, "migrations")
89
+ if (!existsSync(dtMigrations)) continue
90
+ for (const file of listMigrationFiles(dtMigrations)) {
91
+ discovered.push({
92
+ id: `${appName}:${dt}/${file}`,
93
+ absPath: join(dtMigrations, file),
94
+ filename: file,
95
+ source: dt,
96
+ })
97
+ }
98
+ }
99
+ }
100
+
101
+ return discovered
102
+ }
103
+
104
+ function listMigrationFiles(dir: string): string[] {
105
+ return readdirSync(dir)
106
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && !f.endsWith(".test.ts"))
107
+ .sort()
108
+ }
109
+
110
+ // ─── Phase partitioning ───────────────────────────────────
111
+
112
+ /**
113
+ * Import each migration module and split them by phase. Pending filter
114
+ * excludes any migration whose id is already recorded as Success.
115
+ */
116
+ export async function partitionByPhase(
117
+ migrations: DiscoveredMigration[],
118
+ alreadyRun: Set<string>,
119
+ ): Promise<{ before: PreparedMigration[]; after: PreparedMigration[] }> {
120
+ const before: PreparedMigration[] = []
121
+ const after: PreparedMigration[] = []
122
+
123
+ for (const m of migrations) {
124
+ if (alreadyRun.has(m.id)) continue
125
+ const mod = (await import(m.absPath)) as MigrationModule
126
+ const phase: MigrationPhase = mod.phase ?? "after"
127
+ const prepared: PreparedMigration = { ...m, phase, run: mod.default }
128
+ if (phase === "before") before.push(prepared)
129
+ else after.push(prepared)
130
+ }
131
+
132
+ return { before, after }
133
+ }
134
+
135
+ interface PreparedMigration extends DiscoveredMigration {
136
+ phase: MigrationPhase
137
+ run: () => Promise<void> | void
138
+ }
139
+
140
+ // ─── State ────────────────────────────────────────────────
141
+
142
+ /**
143
+ * Core `Migration` DocType tracks which migrations have run. We read the
144
+ * table directly so migration bookkeeping works even before controllers
145
+ * are loaded.
146
+ */
147
+ interface MigrationRow extends Record<string, unknown> {
148
+ name: string
149
+ migrationName: string
150
+ status: string
151
+ }
152
+
153
+ export async function loadExecutedIds(db: FrappeDatabase): Promise<Set<string>> {
154
+ const rows = await db.sql<MigrationRow>(
155
+ `SELECT "name", "migrationName", "status" FROM "Migration" WHERE "status" = 'Success'`,
156
+ )
157
+ return new Set(rows.map((r) => r.migrationName))
158
+ }
159
+
160
+ async function recordMigration(
161
+ db: FrappeDatabase,
162
+ prepared: PreparedMigration,
163
+ status: "Success" | "Failed",
164
+ errorMessage?: string,
165
+ ): Promise<void> {
166
+ const now = new Date().toISOString()
167
+ db.insertRow("Migration", {
168
+ name: prepared.id,
169
+ migrationName: prepared.id,
170
+ app: prepared.id.split(":")[0] ?? "",
171
+ phase: prepared.phase,
172
+ executedAt: now,
173
+ status,
174
+ error: errorMessage ?? null,
175
+ creation: now,
176
+ modified: now,
177
+ owner: "Administrator",
178
+ modifiedBy: "Administrator",
179
+ docstatus: 0,
180
+ idx: 0,
181
+ })
182
+ }
183
+
184
+ // ─── Execution ────────────────────────────────────────────
185
+
186
+ /**
187
+ * Run a list of migrations sequentially, recording each outcome.
188
+ * Throws on the first failure — subsequent migrations are not attempted.
189
+ */
190
+ async function runPhase(
191
+ db: FrappeDatabase,
192
+ phase: PreparedMigration[],
193
+ ): Promise<MigrationRecord[]> {
194
+ const records: MigrationRecord[] = []
195
+
196
+ for (const m of phase) {
197
+ try {
198
+ await m.run()
199
+ await recordMigration(db, m, "Success")
200
+ records.push({
201
+ id: m.id,
202
+ phase: m.phase,
203
+ executedAt: new Date().toISOString(),
204
+ status: "Success",
205
+ })
206
+ } catch (error) {
207
+ const msg = error instanceof Error ? error.message : String(error)
208
+ await recordMigration(db, m, "Failed", msg)
209
+ records.push({
210
+ id: m.id,
211
+ phase: m.phase,
212
+ executedAt: new Date().toISOString(),
213
+ status: "Failed",
214
+ error: msg,
215
+ })
216
+ throw error
217
+ }
218
+ }
219
+
220
+ return records
221
+ }
222
+
223
+ /**
224
+ * Full migrate flow: before → schema-sync → after.
225
+ *
226
+ * Assumes the `Migration` DocType has already been registered (it is part
227
+ * of the core DocType set). The schema sync will ensure its table exists
228
+ * before we try to record anything into it.
229
+ */
230
+ export async function runMigrations(
231
+ db: FrappeDatabase,
232
+ moduleDir: string,
233
+ appName: string,
234
+ ): Promise<MigrationResult> {
235
+ // Pre-sync the schema once so the Migration table exists before we
236
+ // try to query it. This is cheap if schema is already up to date.
237
+ db.migrate()
238
+
239
+ const executed = await loadExecutedIds(db)
240
+ const discovered = discoverMigrations(moduleDir, appName)
241
+ const { before, after } = await partitionByPhase(discovered, executed)
242
+
243
+ const beforeRecords = await runPhase(db, before)
244
+
245
+ // Re-run schema sync after before-migrations — they may have renamed
246
+ // fields/tables, so the target schema diff needs to be recomputed.
247
+ db.migrate()
248
+
249
+ const afterRecords = await runPhase(db, after)
250
+
251
+ return {
252
+ before: beforeRecords,
253
+ after: afterRecords,
254
+ schemaChanges: beforeRecords.length + afterRecords.length,
255
+ }
256
+ }