Merge commit '3c60637c1a27da8ba66888de518d58d5707801f2' as 'repos/effect-smol'

This commit is contained in:
-Puter
2026-07-19 03:28:54 +05:30
parent 2daf979036
commit a37e0cc3c9
2163 changed files with 668421 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-present The Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
# `@effect/sql-sqlite-node`
An `@effect/sql` implementation using Node.js' built-in `node:sqlite` module.
## Documentation
- **API Reference**: [View the full documentation](https://effect-ts.github.io/effect/docs/sql-sqlite-node).

View File

@@ -0,0 +1,23 @@
{
"$schema": "../../node_modules/@effect/docgen/schema.json",
"srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/sql/sqlite-node/src/",
"exclude": ["src/internal/**/*.ts"],
"examplesCompilerOptions": {
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"module": "ES2022",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"rewriteRelativeImportExtensions": true,
"allowImportingTsExtensions": true,
"paths": {
"effect": ["../../../effect/src/index.js"],
"effect/*": ["../../../effect/src/*.js"]
},
"plugins": [
{ "name": "@effect/language-service", "includeSuggestionsInTsc": false }
]
}
}

View File

@@ -0,0 +1,68 @@
{
"name": "@effect/sql-sqlite-node",
"version": "4.0.0-beta.98",
"type": "module",
"license": "MIT",
"description": "A SQLite toolkit for Effect",
"homepage": "https://effect.website",
"repository": {
"type": "git",
"url": "https://github.com/Effect-TS/effect-smol.git",
"directory": "packages/sql/sqlite-node"
},
"bugs": {
"url": "https://github.com/Effect-TS/effect-smol/issues"
},
"tags": [
"typescript",
"sql",
"database"
],
"keywords": [
"typescript",
"sql",
"database"
],
"sideEffects": [],
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts",
"./*": "./src/*.ts",
"./internal/*": null,
"./*/index": null
},
"files": [
"src/**/*.ts",
"dist/**/*.js",
"dist/**/*.js.map",
"dist/**/*.d.ts",
"dist/**/*.d.ts.map"
],
"publishConfig": {
"access": "public",
"provenance": true,
"exports": {
"./package.json": "./package.json",
".": "./dist/index.js",
"./*": "./dist/*.js",
"./internal/*": null,
"./*/index": null
}
},
"scripts": {
"codegen": "effect-utils codegen",
"build": "tsc -b tsconfig.json && pnpm babel",
"babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps",
"check": "tsc -b tsconfig.json",
"test": "vitest",
"coverage": "vitest --coverage"
},
"devDependencies": {
"@effect/platform-node": "workspace:^",
"@types/node": "^26.1.1",
"effect": "workspace:^"
},
"peerDependencies": {
"effect": "workspace:^"
}
}

View File

