@arcote.tech/arc-adapter-db-sqlite-wasm 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,31 @@
1
+ {
2
+ "name": "@arcote.tech/arc-adapter-db-sqlite-wasm",
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
+ "./worker": {
13
+ "import": "./dist/worker.js",
14
+ "types": "./dist/worker.d.ts"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "bun build ./src/index.ts ./src/worker.ts --outdir ./dist --target browser --format esm && bun run build:types",
19
+ "build:types": "tsc --emitDeclarationOnly --declaration --outDir ./dist",
20
+ "dev": "bun build ./src/index.ts ./src/worker.ts --outdir ./dist --target browser --format esm --watch"
21
+ },
22
+ "peerDependencies": {
23
+ "@arcote.tech/arc": "workspace:*",
24
+ "@sqlite.org/sqlite-wasm": "^3.46.0-build1"
25
+ },
26
+ "devDependencies": {
27
+ "@arcote.tech/arc": "workspace:*",
28
+ "@sqlite.org/sqlite-wasm": "^3.46.0-build1",
29
+ "typescript": "^5.0.0"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export {
2
+ SQLiteWasmAdapter,
3
+ SQLiteWasmDatabase,
4
+ createSQLiteWasmAdapterFactory,
5
+ } from "./sqlite-wasm-adapter";
6
+ export type { WorkerRequest, WorkerResponse } from "./types";
@@ -0,0 +1,744 @@
1
+ /**
2
+ * SQLite WASM Adapter
3
+ *
4
+ * Communicates with SQLite running in a Web Worker via postMessage.
5
+ * Implements the same interface as the server-side SQLite adapter.
6
+ */
7
+
8
+ import {
9
+ type ArcContextAny,
10
+ type DatabaseAdapter,
11
+ type DatabaseAgnosticColumnInfo,
12
+ type DatabaseStoreData,
13
+ type DatabaseStoreSchema,
14
+ type DBAdapterFactory,
15
+ type FindOptions,
16
+ type ReadTransaction,
17
+ type ReadWriteTransaction,
18
+ type StoreColumn,
19
+ type StoreTable,
20
+ type WhereCondition,
21
+ extractDatabaseAgnosticSchema,
22
+ } from "@arcote.tech/arc";
23
+ import type { WorkerRequest, WorkerResponse } from "./types";
24
+
25
+ /**
26
+ * Wrapper that communicates with the SQLite Worker
27
+ */
28
+ export class SQLiteWasmDatabase {
29
+ private requestId = 0;
30
+ private pendingRequests = new Map<
31
+ number,
32
+ { resolve: (value: any) => void; reject: (error: Error) => void }
33
+ >();
34
+ private initialized = false;
35
+ private initPromise: Promise<void> | null = null;
36
+
37
+ constructor(
38
+ private worker: Worker,
39
+ private dbName: string,
40
+ ) {
41
+ this.worker.onmessage = this.handleMessage.bind(this);
42
+ }
43
+
44
+ private handleMessage(event: MessageEvent<WorkerResponse>) {
45
+ const response = event.data;
46
+
47
+ switch (response.type) {
48
+ case "init-success":
49
+ this.initialized = true;
50
+ break;
51
+ case "init-error":
52
+ console.error("[SQLite WASM] Init error:", response.error);
53
+ break;
54
+ case "exec-success": {
55
+ const pending = this.pendingRequests.get(response.id);
56
+ if (pending) {
57
+ pending.resolve(response.result);
58
+ this.pendingRequests.delete(response.id);
59
+ }
60
+ break;
61
+ }
62
+ case "exec-error": {
63
+ const pending = this.pendingRequests.get(response.id);
64
+ if (pending) {
65
+ pending.reject(new Error(response.error));
66
+ this.pendingRequests.delete(response.id);
67
+ }
68
+ break;
69
+ }
70
+ case "execBatch-success": {
71
+ const pending = this.pendingRequests.get(response.id);
72
+ if (pending) {
73
+ pending.resolve(undefined);
74
+ this.pendingRequests.delete(response.id);
75
+ }
76
+ break;
77
+ }
78
+ case "execBatch-error": {
79
+ const pending = this.pendingRequests.get(response.id);
80
+ if (pending) {
81
+ pending.reject(new Error(response.error));
82
+ this.pendingRequests.delete(response.id);
83
+ }
84
+ break;
85
+ }
86
+ }
87
+ }
88
+
89
+ async init(): Promise<void> {
90
+ if (this.initialized) return;
91
+ if (this.initPromise) return this.initPromise;
92
+
93
+ this.initPromise = new Promise((resolve, reject) => {
94
+ const handler = (event: MessageEvent<WorkerResponse>) => {
95
+ if (event.data.type === "init-success") {
96
+ this.initialized = true;
97
+ resolve();
98
+ } else if (event.data.type === "init-error") {
99
+ reject(new Error(event.data.error));
100
+ }
101
+ };
102
+
103
+ this.worker.addEventListener("message", handler, { once: true });
104
+ this.sendRequest({ type: "init", dbName: this.dbName });
105
+ });
106
+
107
+ return this.initPromise;
108
+ }
109
+
110
+ private sendRequest(request: WorkerRequest) {
111
+ this.worker.postMessage(request);
112
+ }
113
+
114
+ async exec(sql: string, params?: any[]): Promise<any[]> {
115
+ await this.init();
116
+
117
+ const id = ++this.requestId;
118
+
119
+ return new Promise((resolve, reject) => {
120
+ this.pendingRequests.set(id, { resolve, reject });
121
+ this.sendRequest({ type: "exec", id, sql, params });
122
+ });
123
+ }
124
+
125
+ async execBatch(
126
+ queries: Array<{ sql: string; params?: any[] }>,
127
+ ): Promise<void> {
128
+ await this.init();
129
+
130
+ const id = ++this.requestId;
131
+
132
+ return new Promise((resolve, reject) => {
133
+ this.pendingRequests.set(id, { resolve, reject });
134
+ this.sendRequest({ type: "execBatch", id, queries });
135
+ });
136
+ }
137
+ }
138
+
139
+ class SQLiteWasmReadTransaction implements ReadTransaction {
140
+ constructor(
141
+ protected db: SQLiteWasmDatabase,
142
+ protected tables: Map<string, StoreTable>,
143
+ protected adapter?: SQLiteWasmAdapter,
144
+ ) {}
145
+
146
+ protected hasSoftDelete(tableName: string): boolean {
147
+ if (this.adapter) return this.adapter.hasSoftDelete(tableName);
148
+ const table = this.tables.get(tableName);
149
+ if (!table) return false;
150
+ return table.columns.some((col) => col.name === "deleted");
151
+ }
152
+
153
+ protected deserializeValue(value: any, column: StoreColumn): any {
154
+ if (value === null || value === undefined) return null;
155
+ switch (column.type.toLowerCase()) {
156
+ case "json":
157
+ if (typeof value === "string") {
158
+ try {
159
+ return JSON.parse(value);
160
+ } catch {
161
+ return value;
162
+ }
163
+ }
164
+ return value;
165
+ case "text":
166
+ if (
167
+ typeof value === "string" &&
168
+ (value.startsWith("{") || value.startsWith("["))
169
+ ) {
170
+ try {
171
+ const parsed = JSON.parse(value);
172
+ if (typeof parsed === "object" || Array.isArray(parsed))
173
+ return parsed;
174
+ } catch {}
175
+ }
176
+ return value;
177
+ case "datetime":
178
+ case "timestamp":
179
+ return new Date(value);
180
+ default:
181
+ return value;
182
+ }
183
+ }
184
+
185
+ protected deserializeRow(row: any, table: StoreTable): any {
186
+ const result: any = {};
187
+ for (const column of table.columns) {
188
+ result[column.name] = this.deserializeValue(row[column.name], column);
189
+ }
190
+ return result;
191
+ }
192
+
193
+ protected buildWhereClause(
194
+ where?: WhereCondition,
195
+ tableName?: string,
196
+ ): { sql: string; params: any[] } {
197
+ const conditions: string[] = [];
198
+ const params: any[] = [];
199
+
200
+ if (tableName && this.hasSoftDelete(tableName)) {
201
+ conditions.push('"deleted" = 0');
202
+ }
203
+
204
+ if (!where) {
205
+ return {
206
+ sql: conditions.length > 0 ? conditions.join(" AND ") : "1=1",
207
+ params,
208
+ };
209
+ }
210
+
211
+ Object.entries(where).forEach(([key, value]) => {
212
+ if (
213
+ typeof value === "object" &&
214
+ value !== null &&
215
+ !Array.isArray(value) &&
216
+ !(value instanceof Date)
217
+ ) {
218
+ // Check if it's a query operator object like { $eq: ... }
219
+ const operators = [
220
+ "$eq",
221
+ "$ne",
222
+ "$gt",
223
+ "$gte",
224
+ "$lt",
225
+ "$lte",
226
+ "$in",
227
+ "$nin",
228
+ "$exists",
229
+ ];
230
+ const isOperatorObject = Object.keys(value).some((k) =>
231
+ operators.includes(k),
232
+ );
233
+
234
+ if (isOperatorObject) {
235
+ Object.entries(value as Record<string, unknown>).forEach(
236
+ ([operator, operand]) => {
237
+ switch (operator) {
238
+ case "$eq":
239
+ case "$ne":
240
+ case "$gt":
241
+ case "$gte":
242
+ case "$lt":
243
+ case "$lte":
244
+ conditions.push(
245
+ `"${key}" ${this.getOperatorSymbol(operator)} ?`,
246
+ );
247
+ params.push(this.serializeWhereValue(operand));
248
+ break;
249
+ case "$in":
250
+ case "$nin":
251
+ if (Array.isArray(operand)) {
252
+ conditions.push(
253
+ `"${key}" ${operator === "$in" ? "IN" : "NOT IN"} (${operand.map(() => "?").join(", ")})`,
254
+ );
255
+ params.push(
256
+ ...operand.map((v) => this.serializeWhereValue(v)),
257
+ );
258
+ }
259
+ break;
260
+ case "$exists":
261
+ if (typeof operand === "boolean") {
262
+ conditions.push(
263
+ operand ? `"${key}" IS NOT NULL` : `"${key}" IS NULL`,
264
+ );
265
+ }
266
+ break;
267
+ }
268
+ },
269
+ );
270
+ } else {
271
+ // It's a regular object value, serialize it
272
+ conditions.push(`"${key}" = ?`);
273
+ params.push(this.serializeWhereValue(value));
274
+ }
275
+ } else {
276
+ conditions.push(`"${key}" = ?`);
277
+ params.push(this.serializeWhereValue(value));
278
+ }
279
+ });
280
+
281
+ return { sql: conditions.join(" AND "), params };
282
+ }
283
+
284
+ protected getOperatorSymbol(operator: string): string {
285
+ const operators: Record<string, string> = {
286
+ $eq: "=",
287
+ $ne: "!=",
288
+ $gt: ">",
289
+ $gte: ">=",
290
+ $lt: "<",
291
+ $lte: "<=",
292
+ };
293
+ return operators[operator] || "=";
294
+ }
295
+
296
+ /**
297
+ * Serialize a value for use in WHERE clause params
298
+ * Handles Date objects and other non-primitive types
299
+ */
300
+ protected serializeWhereValue(value: any): any {
301
+ if (value === null || value === undefined) return null;
302
+ if (value instanceof Date) return value.toISOString();
303
+ if (Array.isArray(value) || typeof value === "object") {
304
+ return JSON.stringify(value);
305
+ }
306
+ return value;
307
+ }
308
+
309
+ protected buildOrderByClause(
310
+ orderBy?: Record<string, "asc" | "desc">,
311
+ ): string {
312
+ if (!orderBy) return "";
313
+ const orderClauses = Object.entries(orderBy)
314
+ .map(([key, direction]) => `"${key}" ${direction.toUpperCase()}`)
315
+ .join(", ");
316
+ return orderClauses ? `ORDER BY ${orderClauses}` : "";
317
+ }
318
+
319
+ async find<T>(store: string, options: FindOptions<T>): Promise<T[]> {
320
+ const { where, limit, offset, orderBy } = options || {};
321
+ const whereClause = this.buildWhereClause(where, store);
322
+ const orderByClause = this.buildOrderByClause(orderBy);
323
+ const table = this.tables.get(store);
324
+
325
+ if (!table) throw new Error(`Store ${store} not found`);
326
+
327
+ const query = `SELECT * FROM "${table.name}" WHERE ${whereClause.sql} ${orderByClause} ${limit ? `LIMIT ${limit}` : ""} ${offset ? `OFFSET ${offset}` : ""}`;
328
+ const rows = await this.db.exec(query, whereClause.params);
329
+ return rows.map((row: any) => this.deserializeRow(row, table));
330
+ }
331
+ }
332
+
333
+ class SQLiteWasmReadWriteTransaction
334
+ extends SQLiteWasmReadTransaction
335
+ implements ReadWriteTransaction
336
+ {
337
+ private queries: Array<{ sql: string; params?: any[] }> = [];
338
+
339
+ constructor(
340
+ db: SQLiteWasmDatabase,
341
+ tables: Map<string, StoreTable>,
342
+ protected adapter: SQLiteWasmAdapter,
343
+ ) {
344
+ super(db, tables, adapter);
345
+ }
346
+
347
+ async remove(store: string, id: any) {
348
+ const table = this.tables.get(store);
349
+ if (!table) throw new Error(`Store ${store} not found`);
350
+
351
+ // Check if table has soft delete enabled
352
+ const hasSoftDelete = this.adapter.hasSoftDelete(store);
353
+
354
+ if (hasSoftDelete) {
355
+ // Soft delete: set deleted flag
356
+ this.queries.push({
357
+ sql: `UPDATE "${table.name}" SET "deleted" = 1, "lastUpdate" = ? WHERE "${table.primaryKey}" = ?`,
358
+ params: [new Date().toISOString(), id],
359
+ });
360
+ } else {
361
+ // Hard delete: actually remove the row
362
+ this.queries.push({
363
+ sql: `DELETE FROM "${table.name}" WHERE "${table.primaryKey}" = ?`,
364
+ params: [id],
365
+ });
366
+ }
367
+ }
368
+
369
+ async set(store: string, item: any) {
370
+ const table = this.tables.get(store);
371
+ if (!table) throw new Error(`Store ${store} not found`);
372
+
373
+ if (this.adapter.hasVersioning(store)) {
374
+ await this.setWithVersioning(store, item, table);
375
+ } else {
376
+ await this.setWithoutVersioning(store, item, table);
377
+ }
378
+ }
379
+
380
+ private async setWithoutVersioning(
381
+ _store: string,
382
+ item: any,
383
+ table: StoreTable,
384
+ ) {
385
+ const columnNames = table.columns.map((col) => col.name);
386
+ const values = table.columns.map((column) => {
387
+ let value = item[column.name];
388
+ if (value === undefined && column.default !== undefined)
389
+ value = column.default;
390
+ return this.serializeValue(value, column);
391
+ });
392
+
393
+ const placeholders = columnNames.map(() => "?").join(", ");
394
+ this.queries.push({
395
+ sql: `INSERT OR REPLACE INTO "${table.name}" (${columnNames.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`,
396
+ params: values,
397
+ });
398
+ }
399
+
400
+ private async setWithVersioning(store: string, item: any, table: StoreTable) {
401
+ const regularColumns = table.columns.filter(
402
+ (col) => col.name !== "__version",
403
+ );
404
+ const columnNames = regularColumns.map((col) => col.name);
405
+ const values = regularColumns.map((column) => {
406
+ let value = item[column.name];
407
+ if (value === undefined && column.default !== undefined)
408
+ value = column.default;
409
+ return this.serializeValue(value, column);
410
+ });
411
+
412
+ columnNames.push("__version");
413
+ const placeholders = regularColumns.map(() => "?").join(", ");
414
+
415
+ this.queries.push({
416
+ sql: `
417
+ WITH next_version AS (
418
+ INSERT INTO __arc_version_counters (table_name, last_version) VALUES (?, 1)
419
+ ON CONFLICT(table_name) DO UPDATE SET last_version = last_version + 1
420
+ RETURNING last_version
421
+ )
422
+ INSERT OR REPLACE INTO "${table.name}"
423
+ (${columnNames.map((c) => `"${c}"`).join(", ")})
424
+ VALUES (${placeholders}, (SELECT last_version FROM next_version))
425
+ `,
426
+ params: [...values, store],
427
+ });
428
+ }
429
+
430
+ async commit() {
431
+ if (this.queries.length === 0) return;
432
+ try {
433
+ await this.db.execBatch(this.queries);
434
+ this.queries = [];
435
+ } catch (error) {
436
+ this.queries = [];
437
+ throw error;
438
+ }
439
+ }
440
+
441
+ private serializeValue(value: any, column: StoreColumn): any {
442
+ if (value === null || value === undefined) return null;
443
+
444
+ // Handle empty objects as null (common issue with JSON parsing)
445
+ if (
446
+ typeof value === "object" &&
447
+ !Array.isArray(value) &&
448
+ !(value instanceof Date) &&
449
+ Object.keys(value).length === 0
450
+ ) {
451
+ return null;
452
+ }
453
+
454
+ switch (column.type.toLowerCase()) {
455
+ case "timestamp":
456
+ case "datetime":
457
+ if (value instanceof Date) return value.toISOString();
458
+ if (typeof value === "number")
459
+ return (
460
+ value > 1e10 ? new Date(value) : new Date(value * 1000)
461
+ ).toISOString();
462
+ if (typeof value === "string") {
463
+ const date = new Date(value);
464
+ if (!isNaN(date.getTime())) return date.toISOString();
465
+ }
466
+ // If it's an object that's not a Date, convert to null or stringify
467
+ if (typeof value === "object") {
468
+ console.warn(
469
+ `[SQLite WASM] Unexpected object value for timestamp column "${column.name}":`,
470
+ value,
471
+ );
472
+ return null;
473
+ }
474
+ return value;
475
+ case "json":
476
+ return JSON.stringify(value);
477
+ default:
478
+ if (value instanceof Date) return value.toISOString();
479
+ if (Array.isArray(value) || typeof value === "object")
480
+ return JSON.stringify(value);
481
+ return value;
482
+ }
483
+ }
484
+ }
485
+
486
+ export class SQLiteWasmAdapter implements DatabaseAdapter {
487
+ private tables: Map<string, StoreTable> = new Map();
488
+ private pendingReinitTables: Array<{
489
+ tableName: string;
490
+ reinitFn: (tableName: string, dataStorage: any) => Promise<void>;
491
+ }> = [];
492
+
493
+ private mapType(arcType: string, storeData?: DatabaseStoreData): string {
494
+ if (storeData?.databaseType?.sqlite) return storeData.databaseType.sqlite;
495
+ switch (arcType) {
496
+ case "string":
497
+ case "id":
498
+ case "customId":
499
+ case "stringEnum":
500
+ return "TEXT";
501
+ case "number":
502
+ return "INTEGER";
503
+ case "boolean":
504
+ return "INTEGER";
505
+ case "date":
506
+ return "TIMESTAMP";
507
+ case "object":
508
+ case "array":
509
+ case "record":
510
+ return "JSON";
511
+ case "blob":
512
+ return "BLOB";
513
+ default:
514
+ return "TEXT";
515
+ }
516
+ }
517
+
518
+ private buildConstraints(storeData?: DatabaseStoreData): string[] {
519
+ const constraints: string[] = [];
520
+ if (storeData?.isPrimaryKey) constraints.push("PRIMARY KEY");
521
+ if (storeData?.isAutoIncrement) constraints.push("AUTOINCREMENT");
522
+ if (storeData?.isUnique) constraints.push("UNIQUE");
523
+ if (storeData?.foreignKey) {
524
+ const { table, column, onDelete, onUpdate } = storeData.foreignKey;
525
+ let fk = `REFERENCES ${table}(${column})`;
526
+ if (onDelete) fk += ` ON DELETE ${onDelete}`;
527
+ if (onUpdate) fk += ` ON UPDATE ${onUpdate}`;
528
+ constraints.push(fk);
529
+ }
530
+ return constraints;
531
+ }
532
+
533
+ private generateColumnSQL(
534
+ columnInfo: DatabaseAgnosticColumnInfo & { name: string },
535
+ ): string {
536
+ const type = this.mapType(columnInfo.type, columnInfo.storeData);
537
+ const constraints: string[] = [];
538
+ if (!columnInfo.storeData?.isNullable) constraints.push("NOT NULL");
539
+ constraints.push(...this.buildConstraints(columnInfo.storeData));
540
+ if (columnInfo.defaultValue !== undefined) {
541
+ if (typeof columnInfo.defaultValue === "string")
542
+ constraints.push(`DEFAULT '${columnInfo.defaultValue}'`);
543
+ else constraints.push(`DEFAULT ${columnInfo.defaultValue}`);
544
+ }
545
+ return `"${columnInfo.name}" ${type} ${constraints.join(" ")}`.trim();
546
+ }
547
+
548
+ private generateCreateTableSQL(
549
+ tableName: string,
550
+ columns: (DatabaseAgnosticColumnInfo & { name: string })[],
551
+ ): string {
552
+ const columnDefinitions = columns.map((col) => this.generateColumnSQL(col));
553
+ const indexes = columns
554
+ .filter((col) => col.storeData?.hasIndex && !col.storeData?.isPrimaryKey)
555
+ .map(
556
+ (col) =>
557
+ `CREATE INDEX IF NOT EXISTS idx_${tableName}_${col.name} ON "${tableName}"("${col.name}");`,
558
+ );
559
+ let sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (\n ${columnDefinitions.join(",\n ")}\n)`;
560
+ if (indexes.length > 0) sql += ";\n" + indexes.join("\n");
561
+ return sql;
562
+ }
563
+
564
+ constructor(
565
+ private db: SQLiteWasmDatabase,
566
+ private context: ArcContextAny,
567
+ ) {
568
+ this.context.elements.forEach((element) => {
569
+ if (
570
+ "databaseStoreSchema" in element &&
571
+ typeof element.databaseStoreSchema === "function"
572
+ ) {
573
+ const databaseSchema =
574
+ element.databaseStoreSchema() as DatabaseStoreSchema;
575
+ databaseSchema.tables.forEach((dbTable) => {
576
+ const agnosticSchema = extractDatabaseAgnosticSchema(
577
+ dbTable.schema,
578
+ dbTable.name,
579
+ );
580
+ const columns = agnosticSchema.columns.map((columnInfo) => ({
581
+ name: columnInfo.name,
582
+ type: this.mapType(columnInfo.type, columnInfo.storeData),
583
+ constraints: this.buildConstraints(columnInfo.storeData),
584
+ isNullable: columnInfo.storeData?.isNullable || false,
585
+ defaultValue: columnInfo.defaultValue,
586
+ isPrimaryKey: columnInfo.storeData?.isPrimaryKey || false,
587
+ }));
588
+ const legacyTable: StoreTable = {
589
+ name: dbTable.name,
590
+ primaryKey: columns.find((col) => col.isPrimaryKey)?.name || "_id",
591
+ columns: columns.map((col) => ({
592
+ name: col.name,
593
+ type: col.type,
594
+ isOptional: col.isNullable,
595
+ default: col.defaultValue,
596
+ })),
597
+ };
598
+ this.tables.set(dbTable.name, legacyTable);
599
+ });
600
+ }
601
+ });
602
+ }
603
+
604
+ public async initialize() {
605
+ await this.db.init();
606
+ await this.createVersionCounterTable();
607
+ await this.createTableVersionsTable();
608
+
609
+ const processedSchemas = new Set<DatabaseStoreSchema>();
610
+ const processedTables = new Set<string>();
611
+
612
+ for (const element of this.context.elements) {
613
+ if (
614
+ "databaseStoreSchema" in element &&
615
+ typeof element.databaseStoreSchema === "function"
616
+ ) {
617
+ const databaseSchema =
618
+ element.databaseStoreSchema() as DatabaseStoreSchema;
619
+
620
+ if (processedSchemas.has(databaseSchema)) continue;
621
+ processedSchemas.add(databaseSchema);
622
+
623
+ for (const dbTable of databaseSchema.tables) {
624
+ const tableKey = dbTable.version
625
+ ? `${dbTable.name}_v${dbTable.version}`
626
+ : dbTable.name;
627
+ if (!processedTables.has(tableKey)) {
628
+ const agnosticSchema = extractDatabaseAgnosticSchema(
629
+ dbTable.schema,
630
+ dbTable.name,
631
+ );
632
+ let allColumns = [...agnosticSchema.columns];
633
+ if (dbTable.options?.versioning)
634
+ allColumns.push({
635
+ name: "__version",
636
+ type: "number",
637
+ storeData: { isNullable: false, hasIndex: true },
638
+ defaultValue: 1,
639
+ });
640
+ if (dbTable.options?.softDelete)
641
+ allColumns.push({
642
+ name: "deleted",
643
+ type: "boolean",
644
+ storeData: { isNullable: false, hasIndex: true },
645
+ defaultValue: false,
646
+ });
647
+
648
+ const physicalTableName = this.getPhysicalTableName(
649
+ dbTable.name,
650
+ dbTable.version,
651
+ );
652
+ await this.createTableIfNotExistsNew(physicalTableName, allColumns);
653
+
654
+ // Store reinit function for later execution (only for versioned tables)
655
+ // reinitTable rebuilds views from events when schema version changes
656
+ if (dbTable.version && databaseSchema.reinitTable) {
657
+ this.pendingReinitTables.push({
658
+ tableName: physicalTableName,
659
+ reinitFn: databaseSchema.reinitTable,
660
+ });
661
+ }
662
+
663
+ const legacyTable: StoreTable = {
664
+ name: physicalTableName,
665
+ primaryKey:
666
+ allColumns.find((col) => col.storeData?.isPrimaryKey)?.name ||
667
+ "_id",
668
+ columns: allColumns.map((col) => ({
669
+ name: col.name,
670
+ type: this.mapType(col.type, col.storeData),
671
+ isOptional: col.storeData?.isNullable || false,
672
+ default: col.defaultValue,
673
+ })),
674
+ };
675
+ this.tables.set(dbTable.name, legacyTable);
676
+ processedTables.add(tableKey);
677
+ }
678
+ }
679
+ }
680
+ }
681
+ }
682
+
683
+ private async createTableIfNotExistsNew(
684
+ tableName: string,
685
+ columns: (DatabaseAgnosticColumnInfo & { name: string })[],
686
+ ) {
687
+ await this.db.exec(this.generateCreateTableSQL(tableName, columns));
688
+ }
689
+
690
+ private async createVersionCounterTable() {
691
+ await this.db.exec(
692
+ `CREATE TABLE IF NOT EXISTS __arc_version_counters (table_name TEXT PRIMARY KEY, last_version INTEGER NOT NULL DEFAULT 0)`,
693
+ );
694
+ }
695
+
696
+ private async createTableVersionsTable() {
697
+ await this.db.exec(
698
+ `CREATE TABLE IF NOT EXISTS __arc_table_versions (table_name TEXT NOT NULL, version INTEGER NOT NULL, physical_table_name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_active INTEGER DEFAULT 0, PRIMARY KEY (table_name, version))`,
699
+ );
700
+ }
701
+
702
+ private getPhysicalTableName(logicalName: string, version?: number): string {
703
+ return version ? `${logicalName}_v${version}` : logicalName;
704
+ }
705
+
706
+ public hasVersioning(tableName: string): boolean {
707
+ const table = this.tables.get(tableName);
708
+ return table
709
+ ? table.columns.some((col) => col.name === "__version")
710
+ : false;
711
+ }
712
+
713
+ public hasSoftDelete(tableName: string): boolean {
714
+ const table = this.tables.get(tableName);
715
+ return table ? table.columns.some((col) => col.name === "deleted") : false;
716
+ }
717
+
718
+ public async executeReinitTables(dataStorage: any): Promise<void> {
719
+ for (const { tableName, reinitFn } of this.pendingReinitTables) {
720
+ await reinitFn(tableName, dataStorage);
721
+ }
722
+ this.pendingReinitTables = [];
723
+ }
724
+
725
+ readWriteTransaction(_stores?: string[]) {
726
+ return new SQLiteWasmReadWriteTransaction(this.db, this.tables, this);
727
+ }
728
+ readTransaction(_stores?: string[]) {
729
+ return new SQLiteWasmReadTransaction(this.db, this.tables, this);
730
+ }
731
+ }
732
+
733
+ export const createSQLiteWasmAdapterFactory = (
734
+ worker: Worker,
735
+ dbName: string,
736
+ ): DBAdapterFactory => {
737
+ const db = new SQLiteWasmDatabase(worker, dbName);
738
+
739
+ return async (context: ArcContextAny): Promise<DatabaseAdapter> => {
740
+ const adapter = new SQLiteWasmAdapter(db, context);
741
+ await adapter.initialize();
742
+ return adapter;
743
+ };
744
+ };
package/src/types.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Message types for Worker <-> Main thread communication
3
+ */
4
+
5
+ export type WorkerRequest =
6
+ | { type: "init"; dbName: string }
7
+ | { type: "exec"; id: number; sql: string; params?: any[] }
8
+ | {
9
+ type: "execBatch";
10
+ id: number;
11
+ queries: Array<{ sql: string; params?: any[] }>;
12
+ };
13
+
14
+ export type WorkerResponse =
15
+ | { type: "init-success" }
16
+ | { type: "init-error"; error: string }
17
+ | { type: "exec-success"; id: number; result: any[] }
18
+ | { type: "exec-error"; id: number; error: string }
19
+ | { type: "execBatch-success"; id: number }
20
+ | { type: "execBatch-error"; id: number; error: string };
package/src/worker.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * SQLite WASM Worker
3
+ *
4
+ * This worker runs SQLite in a Web Worker using OPFS for persistence.
5
+ * Communication happens via postMessage.
6
+ *
7
+ * Usage in app:
8
+ * ```typescript
9
+ * import SQLiteWorker from '@arcote.tech/arc-adapter-db-sqlite-wasm/worker?worker'
10
+ * const worker = new SQLiteWorker()
11
+ * ```
12
+ */
13
+
14
+ import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
15
+ import type { WorkerRequest, WorkerResponse } from "./types";
16
+
17
+ let db: any = null;
18
+
19
+ const sendResponse = (response: WorkerResponse) => {
20
+ self.postMessage(response);
21
+ };
22
+
23
+ const handleInit = async (dbName: string) => {
24
+ try {
25
+ const sqlite3 = await sqlite3InitModule({
26
+ print: console.log,
27
+ printErr: console.error,
28
+ });
29
+
30
+ console.log(
31
+ `[SQLite Worker] Running SQLite3 version ${sqlite3.version.libVersion}`,
32
+ );
33
+
34
+ // Use OPFS for persistent storage
35
+ db = new sqlite3.oo1.DB(`file:${dbName}?vfs=opfs`, "ct");
36
+
37
+ sendResponse({ type: "init-success" });
38
+ } catch (error) {
39
+ sendResponse({ type: "init-error", error: (error as Error).message });
40
+ }
41
+ };
42
+
43
+ const handleExec = (id: number, sql: string, params?: any[]) => {
44
+ try {
45
+ if (!db) {
46
+ throw new Error("Database not initialized");
47
+ }
48
+
49
+ let result: any[];
50
+
51
+ if (params && params.length > 0) {
52
+ // Pass params as array directly - SQLite WASM handles ? placeholders with arrays
53
+ result = db.exec({
54
+ sql,
55
+ bind: params,
56
+ rowMode: "object",
57
+ returnValue: "resultRows",
58
+ });
59
+ } else {
60
+ result = db.exec({
61
+ sql,
62
+ rowMode: "object",
63
+ returnValue: "resultRows",
64
+ });
65
+ }
66
+
67
+ sendResponse({ type: "exec-success", id, result: result || [] });
68
+ } catch (error) {
69
+ sendResponse({ type: "exec-error", id, error: (error as Error).message });
70
+ }
71
+ };
72
+
73
+ const handleExecBatch = (
74
+ id: number,
75
+ queries: Array<{ sql: string; params?: any[] }>,
76
+ ) => {
77
+ try {
78
+ if (!db) {
79
+ throw new Error("Database not initialized");
80
+ }
81
+
82
+ // Execute all queries in a transaction
83
+ db.exec("BEGIN TRANSACTION");
84
+
85
+ try {
86
+ for (const query of queries) {
87
+ if (query.params && query.params.length > 0) {
88
+ // Pass params as array directly
89
+ db.exec({ sql: query.sql, bind: query.params });
90
+ } else {
91
+ db.exec(query.sql);
92
+ }
93
+ }
94
+
95
+ db.exec("COMMIT");
96
+ sendResponse({ type: "execBatch-success", id });
97
+ } catch (error) {
98
+ db.exec("ROLLBACK");
99
+ throw error;
100
+ }
101
+ } catch (error) {
102
+ sendResponse({
103
+ type: "execBatch-error",
104
+ id,
105
+ error: (error as Error).message,
106
+ });
107
+ }
108
+ };
109
+
110
+ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
111
+ const request = event.data;
112
+
113
+ switch (request.type) {
114
+ case "init":
115
+ handleInit(request.dbName);
116
+ break;
117
+ case "exec":
118
+ handleExec(request.id, request.sql, request.params);
119
+ break;
120
+ case "execBatch":
121
+ handleExecBatch(request.id, request.queries);
122
+ break;
123
+ }
124
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"]
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["node_modules", "dist"]
12
+ }