Merge commit '3c60637c1a27da8ba66888de518d58d5707801f2' as 'repos/effect-smol'
This commit is contained in:
@@ -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)
|
||||
}))
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
183
repos/effect-smol/packages/sql/sqlite-node/test/Resolver.test.ts
Normal file
183
repos/effect-smol/packages/sql/sqlite-node/test/Resolver.test.ts
Normal 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()
|
||||
}
|
||||
)
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
})
|
||||
@@ -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])))
|
||||
})
|
||||
@@ -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])))
|
||||
})
|
||||
@@ -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")
|
||||
}))
|
||||
})
|
||||
Reference in New Issue
Block a user