@livestore/adapter-cloudflare 0.4.0-dev.22 → 0.4.0-dev.23

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.
@@ -7,6 +7,7 @@ import {
7
7
  type SyncOptions,
8
8
  UnknownError,
9
9
  } from '@livestore/common'
10
+ import type { CfTypes } from '@livestore/common-cf'
10
11
  import {
11
12
  type DevtoolsOptions,
12
13
  Eventlog,
@@ -14,9 +15,9 @@ import {
14
15
  makeLeaderThreadLayer,
15
16
  streamEventsWithSyncState,
16
17
  } from '@livestore/common/leader-thread'
17
- import type { CfTypes } from '@livestore/common-cf'
18
18
  import { LiveStoreEvent } from '@livestore/livestore'
19
- import { sqliteDbFactory } from '@livestore/sqlite-wasm/cf'
19
+ import { CF_SQL_VFS_REQUIRED_PRAGMAS, sqliteDbFactory } from '@livestore/sqlite-wasm/cf'
20
+ import { makeSqliteDb as makeDoSqliteDb } from './make-sqlite-db.ts'
20
21
  import { loadSqlite3Wasm } from '@livestore/sqlite-wasm/load-wasm'
21
22
  import { Effect, FetchHttpClient, Layer, Schedule, SubscriptionRef, WebChannel } from '@livestore/utils/effect'
22
23
 
@@ -57,35 +58,33 @@ export const makeAdapter =
57
58
  const schemaHashSuffix =
58
59
  schema.state.sqlite.migrations.strategy === 'manual' ? 'fixed' : schema.state.sqlite.hash.toString()
59
60
 
60
- const stateDbFileName = getStateDbFileName(schemaHashSuffix)
61
- const eventlogDbFileName = getEventlogDbFileName()
62
-
63
61
  if (resetPersistence === true) {
64
- yield* resetDurableObjectPersistence({
65
- storage,
66
- storeId,
67
- dbFileNames: [stateDbFileName, eventlogDbFileName],
68
- })
62
+ yield* resetDurableObjectPersistence({ storage, storeId })
69
63
  }
70
64
 
65
+ const stateDbFileName = getStateDbFileName(schemaHashSuffix)
66
+
71
67
  const dbState = yield* makeSqliteDb({
72
68
  _tag: 'storage',
73
69
  storage,
74
70
  fileName: stateDbFileName,
75
- configureDb: () => {},
71
+ configureDb: (db) =>
72
+ db.execute(
73
+ [...CF_SQL_VFS_REQUIRED_PRAGMAS, 'cache_size=-8000'].map((p) => `PRAGMA ${p}`).join(';\n'),
74
+ ),
76
75
  }).pipe(UnknownError.mapToUnknownError)
77
76
 
78
- const dbEventlog = yield* makeSqliteDb({
79
- _tag: 'storage',
80
- storage,
81
- fileName: eventlogDbFileName,
77
+ // dbEventlog runs on DO SQLite directly (not through the VFS). SQL-level transaction
78
+ // control (BEGIN/COMMIT/ROLLBACK) is silently dropped — see isTransactionControlStatement
79
+ // in make-sqlite-db.ts for details on why this is safe.
80
+ const dbEventlog = yield* makeDoSqliteDb({
81
+ _tag: 'file',
82
+ db: storage.sql,
82
83
  configureDb: () => {},
83
84
  }).pipe(UnknownError.mapToUnknownError)
84
85
 
85
86
  const shutdownChannel = yield* WebChannel.noopChannel<any, any>()
86
87
 
87
- // Use Durable Object sync backend if no backend is specified
88
-
89
88
  const layer = yield* Layer.build(
90
89
  makeLeaderThreadLayer({
91
90
  schema,
@@ -155,7 +154,7 @@ export const makeAdapter =
155
154
  sqliteDb: syncInMemoryDb,
156
155
  webmeshMode: 'proxy',
157
156
  connectWebmeshNode: Effect.fnUntraced(function* ({ webmeshNode }) {
158
- if (devtoolsOptions.enabled) {
157
+ if (devtoolsOptions.enabled === true) {
159
158
  console.log('connectWebmeshNode', { webmeshNode })
160
159
  // yield* Webmesh.connectViaWebSocket({
161
160
  // node: webmeshNode,
@@ -182,25 +181,23 @@ export const makeAdapter =
182
181
 
183
182
  const getStateDbFileName = (suffix: string) => `state${suffix}@${liveStoreStorageFormatVersion}.db`
184
183
 
185
- const getEventlogDbFileName = () => `eventlog@${liveStoreStorageFormatVersion}.db`
186
-
187
184
  const resetDurableObjectPersistence = ({
188
185
  storage,
189
186
  storeId,
190
- dbFileNames,
191
187
  }: {
192
188
  storage: CfTypes.DurableObjectStorage
193
189
  storeId: string
194
- dbFileNames: ReadonlyArray<string>
195
190
  }) =>
196
191
  Effect.try({
197
192
  try: () =>
193
+ // All three tables live in the DO's single storage.sql database but are
194
+ // owned by different layers during normal operation:
195
+ // - vfs_pages: written by the wa-sqlite VFS layer (backs dbState)
196
+ // - eventlog, __livestore_sync_status: written directly by dbEventlog via storage.sql
198
197
  storage.transactionSync(() => {
199
- for (const baseName of dbFileNames) {
200
- const likePattern = `${baseName}%`
201
- safeSqlExec(storage, 'DELETE FROM vfs_blocks WHERE file_path LIKE ?', likePattern)
202
- safeSqlExec(storage, 'DELETE FROM vfs_files WHERE file_path LIKE ?', likePattern)
203
- }
198
+ safeSqlExec(storage, 'DELETE FROM vfs_pages')
199
+ safeSqlExec(storage, 'DELETE FROM eventlog')
200
+ safeSqlExec(storage, 'DELETE FROM __livestore_sync_status')
204
201
  }),
205
202
  catch: (cause) =>
206
203
  new UnknownError({
@@ -212,11 +209,11 @@ const resetDurableObjectPersistence = ({
212
209
  Effect.withSpan('@livestore/adapter-cloudflare:resetPersistence', { attributes: { storeId } }),
213
210
  )
214
211
 
215
- const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, binding: string) => {
212
+ const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, binding?: string) => {
216
213
  try {
217
- storage.sql.exec(query, binding)
214
+ binding !== undefined ? storage.sql.exec(query, binding) : storage.sql.exec(query)
218
215
  } catch (error) {
219
- if (isMissingVfsTableError(error)) {
216
+ if (isMissingTableError(error) === true) {
220
217
  return
221
218
  }
222
219
 
@@ -224,5 +221,5 @@ const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, bindi
224
221
  }
225
222
  }
226
223
 
227
- const isMissingVfsTableError = (error: unknown): boolean =>
224
+ const isMissingTableError = (error: unknown): boolean =>
228
225
  error instanceof Error && error.message.toLowerCase().includes('no such table')
@@ -8,8 +8,8 @@ import type {
8
8
  SqliteDbSession,
9
9
  } from '@livestore/common'
10
10
  import { SqliteDbHelper, SqliteError } from '@livestore/common'
11
- import { EventSequenceNumber } from '@livestore/common/schema'
12
11
  import type { CfTypes } from '@livestore/common-cf'
12
+ import { EventSequenceNumber } from '@livestore/common/schema'
13
13
  import { Effect } from '@livestore/utils/effect'
14
14
 
15
15
  // Simplified prepared statement implementation using only public API
@@ -24,7 +24,10 @@ class CloudflarePreparedStatement implements PreparedStatement {
24
24
 
25
25
  execute = (bindValues?: PreparedBindValues, options?: { onRowsChanged?: (count: number) => void }) => {
26
26
  try {
27
- const cursor = this.sqlStorage.exec(this.sql, ...(bindValues ? Object.values(bindValues) : []))
27
+ if (isTransactionControlStatement(this.sql) === true) return
28
+
29
+
30
+ const cursor = this.sqlStorage.exec(this.sql, ...(bindValues !== undefined ? Object.values(bindValues) : []))
28
31
 
29
32
  // Count affected rows by iterating through cursor
30
33
  let changedCount = 0
@@ -32,7 +35,7 @@ class CloudflarePreparedStatement implements PreparedStatement {
32
35
  changedCount++
33
36
  }
34
37
 
35
- if (options?.onRowsChanged) {
38
+ if (options?.onRowsChanged !== undefined) {
36
39
  options.onRowsChanged(changedCount)
37
40
  }
38
41
  } catch (e) {
@@ -48,7 +51,7 @@ class CloudflarePreparedStatement implements PreparedStatement {
48
51
  try {
49
52
  const cursor = this.sqlStorage.exec<Record<string, CfTypes.SqlStorageValue>>(
50
53
  this.sql,
51
- ...(bindValues ? Object.values(bindValues) : []),
54
+ ...(bindValues !== undefined ? Object.values(bindValues) : []),
52
55
  )
53
56
  const results: T[] = []
54
57
 
@@ -97,14 +100,12 @@ export type MakeCloudflareSqliteDb = MakeSqliteDb<Metadata, CloudflareDatabaseIn
97
100
 
98
101
  export const makeSqliteDb: MakeCloudflareSqliteDb = (input: CloudflareDatabaseInput) =>
99
102
  Effect.gen(function* () {
100
- // console.log('makeSqliteDb', input)
101
103
  if (input._tag === 'in-memory') {
102
104
  return makeSqliteDb_<Metadata>({
103
105
  sqlStorage: input.db,
104
106
  metadata: {
105
107
  _tag: 'file' as const,
106
108
  dbPointer: 0,
107
- // persistenceInfo: { fileName: ':memory:' },
108
109
  persistenceInfo: { fileName: 'cf' },
109
110
  input,
110
111
  configureDb: input.configureDb,
@@ -118,7 +119,6 @@ export const makeSqliteDb: MakeCloudflareSqliteDb = (input: CloudflareDatabaseIn
118
119
  metadata: {
119
120
  _tag: 'file' as const,
120
121
  dbPointer: 0,
121
- // persistenceInfo: { fileName: `${input.directory}/${input.databaseName}` },
122
122
  persistenceInfo: { fileName: 'cf' },
123
123
  input,
124
124
  configureDb: input.configureDb,
@@ -130,7 +130,6 @@ export const makeSqliteDb: MakeCloudflareSqliteDb = (input: CloudflareDatabaseIn
130
130
  export const makeSqliteDb_ = <
131
131
  TMetadata extends {
132
132
  persistenceInfo: PersistenceInfo
133
- // deleteDb: () => void
134
133
  configureDb: (db: SqliteDb<TMetadata>) => void
135
134
  },
136
135
  >({
@@ -188,14 +187,13 @@ export const makeSqliteDb_ = <
188
187
  destroy: () => {
189
188
  sqliteDb.close()
190
189
 
191
- // metadata.deleteDb()
192
190
  throw new SqliteError({
193
191
  code: -1,
194
192
  cause: 'Database destroy not supported with public SqlStorage API',
195
193
  })
196
194
  },
197
195
  close: () => {
198
- if (isClosed) {
196
+ if (isClosed === true) {
199
197
  return
200
198
  }
201
199
 
@@ -255,3 +253,40 @@ export const makeSqliteDb_ = <
255
253
 
256
254
  return sqliteDb
257
255
  }
256
+
257
+ /**
258
+ * CF DO SQLite rejects SQL-level transaction control and requires `storage.transactionSync()` instead.
259
+ * The current adapter only detects and suppresses those SQL statements. It does not yet translate the
260
+ * caller's transaction intent into a shared Durable Object storage transaction.
261
+ *
262
+ * ## Consistency implications
263
+ *
264
+ * `LeaderSyncProcessor.materializeEventsBatch()` wraps both `dbState` and `dbEventlog` in
265
+ * `BEGIN`/`COMMIT` to keep them consistent. Because this adapter drops those statements,
266
+ * eventlog INSERTs are auto-committed individually while `dbState` (VFS-backed) still has
267
+ * real transaction boundaries. If a batch partially fails, earlier eventlog rows survive
268
+ * while `dbState` rolls back.
269
+ *
270
+ * This is safe because:
271
+ * - The eventlog is append-only and idempotent — replaying already-inserted events is a no-op.
272
+ * - State is always rebuildable from the eventlog on cold start (`recreateDb`).
273
+ * - A Durable Object is single-threaded, so no concurrent reader can observe the
274
+ * intermediate inconsistency.
275
+ *
276
+ * Uses prefix matching to cover all SQLite variants:
277
+ * - `BEGIN [DEFERRED | IMMEDIATE | EXCLUSIVE] [TRANSACTION]`
278
+ * - `COMMIT [TRANSACTION]` / `END [TRANSACTION]`
279
+ * - `ROLLBACK [TRANSACTION] [TO [SAVEPOINT] name]`
280
+ * - `SAVEPOINT name` / `RELEASE [SAVEPOINT] name`
281
+ */
282
+ const isTransactionControlStatement = (sql: string): boolean => {
283
+ const upper = sql.trim().toUpperCase()
284
+ return (
285
+ upper.startsWith('BEGIN') === true ||
286
+ upper.startsWith('COMMIT') === true ||
287
+ upper.startsWith('END') === true ||
288
+ upper.startsWith('ROLLBACK') === true ||
289
+ upper.startsWith('SAVEPOINT') === true ||
290
+ upper.startsWith('RELEASE') === true
291
+ )
292
+ }
package/src/mod.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  import './polyfill.ts'
2
2
 
3
3
  export type { ClientDoWithRpcCallback } from '@livestore/common-cf'
4
- export {
5
- type CreateStoreDoOptions,
6
- createStoreDo,
7
- createStoreDoPromise,
8
- type Env,
9
- } from './create-store-do.ts'
4
+ export { type CreateStoreDoOptions, createStoreDo, createStoreDoPromise, type Env } from './create-store-do.ts'
10
5
  export { makeAdapter } from './make-adapter.ts'