@livestore/adapter-cloudflare 0.4.0-dev.21 → 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,
@@ -125,7 +124,11 @@ export const makeAdapter =
125
124
  options,
126
125
  }),
127
126
  },
128
- initialState: { leaderHead: initialLeaderHead, migrationsReport: initialState.migrationsReport },
127
+ initialState: {
128
+ leaderHead: initialLeaderHead,
129
+ migrationsReport: initialState.migrationsReport,
130
+ storageMode: 'persisted',
131
+ },
129
132
  export: Effect.sync(() => dbState.export()),
130
133
  getEventlogData: Effect.sync(() => dbEventlog.export()),
131
134
  syncState: syncProcessor.syncState,
@@ -151,7 +154,7 @@ export const makeAdapter =
151
154
  sqliteDb: syncInMemoryDb,
152
155
  webmeshMode: 'proxy',
153
156
  connectWebmeshNode: Effect.fnUntraced(function* ({ webmeshNode }) {
154
- if (devtoolsOptions.enabled) {
157
+ if (devtoolsOptions.enabled === true) {
155
158
  console.log('connectWebmeshNode', { webmeshNode })
156
159
  // yield* Webmesh.connectViaWebSocket({
157
160
  // node: webmeshNode,
@@ -178,25 +181,23 @@ export const makeAdapter =
178
181
 
179
182
  const getStateDbFileName = (suffix: string) => `state${suffix}@${liveStoreStorageFormatVersion}.db`
180
183
 
181
- const getEventlogDbFileName = () => `eventlog@${liveStoreStorageFormatVersion}.db`
182
-
183
184
  const resetDurableObjectPersistence = ({
184
185
  storage,
185
186
  storeId,
186
- dbFileNames,
187
187
  }: {
188
188
  storage: CfTypes.DurableObjectStorage
189
189
  storeId: string
190
- dbFileNames: ReadonlyArray<string>
191
190
  }) =>
192
191
  Effect.try({
193
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
194
197
  storage.transactionSync(() => {
195
- for (const baseName of dbFileNames) {
196
- const likePattern = `${baseName}%`
197
- safeSqlExec(storage, 'DELETE FROM vfs_blocks WHERE file_path LIKE ?', likePattern)
198
- safeSqlExec(storage, 'DELETE FROM vfs_files WHERE file_path LIKE ?', likePattern)
199
- }
198
+ safeSqlExec(storage, 'DELETE FROM vfs_pages')
199
+ safeSqlExec(storage, 'DELETE FROM eventlog')
200
+ safeSqlExec(storage, 'DELETE FROM __livestore_sync_status')
200
201
  }),
201
202
  catch: (cause) =>
202
203
  new UnknownError({
@@ -208,11 +209,11 @@ const resetDurableObjectPersistence = ({
208
209
  Effect.withSpan('@livestore/adapter-cloudflare:resetPersistence', { attributes: { storeId } }),
209
210
  )
210
211
 
211
- const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, binding: string) => {
212
+ const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, binding?: string) => {
212
213
  try {
213
- storage.sql.exec(query, binding)
214
+ binding !== undefined ? storage.sql.exec(query, binding) : storage.sql.exec(query)
214
215
  } catch (error) {
215
- if (isMissingVfsTableError(error)) {
216
+ if (isMissingTableError(error) === true) {
216
217
  return
217
218
  }
218
219
 
@@ -220,5 +221,5 @@ const safeSqlExec = (storage: CfTypes.DurableObjectStorage, query: string, bindi
220
221
  }
221
222
  }
222
223
 
223
- const isMissingVfsTableError = (error: unknown): boolean =>
224
+ const isMissingTableError = (error: unknown): boolean =>
224
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'