@pluv/persistence-cloudflare-transactional-storage 0.41.7 → 0.42.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @pluv/persistence-cloudflare-transactional-storage@0.41.7 build /home/runner/work/pluv/pluv/packages/persistence-cloudflare-transactional-storage
2
+ > @pluv/persistence-cloudflare-transactional-storage@0.42.0 build /home/runner/work/pluv/pluv/packages/persistence-cloudflare-transactional-storage
3
3
  > tsup src/index.ts --format esm,cjs --dts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -8,11 +8,11 @@
8
8
  CLI Target: es6
9
9
  ESM Build start
10
10
  CJS Build start
11
- CJS dist/index.js 4.64 KB
11
+ CJS dist/index.js 10.29 KB
12
12
  CJS ⚡️ Build success in 81ms
13
- ESM dist/index.mjs 3.58 KB
14
- ESM ⚡️ Build success in 85ms
13
+ ESM dist/index.mjs 9.23 KB
14
+ ESM ⚡️ Build success in 100ms
15
15
  DTS Build start
16
- DTS ⚡️ Build success in 2575ms
17
- DTS dist/index.d.mts 1.08 KB
18
- DTS dist/index.d.ts 1.08 KB
16
+ DTS ⚡️ Build success in 2969ms
17
+ DTS dist/index.d.mts 1.21 KB
18
+ DTS dist/index.d.ts 1.21 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @pluv/persistence-cloudflare-transactional-storage
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5c2c71d: **BREAKING** Updated the `PersistenceCloudflareTransactionalStorage` constructor so require a new property `mode` (either `kv` or `sqlite`). `sqlite` makes use of `DurableObject` SQLite storage, and `kv` makes use of their key-value storage. `platformCloudflare` now sets the underlying `PersistenceCloudflareTransactionalStorage` to use the `sqlite` mode by default (was previously using `kv`). For more information on the differences, please refer to [Cloudflare's documentation](https://developers.cloudflare.com/durable-objects/api/storage-api/#sql-api).
8
+
9
+ To continue using the `kv` mode, you will need to install `@pluv/persistence-cloudflare-transactional-storage` and provide it manually to `platformCloudflare` with your desired mode.
10
+
11
+ ```ts
12
+ platformCloudflare({
13
+ // ...
14
+ persistence: new PersistenceCloudflareTransactionalStorage({ mode: "kv" }),
15
+ // ...
16
+ });
17
+ ```
18
+
19
+ - 4ce32ee: **BREAKING** `Persistence.getUsers` now returns a map of connection id strings to user JSON objects instead of an array of user JSON objects. This change is intended to be internal only and generally non-breaking.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [cf1529a]
24
+ - @pluv/io@0.42.0
25
+ - @pluv/types@0.42.0
26
+
3
27
  ## 0.41.7
4
28
 
5
29
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -2,10 +2,12 @@ import { AbstractPersistence } from '@pluv/io';
2
2
  import { JsonObject } from '@pluv/types';
3
3
 
4
4
  interface PersistenceCloudflareTransactionalStorageConfig {
5
- state: DurableObjectState;
5
+ mode: "kv" | "sqlite";
6
6
  }
7
7
  declare class PersistenceCloudflareTransactionalStorage extends AbstractPersistence {
8
+ private _mode;
8
9
  private _state;
10
+ private _initialized;
9
11
  constructor(config: PersistenceCloudflareTransactionalStorageConfig);
10
12
  addUser(room: string, connectionId: string, user: JsonObject | null): Promise<void>;
11
13
  deleteStorageState(room: string): Promise<void>;
@@ -13,8 +15,11 @@ declare class PersistenceCloudflareTransactionalStorage extends AbstractPersiste
13
15
  deleteUsers(room: string): Promise<void>;
14
16
  getStorageState(room: string): Promise<string | null>;
15
17
  getUser(room: string, connectionId: string): Promise<JsonObject | null>;
16
- getUsers(room: string): Promise<readonly (JsonObject | null)[]>;
18
+ getUsers(room: string): Promise<Map<[connectionId: string][0], JsonObject | null>>;
17
19
  getUsersSize(room: string): Promise<number>;
20
+ initialize(roomContext: {
21
+ state: DurableObjectState;
22
+ }): this;
18
23
  setStorageState(room: string, state: string): Promise<void>;
19
24
  private _getStorageKey;
20
25
  private _getUserKey;
package/dist/index.d.ts CHANGED
@@ -2,10 +2,12 @@ import { AbstractPersistence } from '@pluv/io';
2
2
  import { JsonObject } from '@pluv/types';
3
3
 
4
4
  interface PersistenceCloudflareTransactionalStorageConfig {
5
- state: DurableObjectState;
5
+ mode: "kv" | "sqlite";
6
6
  }
7
7
  declare class PersistenceCloudflareTransactionalStorage extends AbstractPersistence {
8
+ private _mode;
8
9
  private _state;
10
+ private _initialized;
9
11
  constructor(config: PersistenceCloudflareTransactionalStorageConfig);
10
12
  addUser(room: string, connectionId: string, user: JsonObject | null): Promise<void>;
11
13
  deleteStorageState(room: string): Promise<void>;
@@ -13,8 +15,11 @@ declare class PersistenceCloudflareTransactionalStorage extends AbstractPersiste
13
15
  deleteUsers(room: string): Promise<void>;
14
16
  getStorageState(room: string): Promise<string | null>;
15
17
  getUser(room: string, connectionId: string): Promise<JsonObject | null>;
16
- getUsers(room: string): Promise<readonly (JsonObject | null)[]>;
18
+ getUsers(room: string): Promise<Map<[connectionId: string][0], JsonObject | null>>;
17
19
  getUsersSize(room: string): Promise<number>;
20
+ initialize(roomContext: {
21
+ state: DurableObjectState;
22
+ }): this;
18
23
  setStorageState(room: string, state: string): Promise<void>;
19
24
  private _getStorageKey;
20
25
  private _getUserKey;
package/dist/index.js CHANGED
@@ -47,6 +47,11 @@ module.exports = __toCommonJS(index_exports);
47
47
  // src/PersistenceCloudflareTransactionalStorage.ts
48
48
  var import_io = require("@pluv/io");
49
49
 
50
+ // src/utils/castNumber.ts
51
+ var castNumber = (value) => {
52
+ return typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
53
+ };
54
+
50
55
  // src/utils/partitionByLength.ts
51
56
  var partitionByLength = (arr, length) => {
52
57
  if (!arr.length) return [];
@@ -55,86 +60,275 @@ var partitionByLength = (arr, length) => {
55
60
  return [head, ...partitionByLength(tail, length)];
56
61
  };
57
62
 
63
+ // src/utils/sql.ts
64
+ var sql = (strings, ...values) => {
65
+ return strings.reduce((result, str, i) => {
66
+ const cleaned = str.replace(/(\n[ \t]*)+/g, " ");
67
+ return result + cleaned + (i < values.length ? values[i] : "");
68
+ }, "").trim();
69
+ };
70
+
58
71
  // src/PersistenceCloudflareTransactionalStorage.ts
59
- var CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
60
- var STORAGE_PREFIX = "$PLUV_STORAGE;";
61
- var USER_PREFIX = "$PLUV_USER";
72
+ var KV_CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
73
+ var KV_STORAGE_PREFIX = "$PLUV_STORAGE;";
74
+ var KV_USER_PREFIX = "$PLUV_USER";
75
+ var SQLITE_STORAGE_TABLE = "__pluv_storage";
76
+ var SQLITE_USER_TABLE = "__pluv_user";
62
77
  var PersistenceCloudflareTransactionalStorage = class extends import_io.AbstractPersistence {
63
78
  constructor(config) {
64
79
  super();
65
- const { state } = config;
66
- this._state = state;
80
+ this._state = null;
81
+ this._initialized = null;
82
+ const { mode } = config;
83
+ this._mode = mode;
67
84
  }
68
85
  addUser(room, connectionId, user) {
69
86
  return __async(this, null, function* () {
70
- yield this._state.storage.put(this._getUserKey(room, connectionId), user);
87
+ if (!this._initialized) return;
88
+ if (!this._state) return;
89
+ yield this._initialized;
90
+ if (this._mode === "kv") {
91
+ yield this._state.storage.put(this._getUserKey(room, connectionId), user);
92
+ return;
93
+ }
94
+ this._state.storage.sql.exec(
95
+ sql`
96
+ INSERT INTO ${SQLITE_USER_TABLE} VALUES (?, ?, ?);
97
+ `,
98
+ connectionId,
99
+ JSON.stringify(user),
100
+ room
101
+ );
71
102
  });
72
103
  }
73
104
  deleteStorageState(room) {
74
105
  return __async(this, null, function* () {
75
- yield this._state.storage.delete(this._getStorageKey(room));
106
+ if (!this._initialized) return;
107
+ if (!this._state) return;
108
+ yield this._initialized;
109
+ if (this._mode === "kv") {
110
+ yield this._state.storage.delete(this._getStorageKey(room));
111
+ return;
112
+ }
113
+ this._state.storage.sql.exec(
114
+ sql`
115
+ DELETE FROM ${SQLITE_STORAGE_TABLE}
116
+ WHERE room = ?;
117
+ `,
118
+ room
119
+ );
76
120
  });
77
121
  }
78
122
  deleteUser(room, connectionId) {
79
123
  return __async(this, null, function* () {
80
- yield this._state.storage.delete(this._getUserKey(room, connectionId));
124
+ if (!this._initialized) return;
125
+ if (!this._state) return;
126
+ yield this._initialized;
127
+ if (this._mode === "kv") {
128
+ yield this._state.storage.delete(this._getUserKey(room, connectionId));
129
+ return;
130
+ }
131
+ this._state.storage.sql.exec(
132
+ sql`
133
+ DELETE FROM ${SQLITE_USER_TABLE}
134
+ WHERE room = ?
135
+ AND id = ?;
136
+ `,
137
+ room,
138
+ connectionId
139
+ );
81
140
  });
82
141
  }
83
142
  deleteUsers(room) {
84
143
  return __async(this, null, function* () {
85
- const map = yield this._state.storage.list({
86
- allowConcurrency: true,
87
- noCache: true,
88
- prefix: USER_PREFIX
89
- });
90
- const partitions = partitionByLength(Array.from(map.keys()), CLOUDFLARE_DELETE_BATCH_LIMIT);
91
- yield this._state.storage.transaction((tx) => __async(this, null, function* () {
92
- yield partitions.reduce((promise, partition) => __async(this, null, function* () {
93
- return yield promise.then(() => __async(this, null, function* () {
144
+ if (!this._initialized) return;
145
+ if (!this._state) return;
146
+ yield this._initialized;
147
+ if (this._mode === "kv") {
148
+ const map = yield this._state.storage.list({
149
+ allowConcurrency: true,
150
+ noCache: true,
151
+ prefix: `${KV_USER_PREFIX}::${room}`
152
+ });
153
+ const partitions = partitionByLength(Array.from(map.keys()), KV_CLOUDFLARE_DELETE_BATCH_LIMIT);
154
+ yield this._state.storage.transaction((tx) => __async(this, null, function* () {
155
+ yield partitions.reduce((promise, partition) => __async(this, null, function* () {
156
+ yield promise;
94
157
  yield tx.delete(partition);
95
- }));
96
- }), Promise.resolve());
97
- }));
158
+ }), Promise.resolve());
159
+ }));
160
+ return;
161
+ }
162
+ this._state.storage.sql.exec(
163
+ sql`
164
+ DELETE FROM ${SQLITE_USER_TABLE}
165
+ WHERE room = ?;
166
+ `,
167
+ room
168
+ );
98
169
  });
99
170
  }
100
171
  getStorageState(room) {
101
172
  return __async(this, null, function* () {
102
- const storage = yield this._state.storage.get(this._getStorageKey(room));
103
- return storage != null ? storage : null;
173
+ if (!this._initialized) return null;
174
+ if (!this._state) return null;
175
+ yield this._initialized;
176
+ if (this._mode === "kv") {
177
+ const storage = yield this._state.storage.get(this._getStorageKey(room));
178
+ return storage != null ? storage : null;
179
+ }
180
+ const cursor = this._state.storage.sql.exec(
181
+ sql`
182
+ SELECT room, data
183
+ FROM ${SQLITE_STORAGE_TABLE}
184
+ WHERE room = ?;
185
+ `,
186
+ room
187
+ );
188
+ try {
189
+ const result = cursor.one();
190
+ return result.data;
191
+ } catch (e) {
192
+ return null;
193
+ }
104
194
  });
105
195
  }
106
196
  getUser(room, connectionId) {
107
197
  return __async(this, null, function* () {
108
- const user = yield this._state.storage.get(this._getUserKey(room, connectionId));
109
- return user != null ? user : null;
198
+ if (!this._initialized) return null;
199
+ if (!this._state) return null;
200
+ yield this._initialized;
201
+ if (this._mode === "kv") {
202
+ const user = yield this._state.storage.get(this._getUserKey(room, connectionId));
203
+ return user != null ? user : null;
204
+ }
205
+ const cursor = this._state.storage.sql.exec(
206
+ sql`
207
+ SELECT id, data, room
208
+ FROM ${SQLITE_USER_TABLE}
209
+ WHERE room = ?
210
+ AND id = ?;
211
+ `,
212
+ room,
213
+ connectionId
214
+ );
215
+ try {
216
+ const result = cursor.one();
217
+ const user = JSON.parse(result.data);
218
+ return user;
219
+ } catch (e) {
220
+ return null;
221
+ }
110
222
  });
111
223
  }
112
224
  getUsers(room) {
113
225
  return __async(this, null, function* () {
114
- const storage = yield this._state.storage.list({
115
- prefix: USER_PREFIX
116
- });
117
- return Array.from(storage.values());
226
+ if (!this._initialized) return /* @__PURE__ */ new Map();
227
+ if (!this._state) return /* @__PURE__ */ new Map();
228
+ yield this._initialized;
229
+ if (this._mode === "kv") {
230
+ const entries = yield this._state.storage.list({
231
+ prefix: `${KV_USER_PREFIX}::${room}`
232
+ }).then((map) => Array.from(map.entries()));
233
+ return entries.reduce((map, [key, user]) => {
234
+ const connectionId = key.split("::").slice(-1)[0];
235
+ return !!connectionId ? map.set(connectionId, user) : map;
236
+ }, /* @__PURE__ */ new Map());
237
+ }
238
+ const cursor = this._state.storage.sql.exec(
239
+ sql`
240
+ SELECT id, room, data
241
+ FROM ${SQLITE_USER_TABLE}
242
+ WHERE room = ?;
243
+ `,
244
+ room
245
+ );
246
+ return cursor.toArray().reduce((map, { id, data }) => {
247
+ try {
248
+ const user = JSON.parse(data);
249
+ return map.set(id, user);
250
+ } catch (e) {
251
+ return map;
252
+ }
253
+ }, /* @__PURE__ */ new Map());
118
254
  });
119
255
  }
120
256
  getUsersSize(room) {
121
257
  return __async(this, null, function* () {
122
- const storage = yield this._state.storage.list({ prefix: USER_PREFIX });
123
- return storage.size;
258
+ if (!this._initialized) return 0;
259
+ if (!this._state) return 0;
260
+ yield this._initialized;
261
+ if (this._mode) {
262
+ const storage = yield this._state.storage.list({ prefix: `${KV_USER_PREFIX}::${room}` });
263
+ return storage.size;
264
+ }
265
+ const cursor = this._state.storage.sql.exec(
266
+ sql`
267
+ SELECT COUNT(*) count
268
+ FROM ${SQLITE_USER_TABLE}
269
+ WHERE room = ?;
270
+ `,
271
+ room
272
+ );
273
+ try {
274
+ const result = cursor.one();
275
+ return castNumber(result.count);
276
+ } catch (e) {
277
+ return 0;
278
+ }
124
279
  });
125
280
  }
281
+ initialize(roomContext) {
282
+ this._initialized = (() => __async(this, null, function* () {
283
+ const { state } = roomContext;
284
+ this._state = state;
285
+ if (this._mode === "sqlite") {
286
+ this._state.storage.sql.exec(sql`
287
+ CREATE TABLE IF NOT EXISTS ${SQLITE_STORAGE_TABLE}(
288
+ room TEXT PRIMARY KEY,
289
+ data TEXT NOT NULL
290
+ );
291
+ CREATE TABLE IF NOT EXISTS ${SQLITE_USER_TABLE}(
292
+ id TEXT PRIMARY KEY,
293
+ data TEXT NOT NULL,
294
+ room TEXT NOT NULL
295
+ );
296
+ CREATE INDEX IF NOT EXISTS ${SQLITE_USER_TABLE}_room ON ${SQLITE_USER_TABLE}(room);
297
+ `);
298
+ }
299
+ return true;
300
+ }))();
301
+ return this;
302
+ }
126
303
  setStorageState(room, state) {
127
304
  return __async(this, null, function* () {
128
- yield this._state.storage.put(this._getStorageKey(room), state, {
129
- allowConcurrency: true
130
- });
305
+ if (!this._initialized) return;
306
+ if (!this._state) return;
307
+ yield this._initialized;
308
+ if (this._mode === "kv") {
309
+ yield this._state.storage.put(this._getStorageKey(room), state, { allowConcurrency: true });
310
+ return;
311
+ }
312
+ this._state.storage.sql.exec(
313
+ sql`
314
+ INSERT INTO ${SQLITE_STORAGE_TABLE} VALUES (?, ?);
315
+ `,
316
+ room,
317
+ state
318
+ );
131
319
  });
132
320
  }
133
321
  _getStorageKey(room) {
134
- return `${STORAGE_PREFIX}::${room}`;
322
+ if (this._mode === "sqlite") {
323
+ throw new Error("Attempted to get kv key for sqlite storage");
324
+ }
325
+ return `${KV_STORAGE_PREFIX}::${room}`;
135
326
  }
136
327
  _getUserKey(room, connectionId) {
137
- return `${USER_PREFIX}::${room}::${connectionId}`;
328
+ if (this._mode === "sqlite") {
329
+ throw new Error("Attempted to get kv key for sqlite storage");
330
+ }
331
+ return `${KV_USER_PREFIX}::${room}::${connectionId}`;
138
332
  }
139
333
  };
140
334
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.mjs CHANGED
@@ -22,6 +22,11 @@ var __async = (__this, __arguments, generator) => {
22
22
  // src/PersistenceCloudflareTransactionalStorage.ts
23
23
  import { AbstractPersistence } from "@pluv/io";
24
24
 
25
+ // src/utils/castNumber.ts
26
+ var castNumber = (value) => {
27
+ return typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
28
+ };
29
+
25
30
  // src/utils/partitionByLength.ts
26
31
  var partitionByLength = (arr, length) => {
27
32
  if (!arr.length) return [];
@@ -30,86 +35,275 @@ var partitionByLength = (arr, length) => {
30
35
  return [head, ...partitionByLength(tail, length)];
31
36
  };
32
37
 
38
+ // src/utils/sql.ts
39
+ var sql = (strings, ...values) => {
40
+ return strings.reduce((result, str, i) => {
41
+ const cleaned = str.replace(/(\n[ \t]*)+/g, " ");
42
+ return result + cleaned + (i < values.length ? values[i] : "");
43
+ }, "").trim();
44
+ };
45
+
33
46
  // src/PersistenceCloudflareTransactionalStorage.ts
34
- var CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
35
- var STORAGE_PREFIX = "$PLUV_STORAGE;";
36
- var USER_PREFIX = "$PLUV_USER";
47
+ var KV_CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
48
+ var KV_STORAGE_PREFIX = "$PLUV_STORAGE;";
49
+ var KV_USER_PREFIX = "$PLUV_USER";
50
+ var SQLITE_STORAGE_TABLE = "__pluv_storage";
51
+ var SQLITE_USER_TABLE = "__pluv_user";
37
52
  var PersistenceCloudflareTransactionalStorage = class extends AbstractPersistence {
38
53
  constructor(config) {
39
54
  super();
40
- const { state } = config;
41
- this._state = state;
55
+ this._state = null;
56
+ this._initialized = null;
57
+ const { mode } = config;
58
+ this._mode = mode;
42
59
  }
43
60
  addUser(room, connectionId, user) {
44
61
  return __async(this, null, function* () {
45
- yield this._state.storage.put(this._getUserKey(room, connectionId), user);
62
+ if (!this._initialized) return;
63
+ if (!this._state) return;
64
+ yield this._initialized;
65
+ if (this._mode === "kv") {
66
+ yield this._state.storage.put(this._getUserKey(room, connectionId), user);
67
+ return;
68
+ }
69
+ this._state.storage.sql.exec(
70
+ sql`
71
+ INSERT INTO ${SQLITE_USER_TABLE} VALUES (?, ?, ?);
72
+ `,
73
+ connectionId,
74
+ JSON.stringify(user),
75
+ room
76
+ );
46
77
  });
47
78
  }
48
79
  deleteStorageState(room) {
49
80
  return __async(this, null, function* () {
50
- yield this._state.storage.delete(this._getStorageKey(room));
81
+ if (!this._initialized) return;
82
+ if (!this._state) return;
83
+ yield this._initialized;
84
+ if (this._mode === "kv") {
85
+ yield this._state.storage.delete(this._getStorageKey(room));
86
+ return;
87
+ }
88
+ this._state.storage.sql.exec(
89
+ sql`
90
+ DELETE FROM ${SQLITE_STORAGE_TABLE}
91
+ WHERE room = ?;
92
+ `,
93
+ room
94
+ );
51
95
  });
52
96
  }
53
97
  deleteUser(room, connectionId) {
54
98
  return __async(this, null, function* () {
55
- yield this._state.storage.delete(this._getUserKey(room, connectionId));
99
+ if (!this._initialized) return;
100
+ if (!this._state) return;
101
+ yield this._initialized;
102
+ if (this._mode === "kv") {
103
+ yield this._state.storage.delete(this._getUserKey(room, connectionId));
104
+ return;
105
+ }
106
+ this._state.storage.sql.exec(
107
+ sql`
108
+ DELETE FROM ${SQLITE_USER_TABLE}
109
+ WHERE room = ?
110
+ AND id = ?;
111
+ `,
112
+ room,
113
+ connectionId
114
+ );
56
115
  });
57
116
  }
58
117
  deleteUsers(room) {
59
118
  return __async(this, null, function* () {
60
- const map = yield this._state.storage.list({
61
- allowConcurrency: true,
62
- noCache: true,
63
- prefix: USER_PREFIX
64
- });
65
- const partitions = partitionByLength(Array.from(map.keys()), CLOUDFLARE_DELETE_BATCH_LIMIT);
66
- yield this._state.storage.transaction((tx) => __async(this, null, function* () {
67
- yield partitions.reduce((promise, partition) => __async(this, null, function* () {
68
- return yield promise.then(() => __async(this, null, function* () {
119
+ if (!this._initialized) return;
120
+ if (!this._state) return;
121
+ yield this._initialized;
122
+ if (this._mode === "kv") {
123
+ const map = yield this._state.storage.list({
124
+ allowConcurrency: true,
125
+ noCache: true,
126
+ prefix: `${KV_USER_PREFIX}::${room}`
127
+ });
128
+ const partitions = partitionByLength(Array.from(map.keys()), KV_CLOUDFLARE_DELETE_BATCH_LIMIT);
129
+ yield this._state.storage.transaction((tx) => __async(this, null, function* () {
130
+ yield partitions.reduce((promise, partition) => __async(this, null, function* () {
131
+ yield promise;
69
132
  yield tx.delete(partition);
70
- }));
71
- }), Promise.resolve());
72
- }));
133
+ }), Promise.resolve());
134
+ }));
135
+ return;
136
+ }
137
+ this._state.storage.sql.exec(
138
+ sql`
139
+ DELETE FROM ${SQLITE_USER_TABLE}
140
+ WHERE room = ?;
141
+ `,
142
+ room
143
+ );
73
144
  });
74
145
  }
75
146
  getStorageState(room) {
76
147
  return __async(this, null, function* () {
77
- const storage = yield this._state.storage.get(this._getStorageKey(room));
78
- return storage != null ? storage : null;
148
+ if (!this._initialized) return null;
149
+ if (!this._state) return null;
150
+ yield this._initialized;
151
+ if (this._mode === "kv") {
152
+ const storage = yield this._state.storage.get(this._getStorageKey(room));
153
+ return storage != null ? storage : null;
154
+ }
155
+ const cursor = this._state.storage.sql.exec(
156
+ sql`
157
+ SELECT room, data
158
+ FROM ${SQLITE_STORAGE_TABLE}
159
+ WHERE room = ?;
160
+ `,
161
+ room
162
+ );
163
+ try {
164
+ const result = cursor.one();
165
+ return result.data;
166
+ } catch (e) {
167
+ return null;
168
+ }
79
169
  });
80
170
  }
81
171
  getUser(room, connectionId) {
82
172
  return __async(this, null, function* () {
83
- const user = yield this._state.storage.get(this._getUserKey(room, connectionId));
84
- return user != null ? user : null;
173
+ if (!this._initialized) return null;
174
+ if (!this._state) return null;
175
+ yield this._initialized;
176
+ if (this._mode === "kv") {
177
+ const user = yield this._state.storage.get(this._getUserKey(room, connectionId));
178
+ return user != null ? user : null;
179
+ }
180
+ const cursor = this._state.storage.sql.exec(
181
+ sql`
182
+ SELECT id, data, room
183
+ FROM ${SQLITE_USER_TABLE}
184
+ WHERE room = ?
185
+ AND id = ?;
186
+ `,
187
+ room,
188
+ connectionId
189
+ );
190
+ try {
191
+ const result = cursor.one();
192
+ const user = JSON.parse(result.data);
193
+ return user;
194
+ } catch (e) {
195
+ return null;
196
+ }
85
197
  });
86
198
  }
87
199
  getUsers(room) {
88
200
  return __async(this, null, function* () {
89
- const storage = yield this._state.storage.list({
90
- prefix: USER_PREFIX
91
- });
92
- return Array.from(storage.values());
201
+ if (!this._initialized) return /* @__PURE__ */ new Map();
202
+ if (!this._state) return /* @__PURE__ */ new Map();
203
+ yield this._initialized;
204
+ if (this._mode === "kv") {
205
+ const entries = yield this._state.storage.list({
206
+ prefix: `${KV_USER_PREFIX}::${room}`
207
+ }).then((map) => Array.from(map.entries()));
208
+ return entries.reduce((map, [key, user]) => {
209
+ const connectionId = key.split("::").slice(-1)[0];
210
+ return !!connectionId ? map.set(connectionId, user) : map;
211
+ }, /* @__PURE__ */ new Map());
212
+ }
213
+ const cursor = this._state.storage.sql.exec(
214
+ sql`
215
+ SELECT id, room, data
216
+ FROM ${SQLITE_USER_TABLE}
217
+ WHERE room = ?;
218
+ `,
219
+ room
220
+ );
221
+ return cursor.toArray().reduce((map, { id, data }) => {
222
+ try {
223
+ const user = JSON.parse(data);
224
+ return map.set(id, user);
225
+ } catch (e) {
226
+ return map;
227
+ }
228
+ }, /* @__PURE__ */ new Map());
93
229
  });
94
230
  }
95
231
  getUsersSize(room) {
96
232
  return __async(this, null, function* () {
97
- const storage = yield this._state.storage.list({ prefix: USER_PREFIX });
98
- return storage.size;
233
+ if (!this._initialized) return 0;
234
+ if (!this._state) return 0;
235
+ yield this._initialized;
236
+ if (this._mode) {
237
+ const storage = yield this._state.storage.list({ prefix: `${KV_USER_PREFIX}::${room}` });
238
+ return storage.size;
239
+ }
240
+ const cursor = this._state.storage.sql.exec(
241
+ sql`
242
+ SELECT COUNT(*) count
243
+ FROM ${SQLITE_USER_TABLE}
244
+ WHERE room = ?;
245
+ `,
246
+ room
247
+ );
248
+ try {
249
+ const result = cursor.one();
250
+ return castNumber(result.count);
251
+ } catch (e) {
252
+ return 0;
253
+ }
99
254
  });
100
255
  }
256
+ initialize(roomContext) {
257
+ this._initialized = (() => __async(this, null, function* () {
258
+ const { state } = roomContext;
259
+ this._state = state;
260
+ if (this._mode === "sqlite") {
261
+ this._state.storage.sql.exec(sql`
262
+ CREATE TABLE IF NOT EXISTS ${SQLITE_STORAGE_TABLE}(
263
+ room TEXT PRIMARY KEY,
264
+ data TEXT NOT NULL
265
+ );
266
+ CREATE TABLE IF NOT EXISTS ${SQLITE_USER_TABLE}(
267
+ id TEXT PRIMARY KEY,
268
+ data TEXT NOT NULL,
269
+ room TEXT NOT NULL
270
+ );
271
+ CREATE INDEX IF NOT EXISTS ${SQLITE_USER_TABLE}_room ON ${SQLITE_USER_TABLE}(room);
272
+ `);
273
+ }
274
+ return true;
275
+ }))();
276
+ return this;
277
+ }
101
278
  setStorageState(room, state) {
102
279
  return __async(this, null, function* () {
103
- yield this._state.storage.put(this._getStorageKey(room), state, {
104
- allowConcurrency: true
105
- });
280
+ if (!this._initialized) return;
281
+ if (!this._state) return;
282
+ yield this._initialized;
283
+ if (this._mode === "kv") {
284
+ yield this._state.storage.put(this._getStorageKey(room), state, { allowConcurrency: true });
285
+ return;
286
+ }
287
+ this._state.storage.sql.exec(
288
+ sql`
289
+ INSERT INTO ${SQLITE_STORAGE_TABLE} VALUES (?, ?);
290
+ `,
291
+ room,
292
+ state
293
+ );
106
294
  });
107
295
  }
108
296
  _getStorageKey(room) {
109
- return `${STORAGE_PREFIX}::${room}`;
297
+ if (this._mode === "sqlite") {
298
+ throw new Error("Attempted to get kv key for sqlite storage");
299
+ }
300
+ return `${KV_STORAGE_PREFIX}::${room}`;
110
301
  }
111
302
  _getUserKey(room, connectionId) {
112
- return `${USER_PREFIX}::${room}::${connectionId}`;
303
+ if (this._mode === "sqlite") {
304
+ throw new Error("Attempted to get kv key for sqlite storage");
305
+ }
306
+ return `${KV_USER_PREFIX}::${room}::${connectionId}`;
113
307
  }
114
308
  };
115
309
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pluv/persistence-cloudflare-transactional-storage",
3
- "version": "0.41.7",
3
+ "version": "0.42.0",
4
4
  "description": "@pluv/io crdt storage persistence for cloudflare via transactional storage",
5
5
  "author": "leedavidcs",
6
6
  "license": "MIT",
@@ -17,16 +17,16 @@
17
17
  "access": "public"
18
18
  },
19
19
  "dependencies": {
20
- "@pluv/io": "^0.41.7",
21
- "@pluv/types": "^0.41.7"
20
+ "@pluv/io": "^0.42.0",
21
+ "@pluv/types": "^0.42.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@cloudflare/workers-types": "^4.20250418.0",
25
- "eslint": "^9.25.0",
24
+ "@cloudflare/workers-types": "^4.20250427.0",
25
+ "eslint": "^9.25.1",
26
26
  "tsup": "^8.4.0",
27
27
  "typescript": "^5.8.3",
28
- "@pluv/tsconfig": "^0.41.7",
29
- "eslint-config-pluv": "^0.41.7"
28
+ "@pluv/tsconfig": "^0.42.0",
29
+ "eslint-config-pluv": "^0.42.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsup src/index.ts --format esm,cjs --dts",
@@ -1,6 +1,6 @@
1
1
  import { AbstractPersistence } from "@pluv/io";
2
2
  import type { JsonObject } from "@pluv/types";
3
- import { partitionByLength } from "./utils";
3
+ import { castNumber, partitionByLength, sql } from "./utils";
4
4
 
5
5
  /**
6
6
  * !HACK
@@ -8,92 +8,319 @@ import { partitionByLength } from "./utils";
8
8
  * @see https://developers.cloudflare.com/durable-objects/api/transactional-storage-api/#delete
9
9
  * @date July 8, 2024
10
10
  */
11
- const CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
12
- const STORAGE_PREFIX = "$PLUV_STORAGE;";
13
- const USER_PREFIX = "$PLUV_USER";
11
+ const KV_CLOUDFLARE_DELETE_BATCH_LIMIT = 128;
12
+ const KV_STORAGE_PREFIX = "$PLUV_STORAGE;";
13
+ const KV_USER_PREFIX = "$PLUV_USER";
14
+
15
+ const SQLITE_STORAGE_TABLE = "__pluv_storage";
16
+ const SQLITE_USER_TABLE = "__pluv_user";
14
17
 
15
18
  export interface PersistenceCloudflareTransactionalStorageConfig {
16
- state: DurableObjectState;
19
+ mode: "kv" | "sqlite";
17
20
  }
18
-
19
21
  export class PersistenceCloudflareTransactionalStorage extends AbstractPersistence {
20
- private _state: DurableObjectState;
22
+ private _mode: "kv" | "sqlite";
23
+ private _state: DurableObjectState | null = null;
24
+ private _initialized: Promise<true> | null = null;
21
25
 
22
26
  constructor(config: PersistenceCloudflareTransactionalStorageConfig) {
23
27
  super();
24
28
 
25
- const { state } = config;
29
+ const { mode } = config;
26
30
 
27
- this._state = state;
31
+ this._mode = mode;
28
32
  }
29
33
 
30
34
  public async addUser(room: string, connectionId: string, user: JsonObject | null): Promise<void> {
31
- await this._state.storage.put(this._getUserKey(room, connectionId), user);
35
+ if (!this._initialized) return;
36
+ if (!this._state) return;
37
+
38
+ await this._initialized;
39
+
40
+ if (this._mode === "kv") {
41
+ await this._state.storage.put(this._getUserKey(room, connectionId), user);
42
+ return;
43
+ }
44
+
45
+ this._state.storage.sql.exec(
46
+ sql`
47
+ INSERT INTO ${SQLITE_USER_TABLE} VALUES (?, ?, ?);
48
+ `,
49
+ connectionId,
50
+ JSON.stringify(user),
51
+ room,
52
+ );
32
53
  }
33
54
 
34
55
  public async deleteStorageState(room: string): Promise<void> {
35
- await this._state.storage.delete(this._getStorageKey(room));
56
+ if (!this._initialized) return;
57
+ if (!this._state) return;
58
+
59
+ await this._initialized;
60
+
61
+ if (this._mode === "kv") {
62
+ await this._state.storage.delete(this._getStorageKey(room));
63
+ return;
64
+ }
65
+
66
+ this._state.storage.sql.exec(
67
+ sql`
68
+ DELETE FROM ${SQLITE_STORAGE_TABLE}
69
+ WHERE room = ?;
70
+ `,
71
+ room,
72
+ );
36
73
  }
37
74
 
38
75
  public async deleteUser(room: string, connectionId: string): Promise<void> {
39
- await this._state.storage.delete(this._getUserKey(room, connectionId));
76
+ if (!this._initialized) return;
77
+ if (!this._state) return;
78
+
79
+ await this._initialized;
80
+
81
+ if (this._mode === "kv") {
82
+ await this._state.storage.delete(this._getUserKey(room, connectionId));
83
+
84
+ return;
85
+ }
86
+
87
+ this._state.storage.sql.exec(
88
+ sql`
89
+ DELETE FROM ${SQLITE_USER_TABLE}
90
+ WHERE room = ?
91
+ AND id = ?;
92
+ `,
93
+ room,
94
+ connectionId,
95
+ );
40
96
  }
41
97
 
42
98
  public async deleteUsers(room: string): Promise<void> {
43
- const map = await this._state.storage.list({
44
- allowConcurrency: true,
45
- noCache: true,
46
- prefix: USER_PREFIX,
47
- });
99
+ if (!this._initialized) return;
100
+ if (!this._state) return;
48
101
 
49
- const partitions = partitionByLength(Array.from(map.keys()), CLOUDFLARE_DELETE_BATCH_LIMIT);
102
+ await this._initialized;
50
103
 
51
- await this._state.storage.transaction(async (tx) => {
52
- await partitions.reduce(async (promise, partition) => {
53
- return await promise.then(async () => {
104
+ if (this._mode === "kv") {
105
+ const map = await this._state.storage.list({
106
+ allowConcurrency: true,
107
+ noCache: true,
108
+ prefix: `${KV_USER_PREFIX}::${room}`,
109
+ });
110
+
111
+ const partitions = partitionByLength(Array.from(map.keys()), KV_CLOUDFLARE_DELETE_BATCH_LIMIT);
112
+
113
+ await this._state.storage.transaction(async (tx) => {
114
+ await partitions.reduce(async (promise, partition) => {
115
+ await promise;
54
116
  await tx.delete(partition as string[]);
55
- });
56
- }, Promise.resolve());
57
- });
117
+ }, Promise.resolve());
118
+ });
119
+
120
+ return;
121
+ }
122
+
123
+ this._state.storage.sql.exec(
124
+ sql`
125
+ DELETE FROM ${SQLITE_USER_TABLE}
126
+ WHERE room = ?;
127
+ `,
128
+ room,
129
+ );
58
130
  }
59
131
 
60
132
  public async getStorageState(room: string): Promise<string | null> {
61
- const storage = await this._state.storage.get<string>(this._getStorageKey(room));
133
+ if (!this._initialized) return null;
134
+ if (!this._state) return null;
135
+
136
+ await this._initialized;
137
+
138
+ if (this._mode === "kv") {
139
+ const storage = await this._state.storage.get<string>(this._getStorageKey(room));
140
+ return storage ?? null;
141
+ }
62
142
 
63
- return storage ?? null;
143
+ const cursor = this._state.storage.sql.exec<{ room: string; data: string }>(
144
+ sql`
145
+ SELECT room, data
146
+ FROM ${SQLITE_STORAGE_TABLE}
147
+ WHERE room = ?;
148
+ `,
149
+ room,
150
+ );
151
+
152
+ try {
153
+ const result = cursor.one();
154
+
155
+ return result.data;
156
+ } catch {
157
+ return null;
158
+ }
64
159
  }
65
160
 
66
161
  public async getUser(room: string, connectionId: string): Promise<JsonObject | null> {
67
- const user = await this._state.storage.get<JsonObject | null>(this._getUserKey(room, connectionId));
162
+ if (!this._initialized) return null;
163
+ if (!this._state) return null;
164
+
165
+ await this._initialized;
166
+
167
+ if (this._mode === "kv") {
168
+ const user = await this._state.storage.get<JsonObject | null>(this._getUserKey(room, connectionId));
169
+
170
+ return user ?? null;
171
+ }
172
+
173
+ const cursor = this._state.storage.sql.exec<{ id: string; data: string; room: string }>(
174
+ sql`
175
+ SELECT id, data, room
176
+ FROM ${SQLITE_USER_TABLE}
177
+ WHERE room = ?
178
+ AND id = ?;
179
+ `,
180
+ room,
181
+ connectionId,
182
+ );
68
183
 
69
- return user ?? null;
184
+ try {
185
+ const result = cursor.one();
186
+ const user = JSON.parse(result.data);
187
+
188
+ return user;
189
+ } catch {
190
+ return null;
191
+ }
70
192
  }
71
193
 
72
- public async getUsers(room: string): Promise<readonly (JsonObject | null)[]> {
73
- const storage = await this._state.storage.list({
74
- prefix: USER_PREFIX,
75
- });
194
+ public async getUsers(room: string): Promise<Map<[connectionId: string][0], JsonObject | null>> {
195
+ if (!this._initialized) return new Map();
196
+ if (!this._state) return new Map();
197
+
198
+ await this._initialized;
199
+
200
+ if (this._mode === "kv") {
201
+ const entries = await this._state.storage
202
+ .list<JsonObject | null>({
203
+ prefix: `${KV_USER_PREFIX}::${room}`,
204
+ })
205
+ .then((map) => Array.from(map.entries()));
206
+
207
+ return entries.reduce((map, [key, user]) => {
208
+ const connectionId = key.split("::").slice(-1)[0];
209
+
210
+ return !!connectionId ? map.set(connectionId, user) : map;
211
+ }, new Map<string, JsonObject | null>());
212
+ }
213
+
214
+ const cursor = this._state.storage.sql.exec<{ id: string; data: string; room: string }>(
215
+ sql`
216
+ SELECT id, room, data
217
+ FROM ${SQLITE_USER_TABLE}
218
+ WHERE room = ?;
219
+ `,
220
+ room,
221
+ );
76
222
 
77
- return Array.from(storage.values()) as (JsonObject | null)[];
223
+ return cursor.toArray().reduce((map, { id, data }) => {
224
+ try {
225
+ const user = JSON.parse(data) as JsonObject;
226
+
227
+ return map.set(id, user);
228
+ } catch {
229
+ return map;
230
+ }
231
+ }, new Map<string, JsonObject | null>());
78
232
  }
79
233
 
80
234
  public async getUsersSize(room: string): Promise<number> {
81
- const storage = await this._state.storage.list({ prefix: USER_PREFIX });
235
+ if (!this._initialized) return 0;
236
+ if (!this._state) return 0;
237
+
238
+ await this._initialized;
239
+
240
+ if (this._mode) {
241
+ const storage = await this._state.storage.list({ prefix: `${KV_USER_PREFIX}::${room}` });
242
+
243
+ return storage.size;
244
+ }
245
+
246
+ const cursor = this._state.storage.sql.exec<{ count: number }>(
247
+ sql`
248
+ SELECT COUNT(*) count
249
+ FROM ${SQLITE_USER_TABLE}
250
+ WHERE room = ?;
251
+ `,
252
+ room,
253
+ );
254
+
255
+ try {
256
+ const result = cursor.one();
82
257
 
83
- return storage.size;
258
+ return castNumber(result.count);
259
+ } catch {
260
+ return 0;
261
+ }
262
+ }
263
+
264
+ public initialize(roomContext: { state: DurableObjectState }): this {
265
+ this._initialized = (async () => {
266
+ const { state } = roomContext;
267
+
268
+ this._state = state;
269
+
270
+ if (this._mode === "sqlite") {
271
+ this._state.storage.sql.exec(sql`
272
+ CREATE TABLE IF NOT EXISTS ${SQLITE_STORAGE_TABLE}(
273
+ room TEXT PRIMARY KEY,
274
+ data TEXT NOT NULL
275
+ );
276
+ CREATE TABLE IF NOT EXISTS ${SQLITE_USER_TABLE}(
277
+ id TEXT PRIMARY KEY,
278
+ data TEXT NOT NULL,
279
+ room TEXT NOT NULL
280
+ );
281
+ CREATE INDEX IF NOT EXISTS ${SQLITE_USER_TABLE}_room ON ${SQLITE_USER_TABLE}(room);
282
+ `);
283
+ }
284
+
285
+ return true as const;
286
+ })();
287
+
288
+ return this;
84
289
  }
85
290
 
86
291
  public async setStorageState(room: string, state: string): Promise<void> {
87
- await this._state.storage.put(this._getStorageKey(room), state, {
88
- allowConcurrency: true,
89
- });
292
+ if (!this._initialized) return;
293
+ if (!this._state) return;
294
+
295
+ await this._initialized;
296
+
297
+ if (this._mode === "kv") {
298
+ await this._state.storage.put(this._getStorageKey(room), state, { allowConcurrency: true });
299
+ return;
300
+ }
301
+
302
+ this._state.storage.sql.exec(
303
+ sql`
304
+ INSERT INTO ${SQLITE_STORAGE_TABLE} VALUES (?, ?);
305
+ `,
306
+ room,
307
+ state,
308
+ );
90
309
  }
91
310
 
92
311
  private _getStorageKey(room: string): string {
93
- return `${STORAGE_PREFIX}::${room}`;
312
+ if (this._mode === "sqlite") {
313
+ throw new Error("Attempted to get kv key for sqlite storage");
314
+ }
315
+
316
+ return `${KV_STORAGE_PREFIX}::${room}`;
94
317
  }
95
318
 
96
319
  private _getUserKey(room: string, connectionId: string): string {
97
- return `${USER_PREFIX}::${room}::${connectionId}`;
320
+ if (this._mode === "sqlite") {
321
+ throw new Error("Attempted to get kv key for sqlite storage");
322
+ }
323
+
324
+ return `${KV_USER_PREFIX}::${room}::${connectionId}`;
98
325
  }
99
326
  }
@@ -0,0 +1,3 @@
1
+ export const castNumber = (value: any): number => {
2
+ return typeof value === "number" ? value : typeof value === "string" ? Number.parseInt(value, 10) : Number(value);
3
+ };
@@ -1 +1,3 @@
1
+ export { castNumber } from "./castNumber";
1
2
  export { partitionByLength } from "./partitionByLength";
3
+ export { sql } from "./sql";
@@ -0,0 +1,10 @@
1
+ export const sql = (strings: TemplateStringsArray, ...values: any[]): string => {
2
+ return strings
3
+ .reduce((result, str, i) => {
4
+ // Compresses the string to a single line (not including the interpolated values)
5
+ const cleaned = str.replace(/(\n[ \t]*)+/g, " ");
6
+
7
+ return result + cleaned + (i < values.length ? values[i] : "");
8
+ }, "")
9
+ .trim();
10
+ };