@@ -0,0 +1,371 @@
/**
* Connects Effect SQL to SQLite on Node.js using `node:sqlite`.
*
* This module opens a SQLite database and exposes it as both `SqliteClient` and
* the generic Effect SQL client. It serializes access through one connection,
* caches prepared statements, enables WAL mode unless disabled, and supports
* database backup, and extension loading. Streaming queries and
* `updateValues` are not supported by this driver.
*
* @since 4.0.0
*/
import * as Cache from "effect/Cache"
import * as Config from "effect/Config"
import * as Context from "effect/Context"
import * as Duration from "effect/Duration"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { backup as backupDatabase, DatabaseSync } from "node:sqlite"
import type { StatementSync } from "node:sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
/**
* Runtime type identifier used to mark Node `SqliteClient` values.
*
* @category type IDs
* @since 4.0.0
*/
export const TypeId: TypeId = "~@effect/sql-sqlite-node/SqliteClient"
/**
* Type-level identifier used to mark Node `SqliteClient` values.
*
* @category type IDs
* @since 4.0.0
*/
export type TypeId = "~@effect/sql-sqlite-node/SqliteClient"
/**
* Node SQLite client service, extending `SqlClient` with database export, backup, and extension loading helpers. `updateValues` is not supported.
*
* @category models
* @since 4.0.0
*/
export interface SqliteClient extends Client.SqlClient {
readonly [TypeId]: TypeId
readonly config: SqliteClientConfig
readonly backup: (destination: string) => Effect.Effect<BackupMetadata, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
/** Not supported in sqlite */
readonly updateValues: never
}
/**
* Metadata returned from a Node SQLite backup operation, reporting total and remaining page counts.
*
* @category models
* @since 4.0.0
*/
export interface BackupMetadata {
readonly totalPages: number
readonly remainingPages: number
}
/**
* Service tag for the node SQLite client implementation.
*
* @category services
* @since 4.0.0
*/
export const SqliteClient = Context.Service<SqliteClient>("@effect/sql-sqlite-node/SqliteClient")
/**
* Configuration for a node SQLite client backed by `node:sqlite`, including the database filename, read-only mode, statement cache settings, WAL behavior, span attributes, and query/result name transforms.
*
* @category models
* @since 4.0.0
*/
export interface SqliteClientConfig {
readonly filename: string
readonly readonly?: boolean | undefined
readonly prepareCacheSize?: number | undefined
readonly prepareCacheTTL?: Duration.Input | undefined
readonly disableWAL?: boolean | undefined
readonly spanAttributes?: Record<string, unknown> | undefined
readonly transformResultNames?: ((str: string) => string) | undefined
readonly transformQueryNames?: ((str: string) => string) | undefined
}
interface SqliteConnection extends Connection {
readonly backup: (destination: string) => Effect.Effect<BackupMetadata, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
/**
* Creates a scoped node SQLite client from the supplied configuration, using a single serialized connection with WAL enabled by default and exposing SQLite-specific `export`, `backup`, and `loadExtension` operations.
*
* @category constructors
* @since 4.0.0
*/
export const make = (
options: SqliteClientConfig
): Effect.Effect<SqliteClient, never, Scope.Scope | Reactivity.Reactivity> =>
Effect.gen(function*() {
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames ?
Statement.defaultTransforms(
options.transformResultNames
).array :
undefined
const makeConnection = Effect.gen(function*() {
const scope = yield* Effect.scope
const db = new DatabaseSync(options.filename, {
readOnly: options.readonly ?? false,
allowExtension: true
})
yield* Scope.addFinalizer(scope, Effect.sync(() => db.close()))
db.enableLoadExtension(false)
if (options.disableWAL !== true) {
db.exec("PRAGMA journal_mode = WAL")
}
const prepareCache = yield* Cache.make({
capacity: options.prepareCacheSize ?? 200,
timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),
lookup: (sql: string) =>
Effect.try({
try: () => db.prepare(sql),
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to prepare statement", "prepare") })
})
})
const runStatement = (
statement: StatementSync,
params: ReadonlyArray<unknown>,
raw: boolean
) =>
Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
const useSafeIntegers = Context.get(fiber.context, Client.SafeIntegers)
return Effect.try({
try: () => {
statement.setReadBigInts(useSafeIntegers)
if (statement.columns().length > 0) {
return statement.all(...(params as Array<any>)) as ReadonlyArray<any>
}
const result = statement.run(...(params as Array<any>))
return raw ? { changes: result.changes, lastInsertRowid: result.lastInsertRowid } as any : []
},
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
})
})
const runStatementValues = (
statement: StatementSync,
params: ReadonlyArray<unknown>
) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const useSafeIntegers = Context.get(fiber.context, Client.SafeIntegers)
return Effect.try({
try: () => {
statement.setReadBigInts(useSafeIntegers)
if (statement.columns().length > 0) {
return statement.all(...(params as Array<any>)) as unknown as ReadonlyArray<ReadonlyArray<unknown>>
}
statement.run(...(params as Array<any>))
return []
},
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
})
})
const runStatementValuesUnprepared = (
statement: StatementSync,
params: ReadonlyArray<unknown>
) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const useSafeIntegers = Context.get(fiber.context, Client.SafeIntegers)
return Effect.try({
try: () => {
statement.setReadBigInts(useSafeIntegers)
statement.setReturnArrays(true)
if (statement.columns().length > 0) {
return statement.all(...(params as Array<any>)) as unknown as ReadonlyArray<ReadonlyArray<unknown>>
}
statement.run(...(params as Array<any>))
return []
},
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
})
})
const run = (
sql: string,
params: ReadonlyArray<unknown>,
raw = false
) =>
Effect.flatMap(
Cache.get(prepareCache, sql),
(s) => runStatement(s, params, raw)
)
const runValues = (
sql: string,
params: ReadonlyArray<unknown>
) =>
Effect.acquireUseRelease(
Cache.get(prepareCache, sql),
(statement) => {
statement.setReturnArrays(true)
return runStatementValues(statement, params)
},
(statement) => Effect.sync(() => statement.setReturnArrays(false))
)
const runValuesUnprepared = (
sql: string,
params: ReadonlyArray<unknown>
) => runStatementValuesUnprepared(db.prepare(sql), params)
return identity<SqliteConnection>({
execute(sql, params, transformRows) {
return transformRows
? Effect.map(run(sql, params), transformRows)
: run(sql, params)
},
executeRaw(sql, params) {
return run(sql, params, true)
},
executeValues(sql, params) {
return runValues(sql, params)
},
executeValuesUnprepared(sql, params) {
return runValuesUnprepared(sql, params)
},
executeUnprepared(sql, params, transformRows) {
const effect = runStatement(db.prepare(sql), params ?? [], false)
return transformRows ? Effect.map(effect, transformRows) : effect
},
executeStream(_sql, _params) {
return Stream.die("executeStream not implemented")
},
backup(destination) {
return Effect.suspend(() => {
let totalPages = 0
return Effect.tryPromise({
try: () =>
backupDatabase(db, destination, {
progress: (progress) => {
totalPages = progress.totalPages
}
}).then((pages): BackupMetadata => ({ totalPages: totalPages || pages, remainingPages: 0 })),
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to backup database", "backup") })
})
})
},
loadExtension(path) {
return Effect.acquireUseRelease(
Effect.sync(() => db.enableLoadExtension(true)),
() =>
Effect.try({
try: () => db.loadExtension(path),
catch: (cause) =>
new SqlError({ reason: classifyError(cause, "Failed to load extension", "loadExtension") })
}),
() => Effect.sync(() => db.enableLoadExtension(false))
)
}
})
})
const semaphore = yield* Semaphore.make(1)
const connection = yield* makeConnection
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(
restore(semaphore.take(1)),
() => Scope.addFinalizer(scope, semaphore.release(1))
),
connection
)
})
return Object.assign(
(yield* Client.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"]
],
transformRows
})) as SqliteClient,
{
[TypeId]: TypeId as TypeId,
config: options,
backup: (destination: string) => Effect.flatMap(acquirer, (_) => _.backup(destination)),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path))
}
)
})
/**
* Builds a layer from an Effect `Config` value, providing both the node `SqliteClient` service and the generic `SqlClient` service.
*
* @category layers
* @since 4.0.0
*/
export const layerConfig = (
config: Config.Wrap<SqliteClientConfig>
): Layer.Layer<SqliteClient | Client.SqlClient, Config.ConfigError> =>
Layer.effectContext(
Config.unwrap(config).pipe(
Effect.flatMap(make),
Effect.map((client) =>
Context.make(SqliteClient, client).pipe(
Context.add(Client.SqlClient, client)
)
)
)
).pipe(Layer.provide(Reactivity.layer))
/**
* Builds a layer from a node SQLite client configuration, providing both `SqliteClient` and the generic `SqlClient` service.
*
* @category layers
* @since 4.0.0
*/
export const layer = (
config: SqliteClientConfig
): Layer.Layer<SqliteClient | Client.SqlClient> =>
Layer.effectContext(
Effect.map(make(config), (client) =>
Context.make(SqliteClient, client).pipe(
Context.add(Client.SqlClient, client)
))
).pipe(Layer.provide(Reactivity.layer))
// internal
const classifyError = (cause: unknown, message: string, operation: string) =>
classifySqliteError(sqliteCauseWithErrno(cause), { message, operation })
const sqliteCauseWithErrno = (cause: unknown): unknown => {
if (typeof cause !== "object" || cause === null || !("errcode" in cause) || "errno" in cause) {
return cause
}
const errcode = (cause as { readonly errcode: unknown }).errcode
if (typeof errcode !== "number") {
return cause
}
return Object.assign(cause, { errno: errcode })
}

