metal-orm 1.1.3 → 1.1.4

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 (37) hide show
  1. package/README.md +715 -703
  2. package/dist/index.cjs +655 -75
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +170 -8
  5. package/dist/index.d.ts +170 -8
  6. package/dist/index.js +649 -75
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/scripts/naming-strategy.mjs +16 -1
  10. package/src/core/ast/procedure.ts +21 -0
  11. package/src/core/ast/query.ts +47 -19
  12. package/src/core/ddl/introspect/utils.ts +56 -56
  13. package/src/core/dialect/abstract.ts +560 -547
  14. package/src/core/dialect/base/sql-dialect.ts +43 -29
  15. package/src/core/dialect/mssql/index.ts +369 -232
  16. package/src/core/dialect/mysql/index.ts +99 -7
  17. package/src/core/dialect/postgres/index.ts +121 -60
  18. package/src/core/dialect/sqlite/index.ts +97 -64
  19. package/src/core/execution/db-executor.ts +108 -90
  20. package/src/core/execution/executors/mssql-executor.ts +28 -24
  21. package/src/core/execution/executors/mysql-executor.ts +62 -27
  22. package/src/core/execution/executors/sqlite-executor.ts +10 -9
  23. package/src/index.ts +9 -6
  24. package/src/orm/execute-procedure.ts +77 -0
  25. package/src/orm/execute.ts +74 -73
  26. package/src/orm/interceptor-pipeline.ts +21 -17
  27. package/src/orm/pooled-executor-factory.ts +41 -20
  28. package/src/orm/unit-of-work.ts +6 -4
  29. package/src/query/index.ts +8 -5
  30. package/src/query-builder/delete.ts +3 -2
  31. package/src/query-builder/insert-query-state.ts +47 -19
  32. package/src/query-builder/insert.ts +142 -28
  33. package/src/query-builder/procedure-call.ts +122 -0
  34. package/src/query-builder/select/select-operations.ts +5 -2
  35. package/src/query-builder/select.ts +1146 -1105
  36. package/src/query-builder/update.ts +3 -2
  37. package/src/tree/tree-manager.ts +754 -754
