@mantiq/database 0.0.1

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 (42) hide show
  1. package/README.md +19 -0
  2. package/package.json +77 -0
  3. package/src/DatabaseManager.ts +115 -0
  4. package/src/DatabaseServiceProvider.ts +39 -0
  5. package/src/contracts/Connection.ts +13 -0
  6. package/src/contracts/Grammar.ts +16 -0
  7. package/src/contracts/MongoConnection.ts +122 -0
  8. package/src/contracts/Paginator.ts +10 -0
  9. package/src/drivers/BaseGrammar.ts +220 -0
  10. package/src/drivers/MSSQLConnection.ts +154 -0
  11. package/src/drivers/MSSQLGrammar.ts +106 -0
  12. package/src/drivers/MongoConnection.ts +298 -0
  13. package/src/drivers/MongoQueryBuilderImpl.ts +77 -0
  14. package/src/drivers/MySQLConnection.ts +120 -0
  15. package/src/drivers/MySQLGrammar.ts +19 -0
  16. package/src/drivers/PostgresConnection.ts +125 -0
  17. package/src/drivers/PostgresGrammar.ts +24 -0
  18. package/src/drivers/SQLiteConnection.ts +125 -0
  19. package/src/drivers/SQLiteGrammar.ts +19 -0
  20. package/src/errors/ConnectionError.ts +10 -0
  21. package/src/errors/ModelNotFoundError.ts +14 -0
  22. package/src/errors/QueryError.ts +11 -0
  23. package/src/events/DatabaseEvents.ts +101 -0
  24. package/src/factories/Factory.ts +170 -0
  25. package/src/factories/Faker.ts +382 -0
  26. package/src/helpers/db.ts +37 -0
  27. package/src/index.ts +100 -0
  28. package/src/migrations/Migration.ts +12 -0
  29. package/src/migrations/MigrationRepository.ts +50 -0
  30. package/src/migrations/Migrator.ts +201 -0
  31. package/src/orm/Collection.ts +236 -0
  32. package/src/orm/Document.ts +202 -0
  33. package/src/orm/Model.ts +775 -0
  34. package/src/orm/ModelQueryBuilder.ts +415 -0
  35. package/src/orm/Scope.ts +39 -0
  36. package/src/orm/eagerLoad.ts +300 -0
  37. package/src/query/Builder.ts +456 -0
  38. package/src/query/Expression.ts +18 -0
  39. package/src/schema/Blueprint.ts +196 -0
  40. package/src/schema/ColumnDefinition.ts +93 -0
  41. package/src/schema/SchemaBuilder.ts +376 -0
  42. package/src/seeders/Seeder.ts +28 -0
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ // ── Contracts ────────────────────────────────────────────────────────────────
2
+ export type { DatabaseConnection } from './contracts/Connection.ts'
3
+ export type { Grammar } from './contracts/Grammar.ts'
4
+ export type { PaginationResult } from './contracts/Paginator.ts'
5
+ export type {
6
+ MongoDatabaseConnection,
7
+ MongoCollectionContract,
8
+ MongoQueryBuilder,
9
+ MongoFilter,
10
+ MongoUpdateDoc,
11
+ MongoPipelineStage,
12
+ MongoSortDoc,
13
+ MongoProjection,
14
+ MongoInsertResult,
15
+ MongoInsertManyResult,
16
+ MongoUpdateResult,
17
+ MongoDeleteResult,
18
+ } from './contracts/MongoConnection.ts'
19
+
20
+ // ── Errors ────────────────────────────────────────────────────────────────────
21
+ export { QueryError } from './errors/QueryError.ts'
22
+ export { ModelNotFoundError } from './errors/ModelNotFoundError.ts'
23
+ export { ConnectionError } from './errors/ConnectionError.ts'
24
+
25
+ // ── Query Builder ─────────────────────────────────────────────────────────────
26
+ export { QueryBuilder } from './query/Builder.ts'
27
+ export { Expression, raw } from './query/Expression.ts'
28
+ export type { QueryState, WhereClause, JoinClause, OrderClause, Operator } from './query/Builder.ts'
29
+
30
+ // ── Grammar ───────────────────────────────────────────────────────────────────
31
+ export { BaseGrammar } from './drivers/BaseGrammar.ts'
32
+ export { SQLiteGrammar } from './drivers/SQLiteGrammar.ts'
33
+ export { PostgresGrammar } from './drivers/PostgresGrammar.ts'
34
+ export { MySQLGrammar } from './drivers/MySQLGrammar.ts'
35
+ export { MSSQLGrammar } from './drivers/MSSQLGrammar.ts'
36
+
37
+ // ── SQL Connections ───────────────────────────────────────────────────────────
38
+ export { SQLiteConnection } from './drivers/SQLiteConnection.ts'
39
+ export type { SQLiteConfig } from './drivers/SQLiteConnection.ts'
40
+ export { PostgresConnection } from './drivers/PostgresConnection.ts'
41
+ export type { PostgresConfig } from './drivers/PostgresConnection.ts'
42
+ export { MySQLConnection } from './drivers/MySQLConnection.ts'
43
+ export type { MySQLConfig } from './drivers/MySQLConnection.ts'
44
+ export { MSSQLConnection } from './drivers/MSSQLConnection.ts'
45
+ export type { MSSQLConfig } from './drivers/MSSQLConnection.ts'
46
+
47
+ // ── MongoDB ───────────────────────────────────────────────────────────────────
48
+ export { MongoConnection } from './drivers/MongoConnection.ts'
49
+ export type { MongoConfig } from './drivers/MongoConnection.ts'
50
+
51
+ // ── Schema Builder ────────────────────────────────────────────────────────────
52
+ export type { SchemaBuilder } from './schema/SchemaBuilder.ts'
53
+ export { SchemaBuilderImpl } from './schema/SchemaBuilder.ts'
54
+ export { Blueprint } from './schema/Blueprint.ts'
55
+ export { ColumnDefinition } from './schema/ColumnDefinition.ts'
56
+ export type { IndexDefinition, ForeignKeyDefinition } from './schema/Blueprint.ts'
57
+
58
+ // ── Migrations ────────────────────────────────────────────────────────────────
59
+ export { Migration } from './migrations/Migration.ts'
60
+ export type { MigrationContract } from './migrations/Migration.ts'
61
+ export { Migrator } from './migrations/Migrator.ts'
62
+ export type { MigrationFile } from './migrations/Migrator.ts'
63
+ export { MigrationRepository } from './migrations/MigrationRepository.ts'
64
+
65
+ // ── ORM ───────────────────────────────────────────────────────────────────────
66
+ export { Model } from './orm/Model.ts'
67
+ export type { Scope } from './orm/Scope.ts'
68
+ export { ClosureScope } from './orm/Scope.ts'
69
+ export {
70
+ HasOneRelation,
71
+ HasManyRelation,
72
+ BelongsToRelation,
73
+ BelongsToManyRelation,
74
+ } from './orm/Model.ts'
75
+ export type { ModelStatic } from './orm/Model.ts'
76
+ export { ModelQueryBuilder } from './orm/ModelQueryBuilder.ts'
77
+ export { Collection } from './orm/Collection.ts'
78
+
79
+ // ── MongoDB Document ORM ──────────────────────────────────────────────────────
80
+ export { Document } from './orm/Document.ts'
81
+
82
+ // ── Seeders & Factories ───────────────────────────────────────────────────────
83
+ export { Seeder } from './seeders/Seeder.ts'
84
+ export { Factory } from './factories/Factory.ts'
85
+ export { Faker } from './factories/Faker.ts'
86
+
87
+ // ── Database Manager ──────────────────────────────────────────────────────────
88
+ export { DatabaseManager } from './DatabaseManager.ts'
89
+ export type { DatabaseConfig, ConnectionConfig, SQLConfig } from './DatabaseManager.ts'
90
+ export type { MongoConfig as MongoConnectionConfig } from './DatabaseManager.ts'
91
+
92
+ // ── Bootstrap helpers ─────────────────────────────────────────────────────────
93
+ export { createDatabaseManager, setupModels } from './DatabaseServiceProvider.ts'
94
+
95
+ // ── Events ───────────────────────────────────────────────────────────────────
96
+ export { QueryExecuted, TransactionBeginning, TransactionCommitted, TransactionRolledBack } from './events/DatabaseEvents.ts'
97
+ export { MigrationStarted, MigrationEnded, MigrationsStarted, MigrationsEnded } from './events/DatabaseEvents.ts'
98
+
99
+ // ── Helpers ───────────────────────────────────────────────────────────────────
100
+ export { db, table, schema, mongo, collection, setManager, getManager } from './helpers/db.ts'
@@ -0,0 +1,12 @@
1
+ import type { SchemaBuilder } from '../schema/SchemaBuilder.ts'
2
+ import type { DatabaseConnection } from '../contracts/Connection.ts'
3
+
4
+ export interface MigrationContract {
5
+ up(schema: SchemaBuilder, db: DatabaseConnection): Promise<void>
6
+ down(schema: SchemaBuilder, db: DatabaseConnection): Promise<void>
7
+ }
8
+
9
+ export abstract class Migration implements MigrationContract {
10
+ abstract up(schema: SchemaBuilder, db: DatabaseConnection): Promise<void>
11
+ abstract down(schema: SchemaBuilder, db: DatabaseConnection): Promise<void>
12
+ }
@@ -0,0 +1,50 @@
1
+ import type { DatabaseConnection } from '../contracts/Connection.ts'
2
+
3
+ export class MigrationRepository {
4
+ private readonly table = 'migrations'
5
+
6
+ constructor(private readonly connection: DatabaseConnection) {}
7
+
8
+ async createTable(): Promise<void> {
9
+ const exists = await this.connection.schema().hasTable(this.table)
10
+ if (!exists) {
11
+ await this.connection.schema().create(this.table, (t) => {
12
+ t.id()
13
+ t.string('migration', 255)
14
+ t.integer('batch')
15
+ t.timestamp('run_at').nullable()
16
+ })
17
+ }
18
+ }
19
+
20
+ async getRan(): Promise<string[]> {
21
+ const rows = await this.connection.table(this.table).pluck('migration')
22
+ return rows as string[]
23
+ }
24
+
25
+ async getLastBatch(): Promise<number> {
26
+ const result = await this.connection.table(this.table).max('batch')
27
+ return Number(result ?? 0)
28
+ }
29
+
30
+ async log(migration: string, batch: number): Promise<void> {
31
+ await this.connection.table(this.table).insert({
32
+ migration,
33
+ batch,
34
+ run_at: new Date(),
35
+ })
36
+ }
37
+
38
+ async delete(migration: string): Promise<void> {
39
+ await this.connection.table(this.table).where('migration', migration).delete()
40
+ }
41
+
42
+ async getLastBatchMigrations(): Promise<string[]> {
43
+ const batch = await this.getLastBatch()
44
+ if (batch === 0) return []
45
+ return this.connection.table(this.table)
46
+ .where('batch', batch)
47
+ .orderBy('id', 'desc')
48
+ .pluck('migration') as Promise<string[]>
49
+ }
50
+ }
@@ -0,0 +1,201 @@
1
+ import type { DatabaseConnection } from '../contracts/Connection.ts'
2
+ import type { MigrationContract } from './Migration.ts'
3
+ import type { EventDispatcher } from '@mantiq/core'
4
+ import { MigrationRepository } from './MigrationRepository.ts'
5
+ import { MigrationStarted, MigrationEnded, MigrationsStarted, MigrationsEnded } from '../events/DatabaseEvents.ts'
6
+ import { join } from 'node:path'
7
+
8
+ export interface MigrationFile {
9
+ name: string
10
+ migration: MigrationContract
11
+ }
12
+
13
+ export interface MigratorOptions {
14
+ /** Directory containing migration files. Each file must default-export a Migration class. */
15
+ migrationsPath?: string
16
+ }
17
+
18
+ export class Migrator {
19
+ private repo: MigrationRepository
20
+
21
+ /** Optional event dispatcher. Set by @mantiq/events when installed. */
22
+ static _dispatcher: EventDispatcher | null = null
23
+
24
+ constructor(
25
+ private readonly connection: DatabaseConnection,
26
+ private readonly options: MigratorOptions = {},
27
+ ) {
28
+ this.repo = new MigrationRepository(connection)
29
+ }
30
+
31
+ async run(migrations?: MigrationFile[]): Promise<string[]> {
32
+ await this.repo.createTable()
33
+
34
+ const files = migrations ?? (await this.loadFromDirectory())
35
+ const ran = await this.repo.getRan()
36
+ const pending = files.filter((f) => !ran.includes(f.name))
37
+
38
+ if (!pending.length) return []
39
+
40
+ const batch = (await this.repo.getLastBatch()) + 1
41
+ const ran_: string[] = []
42
+
43
+ await Migrator._dispatcher?.emit(new MigrationsStarted('up'))
44
+
45
+ for (const file of pending) {
46
+ await Migrator._dispatcher?.emit(new MigrationStarted(file.name, 'up'))
47
+ const schema = this.connection.schema()
48
+ await file.migration.up(schema, this.connection)
49
+ await this.repo.log(file.name, batch)
50
+ ran_.push(file.name)
51
+ await Migrator._dispatcher?.emit(new MigrationEnded(file.name, 'up'))
52
+ }
53
+
54
+ await Migrator._dispatcher?.emit(new MigrationsEnded('up'))
55
+
56
+ return ran_
57
+ }
58
+
59
+ async rollback(migrations?: MigrationFile[]): Promise<string[]> {
60
+ await this.repo.createTable()
61
+
62
+ const lastBatch = await this.repo.getLastBatchMigrations()
63
+ if (!lastBatch.length) return []
64
+
65
+ // Build lookup map
66
+ const files = migrations ?? (await this.loadFromDirectory())
67
+ const lookup = new Map(files.map((f) => [f.name, f]))
68
+
69
+ const rolled: string[] = []
70
+
71
+ await Migrator._dispatcher?.emit(new MigrationsStarted('down'))
72
+
73
+ for (const name of lastBatch) {
74
+ const file = lookup.get(name)
75
+ if (!file) continue
76
+ await Migrator._dispatcher?.emit(new MigrationStarted(name, 'down'))
77
+ const schema = this.connection.schema()
78
+ await file.migration.down(schema, this.connection)
79
+ await this.repo.delete(name)
80
+ rolled.push(name)
81
+ await Migrator._dispatcher?.emit(new MigrationEnded(name, 'down'))
82
+ }
83
+
84
+ await Migrator._dispatcher?.emit(new MigrationsEnded('down'))
85
+
86
+ return rolled
87
+ }
88
+
89
+ async reset(migrations?: MigrationFile[]): Promise<string[]> {
90
+ await this.repo.createTable()
91
+
92
+ const ran = await this.repo.getRan()
93
+ const files = migrations ?? (await this.loadFromDirectory())
94
+ const lookup = new Map(files.map((f) => [f.name, f]))
95
+
96
+ // Run in reverse order
97
+ const reversed = [...ran].reverse()
98
+ const rolled: string[] = []
99
+
100
+ await Migrator._dispatcher?.emit(new MigrationsStarted('down'))
101
+
102
+ for (const name of reversed) {
103
+ const file = lookup.get(name)
104
+ if (!file) continue
105
+ await Migrator._dispatcher?.emit(new MigrationStarted(name, 'down'))
106
+ const schema = this.connection.schema()
107
+ await file.migration.down(schema, this.connection)
108
+ await this.repo.delete(name)
109
+ rolled.push(name)
110
+ await Migrator._dispatcher?.emit(new MigrationEnded(name, 'down'))
111
+ }
112
+
113
+ await Migrator._dispatcher?.emit(new MigrationsEnded('down'))
114
+
115
+ return rolled
116
+ }
117
+
118
+ /**
119
+ * Drop all tables and re-run all migrations from scratch.
120
+ */
121
+ async fresh(migrations?: MigrationFile[]): Promise<string[]> {
122
+ const schema = this.connection.schema()
123
+
124
+ // Disable FK constraints so tables can be dropped in any order
125
+ await schema.disableForeignKeyConstraints()
126
+
127
+ try {
128
+ const tables = await this.getAllTables()
129
+ for (const table of tables) {
130
+ await schema.dropIfExists(table)
131
+ }
132
+ } finally {
133
+ await schema.enableForeignKeyConstraints()
134
+ }
135
+
136
+ // Re-run all migrations
137
+ return this.run(migrations)
138
+ }
139
+
140
+ private async getAllTables(): Promise<string[]> {
141
+ const driver = this.connection.getDriverName()
142
+ let rows: Record<string, any>[]
143
+
144
+ if (driver === 'sqlite') {
145
+ rows = await this.connection.select(
146
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
147
+ )
148
+ return rows.map((r) => r['name'] as string)
149
+ } else if (driver === 'postgres') {
150
+ rows = await this.connection.select(
151
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
152
+ )
153
+ return rows.map((r) => r['tablename'] as string)
154
+ } else if (driver === 'mssql') {
155
+ rows = await this.connection.select(
156
+ "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE'",
157
+ )
158
+ return rows.map((r) => r['TABLE_NAME'] as string)
159
+ } else {
160
+ // MySQL
161
+ rows = await this.connection.select('SHOW TABLES')
162
+ return rows.map((r) => Object.values(r)[0] as string)
163
+ }
164
+ }
165
+
166
+ async status(): Promise<Array<{ name: string; ran: boolean; batch: number | null }>> {
167
+ await this.repo.createTable()
168
+
169
+ const files = await this.loadFromDirectory()
170
+ const ran = await this.repo.getRan()
171
+ const rows = await this.connection.table('migrations').get()
172
+ const batchMap = new Map(rows.map((r) => [r['migration'] as string, r['batch'] as number]))
173
+
174
+ return files.map((f) => ({
175
+ name: f.name,
176
+ ran: ran.includes(f.name),
177
+ batch: batchMap.get(f.name) ?? null,
178
+ }))
179
+ }
180
+
181
+ private async loadFromDirectory(): Promise<MigrationFile[]> {
182
+ if (!this.options.migrationsPath) return []
183
+
184
+ const glob = new Bun.Glob('*.ts')
185
+ const files: MigrationFile[] = []
186
+
187
+ for await (const file of glob.scan(this.options.migrationsPath)) {
188
+ const name = file.replace(/\.ts$/, '')
189
+ const path = join(this.options.migrationsPath, file)
190
+ const mod = await import(path)
191
+ const MigrationClass = mod.default
192
+ if (MigrationClass) {
193
+ files.push({ name, migration: new MigrationClass() })
194
+ }
195
+ }
196
+
197
+ // Sort by filename (timestamp prefix ensures correct order)
198
+ files.sort((a, b) => a.name.localeCompare(b.name))
199
+ return files
200
+ }
201
+ }
@@ -0,0 +1,236 @@
1
+ import type { Model } from './Model.ts'
2
+ import { applyMacros } from '@mantiq/core'
3
+
4
+ /**
5
+ * Typed array wrapper for model query results.
6
+ * Provides convenience methods for working with sets of models.
7
+ */
8
+ export class Collection<T extends Model> {
9
+ private items: T[]
10
+
11
+ constructor(items: T[] = []) {
12
+ this.items = [...items]
13
+ }
14
+
15
+ // ── Array-like access ────────────────────────────────────────────────────
16
+
17
+ get length(): number {
18
+ return this.items.length
19
+ }
20
+
21
+ [Symbol.iterator](): Iterator<T> {
22
+ return this.items[Symbol.iterator]()
23
+ }
24
+
25
+ toArray(): T[] {
26
+ return [...this.items]
27
+ }
28
+
29
+ all(): T[] {
30
+ return this.toArray()
31
+ }
32
+
33
+ // ── Element access ──────────────────────────────────────────────────────
34
+
35
+ first(): T | undefined {
36
+ return this.items[0]
37
+ }
38
+
39
+ last(): T | undefined {
40
+ return this.items[this.items.length - 1]
41
+ }
42
+
43
+ get(index: number): T | undefined {
44
+ return this.items[index]
45
+ }
46
+
47
+ // ── Searching ────────────────────────────────────────────────────────────
48
+
49
+ find(callback: (item: T) => boolean): T | undefined {
50
+ return this.items.find(callback)
51
+ }
52
+
53
+ findByKey(id: any): T | undefined {
54
+ return this.items.find((item) => item.getKey() === id)
55
+ }
56
+
57
+ contains(callback: ((item: T) => boolean) | any): boolean {
58
+ if (typeof callback === 'function') {
59
+ return this.items.some(callback as (item: T) => boolean)
60
+ }
61
+ return this.items.some((item) => item.getKey() === callback)
62
+ }
63
+
64
+ // ── Filtering ────────────────────────────────────────────────────────────
65
+
66
+ filter(callback: (item: T) => boolean): Collection<T> {
67
+ return new Collection(this.items.filter(callback))
68
+ }
69
+
70
+ where(key: string, value: any): Collection<T> {
71
+ return this.filter((item) => item.getAttribute(key) === value)
72
+ }
73
+
74
+ whereIn(key: string, values: any[]): Collection<T> {
75
+ return this.filter((item) => values.includes(item.getAttribute(key)))
76
+ }
77
+
78
+ reject(callback: (item: T) => boolean): Collection<T> {
79
+ return this.filter((item) => !callback(item))
80
+ }
81
+
82
+ // ── Transformation ──────────────────────────────────────────────────────
83
+
84
+ map<U>(callback: (item: T, index: number) => U): U[] {
85
+ return this.items.map(callback)
86
+ }
87
+
88
+ flatMap<U>(callback: (item: T) => U[]): U[] {
89
+ return this.items.flatMap(callback)
90
+ }
91
+
92
+ pluck(key: string): any[] {
93
+ return this.items.map((item) => item.getAttribute(key))
94
+ }
95
+
96
+ modelKeys(): any[] {
97
+ return this.items.map((item) => item.getKey())
98
+ }
99
+
100
+ unique(key?: string): Collection<T> {
101
+ const seen = new Set()
102
+ return this.filter((item) => {
103
+ const val = key ? item.getAttribute(key) : item.getKey()
104
+ if (seen.has(val)) return false
105
+ seen.add(val)
106
+ return true
107
+ })
108
+ }
109
+
110
+ // ── Ordering ─────────────────────────────────────────────────────────────
111
+
112
+ sortBy(key: string, direction: 'asc' | 'desc' = 'asc'): Collection<T> {
113
+ const sorted = [...this.items].sort((a, b) => {
114
+ const aVal = a.getAttribute(key)
115
+ const bVal = b.getAttribute(key)
116
+ if (aVal < bVal) return direction === 'asc' ? -1 : 1
117
+ if (aVal > bVal) return direction === 'asc' ? 1 : -1
118
+ return 0
119
+ })
120
+ return new Collection(sorted)
121
+ }
122
+
123
+ reverse(): Collection<T> {
124
+ return new Collection([...this.items].reverse())
125
+ }
126
+
127
+ // ── Aggregates ──────────────────────────────────────────────────────────
128
+
129
+ count(): number {
130
+ return this.items.length
131
+ }
132
+
133
+ sum(key: string): number {
134
+ return this.items.reduce((acc, item) => acc + Number(item.getAttribute(key) ?? 0), 0)
135
+ }
136
+
137
+ avg(key: string): number {
138
+ if (this.items.length === 0) return 0
139
+ return this.sum(key) / this.items.length
140
+ }
141
+
142
+ min(key: string): any {
143
+ if (this.items.length === 0) return undefined
144
+ return this.items.reduce((min, item) => {
145
+ const val = item.getAttribute(key)
146
+ return val < min ? val : min
147
+ }, this.items[0]!.getAttribute(key))
148
+ }
149
+
150
+ max(key: string): any {
151
+ if (this.items.length === 0) return undefined
152
+ return this.items.reduce((max, item) => {
153
+ const val = item.getAttribute(key)
154
+ return val > max ? val : max
155
+ }, this.items[0]!.getAttribute(key))
156
+ }
157
+
158
+ // ── Chunking ─────────────────────────────────────────────────────────────
159
+
160
+ chunk(size: number): Collection<T>[] {
161
+ const chunks: Collection<T>[] = []
162
+ for (let i = 0; i < this.items.length; i += size) {
163
+ chunks.push(new Collection(this.items.slice(i, i + size)))
164
+ }
165
+ return chunks
166
+ }
167
+
168
+ // ── Side effects ────────────────────────────────────────────────────────
169
+
170
+ each(callback: (item: T, index: number) => void): this {
171
+ this.items.forEach(callback)
172
+ return this
173
+ }
174
+
175
+ // ── Grouping ─────────────────────────────────────────────────────────────
176
+
177
+ groupBy(key: string): Map<any, Collection<T>> {
178
+ const groups = new Map<any, T[]>()
179
+ for (const item of this.items) {
180
+ const val = item.getAttribute(key)
181
+ if (!groups.has(val)) groups.set(val, [])
182
+ groups.get(val)!.push(item)
183
+ }
184
+ const result = new Map<any, Collection<T>>()
185
+ for (const [k, v] of groups) result.set(k, new Collection(v))
186
+ return result
187
+ }
188
+
189
+ keyBy(key: string): Map<any, T> {
190
+ const map = new Map<any, T>()
191
+ for (const item of this.items) {
192
+ map.set(item.getAttribute(key), item)
193
+ }
194
+ return map
195
+ }
196
+
197
+ // ── Set operations ──────────────────────────────────────────────────────
198
+
199
+ push(...items: T[]): this {
200
+ this.items.push(...items)
201
+ return this
202
+ }
203
+
204
+ concat(other: Collection<T> | T[]): Collection<T> {
205
+ const otherItems = other instanceof Collection ? other.toArray() : other
206
+ return new Collection([...this.items, ...otherItems])
207
+ }
208
+
209
+ // ── Serialization ───────────────────────────────────────────────────────
210
+
211
+ toJSON(): Record<string, any>[] {
212
+ return this.items.map((item) => item.toJSON())
213
+ }
214
+
215
+ isEmpty(): boolean {
216
+ return this.items.length === 0
217
+ }
218
+
219
+ isNotEmpty(): boolean {
220
+ return this.items.length > 0
221
+ }
222
+
223
+ // ── Batch operations ────────────────────────────────────────────────────
224
+
225
+ async load(...relations: string[]): Promise<this> {
226
+ // Lazy eager-load relations on an existing collection
227
+ // This delegates to the eager loading infrastructure
228
+ if (this.items.length === 0 || relations.length === 0) return this
229
+ const { eagerLoadRelations } = await import('./eagerLoad.ts')
230
+ await eagerLoadRelations(this.items, relations)
231
+ return this
232
+ }
233
+ }
234
+
235
+ // Add macro support — Collection.macro('name', fn) / instance.__macro('name')
236
+ applyMacros(Collection)