@arcote.tech/arc-adapter-db-sqlite 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@arcote.tech/arc-adapter-db-sqlite",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm && bun run build:types",
15
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir ./dist",
16
+ "dev": "bun build ./src/index.ts --outdir ./dist --target node --format esm --watch",
17
+ "test": "bun test"
18
+ },
19
+ "peerDependencies": {
20
+ "@arcote.tech/arc": "workspace:*"
21
+ },
22
+ "devDependencies": {
23
+ "@arcote.tech/arc": "workspace:*",
24
+ "typescript": "^5.0.0"
25
+ }
26
+ }
@@ -0,0 +1,82 @@
1
+ import type { ArcContextAny, DBAdapterFactory } from "@arcote.tech/arc";
2
+ import { Database } from "bun:sqlite";
3
+ import type { SQLiteDatabase } from "./sqlite-adapter";
4
+ import { createSQLiteAdapterFactory } from "./sqlite-adapter";
5
+
6
+ /**
7
+ * Bun SQLite wrapper that implements SQLiteDatabase interface
8
+ */
9
+ export class BunSQLiteDatabase implements SQLiteDatabase {
10
+ private db: Database;
11
+
12
+ constructor(filename: string = ":memory:") {
13
+ this.db = new Database(filename);
14
+ }
15
+
16
+ async exec(sql: string, params?: any[]): Promise<any[]> {
17
+ try {
18
+ if (params && params.length > 0) {
19
+ const stmt = this.db.prepare(sql);
20
+ if (sql.trim().toUpperCase().startsWith("SELECT")) {
21
+ return stmt.all(...params) as any[];
22
+ } else {
23
+ stmt.run(...params);
24
+ return [];
25
+ }
26
+ } else {
27
+ const statements = sql.split(";").filter((s) => s.trim());
28
+ let result: any[] = [];
29
+
30
+ for (const statement of statements) {
31
+ if (!statement.trim()) continue;
32
+
33
+ if (statement.trim().toUpperCase().startsWith("SELECT")) {
34
+ result = this.db.prepare(statement).all() as any[];
35
+ } else {
36
+ this.db.exec(statement);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+ } catch (error) {
42
+ console.error("SQL Error:", error, "\nSQL:", sql, "\nParams:", params);
43
+ throw error;
44
+ }
45
+ }
46
+
47
+ async execBatch(
48
+ queries: Array<{ sql: string; params?: any[] }>,
49
+ ): Promise<void> {
50
+ this.db.exec("BEGIN TRANSACTION");
51
+ try {
52
+ for (const query of queries) {
53
+ if (query.params && query.params.length > 0) {
54
+ this.db.prepare(query.sql).run(...query.params);
55
+ } else {
56
+ this.db.exec(query.sql);
57
+ }
58
+ }
59
+ this.db.exec("COMMIT");
60
+ } catch (error) {
61
+ this.db.exec("ROLLBACK");
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ close() {
67
+ this.db.close();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Create a Bun SQLite adapter factory
73
+ * @param filename - Database filename, defaults to ":memory:" for in-memory database
74
+ */
75
+ export const createBunSQLiteAdapterFactory = (
76
+ filename: string = ":memory:",
77
+ ): DBAdapterFactory => {
78
+ return async (context: ArcContextAny) => {
79
+ const db = new BunSQLiteDatabase(filename);
80
+ return createSQLiteAdapterFactory(db)(context);
81
+ };
82
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { BunSQLiteDatabase, createBunSQLiteAdapterFactory } from "./bun-sqlite";
2
+ export { createSQLiteAdapterFactory, SQLiteAdapter } from "./sqlite-adapter";
3
+ export type { SQLiteDatabase } from "./sqlite-adapter";
@@ -0,0 +1,812 @@
1
+ import {
2
+ type ArcContextAny,
3
+ type DatabaseAdapter,
4
+ type DatabaseAgnosticColumnInfo,
5
+ type DatabaseStoreData,
6
+ type DatabaseStoreSchema,
7
+ type DBAdapterFactory,
8
+ type FindOptions,
9
+ type ReadTransaction,
10
+ type ReadWriteTransaction,
11
+ type StoreColumn,
12
+ type StoreTable,
13
+ type WhereCondition,
14
+ extractDatabaseAgnosticSchema,
15
+ } from "@arcote.tech/arc";
16
+
17
+ export interface SQLiteDatabase {
18
+ exec(sql: string, params?: any[]): Promise<any>;
19
+ execBatch(queries: Array<{ sql: string; params?: any[] }>): Promise<any>;
20
+ }
21
+
22
+ class SQLiteReadTransaction implements ReadTransaction {
23
+ constructor(
24
+ protected db: SQLiteDatabase,
25
+ protected tables: Map<string, StoreTable>,
26
+ protected adapter?: SQLiteAdapter,
27
+ ) {}
28
+
29
+ protected hasSoftDelete(tableName: string): boolean {
30
+ if (this.adapter) {
31
+ return this.adapter.hasSoftDelete(tableName);
32
+ }
33
+ // Fallback: check if table has deleted column
34
+ const table = this.tables.get(tableName);
35
+ if (!table) return false;
36
+ return table.columns.some((col) => col.name === "deleted");
37
+ }
38
+
39
+ protected deserializeValue(value: any, column: StoreColumn): any {
40
+ if (value === null || value === undefined) return null;
41
+
42
+ switch (column.type.toLowerCase()) {
43
+ case "json":
44
+ // SQLite JSON columns should always be parsed
45
+ if (typeof value === "string") {
46
+ try {
47
+ return JSON.parse(value);
48
+ } catch {
49
+ return value;
50
+ }
51
+ }
52
+ return value;
53
+ case "text":
54
+ // For text columns, only attempt JSON parsing if it looks like JSON
55
+ if (
56
+ typeof value === "string" &&
57
+ (value.startsWith("{") || value.startsWith("["))
58
+ ) {
59
+ try {
60
+ const parsed = JSON.parse(value);
61
+ if (typeof parsed === "object" || Array.isArray(parsed)) {
62
+ return parsed;
63
+ }
64
+ } catch {
65
+ // Not valid JSON, return as string
66
+ }
67
+ }
68
+ return value;
69
+ case "datetime":
70
+ case "timestamp":
71
+ return new Date(value);
72
+ default:
73
+ return value;
74
+ }
75
+ }
76
+
77
+ protected deserializeRow(row: any, table: StoreTable): any {
78
+ const result: any = {};
79
+ for (const column of table.columns) {
80
+ const value = row[column.name];
81
+ result[column.name] = this.deserializeValue(value, column);
82
+ }
83
+ return result;
84
+ }
85
+
86
+ protected getId(store: string, id: any) {
87
+ return id;
88
+ }
89
+
90
+ protected buildWhereClause(
91
+ where?: WhereCondition,
92
+ tableName?: string,
93
+ ): {
94
+ sql: string;
95
+ params: any[];
96
+ } {
97
+ const conditions: string[] = [];
98
+ const params: any[] = [];
99
+
100
+ // Only add deleted condition if table has soft delete enabled
101
+ if (tableName && this.hasSoftDelete(tableName)) {
102
+ conditions.push('"deleted" = 0');
103
+ }
104
+
105
+ if (!where) {
106
+ return {
107
+ sql: conditions.length > 0 ? conditions.join(" AND ") : "1=1",
108
+ params,
109
+ };
110
+ }
111
+
112
+ Object.entries(where).forEach(([key, value]) => {
113
+ if (typeof value === "object" && value !== null) {
114
+ Object.entries(value as Record<string, unknown>).forEach(
115
+ ([operator, operand]) => {
116
+ switch (operator) {
117
+ case "$eq":
118
+ case "$ne":
119
+ case "$gt":
120
+ case "$gte":
121
+ case "$lt":
122
+ case "$lte":
123
+ conditions.push(
124
+ `"${key}" ${this.getOperatorSymbol(operator)} ?`,
125
+ );
126
+ params.push(operand);
127
+ break;
128
+ case "$in":
129
+ case "$nin":
130
+ if (Array.isArray(operand)) {
131
+ conditions.push(
132
+ `"${key}" ${operator === "$in" ? "IN" : "NOT IN"} (${operand.map(() => "?").join(", ")})`,
133
+ );
134
+ params.push(...operand);
135
+ }
136
+ break;
137
+ case "$exists":
138
+ if (typeof operand === "boolean") {
139
+ conditions.push(
140
+ operand ? `"${key}" IS NOT NULL` : `"${key}" IS NULL`,
141
+ );
142
+ }
143
+ break;
144
+ }
145
+ },
146
+ );
147
+ } else {
148
+ conditions.push(`"${key}" = ?`);
149
+ params.push(value);
150
+ }
151
+ });
152
+
153
+ return {
154
+ sql: conditions.join(" AND "),
155
+ params,
156
+ };
157
+ }
158
+
159
+ protected getOperatorSymbol(operator: string): string {
160
+ const operators: Record<string, string> = {
161
+ $eq: "=",
162
+ $ne: "!=",
163
+ $gt: ">",
164
+ $gte: ">=",
165
+ $lt: "<",
166
+ $lte: "<=",
167
+ };
168
+ return operators[operator] || "=";
169
+ }
170
+
171
+ protected buildOrderByClause(
172
+ orderBy?: Record<string, "asc" | "desc">,
173
+ ): string {
174
+ if (!orderBy) return "";
175
+
176
+ const orderClauses = Object.entries(orderBy)
177
+ .map(([key, direction]) => `"${key}" ${direction.toUpperCase()}`)
178
+ .join(", ");
179
+
180
+ return orderClauses ? `ORDER BY ${orderClauses}` : "";
181
+ }
182
+
183
+ async find<T>(store: string, options: FindOptions<T>): Promise<T[]> {
184
+ const { where, limit, offset, orderBy } = options || {};
185
+ const whereClause = this.buildWhereClause(where, store);
186
+ const orderByClause = this.buildOrderByClause(orderBy);
187
+ const table = this.tables.get(store);
188
+
189
+ if (!table) {
190
+ throw new Error(`Store ${store} not found`);
191
+ }
192
+
193
+ const query = `
194
+ SELECT *
195
+ FROM "${table.name}"
196
+ WHERE ${whereClause.sql}
197
+ ${orderByClause}
198
+ ${limit ? `LIMIT ${limit}` : ""}
199
+ ${offset ? `OFFSET ${offset}` : ""}
200
+ `;
201
+
202
+ const rows = await this.db.exec(query, whereClause.params);
203
+ return rows.map((row: any) => this.deserializeRow(row, table));
204
+ }
205
+ }
206
+
207
+ class SQLiteReadWriteTransaction
208
+ extends SQLiteReadTransaction
209
+ implements ReadWriteTransaction
210
+ {
211
+ private queries: Array<{ sql: string; params?: any[] }> = [];
212
+
213
+ constructor(
214
+ db: SQLiteDatabase,
215
+ tables: Map<string, StoreTable>,
216
+ protected adapter: SQLiteAdapter,
217
+ ) {
218
+ super(db, tables);
219
+ }
220
+
221
+ async remove(store: string, id: any) {
222
+ const table = this.tables.get(store);
223
+ if (!table) {
224
+ throw new Error(`Store ${store} not found`);
225
+ }
226
+
227
+ // Check if table has soft delete enabled
228
+ const hasSoftDelete = this.adapter.hasSoftDelete(store);
229
+
230
+ if (hasSoftDelete) {
231
+ // Soft delete: set deleted flag
232
+ const query = `UPDATE "${table.name}" SET "deleted" = 1, "lastUpdate" = ? WHERE "${table.primaryKey}" = ?`;
233
+ this.queries.push({
234
+ sql: query,
235
+ params: [new Date().toISOString(), id],
236
+ });
237
+ } else {
238
+ // Hard delete: actually remove the row
239
+ const query = `DELETE FROM "${table.name}" WHERE "${table.primaryKey}" = ?`;
240
+ this.queries.push({
241
+ sql: query,
242
+ params: [id],
243
+ });
244
+ }
245
+ }
246
+
247
+ async set(store: string, item: any) {
248
+ const table = this.tables.get(store);
249
+ if (!table) {
250
+ throw new Error(`Store ${store} not found`);
251
+ }
252
+
253
+ const hasVersioning = this.adapter.hasVersioning(store);
254
+
255
+ if (hasVersioning) {
256
+ // For versioned tables, use a transaction with version increment
257
+ await this.setWithVersioning(store, item, table);
258
+ } else {
259
+ // For non-versioned tables, use simple insert
260
+ await this.setWithoutVersioning(store, item, table);
261
+ }
262
+ }
263
+
264
+ private async setWithoutVersioning(
265
+ store: string,
266
+ item: any,
267
+ table: StoreTable,
268
+ ) {
269
+ const columnNames = table.columns.map((col) => col.name);
270
+ const values = table.columns.map((column) => {
271
+ let value = item[column.name];
272
+ if (value === undefined && column.default !== undefined) {
273
+ value = column.default;
274
+ }
275
+ return this.serializeValue(value, column);
276
+ });
277
+
278
+ const placeholders = columnNames.map(() => "?").join(", ");
279
+
280
+ // For events table, use simple INSERT since events are typically append-only
281
+ if (store === "events") {
282
+ const simpleInsertSql = `
283
+ INSERT INTO "${table.name}"
284
+ (${columnNames.map((c) => `"${c}"`).join(", ")})
285
+ VALUES (${placeholders})
286
+ `;
287
+ this.queries.push({
288
+ sql: simpleInsertSql,
289
+ params: values,
290
+ });
291
+ } else {
292
+ const sql = `
293
+ INSERT OR REPLACE INTO "${table.name}"
294
+ (${columnNames.map((c) => `"${c}"`).join(", ")})
295
+ VALUES (${placeholders})
296
+ `;
297
+ this.queries.push({
298
+ sql: sql,
299
+ params: values,
300
+ });
301
+ }
302
+ }
303
+
304
+ private async setWithVersioning(store: string, item: any, table: StoreTable) {
305
+ // Filter out __version from regular columns and handle it separately
306
+ const regularColumns = table.columns.filter(
307
+ (col) => col.name !== "__version",
308
+ );
309
+ const columnNames = regularColumns.map((col) => col.name);
310
+ const values = regularColumns.map((column) => {
311
+ let value = item[column.name];
312
+ if (value === undefined && column.default !== undefined) {
313
+ value = column.default;
314
+ }
315
+ return this.serializeValue(value, column);
316
+ });
317
+
318
+ // Add __version column
319
+ columnNames.push("__version");
320
+ const placeholders = regularColumns.map(() => "?").join(", ");
321
+
322
+ // Always increment version counter and use the new version
323
+ const sql = `
324
+ WITH next_version AS (
325
+ INSERT INTO __arc_version_counters (table_name, last_version)
326
+ VALUES (?, 1)
327
+ ON CONFLICT(table_name)
328
+ DO UPDATE SET last_version = last_version + 1
329
+ RETURNING last_version
330
+ )
331
+ INSERT OR REPLACE INTO "${table.name}"
332
+ (${columnNames.map((c) => `"${c}"`).join(", ")})
333
+ VALUES (${placeholders}, (SELECT last_version FROM next_version))
334
+ `;
335
+
336
+ this.queries.push({
337
+ sql: sql,
338
+ params: [...values, store],
339
+ });
340
+ }
341
+
342
+ async commit() {
343
+ if (this.queries.length === 0) {
344
+ return Promise.resolve();
345
+ }
346
+
347
+ try {
348
+ await this.db.execBatch(this.queries);
349
+ this.queries = [];
350
+ } catch (error) {
351
+ this.queries = [];
352
+ throw error;
353
+ }
354
+ }
355
+
356
+ private serializeValue(value: any, column: StoreColumn): any {
357
+ if (value === null || value === undefined) return null;
358
+
359
+ switch (column.type.toLowerCase()) {
360
+ case "timestamp":
361
+ case "datetime":
362
+ // Handle various timestamp formats
363
+ if (value instanceof Date) {
364
+ return value.toISOString();
365
+ }
366
+ if (typeof value === "number") {
367
+ // Unix timestamp (seconds or milliseconds)
368
+ const date = value > 1e10 ? new Date(value) : new Date(value * 1000);
369
+ return date.toISOString();
370
+ }
371
+ if (typeof value === "string") {
372
+ // Already an ISO string or date string, validate and normalize
373
+ const date = new Date(value);
374
+ if (!isNaN(date.getTime())) {
375
+ return date.toISOString();
376
+ }
377
+ }
378
+ return value; // Pass through if we can't convert
379
+ case "json":
380
+ return JSON.stringify(value);
381
+ default:
382
+ if (value instanceof Date) {
383
+ return value.toISOString();
384
+ }
385
+ if (Array.isArray(value) || typeof value === "object") {
386
+ return JSON.stringify(value);
387
+ }
388
+ return value;
389
+ }
390
+ }
391
+ }
392
+
393
+ export class SQLiteAdapter implements DatabaseAdapter {
394
+ private tables: Map<string, StoreTable> = new Map();
395
+ private tableSchemas: Map<string, any[]> = new Map();
396
+ private pendingReinitTables: Array<{
397
+ tableName: string;
398
+ reinitFn: (tableName: string, dataStorage: any) => Promise<void>;
399
+ }> = [];
400
+
401
+ private mapType(arcType: string, storeData?: DatabaseStoreData): string {
402
+ // Check for database-specific type override
403
+ if (storeData?.databaseType?.sqlite) {
404
+ return storeData.databaseType.sqlite;
405
+ }
406
+
407
+ switch (arcType) {
408
+ case "string":
409
+ case "id":
410
+ case "customId":
411
+ case "stringEnum":
412
+ return "TEXT";
413
+ case "number":
414
+ return "INTEGER";
415
+ case "boolean":
416
+ return "INTEGER"; // SQLite stores booleans as integers
417
+ case "date":
418
+ return "TIMESTAMP";
419
+ case "object":
420
+ case "array":
421
+ case "record":
422
+ return "JSON";
423
+ case "blob":
424
+ return "BLOB";
425
+ default:
426
+ return "TEXT";
427
+ }
428
+ }
429
+
430
+ private buildConstraints(storeData?: DatabaseStoreData): string[] {
431
+ const constraints: string[] = [];
432
+
433
+ if (storeData?.isPrimaryKey) {
434
+ constraints.push("PRIMARY KEY");
435
+ }
436
+ if (storeData?.isAutoIncrement) {
437
+ constraints.push("AUTOINCREMENT");
438
+ }
439
+ if (storeData?.isUnique) {
440
+ constraints.push("UNIQUE");
441
+ }
442
+ if (storeData?.foreignKey) {
443
+ const { table, column, onDelete, onUpdate } = storeData.foreignKey;
444
+ let fkConstraint = `REFERENCES ${table}(${column})`;
445
+ if (onDelete) fkConstraint += ` ON DELETE ${onDelete}`;
446
+ if (onUpdate) fkConstraint += ` ON UPDATE ${onUpdate}`;
447
+ constraints.push(fkConstraint);
448
+ }
449
+
450
+ return constraints;
451
+ }
452
+
453
+ private generateColumnSQL(
454
+ columnInfo: DatabaseAgnosticColumnInfo & { name: string },
455
+ ): string {
456
+ const type = this.mapType(columnInfo.type, columnInfo.storeData);
457
+ const constraints: string[] = [];
458
+
459
+ if (!columnInfo.storeData?.isNullable) {
460
+ constraints.push("NOT NULL");
461
+ }
462
+
463
+ constraints.push(...this.buildConstraints(columnInfo.storeData));
464
+
465
+ if (columnInfo.defaultValue !== undefined) {
466
+ if (typeof columnInfo.defaultValue === "string") {
467
+ constraints.push(`DEFAULT '${columnInfo.defaultValue}'`);
468
+ } else {
469
+ constraints.push(`DEFAULT ${columnInfo.defaultValue}`);
470
+ }
471
+ }
472
+
473
+ return `"${columnInfo.name}" ${type} ${constraints.join(" ")}`.trim();
474
+ }
475
+
476
+ private generateCreateTableSQL(
477
+ tableName: string,
478
+ columns: (DatabaseAgnosticColumnInfo & { name: string })[],
479
+ ): string {
480
+ const columnDefinitions = columns.map((col) => this.generateColumnSQL(col));
481
+ const indexes = columns
482
+ .filter((col) => col.storeData?.hasIndex && !col.storeData?.isPrimaryKey)
483
+ .map(
484
+ (col) =>
485
+ `CREATE INDEX IF NOT EXISTS idx_${tableName}_${col.name} ON "${tableName}"("${col.name}");`,
486
+ );
487
+
488
+ let sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (\n ${columnDefinitions.join(",\n ")}\n)`;
489
+
490
+ if (indexes.length > 0) {
491
+ sql += ";\n" + indexes.join("\n");
492
+ }
493
+
494
+ return sql;
495
+ }
496
+
497
+ constructor(
498
+ private db: SQLiteDatabase,
499
+ private context: ArcContextAny,
500
+ ) {
501
+ this.context.elements.forEach((element) => {
502
+ if (
503
+ "databaseStoreSchema" in element &&
504
+ typeof element.databaseStoreSchema === "function"
505
+ ) {
506
+ const databaseSchema =
507
+ element.databaseStoreSchema() as DatabaseStoreSchema;
508
+
509
+ databaseSchema.tables.forEach((dbTable) => {
510
+ // Extract database-agnostic schema from ArcObject
511
+ const agnosticSchema = extractDatabaseAgnosticSchema(
512
+ dbTable.schema,
513
+ dbTable.name,
514
+ );
515
+
516
+ // Convert to database-specific columns
517
+ const columns = agnosticSchema.columns.map((columnInfo) => ({
518
+ name: columnInfo.name,
519
+ type: this.mapType(columnInfo.type, columnInfo.storeData),
520
+ constraints: this.buildConstraints(columnInfo.storeData),
521
+ isNullable: columnInfo.storeData?.isNullable || false,
522
+ defaultValue: columnInfo.defaultValue,
523
+ isPrimaryKey: columnInfo.storeData?.isPrimaryKey || false,
524
+ isAutoIncrement: columnInfo.storeData?.isAutoIncrement || false,
525
+ isUnique: columnInfo.storeData?.isUnique || false,
526
+ hasIndex: columnInfo.storeData?.hasIndex || false,
527
+ foreignKey: columnInfo.storeData?.foreignKey,
528
+ }));
529
+
530
+ this.tableSchemas.set(dbTable.name, columns);
531
+
532
+ // Convert to legacy StoreTable format for compatibility with existing transaction code
533
+ const legacyTable: StoreTable = {
534
+ name: dbTable.name,
535
+ primaryKey: columns.find((col) => col.isPrimaryKey)?.name || "_id",
536
+ columns: columns.map((col) => ({
537
+ name: col.name,
538
+ type: col.type,
539
+ isOptional: col.isNullable,
540
+ default: col.defaultValue,
541
+ })),
542
+ };
543
+ this.tables.set(dbTable.name, legacyTable);
544
+ });
545
+ }
546
+ });
547
+ }
548
+
549
+ public async initialize() {
550
+ // Create the version counter table first
551
+ await this.createVersionCounterTable();
552
+ // Create the table versions tracking table
553
+ await this.createTableVersionsTable();
554
+
555
+ const processedSchemas = new Set<DatabaseStoreSchema>();
556
+ const processedTables = new Set<string>();
557
+
558
+ for (const element of this.context.elements) {
559
+ if (
560
+ "databaseStoreSchema" in element &&
561
+ typeof element.databaseStoreSchema === "function"
562
+ ) {
563
+ const databaseSchema =
564
+ element.databaseStoreSchema() as DatabaseStoreSchema;
565
+
566
+ // Skip if we've already processed this exact schema reference
567
+ if (processedSchemas.has(databaseSchema)) {
568
+ continue;
569
+ }
570
+ processedSchemas.add(databaseSchema);
571
+
572
+ for (const dbTable of databaseSchema.tables) {
573
+ const tableKey = dbTable.version
574
+ ? `${dbTable.name}_v${dbTable.version}`
575
+ : dbTable.name;
576
+
577
+ if (!processedTables.has(tableKey)) {
578
+ // Extract database-agnostic schema from ArcObject
579
+ const agnosticSchema = extractDatabaseAgnosticSchema(
580
+ dbTable.schema,
581
+ dbTable.name,
582
+ );
583
+
584
+ // Add system columns if enabled
585
+ let allColumns = [...agnosticSchema.columns];
586
+
587
+ if (dbTable.options?.versioning) {
588
+ allColumns.push({
589
+ name: "__version",
590
+ type: "number",
591
+ storeData: { isNullable: false, hasIndex: true },
592
+ defaultValue: 1,
593
+ });
594
+ }
595
+
596
+ if (dbTable.options?.softDelete) {
597
+ allColumns.push({
598
+ name: "deleted",
599
+ type: "boolean",
600
+ storeData: { isNullable: false, hasIndex: true },
601
+ defaultValue: false,
602
+ });
603
+ }
604
+
605
+ // Determine physical table name based on version
606
+ const physicalTableName = this.getPhysicalTableName(
607
+ dbTable.name,
608
+ dbTable.version,
609
+ );
610
+
611
+ // Check if versioned table already exists
612
+ if (
613
+ dbTable.version &&
614
+ (await this.checkVersionedTableExists(
615
+ dbTable.name,
616
+ dbTable.version,
617
+ ))
618
+ ) {
619
+ console.log(
620
+ `Versioned table ${physicalTableName} already exists, skipping creation`,
621
+ );
622
+ } else {
623
+ await this.createTableIfNotExistsNew(
624
+ physicalTableName,
625
+ allColumns,
626
+ );
627
+
628
+ // Register the new version if it's a versioned table
629
+ if (dbTable.version) {
630
+ await this.registerTableVersion(
631
+ dbTable.name,
632
+ dbTable.version,
633
+ physicalTableName,
634
+ );
635
+ }
636
+ }
637
+
638
+ // Store reinit function for later execution (only for versioned tables)
639
+ // reinitTable rebuilds views from events when schema version changes
640
+ if (dbTable.version && databaseSchema.reinitTable) {
641
+ this.pendingReinitTables.push({
642
+ tableName: physicalTableName,
643
+ reinitFn: databaseSchema.reinitTable,
644
+ });
645
+ }
646
+
647
+ // Update the legacy table mapping with the actual columns (including system columns)
648
+ const legacyTable: StoreTable = {
649
+ name: physicalTableName, // Use physical table name for actual queries
650
+ primaryKey:
651
+ allColumns.find((col) => col.storeData?.isPrimaryKey)?.name ||
652
+ "_id",
653
+ columns: allColumns.map((col) => ({
654
+ name: col.name,
655
+ type: this.mapType(col.type, col.storeData),
656
+ isOptional: col.storeData?.isNullable || false,
657
+ default: col.defaultValue,
658
+ })),
659
+ };
660
+ this.tables.set(dbTable.name, legacyTable); // Still use logical name as key
661
+
662
+ processedTables.add(tableKey);
663
+ }
664
+ }
665
+ }
666
+ }
667
+ }
668
+
669
+ private async createTableIfNotExistsNew(
670
+ tableName: string,
671
+ columns: (DatabaseAgnosticColumnInfo & { name: string })[],
672
+ ) {
673
+ const createTableSQL = this.generateCreateTableSQL(tableName, columns);
674
+ await this.db.exec(createTableSQL);
675
+ }
676
+
677
+ /**
678
+ * Create the version counter table for tracking per-table version sequences
679
+ */
680
+ private async createVersionCounterTable() {
681
+ const sql = `
682
+ CREATE TABLE IF NOT EXISTS __arc_version_counters (
683
+ table_name TEXT PRIMARY KEY,
684
+ last_version INTEGER NOT NULL DEFAULT 0
685
+ )
686
+ `;
687
+ await this.db.exec(sql);
688
+ }
689
+
690
+ /**
691
+ * Create the table versions tracking table for schema versioning
692
+ */
693
+ private async createTableVersionsTable() {
694
+ const sql = `
695
+ CREATE TABLE IF NOT EXISTS __arc_table_versions (
696
+ table_name TEXT NOT NULL,
697
+ version INTEGER NOT NULL,
698
+ physical_table_name TEXT NOT NULL,
699
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
700
+ is_active INTEGER DEFAULT 0,
701
+ PRIMARY KEY (table_name, version)
702
+ )
703
+ `;
704
+ await this.db.exec(sql);
705
+ }
706
+
707
+ /**
708
+ * Generate physical table name with version suffix
709
+ */
710
+ private getPhysicalTableName(logicalName: string, version?: number): string {
711
+ return version ? `${logicalName}_v${version}` : logicalName;
712
+ }
713
+
714
+ /**
715
+ * Check if a versioned table exists
716
+ */
717
+ private async checkVersionedTableExists(
718
+ logicalName: string,
719
+ version: number,
720
+ ): Promise<boolean> {
721
+ const result = await this.db.exec(
722
+ "SELECT COUNT(*) as count FROM __arc_table_versions WHERE table_name = ? AND version = ?",
723
+ [logicalName, version],
724
+ );
725
+ return result[0]?.count > 0;
726
+ }
727
+
728
+ /**
729
+ * Register a new table version
730
+ */
731
+ private async registerTableVersion(
732
+ logicalName: string,
733
+ version: number,
734
+ physicalName: string,
735
+ ): Promise<void> {
736
+ await this.db.exec(
737
+ "INSERT INTO __arc_table_versions (table_name, version, physical_table_name, is_active) VALUES (?, ?, ?, ?)",
738
+ [logicalName, version, physicalName, 1],
739
+ );
740
+ }
741
+
742
+ /**
743
+ * Check if a table has versioning enabled
744
+ */
745
+ public hasVersioning(tableName: string): boolean {
746
+ // Check if the table has a __version column
747
+ const table = this.tables.get(tableName);
748
+ if (!table) return false;
749
+ return table.columns.some((col) => col.name === "__version");
750
+ }
751
+
752
+ /**
753
+ * Check if a table has soft delete enabled
754
+ */
755
+ public hasSoftDelete(tableName: string): boolean {
756
+ // Check if the table has a deleted column
757
+ const table = this.tables.get(tableName);
758
+ if (!table) return false;
759
+ return table.columns.some((col) => col.name === "deleted");
760
+ }
761
+
762
+ /**
763
+ * Execute all pending reinitTable functions
764
+ */
765
+ public async executeReinitTables(dataStorage: any): Promise<void> {
766
+ for (const { tableName, reinitFn } of this.pendingReinitTables) {
767
+ await reinitFn(tableName, dataStorage);
768
+ }
769
+ this.pendingReinitTables = [];
770
+ }
771
+
772
+ readWriteTransaction(stores?: string[]) {
773
+ return new SQLiteReadWriteTransaction(this.db, this.tables, this);
774
+ }
775
+
776
+ readTransaction(stores?: string[]) {
777
+ return new SQLiteReadTransaction(this.db, this.tables, this);
778
+ }
779
+
780
+ /**
781
+ * Destroy the database - drop all tables
782
+ * Used for logout/reset scenarios
783
+ */
784
+ async destroy(): Promise<void> {
785
+ // Drop all application tables
786
+ for (const tableName of this.tables.keys()) {
787
+ const table = this.tables.get(tableName);
788
+ if (table) {
789
+ await this.db.exec(`DROP TABLE IF EXISTS "${table.name}"`);
790
+ }
791
+ }
792
+
793
+ // Drop system tables
794
+ await this.db.exec("DROP TABLE IF EXISTS __arc_version_counters");
795
+ await this.db.exec("DROP TABLE IF EXISTS __arc_table_versions");
796
+
797
+ // Clear internal state
798
+ this.tables.clear();
799
+ this.tableSchemas.clear();
800
+ this.pendingReinitTables = [];
801
+ }
802
+ }
803
+
804
+ export const createSQLiteAdapterFactory = (
805
+ db: SQLiteDatabase,
806
+ ): DBAdapterFactory => {
807
+ return async (context: ArcContextAny): Promise<DatabaseAdapter> => {
808
+ const adapter = new SQLiteAdapter(db, context);
809
+ await adapter.initialize();
810
+ return adapter;
811
+ };
812
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
11
+ }