@livestore/sqlite-wasm 0.4.0-dev.2 → 0.4.0-dev.20

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 (97) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/FacadeVFS.d.ts.map +1 -1
  3. package/dist/FacadeVFS.js +4 -0
  4. package/dist/FacadeVFS.js.map +1 -1
  5. package/dist/browser/mod.d.ts +15 -7
  6. package/dist/browser/mod.d.ts.map +1 -1
  7. package/dist/browser/mod.js +49 -43
  8. package/dist/browser/mod.js.map +1 -1
  9. package/dist/browser/opfs/AccessHandlePoolVFS.d.ts +40 -17
  10. package/dist/browser/opfs/AccessHandlePoolVFS.d.ts.map +1 -1
  11. package/dist/browser/opfs/AccessHandlePoolVFS.js +211 -143
  12. package/dist/browser/opfs/AccessHandlePoolVFS.js.map +1 -1
  13. package/dist/browser/opfs/index.d.ts +3 -2
  14. package/dist/browser/opfs/index.d.ts.map +1 -1
  15. package/dist/browser/opfs/index.js +1 -1
  16. package/dist/browser/opfs/index.js.map +1 -1
  17. package/dist/browser/opfs/opfs-sah-pool.d.ts +1 -1
  18. package/dist/browser/opfs/opfs-sah-pool.d.ts.map +1 -1
  19. package/dist/browser/opfs/opfs-sah-pool.js +1 -1
  20. package/dist/browser/opfs/opfs-sah-pool.js.map +1 -1
  21. package/dist/cf/BlockManager.d.ts +61 -0
  22. package/dist/cf/BlockManager.d.ts.map +1 -0
  23. package/dist/cf/BlockManager.js +157 -0
  24. package/dist/cf/BlockManager.js.map +1 -0
  25. package/dist/cf/CloudflareSqlVFS.d.ts +51 -0
  26. package/dist/cf/CloudflareSqlVFS.d.ts.map +1 -0
  27. package/dist/cf/CloudflareSqlVFS.js +351 -0
  28. package/dist/cf/CloudflareSqlVFS.js.map +1 -0
  29. package/dist/cf/CloudflareWorkerVFS.d.ts +72 -0
  30. package/dist/cf/CloudflareWorkerVFS.d.ts.map +1 -0
  31. package/dist/cf/CloudflareWorkerVFS.js +552 -0
  32. package/dist/cf/CloudflareWorkerVFS.js.map +1 -0
  33. package/dist/cf/mod.d.ts +43 -0
  34. package/dist/cf/mod.d.ts.map +1 -0
  35. package/dist/cf/mod.js +74 -0
  36. package/dist/cf/mod.js.map +1 -0
  37. package/dist/cf/test/async-storage/cloudflare-worker-vfs-advanced.test.d.ts +2 -0
  38. package/dist/cf/test/async-storage/cloudflare-worker-vfs-advanced.test.d.ts.map +1 -0
  39. package/dist/cf/test/async-storage/cloudflare-worker-vfs-advanced.test.js +314 -0
  40. package/dist/cf/test/async-storage/cloudflare-worker-vfs-advanced.test.js.map +1 -0
  41. package/dist/cf/test/async-storage/cloudflare-worker-vfs-core.test.d.ts +2 -0
  42. package/dist/cf/test/async-storage/cloudflare-worker-vfs-core.test.d.ts.map +1 -0
  43. package/dist/cf/test/async-storage/cloudflare-worker-vfs-core.test.js +266 -0
  44. package/dist/cf/test/async-storage/cloudflare-worker-vfs-core.test.js.map +1 -0
  45. package/dist/cf/test/async-storage/cloudflare-worker-vfs-integration.test.d.ts +2 -0
  46. package/dist/cf/test/async-storage/cloudflare-worker-vfs-integration.test.d.ts.map +1 -0
  47. package/dist/cf/test/async-storage/cloudflare-worker-vfs-integration.test.js +462 -0
  48. package/dist/cf/test/async-storage/cloudflare-worker-vfs-integration.test.js.map +1 -0
  49. package/dist/cf/test/async-storage/cloudflare-worker-vfs-reliability.test.d.ts +2 -0
  50. package/dist/cf/test/async-storage/cloudflare-worker-vfs-reliability.test.d.ts.map +1 -0
  51. package/dist/cf/test/async-storage/cloudflare-worker-vfs-reliability.test.js +334 -0
  52. package/dist/cf/test/async-storage/cloudflare-worker-vfs-reliability.test.js.map +1 -0
  53. package/dist/cf/test/sql/cloudflare-sql-vfs-core.test.d.ts +2 -0
  54. package/dist/cf/test/sql/cloudflare-sql-vfs-core.test.d.ts.map +1 -0
  55. package/dist/cf/test/sql/cloudflare-sql-vfs-core.test.js +354 -0
  56. package/dist/cf/test/sql/cloudflare-sql-vfs-core.test.js.map +1 -0
  57. package/dist/load-wasm/mod.node.d.ts.map +1 -1
  58. package/dist/load-wasm/mod.node.js +1 -2
  59. package/dist/load-wasm/mod.node.js.map +1 -1
  60. package/dist/load-wasm/mod.workerd.d.ts +2 -0
  61. package/dist/load-wasm/mod.workerd.d.ts.map +1 -0
  62. package/dist/load-wasm/mod.workerd.js +28 -0
  63. package/dist/load-wasm/mod.workerd.js.map +1 -0
  64. package/dist/make-sqlite-db.d.ts +1 -0
  65. package/dist/make-sqlite-db.d.ts.map +1 -1
  66. package/dist/make-sqlite-db.js +29 -8
  67. package/dist/make-sqlite-db.js.map +1 -1
  68. package/dist/node/NodeFS.d.ts +1 -2
  69. package/dist/node/NodeFS.d.ts.map +1 -1
  70. package/dist/node/NodeFS.js +1 -6
  71. package/dist/node/NodeFS.js.map +1 -1
  72. package/dist/node/mod.d.ts.map +1 -1
  73. package/dist/node/mod.js +5 -10
  74. package/dist/node/mod.js.map +1 -1
  75. package/package.json +21 -8
  76. package/src/FacadeVFS.ts +5 -0
  77. package/src/browser/mod.ts +39 -13
  78. package/src/browser/opfs/AccessHandlePoolVFS.ts +387 -225
  79. package/src/browser/opfs/index.ts +4 -3
  80. package/src/browser/opfs/opfs-sah-pool.ts +1 -1
  81. package/src/cf/BlockManager.ts +225 -0
  82. package/src/cf/CloudflareSqlVFS.ts +450 -0
  83. package/src/cf/CloudflareWorkerVFS.ts +664 -0
  84. package/src/cf/README.md +60 -0
  85. package/src/cf/mod.ts +143 -0
  86. package/src/cf/test/README.md +224 -0
  87. package/src/cf/test/async-storage/cloudflare-worker-vfs-advanced.test.ts +389 -0
  88. package/src/cf/test/async-storage/cloudflare-worker-vfs-core.test.ts +322 -0
  89. package/src/cf/test/async-storage/cloudflare-worker-vfs-integration.test.ts +585 -0
  90. package/src/cf/test/async-storage/cloudflare-worker-vfs-reliability.test.ts +403 -0
  91. package/src/cf/test/sql/cloudflare-sql-vfs-core.test.ts +433 -0
  92. package/src/load-wasm/mod.node.ts +1 -2
  93. package/src/load-wasm/mod.workerd.ts +28 -0
  94. package/src/make-sqlite-db.ts +39 -8
  95. package/src/node/NodeFS.ts +1 -9
  96. package/src/node/mod.ts +5 -12
  97. package/src/ambient.d.ts +0 -18
