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
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document class — runtime instance of a DocType row.
|
|
3
|
+
* Handles CRUD, lifecycle hooks, child tables, and computed fields.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FrappeDatabase } from "../database/database"
|
|
7
|
+
import { getDocTypeMeta, getController, toTableName } from "../doctype/registry"
|
|
8
|
+
import type {
|
|
9
|
+
Dict,
|
|
10
|
+
DocTypeDefinition,
|
|
11
|
+
ControllerHooks,
|
|
12
|
+
DocumentLike,
|
|
13
|
+
FilterInput,
|
|
14
|
+
} from "../doctype/types"
|
|
15
|
+
import {
|
|
16
|
+
DoesNotExistError,
|
|
17
|
+
DuplicateEntryError,
|
|
18
|
+
TimestampMismatchError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
} from "../errors"
|
|
21
|
+
import { generateName } from "./naming"
|
|
22
|
+
|
|
23
|
+
// ─── Standard field names (set directly on the instance) ──
|
|
24
|
+
|
|
25
|
+
const STANDARD_PROPS = new Set([
|
|
26
|
+
"name",
|
|
27
|
+
"doctype",
|
|
28
|
+
"owner",
|
|
29
|
+
"creation",
|
|
30
|
+
"modified",
|
|
31
|
+
"modifiedBy",
|
|
32
|
+
"docstatus",
|
|
33
|
+
"idx",
|
|
34
|
+
"parent",
|
|
35
|
+
"parentType",
|
|
36
|
+
"parentField",
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
// ─── Document ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export class Document implements DocumentLike {
|
|
42
|
+
// Identity
|
|
43
|
+
name!: string
|
|
44
|
+
readonly doctype: string
|
|
45
|
+
|
|
46
|
+
// Standard fields (auto-managed)
|
|
47
|
+
owner: string = ""
|
|
48
|
+
creation: string = ""
|
|
49
|
+
modified: string = ""
|
|
50
|
+
modifiedBy: string = ""
|
|
51
|
+
docstatus: 0 | 1 | 2 = 0
|
|
52
|
+
idx: number = 0
|
|
53
|
+
|
|
54
|
+
// Child table fields (only present on child documents)
|
|
55
|
+
parent?: string
|
|
56
|
+
parentType?: string
|
|
57
|
+
parentField?: string
|
|
58
|
+
|
|
59
|
+
// ── Internal state (prefixed to avoid field name conflicts) ──
|
|
60
|
+
_isNew: boolean = true
|
|
61
|
+
_originalValues: Dict = {}
|
|
62
|
+
_db!: FrappeDatabase
|
|
63
|
+
_user: string = "Guest"
|
|
64
|
+
_meta!: DocTypeDefinition
|
|
65
|
+
_controller?: ControllerHooks
|
|
66
|
+
_dynFields: Dict = {}
|
|
67
|
+
|
|
68
|
+
constructor(doctype: string) {
|
|
69
|
+
this.doctype = doctype
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Initialization ─────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
_init(db: FrappeDatabase, user: string): void {
|
|
75
|
+
this._db = db
|
|
76
|
+
this._user = user
|
|
77
|
+
this._meta = getDocTypeMeta(this.doctype)!
|
|
78
|
+
this._controller = getController(this.doctype)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_loadValues(data: Dict): void {
|
|
82
|
+
for (const [key, value] of Object.entries(data)) {
|
|
83
|
+
this.set(key, value)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_snapshotOriginal(): void {
|
|
88
|
+
this._originalValues = this.asDict()
|
|
89
|
+
this._isNew = false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── State ──────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
isNew(): boolean {
|
|
95
|
+
return this._isNew
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
isModified(field?: string): boolean {
|
|
99
|
+
if (this._isNew) return true
|
|
100
|
+
if (field) return this.get(field) !== this._originalValues[field]
|
|
101
|
+
const current = this.asDict()
|
|
102
|
+
return Object.keys(current).some(
|
|
103
|
+
(k) => !k.startsWith("_") && current[k] !== this._originalValues[k],
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── CRUD ───────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async insert(): Promise<this> {
|
|
110
|
+
const { schema } = this._meta
|
|
111
|
+
const hooks = this._controller
|
|
112
|
+
const now = new Date().toISOString()
|
|
113
|
+
|
|
114
|
+
await hooks?.beforeInsert?.(this)
|
|
115
|
+
this._setDefaults()
|
|
116
|
+
|
|
117
|
+
this.owner = this._user
|
|
118
|
+
this.creation = now
|
|
119
|
+
this.modified = now
|
|
120
|
+
this.modifiedBy = this._user
|
|
121
|
+
|
|
122
|
+
if (!this.name) {
|
|
123
|
+
this.name = generateName(
|
|
124
|
+
schema.naming ?? "autoincrement",
|
|
125
|
+
this.doctype,
|
|
126
|
+
this.asDict(),
|
|
127
|
+
this._db,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this._runComputed()
|
|
132
|
+
await hooks?.validate?.(this)
|
|
133
|
+
|
|
134
|
+
if (await this._db.exists(this.doctype, this.name)) {
|
|
135
|
+
throw new DuplicateEntryError(`${this.doctype} "${this.name}" already exists`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this._db.insertRow(toTableName(this.doctype), this.getValidDict())
|
|
139
|
+
await this._insertChildren()
|
|
140
|
+
|
|
141
|
+
await hooks?.afterInsert?.(this)
|
|
142
|
+
await hooks?.onUpdate?.(this)
|
|
143
|
+
await hooks?.afterSave?.(this)
|
|
144
|
+
|
|
145
|
+
this._snapshotOriginal()
|
|
146
|
+
return this
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async save(): Promise<this> {
|
|
150
|
+
if (this._isNew) return this.insert()
|
|
151
|
+
|
|
152
|
+
const hooks = this._controller
|
|
153
|
+
const now = new Date().toISOString()
|
|
154
|
+
|
|
155
|
+
this.modified = now
|
|
156
|
+
this.modifiedBy = this._user
|
|
157
|
+
|
|
158
|
+
// Optimistic locking
|
|
159
|
+
const dbModified = await this._db.getValue(this.doctype, this.name, "modified")
|
|
160
|
+
if (
|
|
161
|
+
dbModified &&
|
|
162
|
+
this._originalValues["modified"] &&
|
|
163
|
+
dbModified !== this._originalValues["modified"]
|
|
164
|
+
) {
|
|
165
|
+
throw new TimestampMismatchError(
|
|
166
|
+
`${this.doctype} "${this.name}" was modified by another user`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await hooks?.beforeSave?.(this)
|
|
171
|
+
this._setDefaults()
|
|
172
|
+
this._runComputed()
|
|
173
|
+
await hooks?.validate?.(this)
|
|
174
|
+
|
|
175
|
+
this._db.updateRow(toTableName(this.doctype), this.name, this.getValidDict())
|
|
176
|
+
await this._updateChildren()
|
|
177
|
+
|
|
178
|
+
await hooks?.onUpdate?.(this)
|
|
179
|
+
await hooks?.afterSave?.(this)
|
|
180
|
+
|
|
181
|
+
this._snapshotOriginal()
|
|
182
|
+
return this
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async submit(): Promise<this> {
|
|
186
|
+
if (!this._meta.schema.isSubmittable) {
|
|
187
|
+
throw new ValidationError(`${this.doctype} is not submittable`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const hooks = this._controller
|
|
191
|
+
await hooks?.beforeSubmit?.(this)
|
|
192
|
+
|
|
193
|
+
this.docstatus = 1
|
|
194
|
+
this._runComputed()
|
|
195
|
+
await hooks?.validate?.(this)
|
|
196
|
+
|
|
197
|
+
const now = new Date().toISOString()
|
|
198
|
+
this.modified = now
|
|
199
|
+
this.modifiedBy = this._user
|
|
200
|
+
this._db.updateRow(toTableName(this.doctype), this.name, this.getValidDict())
|
|
201
|
+
await this._updateChildren()
|
|
202
|
+
|
|
203
|
+
await hooks?.onSubmit?.(this)
|
|
204
|
+
await hooks?.afterSave?.(this)
|
|
205
|
+
|
|
206
|
+
this._snapshotOriginal()
|
|
207
|
+
return this
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async cancel(): Promise<this> {
|
|
211
|
+
if (!this._meta.schema.isSubmittable) {
|
|
212
|
+
throw new ValidationError(`${this.doctype} is not submittable`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const hooks = this._controller
|
|
216
|
+
await hooks?.beforeCancel?.(this)
|
|
217
|
+
|
|
218
|
+
this.docstatus = 2
|
|
219
|
+
|
|
220
|
+
const now = new Date().toISOString()
|
|
221
|
+
this.modified = now
|
|
222
|
+
this.modifiedBy = this._user
|
|
223
|
+
this._db.updateRow(toTableName(this.doctype), this.name, this.getValidDict())
|
|
224
|
+
|
|
225
|
+
await hooks?.onCancel?.(this)
|
|
226
|
+
await hooks?.afterSave?.(this)
|
|
227
|
+
|
|
228
|
+
this._snapshotOriginal()
|
|
229
|
+
return this
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async delete(): Promise<void> {
|
|
233
|
+
const hooks = this._controller
|
|
234
|
+
await hooks?.onTrash?.(this)
|
|
235
|
+
await this._deleteChildren()
|
|
236
|
+
this._db.deleteRow(toTableName(this.doctype), this.name)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async reload(): Promise<this> {
|
|
240
|
+
const row = this._db.getRow(toTableName(this.doctype), this.name)
|
|
241
|
+
if (!row) throw new DoesNotExistError(`${this.doctype} "${this.name}" does not exist`)
|
|
242
|
+
|
|
243
|
+
this._dynFields = {}
|
|
244
|
+
this._loadValues(row)
|
|
245
|
+
await this._loadChildren()
|
|
246
|
+
await this._controller?.onLoad?.(this)
|
|
247
|
+
|
|
248
|
+
this._snapshotOriginal()
|
|
249
|
+
return this
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Field access ────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
get(field: string): unknown {
|
|
255
|
+
if (STANDARD_PROPS.has(field)) return (this as Record<string, unknown>)[field]
|
|
256
|
+
return this._dynFields[field]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
set(field: string, value: unknown): void {
|
|
260
|
+
if (STANDARD_PROPS.has(field)) {
|
|
261
|
+
;(this as Record<string, unknown>)[field] = value
|
|
262
|
+
} else {
|
|
263
|
+
this._dynFields[field] = value
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async getDbValue(field: string): Promise<unknown> {
|
|
268
|
+
if (this._isNew) return undefined
|
|
269
|
+
return this._db.getValue(this.doctype, this.name, field)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Child table helpers ─────────────────────────────────
|
|
273
|
+
|
|
274
|
+
append(fieldName: string, data?: Dict): Document {
|
|
275
|
+
const fieldDef = this._meta.schema.fields[fieldName]
|
|
276
|
+
if (!fieldDef || fieldDef.type !== "Table") {
|
|
277
|
+
throw new ValidationError(`"${fieldName}" is not a table field on ${this.doctype}`)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const children = (this.get(fieldName) as Document[] | undefined) ?? []
|
|
281
|
+
|
|
282
|
+
const child = new Document(fieldDef.childDocType!)
|
|
283
|
+
child._init(this._db, this._user)
|
|
284
|
+
child.parent = this.name
|
|
285
|
+
child.parentType = this.doctype
|
|
286
|
+
child.parentField = fieldName
|
|
287
|
+
child.idx = children.length + 1
|
|
288
|
+
if (data) child._loadValues(data)
|
|
289
|
+
|
|
290
|
+
children.push(child)
|
|
291
|
+
this.set(fieldName, children)
|
|
292
|
+
return child
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
remove(fieldName: string, row: DocumentLike): void {
|
|
296
|
+
const children = this.get(fieldName) as Document[] | undefined
|
|
297
|
+
if (!children) return
|
|
298
|
+
|
|
299
|
+
const idx = children.indexOf(row as Document)
|
|
300
|
+
if (idx !== -1) {
|
|
301
|
+
children.splice(idx, 1)
|
|
302
|
+
children.forEach((child, i) => {
|
|
303
|
+
child.idx = i + 1
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async addComment(_text: string, _commentType?: string): Promise<void> {
|
|
309
|
+
// Placeholder — will be wired to a Comment DocType in a later layer
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Serialization ───────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
asDict(): Dict {
|
|
315
|
+
const dict: Dict = {
|
|
316
|
+
name: this.name,
|
|
317
|
+
doctype: this.doctype,
|
|
318
|
+
owner: this.owner,
|
|
319
|
+
creation: this.creation,
|
|
320
|
+
modified: this.modified,
|
|
321
|
+
modifiedBy: this.modifiedBy,
|
|
322
|
+
docstatus: this.docstatus,
|
|
323
|
+
idx: this.idx,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this.parent !== undefined) dict["parent"] = this.parent
|
|
327
|
+
if (this.parentType !== undefined) dict["parentType"] = this.parentType
|
|
328
|
+
if (this.parentField !== undefined) dict["parentField"] = this.parentField
|
|
329
|
+
|
|
330
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
331
|
+
const val = this.get(fieldName)
|
|
332
|
+
if (val === undefined) continue
|
|
333
|
+
|
|
334
|
+
if (fieldDef.type === "Table" && Array.isArray(val)) {
|
|
335
|
+
dict[fieldName] = val.map((child) => (child instanceof Document ? child.asDict() : child))
|
|
336
|
+
} else {
|
|
337
|
+
dict[fieldName] = val
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return dict
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
getValidDict(): Dict {
|
|
345
|
+
const dict: Dict = {
|
|
346
|
+
name: this.name,
|
|
347
|
+
owner: this.owner,
|
|
348
|
+
creation: this.creation,
|
|
349
|
+
modified: this.modified,
|
|
350
|
+
modifiedBy: this.modifiedBy,
|
|
351
|
+
docstatus: this.docstatus,
|
|
352
|
+
idx: this.idx,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (this.parent !== undefined) dict["parent"] = this.parent
|
|
356
|
+
if (this.parentType !== undefined) dict["parentType"] = this.parentType
|
|
357
|
+
if (this.parentField !== undefined) dict["parentField"] = this.parentField
|
|
358
|
+
|
|
359
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
360
|
+
if (fieldDef.type === "Table") continue
|
|
361
|
+
const val = this.get(fieldName)
|
|
362
|
+
if (val !== undefined) dict[fieldName] = val
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return dict
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Private helpers ─────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
private _setDefaults(): void {
|
|
371
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
372
|
+
if (fieldDef.type === "Table") continue
|
|
373
|
+
const current = this.get(fieldName)
|
|
374
|
+
if (current !== undefined && current !== null) continue
|
|
375
|
+
if (fieldDef.default === undefined) continue
|
|
376
|
+
|
|
377
|
+
let defaultValue: unknown = fieldDef.default
|
|
378
|
+
if (defaultValue === "today") {
|
|
379
|
+
defaultValue = new Date().toISOString().split("T")[0]
|
|
380
|
+
} else if (defaultValue === "now") {
|
|
381
|
+
defaultValue = new Date().toISOString()
|
|
382
|
+
} else if (defaultValue === "__user") {
|
|
383
|
+
defaultValue = this._user
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
this.set(fieldName, defaultValue)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private _runComputed(): void {
|
|
391
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
392
|
+
if (fieldDef.computed) {
|
|
393
|
+
this.set(fieldName, fieldDef.computed(this))
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private async _insertChildren(): Promise<void> {
|
|
399
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
400
|
+
if (fieldDef.type !== "Table") continue
|
|
401
|
+
const children = this.get(fieldName)
|
|
402
|
+
if (!Array.isArray(children)) continue
|
|
403
|
+
|
|
404
|
+
const childTableName = toTableName(fieldDef.childDocType!)
|
|
405
|
+
|
|
406
|
+
for (const child of children) {
|
|
407
|
+
const childDoc = this._toChildDoc(child, fieldDef.childDocType!, fieldName)
|
|
408
|
+
if (!childDoc.name) {
|
|
409
|
+
childDoc.name = generateName("autoincrement", fieldDef.childDocType!, {}, this._db)
|
|
410
|
+
}
|
|
411
|
+
this._db.insertRow(childTableName, childDoc.getValidDict())
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private async _updateChildren(): Promise<void> {
|
|
417
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
418
|
+
if (fieldDef.type !== "Table") continue
|
|
419
|
+
const children = this.get(fieldName)
|
|
420
|
+
if (!Array.isArray(children)) continue
|
|
421
|
+
|
|
422
|
+
const childTableName = toTableName(fieldDef.childDocType!)
|
|
423
|
+
this._db.deleteChildren(childTableName, this.name, fieldName)
|
|
424
|
+
|
|
425
|
+
for (const child of children) {
|
|
426
|
+
const childDoc = this._toChildDoc(child, fieldDef.childDocType!, fieldName)
|
|
427
|
+
if (!childDoc.name) {
|
|
428
|
+
childDoc.name = generateName("autoincrement", fieldDef.childDocType!, {}, this._db)
|
|
429
|
+
}
|
|
430
|
+
if (!childDoc.owner) {
|
|
431
|
+
childDoc.owner = this._user
|
|
432
|
+
childDoc.creation = this.creation
|
|
433
|
+
}
|
|
434
|
+
this._db.insertRow(childTableName, childDoc.getValidDict())
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private async _deleteChildren(): Promise<void> {
|
|
440
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
441
|
+
if (fieldDef.type !== "Table") continue
|
|
442
|
+
this._db.deleteChildren(toTableName(fieldDef.childDocType!), this.name, fieldName)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private async _loadChildren(): Promise<void> {
|
|
447
|
+
for (const [fieldName, fieldDef] of Object.entries(this._meta?.schema.fields ?? {})) {
|
|
448
|
+
if (fieldDef.type !== "Table") continue
|
|
449
|
+
const rows = this._db.getChildren(toTableName(fieldDef.childDocType!), this.name, fieldName)
|
|
450
|
+
const children = rows.map((row) => {
|
|
451
|
+
const child = new Document(fieldDef.childDocType!)
|
|
452
|
+
child._init(this._db, this._user)
|
|
453
|
+
child._loadValues(row)
|
|
454
|
+
child._isNew = false
|
|
455
|
+
return child
|
|
456
|
+
})
|
|
457
|
+
this.set(fieldName, children)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private _toChildDoc(child: unknown, childDocType: string, fieldName: string): Document {
|
|
462
|
+
if (child instanceof Document) {
|
|
463
|
+
child.parent = this.name
|
|
464
|
+
child.parentType = this.doctype
|
|
465
|
+
child.parentField = fieldName
|
|
466
|
+
child.modified = this.modified
|
|
467
|
+
child.modifiedBy = this._user
|
|
468
|
+
return child
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Plain object — wrap it
|
|
472
|
+
const doc = new Document(childDocType)
|
|
473
|
+
doc._init(this._db, this._user)
|
|
474
|
+
doc._loadValues(child as Dict)
|
|
475
|
+
doc.parent = this.name
|
|
476
|
+
doc.parentType = this.doctype
|
|
477
|
+
doc.parentField = fieldName
|
|
478
|
+
doc.owner = this._user
|
|
479
|
+
doc.creation = this.creation
|
|
480
|
+
doc.modified = this.modified
|
|
481
|
+
doc.modifiedBy = this._user
|
|
482
|
+
return doc
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Factory functions ────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
export function newDoc(
|
|
489
|
+
doctype: string,
|
|
490
|
+
data: Dict | undefined,
|
|
491
|
+
db: FrappeDatabase,
|
|
492
|
+
user: string,
|
|
493
|
+
): Document {
|
|
494
|
+
const meta = getDocTypeMeta(doctype)
|
|
495
|
+
if (!meta) throw new ValidationError(`DocType "${doctype}" is not registered`)
|
|
496
|
+
|
|
497
|
+
const doc = new Document(doctype)
|
|
498
|
+
doc._init(db, user)
|
|
499
|
+
if (data) doc._loadValues(data)
|
|
500
|
+
return doc
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function getDoc(
|
|
504
|
+
doctype: string,
|
|
505
|
+
name: string,
|
|
506
|
+
db: FrappeDatabase,
|
|
507
|
+
user: string,
|
|
508
|
+
): Promise<Document> {
|
|
509
|
+
const meta = getDocTypeMeta(doctype)
|
|
510
|
+
if (!meta) throw new ValidationError(`DocType "${doctype}" is not registered`)
|
|
511
|
+
|
|
512
|
+
const row = db.getRow(toTableName(doctype), name)
|
|
513
|
+
if (!row) throw new DoesNotExistError(`${doctype} "${name}" does not exist`)
|
|
514
|
+
|
|
515
|
+
const doc = new Document(doctype)
|
|
516
|
+
doc._init(db, user)
|
|
517
|
+
doc._loadValues(row)
|
|
518
|
+
|
|
519
|
+
// Load child tables
|
|
520
|
+
for (const [fieldName, fieldDef] of Object.entries(meta.schema.fields)) {
|
|
521
|
+
if (fieldDef.type !== "Table") continue
|
|
522
|
+
const rows = db.getChildren(toTableName(fieldDef.childDocType!), name, fieldName)
|
|
523
|
+
doc.set(
|
|
524
|
+
fieldName,
|
|
525
|
+
rows.map((childRow) => {
|
|
526
|
+
const child = new Document(fieldDef.childDocType!)
|
|
527
|
+
child._init(db, user)
|
|
528
|
+
child._loadValues(childRow)
|
|
529
|
+
child._snapshotOriginal()
|
|
530
|
+
return child
|
|
531
|
+
}),
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await getController(doctype)?.onLoad?.(doc)
|
|
536
|
+
doc._snapshotOriginal()
|
|
537
|
+
return doc
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export async function getList(
|
|
541
|
+
doctype: string,
|
|
542
|
+
args: {
|
|
543
|
+
filters?: FilterInput
|
|
544
|
+
fields?: string[]
|
|
545
|
+
orderBy?: string
|
|
546
|
+
limit?: number
|
|
547
|
+
offset?: number
|
|
548
|
+
},
|
|
549
|
+
db: FrappeDatabase,
|
|
550
|
+
): Promise<Dict[]> {
|
|
551
|
+
return db.getAll(doctype, args)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export async function deleteDoc(
|
|
555
|
+
doctype: string,
|
|
556
|
+
name: string,
|
|
557
|
+
db: FrappeDatabase,
|
|
558
|
+
user: string,
|
|
559
|
+
): Promise<void> {
|
|
560
|
+
const doc = await getDoc(doctype, name, db, user)
|
|
561
|
+
await doc.delete()
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function getSingle(
|
|
565
|
+
doctype: string,
|
|
566
|
+
fieldOrFields: string | string[] | undefined,
|
|
567
|
+
db: FrappeDatabase,
|
|
568
|
+
user: string,
|
|
569
|
+
): Promise<unknown> {
|
|
570
|
+
const meta = getDocTypeMeta(doctype)
|
|
571
|
+
if (!meta?.schema.isSingle) throw new ValidationError(`"${doctype}" is not a Single DocType`)
|
|
572
|
+
|
|
573
|
+
const row = db.getRow(toTableName(doctype), doctype)
|
|
574
|
+
if (!row) {
|
|
575
|
+
const doc = newDoc(doctype, { name: doctype }, db, user)
|
|
576
|
+
await doc.insert()
|
|
577
|
+
return getSingle(doctype, fieldOrFields, db, user)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!fieldOrFields) {
|
|
581
|
+
const doc = new Document(doctype)
|
|
582
|
+
doc._init(db, user)
|
|
583
|
+
doc._loadValues(row)
|
|
584
|
+
doc._snapshotOriginal()
|
|
585
|
+
return doc
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (typeof fieldOrFields === "string") return row[fieldOrFields]
|
|
589
|
+
|
|
590
|
+
const result: Dict = {}
|
|
591
|
+
for (const f of fieldOrFields) result[f] = row[f]
|
|
592
|
+
return result
|
|
593
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Name generation strategies for documents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUIDv7 } from "bun"
|
|
6
|
+
import { createHash } from "node:crypto"
|
|
7
|
+
|
|
8
|
+
import type { FrappeDatabase } from "../database/database"
|
|
9
|
+
import { toTableName } from "../doctype/registry"
|
|
10
|
+
import type { NamingStrategy, Dict } from "../doctype/types"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a name for a new document based on the DocType's naming strategy.
|
|
14
|
+
*/
|
|
15
|
+
export function generateName(
|
|
16
|
+
strategy: NamingStrategy,
|
|
17
|
+
doctype: string,
|
|
18
|
+
data: Dict,
|
|
19
|
+
db: FrappeDatabase,
|
|
20
|
+
): string {
|
|
21
|
+
if (strategy === "uuid") {
|
|
22
|
+
return randomUUIDv7()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (strategy === "hash") {
|
|
26
|
+
const hash = createHash("sha256")
|
|
27
|
+
.update(`${doctype}-${Date.now()}-${Math.random()}`)
|
|
28
|
+
.digest("hex")
|
|
29
|
+
return hash.substring(0, 10)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (strategy === "autoincrement") {
|
|
33
|
+
const tableName = toTableName(doctype)
|
|
34
|
+
return String(db.getNextId(tableName))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (strategy === "prompt") {
|
|
38
|
+
const name = data["name"]
|
|
39
|
+
if (!name) throw new Error(`DocType "${doctype}" requires a name (naming: "prompt")`)
|
|
40
|
+
return String(name)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (strategy.startsWith("field:")) {
|
|
44
|
+
const fieldName = strategy.slice(6)
|
|
45
|
+
const value = data[fieldName]
|
|
46
|
+
if (!value) throw new Error(`DocType "${doctype}" naming "field:${fieldName}": field is empty`)
|
|
47
|
+
return String(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (strategy.startsWith("series:")) {
|
|
51
|
+
const pattern = strategy.substring(7)
|
|
52
|
+
return db.getNextSeries(pattern)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Unknown naming strategy: ${strategy}`)
|
|
56
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frappe error types that map to HTTP status codes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class FrappeError extends Error {
|
|
6
|
+
httpStatus: number = 500
|
|
7
|
+
title?: string
|
|
8
|
+
|
|
9
|
+
constructor(message: string, title?: string) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = this.constructor.name
|
|
12
|
+
this.title = title
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ValidationError extends FrappeError {
|
|
17
|
+
override httpStatus = 417
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PermissionError extends FrappeError {
|
|
21
|
+
override httpStatus = 403
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class AuthenticationError extends FrappeError {
|
|
25
|
+
override httpStatus = 401
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DoesNotExistError extends FrappeError {
|
|
29
|
+
override httpStatus = 404
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DuplicateEntryError extends FrappeError {
|
|
33
|
+
override httpStatus = 409
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TimestampMismatchError extends FrappeError {
|
|
37
|
+
override httpStatus = 409
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class RateLimitError extends FrappeError {
|
|
41
|
+
override httpStatus = 429
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Map of error class constructors for use with frappe.throw() */
|
|
45
|
+
export const ErrorTypes = {
|
|
46
|
+
ValidationError,
|
|
47
|
+
PermissionError,
|
|
48
|
+
AuthenticationError,
|
|
49
|
+
DoesNotExistError,
|
|
50
|
+
DuplicateEntryError,
|
|
51
|
+
TimestampMismatchError,
|
|
52
|
+
RateLimitError,
|
|
53
|
+
} as const
|