| 1 | /** |
| 2 | * Provides effectful key/value storage for persistence backends. |
| 3 | * |
| 4 | * `KeyValueStore` is a service for storing string or binary values by key. It |
| 5 | * is useful for lightweight durable state, browser storage, local files, SQL |
| 6 | * tables, tests, and as a storage building block for higher-level persistence |
| 7 | * APIs. This module includes store operations, prefixed views, schema-aware JSON |
| 8 | * storage, error values, and layers for memory, filesystem, Web Storage, and |
| 9 | * SQL-backed stores. |
| 10 | * |
| 11 | * @stability unstable |
| 12 | * @since 4.0.0 |
| 13 | */ |
| 14 | import * as Context from "../Context.ts" |
| 15 | import * as Data from "../Data.ts" |
| 16 | import * as Effect from "../Effect.ts" |
| 17 | import * as Base64 from "../encoding/Base64.ts" |
| 18 | import * as FileSystem from "../FileSystem.ts" |
| 19 | import { dual, identity, type LazyArg } from "../Function.ts" |
| 20 | import * as Layer from "../Layer.ts" |
| 21 | import * as Option from "../Option.ts" |
| 22 | import * as Path from "../Path.ts" |
| 23 | import type { PlatformError } from "../PlatformError.ts" |
| 24 | import * as Predicate from "../Predicate.ts" |
| 25 | import * as Result from "../Result.ts" |
| 26 | import * as Schema from "../Schema.ts" |
| 27 | import * as SqlClient from "../sql/SqlClient.ts" |
| 28 | import type { SqlError } from "../sql/SqlError.ts" |
| 29 | import * as UndefinedOr from "../UndefinedOr.ts" |
| 30 | |
| 31 | const TypeId = "~effect/persistence/KeyValueStore" as const |
| 32 | |
| 33 | /** |
| 34 | * Effectful key/value store service for string and binary values. |
| 35 | * |
| 36 | * @stability unstable |
| 37 | * @category models |
| 38 | * @since 4.0.0 |
| 39 | */ |
| 40 | export interface KeyValueStore { |
| 41 | readonly [TypeId]: typeof TypeId |
| 42 | /** |
| 43 | * Returns the value of the specified key if it exists. |
| 44 | */ |
| 45 | readonly get: (key: string) => Effect.Effect<string | undefined, KeyValueStoreError> |
| 46 | |
| 47 | /** |
| 48 | * Returns the value of the specified key if it exists. |
| 49 | */ |
| 50 | readonly getUint8Array: (key: string) => Effect.Effect<Uint8Array | undefined, KeyValueStoreError> |
| 51 | |
| 52 | /** |
| 53 | * Sets the value of the specified key. |
| 54 | */ |
| 55 | readonly set: (key: string, value: string | Uint8Array) => Effect.Effect<void, KeyValueStoreError> |
| 56 | |
| 57 | /** |
| 58 | * Removes the specified key. |
| 59 | */ |
| 60 | readonly remove: (key: string) => Effect.Effect<void, KeyValueStoreError> |
| 61 | |
| 62 | /** |
| 63 | * Removes all entries. |
| 64 | */ |
| 65 | readonly clear: Effect.Effect<void, KeyValueStoreError> |
| 66 | |
| 67 | /** |
| 68 | * Returns the number of entries. |
| 69 | */ |
| 70 | readonly size: Effect.Effect<number, KeyValueStoreError> |
| 71 | |
| 72 | /** |
| 73 | * Updates the value of the specified key if it exists. |
| 74 | */ |
| 75 | readonly modify: ( |
| 76 | key: string, |
| 77 | f: (value: string) => string |
| 78 | ) => Effect.Effect<string | undefined, KeyValueStoreError> |
| 79 | |
| 80 | /** |
| 81 | * Updates the value of the specified key if it exists. |
| 82 | */ |
| 83 | readonly modifyUint8Array: ( |
| 84 | key: string, |
| 85 | f: (value: Uint8Array) => Uint8Array |
| 86 | ) => Effect.Effect<Uint8Array | undefined, KeyValueStoreError> |
| 87 | |
| 88 | /** |
| 89 | * Returns true if the KeyValueStore contains the specified key. |
| 90 | */ |
| 91 | readonly has: (key: string) => Effect.Effect<boolean, KeyValueStoreError> |
| 92 | |
| 93 | /** |
| 94 | * Checks whether the KeyValueStore contains any entries. |
| 95 | */ |
| 96 | readonly isEmpty: Effect.Effect<boolean, KeyValueStoreError> |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Implementation callbacks used by `make` to construct a `KeyValueStore`. |
| 101 | * |
| 102 | * **Details** |
| 103 | * |
| 104 | * Primitive operations are required, while helpers such as `has`, `isEmpty`, |
| 105 | * and `modify` can be supplied to override the defaults. |
| 106 | * |
| 107 | * @stability unstable |
| 108 | * @category options |
| 109 | * @since 4.0.0 |
| 110 | */ |
| 111 | export type MakeOptions = Partial<KeyValueStore> & { |
| 112 | /** |
| 113 | * Returns the value of the specified key if it exists. |
| 114 | */ |
| 115 | readonly get: (key: string) => Effect.Effect<string | undefined, KeyValueStoreError> |
| 116 | |
| 117 | /** |
| 118 | * Returns the value of the specified key if it exists. |
| 119 | */ |
| 120 | readonly getUint8Array: (key: string) => Effect.Effect<Uint8Array | undefined, KeyValueStoreError> |
| 121 | |
| 122 | /** |
| 123 | * Sets the value of the specified key. |
| 124 | */ |
| 125 | readonly set: (key: string, value: string | Uint8Array) => Effect.Effect<void, KeyValueStoreError> |
| 126 | |
| 127 | /** |
| 128 | * Removes the specified key. |
| 129 | */ |
| 130 | readonly remove: (key: string) => Effect.Effect<void, KeyValueStoreError> |
| 131 | |
| 132 | /** |
| 133 | * Removes all entries. |
| 134 | */ |
| 135 | readonly clear: Effect.Effect<void, KeyValueStoreError> |
| 136 | |
| 137 | /** |
| 138 | * Returns the number of entries. |
| 139 | */ |
| 140 | readonly size: Effect.Effect<number, KeyValueStoreError> |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * Implementation callbacks for adapting a string-only backing store into a |
| 145 | * `KeyValueStore`. |
| 146 | * |
| 147 | * @stability unstable |
| 148 | * @category options |
| 149 | * @since 4.0.0 |
| 150 | */ |
| 151 | export type MakeStringOptions = Partial<Omit<KeyValueStore, "set">> & { |
| 152 | /** |
| 153 | * Returns the value of the specified key if it exists. |
| 154 | */ |
| 155 | readonly get: (key: string) => Effect.Effect<string | undefined, KeyValueStoreError> |
| 156 | |
| 157 | /** |
| 158 | * Sets the value of the specified key. |
| 159 | */ |
| 160 | readonly set: (key: string, value: string) => Effect.Effect<void, KeyValueStoreError> |
| 161 | |
| 162 | /** |
| 163 | * Removes the specified key. |
| 164 | */ |
| 165 | readonly remove: (key: string) => Effect.Effect<void, KeyValueStoreError> |
| 166 | |
| 167 | /** |
| 168 | * Removes all entries. |
| 169 | */ |
| 170 | readonly clear: Effect.Effect<void, KeyValueStoreError> |
| 171 | |
| 172 | /** |
| 173 | * Returns the number of entries. |
| 174 | */ |
| 175 | readonly size: Effect.Effect<number, KeyValueStoreError> |
| 176 | } |
| 177 | |
| 178 | const ErrorTypeId = "~effect/persistence/KeyValueStore/KeyValueStoreError" as const |
| 179 | |
| 180 | /** |
| 181 | * Error raised by key/value store operations, including the failed method, |
| 182 | * optional key, message, and cause. |
| 183 | * |
| 184 | * @stability unstable |
| 185 | * @category errors |
| 186 | * @since 4.0.0 |
| 187 | */ |
| 188 | export class KeyValueStoreError extends Data.TaggedError("KeyValueStoreError")<{ |
| 189 | message: string |
| 190 | method: string |
| 191 | key?: string |
| 192 | cause?: unknown |
| 193 | }> { |
| 194 | /** |
| 195 | * Marks this value as a key-value store error for runtime guards. |
| 196 | * |
| 197 | * @stability unstable |
| 198 | * @since 4.0.0 |
| 199 | */ |
| 200 | readonly [ErrorTypeId]: typeof ErrorTypeId = ErrorTypeId |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Service tag for string and binary key/value storage. |
| 205 | * |
| 206 | * **When to use** |
| 207 | * |
| 208 | * Use to access or provide the persistence store used for lightweight durable |
| 209 | * state. |
| 210 | * |
| 211 | * @stability unstable |
| 212 | * @category services |
| 213 | * @since 4.0.0 |
| 214 | */ |
| 215 | export const KeyValueStore: Context.Service< |
| 216 | KeyValueStore, |
| 217 | KeyValueStore |
| 218 | > = Context.Service("effect/persistence/KeyValueStore") |
| 219 | |
| 220 | /** |
| 221 | * Constructs a `KeyValueStore` from primitive store operations. |
| 222 | * |
| 223 | * **Details** |
| 224 | * |
| 225 | * Default implementations are derived for `has`, `isEmpty`, `modify`, and |
| 226 | * `modifyUint8Array` unless they are provided in the options. |
| 227 | * |
| 228 | * @stability unstable |
| 229 | * @category constructors |
| 230 | * @since 4.0.0 |
| 231 | */ |
| 232 | export const make = (options: MakeOptions): KeyValueStore => |
| 233 | KeyValueStore.of({ |
| 234 | [TypeId]: TypeId, |
| 235 | has: (key) => Effect.map(options.get(key), Predicate.isNotUndefined), |
| 236 | isEmpty: Effect.map(options.size, (size) => size === 0), |
| 237 | modify: (key, f) => |
| 238 | Effect.flatMap( |
| 239 | options.get(key), |
| 240 | (o) => { |
| 241 | if (o === undefined) { |
| 242 | return Effect.undefined |
| 243 | } |
| 244 | const newValue = f(o) |
| 245 | return Effect.as( |
| 246 | options.set(key, newValue), |
| 247 | newValue |
| 248 | ) |
| 249 | } |
| 250 | ), |
| 251 | modifyUint8Array: (key, f) => |
| 252 | Effect.flatMap( |
| 253 | options.getUint8Array(key), |
| 254 | (o) => { |
| 255 | if (o === undefined) { |
| 256 | return Effect.undefined |
| 257 | } |
| 258 | const newValue = f(o) |
| 259 | return Effect.as(options.set(key, newValue), newValue) |
| 260 | } |
| 261 | ), |
| 262 | ...options |
| 263 | }) |
| 264 | |
| 265 | /** |
| 266 | * Adapts a string-only backing store into a `KeyValueStore`. |
| 267 | * |
| 268 | * **Details** |
| 269 | * |
| 270 | * `Uint8Array` values are stored as base64 strings. `getUint8Array` decodes |
| 271 | * base64 values and falls back to UTF-8 encoding for non-base64 strings. |
| 272 | * |
| 273 | * @stability unstable |
| 274 | * @category constructors |
| 275 | * @since 4.0.0 |
| 276 | */ |
| 277 | export const makeStringOnly = ( |
| 278 | options: MakeStringOptions |
| 279 | ): KeyValueStore => { |
| 280 | const encoder = new TextEncoder() |
| 281 | return make({ |
| 282 | ...options, |
| 283 | getUint8Array: (key) => |
| 284 | options.get(key).pipe( |
| 285 | Effect.map(UndefinedOr.map((value) => |
| 286 | Result.match(Base64.decode(value), { |
| 287 | onFailure: () => encoder.encode(value), |
| 288 | onSuccess: identity |
| 289 | }) |
| 290 | )) |
| 291 | ), |
| 292 | set: (key, value) => |
| 293 | typeof value === "string" |
| 294 | ? options.set(key, value) |
| 295 | : Effect.suspend(() => options.set(key, Base64.encode(value))) |
| 296 | }) |
| 297 | } |
| 298 | |
| 299 | /** |
| 300 | * Returns a view of a `KeyValueStore` that prepends the given prefix to every |
| 301 | * key. |
| 302 | * |
| 303 | * @stability unstable |
| 304 | * @category combinators |
| 305 | * @since 4.0.0 |
| 306 | */ |
| 307 | export const prefix: { |
| 308 | (prefix: string): (self: KeyValueStore) => KeyValueStore |
| 309 | (self: KeyValueStore, prefix: string): KeyValueStore |
| 310 | } = dual(2, (self: KeyValueStore, prefix: string): KeyValueStore => ({ |
| 311 | ...self, |
| 312 | get: (key) => self.get(`${prefix}${key}`), |
| 313 | getUint8Array: (key) => self.getUint8Array(`${prefix}${key}`), |
| 314 | set: (key, value) => self.set(`${prefix}${key}`, value), |
| 315 | remove: (key) => self.remove(`${prefix}${key}`), |
| 316 | has: (key) => self.has(`${prefix}${key}`), |
| 317 | modify: (key, f) => self.modify(`${prefix}${key}`, f), |
| 318 | modifyUint8Array: (key, f) => self.modifyUint8Array(`${prefix}${key}`, f) |
| 319 | })) |
| 320 | |
| 321 | /** |
| 322 | * Provides a process-local in-memory `KeyValueStore` backed by a `Map`. |
| 323 | * |
| 324 | * @stability unstable |
| 325 | * @category layers |
| 326 | * @since 4.0.0 |
| 327 | */ |
| 328 | export const layerMemory: Layer.Layer<KeyValueStore> = Layer.sync(KeyValueStore)(() => { |
| 329 | const store = new Map<string, string | Uint8Array>() |
| 330 | const encoder = new TextEncoder() |
| 331 | |
| 332 | return make({ |
| 333 | get: (key: string) => |
| 334 | Effect.sync(() => { |
| 335 | const value = store.get(key) |
| 336 | return value === undefined ? undefined : typeof value === "string" ? value : Base64.encode(value) |
| 337 | }), |
| 338 | getUint8Array: (key: string) => |
| 339 | Effect.sync(() => { |
| 340 | const value = store.get(key) |
| 341 | return value === undefined ? undefined : typeof value === "string" ? encoder.encode(value) : value |
| 342 | }), |
| 343 | set: (key: string, value: string | Uint8Array) => Effect.sync(() => store.set(key, value)), |
| 344 | remove: (key: string) => Effect.sync(() => store.delete(key)), |
| 345 | clear: Effect.sync(() => store.clear()), |
| 346 | size: Effect.sync(() => store.size) |
| 347 | }) |
| 348 | }) |
| 349 | |
| 350 | /** |
| 351 | * Provides a `KeyValueStore` backed by files in the specified directory. |
| 352 | * |
| 353 | * **Details** |
| 354 | * |
| 355 | * The directory is created if needed, and each key is percent-encoded as a |
| 356 | * single file name. Empty keys, `.` and `..` are rejected. Keys are only |
| 357 | * guaranteed to be distinct on case-sensitive file systems. |
| 358 | * |
| 359 | * `clear` removes the directory recursively, so it must not be shared with |
| 360 | * unrelated data. |
| 361 | * |
| 362 | * @stability unstable |
| 363 | * @category layers |
| 364 | * @since 4.0.0 |
| 365 | */ |
| 366 | export const layerFileSystem = ( |
| 367 | directory: string |
| 368 | ): Layer.Layer<KeyValueStore, PlatformError, FileSystem.FileSystem | Path.Path> => |
| 369 | Layer.effect(KeyValueStore)(Effect.gen(function*() { |
| 370 | const fs = yield* FileSystem.FileSystem |
| 371 | const path = yield* Path.Path |
| 372 | const withKeyPath = <A>( |
| 373 | method: string, |
| 374 | key: string, |
| 375 | f: (path: string) => Effect.Effect<A, KeyValueStoreError> |
| 376 | ): Effect.Effect<A, KeyValueStoreError> => |
| 377 | key.length === 0 || key === "." || key === ".." |
| 378 | ? Effect.fail( |
| 379 | new KeyValueStoreError({ |
| 380 | method, |
| 381 | key, |
| 382 | message: `Invalid key ${key}` |
| 383 | }) |
| 384 | ) |
| 385 | : f(path.join(directory, encodeURIComponent(key))) |
| 386 | |
| 387 | if (!(yield* fs.exists(directory))) { |
| 388 | yield* fs.makeDirectory(directory, { recursive: true }) |
| 389 | } |
| 390 | |
| 391 | return make({ |
| 392 | get: (key: string) => |
| 393 | withKeyPath("get", key, (path) => |
| 394 | Effect.catchTag( |
| 395 | fs.readFileString(path), |
| 396 | "PlatformError", |
| 397 | (cause) => |
| 398 | cause.reason._tag === "NotFound" ? Effect.undefined : Effect.fail( |
| 399 | new KeyValueStoreError({ |
| 400 | method: "get", |
| 401 | key, |
| 402 | message: `Unable to get item with key ${key}`, |
| 403 | cause |
| 404 | }) |
| 405 | ) |
| 406 | )), |
| 407 | getUint8Array: (key: string) => |
| 408 | withKeyPath("getUint8Array", key, (path) => |
| 409 | Effect.catchTag( |
| 410 | fs.readFile(path), |
| 411 | "PlatformError", |
| 412 | (cause) => |
| 413 | cause.reason._tag === "NotFound" ? Effect.undefined : Effect.fail( |
| 414 | new KeyValueStoreError({ |
| 415 | method: "getUint8Array", |
| 416 | key, |
| 417 | message: `Unable to get item with key ${key}`, |
| 418 | cause |
| 419 | }) |
| 420 | ) |
| 421 | )), |
| 422 | set: (key: string, value: string | Uint8Array) => |
| 423 | withKeyPath("set", key, (path) => |
| 424 | Effect.mapError( |
| 425 | typeof value === "string" ? fs.writeFileString(path, value) : fs.writeFile(path, value), |
| 426 | (cause) => |
| 427 | new KeyValueStoreError({ |
| 428 | method: "set", |
| 429 | key, |
| 430 | message: `Unable to set item with key ${key}`, |
| 431 | cause |
| 432 | }) |
| 433 | )), |
| 434 | remove: (key: string) => |
| 435 | withKeyPath("remove", key, (path) => |
| 436 | Effect.mapError(fs.remove(path), (cause) => |
| 437 | new KeyValueStoreError({ |
| 438 | method: "remove", |
| 439 | key, |
| 440 | message: `Unable to remove item with key ${key}`, |
| 441 | cause |
| 442 | }))), |
| 443 | has: (key: string) => |
| 444 | withKeyPath("has", key, (path) => |
| 445 | Effect.mapError(fs.exists(path), (cause) => |
| 446 | new KeyValueStoreError({ |
| 447 | method: "has", |
| 448 | key, |
| 449 | message: `Unable to check existence of item with key ${key}`, |
| 450 | cause |
| 451 | }))), |
| 452 | clear: Effect.mapError( |
| 453 | Effect.andThen( |
| 454 | fs.remove(directory, { recursive: true }), |
| 455 | fs.makeDirectory(directory, { recursive: true }) |
| 456 | ), |
| 457 | (cause) => |
| 458 | new KeyValueStoreError({ |
| 459 | method: "clear", |
| 460 | message: `Unable to clear storage`, |
| 461 | cause |
| 462 | }) |
| 463 | ), |
| 464 | size: Effect.matchEffect( |
| 465 | fs.readDirectory(directory), |
| 466 | { |
| 467 | onSuccess: (files) => Effect.succeed(files.length), |
| 468 | onFailure: (cause) => |
| 469 | Effect.fail( |
| 470 | new KeyValueStoreError({ |
| 471 | method: "size", |
| 472 | message: `Unable to get size`, |
| 473 | cause |
| 474 | }) |
| 475 | ) |
| 476 | } |
| 477 | ) |
| 478 | }) |
| 479 | })) |
| 480 | |
| 481 | /** |
| 482 | * Options for configuring the SQL-backed `KeyValueStore` layer. |
| 483 | * |
| 484 | * @stability unstable |
| 485 | * @category layers |
| 486 | * @since 4.0.0 |
| 487 | */ |
| 488 | export interface LayerSqlOptions { |
| 489 | /** |
| 490 | * The SQL table name used to store values. |
| 491 | * |
| 492 | * @default "effect_key_value_store" |
| 493 | */ |
| 494 | readonly table?: string |
| 495 | } |
| 496 | |
| 497 | /** |
| 498 | * Provides a SQL-backed `KeyValueStore`. |
| 499 | * |
| 500 | * **Details** |
| 501 | * |
| 502 | * The layer creates the configured table if it does not exist and stores both |
| 503 | * string and binary values through the current `SqlClient`. |
| 504 | * |
| 505 | * @stability unstable |
| 506 | * @category layers |
| 507 | * @since 4.0.0 |
| 508 | */ |
| 509 | export const layerSql = ( |
| 510 | options: LayerSqlOptions = {} |
| 511 | ): Layer.Layer<KeyValueStore, never, SqlClient.SqlClient> => |
| 512 | Layer.effect(KeyValueStore)( |
| 513 | Effect.gen(function*() { |
| 514 | const sql = (yield* SqlClient.SqlClient).withoutTransforms() |
| 515 | const table = sql(options.table ?? "effect_key_value_store") |
| 516 | |
| 517 | yield* sql.onDialectOrElse({ |
| 518 | mysql: () => |
| 519 | sql` |
| 520 | CREATE TABLE IF NOT EXISTS ${table} ( |
| 521 | id VARCHAR(191) PRIMARY KEY, |
| 522 | value BLOB NOT NULL, |
| 523 | value_type SMALLINT NOT NULL |
| 524 | ) |
| 525 | `, |
| 526 | pg: () => |
| 527 | sql` |
| 528 | CREATE TABLE IF NOT EXISTS ${table} ( |
| 529 | id TEXT PRIMARY KEY, |
| 530 | value BYTEA NOT NULL, |
| 531 | value_type SMALLINT NOT NULL |
| 532 | ) |
| 533 | `, |
| 534 | mssql: () => |
| 535 | sql` |
| 536 | IF NOT EXISTS (SELECT * FROM sysobjects WHERE name=${table} AND xtype='U') |
| 537 | CREATE TABLE ${table} ( |
| 538 | id NVARCHAR(450) PRIMARY KEY, |
| 539 | value VARBINARY(MAX) NOT NULL, |
| 540 | value_type SMALLINT NOT NULL |
| 541 | ) |
| 542 | `, |
| 543 | // sqlite |
| 544 | orElse: () => |
| 545 | sql` |
| 546 | CREATE TABLE IF NOT EXISTS ${table} ( |
| 547 | id TEXT PRIMARY KEY, |
| 548 | value BLOB NOT NULL, |
| 549 | value_type INTEGER NOT NULL |
| 550 | ) |
| 551 | ` |
| 552 | }).pipe(Effect.orDie) |
| 553 | |
| 554 | type UpsertFn = (entry: { |
| 555 | id: string |
| 556 | value: Uint8Array |
| 557 | value_type: number |
| 558 | }) => Effect.Effect<unknown, SqlError> |
| 559 | |
| 560 | const upsert = sql.onDialectOrElse({ |
| 561 | pg: (): UpsertFn => (entry) => |
| 562 | sql` |
| 563 | INSERT INTO ${table} (id, value, value_type) VALUES (${entry.id}, ${entry.value}, ${entry.value_type}) |
| 564 | ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value, value_type=EXCLUDED.value_type |
| 565 | `.unprepared, |
| 566 | mysql: (): UpsertFn => (entry) => |
| 567 | sql` |
| 568 | INSERT INTO ${table} (id, value, value_type) VALUES (${entry.id}, ${entry.value}, ${entry.value_type}) |
| 569 | ON DUPLICATE KEY UPDATE value=VALUES(value), value_type=VALUES(value_type) |
| 570 | `, |
| 571 | mssql: (): UpsertFn => (entry) => |
| 572 | sql` |
| 573 | MERGE ${table} AS target |
| 574 | USING (SELECT ${entry.id} AS id, ${entry.value} AS value, ${entry.value_type} AS value_type) AS source |
| 575 | ON target.id = source.id |
| 576 | WHEN MATCHED THEN UPDATE SET value = source.value, value_type = source.value_type |
| 577 | WHEN NOT MATCHED THEN INSERT (id, value, value_type) |
| 578 | VALUES (source.id, source.value, source.value_type); |
| 579 | `, |
| 580 | // sqlite |
| 581 | orElse: (): UpsertFn => (entry) => |
| 582 | sql` |
| 583 | INSERT INTO ${table} (id, value, value_type) VALUES (${entry.id}, ${entry.value}, ${entry.value_type}) |
| 584 | ON CONFLICT(id) DO UPDATE SET value=excluded.value, value_type=excluded.value_type |
| 585 | `.unprepared |
| 586 | }) |
| 587 | |
| 588 | const encoder = new TextEncoder() |
| 589 | const decoder = new TextDecoder() |
| 590 | const ValueTypeString = 0 |
| 591 | const ValueTypeUint8Array = 1 |
| 592 | |
| 593 | type Row = { |
| 594 | value: Uint8Array |
| 595 | value_type: number |
| 596 | } |
| 597 | |
| 598 | return make({ |
| 599 | get: (key: string) => |
| 600 | sql<Row>`SELECT value, value_type FROM ${table} WHERE id = ${key}`.pipe( |
| 601 | Effect.mapError((cause) => |
| 602 | new KeyValueStoreError({ |
| 603 | method: "get", |
| 604 | key, |
| 605 | message: `Unable to get item with key ${key}`, |
| 606 | cause |
| 607 | }) |
| 608 | ), |
| 609 | Effect.flatMap((rows) => { |
| 610 | if (rows.length === 0) { |
| 611 | return Effect.undefined |
| 612 | } |
| 613 | const row = rows[0] |
| 614 | switch (row.value_type) { |
| 615 | case ValueTypeString: |
| 616 | return Effect.succeed(decoder.decode(row.value)) |
| 617 | case ValueTypeUint8Array: |
| 618 | return Effect.succeed(Base64.encode(row.value)) |
| 619 | default: |
| 620 | return Effect.fail( |
| 621 | new KeyValueStoreError({ |
| 622 | method: "get", |
| 623 | key, |
| 624 | message: `Invalid stored value type for key ${key}: ${row.value_type}` |
| 625 | }) |
| 626 | ) |
| 627 | } |
| 628 | }) |
| 629 | ), |
| 630 | getUint8Array: (key: string) => |
| 631 | sql<Row>`SELECT value, value_type FROM ${table} WHERE id = ${key}`.pipe( |
| 632 | Effect.mapError((cause) => |
| 633 | new KeyValueStoreError({ |
| 634 | method: "getUint8Array", |
| 635 | key, |
| 636 | message: `Unable to get item with key ${key}`, |
| 637 | cause |
| 638 | }) |
| 639 | ), |
| 640 | Effect.flatMap((rows) => { |
| 641 | if (rows.length === 0) { |
| 642 | return Effect.undefined |
| 643 | } |
| 644 | const row = rows[0] |
| 645 | switch (row.value_type) { |
| 646 | case ValueTypeString: |
| 647 | return Effect.succeed(row.value) |
| 648 | case ValueTypeUint8Array: |
| 649 | return Effect.succeed(row.value) |
| 650 | default: |
| 651 | return Effect.fail( |
| 652 | new KeyValueStoreError({ |
| 653 | method: "getUint8Array", |
| 654 | key, |
| 655 | message: `Invalid stored value type for key ${key}: ${row.value_type}` |
| 656 | }) |
| 657 | ) |
| 658 | } |
| 659 | }) |
| 660 | ), |
| 661 | set: (key: string, value: string | Uint8Array) => |
| 662 | upsert({ |
| 663 | id: key, |
| 664 | value: typeof value === "string" ? encoder.encode(value) : value, |
| 665 | value_type: typeof value === "string" ? ValueTypeString : ValueTypeUint8Array |
| 666 | }).pipe( |
| 667 | Effect.mapError((cause) => |
| 668 | new KeyValueStoreError({ |
| 669 | method: "set", |
| 670 | key, |
| 671 | message: `Unable to set item with key ${key}`, |
| 672 | cause |
| 673 | }) |
| 674 | ), |
| 675 | Effect.asVoid |
| 676 | ), |
| 677 | remove: (key: string) => |
| 678 | sql`DELETE FROM ${table} WHERE id = ${key}`.pipe( |
| 679 | Effect.mapError((cause) => |
| 680 | new KeyValueStoreError({ |
| 681 | method: "remove", |
| 682 | key, |
| 683 | message: `Unable to remove item with key ${key}`, |
| 684 | cause |
| 685 | }) |
| 686 | ), |
| 687 | Effect.asVoid |
| 688 | ), |
| 689 | clear: sql`DELETE FROM ${table}`.pipe( |
| 690 | Effect.mapError((cause) => |
| 691 | new KeyValueStoreError({ |
| 692 | method: "clear", |
| 693 | message: `Unable to clear storage`, |
| 694 | cause |
| 695 | }) |
| 696 | ), |
| 697 | Effect.asVoid |
| 698 | ), |
| 699 | size: sql<{ count: number }>`SELECT COUNT(*) as count FROM ${table}`.pipe( |
| 700 | Effect.mapError((cause) => |
| 701 | new KeyValueStoreError({ |
| 702 | method: "size", |
| 703 | message: `Unable to get size`, |
| 704 | cause |
| 705 | }) |
| 706 | ), |
| 707 | Effect.map((rows) => rows.length === 0 ? 0 : Number(rows[0].count)) |
| 708 | ) |
| 709 | }) |
| 710 | }) |
| 711 | ) |
| 712 | |
| 713 | const SchemaStoreTypeId = "~effect/persistence/KeyValueStore/SchemaStore" as const |
| 714 | |
| 715 | /** |
| 716 | * Schema-aware view of a `KeyValueStore` that stores values as encoded JSON. |
| 717 | * |
| 718 | * @stability unstable |
| 719 | * @category models |
| 720 | * @since 4.0.0 |
| 721 | */ |
| 722 | export interface SchemaStore<S extends Schema.Constraint> { |
| 723 | readonly [SchemaStoreTypeId]: typeof SchemaStoreTypeId |
| 724 | /** |
| 725 | * Returns the value of the specified key if it exists. |
| 726 | */ |
| 727 | readonly get: ( |
| 728 | key: string |
| 729 | ) => Effect.Effect<Option.Option<S["Type"]>, KeyValueStoreError | Schema.SchemaError, S["DecodingServices"]> |
| 730 | |
| 731 | /** |
| 732 | * Sets the value of the specified key. |
| 733 | */ |
| 734 | readonly set: ( |
| 735 | key: string, |
| 736 | value: S["Type"] |
| 737 | ) => Effect.Effect<void, KeyValueStoreError | Schema.SchemaError, S["EncodingServices"]> |
| 738 | |
| 739 | /** |
| 740 | * Removes the specified key. |
| 741 | */ |
| 742 | readonly remove: (key: string) => Effect.Effect<void, KeyValueStoreError> |
| 743 | |
| 744 | /** |
| 745 | * Removes all entries. |
| 746 | */ |
| 747 | readonly clear: Effect.Effect<void, KeyValueStoreError> |
| 748 | |
| 749 | /** |
| 750 | * Returns the number of entries. |
| 751 | */ |
| 752 | readonly size: Effect.Effect<number, KeyValueStoreError> |
| 753 | |
| 754 | /** |
| 755 | * Updates the value of the specified key if it exists. |
| 756 | */ |
| 757 | readonly modify: ( |
| 758 | key: string, |
| 759 | f: (value: S["Type"]) => S["Type"] |
| 760 | ) => Effect.Effect< |
| 761 | Option.Option<S["Type"]>, |
| 762 | KeyValueStoreError | Schema.SchemaError, |
| 763 | S["DecodingServices"] | S["EncodingServices"] |
| 764 | > |
| 765 | |
| 766 | /** |
| 767 | * Returns true if the KeyValueStore contains the specified key. |
| 768 | */ |
| 769 | readonly has: (key: string) => Effect.Effect<boolean, KeyValueStoreError> |
| 770 | |
| 771 | /** |
| 772 | * Checks whether the KeyValueStore contains any entries. |
| 773 | */ |
| 774 | readonly isEmpty: Effect.Effect<boolean, KeyValueStoreError> |
| 775 | } |
| 776 | |
| 777 | /** |
| 778 | * Adapts a `KeyValueStore` into a `SchemaStore` using the schema's JSON codec. |
| 779 | * |
| 780 | * @stability unstable |
| 781 | * @category converting |
| 782 | * @since 4.0.0 |
| 783 | */ |
| 784 | export const toSchemaStore = <S extends Schema.Constraint>(self: KeyValueStore, schema: S): SchemaStore<S> => { |
| 785 | const serializer = Schema.toCodecJson(schema) |
| 786 | const jsonSchema = Schema.fromJsonString(serializer) |
| 787 | const decode = Schema.decodeEffect(jsonSchema) |
| 788 | const encode = Schema.encodeEffect(jsonSchema) |
| 789 | |
| 790 | const get = (key: string) => |
| 791 | Effect.flatMap( |
| 792 | self.get(key), |
| 793 | UndefinedOr.match({ |
| 794 | onUndefined: () => Effect.succeedNone, |
| 795 | onDefined: (value) => Effect.asSome(decode(value)) |
| 796 | }) |
| 797 | ) |
| 798 | |
| 799 | const set = (key: string, value: S["Type"]) => Effect.flatMap(encode(value), (json) => self.set(key, json)) |
| 800 | |
| 801 | const modify = (key: string, f: (value: S["Type"]) => S["Type"]) => |
| 802 | Effect.flatMap( |
| 803 | get(key), |
| 804 | (o) => { |
| 805 | if (Option.isNone(o)) { |
| 806 | return Effect.succeedNone |
| 807 | } |
| 808 | const newValue = f(o.value) |
| 809 | return Effect.as( |
| 810 | set(key, newValue), |
| 811 | Option.some(newValue) |
| 812 | ) |
| 813 | } |
| 814 | ) |
| 815 | |
| 816 | return { |
| 817 | [SchemaStoreTypeId]: SchemaStoreTypeId, |
| 818 | get, |
| 819 | set, |
| 820 | modify, |
| 821 | remove: self.remove, |
| 822 | clear: self.clear, |
| 823 | size: self.size, |
| 824 | has: self.has, |
| 825 | isEmpty: self.isEmpty |
| 826 | } |
| 827 | } |
| 828 | |
| 829 | /** |
| 830 | * Provides a `KeyValueStore` backed by a Web `Storage` instance such as |
| 831 | * `localStorage` or `sessionStorage`. |
| 832 | * |
| 833 | * **Details** |
| 834 | * |
| 835 | * This layer uses the Web Storage API: |
| 836 | * https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API |
| 837 | * |
| 838 | * @stability unstable |
| 839 | * @category layers |
| 840 | * @since 4.0.0 |
| 841 | */ |
| 842 | export const layerStorage = ( |
| 843 | evaluate: LazyArg<Storage> |
| 844 | ): Layer.Layer<KeyValueStore> => |
| 845 | Layer.sync(KeyValueStore)(() => { |
| 846 | const storage = evaluate() |
| 847 | return makeStringOnly({ |
| 848 | get: (key: string) => |
| 849 | Effect.try({ |
| 850 | try: () => storage.getItem(key) ?? undefined, |
| 851 | catch: (cause) => |
| 852 | new KeyValueStoreError({ |
| 853 | key, |
| 854 | method: "get", |
| 855 | message: `Unable to get item with key ${key}`, |
| 856 | cause |
| 857 | }) |
| 858 | }), |
| 859 | |
| 860 | set: (key: string, value: string) => |
| 861 | Effect.try({ |
| 862 | try: () => storage.setItem(key, value), |
| 863 | catch: (cause) => |
| 864 | new KeyValueStoreError({ |
| 865 | key, |
| 866 | method: "set", |
| 867 | message: `Unable to set item with key ${key}`, |
| 868 | cause |
| 869 | }) |
| 870 | }), |
| 871 | |
| 872 | remove: (key: string) => |
| 873 | Effect.try({ |
| 874 | try: () => storage.removeItem(key), |
| 875 | catch: (cause) => |
| 876 | new KeyValueStoreError({ |
| 877 | key, |
| 878 | method: "remove", |
| 879 | message: `Unable to remove item with key ${key}`, |
| 880 | cause |
| 881 | }) |
| 882 | }), |
| 883 | |
| 884 | clear: Effect.try({ |
| 885 | try: () => storage.clear(), |
| 886 | catch: (cause) => |
| 887 | new KeyValueStoreError({ |
| 888 | method: "clear", |
| 889 | message: `Unable to clear storage`, |
| 890 | cause |
| 891 | }) |
| 892 | }), |
| 893 | |
| 894 | size: Effect.try({ |
| 895 | try: () => storage.length, |
| 896 | catch: (cause) => |
| 897 | new KeyValueStoreError({ |
| 898 | method: "size", |
| 899 | message: `Unable to get size`, |
| 900 | cause |
| 901 | }) |
| 902 | }) |
| 903 | }) |
| 904 | }) |