@effect/platform-browser 4.0.0-beta.7 → 4.0.0-beta.70

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 (91) hide show
  1. package/dist/BrowserCrypto.d.ts +23 -0
  2. package/dist/BrowserCrypto.d.ts.map +1 -0
  3. package/dist/BrowserCrypto.js +61 -0
  4. package/dist/BrowserCrypto.js.map +1 -0
  5. package/dist/BrowserHttpClient.d.ts +32 -16
  6. package/dist/BrowserHttpClient.d.ts.map +1 -1
  7. package/dist/BrowserHttpClient.js +66 -18
  8. package/dist/BrowserHttpClient.js.map +1 -1
  9. package/dist/BrowserKeyValueStore.d.ts +17 -14
  10. package/dist/BrowserKeyValueStore.d.ts.map +1 -1
  11. package/dist/BrowserKeyValueStore.js +130 -10
  12. package/dist/BrowserKeyValueStore.js.map +1 -1
  13. package/dist/BrowserPersistence.d.ts +21 -0
  14. package/dist/BrowserPersistence.d.ts.map +1 -0
  15. package/dist/BrowserPersistence.js +188 -0
  16. package/dist/BrowserPersistence.js.map +1 -0
  17. package/dist/BrowserRuntime.d.ts +31 -4
  18. package/dist/BrowserRuntime.d.ts.map +1 -1
  19. package/dist/BrowserRuntime.js +3 -1
  20. package/dist/BrowserRuntime.js.map +1 -1
  21. package/dist/BrowserSocket.d.ts +31 -6
  22. package/dist/BrowserSocket.d.ts.map +1 -1
  23. package/dist/BrowserSocket.js +31 -6
  24. package/dist/BrowserSocket.js.map +1 -1
  25. package/dist/BrowserStream.d.ts +33 -5
  26. package/dist/BrowserStream.d.ts.map +1 -1
  27. package/dist/BrowserStream.js +33 -5
  28. package/dist/BrowserStream.js.map +1 -1
  29. package/dist/BrowserWorker.d.ts +8 -4
  30. package/dist/BrowserWorker.d.ts.map +1 -1
  31. package/dist/BrowserWorker.js +30 -6
  32. package/dist/BrowserWorker.js.map +1 -1
  33. package/dist/BrowserWorkerRunner.d.ts +12 -6
  34. package/dist/BrowserWorkerRunner.d.ts.map +1 -1
  35. package/dist/BrowserWorkerRunner.js +34 -10
  36. package/dist/BrowserWorkerRunner.js.map +1 -1
  37. package/dist/Clipboard.d.ts +40 -13
  38. package/dist/Clipboard.d.ts.map +1 -1
  39. package/dist/Clipboard.js +33 -11
  40. package/dist/Clipboard.js.map +1 -1
  41. package/dist/Geolocation.d.ts +58 -24
  42. package/dist/Geolocation.d.ts.map +1 -1
  43. package/dist/Geolocation.js +46 -16
  44. package/dist/Geolocation.js.map +1 -1
  45. package/dist/IndexedDb.d.ts +77 -0
  46. package/dist/IndexedDb.d.ts.map +1 -0
  47. package/dist/IndexedDb.js +87 -0
  48. package/dist/IndexedDb.js.map +1 -0
  49. package/dist/IndexedDbDatabase.d.ts +150 -0
  50. package/dist/IndexedDbDatabase.d.ts.map +1 -0
  51. package/dist/IndexedDbDatabase.js +324 -0
  52. package/dist/IndexedDbDatabase.js.map +1 -0
  53. package/dist/IndexedDbQueryBuilder.d.ts +430 -0
  54. package/dist/IndexedDbQueryBuilder.d.ts.map +1 -0
  55. package/dist/IndexedDbQueryBuilder.js +927 -0
  56. package/dist/IndexedDbQueryBuilder.js.map +1 -0
  57. package/dist/IndexedDbTable.d.ts +153 -0
  58. package/dist/IndexedDbTable.d.ts.map +1 -0
  59. package/dist/IndexedDbTable.js +58 -0
  60. package/dist/IndexedDbTable.js.map +1 -0
  61. package/dist/IndexedDbVersion.d.ts +80 -0
  62. package/dist/IndexedDbVersion.d.ts.map +1 -0
  63. package/dist/IndexedDbVersion.js +25 -0
  64. package/dist/IndexedDbVersion.js.map +1 -0
  65. package/dist/Permissions.d.ts +45 -15
  66. package/dist/Permissions.d.ts.map +1 -1
  67. package/dist/Permissions.js +35 -10
  68. package/dist/Permissions.js.map +1 -1
  69. package/dist/index.d.ts +357 -10
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +357 -10
  72. package/dist/index.js.map +1 -1
  73. package/package.json +4 -3
  74. package/src/BrowserCrypto.ts +71 -0
  75. package/src/BrowserHttpClient.ts +76 -24
  76. package/src/BrowserKeyValueStore.ts +146 -12
  77. package/src/BrowserPersistence.ts +319 -0
  78. package/src/BrowserRuntime.ts +31 -4
  79. package/src/BrowserSocket.ts +31 -6
  80. package/src/BrowserStream.ts +33 -5
  81. package/src/BrowserWorker.ts +30 -6
  82. package/src/BrowserWorkerRunner.ts +34 -10
  83. package/src/Clipboard.ts +37 -13
  84. package/src/Geolocation.ts +54 -20
  85. package/src/IndexedDb.ts +115 -0
  86. package/src/IndexedDbDatabase.ts +645 -0
  87. package/src/IndexedDbQueryBuilder.ts +2040 -0
  88. package/src/IndexedDbTable.ts +247 -0
  89. package/src/IndexedDbVersion.ts +119 -0
  90. package/src/Permissions.ts +40 -13
  91. package/src/index.ts +364 -10
