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/frappe.d.ts
ADDED
|
@@ -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
|
+
}
|