View File

@@ -0,0 +1,90 @@
/**
* Runs database migrations for Node.js SQLite projects that use Effect SQL.
*
* This module re-exports the shared migration loaders and errors, then provides
* `run` and `layer` helpers that apply pending migration files with the current
* `SqlClient`. It does not add Node-specific schema dump support; migration
* execution is handled by the shared SQL migrator.
*
* @since 4.0.0
*/
import type * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Migrator from "effect/unstable/sql/Migrator"
import type * as Client from "effect/unstable/sql/SqlClient"
import type { SqlError } from "effect/unstable/sql/SqlError"
/**
* @since 4.0.0
*/
export * from "effect/unstable/sql/Migrator"
/**
* Runs SQL migrations for a SQLite database using the shared `Migrator` implementation and the current `SqlClient`.
*
* @category constructors
* @since 4.0.0
*/
export const run: <R2 = never>(
options: Migrator.MigratorOptions<R2>
) => Effect.Effect<
ReadonlyArray<readonly [id: number, name: string]>,
Migrator.MigrationError | SqlError,
Client.SqlClient | R2
> = Migrator.make({
// dumpSchema(path, table) {
// const dump = (args: Array<string>) =>
// Effect.gen(function*() {
// const sql = yield* SqliteClient
// const dump = yield* pipe(
// Command.make("sqlite3", sql.config.filename, ...args),
// Command.string
// )
// return dump.replace(/^create table sqlite_sequence\(.*$/im, "")
// .replace(/\n{2,}/gm, "\n\n")
// .trim()
// }).pipe(
// Effect.mapError((error) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
// )
//
// const dumpSchema = dump([".schema"])
//
// const dumpMigrations = dump([
// "--cmd",
// `.mode insert ${table}`,
// `select * from ${table}`
// ])
//
// const dumpAll = Effect.map(
// Effect.all([dumpSchema, dumpMigrations], { concurrency: 2 }),
// ([schema, migrations]) => schema + "\n\n" + migrations
// )
//
// const dumpFile = (file: string) =>
// Effect.gen(function*() {
// const fs = yield* FileSystem
// const path = yield* Path
// const dump = yield* dumpAll
// yield* fs.makeDirectory(path.dirname(file), { recursive: true })
// yield* fs.writeFileString(file, dump)
// }).pipe(
// Effect.mapError((error) => new Migrator.MigrationError({ kind: "Failed", message: error.message }))
// )
//
// return dumpFile(path)
// }
})
/**
* Creates a layer that runs the configured SQLite migrations during layer construction and provides no services.
*
* @category constructors
* @since 4.0.0
*/
export const layer = <R>(
options: Migrator.MigratorOptions<R>
): Layer.Layer<
never,
Migrator.MigrationError | SqlError,
Client.SqlClient | R
> => Layer.effectDiscard(run(options))

View File

@@ -0,0 +1,15 @@
/**
* @since 4.0.0
*/
// @barrel: Auto-generated exports. Do not edit manually.
/**
* @since 4.0.0
*/
export * as SqliteClient from "./SqliteClient.ts"
/**
* @since 4.0.0
*/
export * as SqliteMigrator from "./SqliteMigrator.ts"

View File