@@ -1,754 +1,754 @@
1
- /**
2
- * Tree Manager
3
- *
4
- * Level 2 API: ORM runtime integration for tree operations.
5
- * Provides session-aware tree manipulation with Unit of Work integration.
6
- */
7
-
8
- import type { OrmSession } from '../orm/orm-session.js';
9
- import type { DbExecutor } from '../core/execution/db-executor.js';
10
- import type { Dialect } from '../core/dialect/abstract.js';
11
- import type { TableDef } from '../schema/table.js';
12
- import { selectFrom, insertInto, update, deleteFrom } from '../query/index.js';
13
- import type { QueryResult } from '../core/execution/db-executor.js';
14
- import { and, eq, gte, lte, ValueOperandInput } from '../core/ast/expression.js';
15
-
16
- import type {
17
- TreeConfig,
18
- NestedSetBounds,
19
- RecoverResult,
20
- TreeScope,
21
- ThreadedNode,
22
- } from './tree-types.js';
23
- import {
24
- resolveTreeConfig,
25
- validateTreeTable,
26
- } from './tree-types.js';
27
- import {
28
- NestedSetStrategy,
29
- NodeWithPk,
30
- buildScopeConditions,
31
- } from './nested-set-strategy.js';
32
- import { treeQuery, TreeQuery, threadResults } from './tree-query.js';
33
-
34
- /**
35
- * Options for creating a TreeManager.
36
- */
37
- export interface TreeManagerOptions<T extends TableDef> {
38
- /** Database executor for running queries */
39
- executor: DbExecutor;
40
- /** SQL dialect */
41
- dialect: Dialect;
42
- /** Table definition */
43
- table: T;
44
- /** Tree configuration */
45
- config?: Partial<TreeConfig>;
46
- /** Optional scope values for multi-tree tables */
47
- scope?: TreeScope;
48
- }
49
-
50
- /**
51
- * Result of fetching a node with tree metadata.
52
- */
53
- export interface TreeNodeResult<T = Record<string, unknown>> {
54
- /** The node data */
55
- data: T;
56
- /** Nested set bounds */
57
- lft: number;
58
- rght: number;
59
- /** Parent ID (null for roots) */
60
- parentId: unknown;
61
- /** Depth level (if available) */
62
- depth?: number;
63
- /** Whether this is a leaf node */
64
- isLeaf: boolean;
65
- /** Whether this is a root node */
66
- isRoot: boolean;
67
- /** Count of descendants */
68
- childCount: number;
69
- }
70
-
71
- /**
72
- * Tree Manager for Level 2 ORM runtime integration.
73
- * Provides session-aware tree operations with proper change tracking.
74
- *
75
- * @typeParam T - The table definition type
76
- *
77
- * @example
78
- * ```ts
79
- * const manager = new TreeManager({
80
- * executor: session.executor,
81
- * dialect: session.dialect,
82
- * table: categories,
83
- * config: { parentKey: 'parentId', leftKey: 'lft', rightKey: 'rght' },
84
- * });
85
- *
86
- * const node = await manager.getNode(5);
87
- * await manager.moveUp(node);
88
- * ```
89
- */
90
- export class TreeManager<T extends TableDef> {
91
- readonly table: T;
92
- readonly config: TreeConfig;
93
- readonly query: TreeQuery<T>;
94
-
95
- private readonly executor: DbExecutor;
96
- private readonly dialect: Dialect;
97
- private readonly scopeValues: TreeScope;
98
- private readonly pkName: string;
99
-
100
- constructor(options: TreeManagerOptions<T>) {
101
- const { executor, dialect, table, config = {}, scope = {} } = options;
102
-
103
- this.executor = executor;
104
- this.dialect = dialect;
105
- this.table = table;
106
- this.config = resolveTreeConfig(config);
107
- this.scopeValues = scope;
108
- this.pkName = this.getPrimaryKeyName();
109
-
110
- const validation = validateTreeTable(table, this.config);
111
- if (!validation.valid) {
112
- throw new Error(
113
- `Invalid tree table '${table.name}': missing columns ${validation.missingColumns.join(', ')}`
114
- );
115
- }
116
-
117
- this.query = treeQuery(table, this.config).withScope(scope);
118
- }
119
-
120
- /**
121
- * Gets a node by ID with tree metadata.
122
- */
123
- async getNode(id: unknown): Promise<TreeNodeResult | null> {
124
- const query = this.query.findById(id);
125
- const { sql, params } = query.compile(this.dialect);
126
- const results = await this.executor.executeSql(sql, params);
127
- const rows = queryResultsToRows(results);
128
-
129
- if (rows.length === 0) return null;
130
-
131
- return this.createNodeResult(rows[0]);
132
- }
133
-
134
- /**
135
- * Gets the root nodes.
136
- */
137
- async getRoots(): Promise<TreeNodeResult[]> {
138
- const query = this.query.findRoots();
139
- const { sql, params } = query.compile(this.dialect);
140
- const results = await this.executor.executeSql(sql, params);
141
- const rows = queryResultsToRows(results);
142
-
143
- return rows.map(row => this.createNodeResult(row));
144
- }
145
-
146
- /**
147
- * Gets direct children of a node.
148
- */
149
- async getChildren(parentId: unknown): Promise<TreeNodeResult[]> {
150
- const query = this.query.findDirectChildren(parentId);
151
- const { sql, params } = query.compile(this.dialect);
152
- const results = await this.executor.executeSql(sql, params);
153
- const rows = queryResultsToRows(results);
154
-
155
- return rows.map(row => this.createNodeResult(row));
156
- }
157
-
158
- /**
159
- * Gets all descendants of a node.
160
- */
161
- async getDescendants(node: TreeNodeResult | NestedSetBounds): Promise<TreeNodeResult[]> {
162
- const bounds = this.getBounds(node);
163
- const query = this.query.findDescendants(bounds);
164
- const { sql, params } = query.compile(this.dialect);
165
- const results = await this.executor.executeSql(sql, params);
166
- const rows = queryResultsToRows(results);
167
-
168
- return rows.map(row => this.createNodeResult(row));
169
- }
170
-
171
- /**
172
- * Gets the path from root to a node (ancestors).
173
- */
174
- async getPath(node: TreeNodeResult | NestedSetBounds, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
175
- const bounds = this.getBounds(node);
176
- const query = this.query.findAncestors(bounds, { includeSelf });
177
- const { sql, params } = query.compile(this.dialect);
178
- const results = await this.executor.executeSql(sql, params);
179
- const rows = queryResultsToRows(results);
180
-
181
- return rows.map(row => this.createNodeResult(row));
182
- }
183
-
184
- /**
185
- * Gets siblings of a node.
186
- */
187
- async getSiblings(node: TreeNodeResult, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
188
- const query = this.query.findSiblings(
189
- node.parentId,
190
- includeSelf ? undefined : (node.data as Record<string, unknown>)[this.pkName]
191
- );
192
- const { sql, params } = query.compile(this.dialect);
193
- const results = await this.executor.executeSql(sql, params);
194
- const rows = queryResultsToRows(results);
195
-
196
- return rows.map(row => this.createNodeResult(row));
197
- }
198
-
199
- /**
200
- * Gets the parent of a node.
201
- */
202
- async getParent(node: TreeNodeResult): Promise<TreeNodeResult | null> {
203
- if (node.isRoot) return null;
204
- return this.getNode(node.parentId);
205
- }
206
-
207
- /**
208
- * Gets all descendants as a threaded (nested) structure.
209
- */
210
- async getDescendantsThreaded(node: TreeNodeResult | NestedSetBounds): Promise<ThreadedNode<Record<string, unknown>>[]> {
211
- const bounds = this.getBounds(node);
212
- const query = this.query.findDescendants(bounds);
213
- const { sql, params } = query.compile(this.dialect);
214
- const queryResults = await this.executor.executeSql(sql, params);
215
- const rows = queryResultsToRows(queryResults);
216
-
217
- return threadResults(
218
- rows,
219
- this.config.leftKey,
220
- this.config.rightKey
221
- );
222
- }
223
-
224
- /**
225
- * Counts descendants of a node.
226
- */
227
- childCount(node: TreeNodeResult | NestedSetBounds): number {
228
- const bounds = this.getBounds(node);
229
- return NestedSetStrategy.childCount(bounds.lft, bounds.rght);
230
- }
231
-
232
- /**
233
- * Gets the depth (level) of a node.
234
- */
235
- async getLevel(node: TreeNodeResult | NestedSetBounds): Promise<number> {
236
- const bounds = this.getBounds(node);
237
-
238
- if ('depth' in node && typeof node.depth === 'number') {
239
- return node.depth;
240
- }
241
-
242
- const ancestors = await this.getPath(bounds, false);
243
- return ancestors.length;
244
- }
245
-
246
- /**
247
- * Moves a node up among its siblings.
248
- * @returns true if moved, false if already at top
249
- */
250
- async moveUp(node: TreeNodeResult): Promise<boolean> {
251
- const siblings = await this.getSiblings(node, true);
252
- const nodeIndex = siblings.findIndex(
253
- s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
254
- );
255
-
256
- if (nodeIndex <= 0) return false;
257
-
258
- const prevSibling = siblings[nodeIndex - 1];
259
- return this.swapNodes(node, prevSibling);
260
- }
261
-
262
- /**
263
- * Moves a node down among its siblings.
264
- * @returns true if moved, false if already at bottom
265
- */
266
- async moveDown(node: TreeNodeResult): Promise<boolean> {
267
- const siblings = await this.getSiblings(node, true);
268
- const nodeIndex = siblings.findIndex(
269
- s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
270
- );
271
-
272
- if (nodeIndex < 0 || nodeIndex >= siblings.length - 1) return false;
273
-
274
- const nextSibling = siblings[nodeIndex + 1];
275
- return this.swapNodes(node, nextSibling);
276
- }
277
-
278
- /**
279
- * Moves a node to be the last child of a new parent.
280
- */
281
- async moveTo(node: TreeNodeResult, newParentId: unknown | null): Promise<void> {
282
- NestedSetStrategy.subtreeWidth(node.lft, node.rght);
283
-
284
- let newPos: { lft: number; rght: number; depth: number };
285
-
286
- if (newParentId === null) {
287
- const maxRght = await this.getMaxRght();
288
- newPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
289
- } else {
290
- const newParent = await this.getNode(newParentId);
291
- if (!newParent) {
292
- throw new Error(`Parent node ${newParentId} not found`);
293
- }
294
- newPos = NestedSetStrategy.calculateInsertAsLastChild(
295
- newParent.rght,
296
- newParent.depth ?? await this.getLevel(newParent)
297
- );
298
- }
299
-
300
- await this.moveSubtree(node, newPos.lft, newParentId, newPos.depth);
301
- }
302
-
303
- /**
304
- * Inserts a new node as a child of a parent.
305
- * @returns The ID of the new node (if auto-generated)
306
- */
307
- async insertAsChild(
308
- parentId: unknown | null,
309
- data: Record<string, unknown>
310
- ): Promise<unknown> {
311
- let insertPos: { lft: number; rght: number; depth: number };
312
-
313
- if (parentId === null) {
314
- const maxRght = await this.getMaxRght();
315
- insertPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
316
- } else {
317
- const parent = await this.getNode(parentId);
318
- if (!parent) {
319
- throw new Error(`Parent node ${parentId} not found`);
320
- }
321
-
322
- await this.shiftForInsert(parent.rght);
323
-
324
- insertPos = NestedSetStrategy.calculateInsertAsLastChild(
325
- parent.rght,
326
- parent.depth ?? await this.getLevel(parent)
327
- );
328
- }
329
-
330
- const insertData = {
331
- ...data,
332
- [this.config.parentKey]: parentId,
333
- [this.config.leftKey]: insertPos.lft,
334
- [this.config.rightKey]: insertPos.rght,
335
- };
336
-
337
- if (this.config.depthKey) {
338
- insertData[this.config.depthKey] = insertPos.depth;
339
- }
340
-
341
- const scopeData = buildScopeConditions(this.config.scope, this.scopeValues);
342
- Object.assign(insertData, scopeData);
343
-
344
- const insertQuery = insertInto(this.table).values(insertData as Record<string, ValueOperandInput>);
345
- const { sql, params } = insertQuery.compile(this.dialect);
346
- await this.executor.executeSql(sql, params);
347
-
348
- // If ID was provided in data, return it
349
- if (insertData[this.pkName] !== undefined) {
350
- return insertData[this.pkName];
351
- }
352
-
353
- // For auto-increment, query for the inserted node by its unique lft value
354
- const findQuery = selectFrom(this.table)
355
- .where(eq(this.table.columns[this.config.leftKey], insertPos.lft));
356
- const { sql: findSql, params: findParams } = findQuery.compile(this.dialect);
357
- const results = await this.executor.executeSql(findSql, findParams);
358
- const rows = queryResultsToRows(results);
359
-
360
- if (rows.length > 0) {
361
- return rows[0][this.pkName];
362
- }
363
-
364
- return undefined;
365
- }
366
-
367
- /**
368
- * Removes a node and re-parents its children to the node's parent.
369
- */
370
- async removeFromTree(node: TreeNodeResult): Promise<void> {
371
- const nodeId = (node.data as Record<string, unknown>)[this.pkName];
372
-
373
- await this.executeUpdate(
374
- eq(this.table.columns[this.config.parentKey], nodeId),
375
- { [this.config.parentKey]: node.parentId }
376
- );
377
-
378
- const gap = NestedSetStrategy.calculateDeleteGap(node.lft, node.rght);
379
- await this.shiftForDelete(node.rght, 2);
380
-
381
- NestedSetStrategy.calculateShiftForDelete(node.lft + 1, gap.width - 2);
382
- if (gap.width > 2) {
383
- await this.executeRawUpdate(
384
- `UPDATE ${this.quoteTable()} SET ` +
385
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - 1, ` +
386
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - 1 ` +
387
- `WHERE ${this.quoteCol(this.config.leftKey)} > ? AND ${this.quoteCol(this.config.rightKey)} < ?`,
388
- [node.lft, node.rght]
389
- );
390
- }
391
- }
392
-
393
- /**
394
- * Deletes a node and all its descendants.
395
- */
396
- async deleteSubtree(node: TreeNodeResult): Promise<number> {
397
- const bounds = { lft: node.lft, rght: node.rght };
398
- const width = NestedSetStrategy.subtreeWidth(bounds.lft, bounds.rght);
399
-
400
- const deleteQuery = deleteFrom(this.table)
401
- .where(
402
- and(
403
- gte(this.table.columns[this.config.leftKey], bounds.lft),
404
- lte(this.table.columns[this.config.rightKey], bounds.rght)
405
- )
406
- );
407
-
408
- const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
409
- let finalQuery = deleteQuery;
410
- for (const [key, value] of Object.entries(scopeConditions)) {
411
- finalQuery = finalQuery.where(eq(this.table.columns[key], value));
412
- }
413
-
414
- const { sql, params } = finalQuery.compile(this.dialect);
415
- await this.executor.executeSql(sql, params);
416
-
417
- await this.shiftForDelete(bounds.rght, width);
418
-
419
- return width / 2;
420
- }
421
-
422
- /**
423
- * Rebuilds the tree structure from parent_id relationships.
424
- * Useful for fixing corrupted trees or initial population.
425
- */
426
- async recover(): Promise<RecoverResult> {
427
- try {
428
- const nodes = await this.getAllNodesForRecovery();
429
- const updates = NestedSetStrategy.recover(nodes, (a, b) => {
430
- if (this.config.recoverOrder) {
431
- const [key, dir] = Object.entries(this.config.recoverOrder)[0];
432
- const aVal = a[key as keyof typeof a];
433
- const bVal = b[key as keyof typeof b];
434
- const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
435
- return dir === 'DESC' ? -cmp : cmp;
436
- }
437
- return (a.pk as number) - (b.pk as number);
438
- });
439
-
440
- for (const update of updates) {
441
- const updateData: Record<string, unknown> = {
442
- [this.config.leftKey]: update.lft,
443
- [this.config.rightKey]: update.rght,
444
- };
445
- if (this.config.depthKey) {
446
- updateData[this.config.depthKey] = update.depth;
447
- }
448
-
449
- await this.executeUpdate(
450
- eq(this.table.columns[this.pkName], update.pk),
451
- updateData
452
- );
453
- }
454
-
455
- return { processed: updates.length, success: true };
456
- } catch (error) {
457
- return {
458
- processed: 0,
459
- success: false,
460
- errors: [(error as Error).message],
461
- };
462
- }
463
- }
464
-
465
- /**
466
- * Validates the tree structure.
467
- * @returns Array of validation errors (empty if valid)
468
- */
469
- async validate(): Promise<string[]> {
470
- const query = this.query.findTreeList();
471
- const { sql, params } = query.compile(this.dialect);
472
- const queryResults = await this.executor.executeSql(sql, params);
473
- const rows = queryResultsToRows(queryResults);
474
-
475
- return NestedSetStrategy.validateTree(
476
- rows,
477
- row => row[this.config.leftKey] as number,
478
- row => row[this.config.rightKey] as number,
479
- row => row[this.pkName]
480
- );
481
- }
482
-
483
- /**
484
- * Creates a new TreeManager with different scope values.
485
- */
486
- withScope(scope: TreeScope): TreeManager<T> {
487
- return new TreeManager({
488
- executor: this.executor,
489
- dialect: this.dialect,
490
- table: this.table,
491
- config: this.config,
492
- scope: { ...this.scopeValues, ...scope },
493
- });
494
- }
495
-
496
- // ===== Private Helpers =====
497
-
498
- private createNodeResult(row: Record<string, unknown>): TreeNodeResult {
499
- const lft = row[this.config.leftKey] as number;
500
- const rght = row[this.config.rightKey] as number;
501
- const parentId = row[this.config.parentKey];
502
- const depth = this.config.depthKey ? row[this.config.depthKey] as number | undefined : undefined;
503
-
504
- return {
505
- data: row,
506
- lft,
507
- rght,
508
- parentId,
509
- depth,
510
- isLeaf: NestedSetStrategy.isLeaf(lft, rght),
511
- isRoot: NestedSetStrategy.isRoot(parentId),
512
- childCount: NestedSetStrategy.childCount(lft, rght),
513
- };
514
- }
515
-
516
- private getBounds(node: TreeNodeResult | NestedSetBounds): NestedSetBounds {
517
- if ('data' in node) {
518
- return { lft: node.lft, rght: node.rght };
519
- }
520
- return node;
521
- }
522
-
523
- private getPrimaryKeyName(): string {
524
- for (const [name, col] of Object.entries(this.table.columns)) {
525
- if (col.primary) {
526
- return name;
527
- }
528
- }
529
- if (this.table.primaryKey && this.table.primaryKey.length > 0) {
530
- return this.table.primaryKey[0];
531
- }
532
- return 'id';
533
- }
534
-
535
- private async getMaxRght(): Promise<number> {
536
- const query = selectFrom(this.table)
537
- .selectRaw(`MAX(${this.config.rightKey}) as max_rght`);
538
- const { sql, params } = query.compile(this.dialect);
539
- const queryResults = await this.executor.executeSql(sql, params);
540
- const rows = queryResultsToRows(queryResults);
541
-
542
- const maxRght = rows[0]?.max_rght;
543
- return typeof maxRght === 'number' ? maxRght : 0;
544
- }
545
-
546
- private async shiftForInsert(insertPoint: number): Promise<void> {
547
- await this.executeRawUpdate(
548
- `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + 2 ` +
549
- `WHERE ${this.quoteCol(this.config.rightKey)} >= ?`,
550
- [insertPoint]
551
- );
552
-
553
- await this.executeRawUpdate(
554
- `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + 2 ` +
555
- `WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
556
- [insertPoint]
557
- );
558
- }
559
-
560
- private async shiftForDelete(deletedRght: number, width: number): Promise<void> {
561
- await this.executeRawUpdate(
562
- `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? ` +
563
- `WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
564
- [width, deletedRght]
565
- );
566
-
567
- await this.executeRawUpdate(
568
- `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? ` +
569
- `WHERE ${this.quoteCol(this.config.rightKey)} > ?`,
570
- [width, deletedRght]
571
- );
572
- }
573
-
574
- private async swapNodes(nodeA: TreeNodeResult, nodeB: TreeNodeResult): Promise<boolean> {
575
- const shift = NestedSetStrategy.calculateMoveUp(
576
- { lft: nodeA.lft, rght: nodeA.rght },
577
- { lft: nodeB.lft, rght: nodeB.rght }
578
- );
579
-
580
- if (!shift) return false;
581
-
582
- const tempOffset = 10000000;
583
-
584
- await this.executeRawUpdate(
585
- `UPDATE ${this.quoteTable()} SET ` +
586
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
587
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
588
- `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
589
- [tempOffset, tempOffset, nodeA.lft, nodeA.rght]
590
- );
591
-
592
- await this.executeRawUpdate(
593
- `UPDATE ${this.quoteTable()} SET ` +
594
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
595
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
596
- `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
597
- [shift.siblingShift, shift.siblingShift, nodeB.lft, nodeB.rght]
598
- );
599
-
600
- await this.executeRawUpdate(
601
- `UPDATE ${this.quoteTable()} SET ` +
602
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
603
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ? ` +
604
- `WHERE ${this.quoteCol(this.config.leftKey)} >= ?`,
605
- [tempOffset, shift.nodeShift, tempOffset, shift.nodeShift, nodeA.lft + tempOffset]
606
- );
607
-
608
- return true;
609
- }
610
-
611
- private async moveSubtree(
612
- node: TreeNodeResult,
613
- newLft: number,
614
- newParentId: unknown | null,
615
- newDepth: number
616
- ): Promise<void> {
617
- const width = NestedSetStrategy.subtreeWidth(node.lft, node.rght);
618
- const delta = newLft - node.lft;
619
- const depthDelta = this.config.depthKey
620
- ? newDepth - (node.depth ?? 0)
621
- : 0;
622
- const nodeId = (node.data as Record<string, unknown>)[this.pkName];
623
-
624
- const tempOffset = 10000000;
625
-
626
- await this.executeRawUpdate(
627
- `UPDATE ${this.quoteTable()} SET ` +
628
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ? ` +
629
- `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
630
- [tempOffset, node.lft, node.rght]
631
- );
632
-
633
- await this.executeRawUpdate(
634
- `UPDATE ${this.quoteTable()} SET ` +
635
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
636
- `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
637
- [tempOffset, node.lft + tempOffset, node.rght]
638
- );
639
-
640
- await this.shiftForDelete(node.rght, width);
641
- await this.shiftForInsert(newLft);
642
-
643
- let updateSql = `UPDATE ${this.quoteTable()} SET ` +
644
- `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
645
- `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ?`;
646
-
647
- const updateParams: unknown[] = [tempOffset, delta, tempOffset, delta];
648
-
649
- if (this.config.depthKey && depthDelta !== 0) {
650
- updateSql += `, ${this.quoteCol(this.config.depthKey)} = ${this.quoteCol(this.config.depthKey)} + ?`;
651
- updateParams.push(depthDelta);
652
- }
653
-
654
- updateSql += ` WHERE ${this.quoteCol(this.config.leftKey)} >= ?`;
655
- updateParams.push(node.lft + tempOffset);
656
-
657
- await this.executeRawUpdate(updateSql, updateParams);
658
-
659
- await this.executeUpdate(
660
- eq(this.table.columns[this.pkName], nodeId),
661
- { [this.config.parentKey]: newParentId }
662
- );
663
- }
664
-
665
- private async getAllNodesForRecovery(): Promise<NodeWithPk<unknown>[]> {
666
- const query = selectFrom(this.table)
667
- .select(this.pkName, this.config.parentKey, this.config.leftKey, this.config.rightKey);
668
-
669
- const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
670
- let finalQuery = query;
671
- for (const [key, value] of Object.entries(scopeConditions)) {
672
- finalQuery = finalQuery.where(eq(this.table.columns[key], value));
673
- }
674
-
675
- const { sql, params } = finalQuery.compile(this.dialect);
676
- const queryResults = await this.executor.executeSql(sql, params);
677
- const rows = queryResultsToRows(queryResults);
678
-
679
- return rows.map(row => ({
680
- pk: row[this.pkName],
681
- lft: row[this.config.leftKey] as number,
682
- rght: row[this.config.rightKey] as number,
683
- parentId: row[this.config.parentKey],
684
- }));
685
- }
686
-
687
- private async executeUpdate(
688
- condition: ReturnType<typeof eq>,
689
- data: Record<string, unknown>
690
- ): Promise<void> {
691
- const query = update(this.table).set(data).where(condition);
692
- const { sql, params } = query.compile(this.dialect);
693
- await this.executor.executeSql(sql, params);
694
- }
695
-
696
- private async executeRawUpdate(sql: string, params: unknown[]): Promise<void> {
697
- await this.executor.executeSql(sql, params);
698
- }
699
-
700
- private quoteTable(): string {
701
- const quote = this.getQuoteChar();
702
- return `${quote}${this.table.name}${quote}`;
703
- }
704
-
705
- private quoteCol(name: string): string {
706
- const quote = this.getQuoteChar();
707
- return `${quote}${name}${quote}`;
708
- }
709
-
710
- private getQuoteChar(): string {
711
- const dialectName = this.dialect.constructor.name.toLowerCase();
712
- return dialectName.includes('mysql') ? '`' : '"';
713
- }
714
- }
715
-
716
- /**
717
- * Creates a TreeManager from an OrmSession.
718
- * Convenience factory for Level 2 integration.
719
- */
720
- export function createTreeManager<T extends TableDef>(
721
- session: OrmSession,
722
- table: T,
723
- config?: Partial<TreeConfig>,
724
- scope?: TreeScope
725
- ): TreeManager<T> {
726
- return new TreeManager({
727
- executor: session.executor,
728
- dialect: session.dialect,
729
- table,
730
- config,
731
- scope,
732
- });
733
- }
734
-
735
- /**
736
- * Converts QueryResult[] to row objects.
737
- * Handles the canonical { columns, values } format.
738
- */
739
- function queryResultsToRows(results: QueryResult[]): Record<string, unknown>[] {
740
- const rows: Record<string, unknown>[] = [];
741
-
742
- for (const result of results) {
743
- const { columns, values } = result;
744
- for (const valueRow of values) {
745
- const row: Record<string, unknown> = {};
746
- for (let i = 0; i < columns.length; i++) {
747
- row[columns[i]] = valueRow[i];
748
- }
749
- rows.push(row);
750
- }
751
- }
752
-
753
- return rows;
754
- }
1
+ /**
2
+ * Tree Manager
3
+ *
4
+ * Level 2 API: ORM runtime integration for tree operations.
5
+ * Provides session-aware tree manipulation with Unit of Work integration.
6
+ */
7
+
8
+ import type { OrmSession } from '../orm/orm-session.js';
9
+ import type { DbExecutor } from '../core/execution/db-executor.js';
10
+ import type { Dialect } from '../core/dialect/abstract.js';
11
+ import type { TableDef } from '../schema/table.js';
12
+ import { selectFrom, insertInto, update, deleteFrom } from '../query/index.js';
13
+ import type { QueryResult } from '../core/execution/db-executor.js';
14
+ import { and, eq, gte, lte, ValueOperandInput } from '../core/ast/expression.js';
15
+
16
+ import type {
17
+ TreeConfig,
18
+ NestedSetBounds,
19
+ RecoverResult,
20
+ TreeScope,
21
+ ThreadedNode,
22
+ } from './tree-types.js';
23
+ import {
24
+ resolveTreeConfig,
25
+ validateTreeTable,
26
+ } from './tree-types.js';
27
+ import {
28
+ NestedSetStrategy,
29
+ NodeWithPk,
30
+ buildScopeConditions,
31
+ } from './nested-set-strategy.js';
32
+ import { treeQuery, TreeQuery, threadResults } from './tree-query.js';
33
+
34
+ /**
35
+ * Options for creating a TreeManager.
36
+ */
37
+ export interface TreeManagerOptions<T extends TableDef> {
38
+ /** Database executor for running queries */
39
+ executor: DbExecutor;
40
+ /** SQL dialect */
41
+ dialect: Dialect;
42
+ /** Table definition */
43
+ table: T;
44
+ /** Tree configuration */
45
+ config?: Partial<TreeConfig>;
46
+ /** Optional scope values for multi-tree tables */
47
+ scope?: TreeScope;
48
+ }
49
+
50
+ /**
51
+ * Result of fetching a node with tree metadata.
52
+ */
53
+ export interface TreeNodeResult<T = Record<string, unknown>> {
54
+ /** The node data */
55
+ data: T;
56
+ /** Nested set bounds */
57
+ lft: number;
58
+ rght: number;
59
+ /** Parent ID (null for roots) */
60
+ parentId: unknown;
61
+ /** Depth level (if available) */
62
+ depth?: number;
63
+ /** Whether this is a leaf node */
64
+ isLeaf: boolean;
65
+ /** Whether this is a root node */
66
+ isRoot: boolean;
67
+ /** Count of descendants */
68
+ childCount: number;
69
+ }
70
+
71
+ /**
72
+ * Tree Manager for Level 2 ORM runtime integration.
73
+ * Provides session-aware tree operations with proper change tracking.
74
+ *
75
+ * @typeParam T - The table definition type
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const manager = new TreeManager({
80
+ * executor: session.executor,
81
+ * dialect: session.dialect,
82
+ * table: categories,
83
+ * config: { parentKey: 'parentId', leftKey: 'lft', rightKey: 'rght' },
84
+ * });
85
+ *
86
+ * const node = await manager.getNode(5);
87
+ * await manager.moveUp(node);
88
+ * ```
89
+ */
90
+ export class TreeManager<T extends TableDef> {
91
+ readonly table: T;
92
+ readonly config: TreeConfig;
93
+ readonly query: TreeQuery<T>;
94
+
95
+ private readonly executor: DbExecutor;
96
+ private readonly dialect: Dialect;
97
+ private readonly scopeValues: TreeScope;
98
+ private readonly pkName: string;
99
+
100
+ constructor(options: TreeManagerOptions<T>) {
101
+ const { executor, dialect, table, config = {}, scope = {} } = options;
102
+
103
+ this.executor = executor;
104
+ this.dialect = dialect;
105
+ this.table = table;
106
+ this.config = resolveTreeConfig(config);
107
+ this.scopeValues = scope;
108
+ this.pkName = this.getPrimaryKeyName();
109
+
110
+ const validation = validateTreeTable(table, this.config);
111
+ if (!validation.valid) {
112
+ throw new Error(
113
+ `Invalid tree table '${table.name}': missing columns ${validation.missingColumns.join(', ')}`
114
+ );
115
+ }
116
+
117
+ this.query = treeQuery(table, this.config).withScope(scope);
118
+ }
119
+
120
+ /**
121
+ * Gets a node by ID with tree metadata.
122
+ */
123
+ async getNode(id: unknown): Promise<TreeNodeResult | null> {
124
+ const query = this.query.findById(id);
125
+ const { sql, params } = query.compile(this.dialect);
126
+ const results = await this.executor.executeSql(sql, params);
127
+ const rows = queryResultsToRows(results);
128
+
129
+ if (rows.length === 0) return null;
130
+
131
+ return this.createNodeResult(rows[0]);
132
+ }
133
+
134
+ /**
135
+ * Gets the root nodes.
136
+ */
137
+ async getRoots(): Promise<TreeNodeResult[]> {
138
+ const query = this.query.findRoots();
139
+ const { sql, params } = query.compile(this.dialect);
140
+ const results = await this.executor.executeSql(sql, params);
141
+ const rows = queryResultsToRows(results);
142
+
143
+ return rows.map(row => this.createNodeResult(row));
144
+ }
145
+
146
+ /**
147
+ * Gets direct children of a node.
148
+ */
149
+ async getChildren(parentId: unknown): Promise<TreeNodeResult[]> {
150
+ const query = this.query.findDirectChildren(parentId);
151
+ const { sql, params } = query.compile(this.dialect);
152
+ const results = await this.executor.executeSql(sql, params);
153
+ const rows = queryResultsToRows(results);
154
+
155
+ return rows.map(row => this.createNodeResult(row));
156
+ }
157
+
158
+ /**
159
+ * Gets all descendants of a node.
160
+ */
161
+ async getDescendants(node: TreeNodeResult | NestedSetBounds): Promise<TreeNodeResult[]> {
162
+ const bounds = this.getBounds(node);
163
+ const query = this.query.findDescendants(bounds);
164
+ const { sql, params } = query.compile(this.dialect);
165
+ const results = await this.executor.executeSql(sql, params);
166
+ const rows = queryResultsToRows(results);
167
+
168
+ return rows.map(row => this.createNodeResult(row));
169
+ }
170
+
171
+ /**
172
+ * Gets the path from root to a node (ancestors).
173
+ */
174
+ async getPath(node: TreeNodeResult | NestedSetBounds, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
175
+ const bounds = this.getBounds(node);
176
+ const query = this.query.findAncestors(bounds, { includeSelf });
177
+ const { sql, params } = query.compile(this.dialect);
178
+ const results = await this.executor.executeSql(sql, params);
179
+ const rows = queryResultsToRows(results);
180
+
181
+ return rows.map(row => this.createNodeResult(row));
182
+ }
183
+
184
+ /**
185
+ * Gets siblings of a node.
186
+ */
187
+ async getSiblings(node: TreeNodeResult, includeSelf: boolean = true): Promise<TreeNodeResult[]> {
188
+ const query = this.query.findSiblings(
189
+ node.parentId,
190
+ includeSelf ? undefined : (node.data as Record<string, unknown>)[this.pkName]
191
+ );
192
+ const { sql, params } = query.compile(this.dialect);
193
+ const results = await this.executor.executeSql(sql, params);
194
+ const rows = queryResultsToRows(results);
195
+
196
+ return rows.map(row => this.createNodeResult(row));
197
+ }
198
+
199
+ /**
200
+ * Gets the parent of a node.
201
+ */
202
+ async getParent(node: TreeNodeResult): Promise<TreeNodeResult | null> {
203
+ if (node.isRoot) return null;
204
+ return this.getNode(node.parentId);
205
+ }
206
+
207
+ /**
208
+ * Gets all descendants as a threaded (nested) structure.
209
+ */
210
+ async getDescendantsThreaded(node: TreeNodeResult | NestedSetBounds): Promise<ThreadedNode<Record<string, unknown>>[]> {
211
+ const bounds = this.getBounds(node);
212
+ const query = this.query.findDescendants(bounds);
213
+ const { sql, params } = query.compile(this.dialect);
214
+ const queryResults = await this.executor.executeSql(sql, params);
215
+ const rows = queryResultsToRows(queryResults);
216
+
217
+ return threadResults(
218
+ rows,
219
+ this.config.leftKey,
220
+ this.config.rightKey
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Counts descendants of a node.
226
+ */
227
+ childCount(node: TreeNodeResult | NestedSetBounds): number {
228
+ const bounds = this.getBounds(node);
229
+ return NestedSetStrategy.childCount(bounds.lft, bounds.rght);
230
+ }
231
+
232
+ /**
233
+ * Gets the depth (level) of a node.
234
+ */
235
+ async getLevel(node: TreeNodeResult | NestedSetBounds): Promise<number> {
236
+ const bounds = this.getBounds(node);
237
+
238
+ if ('depth' in node && typeof node.depth === 'number') {
239
+ return node.depth;
240
+ }
241
+
242
+ const ancestors = await this.getPath(bounds, false);
243
+ return ancestors.length;
244
+ }
245
+
246
+ /**
247
+ * Moves a node up among its siblings.
248
+ * @returns true if moved, false if already at top
249
+ */
250
+ async moveUp(node: TreeNodeResult): Promise<boolean> {
251
+ const siblings = await this.getSiblings(node, true);
252
+ const nodeIndex = siblings.findIndex(
253
+ s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
254
+ );
255
+
256
+ if (nodeIndex <= 0) return false;
257
+
258
+ const prevSibling = siblings[nodeIndex - 1];
259
+ return this.swapNodes(node, prevSibling);
260
+ }
261
+
262
+ /**
263
+ * Moves a node down among its siblings.
264
+ * @returns true if moved, false if already at bottom
265
+ */
266
+ async moveDown(node: TreeNodeResult): Promise<boolean> {
267
+ const siblings = await this.getSiblings(node, true);
268
+ const nodeIndex = siblings.findIndex(
269
+ s => (s.data as Record<string, unknown>)[this.pkName] === (node.data as Record<string, unknown>)[this.pkName]
270
+ );
271
+
272
+ if (nodeIndex < 0 || nodeIndex >= siblings.length - 1) return false;
273
+
274
+ const nextSibling = siblings[nodeIndex + 1];
275
+ return this.swapNodes(node, nextSibling);
276
+ }
277
+
278
+ /**
279
+ * Moves a node to be the last child of a new parent.
280
+ */
281
+ async moveTo(node: TreeNodeResult, newParentId: unknown | null): Promise<void> {
282
+ NestedSetStrategy.subtreeWidth(node.lft, node.rght);
283
+
284
+ let newPos: { lft: number; rght: number; depth: number };
285
+
286
+ if (newParentId === null) {
287
+ const maxRght = await this.getMaxRght();
288
+ newPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
289
+ } else {
290
+ const newParent = await this.getNode(newParentId);
291
+ if (!newParent) {
292
+ throw new Error(`Parent node ${newParentId} not found`);
293
+ }
294
+ newPos = NestedSetStrategy.calculateInsertAsLastChild(
295
+ newParent.rght,
296
+ newParent.depth ?? await this.getLevel(newParent)
297
+ );
298
+ }
299
+
300
+ await this.moveSubtree(node, newPos.lft, newParentId, newPos.depth);
301
+ }
302
+
303
+ /**
304
+ * Inserts a new node as a child of a parent.
305
+ * @returns The ID of the new node (if auto-generated)
306
+ */
307
+ async insertAsChild(
308
+ parentId: unknown | null,
309
+ data: Record<string, unknown>
310
+ ): Promise<unknown> {
311
+ let insertPos: { lft: number; rght: number; depth: number };
312
+
313
+ if (parentId === null) {
314
+ const maxRght = await this.getMaxRght();
315
+ insertPos = NestedSetStrategy.calculateInsertAsRoot(maxRght);
316
+ } else {
317
+ const parent = await this.getNode(parentId);
318
+ if (!parent) {
319
+ throw new Error(`Parent node ${parentId} not found`);
320
+ }
321
+
322
+ await this.shiftForInsert(parent.rght);
323
+
324
+ insertPos = NestedSetStrategy.calculateInsertAsLastChild(
325
+ parent.rght,
326
+ parent.depth ?? await this.getLevel(parent)
327
+ );
328
+ }
329
+
330
+ const insertData = {
331
+ ...data,
332
+ [this.config.parentKey]: parentId,
333
+ [this.config.leftKey]: insertPos.lft,
334
+ [this.config.rightKey]: insertPos.rght,
335
+ };
336
+
337
+ if (this.config.depthKey) {
338
+ insertData[this.config.depthKey] = insertPos.depth;
339
+ }
340
+
341
+ const scopeData = buildScopeConditions(this.config.scope, this.scopeValues);
342
+ Object.assign(insertData, scopeData);
343
+
344
+ const insertQuery = insertInto(this.table).values(insertData as Record<string, ValueOperandInput>);
345
+ const { sql, params } = insertQuery.compile(this.dialect);
346
+ await this.executor.executeSql(sql, params);
347
+
348
+ // If ID was provided in data, return it
349
+ if (insertData[this.pkName] !== undefined) {
350
+ return insertData[this.pkName];
351
+ }
352
+
353
+ // For auto-increment, query for the inserted node by its unique lft value
354
+ const findQuery = selectFrom(this.table)
355
+ .where(eq(this.table.columns[this.config.leftKey], insertPos.lft));
356
+ const { sql: findSql, params: findParams } = findQuery.compile(this.dialect);
357
+ const results = await this.executor.executeSql(findSql, findParams);
358
+ const rows = queryResultsToRows(results);
359
+
360
+ if (rows.length > 0) {
361
+ return rows[0][this.pkName];
362
+ }
363
+
364
+ return undefined;
365
+ }
366
+
367
+ /**
368
+ * Removes a node and re-parents its children to the node's parent.
369
+ */
370
+ async removeFromTree(node: TreeNodeResult): Promise<void> {
371
+ const nodeId = (node.data as Record<string, unknown>)[this.pkName];
372
+
373
+ await this.executeUpdate(
374
+ eq(this.table.columns[this.config.parentKey], nodeId),
375
+ { [this.config.parentKey]: node.parentId }
376
+ );
377
+
378
+ const gap = NestedSetStrategy.calculateDeleteGap(node.lft, node.rght);
379
+ await this.shiftForDelete(node.rght, 2);
380
+
381
+ NestedSetStrategy.calculateShiftForDelete(node.lft + 1, gap.width - 2);
382
+ if (gap.width > 2) {
383
+ await this.executeRawUpdate(
384
+ `UPDATE ${this.quoteTable()} SET ` +
385
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - 1, ` +
386
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - 1 ` +
387
+ `WHERE ${this.quoteCol(this.config.leftKey)} > ? AND ${this.quoteCol(this.config.rightKey)} < ?`,
388
+ [node.lft, node.rght]
389
+ );
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Deletes a node and all its descendants.
395
+ */
396
+ async deleteSubtree(node: TreeNodeResult): Promise<number> {
397
+ const bounds = { lft: node.lft, rght: node.rght };
398
+ const width = NestedSetStrategy.subtreeWidth(bounds.lft, bounds.rght);
399
+
400
+ const deleteQuery = deleteFrom(this.table)
401
+ .where(
402
+ and(
403
+ gte(this.table.columns[this.config.leftKey], bounds.lft),
404
+ lte(this.table.columns[this.config.rightKey], bounds.rght)
405
+ )
406
+ );
407
+
408
+ const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
409
+ let finalQuery = deleteQuery;
410
+ for (const [key, value] of Object.entries(scopeConditions)) {
411
+ finalQuery = finalQuery.where(eq(this.table.columns[key], value));
412
+ }
413
+
414
+ const { sql, params } = finalQuery.compile(this.dialect);
415
+ await this.executor.executeSql(sql, params);
416
+
417
+ await this.shiftForDelete(bounds.rght, width);
418
+
419
+ return width / 2;
420
+ }
421
+
422
+ /**
423
+ * Rebuilds the tree structure from parent_id relationships.
424
+ * Useful for fixing corrupted trees or initial population.
425
+ */
426
+ async recover(): Promise<RecoverResult> {
427
+ try {
428
+ const nodes = await this.getAllNodesForRecovery();
429
+ const updates = NestedSetStrategy.recover(nodes, (a, b) => {
430
+ if (this.config.recoverOrder) {
431
+ const [key, dir] = Object.entries(this.config.recoverOrder)[0];
432
+ const aVal = a[key as keyof typeof a];
433
+ const bVal = b[key as keyof typeof b];
434
+ const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
435
+ return dir === 'DESC' ? -cmp : cmp;
436
+ }
437
+ return (a.pk as number) - (b.pk as number);
438
+ });
439
+
440
+ for (const update of updates) {
441
+ const updateData: Record<string, unknown> = {
442
+ [this.config.leftKey]: update.lft,
443
+ [this.config.rightKey]: update.rght,
444
+ };
445
+ if (this.config.depthKey) {
446
+ updateData[this.config.depthKey] = update.depth;
447
+ }
448
+
449
+ await this.executeUpdate(
450
+ eq(this.table.columns[this.pkName], update.pk),
451
+ updateData
452
+ );
453
+ }
454
+
455
+ return { processed: updates.length, success: true };
456
+ } catch (error) {
457
+ return {
458
+ processed: 0,
459
+ success: false,
460
+ errors: [(error as Error).message],
461
+ };
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Validates the tree structure.
467
+ * @returns Array of validation errors (empty if valid)
468
+ */
469
+ async validate(): Promise<string[]> {
470
+ const query = this.query.findTreeList();
471
+ const { sql, params } = query.compile(this.dialect);
472
+ const queryResults = await this.executor.executeSql(sql, params);
473
+ const rows = queryResultsToRows(queryResults);
474
+
475
+ return NestedSetStrategy.validateTree(
476
+ rows,
477
+ row => row[this.config.leftKey] as number,
478
+ row => row[this.config.rightKey] as number,
479
+ row => row[this.pkName]
480
+ );
481
+ }
482
+
483
+ /**
484
+ * Creates a new TreeManager with different scope values.
485
+ */
486
+ withScope(scope: TreeScope): TreeManager<T> {
487
+ return new TreeManager({
488
+ executor: this.executor,
489
+ dialect: this.dialect,
490
+ table: this.table,
491
+ config: this.config,
492
+ scope: { ...this.scopeValues, ...scope },
493
+ });
494
+ }
495
+
496
+ // ===== Private Helpers =====
497
+
498
+ private createNodeResult(row: Record<string, unknown>): TreeNodeResult {
499
+ const lft = row[this.config.leftKey] as number;
500
+ const rght = row[this.config.rightKey] as number;
501
+ const parentId = row[this.config.parentKey];
502
+ const depth = this.config.depthKey ? row[this.config.depthKey] as number | undefined : undefined;
503
+
504
+ return {
505
+ data: row,
506
+ lft,
507
+ rght,
508
+ parentId,
509
+ depth,
510
+ isLeaf: NestedSetStrategy.isLeaf(lft, rght),
511
+ isRoot: NestedSetStrategy.isRoot(parentId),
512
+ childCount: NestedSetStrategy.childCount(lft, rght),
513
+ };
514
+ }
515
+
516
+ private getBounds(node: TreeNodeResult | NestedSetBounds): NestedSetBounds {
517
+ if ('data' in node) {
518
+ return { lft: node.lft, rght: node.rght };
519
+ }
520
+ return node;
521
+ }
522
+
523
+ private getPrimaryKeyName(): string {
524
+ for (const [name, col] of Object.entries(this.table.columns)) {
525
+ if (col.primary) {
526
+ return name;
527
+ }
528
+ }
529
+ if (this.table.primaryKey && this.table.primaryKey.length > 0) {
530
+ return this.table.primaryKey[0];
531
+ }
532
+ return 'id';
533
+ }
534
+
535
+ private async getMaxRght(): Promise<number> {
536
+ const query = selectFrom(this.table)
537
+ .selectRaw(`MAX(${this.config.rightKey}) as max_rght`);
538
+ const { sql, params } = query.compile(this.dialect);
539
+ const queryResults = await this.executor.executeSql(sql, params);
540
+ const rows = queryResultsToRows(queryResults);
541
+
542
+ const maxRght = rows[0]?.max_rght;
543
+ return typeof maxRght === 'number' ? maxRght : 0;
544
+ }
545
+
546
+ private async shiftForInsert(insertPoint: number): Promise<void> {
547
+ await this.executeRawUpdate(
548
+ `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + 2 ` +
549
+ `WHERE ${this.quoteCol(this.config.rightKey)} >= ?`,
550
+ [insertPoint]
551
+ );
552
+
553
+ await this.executeRawUpdate(
554
+ `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + 2 ` +
555
+ `WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
556
+ [insertPoint]
557
+ );
558
+ }
559
+
560
+ private async shiftForDelete(deletedRght: number, width: number): Promise<void> {
561
+ await this.executeRawUpdate(
562
+ `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? ` +
563
+ `WHERE ${this.quoteCol(this.config.leftKey)} > ?`,
564
+ [width, deletedRght]
565
+ );
566
+
567
+ await this.executeRawUpdate(
568
+ `UPDATE ${this.quoteTable()} SET ${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? ` +
569
+ `WHERE ${this.quoteCol(this.config.rightKey)} > ?`,
570
+ [width, deletedRght]
571
+ );
572
+ }
573
+
574
+ private async swapNodes(nodeA: TreeNodeResult, nodeB: TreeNodeResult): Promise<boolean> {
575
+ const shift = NestedSetStrategy.calculateMoveUp(
576
+ { lft: nodeA.lft, rght: nodeA.rght },
577
+ { lft: nodeB.lft, rght: nodeB.rght }
578
+ );
579
+
580
+ if (!shift) return false;
581
+
582
+ const tempOffset = 10000000;
583
+
584
+ await this.executeRawUpdate(
585
+ `UPDATE ${this.quoteTable()} SET ` +
586
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
587
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
588
+ `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
589
+ [tempOffset, tempOffset, nodeA.lft, nodeA.rght]
590
+ );
591
+
592
+ await this.executeRawUpdate(
593
+ `UPDATE ${this.quoteTable()} SET ` +
594
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ?, ` +
595
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
596
+ `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
597
+ [shift.siblingShift, shift.siblingShift, nodeB.lft, nodeB.rght]
598
+ );
599
+
600
+ await this.executeRawUpdate(
601
+ `UPDATE ${this.quoteTable()} SET ` +
602
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
603
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ? ` +
604
+ `WHERE ${this.quoteCol(this.config.leftKey)} >= ?`,
605
+ [tempOffset, shift.nodeShift, tempOffset, shift.nodeShift, nodeA.lft + tempOffset]
606
+ );
607
+
608
+ return true;
609
+ }
610
+
611
+ private async moveSubtree(
612
+ node: TreeNodeResult,
613
+ newLft: number,
614
+ newParentId: unknown | null,
615
+ newDepth: number
616
+ ): Promise<void> {
617
+ const width = NestedSetStrategy.subtreeWidth(node.lft, node.rght);
618
+ const delta = newLft - node.lft;
619
+ const depthDelta = this.config.depthKey
620
+ ? newDepth - (node.depth ?? 0)
621
+ : 0;
622
+ const nodeId = (node.data as Record<string, unknown>)[this.pkName];
623
+
624
+ const tempOffset = 10000000;
625
+
626
+ await this.executeRawUpdate(
627
+ `UPDATE ${this.quoteTable()} SET ` +
628
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} + ? ` +
629
+ `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
630
+ [tempOffset, node.lft, node.rght]
631
+ );
632
+
633
+ await this.executeRawUpdate(
634
+ `UPDATE ${this.quoteTable()} SET ` +
635
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} + ? ` +
636
+ `WHERE ${this.quoteCol(this.config.leftKey)} >= ? AND ${this.quoteCol(this.config.rightKey)} <= ?`,
637
+ [tempOffset, node.lft + tempOffset, node.rght]
638
+ );
639
+
640
+ await this.shiftForDelete(node.rght, width);
641
+ await this.shiftForInsert(newLft);
642
+
643
+ let updateSql = `UPDATE ${this.quoteTable()} SET ` +
644
+ `${this.quoteCol(this.config.leftKey)} = ${this.quoteCol(this.config.leftKey)} - ? + ?, ` +
645
+ `${this.quoteCol(this.config.rightKey)} = ${this.quoteCol(this.config.rightKey)} - ? + ?`;
646
+
647
+ const updateParams: unknown[] = [tempOffset, delta, tempOffset, delta];
648
+
649
+ if (this.config.depthKey && depthDelta !== 0) {
650
+ updateSql += `, ${this.quoteCol(this.config.depthKey)} = ${this.quoteCol(this.config.depthKey)} + ?`;
651
+ updateParams.push(depthDelta);
652
+ }
653
+
654
+ updateSql += ` WHERE ${this.quoteCol(this.config.leftKey)} >= ?`;
655
+ updateParams.push(node.lft + tempOffset);
656
+
657
+ await this.executeRawUpdate(updateSql, updateParams);
658
+
659
+ await this.executeUpdate(
660
+ eq(this.table.columns[this.pkName], nodeId),
661
+ { [this.config.parentKey]: newParentId }
662
+ );
663
+ }
664
+
665
+ private async getAllNodesForRecovery(): Promise<NodeWithPk<unknown>[]> {
666
+ const query = selectFrom(this.table)
667
+ .select(this.pkName, this.config.parentKey, this.config.leftKey, this.config.rightKey);
668
+
669
+ const scopeConditions = buildScopeConditions(this.config.scope, this.scopeValues);
670
+ let finalQuery = query;
671
+ for (const [key, value] of Object.entries(scopeConditions)) {
672
+ finalQuery = finalQuery.where(eq(this.table.columns[key], value));
673
+ }
674
+
675
+ const { sql, params } = finalQuery.compile(this.dialect);
676
+ const queryResults = await this.executor.executeSql(sql, params);
677
+ const rows = queryResultsToRows(queryResults);
678
+
679
+ return rows.map(row => ({
680
+ pk: row[this.pkName],
681
+ lft: row[this.config.leftKey] as number,
682
+ rght: row[this.config.rightKey] as number,
683
+ parentId: row[this.config.parentKey],
684
+ }));
685
+ }
686
+
687
+ private async executeUpdate(
688
+ condition: ReturnType<typeof eq>,
689
+ data: Record<string, unknown>
690
+ ): Promise<void> {
691
+ const query = update(this.table).set(data).where(condition);
692
+ const { sql, params } = query.compile(this.dialect);
693
+ await this.executor.executeSql(sql, params);
694
+ }
695
+
696
+ private async executeRawUpdate(sql: string, params: unknown[]): Promise<void> {
697
+ await this.executor.executeSql(sql, params);
698
+ }
699
+
700
+ private quoteTable(): string {
701
+ const quote = this.getQuoteChar();
702
+ return `${quote}${this.table.name}${quote}`;
703
+ }
704
+
705
+ private quoteCol(name: string): string {
706
+ const quote = this.getQuoteChar();
707
+ return `${quote}${name}${quote}`;
708
+ }
709
+
710
+ private getQuoteChar(): string {
711
+ const dialectName = this.dialect.constructor.name.toLowerCase();
712
+ return dialectName.includes('mysql') ? '`' : '"';
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Creates a TreeManager from an OrmSession.
718
+ * Convenience factory for Level 2 integration.
719
+ */
720
+ export function createTreeManager<T extends TableDef>(
721
+ session: OrmSession,
722
+ table: T,
723
+ config?: Partial<TreeConfig>,
724
+ scope?: TreeScope
725
+ ): TreeManager<T> {
726
+ return new TreeManager({
727
+ executor: session.executor,
728
+ dialect: session.dialect,
729
+ table,
730
+ config,
731
+ scope,
732
+ });
733
+ }
734
+
735
+ /**
736
+ * Converts QueryResult[] to row objects.
737
+ * Handles the canonical { columns, values } format.
738
+ */
739
+ function queryResultsToRows(results: QueryResult[]): Record<string, unknown>[] {
740
+ const rows: Record<string, unknown>[] = [];
741
+
742
+ for (const result of results) {
743
+ const { columns, values } = result;
744
+ for (const valueRow of values) {
745
+ const row: Record<string, unknown> = {};
746
+ for (let i = 0; i < columns.length; i++) {
747
+ row[columns[i]] = valueRow[i];
748
+ }
749
+ rows.push(row);
750
+ }
751
+ }
752
+
753
+ return rows;
754
+ }