@mauryasumit/driftdb 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +810 -0
  2. package/dist/db.d.ts +30 -0
  3. package/dist/db.d.ts.map +1 -0
  4. package/dist/db.js +115 -0
  5. package/dist/db.js.map +1 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +12 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/orm/model.d.ts +35 -0
  11. package/dist/orm/model.d.ts.map +1 -0
  12. package/dist/orm/model.js +34 -0
  13. package/dist/orm/model.js.map +1 -0
  14. package/dist/orm/query-builder.d.ts +8 -0
  15. package/dist/orm/query-builder.d.ts.map +1 -0
  16. package/dist/orm/query-builder.js +90 -0
  17. package/dist/orm/query-builder.js.map +1 -0
  18. package/dist/orm/repository.d.ts +38 -0
  19. package/dist/orm/repository.d.ts.map +1 -0
  20. package/dist/orm/repository.js +107 -0
  21. package/dist/orm/repository.js.map +1 -0
  22. package/dist/orm/schema.d.ts +20 -0
  23. package/dist/orm/schema.d.ts.map +1 -0
  24. package/dist/orm/schema.js +81 -0
  25. package/dist/orm/schema.js.map +1 -0
  26. package/dist/queue/queue.d.ts +17 -0
  27. package/dist/queue/queue.d.ts.map +1 -0
  28. package/dist/queue/queue.js +109 -0
  29. package/dist/queue/queue.js.map +1 -0
  30. package/dist/storage/s3-adapter.d.ts +21 -0
  31. package/dist/storage/s3-adapter.d.ts.map +1 -0
  32. package/dist/storage/s3-adapter.js +133 -0
  33. package/dist/storage/s3-adapter.js.map +1 -0
  34. package/dist/sync/change-log.d.ts +15 -0
  35. package/dist/sync/change-log.d.ts.map +1 -0
  36. package/dist/sync/change-log.js +78 -0
  37. package/dist/sync/change-log.js.map +1 -0
  38. package/dist/sync/engine.d.ts +31 -0
  39. package/dist/sync/engine.d.ts.map +1 -0
  40. package/dist/sync/engine.js +210 -0
  41. package/dist/sync/engine.js.map +1 -0
  42. package/dist/sync/snapshot-manager.d.ts +17 -0
  43. package/dist/sync/snapshot-manager.d.ts.map +1 -0
  44. package/dist/sync/snapshot-manager.js +91 -0
  45. package/dist/sync/snapshot-manager.js.map +1 -0
  46. package/dist/types.d.ts +120 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +3 -0
  49. package/dist/types.js.map +1 -0
  50. package/dist/utils/compress.d.ts +3 -0
  51. package/dist/utils/compress.d.ts.map +1 -0
  52. package/dist/utils/compress.js +16 -0
  53. package/dist/utils/compress.js.map +1 -0
  54. package/dist/utils/crypto.d.ts +4 -0
  55. package/dist/utils/crypto.d.ts.map +1 -0
  56. package/dist/utils/crypto.js +35 -0
  57. package/dist/utils/crypto.js.map +1 -0
  58. package/dist/utils/id.d.ts +3 -0
  59. package/dist/utils/id.d.ts.map +1 -0
  60. package/dist/utils/id.js +13 -0
  61. package/dist/utils/id.js.map +1 -0
  62. package/dist/utils/retry.d.ts +5 -0
  63. package/dist/utils/retry.d.ts.map +1 -0
  64. package/dist/utils/retry.js +36 -0
  65. package/dist/utils/retry.js.map +1 -0
  66. package/package.json +55 -0
  67. package/src/db.ts +154 -0
  68. package/src/index.ts +24 -0
  69. package/src/orm/model.ts +95 -0
  70. package/src/orm/query-builder.ts +100 -0
  71. package/src/orm/repository.ts +156 -0
  72. package/src/orm/schema.ts +92 -0
  73. package/src/queue/queue.ts +138 -0
  74. package/src/storage/s3-adapter.ts +181 -0
  75. package/src/sync/change-log.ts +101 -0
  76. package/src/sync/engine.ts +249 -0
  77. package/src/sync/snapshot-manager.ts +80 -0
  78. package/src/types.ts +130 -0
  79. package/src/utils/compress.ts +14 -0
  80. package/src/utils/crypto.ts +33 -0
  81. package/src/utils/id.ts +10 -0
  82. package/src/utils/retry.ts +38 -0
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@mauryasumit/driftdb",
3
+ "version": "2.0.0",
4
+ "description": "Local-first SQLite database with automatic S3 sync — offline-first, no infrastructure required",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist",
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc --project tsconfig.json",
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "jest",
15
+ "test:coverage": "jest --coverage",
16
+ "lint": "eslint src --ext .ts",
17
+ "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
18
+ "prebuild": "npm run clean",
19
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build",
20
+ "publish:patch": "npm version patch && npm publish --access public",
21
+ "publish:minor": "npm version minor && npm publish --access public",
22
+ "publish:major": "npm version major && npm publish --access public",
23
+ "publish:dry": "npm publish --dry-run --access public"
24
+ },
25
+ "keywords": [
26
+ "sqlite",
27
+ "s3",
28
+ "local-first",
29
+ "offline",
30
+ "sync",
31
+ "database",
32
+ "orm",
33
+ "local-first",
34
+ "durable"
35
+ ],
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "@aws-sdk/client-s3": "^3.600.0",
39
+ "@aws-sdk/lib-storage": "^3.600.0",
40
+ "better-sqlite3": "^12.8.0",
41
+ "uuid": "^9.0.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/better-sqlite3": "^7.6.10",
45
+ "@types/jest": "^29.5.12",
46
+ "@types/node": "^20.14.0",
47
+ "@types/uuid": "^9.0.8",
48
+ "jest": "^29.7.0",
49
+ "ts-jest": "^29.1.4",
50
+ "typescript": "^5.4.5"
51
+ },
52
+ "engines": {
53
+ "node": ">=18.0.0"
54
+ }
55
+ }
package/src/db.ts ADDED
@@ -0,0 +1,154 @@
1
+ import BetterSqlite3 from 'better-sqlite3';
2
+ import type Database from 'better-sqlite3';
3
+ import { mkdirSync, existsSync } from 'fs';
4
+ import { dirname } from 'path';
5
+ import type { DBConfig, ModelSchema, SyncMetrics } from './types.js';
6
+ import { Repository } from './orm/repository.js';
7
+ import { SyncEngine } from './sync/engine.js';
8
+ import { generateNodeId } from './utils/id.js';
9
+ import type { Model, ModelStatic } from './orm/model.js';
10
+ import type { BaseRecord } from './types.js';
11
+
12
+ const META_SCHEMA = `
13
+ CREATE TABLE IF NOT EXISTS _driftdb_meta (
14
+ key TEXT PRIMARY KEY,
15
+ value TEXT NOT NULL
16
+ );
17
+ `;
18
+
19
+ export class DB {
20
+ private readonly sqliteDb: Database.Database;
21
+ private readonly config: DBConfig;
22
+ private readonly nodeId: string;
23
+ private readonly syncEngine: SyncEngine;
24
+ private readonly repos = new Map<string, Repository<BaseRecord>>();
25
+
26
+ constructor(config: DBConfig) {
27
+ this.config = config;
28
+
29
+ if (config.sqlitePath !== ':memory:') {
30
+ const dir = dirname(config.sqlitePath);
31
+ if (dir && dir !== '.') {
32
+ mkdirSync(dir, { recursive: true });
33
+ }
34
+ }
35
+
36
+ this.sqliteDb = new BetterSqlite3(config.sqlitePath);
37
+ this.sqliteDb.pragma('journal_mode = WAL');
38
+ this.sqliteDb.pragma('synchronous = NORMAL');
39
+ this.sqliteDb.pragma('foreign_keys = ON');
40
+ this.sqliteDb.pragma('cache_size = -64000');
41
+ this.sqliteDb.pragma('temp_store = MEMORY');
42
+
43
+ this.sqliteDb.exec(META_SCHEMA);
44
+
45
+ this.nodeId = this.getOrCreateNodeId(config.nodeId);
46
+ this.syncEngine = new SyncEngine(this.sqliteDb, this.nodeId, config);
47
+
48
+ if (config.autoSync !== false && config.s3Config) {
49
+ this.syncEngine.start();
50
+ }
51
+ }
52
+
53
+ private getOrCreateNodeId(preferred?: string): string {
54
+ if (preferred) {
55
+ this.sqliteDb
56
+ .prepare(`INSERT OR REPLACE INTO _driftdb_meta (key, value) VALUES ('nodeId', ?)`)
57
+ .run(preferred);
58
+ return preferred;
59
+ }
60
+
61
+ const row = this.sqliteDb
62
+ .prepare(`SELECT value FROM _driftdb_meta WHERE key = 'nodeId'`)
63
+ .get() as { value: string } | undefined;
64
+
65
+ if (row) return row.value;
66
+
67
+ const id = generateNodeId();
68
+ this.sqliteDb
69
+ .prepare(`INSERT INTO _driftdb_meta (key, value) VALUES ('nodeId', ?)`)
70
+ .run(id);
71
+ return id;
72
+ }
73
+
74
+ define<S extends ModelSchema>(
75
+ tableName: string,
76
+ schema: S
77
+ ): Repository<BaseRecord & { [K in keyof S]: unknown }> {
78
+ const repo = new Repository<BaseRecord & { [K in keyof S]: unknown }>(
79
+ this.sqliteDb,
80
+ tableName,
81
+ schema,
82
+ this.syncEngine.getChangeLog()
83
+ );
84
+ this.repos.set(tableName, repo as unknown as Repository<BaseRecord>);
85
+ return repo;
86
+ }
87
+
88
+ registerModel<T extends Model>(ModelClass: ModelStatic<T>): void {
89
+ if (!ModelClass.tableName) {
90
+ throw new Error(`Model ${ModelClass.name} must define a static 'tableName'`);
91
+ }
92
+ if (!ModelClass.schema) {
93
+ throw new Error(`Model ${ModelClass.name} must define a static 'schema'`);
94
+ }
95
+
96
+ const repo = new Repository<T>(
97
+ this.sqliteDb,
98
+ ModelClass.tableName,
99
+ ModelClass.schema,
100
+ this.syncEngine.getChangeLog()
101
+ );
102
+
103
+ (ModelClass as unknown as { _repo: Repository<T> })._repo = repo;
104
+ this.repos.set(ModelClass.tableName, repo as unknown as Repository<BaseRecord>);
105
+ }
106
+
107
+ getNodeId(): string {
108
+ return this.nodeId;
109
+ }
110
+
111
+ getMetrics(): Readonly<SyncMetrics> {
112
+ return this.syncEngine.getMetrics();
113
+ }
114
+
115
+ async flush(): Promise<void> {
116
+ return this.syncEngine.flush();
117
+ }
118
+
119
+ async snapshot(): Promise<void> {
120
+ return this.syncEngine.triggerSnapshot();
121
+ }
122
+
123
+ startSync(): void {
124
+ this.syncEngine.start();
125
+ }
126
+
127
+ stopSync(): void {
128
+ this.syncEngine.stop();
129
+ }
130
+
131
+ raw(): Database.Database {
132
+ return this.sqliteDb;
133
+ }
134
+
135
+ close(): void {
136
+ this.syncEngine.stop();
137
+ this.sqliteDb.close();
138
+ }
139
+
140
+ transaction<T>(fn: () => T): T {
141
+ return this.sqliteDb.transaction(fn)();
142
+ }
143
+
144
+ vacuum(): void {
145
+ this.sqliteDb.exec('VACUUM');
146
+ }
147
+
148
+ integrityCheck(): boolean {
149
+ const result = this.sqliteDb
150
+ .prepare('PRAGMA integrity_check')
151
+ .get() as { integrity_check: string };
152
+ return result.integrity_check === 'ok';
153
+ }
154
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export { DB } from './db.js';
2
+ export { Model } from './orm/model.js';
3
+ export type { ModelStatic } from './orm/model.js';
4
+ export { Repository } from './orm/repository.js';
5
+ export { Column } from './orm/schema.js';
6
+ export type { RecordOf } from './orm/repository.js';
7
+
8
+ export type {
9
+ DBConfig,
10
+ S3Config,
11
+ ModelSchema,
12
+ ColumnDef,
13
+ ColumnType,
14
+ BaseRecord,
15
+ WhereClause,
16
+ FindOptions,
17
+ SyncMetrics,
18
+ RetryConfig,
19
+ EncryptionConfig,
20
+ LogBatch,
21
+ ChangeLogEntry,
22
+ SyncJob,
23
+ SyncManifest,
24
+ } from './types.js';
@@ -0,0 +1,95 @@
1
+ import type { BaseRecord, FindOptions, ModelSchema, WhereClause } from '../types.js';
2
+ import type { Repository } from './repository.js';
3
+
4
+ export abstract class Model implements BaseRecord {
5
+ id!: string;
6
+ createdAt!: number;
7
+ updatedAt!: number;
8
+
9
+ static tableName: string;
10
+ static schema: ModelSchema;
11
+
12
+ static _repo: Repository<BaseRecord>;
13
+
14
+ static async create<T extends Model>(
15
+ this: ModelStatic<T>,
16
+ data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>
17
+ ): Promise<T> {
18
+ return (this._repo as Repository<T>).create(data) as Promise<T>;
19
+ }
20
+
21
+ static async findById<T extends Model>(
22
+ this: ModelStatic<T>,
23
+ id: string
24
+ ): Promise<T | null> {
25
+ return (this._repo as Repository<T>).findById(id) as Promise<T | null>;
26
+ }
27
+
28
+ static async findOne<T extends Model>(
29
+ this: ModelStatic<T>,
30
+ where: WhereClause<T>
31
+ ): Promise<T | null> {
32
+ return (this._repo as Repository<T>).findOne(where) as Promise<T | null>;
33
+ }
34
+
35
+ static async find<T extends Model>(
36
+ this: ModelStatic<T>,
37
+ options: FindOptions<T> = {}
38
+ ): Promise<T[]> {
39
+ return (this._repo as Repository<T>).find(options) as Promise<T[]>;
40
+ }
41
+
42
+ static async filter<T extends Model>(
43
+ this: ModelStatic<T>,
44
+ where: WhereClause<T> = {},
45
+ options: Omit<FindOptions<T>, 'where'> = {}
46
+ ): Promise<T[]> {
47
+ return (this._repo as Repository<T>).filter(where, options) as Promise<T[]>;
48
+ }
49
+
50
+ static async update<T extends Model>(
51
+ this: ModelStatic<T>,
52
+ where: WhereClause<T>,
53
+ data: Partial<Omit<T, 'id' | 'createdAt'>>
54
+ ): Promise<number> {
55
+ return (this._repo as Repository<T>).update(where, data);
56
+ }
57
+
58
+ static async delete<T extends Model>(
59
+ this: ModelStatic<T>,
60
+ where: WhereClause<T>
61
+ ): Promise<number> {
62
+ return (this._repo as Repository<T>).delete(where);
63
+ }
64
+
65
+ static async count<T extends Model>(
66
+ this: ModelStatic<T>,
67
+ where: WhereClause<T> = {}
68
+ ): Promise<number> {
69
+ return (this._repo as Repository<T>).count(where);
70
+ }
71
+
72
+ static async upsert<T extends Model>(
73
+ this: ModelStatic<T>,
74
+ where: WhereClause<T>,
75
+ data: Partial<Omit<T, 'id' | 'createdAt'>>
76
+ ): Promise<T> {
77
+ return (this._repo as Repository<T>).upsert(where, data) as Promise<T>;
78
+ }
79
+ }
80
+
81
+ export interface ModelStatic<T extends Model = Model> {
82
+ new(): T;
83
+ tableName: string;
84
+ schema: ModelSchema;
85
+ _repo: Repository<BaseRecord>;
86
+ create(data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T>;
87
+ findById(id: string): Promise<T | null>;
88
+ findOne(where: WhereClause<T>): Promise<T | null>;
89
+ find(options?: FindOptions<T>): Promise<T[]>;
90
+ filter(where?: WhereClause<T>, options?: Omit<FindOptions<T>, 'where'>): Promise<T[]>;
91
+ update(where: WhereClause<T>, data: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<number>;
92
+ delete(where: WhereClause<T>): Promise<number>;
93
+ count(where?: WhereClause<T>): Promise<number>;
94
+ upsert(where: WhereClause<T>, data: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<T>;
95
+ }
@@ -0,0 +1,100 @@
1
+ import type { WhereClause, FindOptions } from '../types.js';
2
+
3
+ export interface QueryParts {
4
+ sql: string;
5
+ params: unknown[];
6
+ }
7
+
8
+ export function buildWhereClause<T>(where: WhereClause<T>): QueryParts {
9
+ const conditions: string[] = [];
10
+ const params: unknown[] = [];
11
+
12
+ for (const [key, value] of Object.entries(where)) {
13
+ if (value === undefined) continue;
14
+
15
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
16
+ const ops = value as Record<string, unknown>;
17
+
18
+ if ('$gt' in ops) {
19
+ conditions.push(`"${key}" > ?`);
20
+ params.push(ops['$gt']);
21
+ }
22
+ if ('$gte' in ops) {
23
+ conditions.push(`"${key}" >= ?`);
24
+ params.push(ops['$gte']);
25
+ }
26
+ if ('$lt' in ops) {
27
+ conditions.push(`"${key}" < ?`);
28
+ params.push(ops['$lt']);
29
+ }
30
+ if ('$lte' in ops) {
31
+ conditions.push(`"${key}" <= ?`);
32
+ params.push(ops['$lte']);
33
+ }
34
+ if ('$ne' in ops) {
35
+ conditions.push(`"${key}" != ?`);
36
+ params.push(ops['$ne']);
37
+ }
38
+ if ('$like' in ops) {
39
+ conditions.push(`"${key}" LIKE ?`);
40
+ params.push(ops['$like']);
41
+ }
42
+ if ('$in' in ops && Array.isArray(ops['$in'])) {
43
+ const arr = ops['$in'] as unknown[];
44
+ if (arr.length === 0) {
45
+ conditions.push('0 = 1');
46
+ } else {
47
+ conditions.push(`"${key}" IN (${arr.map(() => '?').join(',')})`);
48
+ params.push(...arr);
49
+ }
50
+ }
51
+ } else {
52
+ if (value === null) {
53
+ conditions.push(`"${key}" IS NULL`);
54
+ } else {
55
+ conditions.push(`"${key}" = ?`);
56
+ params.push(value);
57
+ }
58
+ }
59
+ }
60
+
61
+ return {
62
+ sql: conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '',
63
+ params,
64
+ };
65
+ }
66
+
67
+ export function buildSelectSQL<T>(
68
+ tableName: string,
69
+ options: FindOptions<T> = {}
70
+ ): QueryParts {
71
+ const parts: string[] = [`SELECT * FROM "${tableName}"`];
72
+ const params: unknown[] = [];
73
+
74
+ if (options.where && Object.keys(options.where).length > 0) {
75
+ const { sql, params: wParams } = buildWhereClause(options.where);
76
+ if (sql) {
77
+ parts.push(sql);
78
+ params.push(...wParams);
79
+ }
80
+ }
81
+
82
+ if (options.orderBy) {
83
+ const orderClauses = Object.entries(options.orderBy)
84
+ .map(([col, dir]) => `"${col}" ${dir ?? 'ASC'}`)
85
+ .join(', ');
86
+ if (orderClauses) parts.push(`ORDER BY ${orderClauses}`);
87
+ }
88
+
89
+ if (options.limit !== undefined) {
90
+ parts.push(`LIMIT ?`);
91
+ params.push(options.limit);
92
+ }
93
+
94
+ if (options.offset !== undefined) {
95
+ parts.push(`OFFSET ?`);
96
+ params.push(options.offset);
97
+ }
98
+
99
+ return { sql: parts.join(' '), params };
100
+ }
@@ -0,0 +1,156 @@
1
+ import type Database from 'better-sqlite3';
2
+ import type { BaseRecord, FindOptions, ModelSchema, WhereClause } from '../types.js';
3
+ import { buildCreateTableSQL, normalizeSchema } from './schema.js';
4
+ import { buildSelectSQL, buildWhereClause } from './query-builder.js';
5
+ import { generateId } from '../utils/id.js';
6
+ import type { ChangeLog } from '../sync/change-log.js';
7
+
8
+ export type RecordOf<S extends ModelSchema> = BaseRecord & {
9
+ [K in keyof S]: S[K] extends { type: 'INTEGER' }
10
+ ? number
11
+ : S[K] extends { type: 'REAL' }
12
+ ? number
13
+ : S[K] extends { type: 'BOOLEAN' }
14
+ ? boolean
15
+ : S[K] extends { type: 'BLOB' }
16
+ ? Buffer
17
+ : string;
18
+ };
19
+
20
+ export class Repository<T extends BaseRecord> {
21
+ private readonly db: Database.Database;
22
+ private readonly tableName: string;
23
+ private readonly schema: ModelSchema;
24
+ private readonly changeLog: ChangeLog | null;
25
+
26
+ constructor(
27
+ db: Database.Database,
28
+ tableName: string,
29
+ schema: ModelSchema,
30
+ changeLog: ChangeLog | null
31
+ ) {
32
+ this.db = db;
33
+ this.tableName = tableName;
34
+ this.schema = normalizeSchema(schema);
35
+ this.changeLog = changeLog;
36
+ this.initTable();
37
+ }
38
+
39
+ private initTable(): void {
40
+ const sql = buildCreateTableSQL(this.tableName, this.schema);
41
+ this.db.exec(sql);
42
+ }
43
+
44
+ async create(data: Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>): Promise<T> {
45
+ const now = Date.now();
46
+ const id = generateId();
47
+ const record = { id, createdAt: now, updatedAt: now, ...data } as T;
48
+
49
+ const keys = Object.keys(record);
50
+ const placeholders = keys.map(() => '?').join(', ');
51
+ const cols = keys.map((k) => `"${k}"`).join(', ');
52
+ const values = keys.map((k) => (record as Record<string, unknown>)[k]);
53
+
54
+ this.db
55
+ .prepare(`INSERT INTO "${this.tableName}" (${cols}) VALUES (${placeholders})`)
56
+ .run(...values);
57
+
58
+ this.changeLog?.append(this.tableName, 'insert', record as Record<string, unknown>);
59
+
60
+ return record;
61
+ }
62
+
63
+ async findById(id: string): Promise<T | null> {
64
+ const row = this.db
65
+ .prepare(`SELECT * FROM "${this.tableName}" WHERE id = ?`)
66
+ .get(id) as T | undefined;
67
+ return row ?? null;
68
+ }
69
+
70
+ async findOne(where: WhereClause<T>): Promise<T | null> {
71
+ const { sql, params } = buildSelectSQL<T>(this.tableName, { where, limit: 1 });
72
+ const row = this.db.prepare(sql).get(...params) as T | undefined;
73
+ return row ?? null;
74
+ }
75
+
76
+ async find(options: FindOptions<T> = {}): Promise<T[]> {
77
+ const { sql, params } = buildSelectSQL<T>(this.tableName, options);
78
+ return this.db.prepare(sql).all(...params) as T[];
79
+ }
80
+
81
+ async filter(where: WhereClause<T> = {}, options: Omit<FindOptions<T>, 'where'> = {}): Promise<T[]> {
82
+ return this.find({ where, ...options });
83
+ }
84
+
85
+ async update(where: WhereClause<T>, data: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<number> {
86
+ const now = Date.now();
87
+ const updateData = { ...data, updatedAt: now };
88
+
89
+ const setCols = Object.keys(updateData)
90
+ .map((k) => `"${k}" = ?`)
91
+ .join(', ');
92
+ const setValues = Object.values(updateData);
93
+
94
+ const { sql: whereSQL, params: whereParams } = buildWhereClause(where);
95
+ const sql = `UPDATE "${this.tableName}" SET ${setCols} ${whereSQL}`;
96
+
97
+ const result = this.db.prepare(sql).run(...setValues, ...whereParams);
98
+
99
+ if (result.changes > 0) {
100
+ this.changeLog?.append(this.tableName, 'update', {
101
+ where: where as Record<string, unknown>,
102
+ data: updateData as Record<string, unknown>,
103
+ });
104
+ }
105
+
106
+ return result.changes;
107
+ }
108
+
109
+ async delete(where: WhereClause<T>): Promise<number> {
110
+ const { sql: whereSQL, params: whereParams } = buildWhereClause(where);
111
+ const sql = `DELETE FROM "${this.tableName}" ${whereSQL}`;
112
+
113
+ const result = this.db.prepare(sql).run(...whereParams);
114
+
115
+ if (result.changes > 0) {
116
+ this.changeLog?.append(this.tableName, 'delete', {
117
+ where: where as Record<string, unknown>,
118
+ });
119
+ }
120
+
121
+ return result.changes;
122
+ }
123
+
124
+ async deleteById(id: string): Promise<boolean> {
125
+ const changes = await this.delete({ id } as WhereClause<T>);
126
+ return changes > 0;
127
+ }
128
+
129
+ async count(where: WhereClause<T> = {}): Promise<number> {
130
+ const { sql: whereSQL, params } = buildWhereClause(where);
131
+ const sql = `SELECT COUNT(*) as cnt FROM "${this.tableName}" ${whereSQL}`;
132
+ const row = this.db.prepare(sql).get(...params) as { cnt: number };
133
+ return row.cnt;
134
+ }
135
+
136
+ async upsert(
137
+ where: WhereClause<T>,
138
+ data: Partial<Omit<T, 'id' | 'createdAt'>>
139
+ ): Promise<T> {
140
+ const existing = await this.findOne(where);
141
+ if (existing) {
142
+ await this.update(where, data);
143
+ return (await this.findById(existing.id))!;
144
+ }
145
+ return this.create(data as Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>);
146
+ }
147
+
148
+ raw<R = unknown>(sql: string, params: unknown[] = []): R[] {
149
+ return this.db.prepare(sql).all(...params) as R[];
150
+ }
151
+
152
+ runRaw(sql: string, params: unknown[] = []): { changes: number; lastInsertRowid: number | bigint } {
153
+ const result = this.db.prepare(sql).run(...params);
154
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
155
+ }
156
+ }
@@ -0,0 +1,92 @@
1
+ import type { ColumnDef, ColumnType, ModelSchema } from '../types.js';
2
+
3
+ export class ColumnBuilder {
4
+ private readonly def: ColumnDef;
5
+
6
+ constructor(type: ColumnType) {
7
+ this.def = { type };
8
+ }
9
+
10
+ required(): this {
11
+ this.def.notNull = true;
12
+ return this;
13
+ }
14
+
15
+ unique(): this {
16
+ this.def.unique = true;
17
+ return this;
18
+ }
19
+
20
+ default(value: string | number | boolean | null): this {
21
+ this.def.default = value;
22
+ return this;
23
+ }
24
+
25
+ index(): this {
26
+ this.def.index = true;
27
+ return this;
28
+ }
29
+
30
+ build(): ColumnDef {
31
+ return { ...this.def };
32
+ }
33
+ }
34
+
35
+ export const Column = {
36
+ text(): ColumnBuilder {
37
+ return new ColumnBuilder('TEXT');
38
+ },
39
+ integer(): ColumnBuilder {
40
+ return new ColumnBuilder('INTEGER');
41
+ },
42
+ real(): ColumnBuilder {
43
+ return new ColumnBuilder('REAL');
44
+ },
45
+ blob(): ColumnBuilder {
46
+ return new ColumnBuilder('BLOB');
47
+ },
48
+ boolean(): ColumnBuilder {
49
+ return new ColumnBuilder('BOOLEAN');
50
+ },
51
+ };
52
+
53
+ export function buildCreateTableSQL(tableName: string, schema: ModelSchema): string {
54
+ const cols: string[] = [
55
+ 'id TEXT PRIMARY KEY',
56
+ 'createdAt INTEGER NOT NULL',
57
+ 'updatedAt INTEGER NOT NULL',
58
+ ];
59
+
60
+ const indices: string[] = [];
61
+
62
+ for (const [name, rawDef] of Object.entries(schema)) {
63
+ const def: ColumnDef =
64
+ rawDef instanceof ColumnBuilder ? rawDef.build() : rawDef;
65
+
66
+ let col = `"${name}" ${def.type}`;
67
+ if (def.notNull) col += ' NOT NULL';
68
+ if (def.unique) col += ' UNIQUE';
69
+ if (def.default !== undefined) {
70
+ const v = def.default;
71
+ col += ` DEFAULT ${typeof v === 'string' ? `'${v}'` : String(v)}`;
72
+ }
73
+ cols.push(col);
74
+
75
+ if (def.index) {
76
+ indices.push(
77
+ `CREATE INDEX IF NOT EXISTS "idx_${tableName}_${name}" ON "${tableName}" ("${name}");`
78
+ );
79
+ }
80
+ }
81
+
82
+ const create = `CREATE TABLE IF NOT EXISTS "${tableName}" (\n ${cols.join(',\n ')}\n);`;
83
+ return [create, ...indices].join('\n');
84
+ }
85
+
86
+ export function normalizeSchema(schema: ModelSchema): ModelSchema {
87
+ const normalized: ModelSchema = {};
88
+ for (const [key, val] of Object.entries(schema)) {
89
+ normalized[key] = val instanceof ColumnBuilder ? val.build() : val;
90
+ }
91
+ return normalized;
92
+ }