@@ -0,0 +1,88 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
const makeClient = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return yield* SqliteClient.make({
filename: dir + "/test.db"
})
}).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer]))
describe("Client", () => {
it.effect("should work", () =>
Effect.gen(function*() {
const sql = yield* makeClient
let response
response = yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`
assert.deepStrictEqual(response, [])
response = yield* sql`INSERT INTO test (name) VALUES ('hello')`
assert.deepStrictEqual(response, [])
response = yield* sql`SELECT * FROM test`
assert.deepStrictEqual(response, [{ id: 1, name: "hello" }])
response = yield* sql`SELECT * FROM test`.valuesUnprepared
assert.deepStrictEqual(response, [[1, "hello"]])
response = yield* sql`INSERT INTO test (name) VALUES ('world')`.pipe(sql.withTransaction)
assert.deepStrictEqual(response, [])
response = yield* sql`SELECT * FROM test`
assert.deepStrictEqual(response, [
{ id: 1, name: "hello" },
{ id: 2, name: "world" }
])
}))
it.effect("should work with raw", () =>
Effect.gen(function*() {
const sql = yield* makeClient
let response
response = yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`.raw
assert.deepStrictEqual(response, { changes: 0, lastInsertRowid: 0 })
response = yield* sql`INSERT INTO test (name) VALUES ('hello')`.raw
assert.deepStrictEqual(response, { changes: 1, lastInsertRowid: 1 })
response = yield* sql`SELECT * FROM test`.raw
assert.deepStrictEqual(response, [{ id: 1, name: "hello" }])
response = yield* sql`INSERT INTO test (name) VALUES ('world')`.raw.pipe(sql.withTransaction)
assert.deepStrictEqual(response, { changes: 1, lastInsertRowid: 2 })
response = yield* sql`SELECT * FROM test`
assert.deepStrictEqual(response, [
{ id: 1, name: "hello" },
{ id: 2, name: "world" }
])
}))
it.effect("withTransaction", () =>
Effect.gen(function*() {
const sql = yield* makeClient
yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`
yield* sql.withTransaction(sql`INSERT INTO test (name) VALUES ('hello')`)
const rows = yield* sql`SELECT * FROM test`
assert.deepStrictEqual(rows, [{ id: 1, name: "hello" }])
}))
it.effect("withTransaction rollback", () =>
Effect.gen(function*() {
const sql = yield* makeClient
yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`
yield* sql`INSERT INTO test (name) VALUES ('hello')`.pipe(
Effect.andThen(Effect.fail("boom")),
sql.withTransaction,
Effect.ignore
)
const rows = yield* sql`SELECT * FROM test`
assert.deepStrictEqual(rows, [])
}))
it.effect("supports backup and export", () =>
Effect.gen(function*() {
const sql = yield* makeClient
yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`
yield* sql`INSERT INTO test (name) VALUES ('hello')`
const metadata = yield* sql.backup(sql.config.filename + ".backup")
assert(metadata.totalPages > 0)
assert.strictEqual(metadata.remainingPages, 0)
}))
})

View File

@@ -0,0 +1,29 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { Effect, FileSystem, Layer } from "effect"
import * as KeyValueStoreTest from "effect-test/unstable/persistence/KeyValueStoreTest"
import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
import { Reactivity } from "effect/unstable/reactivity"
const ClientLayer = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return SqliteClient.layer({
filename: dir + "/test.db"
})
}).pipe(
Layer.unwrap,
Layer.provide([NodeFileSystem.layer, Reactivity.layer])
)
KeyValueStoreTest.suite(
"sql-sqlite",
KeyValueStore.layerSql().pipe(Layer.provide(ClientLayer))
)
KeyValueStoreTest.suite(
"sql-sqlite-custom-table",
KeyValueStore.layerSql({ table: "effect_key_value_store_custom" }).pipe(
Layer.provide(ClientLayer)
)
)

View File

@@ -0,0 +1,109 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { expect, it } from "@effect/vitest"
import { Duration, Effect, FileSystem, Layer } from "effect"
import { TestClock } from "effect/testing"
import { Persistence } from "effect/unstable/persistence"
import { Reactivity } from "effect/unstable/reactivity"
import type * as SqlClient from "effect/unstable/sql/SqlClient"
const ClientLayer = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return SqliteClient.layer({
filename: dir + "/test.db"
})
}).pipe(
Layer.unwrap,
Layer.provide([NodeFileSystem.layer, Reactivity.layer])
)
const testLayer = (layer: Layer.Layer<Persistence.BackingPersistence, never, SqlClient.SqlClient>) =>
layer.pipe(Layer.provideMerge(ClientLayer))
const suite = (name: string, layer: Layer.Layer<Persistence.BackingPersistence, never, SqlClient.SqlClient>) =>
it.layer(testLayer(layer))(`Persistence (${name})`, (it) => {
it.effect("set + get", () =>
Effect.gen(function*() {
const persistence = yield* Persistence.BackingPersistence
const store = yield* persistence.make("test_store")
yield* store.set("key1", { name: "Alice" }, undefined)
const value = yield* store.get("key1")
expect(value).toEqual({ name: "Alice" })
// test upsert
yield* store.set("key1", { name: "Alice" }, undefined)
}))
it.effect("setMany + getMany", () =>
Effect.gen(function*() {
const persistence = yield* Persistence.BackingPersistence
const store = yield* persistence.make("test_store_2")
yield* store.setMany([
["key1", { name: "Alice" }, undefined],
["key2", { name: "Bob" }, undefined],
["key3", { name: "Charlie" }, undefined]
])
const values = yield* store.getMany(["key1", "key2", "key3", "key4"])
expect(values).toEqual([
{ name: "Alice" },
{ name: "Bob" },
{ name: "Charlie" },
undefined
])
}))
it.effect("remove", () =>
Effect.gen(function*() {
const persistence = yield* Persistence.BackingPersistence
const store = yield* persistence.make("test_store_2")
yield* store.setMany([
["key1", { name: "Alice" }, undefined],
["key2", { name: "Bob" }, undefined],
["key3", { name: "Charlie" }, undefined]
])
yield* store.remove("key2")
const valuesAfter = yield* store.getMany(["key1", "key2", "key3"])
expect(valuesAfter).toEqual([{ name: "Alice" }, undefined, { name: "Charlie" }])
}))
it.effect("expires", () =>
Effect.gen(function*() {
const persistence = yield* Persistence.BackingPersistence
const store = yield* persistence.make("test_store_3")
yield* store.setMany([
["key1", { name: "Alice" }, undefined],
["key2", { name: "Bob" }, undefined],
["key3", { name: "Charlie" }, Duration.seconds(10)]
])
let values = yield* store.getMany(["key1", "key2", "key3"])
expect(values).toEqual([{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }])
yield* TestClock.adjust(Duration.seconds(5))
values = yield* store.getMany(["key1", "key2", "key3"])
expect(values).toEqual([{ name: "Alice" }, { name: "Bob" }, { name: "Charlie" }])
yield* TestClock.adjust(Duration.seconds(5))
values = yield* store.getMany(["key1", "key2", "key3"])
expect(values).toEqual([{ name: "Alice" }, { name: "Bob" }, undefined])
}))
it.effect("isolation between stores", () =>
Effect.gen(function*() {
const persistence = yield* Persistence.BackingPersistence
const storeA = yield* persistence.make("test_store_a")
const storeB = yield* persistence.make("test_store_b")
yield* storeA.set("shared-key", { name: "Alice" }, undefined)
yield* storeB.set("shared-key", { name: "Bob" }, undefined)
expect(yield* storeA.get("shared-key")).toEqual({ name: "Alice" })
expect(yield* storeB.get("shared-key")).toEqual({ name: "Bob" })
yield* storeA.clear
expect(yield* storeA.get("shared-key")).toEqual(undefined)
expect(yield* storeB.get("shared-key")).toEqual({ name: "Bob" })
}))
})
suite("table-per-store", Persistence.layerBackingSqlMultiTable)
suite("single-table", Persistence.layerBackingSql)

View File

@@ -0,0 +1,183 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Cause, Effect, FileSystem, Iterable, Schema } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlError, SqlResolver } from "effect/unstable/sql"
const makeClient = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
return yield* SqliteClient.make({
filename: dir + "/test.db"
})
}).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer]))
const seededClient = Effect.gen(function*() {
const sql = yield* makeClient
yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`
for (const id of Iterable.range(1, 100)) {
yield* sql`INSERT INTO test ${sql.insert({ id, name: `name${id}` })}`
}
return sql
})
describe("Resolver", () => {
describe("ordered", () => {
it.effect("insert", () =>
Effect.gen(function*() {
const batches: Array<Array<string>> = []
const sql = yield* seededClient
const Insert = SqlResolver.ordered({
Request: Schema.String,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
execute: (names) => {
batches.push(names)
return sql`INSERT INTO test ${sql.insert(names.map((name) => ({ name })))} RETURNING *`
}
})
const execute = SqlResolver.request(Insert)
assert.deepStrictEqual(
yield* Effect.all({
one: execute("one"),
two: execute("two")
}, { concurrency: "unbounded" }),
{
one: { id: 101, name: "one" },
two: { id: 102, name: "two" }
}
)
assert.deepStrictEqual(batches, [["one", "two"]])
}))
it.effect("result length mismatch", () =>
Effect.gen(function*() {
const batches: Array<Array<number>> = []
const sql = yield* seededClient
const Select = SqlResolver.ordered({
Request: Schema.Number,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
execute: (ids) => {
batches.push(ids)
return sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}`
}
})
const execute = SqlResolver.request(Select)
const error = yield* Effect.all([
execute(1),
execute(2),
execute(3),
execute(101)
], { concurrency: "unbounded" })
.pipe(Effect.flip)
assert(error instanceof SqlError.ResultLengthMismatch)
assert.strictEqual(error.actual, 3)
assert.strictEqual(error.expected, 4)
assert.deepStrictEqual(batches, [[1, 2, 3, 101]])
}))
})
describe("grouped", () => {
it.effect("find by name", () =>
Effect.gen(function*() {
const sql = yield* seededClient
const FindByName = SqlResolver.grouped({
Request: Schema.String,
RequestGroupKey: (name) => name,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
ResultGroupKey: (result) => result.name,
execute: (names) => sql`SELECT * FROM test WHERE name IN ${sql.in(names)}`
})
yield* sql`INSERT INTO test ${sql.insert({ name: "name1" })}`
const execute = SqlResolver.request(FindByName)
assert.deepStrictEqual(
yield* Effect.all({
one: execute("name1"),
two: execute("name2"),
three: Effect.flip(execute("name0"))
}, { concurrency: "unbounded" }),
{
one: [{ id: 1, name: "name1" }, { id: 101, name: "name1" }],
two: [{ id: 2, name: "name2" }],
three: new Cause.NoSuchElementError()
}
)
}))
it.effect("using raw rows", () =>
Effect.gen(function*() {
const sql = yield* seededClient
const FindByName = SqlResolver.grouped({
Request: Schema.String,
RequestGroupKey: (name) => name,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
ResultGroupKey: (_, result: any) => result.name,
execute: (names) => sql`SELECT * FROM test WHERE name IN ${sql.in(names)}`
})
yield* sql`INSERT INTO test ${sql.insert({ name: "name1" })}`
const execute = SqlResolver.request(FindByName)
assert.deepStrictEqual(
yield* Effect.all({
one: execute("name1"),
two: execute("name2"),
three: Effect.flip(execute("name0"))
}, { concurrency: "unbounded" }),
{
one: [{ id: 1, name: "name1" }, { id: 101, name: "name1" }],
two: [{ id: 2, name: "name2" }],
three: new Cause.NoSuchElementError()
}
)
}))
})
describe("findById", () => {
it.effect("find by id", () =>
Effect.gen(function*() {
const sql = yield* seededClient
const FindById = SqlResolver.findById({
Id: Schema.Number,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
ResultId: (result) => result.id,
execute: (ids) => sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}`
})
const execute = SqlResolver.request(FindById)
assert.deepStrictEqual(
yield* Effect.all({
one: execute(1),
two: execute(2),
three: Effect.flip(execute(101))
}, { concurrency: "unbounded" }),
{
one: { id: 1, name: "name1" },
two: { id: 2, name: "name2" },
three: new Cause.NoSuchElementError()
}
)
}))
it.effect("using raw rows", () =>
Effect.gen(function*() {
const sql = yield* seededClient
const FindById = SqlResolver.findById({
Id: Schema.Number,
Result: Schema.Struct({ id: Schema.Number, name: Schema.String }),
ResultId: (_, result: any) => result.id,
execute: (ids) => sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}`
})
const execute = SqlResolver.request(FindById)
assert.deepStrictEqual(
yield* Effect.all({
one: execute(1),
two: execute(2),
three: Effect.flip(execute(101))
}, { concurrency: "unbounded" }),
{
one: { id: 1, name: "name1" },
two: { id: 2, name: "name2" },
three: new Cause.NoSuchElementError()
}
)
}))
})
})

View File

@@ -0,0 +1,84 @@
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Effect } from "effect"
import * as EventJournal from "effect/unstable/eventlog/EventJournal"
import * as SqlEventJournal from "effect/unstable/eventlog/SqlEventJournal"
import { Reactivity } from "effect/unstable/reactivity"
import * as SqlClient from "effect/unstable/sql/SqlClient"
const makeJournal = Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
return yield* SqlEventJournal.make().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
}).pipe(Effect.provide(Reactivity.layer))
describe("SqlEventJournal", () => {
it.effect("writes and reads entries", () =>
Effect.gen(function*() {
const journal = yield* makeJournal
let createdAt = 0
yield* journal.write({
event: "UserCreated",
primaryKey: "user-1",
payload: new Uint8Array([1]),
effect: (entry) =>
Effect.sync(() => {
createdAt = entry.createdAtMillis
})
})
const entries = yield* journal.entries
assert.strictEqual(entries.length, 1)
assert.strictEqual(entries[0].event, "UserCreated")
assert.strictEqual(entries[0].createdAtMillis, createdAt)
}))
it.effect("writes remote entries and sequences", () =>
Effect.gen(function*() {
const journal = yield* makeJournal
const remoteId = EventJournal.makeRemoteIdUnsafe()
const initial = yield* journal.nextRemoteSequence(remoteId)
assert.strictEqual(initial, 0)
const entryA = new EventJournal.Entry({
id: EventJournal.makeEntryIdUnsafe(),
event: "UserCreated",
primaryKey: "user-2",
payload: new Uint8Array([2])
}, { disableChecks: true })
const entryB = new EventJournal.Entry({
id: EventJournal.makeEntryIdUnsafe(),
event: "UserCreated",
primaryKey: "user-3",
payload: new Uint8Array([3])
}, { disableChecks: true })
const remoteEntries = [
new EventJournal.RemoteEntry({ remoteSequence: 0, entry: entryA }),
new EventJournal.RemoteEntry({ remoteSequence: 1, entry: entryB })
]
const seenConflicts: Array<ReadonlyArray<EventJournal.Entry>> = []
yield* journal.writeFromRemote({
remoteId,
entries: remoteEntries,
effect: ({ conflicts }) =>
Effect.sync(() => {
seenConflicts.push(conflicts)
})
})
const next = yield* journal.nextRemoteSequence(remoteId)
assert.strictEqual(next, 2)
yield* journal.write({
event: "LocalCreated",
primaryKey: "local-1",
payload: new Uint8Array([4]),
effect: () => Effect.void
})
const uncommitted = yield* journal.withRemoteUncommited(remoteId, (entries) => Effect.succeed(entries))
assert.strictEqual(uncommitted.length, 1)
assert.strictEqual(uncommitted[0].event, "LocalCreated")
assert.strictEqual(seenConflicts.length, 2)
assert.strictEqual(seenConflicts[0][0]?.idString, entryA.idString)
assert.strictEqual(seenConflicts[1][0]?.idString, entryB.idString)
}))
})

View File

@@ -0,0 +1,249 @@
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Layer, Queue, Redacted, Stream } from "effect"
import * as EventJournal from "effect/unstable/eventlog/EventJournal"
import * as EventLog from "effect/unstable/eventlog/EventLog"
import * as EventLogEncryption from "effect/unstable/eventlog/EventLogEncryption"
import * as EventLogMessage from "effect/unstable/eventlog/EventLogMessage"
import type { StoreId } from "effect/unstable/eventlog/EventLogMessage"
import * as EventLogServer from "effect/unstable/eventlog/EventLogServerEncrypted"
import * as EventLogSessionAuth from "effect/unstable/eventlog/EventLogSessionAuth"
import { makeGetIdentityRootSecretMaterial } from "effect/unstable/eventlog/internal/identityRootSecretDerivation"
import * as SqlEventLogServer from "effect/unstable/eventlog/SqlEventLogServerEncrypted"
import { Reactivity } from "effect/unstable/reactivity"
import * as RpcTest from "effect/unstable/rpc/RpcTest"
import * as SqlClient from "effect/unstable/sql/SqlClient"
const storeIdA = "store-a" as StoreId
const storeIdB = "store-b" as StoreId
const makeEntry = (value: number) =>
new EventJournal.Entry({
id: EventJournal.makeEntryIdUnsafe(),
event: "UserCreated",
primaryKey: `user-${value}`,
payload: new Uint8Array([value])
}, { disableChecks: true })
const persistEntries = (
encryption: EventLogEncryption.EventLogEncryption["Service"],
identity: EventLog.Identity["Service"],
entries: ReadonlyArray<EventJournal.Entry>
) =>
Effect.gen(function*() {
const encrypted = yield* encryption.encrypt(identity, entries)
return encrypted.encryptedEntries.map((encryptedEntry, index) =>
new EventLogServer.PersistedEntry({
entryId: entries[index].id,
iv: encrypted.iv,
encryptedEntry
})
)
})
const makePersistedEntry = (index: number, entryId = EventJournal.makeEntryIdUnsafe()) =>
new EventLogServer.PersistedEntry({
entryId,
iv: new Uint8Array(12),
encryptedEntry: Uint8Array.of(index)
})
const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto)
const makeAuthenticateRequest = Effect.fnUntraced(function*(options: {
readonly identity: EventLog.Identity["Service"]
readonly challenge: Uint8Array
readonly remoteId: EventJournal.RemoteId
}) {
const rootSecretMaterial = yield* getIdentityRootSecretMaterial(options.identity)
const signature = yield* EventLogSessionAuth.signSessionAuthPayload({
remoteId: options.remoteId,
challenge: options.challenge,
publicKey: options.identity.publicKey,
signingPublicKey: rootSecretMaterial.signingPublicKey,
signingPrivateKey: Redacted.value(rootSecretMaterial.signingPrivateKey)
})
return new EventLogMessage.Authenticate({
publicKey: options.identity.publicKey,
signingPublicKey: rootSecretMaterial.signingPublicKey,
signature,
algorithm: "Ed25519"
})
})
describe("SqlEventLogServer", () => {
it.effect("persists remote id across storage instances", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storageA = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
const storageB = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
const idA = yield* storageA.getId
const idB = yield* storageB.getId
assert.deepStrictEqual(idA, idB)
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
it.effect("rejects session-auth rebinding for an existing publicKey", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
const rpcClient = yield* RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe(
Effect.provide(
EventLogServer.layerRpcHandlers.pipe(
Layer.provide(Layer.succeed(EventLogServer.Storage, storage))
)
)
)
const firstIdentity = yield* EventLog.makeIdentity
const secondIdentitySeed = yield* EventLog.makeIdentity
const secondIdentity: EventLog.Identity["Service"] = {
publicKey: firstIdentity.publicKey,
privateKey: secondIdentitySeed.privateKey
}
const firstMaterial = yield* getIdentityRootSecretMaterial(firstIdentity)
const secondMaterial = yield* getIdentityRootSecretMaterial(secondIdentity)
const sameSigningPublicKey =
firstMaterial.signingPublicKey.byteLength === secondMaterial.signingPublicKey.byteLength &&
firstMaterial.signingPublicKey.every((byte, index) => byte === secondMaterial.signingPublicKey[index])
assert.strictEqual(sameSigningPublicKey, false)
const firstHello = yield* rpcClient["EventLog.Hello"]()
yield* rpcClient["EventLog.Authenticate"](
yield* makeAuthenticateRequest({
identity: firstIdentity,
challenge: firstHello.challenge,
remoteId: firstHello.remoteId
})
)
const secondHello = yield* rpcClient["EventLog.Hello"]()
const error = yield* rpcClient["EventLog.Authenticate"](
yield* makeAuthenticateRequest({
identity: secondIdentity,
challenge: secondHello.challenge,
remoteId: secondHello.remoteId
})
).pipe(Effect.flip)
assert.instanceOf(error, EventLogMessage.EventLogProtocolError)
assert.strictEqual(error.code, "Forbidden")
assert.strictEqual(error.message, "Session auth signature verification failed")
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
it.effect("writes entries and streams changes", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
const encryption = yield* EventLogEncryption.EventLogEncryption
const identity = yield* encryption.generateIdentity
const entries = [makeEntry(1), makeEntry(2)]
const persisted = yield* persistEntries(encryption, identity, entries)
const written = yield* storage.write(identity.publicKey, storeIdA, persisted)
assert.deepStrictEqual(written.map((entry) => entry.sequence), [1, 2])
const changes = yield* storage.changes(identity.publicKey, storeIdA, 0).pipe(
Stream.toQueue({ capacity: "unbounded" })
)
const taken = yield* Queue.takeAll(changes)
assert.deepStrictEqual(taken.map((entry) => entry.sequence), [1, 2])
const nextEntry = makeEntry(3)
const nextPersisted = yield* persistEntries(encryption, identity, [nextEntry])
const updated = yield* storage.write(identity.publicKey, storeIdA, nextPersisted)
assert.deepStrictEqual(updated.map((entry) => entry.sequence), [3])
const next = yield* Queue.take(changes)
assert.strictEqual(next.sequence, 3)
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
it.effect("isolates same publicKey across storeIds", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
yield* storage.write("client-1", storeIdA, [makePersistedEntry(1)])
yield* storage.write("client-1", storeIdB, [makePersistedEntry(2)])
const storeAEntries = yield* storage.changes("client-1", storeIdA, 0).pipe(
Stream.take(1),
Stream.runCollect
)
const storeBEntries = yield* storage.changes("client-1", storeIdB, 0).pipe(
Stream.take(1),
Stream.runCollect
)
assert.deepStrictEqual(storeAEntries.map((entry) => entry.sequence), [1])
assert.deepStrictEqual(storeBEntries.map((entry) => entry.sequence), [1])
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
it.effect("isolates same storeId across publicKeys", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
yield* storage.write("client-1", storeIdA, [makePersistedEntry(1)])
yield* storage.write("client-2", storeIdA, [makePersistedEntry(2)])
const clientOneEntries = yield* storage.changes("client-1", storeIdA, 0).pipe(
Stream.take(1),
Stream.runCollect
)
const clientTwoEntries = yield* storage.changes("client-2", storeIdA, 0).pipe(
Stream.take(1),
Stream.runCollect
)
assert.deepStrictEqual(clientOneEntries.map((entry) => entry.sequence), [1])
assert.deepStrictEqual(clientTwoEntries.map((entry) => entry.sequence), [1])
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
it.effect("keeps deduplication isolated per encrypted scope", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServer.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql)
)
const sharedEntryId = EventJournal.makeEntryIdUnsafe()
yield* storage.write("client-1", storeIdA, [makePersistedEntry(1, sharedEntryId)])
yield* storage.write("client-1", storeIdA, [makePersistedEntry(2, sharedEntryId)])
yield* storage.write("client-1", storeIdB, [makePersistedEntry(3, sharedEntryId)])
yield* storage.write("client-2", storeIdA, [makePersistedEntry(4, sharedEntryId)])
assert.deepStrictEqual(
(yield* storage.changes("client-1", storeIdA, 0).pipe(
Stream.take(1),
Stream.runCollect
)).map((entry) => entry.sequence),
[1]
)
assert.deepStrictEqual(
(yield* storage.changes("client-1", storeIdB, 0).pipe(
Stream.take(1),
Stream.runCollect
)).map((entry) => entry.sequence),
[1]
)
assert.deepStrictEqual(
(yield* storage.changes("client-2", storeIdA, 0).pipe(
Stream.take(1),
Stream.runCollect
)).map((entry) => entry.sequence),
[1]
)
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
})

View File

@@ -0,0 +1,110 @@
import { SqliteClient } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Layer, Redacted } from "effect"
import * as SqlEventLogServerUnencryptedStorageTest from "effect-test/unstable/eventlog/SqlEventLogServerUnencryptedStorageTest"
import type * as EventJournal from "effect/unstable/eventlog/EventJournal"
import * as EventLog from "effect/unstable/eventlog/EventLog"
import * as EventLogEncryption from "effect/unstable/eventlog/EventLogEncryption"
import * as EventLogMessage from "effect/unstable/eventlog/EventLogMessage"
import * as EventLogServerUnencrypted from "effect/unstable/eventlog/EventLogServerUnencrypted"
import * as EventLogSessionAuth from "effect/unstable/eventlog/EventLogSessionAuth"
import { makeGetIdentityRootSecretMaterial } from "effect/unstable/eventlog/internal/identityRootSecretDerivation"
import * as SqlEventLogServerUnencrypted from "effect/unstable/eventlog/SqlEventLogServerUnencrypted"
import { Reactivity } from "effect/unstable/reactivity"
import * as RpcTest from "effect/unstable/rpc/RpcTest"
import * as SqlClient from "effect/unstable/sql/SqlClient"
SqlEventLogServerUnencryptedStorageTest.suite(
"sql-sqlite-node",
SqliteClient.layer({
filename: ":memory:"
})
)
const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto)
const makeAuthenticateRequest = Effect.fnUntraced(function*(options: {
readonly identity: EventLog.Identity["Service"]
readonly challenge: Uint8Array
readonly remoteId: EventJournal.RemoteId
}) {
const rootSecretMaterial = yield* getIdentityRootSecretMaterial(options.identity)
const signature = yield* EventLogSessionAuth.signSessionAuthPayload({
remoteId: options.remoteId,
challenge: options.challenge,
publicKey: options.identity.publicKey,
signingPublicKey: rootSecretMaterial.signingPublicKey,
signingPrivateKey: Redacted.value(rootSecretMaterial.signingPrivateKey)
})
return new EventLogMessage.Authenticate({
publicKey: options.identity.publicKey,
signingPublicKey: rootSecretMaterial.signingPublicKey,
signature,
algorithm: "Ed25519"
})
})
describe("SqlEventLogServerUnencrypted (sql-sqlite-node)", () => {
it.effect("rejects session-auth rebinding for an existing publicKey", () =>
Effect.gen(function*() {
const sql = yield* SqliteClient.make({ filename: ":memory:" })
const storage = yield* SqlEventLogServerUnencrypted.makeStorage().pipe(
Effect.provideService(SqlClient.SqlClient, sql),
Effect.orDie
)
const rpcClient = yield* RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe(
Effect.provide(
EventLogServerUnencrypted.layerRpcHandlers.pipe(
Layer.provideMerge(EventLog.layerRegistry),
Layer.provide(Layer.succeed(EventLogServerUnencrypted.Storage, storage)),
Layer.provide(Layer.succeed(EventLogServerUnencrypted.StoreMapping, {
resolve: ({ storeId }) => Effect.succeed(storeId),
hasStore: () => Effect.succeed(true)
})),
Layer.provide(Layer.succeed(EventLogServerUnencrypted.EventLogServerAuthorization, {
authorizeWrite: () => Effect.void,
authorizeRead: () => Effect.void,
authorizeIdentity: () => Effect.void
}))
)
)
)
const firstIdentity = yield* EventLog.makeIdentity
const secondIdentitySeed = yield* EventLog.makeIdentity
const secondIdentity: EventLog.Identity["Service"] = {
publicKey: firstIdentity.publicKey,
privateKey: secondIdentitySeed.privateKey
}
const firstMaterial = yield* getIdentityRootSecretMaterial(firstIdentity)
const secondMaterial = yield* getIdentityRootSecretMaterial(secondIdentity)
const sameSigningPublicKey =
firstMaterial.signingPublicKey.byteLength === secondMaterial.signingPublicKey.byteLength &&
firstMaterial.signingPublicKey.every((byte, index) => byte === secondMaterial.signingPublicKey[index])
assert.strictEqual(sameSigningPublicKey, false)
const firstHello = yield* rpcClient["EventLog.Hello"]()
yield* rpcClient["EventLog.Authenticate"](
yield* makeAuthenticateRequest({
identity: firstIdentity,
challenge: firstHello.challenge,
remoteId: firstHello.remoteId
})
)
const secondHello = yield* rpcClient["EventLog.Hello"]()
const error = yield* rpcClient["EventLog.Authenticate"](
yield* makeAuthenticateRequest({
identity: secondIdentity,
challenge: secondHello.challenge,
remoteId: secondHello.remoteId
})
).pipe(Effect.flip)
assert.instanceOf(error, EventLogMessage.EventLogProtocolError)
assert.strictEqual(error.code, "Forbidden")
assert.strictEqual(error.message, "Session auth signature verification failed")
}).pipe(Effect.provide([Reactivity.layer, EventLogEncryption.layerSubtle])))
})

View File

@@ -0,0 +1,48 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
import { assert, describe, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import * as SqlClient from "effect/unstable/sql/SqlClient"
import * as SqlError from "effect/unstable/sql/SqlError"
const makeClients = Effect.gen(function*() {
const fs = yield* FileSystem.FileSystem
const dir = yield* fs.makeTempDirectoryScoped()
const filename = dir + "/test.db"
return {
lockClient: yield* SqliteClient.make({ filename }),
migratorClient: yield* SqliteClient.make({ filename })
}
}).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer]))
describe("SqliteMigrator", () => {
it.effect("fails on lock errors", () =>
Effect.gen(function*() {
const { lockClient, migratorClient } = yield* makeClients
yield* migratorClient`PRAGMA busy_timeout = 1`
yield* SqliteMigrator.run({
loader: SqliteMigrator.fromRecord({})
}).pipe(Effect.provideService(SqlClient.SqlClient, migratorClient))
yield* Effect.acquireRelease(
lockClient`BEGIN IMMEDIATE`,
() => lockClient`ROLLBACK`.pipe(Effect.ignore)
)
const error = yield* Effect.flip(
SqliteMigrator.run({
loader: SqliteMigrator.fromRecord({
"1_test": Effect.void
})
}).pipe(Effect.provideService(SqlClient.SqlClient, migratorClient))
)
assert.strictEqual(error._tag, "SqlError")
assert(SqlError.isSqlError(error))
assert.strictEqual(error.reason._tag, "LockTimeoutError")
}))
})

View File

@@ -0,0 +1,11 @@
{
"$schema": "http://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"references": [
{ "path": "../../effect" }
],
"compilerOptions": {
"types": ["node"]
}
}

View File

@@ -0,0 +1,6 @@
import { mergeConfig, type ViteUserConfig } from "vitest/config"
import shared from "../../../vitest.shared.ts"
const config: ViteUserConfig = {}
export default mergeConfig(shared, config)