@@ -1,4 +1,5 @@
1
- import { Effect } from '@livestore/utils/effect'
1
+ import { Effect, type Scope } from '@livestore/utils/effect'
2
+ import type { Opfs } from '@livestore/utils/effect/browser'
2
3
  import type * as WaSqlite from '@livestore/wa-sqlite'
3
4
 
4
5
  import { AccessHandlePoolVFS } from './AccessHandlePoolVFS.ts'
@@ -14,7 +15,7 @@ export const makeOpfsDb = ({
14
15
  sqlite3: WaSqlite.SQLiteAPI
15
16
  directory: string
16
17
  fileName: string
17
- }) =>
18
+ }): Effect.Effect<{ dbPointer: number; vfs: AccessHandlePoolVFS }, never, Opfs.Opfs | Scope.Scope> =>
18
19
  Effect.gen(function* () {
19
20
  // Replace all special characters with underscores
20
21
  const safePath = directory.replaceAll(/["*/:<>?\\|]/g, '_')
@@ -22,7 +23,7 @@ export const makeOpfsDb = ({
22
23
  const vfsName = `opfs${pathSegment}`
23
24
 
24
25
  if (sqlite3.vfs_registered.has(vfsName) === false) {
25
- const vfs = yield* Effect.promise(() => AccessHandlePoolVFS.create(vfsName, directory, (sqlite3 as any).module))
26
+ const vfs = yield* AccessHandlePoolVFS.create(vfsName, directory, (sqlite3 as any).module)
26
27
 
27
28
  sqlite3.vfs_register(vfs, false)
28
29
  opfsVfsMap.set(vfsName, vfs)
@@ -14,7 +14,7 @@ const PERSISTENT_FILE_TYPES =
14
14
 
15
15
  const textDecoder = new TextDecoder()
16
16
 
17
- export const decodeSAHPoolFilename = async (file: File): Promise<string> => {
17
+ export const decodeAccessHandlePoolFilename = async (file: File): Promise<string> => {
18
18
  // Read the path and digest of the path from the file.
19
19
  const corpus = new Uint8Array(await file.slice(0, HEADER_CORPUS_SIZE).arrayBuffer())
20
20
 
@@ -0,0 +1,225 @@
1
+ import type { CfTypes } from '@livestore/common-cf'
2
+
3
+ export interface BlockRange {
4
+ startBlock: number
5
+ endBlock: number
6
+ startOffset: number
7
+ endOffset: number
8
+ }
9
+
10
+ export interface BlockData {
11
+ blockId: number
12
+ data: Uint8Array
13
+ }
14
+
15
+ /**
16
+ * BlockManager handles the conversion between file operations and block-based storage
17
+ * for the CloudflareSqlVFS. It manages fixed-size blocks stored in SQL tables.
18
+ */
19
+ export class BlockManager {
20
+ private readonly blockSize: number
21
+
22
+ constructor(blockSize: number = 64 * 1024) {
23
+ this.blockSize = blockSize
24
+ }
25
+
26
+ /**
27
+ * Calculate which blocks are needed for a given file operation
28
+ */
29
+ calculateBlockRange(offset: number, length: number): BlockRange {
30
+ const startBlock = Math.floor(offset / this.blockSize)
31
+ const endBlock = Math.floor((offset + length - 1) / this.blockSize)
32
+ const startOffset = offset % this.blockSize
33
+ const endOffset = ((offset + length - 1) % this.blockSize) + 1
34
+
35
+ return {
36
+ startBlock,
37
+ endBlock,
38
+ startOffset,
39
+ endOffset,
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Read blocks from SQL storage and return as a Map
45
+ */
46
+ readBlocks(sql: CfTypes.SqlStorage, filePath: string, blockIds: number[]): Map<number, Uint8Array> {
47
+ const blocks = new Map<number, Uint8Array>()
48
+
49
+ if (blockIds.length === 0) {
50
+ return blocks
51
+ }
52
+
53
+ // Build IN clause for efficient querying
54
+ const placeholders = blockIds.map(() => '?').join(',')
55
+ const query = `
56
+ SELECT block_id, block_data
57
+ FROM vfs_blocks
58
+ WHERE file_path = ? AND block_id IN (${placeholders})
59
+ ORDER BY block_id
60
+ `
61
+
62
+ const cursor = sql.exec<{ block_id: number; block_data: ArrayBuffer }>(query, filePath, ...blockIds)
63
+
64
+ for (const row of cursor) {
65
+ blocks.set(row.block_id, new Uint8Array(row.block_data))
66
+ }
67
+
68
+ return blocks
69
+ }
70
+
71
+ /**
72
+ * Write blocks to SQL storage using exec for now (prepared statements later)
73
+ */
74
+ writeBlocks(sql: CfTypes.SqlStorage, filePath: string, blocks: Map<number, Uint8Array>): void {
75
+ if (blocks.size === 0) {
76
+ return
77
+ }
78
+
79
+ for (const [blockId, data] of blocks) {
80
+ sql.exec(
81
+ 'INSERT OR REPLACE INTO vfs_blocks (file_path, block_id, block_data) VALUES (?, ?, ?)',
82
+ filePath,
83
+ blockId,
84
+ data,
85
+ )
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Delete blocks at or after the specified block ID (used for truncation)
91
+ */
92
+ deleteBlocksAfter(sql: CfTypes.SqlStorage, filePath: string, startBlockId: number): void {
93
+ sql.exec('DELETE FROM vfs_blocks WHERE file_path = ? AND block_id >= ?', filePath, startBlockId)
94
+ }
95
+
96
+ /**
97
+ * Split write data into blocks, handling partial blocks at boundaries
98
+ */
99
+ splitIntoBlocks(
100
+ data: Uint8Array,
101
+ offset: number,
102
+ ): Map<number, { blockId: number; blockOffset: number; data: Uint8Array }> {
103
+ const blocks = new Map<number, { blockId: number; blockOffset: number; data: Uint8Array }>()
104
+
105
+ let remainingData = data
106
+ let currentOffset = offset
107
+
108
+ while (remainingData.length > 0) {
109
+ const blockId = Math.floor(currentOffset / this.blockSize)
110
+ const blockOffset = currentOffset % this.blockSize
111
+ const bytesToWrite = Math.min(remainingData.length, this.blockSize - blockOffset)
112
+
113
+ const blockData = remainingData.slice(0, bytesToWrite)
114
+ blocks.set(blockId, {
115
+ blockId,
116
+ blockOffset,
117
+ data: blockData,
118
+ })
119
+
120
+ remainingData = remainingData.slice(bytesToWrite)
121
+ currentOffset += bytesToWrite
122
+ }
123
+
124
+ return blocks
125
+ }
126
+
127
+ /**
128
+ * Assemble read data from blocks into a continuous buffer
129
+ */
130
+ assembleBlocks(blocks: Map<number, Uint8Array>, range: BlockRange, requestedLength: number): Uint8Array {
131
+ const result = new Uint8Array(requestedLength)
132
+ let resultOffset = 0
133
+
134
+ for (let blockId = range.startBlock; blockId <= range.endBlock; blockId++) {
135
+ const blockData = blocks.get(blockId)
136
+ if (!blockData) {
137
+ // Block not found - fill with zeros (sparse file behavior)
138
+ const zeroLength = Math.min(this.blockSize, requestedLength - resultOffset)
139
+ // result is already zero-filled by default
140
+ resultOffset += zeroLength
141
+ continue
142
+ }
143
+
144
+ // Calculate the slice of this block we need
145
+ const blockStartOffset = blockId === range.startBlock ? range.startOffset : 0
146
+ const blockEndOffset = blockId === range.endBlock ? range.endOffset : blockData.length
147
+ const sliceLength = blockEndOffset - blockStartOffset
148
+
149
+ if (sliceLength > 0) {
150
+ const slice = blockData.slice(blockStartOffset, blockEndOffset)
151
+ result.set(slice, resultOffset)
152
+ resultOffset += sliceLength
153
+ }
154
+ }
155
+
156
+ return result
157
+ }
158
+
159
+ /**
160
+ * Handle partial block writes by reading existing block, modifying, and returning complete block
161
+ */
162
+ mergePartialBlock(
163
+ sql: CfTypes.SqlStorage,
164
+ filePath: string,
165
+ blockId: number,
166
+ blockOffset: number,
167
+ newData: Uint8Array,
168
+ ): Uint8Array {
169
+ // Read existing block data if it exists
170
+ const existingBlocks = this.readBlocks(sql, filePath, [blockId])
171
+ const existingBlock = existingBlocks.get(blockId) || new Uint8Array(this.blockSize)
172
+
173
+ // Create a new block with the merged data
174
+ const mergedBlock = new Uint8Array(this.blockSize)
175
+ mergedBlock.set(existingBlock)
176
+ mergedBlock.set(newData, blockOffset)
177
+
178
+ return mergedBlock
179
+ }
180
+
181
+ /**
182
+ * Get statistics about block usage for a file
183
+ */
184
+ getBlockStats(
185
+ sql: CfTypes.SqlStorage,
186
+ filePath: string,
187
+ ): { totalBlocks: number; storedBlocks: number; totalBytes: number } {
188
+ const blockStatsCursor = sql.exec<{ stored_blocks: number; total_bytes: number }>(
189
+ `SELECT
190
+ COUNT(*) as stored_blocks,
191
+ COALESCE(SUM(LENGTH(block_data)), 0) as total_bytes
192
+ FROM vfs_blocks
193
+ WHERE file_path = ?`,
194
+ filePath,
195
+ )
196
+
197
+ const result = blockStatsCursor.one()
198
+
199
+ // Get file size to calculate theoretical total blocks
200
+ const fileSizeCursor = sql.exec<{ file_size: number }>(
201
+ 'SELECT file_size FROM vfs_files WHERE file_path = ?',
202
+ filePath,
203
+ )
204
+
205
+ let fileSize = 0
206
+ try {
207
+ const fileSizeResult = fileSizeCursor.one()
208
+ fileSize = fileSizeResult.file_size
209
+ } catch {
210
+ // File doesn't exist
211
+ }
212
+
213
+ const totalBlocks = Math.ceil(fileSize / this.blockSize)
214
+
215
+ return {
216
+ totalBlocks,
217
+ storedBlocks: result.stored_blocks,
218
+ totalBytes: result.total_bytes,
219
+ }
220
+ }
221
+
222
+ getBlockSize(): number {
223
+ return this.blockSize
224
+ }
225
+ }
@@ -0,0 +1,450 @@
1
+ import type { CfTypes } from '@livestore/common-cf'
2
+ import * as VFS from '@livestore/wa-sqlite/src/VFS.js'
3
+ import { FacadeVFS } from '../FacadeVFS.ts'
4
+ import { BlockManager } from './BlockManager.ts'
5
+
6
+ const SECTOR_SIZE = 4096
7
+
8
+ // Block size for SQL-based storage (same as CloudflareWorkerVFS for consistency)
9
+ const BLOCK_SIZE = 64 * 1024 // 64 KiB
10
+
11
+ // Maximum number of open files
12
+ const DEFAULT_MAX_FILES = 100
13
+
14
+ // These file types are expected to persist in the file system
15
+ const PERSISTENT_FILE_TYPES =
16
+ VFS.SQLITE_OPEN_MAIN_DB | VFS.SQLITE_OPEN_MAIN_JOURNAL | VFS.SQLITE_OPEN_SUPER_JOURNAL | VFS.SQLITE_OPEN_WAL
17
+
18
+ interface FileMetadata {
19
+ path: string
20
+ size: number
21
+ flags: number
22
+ created: number
23
+ modified: number
24
+ }
25
+
26
+ interface FileHandle {
27
+ path: string
28
+ flags: number
29
+ metadata: FileMetadata
30
+ }
31
+
32
+ export interface SqlVfsOptions {
33
+ maxFiles?: number
34
+ blockSize?: number
35
+ }
36
+
37
+ /**
38
+ * VFS implementation using Cloudflare Durable Object SQL storage as the backend.
39
+ * This provides a synchronous VFS interface by leveraging SQL's synchronous API.
40
+ *
41
+ * Storage Strategy:
42
+ * - Files are stored as blocks in SQL tables for efficient I/O
43
+ * - File metadata stored in vfs_files table
44
+ * - File data stored as fixed-size blocks in vfs_blocks table
45
+ * - Synchronous operations via SQL's synchronous API
46
+ *
47
+ * Key advantages over async VFS:
48
+ * - No async/await complexity
49
+ * - Native SQL ACID properties
50
+ * - Efficient range queries for file operations
51
+ * - Built-in consistency and durability
52
+ */
53
+ export class CloudflareSqlVFS extends FacadeVFS {
54
+ log = null
55
+
56
+ #sql: CfTypes.SqlStorage
57
+ #initialized = false
58
+ #blockManager: BlockManager
59
+
60
+ // File management
61
+ #openFiles = new Map<number, FileHandle>()
62
+ #maxFiles: number
63
+
64
+ static async create(name: string, sql: CfTypes.SqlStorage, module: any, options: SqlVfsOptions = {}) {
65
+ const vfs = new CloudflareSqlVFS(name, sql, module, options)
66
+ await vfs.isReady()
67
+ return vfs
68
+ }
69
+
70
+ constructor(name: string, sql: CfTypes.SqlStorage, module: any, options: SqlVfsOptions = {}) {
71
+ super(name, module)
72
+ this.#sql = sql
73
+ this.#maxFiles = options.maxFiles || DEFAULT_MAX_FILES
74
+ this.#blockManager = new BlockManager(options.blockSize || BLOCK_SIZE)
75
+ }
76
+
77
+ /**
78
+ * Initialize the VFS by setting up SQL schema
79
+ */
80
+ async isReady(): Promise<boolean> {
81
+ if (this.#initialized) {
82
+ return true
83
+ }
84
+
85
+ try {
86
+ // Initialize SQL schema
87
+ this.#initializeSchema()
88
+
89
+ // Clean up non-persistent files from previous sessions
90
+ this.#cleanupNonPersistentFiles()
91
+
92
+ this.#initialized = true
93
+ return true
94
+ } catch (error) {
95
+ console.error('CloudflareSqlVFS initialization failed:', error)
96
+ return false
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Initialize the SQL schema for the VFS
102
+ */
103
+ #initializeSchema(): void {
104
+ // Execute each statement individually to avoid parsing issues
105
+ const statements = [
106
+ `CREATE TABLE IF NOT EXISTS vfs_files (
107
+ file_path TEXT PRIMARY KEY,
108
+ file_size INTEGER NOT NULL DEFAULT 0,
109
+ flags INTEGER NOT NULL DEFAULT 0,
110
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
111
+ modified_at INTEGER NOT NULL DEFAULT (unixepoch())
112
+ )`,
113
+
114
+ `CREATE TABLE IF NOT EXISTS vfs_blocks (
115
+ file_path TEXT NOT NULL,
116
+ block_id INTEGER NOT NULL,
117
+ block_data BLOB NOT NULL,
118
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
119
+ PRIMARY KEY (file_path, block_id),
120
+ FOREIGN KEY (file_path) REFERENCES vfs_files(file_path) ON DELETE CASCADE
121
+ )`,
122
+
123
+ `CREATE INDEX IF NOT EXISTS idx_vfs_blocks_range ON vfs_blocks(file_path, block_id)`,
124
+
125
+ `CREATE INDEX IF NOT EXISTS idx_vfs_files_modified ON vfs_files(modified_at)`,
126
+
127
+ `CREATE TRIGGER IF NOT EXISTS trg_vfs_files_update_modified
128
+ AFTER UPDATE OF file_size ON vfs_files
129
+ BEGIN
130
+ UPDATE vfs_files SET modified_at = unixepoch() WHERE file_path = NEW.file_path;
131
+ END`,
132
+ ]
133
+
134
+ for (const statement of statements) {
135
+ try {
136
+ this.#sql.exec(statement)
137
+ } catch (error) {
138
+ console.error('Failed to execute schema statement:', statement)
139
+ throw error
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Clean up non-persistent files from previous sessions
146
+ */
147
+ #cleanupNonPersistentFiles(): void {
148
+ try {
149
+ const cursor = this.#sql.exec<{ file_path: string; flags: number }>('SELECT file_path, flags FROM vfs_files')
150
+
151
+ const filesToDelete: string[] = []
152
+
153
+ for (const row of cursor) {
154
+ // Check if file should be persistent
155
+ if (!(row.flags & PERSISTENT_FILE_TYPES)) {
156
+ filesToDelete.push(row.file_path)
157
+ }
158
+ }
159
+
160
+ // Delete non-persistent files
161
+ for (const filePath of filesToDelete) {
162
+ this.#sql.exec('DELETE FROM vfs_files WHERE file_path = ?', filePath)
163
+ }
164
+ } catch (error) {
165
+ console.warn('Error during cleanup:', error)
166
+ }
167
+ }
168
+
169
+ // VFS Interface Implementation
170
+
171
+ jOpen(path: string, fileId: number, flags: number, pOutFlags: DataView): number {
172
+ try {
173
+ if (this.#openFiles.size >= this.#maxFiles) {
174
+ return VFS.SQLITE_CANTOPEN
175
+ }
176
+
177
+ // Check if file exists
178
+ const existingFile = this.#getFileMetadata(path)
179
+
180
+ if (!existingFile && !(flags & VFS.SQLITE_OPEN_CREATE)) {
181
+ return VFS.SQLITE_CANTOPEN
182
+ }
183
+
184
+ let metadata: FileMetadata
185
+
186
+ if (existingFile) {
187
+ metadata = existingFile
188
+ } else {
189
+ // Create new file
190
+ const now = Math.floor(Date.now() / 1000)
191
+ metadata = {
192
+ path,
193
+ size: 0,
194
+ flags,
195
+ created: now,
196
+ modified: now,
197
+ }
198
+
199
+ this.#sql.exec(
200
+ 'INSERT INTO vfs_files (file_path, file_size, flags, created_at, modified_at) VALUES (?, ?, ?, ?, ?)',
201
+ path,
202
+ 0,
203
+ flags,
204
+ now,
205
+ now,
206
+ )
207
+ }
208
+
209
+ // Store file handle
210
+ this.#openFiles.set(fileId, {
211
+ path,
212
+ flags,
213
+ metadata,
214
+ })
215
+
216
+ pOutFlags.setInt32(0, flags, true)
217
+ return VFS.SQLITE_OK
218
+ } catch (error) {
219
+ console.error('jOpen error:', error)
220
+ return VFS.SQLITE_CANTOPEN
221
+ }
222
+ }
223
+
224
+ jClose(fileId: number): number {
225
+ this.#openFiles.delete(fileId)
226
+ return VFS.SQLITE_OK
227
+ }
228
+
229
+ jRead(fileId: number, buffer: Uint8Array, offset: number): number {
230
+ try {
231
+ const handle = this.#openFiles.get(fileId)
232
+ if (!handle) {
233
+ return VFS.SQLITE_IOERR
234
+ }
235
+
236
+ const range = this.#blockManager.calculateBlockRange(offset, buffer.length)
237
+ const blockIds = []
238
+ for (let i = range.startBlock; i <= range.endBlock; i++) {
239
+ blockIds.push(i)
240
+ }
241
+
242
+ const blocks = this.#blockManager.readBlocks(this.#sql, handle.path, blockIds)
243
+ const data = this.#blockManager.assembleBlocks(blocks, range, buffer.length)
244
+
245
+ buffer.set(data)
246
+ return VFS.SQLITE_OK
247
+ } catch (error) {
248
+ console.error('jRead error:', error)
249
+ return VFS.SQLITE_IOERR
250
+ }
251
+ }
252
+
253
+ jWrite(fileId: number, data: Uint8Array, offset: number): number {
254
+ try {
255
+ const handle = this.#openFiles.get(fileId)
256
+ if (!handle) {
257
+ return VFS.SQLITE_IOERR
258
+ }
259
+
260
+ // Split write data into blocks
261
+ const writeBlocks = this.#blockManager.splitIntoBlocks(data, offset)
262
+ const finalBlocks = new Map<number, Uint8Array>()
263
+
264
+ for (const [blockId, blockInfo] of writeBlocks) {
265
+ let blockData: Uint8Array
266
+
267
+ if (blockInfo.blockOffset === 0 && blockInfo.data.length === this.#blockManager.getBlockSize()) {
268
+ // Full block write
269
+ blockData = blockInfo.data
270
+ } else {
271
+ // Partial block write - merge with existing data
272
+ blockData = this.#blockManager.mergePartialBlock(
273
+ this.#sql,
274
+ handle.path,
275
+ blockInfo.blockId,
276
+ blockInfo.blockOffset,
277
+ blockInfo.data,
278
+ )
279
+ }
280
+
281
+ finalBlocks.set(blockId, blockData)
282
+ }
283
+
284
+ // Write blocks to SQL storage
285
+ this.#blockManager.writeBlocks(this.#sql, handle.path, finalBlocks)
286
+
287
+ // Update file size if necessary
288
+ const newSize = Math.max(handle.metadata.size, offset + data.length)
289
+ if (newSize !== handle.metadata.size) {
290
+ this.#sql.exec('UPDATE vfs_files SET file_size = ? WHERE file_path = ?', newSize, handle.path)
291
+ handle.metadata.size = newSize
292
+ }
293
+
294
+ return VFS.SQLITE_OK
295
+ } catch (error) {
296
+ console.error('jWrite error:', error)
297
+ return VFS.SQLITE_IOERR
298
+ }
299
+ }
300
+
301
+ jTruncate(fileId: number, size: number): number {
302
+ try {
303
+ const handle = this.#openFiles.get(fileId)
304
+ if (!handle) {
305
+ return VFS.SQLITE_IOERR
306
+ }
307
+
308
+ // Calculate which block contains the new end of file
309
+ const lastBlockId = Math.floor(size / this.#blockManager.getBlockSize())
310
+
311
+ // Delete blocks beyond the truncation point
312
+ this.#blockManager.deleteBlocksAfter(this.#sql, handle.path, lastBlockId + 1)
313
+
314
+ // If truncating within a block, we need to truncate that block's data
315
+ if (size % this.#blockManager.getBlockSize() !== 0) {
316
+ const existingBlocks = this.#blockManager.readBlocks(this.#sql, handle.path, [lastBlockId])
317
+ const blockData = existingBlocks.get(lastBlockId)
318
+
319
+ if (blockData) {
320
+ const truncatedBlock = blockData.slice(0, size % this.#blockManager.getBlockSize())
321
+ const paddedBlock = new Uint8Array(this.#blockManager.getBlockSize())
322
+ paddedBlock.set(truncatedBlock)
323
+
324
+ const blocksToWrite = new Map([[lastBlockId, paddedBlock]])
325
+ this.#blockManager.writeBlocks(this.#sql, handle.path, blocksToWrite)
326
+ }
327
+ }
328
+
329
+ // Update file metadata
330
+ this.#sql.exec('UPDATE vfs_files SET file_size = ? WHERE file_path = ?', size, handle.path)
331
+ handle.metadata.size = size
332
+
333
+ return VFS.SQLITE_OK
334
+ } catch (error) {
335
+ console.error('jTruncate error:', error)
336
+ return VFS.SQLITE_IOERR
337
+ }
338
+ }
339
+
340
+ jSync(fileId: number, _flags: number): number {
341
+ // SQL storage provides immediate durability, so sync is effectively a no-op
342
+ const handle = this.#openFiles.get(fileId)
343
+ if (!handle) {
344
+ return VFS.SQLITE_IOERR
345
+ }
346
+ return VFS.SQLITE_OK
347
+ }
348
+
349
+ jFileSize(fileId: number, pSize64: DataView): number {
350
+ try {
351
+ const handle = this.#openFiles.get(fileId)
352
+ if (!handle) {
353
+ return VFS.SQLITE_IOERR
354
+ }
355
+
356
+ pSize64.setBigInt64(0, BigInt(handle.metadata.size), true)
357
+ return VFS.SQLITE_OK
358
+ } catch (error) {
359
+ console.error('jFileSize error:', error)
360
+ return VFS.SQLITE_IOERR
361
+ }
362
+ }
363
+
364
+ jDelete(path: string, _syncDir: number): number {
365
+ try {
366
+ this.#sql.exec('DELETE FROM vfs_files WHERE file_path = ?', path)
367
+ return VFS.SQLITE_OK
368
+ } catch (error) {
369
+ console.error('jDelete error:', error)
370
+ return VFS.SQLITE_IOERR
371
+ }
372
+ }
373
+
374
+ jAccess(path: string, _flags: number, pResOut: DataView): number {
375
+ try {
376
+ const metadata = this.#getFileMetadata(path)
377
+ pResOut.setInt32(0, metadata ? 1 : 0, true)
378
+ return VFS.SQLITE_OK
379
+ } catch (error) {
380
+ console.error('jAccess error:', error)
381
+ return VFS.SQLITE_IOERR
382
+ }
383
+ }
384
+
385
+ jSectorSize(_fileId: number): number {
386
+ return SECTOR_SIZE
387
+ }
388
+
389
+ jDeviceCharacteristics(_fileId: number): number {
390
+ return VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN
391
+ }
392
+
393
+ // Helper methods
394
+
395
+ #getFileMetadata(path: string): FileMetadata | undefined {
396
+ try {
397
+ const cursor = this.#sql.exec<{
398
+ file_path: string
399
+ file_size: number
400
+ flags: number
401
+ created_at: number
402
+ modified_at: number
403
+ }>('SELECT file_path, file_size, flags, created_at, modified_at FROM vfs_files WHERE file_path = ?', path)
404
+
405
+ const row = cursor.one()
406
+ return {
407
+ path: row.file_path,
408
+ size: row.file_size,
409
+ flags: row.flags,
410
+ created: row.created_at,
411
+ modified: row.modified_at,
412
+ }
413
+ } catch {
414
+ return undefined
415
+ }
416
+ }
417
+
418
+ // Statistics and debugging
419
+
420
+ getStats(): {
421
+ activeFiles: number
422
+ openFiles: number
423
+ maxFiles: number
424
+ blockSize: number
425
+ totalStoredBytes: number
426
+ } {
427
+ try {
428
+ const cursor = this.#sql.exec<{ total_files: number; total_bytes: number }>(
429
+ 'SELECT COUNT(*) as total_files, COALESCE(SUM(LENGTH(block_data)), 0) as total_bytes FROM vfs_files LEFT JOIN vfs_blocks USING (file_path)',
430
+ )
431
+ const stats = cursor.one()
432
+
433
+ return {
434
+ activeFiles: stats.total_files,
435
+ openFiles: this.#openFiles.size,
436
+ maxFiles: this.#maxFiles,
437
+ blockSize: this.#blockManager.getBlockSize(),
438
+ totalStoredBytes: stats.total_bytes,
439
+ }
440
+ } catch {
441
+ return {
442
+ activeFiles: 0,
443
+ openFiles: this.#openFiles.size,
444
+ maxFiles: this.#maxFiles,
445
+ blockSize: this.#blockManager.getBlockSize(),
446
+ totalStoredBytes: 0,
447
+ }
448
+ }
449
+ }
450
+ }