@@ -1,13 +1,45 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * Browser implementations of the Effect `HttpClient`.
3
+ *
4
+ * This module exposes HTTP client layers for code that runs in a browser. It
5
+ * re-exports the fetch-based client for the common case, where requests should
6
+ * use the platform `fetch` implementation and optional `RequestInit` defaults,
7
+ * and it provides an `XMLHttpRequest`-backed layer for integrations that need
8
+ * XHR semantics such as response type control or environments where XHR is the
9
+ * required transport.
10
+ *
11
+ * Use these layers for single-page applications, browser tests, generated
12
+ * `HttpApiClient`s, and other client-side Effect programs that need HTTP
13
+ * requests to participate in interruption, typed transport / decode errors, and
14
+ * Effect's response body readers.
15
+ *
16
+ * Browser networking rules still apply. Cross-origin requests are subject to
17
+ * CORS preflights and server allowlists, especially when using custom headers,
18
+ * non-simple methods, or non-simple content types. Only CORS-exposed response
19
+ * headers are readable, and cookies / authentication are controlled by the
20
+ * browser and the configured fetch `RequestInit.credentials` policy. The XHR
21
+ * layer uses the browser's `XMLHttpRequest` defaults for credentials.
22
+ *
23
+ * Body handling differs between the transports. Fetch delegates body framing to
24
+ * the Web Fetch implementation. The XHR client sends empty, raw, `Uint8Array`,
25
+ * and `FormData` bodies directly, buffers `Stream` request bodies before
26
+ * sending, defaults responses to text, and can be switched to `ArrayBuffer`
27
+ * responses with `withXHRArrayBuffer`. When sending `FormData`, avoid setting
28
+ * an incompatible `Content-Type` header so the browser can generate the
29
+ * multipart boundary.
30
+ *
31
+ * @since 4.0.0
3
32
  */
4
33
  import * as Cause from "effect/Cause"
34
+ import * as Context from "effect/Context"
5
35
  import * as Effect from "effect/Effect"
6
36
  import type { LazyArg } from "effect/Function"
7
37
  import * as Inspectable from "effect/Inspectable"
8
38
  import type * as Layer from "effect/Layer"
39
+ import * as Option from "effect/Option"
40
+ import { type Pipeable, pipeArguments } from "effect/Pipeable"
9
41
  import * as Queue from "effect/Queue"
10
- import * as ServiceMap from "effect/ServiceMap"
42
+ import type * as Schema from "effect/Schema"
11
43
  import * as Stream from "effect/Stream"
12
44
  import * as Cookies from "effect/unstable/http/Cookies"
13
45
  import * as Headers from "effect/unstable/http/Headers"
@@ -25,18 +57,24 @@ import * as HeaderParser from "multipasta/HeadersParser"
25
57
 
