@playfast/reform-db-sqlite 0.0.1
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.
- package/README.md +76 -0
- package/package.json +55 -0
- package/src/engine.ts +130 -0
- package/src/index.ts +21 -0
- package/src/sqlite.bun.spec.ts +137 -0
- package/src/sqlite.ts +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# `@playfast/reform-db-sqlite`
|
|
4
|
+
|
|
5
|
+
**The [`bun:sqlite`](https://bun.sh/docs/api/sqlite) engine for [`@playfast/reform-db`](../reform-db).**
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Backs the engine-neutral `Db` driver from [`@playfast/reform-db`](../reform-db) with
|
|
12
|
+
Bun's built-in SQLite. Zero native dependencies (it's part of the Bun runtime),
|
|
13
|
+
`serialize()`/`deserialize()` snapshots, and **emulated reactivity** — SQLite has no
|
|
14
|
+
native live queries, so a live `DbQuery` re-runs after every write. Your tables,
|
|
15
|
+
queries, and migrations stay defined in the engine-neutral core.
|
|
16
|
+
|
|
17
|
+
> **Bun only.** `bun:sqlite` exists only under the Bun runtime. Import this package
|
|
18
|
+
> from code that runs on Bun; on Node the module import fails (surfaced as a `DbError`).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
bun add @playfast/reform-db-sqlite @playfast/reform-db kysely
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`effect`, `kysely`, `@playfast/reform`, and `@playfast/reform-db` are peer
|
|
27
|
+
dependencies.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { DbSchema, DbTable, DbColumn, Migration } from '@playfast/reform-db'
|
|
33
|
+
import { Sqlite } from '@playfast/reform-db-sqlite'
|
|
34
|
+
import { Layer } from 'effect'
|
|
35
|
+
|
|
36
|
+
const AppDb = DbSchema.make('app', { tables: [/* … */] })
|
|
37
|
+
|
|
38
|
+
// IMPORTANT: render migrations for the SQLite dialect.
|
|
39
|
+
const migrations = Migration.fromSchema(AppDb, Migration.sqlite)
|
|
40
|
+
|
|
41
|
+
// Persistent (a file on disk):
|
|
42
|
+
const DataLayer = QueriesAndProcedures.pipe(
|
|
43
|
+
Layer.provideMerge(Sqlite.layer({ filename: 'app.db', migrations })),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Ephemeral in-memory — the default for tests / proofs:
|
|
47
|
+
const TestLayer = QueriesAndProcedures.pipe(
|
|
48
|
+
Layer.provideMerge(Sqlite.memory({ migrations })),
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Both layers build the `Db` service **synchronously** (bun:sqlite opens on a background
|
|
53
|
+
fiber inside `makeDriver`), so they slot straight under reform's `runSync` scene /
|
|
54
|
+
proof mount.
|
|
55
|
+
|
|
56
|
+
### Type storage
|
|
57
|
+
|
|
58
|
+
SQLite has no native `boolean` or `json` types, so the engine stores them as `integer`
|
|
59
|
+
(`0`/`1`) and `text` (JSON) respectively. The column `Schema`s in `@playfast/reform-db`
|
|
60
|
+
are engine-tolerant, so `DbColumn.boolean()` / `DbColumn.json(schema)` decode back to a
|
|
61
|
+
real `boolean` / object on read — the same table definition works on both engines.
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
- `Sqlite.layer(options?)` — persistent (or in-memory, if `filename` is omitted) `Db`.
|
|
66
|
+
Options: `filename?`, `migrations?`, `loadDataDir?` (seed from a prior `dump` `Blob`).
|
|
67
|
+
- `Sqlite.memory(options?)` — ephemeral in-memory `Db`. Options: `migrations?`,
|
|
68
|
+
`loadDataDir?`.
|
|
69
|
+
- `makeSqliteEngine(options)` — the raw `DbEngine`, for wiring `makeDriver` yourself.
|
|
70
|
+
|
|
71
|
+
Snapshotting: `driver.dump()` returns a `Blob` of `Database.serialize()` bytes; feed it
|
|
72
|
+
back via `loadDataDir` to boot a database already populated (`Database.deserialize`).
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@playfast/reform-db-sqlite",
|
|
3
|
+
"playbook": "./playbook",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "bun:sqlite engine for @playfast/reform-db — a Db driver backed by Bun's built-in SQLite, with emulated reactivity and serialize()/deserialize() snapshots.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"reform",
|
|
9
|
+
"effect",
|
|
10
|
+
"sqlite",
|
|
11
|
+
"bun",
|
|
12
|
+
"sql",
|
|
13
|
+
"reactive",
|
|
14
|
+
"local-first"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/playfast/reform.git",
|
|
20
|
+
"directory": "packages/reform-db-sqlite"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
24
|
+
},
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"exports": {
|
|
27
|
+
"./package.json": "./package.json",
|
|
28
|
+
".": "./src/index.ts",
|
|
29
|
+
"./*": "./src/*.ts"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"clean": "rm -rf dist .tsbuildinfo",
|
|
37
|
+
"check": "tsc --noEmit",
|
|
38
|
+
"build": "tsc -p tsconfig.build.json",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"lint": "oxlint src",
|
|
41
|
+
"lint:fix": "oxlint --fix src"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"effect": "*",
|
|
45
|
+
"kysely": "*",
|
|
46
|
+
"@playfast/reform": "*",
|
|
47
|
+
"@playfast/reform-db": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/bun": "^1.3.14"
|
|
51
|
+
},
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type DbEngine, DbError, type EngineBackend, type Migration } from '@playfast/reform-db'
|
|
2
|
+
import { Effect, Option } from 'effect'
|
|
3
|
+
import { createQueryId, type RootOperationNode, SqliteQueryCompiler } from 'kysely'
|
|
4
|
+
// Type-only: erased at build, so this package imports cleanly on Node (where the
|
|
5
|
+
// `bun:sqlite` module does not exist); the real module is imported lazily at open time.
|
|
6
|
+
import type { Database } from 'bun:sqlite'
|
|
7
|
+
|
|
8
|
+
// The bun:sqlite engine: Bun's built-in SQLite. It compiles operation nodes to SQLite
|
|
9
|
+
// SQL, opens a `Database` lazily (so importing this package never touches the
|
|
10
|
+
// Bun-only `bun:sqlite` module on Node), and runs migrations. SQLite has NO native
|
|
11
|
+
// live queries, so `live` is `Option.none` — `makeDriver` emulates reactivity by
|
|
12
|
+
// re-running reads after each write. `dump` uses `Database.serialize()` (a full
|
|
13
|
+
// on-the-wire snapshot Buffer), restored via `Database.deserialize` on the next open.
|
|
14
|
+
|
|
15
|
+
// A value SQLite can bind (bun:sqlite's `SQLQueryBindings`). Booleans ride as `0`/`1`,
|
|
16
|
+
// JSON columns as text — matching the engine-tolerant column `Schema`s in
|
|
17
|
+
// `@playfast/reform-db` that decode either shape. `ExternalApi`-named: it mirrors the
|
|
18
|
+
// driver's own binding shape, which admits a raw `null`.
|
|
19
|
+
type BindingExternalApi = string | number | bigint | boolean | Uint8Array | null
|
|
20
|
+
|
|
21
|
+
export interface SqliteOptionsExternalApi {
|
|
22
|
+
/** Database file path; omit for an ephemeral in-memory (`:memory:`) database. */
|
|
23
|
+
readonly filename?: string
|
|
24
|
+
/**
|
|
25
|
+
* DDL/seed migrations to run on open. Use `Migration.fromSchema(AppDb, Migration.sqlite)`
|
|
26
|
+
* so column types render for SQLite; `Sqlite.layer`/`Sqlite.memory` do NOT pick the
|
|
27
|
+
* dialect for you.
|
|
28
|
+
*/
|
|
29
|
+
readonly migrations?: ReadonlyArray<Migration.Migration>
|
|
30
|
+
/**
|
|
31
|
+
* Seed the database from a previous `dump` snapshot (`Database.serialize()` bytes,
|
|
32
|
+
* wrapped in a `Blob`). When present the database is opened via `Database.deserialize`
|
|
33
|
+
* — an in-memory database restored from the snapshot — before migrations run
|
|
34
|
+
* (`CREATE TABLE IF NOT EXISTS` then no-ops). `filename` is ignored in this case.
|
|
35
|
+
*/
|
|
36
|
+
readonly loadDataDir?: Blob
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SqliteOptions = SqliteOptionsExternalApi
|
|
40
|
+
|
|
41
|
+
const compiler = new SqliteQueryCompiler()
|
|
42
|
+
|
|
43
|
+
const compile = (node: RootOperationNode) => {
|
|
44
|
+
const compiled = compiler.compileQuery(node, createQueryId())
|
|
45
|
+
return { sql: compiled.sql, parameters: compiled.parameters }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const bindable = (candidate: unknown): candidate is BindingExternalApi =>
|
|
49
|
+
candidate instanceof Uint8Array ||
|
|
50
|
+
typeof candidate === 'string' ||
|
|
51
|
+
typeof candidate === 'number' ||
|
|
52
|
+
typeof candidate === 'bigint' ||
|
|
53
|
+
typeof candidate === 'boolean'
|
|
54
|
+
|
|
55
|
+
// SQLite binds strings/numbers/bigints/booleans/blobs/null directly and throws on any
|
|
56
|
+
// other object — so a json-column value (an object/array) is serialized to JSON text.
|
|
57
|
+
const coerceParam = (param: unknown): BindingExternalApi => {
|
|
58
|
+
if (param === undefined || param === null) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
// oxlint-disable-next-line reform-rules/no-json-parse-stringify -- encodes an already-decoded json-column value for SQLite's text storage; the column's own Schema round-trips it on read
|
|
62
|
+
return bindable(param) ? param : JSON.stringify(param)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Open the database (loading the Bun-only module lazily) and run migrations. Though
|
|
66
|
+
// bun:sqlite is synchronous, this is the ASYNC-shaped work `makeDriver` defers to a
|
|
67
|
+
// background fiber so the `Db` service can be acquired SYNCHRONOUSLY.
|
|
68
|
+
const openSqlite = Effect.fn('openSqlite')(function* (
|
|
69
|
+
options: SqliteOptions,
|
|
70
|
+
): Effect.fn.Return<Database, DbError> {
|
|
71
|
+
const { Database } = yield* Effect.tryPromise({
|
|
72
|
+
try: () => import('bun:sqlite'),
|
|
73
|
+
catch: (cause) => new DbError({ reason: 'bun:sqlite unavailable (Bun runtime required)', cause }),
|
|
74
|
+
})
|
|
75
|
+
// `Database.deserialize` wants the snapshot bytes synchronously; the `Blob` is already
|
|
76
|
+
// fully in memory (it came from a `dump`), so read it async here, then open sync below.
|
|
77
|
+
const seed = yield* Option.match(Option.fromNullable(options.loadDataDir), {
|
|
78
|
+
onNone: () => Effect.succeed(Option.none<Uint8Array>()),
|
|
79
|
+
onSome: (blob) =>
|
|
80
|
+
Effect.tryPromise({
|
|
81
|
+
try: () => blob.arrayBuffer(),
|
|
82
|
+
catch: (cause) => new DbError({ reason: 'loadDataDir read failed', cause }),
|
|
83
|
+
}).pipe(Effect.map((buffer) => Option.some(new Uint8Array(buffer)))),
|
|
84
|
+
})
|
|
85
|
+
const db = yield* Effect.try({
|
|
86
|
+
try: () =>
|
|
87
|
+
Option.match(seed, {
|
|
88
|
+
onNone: () => new Database(options.filename ?? ':memory:'),
|
|
89
|
+
// A snapshot restore is an in-memory database seeded from the serialized bytes.
|
|
90
|
+
onSome: (bytes) => Database.deserialize(bytes),
|
|
91
|
+
}),
|
|
92
|
+
catch: (cause) => new DbError({ reason: 'open failed', cause }),
|
|
93
|
+
})
|
|
94
|
+
yield* Option.match(Option.fromNullable(options.migrations), {
|
|
95
|
+
onNone: () => Effect.void,
|
|
96
|
+
onSome: (migrations) =>
|
|
97
|
+
Effect.forEach(migrations, (migration) =>
|
|
98
|
+
Effect.forEach(migration.statements, (statement) =>
|
|
99
|
+
Effect.try({
|
|
100
|
+
try: () => db.run(statement),
|
|
101
|
+
catch: (cause) => new DbError({ reason: 'migration failed', sql: statement, cause }),
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
})
|
|
106
|
+
return db
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const backendOf = (db: Database): EngineBackend => ({
|
|
110
|
+
query: (sql, parameters) =>
|
|
111
|
+
Effect.try({
|
|
112
|
+
try: () => db.query(sql).all(...parameters.map(coerceParam)),
|
|
113
|
+
catch: (cause) => new DbError({ reason: 'query failed', sql, cause }),
|
|
114
|
+
}),
|
|
115
|
+
// No native reactivity: `makeDriver` re-runs live queries after each write.
|
|
116
|
+
live: Option.none(),
|
|
117
|
+
// `serialize()` returns the full database as a Buffer; wrap it as a portable Blob so
|
|
118
|
+
// it round-trips through the same dump→save→reload contract as PGlite's tarball.
|
|
119
|
+
dump: Effect.try({
|
|
120
|
+
try: () => new Blob([new Uint8Array(db.serialize())]),
|
|
121
|
+
catch: (cause) => new DbError({ reason: 'dump failed', cause }),
|
|
122
|
+
}),
|
|
123
|
+
close: Effect.sync(() => db.close()),
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/** The bun:sqlite `DbEngine`. Feed to `makeDriver` (see `Sqlite.layer`/`Sqlite.memory`). */
|
|
127
|
+
export const make = (options: SqliteOptions): DbEngine => ({
|
|
128
|
+
compile,
|
|
129
|
+
open: () => openSqlite(options).pipe(Effect.map(backendOf)),
|
|
130
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @playfast/reform-db-sqlite — the bun:sqlite engine for @playfast/reform-db.
|
|
2
|
+
//
|
|
3
|
+
// Provides the `Db` driver layer backed by Bun's built-in SQLite (`bun:sqlite`):
|
|
4
|
+
// synchronous, dependency-free, with emulated reactivity (live `DbQuery`s re-run
|
|
5
|
+
// after each write) and `serialize()`/`deserialize()` snapshots. Pair it with the
|
|
6
|
+
// engine-neutral primitives (`DbColumn`/`DbTable`/`DbSchema`/`DbQuery`/`Migration`)
|
|
7
|
+
// from `@playfast/reform-db` — remember the SQLite dialect for migrations:
|
|
8
|
+
//
|
|
9
|
+
// import { Sqlite } from '@playfast/reform-db-sqlite'
|
|
10
|
+
// import { Migration } from '@playfast/reform-db'
|
|
11
|
+
// const DataLayer = QLive.pipe(
|
|
12
|
+
// Layer.provideMerge(
|
|
13
|
+
// Sqlite.layer({ filename: 'app.db', migrations: Migration.fromSchema(AppDb, Migration.sqlite) }),
|
|
14
|
+
// ),
|
|
15
|
+
// )
|
|
16
|
+
|
|
17
|
+
export * as Sqlite from './sqlite'
|
|
18
|
+
export type { MemoryOptions } from './sqlite'
|
|
19
|
+
export type { SqliteOptions } from './engine'
|
|
20
|
+
// The engine itself, for advanced callers wiring `makeDriver` directly.
|
|
21
|
+
export { make as makeSqliteEngine } from './engine'
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Bun-runtime integration test for the bun:sqlite engine. It uses Bun's own test
|
|
2
|
+
// runner (`bun:test`) because `bun:sqlite` only exists under Bun — and vitest runs its
|
|
3
|
+
// workers in Node, where that module is absent. Run it with `bun test` (the package's
|
|
4
|
+
// `test` script); vitest's `*.test.ts` glob does not match `.spec.ts`, so the
|
|
5
|
+
// workspace `vitest run` ignores it.
|
|
6
|
+
import {
|
|
7
|
+
Db,
|
|
8
|
+
DbColumn,
|
|
9
|
+
DbQuery,
|
|
10
|
+
DbSchema,
|
|
11
|
+
DbTable,
|
|
12
|
+
Migration,
|
|
13
|
+
type QueryData,
|
|
14
|
+
} from '@playfast/reform-db'
|
|
15
|
+
import { expect, test } from 'bun:test'
|
|
16
|
+
import { Context, Effect, Exit, Layer, Schedule, Schema, Scope } from 'effect'
|
|
17
|
+
import { Sqlite } from './index'
|
|
18
|
+
|
|
19
|
+
const Meta = Schema.Struct({ note: Schema.String })
|
|
20
|
+
|
|
21
|
+
// A table covering the type shapes SQLite stores differently from Postgres: a boolean
|
|
22
|
+
// (stored 0/1) and a json column (stored as text). The engine-tolerant column Schemas
|
|
23
|
+
// in @playfast/reform-db must still decode them back to `boolean` / object.
|
|
24
|
+
const Items = DbTable.make('items', {
|
|
25
|
+
columns: {
|
|
26
|
+
id: DbColumn.text({ primaryKey: true }),
|
|
27
|
+
label: DbColumn.text(),
|
|
28
|
+
rank: DbColumn.integer(),
|
|
29
|
+
done: DbColumn.boolean(),
|
|
30
|
+
meta: DbColumn.json(Meta),
|
|
31
|
+
},
|
|
32
|
+
primaryKey: 'id',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const AppDb = DbSchema.make('app', { tables: [Items] })
|
|
36
|
+
const migrations = Migration.fromSchema(AppDb, Migration.sqlite)
|
|
37
|
+
|
|
38
|
+
const ItemsQuery = DbQuery.make('items', { output: Items.Row })
|
|
39
|
+
|
|
40
|
+
type Item = {
|
|
41
|
+
readonly id: string
|
|
42
|
+
readonly label: string
|
|
43
|
+
readonly rank: number
|
|
44
|
+
readonly done: boolean
|
|
45
|
+
readonly meta: { readonly note: string }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rowsOf = (data: QueryData<Item>): ReadonlyArray<Item> =>
|
|
49
|
+
data._tag === 'Success' ? data.value : []
|
|
50
|
+
|
|
51
|
+
const waitUntil = (predicate: () => boolean) =>
|
|
52
|
+
Effect.sync(predicate).pipe(
|
|
53
|
+
Effect.repeat({ until: (ready) => ready, schedule: Schedule.spaced('20 millis') }),
|
|
54
|
+
Effect.timeout('5 seconds'),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const liveLayer = (dataLayer: Layer.Layer<Db>) =>
|
|
58
|
+
DbQuery.live(ItemsQuery, {
|
|
59
|
+
schema: AppDb,
|
|
60
|
+
inputs: [],
|
|
61
|
+
build: (_inputs, db) => db.selectFrom('items').selectAll().orderBy('rank'),
|
|
62
|
+
}).pipe(Layer.provideMerge(dataLayer))
|
|
63
|
+
|
|
64
|
+
test('DbQuery reflects writes (emulated reactivity), decoding boolean + json columns', async () => {
|
|
65
|
+
const program = Effect.gen(function* () {
|
|
66
|
+
const store = yield* ItemsQuery.store
|
|
67
|
+
|
|
68
|
+
// The table exists but is empty; the emulated live query resolves to empty Success.
|
|
69
|
+
yield* waitUntil(() => store.getSnapshot()._tag === 'Success')
|
|
70
|
+
expect(rowsOf(store.getSnapshot())).toEqual([])
|
|
71
|
+
|
|
72
|
+
const insert = (id: string, label: string, rank: number, done: boolean, note: string) =>
|
|
73
|
+
Db.exec(AppDb.kysely.insertInto('items').values({ id, label, rank, done, meta: { note } }))
|
|
74
|
+
|
|
75
|
+
yield* insert('b', 'second', 2, false, 'two')
|
|
76
|
+
yield* insert('a', 'first', 1, true, 'one')
|
|
77
|
+
|
|
78
|
+
// Each `exec` re-fires the emulated live query.
|
|
79
|
+
yield* waitUntil(() => rowsOf(store.getSnapshot()).length === 2)
|
|
80
|
+
const rows = rowsOf(store.getSnapshot())
|
|
81
|
+
expect(rows.map((row) => row.label)).toEqual(['first', 'second'])
|
|
82
|
+
// boolean decoded from SQLite's 0/1, json decoded from stored text.
|
|
83
|
+
expect(rows.map((row) => row.done)).toEqual([true, false])
|
|
84
|
+
expect(rows.map((row) => row.meta.note)).toEqual(['one', 'two'])
|
|
85
|
+
}).pipe(Effect.provide(liveLayer(Sqlite.memory({ migrations }))))
|
|
86
|
+
|
|
87
|
+
await Effect.runPromise(program)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('dump() → loadDataDir round-trips rows into a fresh in-memory db', async () => {
|
|
91
|
+
const dump = await Effect.runPromise(
|
|
92
|
+
Effect.scoped(
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const context = yield* Layer.build(Sqlite.memory({ migrations }))
|
|
95
|
+
const driver = Context.get(context, Db)
|
|
96
|
+
yield* Db.exec(
|
|
97
|
+
AppDb.kysely
|
|
98
|
+
.insertInto('items')
|
|
99
|
+
.values({ id: 'a', label: 'first', rank: 1, done: true, meta: { note: 'one' } }),
|
|
100
|
+
).pipe(Effect.provideService(Db, driver))
|
|
101
|
+
return yield* driver.dump()
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
await Effect.runPromise(
|
|
107
|
+
Effect.scoped(
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const store = yield* ItemsQuery.store
|
|
110
|
+
yield* waitUntil(() => rowsOf(store.getSnapshot()).length === 1)
|
|
111
|
+
const row = rowsOf(store.getSnapshot())[0]
|
|
112
|
+
expect(row?.label).toBe('first')
|
|
113
|
+
expect(row?.done).toBe(true)
|
|
114
|
+
expect(row?.meta.note).toBe('one')
|
|
115
|
+
}).pipe(Effect.provide(liveLayer(Sqlite.memory({ migrations, loadDataDir: dump })))),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('Sqlite.memory layer is sync-providable: store starts loading, then resolves to Success', async () => {
|
|
121
|
+
const layer = liveLayer(Sqlite.memory({ migrations }))
|
|
122
|
+
|
|
123
|
+
// Build the layer SYNCHRONOUSLY (bun:sqlite opens on a background fiber). This is the
|
|
124
|
+
// path reform's `runSync` renderer/proof mount takes; it must not throw "Fiber cannot
|
|
125
|
+
// be resolved synchronously".
|
|
126
|
+
const scope = Effect.runSync(Scope.make())
|
|
127
|
+
const context = Effect.runSync(Layer.buildWithScope(layer, scope))
|
|
128
|
+
const store = Context.get(context, ItemsQuery.store)
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
expect(store.getSnapshot()._tag).toBe('Loading')
|
|
132
|
+
await Effect.runPromise(waitUntil(() => store.getSnapshot()._tag === 'Success'))
|
|
133
|
+
expect(rowsOf(store.getSnapshot())).toEqual([])
|
|
134
|
+
} finally {
|
|
135
|
+
await Effect.runPromise(Scope.close(scope, Exit.void))
|
|
136
|
+
}
|
|
137
|
+
})
|
package/src/sqlite.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Db, makeDriver } from '@playfast/reform-db'
|
|
2
|
+
import { Layer } from 'effect'
|
|
3
|
+
import { make, type SqliteOptions } from './engine'
|
|
4
|
+
|
|
5
|
+
// The bun:sqlite `Db` layers, built on `@playfast/reform-db`'s engine-neutral
|
|
6
|
+
// `makeDriver` and the SQLite engine here. Both build the `Db` service synchronously
|
|
7
|
+
// (bun:sqlite opens on a background fiber inside `makeDriver`), so they are providable
|
|
8
|
+
// under reform's `runSync` scene/proof mount. Reactivity is emulated by the core:
|
|
9
|
+
// SQLite has no native live queries, so live `DbQuery`s re-run after each write.
|
|
10
|
+
//
|
|
11
|
+
// NOTE: migrations must be rendered for SQLite — pass
|
|
12
|
+
// `Migration.fromSchema(AppDb, Migration.sqlite)`.
|
|
13
|
+
|
|
14
|
+
// Options for `Sqlite.memory` — an in-memory database can still be migrated and seeded
|
|
15
|
+
// from a `dump` snapshot, so it accepts everything but `filename`.
|
|
16
|
+
export interface MemoryOptionsExternalApi {
|
|
17
|
+
readonly migrations?: SqliteOptions['migrations']
|
|
18
|
+
readonly loadDataDir?: SqliteOptions['loadDataDir']
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type MemoryOptions = MemoryOptionsExternalApi
|
|
22
|
+
|
|
23
|
+
/** A persistent (or in-memory, if `filename` is omitted) bun:sqlite-backed `Db` driver. */
|
|
24
|
+
export const layer = (options: SqliteOptions = {}): Layer.Layer<Db> =>
|
|
25
|
+
Layer.scoped(Db, makeDriver(make(options)))
|
|
26
|
+
|
|
27
|
+
/** An ephemeral in-memory bun:sqlite-backed `Db` driver — the default for tests/proofs. */
|
|
28
|
+
export const memory = (options: MemoryOptions = {}): Layer.Layer<Db> =>
|
|
29
|
+
Layer.scoped(
|
|
30
|
+
Db,
|
|
31
|
+
// Spread each option only when set so the bare `Sqlite.memory()` open stays unchanged.
|
|
32
|
+
makeDriver(
|
|
33
|
+
make({
|
|
34
|
+
...(options.migrations !== undefined ? { migrations: options.migrations } : {}),
|
|
35
|
+
...(options.loadDataDir !== undefined ? { loadDataDir: options.loadDataDir } : {}),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
)
|