26
58
  export {
27
59
  /**
28
- * @since 1.0.0
60
+ * Context reference for the `fetch` implementation used by the fetch-based HTTP client.
61
+ *
29
62
  * @category Fetch
63
+ * @since 4.0.0
30
64
  */
31
65
  Fetch,
32
66
  /**
33
- * @since 1.0.0
67
+ * Layer that provides an `HttpClient` implementation backed by the configured `Fetch` function.
68
+ *
34
69
  * @category Fetch
70
+ * @since 4.0.0
35
71
  */
36
72
  layer as layerFetch,
37
73
  /**
38
- * @since 1.0.0
74
+ * Service containing default `RequestInit` options for the fetch-based HTTP client.
75
+ *
39
76
  * @category Fetch
77
+ * @since 4.0.0
40
78
  */
41
79
  RequestInit
42
80
  } from "effect/unstable/http/FetchHttpClient"
@@ -46,23 +84,29 @@ export {
46
84
  // =============================================================================
47
85
 
48
86
  /**
49
- * @since 1.0.0
50
- * @category Models
87
+ * Allowed response body modes for the browser XHR HTTP client.
88
+ *
89
+ * @category models
90
+ * @since 4.0.0
51
91
  */
52
92
  export type XHRResponseType = "arraybuffer" | "text"
53
93
 
54
94
  /**
55
- * @since 1.0.0
56
- * @category References
95
+ * Reference that controls the `XMLHttpRequest.responseType` used by the browser XHR HTTP client, defaulting to `"text"`.
96
+ *
97
+ * @category references
98
+ * @since 4.0.0
57
99
  */
58
- export const CurrentXHRResponseType: ServiceMap.Reference<XHRResponseType> = ServiceMap.Reference(
100
+ export const CurrentXHRResponseType: Context.Reference<XHRResponseType> = Context.Reference(
59
101
  "@effect/platform-browser/BrowserHttpClient/CurrentXHRResponseType",
60
102
  { defaultValue: (): XHRResponseType => "text" }
61
103
  )
62
104
 
63
105
  /**
64
- * @since 1.0.0
65
- * @category References
106
+ * Runs an effect with `CurrentXHRResponseType` set to `"arraybuffer"` so the XHR HTTP client receives response bodies as `ArrayBuffer` values.
107
+ *
108
+ * @category references
109
+ * @since 4.0.0
66
110
  */
67
111
  export const withXHRArrayBuffer = <A, E, R>(
68
112
  self: Effect.Effect<A, E, R>
@@ -74,10 +118,12 @@ export const withXHRArrayBuffer = <A, E, R>(
74
118
  )
75
119
 
76
120
  /**
77
- * @since 1.0.0
78
- * @category Services
121
+ * Service tag for the `XMLHttpRequest` constructor used by the browser XHR HTTP client.
122
+ *
123
+ * @category services
124
+ * @since 4.0.0
79
125
  */
80
- export class XMLHttpRequest extends ServiceMap.Service<
126
+ export class XMLHttpRequest extends Context.Service<
81
127
  XMLHttpRequest,
82
128
  LazyArg<globalThis.XMLHttpRequest>
83
129
  >()("@effect/platform-browser/BrowserHttpClient/XMLHttpRequest") {}
@@ -87,8 +133,8 @@ const makeXhrRequest = () => new globalThis.XMLHttpRequest()
87
133
  const makeXmlHttpRequest = HttpClient.make(
88
134
  (request, url, signal, fiber) =>
89
135
  Effect.suspend(() => {
90
- const xhr = ServiceMap.getOrElse(
91
- fiber.services,
136
+ const xhr = Context.getOrElse(
137
+ fiber.context,
92
138
  XMLHttpRequest,
93
139
  () => makeXhrRequest
94
140
  )()
@@ -213,7 +259,7 @@ abstract class IncomingMessageImpl<E> extends Inspectable.Class implements HttpI
213
259
  }
214
260
 
215
261
  get remoteAddress() {
216
- return undefined
262
+ return Option.none()
217
263
  }
218
264
 
219
265
  _textEffect: Effect.Effect<string, E> | undefined
@@ -247,10 +293,10 @@ abstract class IncomingMessageImpl<E> extends Inspectable.Class implements HttpI
247
293
  )
248
294
  }
249
295
 
250
- get json(): Effect.Effect<unknown, E> {
296
+ get json(): Effect.Effect<Schema.Json, E> {
251
297
  return Effect.flatMap(this.text, (text) =>
252
298
  Effect.try({
253
- try: () => text === "" ? null : JSON.parse(text) as unknown,
299
+ try: () => text === "" ? null : JSON.parse(text),
254
300
  catch: this.onError
255
301
  }))
256
302
  }
@@ -334,7 +380,7 @@ abstract class IncomingMessageImpl<E> extends Inspectable.Class implements HttpI
334
380
  }
335
381
 
336
382
  class ClientResponseImpl extends IncomingMessageImpl<HttpClientError.HttpClientError>
337
- implements HttpClientResponse.HttpClientResponse
383
+ implements HttpClientResponse.HttpClientResponse, Pipeable
338
384
  {
339
385
  readonly [HttpClientResponse.TypeId]: typeof HttpClientResponse.TypeId
340
386
  readonly request: HttpClientRequest.HttpClientRequest
@@ -374,12 +420,18 @@ class ClientResponseImpl extends IncomingMessageImpl<HttpClientError.HttpClientE
374
420
  status: this.status
375
421
  })
376
422
  }
423
+
424
+ pipe() {
425
+ return pipeArguments(this, arguments)
426
+ }
377
427
  }
378
428
 
379
429
  /**
380
- * @since 1.0.0
381
- * @category Layers
430
+ * Layer that provides an `HttpClient` implementation backed by the browser `XMLHttpRequest` API.
431
+ *
432
+ * @category layers
433
+ * @since 4.0.0
382
434
  */
383
- export const layerXMLHttpRequest: Layer.Layer<HttpClient.HttpClient> = HttpClient.layerMergedServices(
435
+ export const layerXMLHttpRequest: Layer.Layer<HttpClient.HttpClient> = HttpClient.layerMergedContext(
384
436
  Effect.succeed(makeXmlHttpRequest)
385
437
  )
@@ -1,29 +1,163 @@
1
1
  /**
2
- * @since 1.0.0
2
+ * Browser-backed `KeyValueStore` layers for Effect programs.
3
+ *
4
+ * This module provides `KeyValueStore` implementations backed by the browser's
5
+ * synchronous Web Storage APIs: `localStorage` for origin-scoped data that
6
+ * persists across page reloads and browser sessions, and `sessionStorage` for
7
+ * page-session data that is cleared when that tab or window's page session
8
+ * ends. They are useful for small client-side values such as user preferences,
9
+ * feature flags, lightweight caches, persisted drafts, or session-only workflow
10
+ * state.
11
+ *
12
+ * Web Storage is only available in browser environments and is scoped by origin.
13
+ * Browsers may deny access in private modes or restricted contexts, and writes
14
+ * can fail when storage quotas are exceeded. The API stores strings and runs
15
+ * synchronously on the main thread, so prefer it for small payloads and avoid
16
+ * treating it as a database or a secure place for sensitive data.
17
+ *
18
+ * @since 4.0.0
3
19
  */
4
- import type * as Layer from "effect/Layer"
20
+ import * as Effect from "effect/Effect"
21
+ import * as Layer from "effect/Layer"
5
22
  import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
23
+ import { IndexedDb } from "./IndexedDb.ts"
6
24
 
7
25
  /**
8
- * Creates a `KeyValueStore` layer that uses the browser's `localStorage` api.
9
- *
10
- * Values are stored between sessions.
26
+ * Creates a `KeyValueStore` layer that uses the browser's `localStorage` API and stores values between browser sessions.
11
27
  *
12
- * @since 1.0.0
13
- * @category Layers
28
+ * @category layers
29
+ * @since 4.0.0
14
30
  */
15
31
  export const layerLocalStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
16
32
  globalThis.localStorage
17
33
  )
18
34
 
19
35
  /**
20
- * Creates a `KeyValueStore` layer that uses the browser's `sessionStorage` api.
36
+ * Creates a `KeyValueStore` layer that uses the browser's `sessionStorage` API and stores values only for the current session.
21
37
  *
22
- * Values are stored only for the current session.
23
- *
24
- * @since 1.0.0
25
- * @category Layers
38
+ * @category layers
39
+ * @since 4.0.0
26
40
  */
27
41
  export const layerSessionStorage: Layer.Layer<KeyValueStore.KeyValueStore> = KeyValueStore.layerStorage(() =>
28
42
  globalThis.sessionStorage
29
43
  )
44
+
45
+ /**
46
+ * Creates a `KeyValueStore` layer backed by IndexedDB.
47
+ *
48
+ * @category layers
49
+ * @since 4.0.0
50
+ */
51
+ export const layerIndexedDb = (options?: {
52
+ readonly database?: string | undefined
53
+ }): Layer.Layer<KeyValueStore.KeyValueStore, never, IndexedDb> =>
54
+ Layer.effect(KeyValueStore.KeyValueStore)(
55
+ Effect.gen(function*() {
56
+ const db = yield* Effect.acquireRelease(
57
+ openDatabase(options?.database ?? "effect_key_value_store"),
58
+ (db) => Effect.sync(() => db.close())
59
+ ).pipe(Effect.orDie)
60
+
61
+ return KeyValueStore.make({
62
+ clear: Effect.suspend(() => {
63
+ const store = getKvsEntriesStore(db, "readwrite")
64
+ return idbRequest({ method: "clear", message: "Failed to clear backing store" }, () => store.clear())
65
+ }),
66
+ get: (key: string) =>
67
+ Effect.map(
68
+ Effect.suspend(() => {
69
+ const store = getKvsEntriesStore(db, "readonly")
70
+ return idbRequest<{ key: string; value: string } | undefined>({
71
+ method: "get",
72
+ message: "Failed to get value from backing store",
73
+ key
74
+ }, () => store.get(key))
75
+ }),
76
+ (found) => typeof found?.value === "string" ? found.value : undefined
77
+ ),
78
+ getUint8Array: (key: string) =>
79
+ Effect.map(
80
+ Effect.suspend(() => {
81
+ const store = getKvsEntriesStore(db, "readonly")
82
+ return idbRequest<{ key: string; value: Uint8Array } | undefined>({
83
+ method: "getUint8Array",
84
+ message: "Failed to get value from backing store",
85
+ key
86
+ }, () => store.get(key))
87
+ }),
88
+ (found) => found?.value && found.value instanceof Uint8Array ? found.value : undefined
89
+ ),
90
+ set: (key: string, value: string | Uint8Array) =>
91
+ Effect.asVoid(Effect.suspend(() => {
92
+ const store = getKvsEntriesStore(db, "readwrite")
93
+ return idbRequest(
94
+ { method: "set", message: "Failed to set value in backing store", key },
95
+ () => store.put({ key, value })
96
+ )
97
+ })),
98
+ size: Effect.suspend(() => {
99
+ const store = getKvsEntriesStore(db, "readonly")
100
+ return idbRequest<number>(
101
+ { method: "size", message: "Failed to get backing store size" },
102
+ () => store.count()
103
+ )
104
+ }),
105
+ remove: (key: string) =>
106
+ Effect.asVoid(Effect.suspend(() => {
107
+ const store = getKvsEntriesStore(db, "readwrite")
108
+ return idbRequest(
109
+ { method: "remove", message: "Failed to remove value from backing store", key },
110
+ () => store.delete(key)
111
+ )
112
+ }))
113
+ })
114
+ })
115
+ )
116
+
117
+ const databaseVersion = 1
118
+ const entriesStoreName = "entries"
119
+ const openDatabase = Effect.fnUntraced(function*(database: string) {
120
+ const idb = (yield* IndexedDb).indexedDB
121
+ const openRequest = yield* Effect.try({
122
+ try: () => idb.open(database, databaseVersion),
123
+ catch: (cause) =>
124
+ new KeyValueStore.KeyValueStoreError({
125
+ method: "open",
126
+ message: "Failed to open backing store database",
127
+ cause
128
+ })
129
+ })
130
+ openRequest.onupgradeneeded = () => {
131
+ const db = openRequest.result
132
+ if (!db.objectStoreNames.contains(entriesStoreName)) {
133
+ db.createObjectStore(entriesStoreName, { keyPath: "key" })
134
+ }
135
+ }
136
+ return yield* idbRequest({ method: "open", message: "Failed to open backing store database" }, () => openRequest)
137
+ })
138
+
139
+ const idbRequest = <A>(
140
+ failArgs: { method: string; message: string; key?: string },
141
+ evaluate: () => IDBRequest<A>
142
+ ): Effect.Effect<A, KeyValueStore.KeyValueStoreError> =>
143
+ Effect.callback<A, KeyValueStore.KeyValueStoreError>((resume) => {
144
+ const request = evaluate()
145
+ if (request.readyState === "done") {
146
+ return resume(Effect.succeed(request.result))
147
+ }
148
+ request.onsuccess = () => {
149
+ resume(Effect.succeed(request.result))
150
+ }
151
+ request.onerror = () =>
152
+ resume(Effect.fail(
153
+ new KeyValueStore.KeyValueStoreError({
154
+ ...failArgs,
155
+ cause: request.error
156
+ })
157
+ ))
158
+ })
159
+
160
+ const getKvsEntriesStore = (db: IDBDatabase, mode: IDBTransactionMode) => {
161
+ const transaction = db.transaction(entriesStoreName, mode)
162
+ return transaction.objectStore(entriesStoreName)
163
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Browser-backed persistence layers for Effect's persistence service.
3
+ *
4
+ * This module provides IndexedDB implementations of the Effect persistence services for applications that need a
5
+ * durable client-side cache, such as remembered query results, offline-capable workflows, or values that should survive
6
+ * page reloads. Entries are stored by persistence store id and key in a shared IndexedDB object store, with optional
7
+ * expiration timestamps for TTL-based invalidation.
8
+ *
9
+ * Because this storage depends on browser IndexedDB, operations can fail when storage is unavailable, quota is exceeded,
10
+ * data is cleared by the user or browser, or the payload cannot be structured-cloned by IndexedDB. Expired entries are
11
+ * removed lazily when they are read, so this module is best suited for application-managed cached objects rather than
12
+ * security-sensitive or authoritative data.
13
+ *
14
+ * @since 4.0.0
15
+ */
16
+ import type * as Arr from "effect/Array"
17
+ import * as Clock from "effect/Clock"
18
+ import type * as Duration from "effect/Duration"
19
+ import * as Effect from "effect/Effect"
20
+ import * as Layer from "effect/Layer"
21
+ import * as Persistence from "effect/unstable/persistence/Persistence"
22
+
23
+ /**
24
+ * Creates a `BackingPersistence` layer backed by IndexedDB, optionally using the provided database name.
25
+ *
26
+ * @category layers
27
+ * @since 4.0.0
28
+ */
29
+ export const layerBackingIndexedDb = (options?: {
30
+ readonly database?: string | undefined
31
+ }): Layer.Layer<Persistence.BackingPersistence> =>
32
+ Layer.effect(Persistence.BackingPersistence)(Effect.gen(function*() {
33
+ const db = yield* Effect.acquireRelease(
34
+ openDatabase(options?.database ?? defaultDatabase),
35
+ (db) => Effect.sync(() => db.close())
36
+ ).pipe(Effect.orDie)
37
+
38
+ return Persistence.BackingPersistence.of({
39
+ make: Effect.fnUntraced(function*(storeId) {
40
+ const clock = yield* Clock.Clock
41
+ return {
42
+ get: (key) => get(db, clock, storeId, key),
43
+ getMany: (keys) => getMany(db, clock, storeId, keys),
44
+ set: (key, value, ttl) => set(db, clock, storeId, key, value, ttl),
45
+ setMany: (entries) => setMany(db, clock, storeId, entries),
46
+ remove: (key) => remove(db, storeId, key),
47
+ clear: clear(db, storeId)
48
+ }
49
+ })
50
+ })
51
+ }))
52
+
53
+ const defaultDatabase = "effect_persistence"
54
+ const databaseVersion = 1
55
+ const entriesStoreName = "entries"
56
+ const storeIdIndexName = "storeId"
57
+
58
+ /**
59
+ * Creates a `Persistence` layer backed by IndexedDB, optionally using the provided database name.
60
+ *
61
+ * @category layers
62
+ * @since 4.0.0
63
+ */
64
+ export const layerIndexedDb = (options?: {
65
+ readonly database?: string | undefined
66
+ }): Layer.Layer<Persistence.Persistence> =>
67
+ Persistence.layer.pipe(
68
+ Layer.provide(layerBackingIndexedDb(options))
69
+ )
70
+
71
+ const openDatabase = (database: string): Effect.Effect<IDBDatabase, Persistence.PersistenceError> =>
72
+ Effect.gen(function*() {
73
+ const openRequest = yield* Effect.try({
74
+ try: () => globalThis.indexedDB.open(database, databaseVersion),
75
+ catch: (cause) =>
76
+ new Persistence.PersistenceError({
77
+ message: "Failed to open backing store database",
78
+ cause
79
+ })
80
+ })
81
+
82
+ openRequest.onupgradeneeded = () => {
83
+ const db = openRequest.result
84
+ const entries = db.objectStoreNames.contains(entriesStoreName)
85
+ ? openRequest.transaction?.objectStore(entriesStoreName)
86
+ : db.createObjectStore(entriesStoreName, { keyPath: ["storeId", "id"] })
87
+ if (entries && !entries.indexNames.contains(storeIdIndexName)) {
88
+ entries.createIndex(storeIdIndexName, storeIdIndexName, { unique: false })
89
+ }
90
+ }
91
+
92
+ return yield* idbRequest("Failed to open backing store database", () => openRequest)
93
+ })
94
+
95
+ interface EntryRow {
96
+ readonly storeId: string
97
+ readonly id: string
98
+ readonly value: object
99
+ readonly expires: number | null
100
+ }
101
+
102
+ const isExpired = (row: EntryRow, now: number): boolean => row.expires !== null && row.expires <= now
103
+
104
+ const get = (
105
+ db: IDBDatabase,
106
+ clock: Clock.Clock,
107
+ storeId: string,
108
+ key: string
109
+ ): Effect.Effect<object | undefined, Persistence.PersistenceError> =>
110
+ withEntriesTransaction<object | undefined>(
111
+ db,
112
+ "readwrite",
113
+ `Failed to get key ${key} from backing store`,
114
+ (
115
+ entries,
116
+ setResult,
117
+ fail
118
+ ) => {
119
+ const now = clock.currentTimeMillisUnsafe()
120
+ const id: [string, string] = [storeId, key]
121
+ const request = entries.get(id)
122
+ request.onerror = () => fail(request.error)
123
+ request.onsuccess = () => {
124
+ const row = request.result as EntryRow | undefined
125
+ if (!row || !isExpired(row, now)) {
126
+ setResult(row?.value)
127
+ return
128
+ }
129
+
130
+ const deleteRequest = entries.delete(id)
131
+ deleteRequest.onerror = () => fail(deleteRequest.error)
132
+ deleteRequest.onsuccess = () => setResult(undefined)
133
+ }
134
+ }
135
+ )
136
+
137
+ const getMany = (
138
+ db: IDBDatabase,
139
+ clock: Clock.Clock,
140
+ storeId: string,
141
+ keys: Arr.NonEmptyArray<string>
142
+ ): Effect.Effect<Arr.NonEmptyArray<object | undefined>, Persistence.PersistenceError> =>
143
+ withEntriesTransaction(
144
+ db,
145
+ "readwrite",
146
+ "Failed to getMany from backing store",
147
+ (entries, setResult, fail) => {
148
+ const now = clock.currentTimeMillisUnsafe()
149
+ const results = new Array<object | undefined>(keys.length)
150
+ setResult(results as any)
151
+
152
+ for (let i = 0; i < keys.length; i++) {
153
+ const key = keys[i]
154
+ const keyPath = [storeId, key]
155
+ const request = entries.get(keyPath)
156
+ request.onerror = () => fail(request.error)
157
+ request.onsuccess = () => {
158
+ const row = request.result as EntryRow | undefined
159
+ if (!row) return
160
+ else if (!isExpired(row, now)) {
161
+ results[i] = row.value
162
+ return
163
+ }
164
+ const deleteRequest = entries.delete(keyPath)
165
+ deleteRequest.onerror = () => fail(deleteRequest.error)
166
+ }
167
+ }
168
+ }
169
+ )
170
+
171
+ const set = (
172
+ db: IDBDatabase,
173
+ clock: Clock.Clock,
174
+ storeId: string,
175
+ key: string,
176
+ value: object,
177
+ ttl: Duration.Duration | undefined
178
+ ): Effect.Effect<void, Persistence.PersistenceError> =>
179
+ withEntriesTransaction(
180
+ db,
181
+ "readwrite",
182
+ `Failed to set key ${key} in backing store`,
183
+ (entries, setResult, fail) => {
184
+ const request = entries.put(
185
+ {
186
+ storeId,
187
+ id: key,
188
+ value,
189
+ expires: Persistence.unsafeTtlToExpires(clock, ttl)
190
+ } satisfies EntryRow
191
+ )
192
+ request.onerror = () => fail(request.error)
193
+ request.onsuccess = () => setResult(undefined)
194
+ }
195
+ )
196
+
197
+ const setMany = (
198
+ db: IDBDatabase,
199
+ clock: Clock.Clock,
200
+ storeId: string,
201
+ entries: Arr.NonEmptyArray<readonly [key: string, value: object, ttl: Duration.Duration | undefined]>
202
+ ): Effect.Effect<void, Persistence.PersistenceError> =>
203
+ withEntriesTransaction(
204
+ db,
205
+ "readwrite",
206
+ "Failed to setMany in backing store",
207
+ (store, setResult, fail) => {
208
+ for (const [key, value, ttl] of entries) {
209
+ const request = store.put(
210
+ {
211
+ storeId,
212
+ id: key,
213
+ value,
214
+ expires: Persistence.unsafeTtlToExpires(clock, ttl)
215
+ } satisfies EntryRow
216
+ )
217
+ request.onerror = () => fail(request.error)
218
+ request.onsuccess = () => setResult(undefined)
219
+ }
220
+ }
221
+ )
222
+
223
+ const remove = (
224
+ db: IDBDatabase,
225
+ storeId: string,
226
+ key: string
227
+ ): Effect.Effect<void, Persistence.PersistenceError> =>
228
+ withEntriesTransaction(
229
+ db,
230
+ "readwrite",
231
+ `Failed to remove key ${key} from backing store`,
232
+ (entries, setResult, fail) => {
233
+ const request = entries.delete([storeId, key])
234
+ request.onerror = () => fail(request.error)
235
+ request.onsuccess = () => setResult(undefined)
236
+ }
237
+ )
238
+
239
+ const clear = (db: IDBDatabase, storeId: string): Effect.Effect<void, Persistence.PersistenceError> =>
240
+ withEntriesTransaction(db, "readwrite", "Failed to clear backing store", (entries, setResult, fail) => {
241
+ const index = entries.index(storeIdIndexName)
242
+ const cursorRequest = index.openCursor(storeId)
243
+ cursorRequest.onerror = () => fail(cursorRequest.error)
244
+ cursorRequest.onsuccess = () => {
245
+ const cursor = cursorRequest.result
246
+ if (!cursor) {
247
+ setResult(undefined)
248
+ return
249
+ }
250
+ const deleteRequest = cursor.delete()
251
+ deleteRequest.onerror = () => fail(deleteRequest.error)
252
+ deleteRequest.onsuccess = () => cursor.continue()
253
+ }
254
+ })
255
+
256
+ const withEntriesTransaction = <A>(
257
+ db: IDBDatabase,
258
+ mode: IDBTransactionMode,
259
+ message: string,
260
+ run: (
261
+ entries: IDBObjectStore,
262
+ onResult: (result: A) => void,
263
+ fail: (cause: unknown) => void
264
+ ) => void
265
+ ): Effect.Effect<A, Persistence.PersistenceError> =>
266
+ Effect.callback<A, Persistence.PersistenceError>((resume) => {
267
+ const tx = db.transaction(entriesStoreName, mode)
268
+ const entries = tx.objectStore(entriesStoreName)
269
+
270
+ let result: A | undefined
271
+ let setResult = false
272
+ let done = false
273
+
274
+ const fail = (cause: unknown) => {
275
+ done = true
276
+ resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
277
+ }
278
+
279
+ tx.oncomplete = () => {
280
+ done = true
281
+ if (setResult) resume(Effect.succeed(result!))
282
+ }
283
+ tx.onerror = () => {
284
+ done = true
285
+ fail(tx.error)
286
+ }
287
+ tx.onabort = () => {
288
+ done = true
289
+ fail(tx.error)
290
+ }
291
+
292
+ run(entries, (next) => {
293
+ if (done) return resume(Effect.succeed(next))
294
+ setResult = true
295
+ result = next
296
+ }, fail)
297
+
298
+ return Effect.sync(() => {
299
+ tx.abort()
300
+ })
301
+ })
302
+
303
+ const idbRequest = <A>(
304
+ message: string,
305
+ evaluate: () => IDBRequest<A>
306
+ ): Effect.Effect<A, Persistence.PersistenceError> =>
307
+ Effect.callback<A, Persistence.PersistenceError>((resume) => {
308
+ const request = evaluate()
309
+ const fail = (cause: unknown) => {
310
+ resume(Effect.fail(new Persistence.PersistenceError({ message, cause })))
311
+ }
312
+ if (request.readyState === "done") {
313
+ resume(Effect.succeed(request.result))
314
+ }
315
+ request.onsuccess = () => {
316
+ resume(Effect.succeed(request.result))
317
+ }
318
+ request.onerror = () => fail(request.error)
319
+ })