Merge pull request #308 from bknd-io/feat/opfs-and-sqlocal

feat: opfs and sqlocal
This commit is contained in:
dswbx
2025-12-02 14:18:55 +01:00
committed by GitHub
31 changed files with 810 additions and 257 deletions

View File

@@ -1,8 +1,8 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from 'postgres'
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";

View File

@@ -267,6 +267,11 @@ async function buildAdapters() {
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],

View File

@@ -129,6 +129,7 @@
"react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7",
@@ -257,6 +258,11 @@
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js"
},
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json",

View File

@@ -0,0 +1,152 @@
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { type Api, ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
import type { BkndAdminConfig } from "bknd/ui";
const Admin = lazy(() =>
Promise.all([
import("bknd/ui"),
// @ts-ignore
import("bknd/dist/styles.css"),
]).then(([mod]) => ({
default: mod.Admin,
})),
);
function safeViewTransition(fn: () => void) {
if (document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
BkndConfig<Args>,
"connection" | "app"
> & {
adminConfig?: BkndAdminConfig;
connection?: ClientConfig | DatabasePath;
};
export type BkndBrowserAppProps = {
children: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
const BkndBrowserAppContext = createContext<{
app: App;
hash: string;
}>(undefined!);
export function BkndBrowserApp({
children,
adminConfig,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [api, setApi] = useState<Api | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setApi(app.getApi());
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ ...config, adminConfig })
.then((app) => onBuilt(app as any))
.catch(console.error);
}, []);
if (!app || !api) {
return (
loading ?? (
<Center>
<span style={{ opacity: 0.2 }}>Loading...</span>
</Center>
)
);
}
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider api={api}>
<Router key={hash}>
<Switch>
{children}
<Route path={adminRoutePath}>
<Suspense>
<Admin config={adminConfig} />
</Suspense>
</Route>
<Route path="*">
{notFound ?? (
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
)}
</Route>
</Switch>
</Router>
</ClientProvider>
</BkndBrowserAppContext.Provider>
);
}
export function useApp() {
return useContext(BkndBrowserAppContext);
}
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
style={{
width: "100%",
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
...(props.style ?? {}),
}}
/>
);
let initialized = false;
async function setup(config: BrowserBkndConfig = {}) {
if (initialized) return;
initialized = true;
registries.media.register("opfs", OpfsStorageAdapter);
const app = App.create({
...config,
// @ts-ignore
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
});
await config.beforeBuild?.(app);
await app.build({ sync: true });
await config.onBuilt?.(app);
return app;
}

View File

@@ -0,0 +1,34 @@
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
// @ts-ignore
import { assetsPath } from "../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
describe("OpfsStorageAdapter", async () => {
let mockRoot: MockFileSystemDirectoryHandle;
let testSuiteAdapter: OpfsStorageAdapter;
const _mock = spyOn(global, "navigator");
beforeAll(() => {
// mock navigator.storage.getDirectory()
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
const mockNavigator = {
storage: {
getDirectory: vi.fn().mockResolvedValue(mockRoot),
},
};
// @ts-ignore
_mock.mockReturnValue(mockNavigator);
testSuiteAdapter = new OpfsStorageAdapter();
});
afterAll(() => {
_mock.mockRestore();
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
});

View File

@@ -0,0 +1,265 @@
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile, isBlob } from "bknd/utils";
export const opfsAdapterConfig = s.object(
{
root: s.string({ default: "" }).optional(),
},
{
title: "OPFS",
description: "Origin Private File System storage",
additionalProperties: false,
},
);
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
/**
* Storage adapter for OPFS (Origin Private File System)
* Provides browser-based file storage using the File System Access API
*/
export class OpfsStorageAdapter extends StorageAdapter {
private config: OpfsAdapterConfig;
private rootPromise: Promise<FileSystemDirectoryHandle>;
constructor(config: Partial<OpfsAdapterConfig> = {}) {
super();
this.config = parse(opfsAdapterConfig, config);
this.rootPromise = this.initializeRoot();
}
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
if (!this.config.root) {
return opfsRoot;
}
// navigate to or create nested directory structure
const parts = this.config.root.split("/").filter(Boolean);
let current = opfsRoot;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
getSchema() {
return opfsAdapterConfig;
}
getName(): string {
return "opfs";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const root = await this.rootPromise;
const files: FileListObject[] = [];
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
if (!prefix || name.startsWith(prefix)) {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
key: name,
last_modified: new Date(file.lastModified),
size: file.size,
});
}
}
}
return files;
}
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
try {
let contentBuffer: ArrayBuffer;
if (isFile(body)) {
contentBuffer = await body.arrayBuffer();
await writable.write(contentBuffer);
} else if (body instanceof ReadableStream) {
const chunks: Uint8Array[] = [];
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
await writable.write(value);
}
} finally {
reader.releaseLock();
}
// compute total size and combine chunks for etag
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
contentBuffer = combined.buffer;
} else if (isBlob(body)) {
contentBuffer = await (body as Blob).arrayBuffer();
await writable.write(contentBuffer);
} else {
// body is ArrayBuffer or ArrayBufferView
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
contentBuffer = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
} else {
contentBuffer = body as ArrayBuffer;
}
await writable.write(body);
}
await writable.close();
return await this.computeEtagFromArrayBuffer(contentBuffer);
} catch (error) {
await writable.abort();
throw error;
}
}
async deleteObject(key: string): Promise<void> {
try {
const root = await this.rootPromise;
await root.removeEntry(key);
} catch {
// file doesn't exist, which is fine
}
}
async objectExists(key: string): Promise<boolean> {
try {
const root = await this.rootPromise;
await root.getFileHandle(key);
return true;
} catch {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
const fileSize = file.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const arrayBuffer = await file.arrayBuffer();
const chunk = arrayBuffer.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.byteLength.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// normal request - return entire file
const content = await file.arrayBuffer();
responseHeaders.set("Content-Length", content.byteLength.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch {
// handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(_key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
return {
type: guessMimeType(key) || "application/octet-stream",
size: file.size,
};
}
toJSON(_secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}

View File

@@ -0,0 +1,2 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";

View File

@@ -0,0 +1,136 @@
// mock OPFS API for testing
class MockFileSystemFileHandle {
kind: "file" = "file";
name: string;
private content: ArrayBuffer;
private lastModified: number;
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
this.name = name;
this.content = content;
this.lastModified = Date.now();
}
async getFile(): Promise<File> {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.guessMimeType(),
});
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: any) {
if (data instanceof ArrayBuffer) {
handle.content = data;
} else if (ArrayBuffer.isView(data)) {
handle.content = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else if (data instanceof Blob) {
handle.content = await data.arrayBuffer();
}
handle.lastModified = Date.now();
},
async close() {},
async abort() {},
async seek(_position: number) {},
async truncate(_size: number) {},
} as FileSystemWritableFileStream;
}
private guessMimeType(): string {
const ext = this.name.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
txt: "text/plain",
json: "application/json",
pdf: "application/pdf",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
}
export class MockFileSystemDirectoryHandle {
kind: "directory" = "directory";
name: string;
private files: Map<string, MockFileSystemFileHandle> = new Map();
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
constructor(name: string = "root") {
this.name = name;
}
async getFileHandle(
name: string,
options?: FileSystemGetFileOptions,
): Promise<FileSystemFileHandle> {
if (this.files.has(name)) {
return this.files.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemFileHandle(name);
this.files.set(name, handle);
return handle as any;
}
throw new Error(`File not found: ${name}`);
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions,
): Promise<FileSystemDirectoryHandle> {
if (this.directories.has(name)) {
return this.directories.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemDirectoryHandle(name);
this.directories.set(name, handle);
return handle as any;
}
throw new Error(`Directory not found: ${name}`);
}
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
this.files.delete(name);
this.directories.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, handle] of this.files) {
yield [name, handle as any];
}
for (const [name, handle] of this.directories) {
yield [name, handle as any];
}
}
async *keys(): AsyncIterableIterator<string> {
for (const name of this.files.keys()) {
yield name;
}
for (const name of this.directories.keys()) {
yield name;
}
}
async *values(): AsyncIterableIterator<FileSystemHandle> {
for (const handle of this.files.values()) {
yield handle as any;
}
for (const handle of this.directories.values()) {
yield handle as any;
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
return this.entries();
}
}

View File

@@ -1,4 +1,4 @@
import { describe, beforeAll, afterAll } from "vitest";
import { describe } from "vitest";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest";

View File

@@ -6,15 +6,12 @@ import {
type CompiledQuery,
type DatabaseIntrospector,
type Dialect,
type Expression,
type Kysely,
type KyselyPlugin,
type OnModifyForeignAction,
type QueryResult,
type RawBuilder,
type SelectQueryBuilder,
type SelectQueryNode,
type Simplify,
sql,
} from "kysely";
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";

View File

@@ -1,5 +0,0 @@
export { pg, PgPostgresConnection, type PgPostgresConnectionConfig } from "./PgPostgresConnection";
export { PostgresIntrospector } from "./PostgresIntrospector";
export { PostgresConnection, type QB, plugins } from "./PostgresConnection";
export { postgresJs, PostgresJsConnection, type PostgresJsConfig } from "./PostgresJsConnection";
export { createCustomPostgresConnection } from "./custom";

View File

@@ -17,26 +17,39 @@ export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = {
excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>;
};
} & (
| {
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
}
| {
kysely: Kysely<any>;
}
);
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite";
constructor(config: SqliteConnectionConfig) {
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
const { excludeTables, additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
const kysely = new Kysely({
dialect: customIntrospector(dialect, SqliteIntrospector, {
excludeTables,
let kysely: Kysely<any>;
if ("dialect" in config) {
kysely = new Kysely({
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...(config.dialectArgs ?? [])),
plugins,
}).create(...dialectArgs),
plugins,
});
});
} else if ("kysely" in config) {
kysely = config.kysely;
} else {
throw new Error("Either dialect or kysely must be provided");
}
super(
kysely,

View File

@@ -0,0 +1,15 @@
import { describe } from "bun:test";
import { SQLocalConnection } from "./SQLocalConnection";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { SQLocalKysely } from "sqlocal/kysely";
describe("SQLocalConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => ({
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});

View File

@@ -0,0 +1,50 @@
import { Kysely, ParseJSONResultsPlugin } from "kysely";
import { SqliteConnection } from "../SqliteConnection";
import { SqliteIntrospector } from "../SqliteIntrospector";
import type { DB } from "bknd";
import type { SQLocalKysely } from "sqlocal/kysely";
const plugins = [new ParseJSONResultsPlugin()];
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
private connected: boolean = false;
constructor(client: SQLocalKysely) {
// @ts-expect-error - config is protected
client.config.onConnect = () => {
// we need to listen for the connection, it will be awaited in init()
this.connected = true;
};
super({
kysely: new Kysely<any>({
dialect: {
...client.dialect,
createIntrospector: (db: Kysely<DB>) => {
return new SqliteIntrospector(db as any, {
plugins,
});
},
},
plugins,
}) as any,
});
this.client = client;
}
override async init() {
if (this.initialized) return;
let tries = 0;
while (!this.connected && tries < 100) {
tries++;
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (!this.connected) {
throw new Error("Failed to connect to SQLite database");
}
this.initialized = true;
}
}
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
return new SQLocalConnection(instance);
}

View File

@@ -152,6 +152,9 @@ export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
// data sqlocal
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
// data postgres
export {
pg,

View File

@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
export async function adapterTestSuite(
testRunner: TestRunner,
adapter: StorageAdapter,
_adapter: StorageAdapter | (() => StorageAdapter),
file: File | BunFile,
opts?: {
retries?: number;
@@ -25,7 +25,12 @@ export async function adapterTestSuite(
const _filename = randomString(10);
const filename = `${_filename}.png`;
const getAdapter = (
typeof _adapter === "function" ? _adapter : () => _adapter
) as () => StorageAdapter;
await test("puts an object", async () => {
const adapter = getAdapter();
objects = (await adapter.listObjects()).length;
const result = await adapter.putObject(filename, file as unknown as File);
expect(result).toBeDefined();
@@ -38,6 +43,7 @@ export async function adapterTestSuite(
});
await test("lists objects", async () => {
const adapter = getAdapter();
const length = await retry(
() => adapter.listObjects().then((res) => res.length),
(length) => length > objects,
@@ -49,10 +55,12 @@ export async function adapterTestSuite(
});
await test("file exists", async () => {
const adapter = getAdapter();
expect(await adapter.objectExists(filename)).toBe(true);
});
await test("gets an object", async () => {
const adapter = getAdapter();
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBe(true);
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
@@ -62,6 +70,7 @@ export async function adapterTestSuite(
if (options.testRange) {
await test("handles range request - partial content", async () => {
const headers = new Headers({ Range: "bytes=0-99" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -70,6 +79,7 @@ export async function adapterTestSuite(
await test("handles range request - suffix range", async () => {
const headers = new Headers({ Range: "bytes=-100" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(206); // Partial Content
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -77,6 +87,7 @@ export async function adapterTestSuite(
await test("handles invalid range request", async () => {
const headers = new Headers({ Range: "bytes=invalid" });
const adapter = getAdapter();
const res = await adapter.getObject(filename, headers);
expect(res.status).toBe(416); // Range Not Satisfiable
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
@@ -84,6 +95,7 @@ export async function adapterTestSuite(
}
await test("gets object meta", async () => {
const adapter = getAdapter();
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
@@ -91,6 +103,7 @@ export async function adapterTestSuite(
});
await test("deletes an object", async () => {
const adapter = getAdapter();
expect(await adapter.deleteObject(filename)).toBeUndefined();
if (opts?.skipExistsAfterDelete !== true) {

View File

@@ -126,7 +126,7 @@ export function emailOTP({
...entityConfig,
},
"generated",
),
) as any,
},
({ index }, schema) => {
const otp = schema[entityName]!;

View File

@@ -1,26 +1,14 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
export default defineConfig({
plugins: [tsconfigPaths()],
plugins: [
tsconfigPaths({
root: ".",
ignoreConfigErrors: true,
}) as any,
],
test: {
projects: ["**/*.vitest.config.ts", "**/*/vitest.config.ts"],
include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
},
});
// export defineConfig({
// plugins: [tsconfigPaths()],
// test: {
// globals: true,
// environment: "jsdom",
// setupFiles: ["./__test__/vitest/setup.ts"],
// include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
// coverage: {
// provider: "v8",
// reporter: ["text", "json", "html"],
// exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.config.ts"],
// },
// },
// });

View File

@@ -100,6 +100,7 @@
"react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7",
@@ -155,10 +156,10 @@
"name": "@bknd/sqlocal",
"version": "0.0.1",
"dependencies": {
"sqlocal": "^0.14.0",
"sqlocal": "^0.16.0",
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/node": "^24.10.1",
"@vitest/browser": "^3.0.8",
"@vitest/ui": "^3.0.8",
"bknd": "workspace:*",
@@ -1163,7 +1164,7 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.7", "", {}, "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g=="],
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="],
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.50.4-build1", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-Qig2Wso7gPkU1PtXwFzndh+CTRzrIFxVGqv6eCetjU7YqxlHItj+GvQYwYTppCRgAPawtRN/4AJcEgB9xDHGug=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
@@ -1287,7 +1288,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="],
@@ -3323,7 +3324,7 @@
"sql-formatter": ["sql-formatter@15.6.10", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-0bJOPQrRO/JkjQhiThVayq0hOKnI1tHI+2OTkmT7TGtc6kqS+V7kveeMzRW+RNQGxofmTmet9ILvztyuxv0cJQ=="],
"sqlocal": ["sqlocal@0.14.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build4", "coincident": "^1.2.3" }, "peerDependencies": { "drizzle-orm": "*", "kysely": "*" }, "optionalPeers": ["drizzle-orm", "kysely"] }, "sha512-qhkjWXrvh0aG0IwMeuTznCozNTJvlp2hr+JlSGOx2c1vpjgWoXYvZEf4jcynJ5n/pm05mdKNcDFzb/9KJL/IHQ=="],
"sqlocal": ["sqlocal@0.16.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.50.4-build1", "coincident": "^1.2.3" }, "peerDependencies": { "@angular/core": ">=17.0.0", "drizzle-orm": "*", "kysely": "*", "react": ">=18.0.0", "vite": ">=4.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@angular/core", "drizzle-orm", "kysely", "react", "vite", "vue"] }, "sha512-iK9IAnPGW+98Pw0dWvhPZlapEZ9NaAKMEhRsbY1XlXPpAnRXblF6hP3NGtfLcW2dErWRJ79xzX3tAVZ2jNwqCg=="],
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
@@ -3541,7 +3542,7 @@
"undici": ["undici@5.28.5", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-zICwjrDrcrUE0pyyJc1I2QzBkLM8FINsgOrt6WjA+BgajVq9Nxu2PbFFXUrAggLfDXlZGZBVZYw7WNV5KiBiBA=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
@@ -4189,8 +4190,6 @@
"base/pascalcase": ["pascalcase@0.1.1", "", {}, "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw=="],
"bknd/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"bknd/kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="],
"bknd/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
@@ -4843,10 +4842,10 @@
"@types/graceful-fs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/pg/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@types/resolve/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
"@types/yauzl/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
@@ -4909,8 +4908,6 @@
"bknd-cli/@libsql/client/libsql": ["libsql@0.4.7", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.4.7", "@libsql/darwin-x64": "0.4.7", "@libsql/linux-arm64-gnu": "0.4.7", "@libsql/linux-arm64-musl": "0.4.7", "@libsql/linux-x64-gnu": "0.4.7", "@libsql/linux-x64-musl": "0.4.7", "@libsql/win32-x64-msvc": "0.4.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw=="],
"bknd/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"bknd/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"bknd/miniflare/workerd": ["workerd@1.20251011.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20251011.0", "@cloudflare/workerd-darwin-arm64": "1.20251011.0", "@cloudflare/workerd-linux-64": "1.20251011.0", "@cloudflare/workerd-linux-arm64": "1.20251011.0", "@cloudflare/workerd-windows-64": "1.20251011.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q=="],
@@ -5479,8 +5476,6 @@
"wrangler/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"@bknd/plasmic/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],

View File

@@ -188,23 +188,20 @@ export default serve<Env>({
### SQLocal
To use bknd with `sqlocal` for a offline expierence, you need to install the `@bknd/sqlocal` package. You can do so by running the following command:
To use bknd with `sqlocal` for a offline expierence, you need to install the `sqlocal` package. You can do so by running the following command:
```bash
npm install @bknd/sqlocal
npm install sqlocal
```
This package uses `sqlocal` under the hood. Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options:
Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options:
```ts
import { createApp } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
import { createApp, sqlocal } from "bknd";
import { SQLocalKysely } from "sqlocal/kysely";
const app = createApp({
connection: new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
}),
connection: sqlocal(new SQLocalKysely(":localStorage:")),
});
```

View File

@@ -10,11 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"@bknd/sqlocal": "file:../../packages/sqlocal",
"bknd": "file:../../app",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sqlocal": "^0.14.0",
"sqlocal": "^0.16.0",
"wouter": "^3.6.0"
},
"devDependencies": {
@@ -26,7 +25,7 @@
"tailwindcss": "^4.0.14",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0",
"vite": "^7.2.4",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -1,62 +1,7 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { checksum } from "bknd/utils";
import { App, boolean, em, entity, text } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
import { Route, Router, Switch } from "wouter";
import { boolean, em, entity, text } from "bknd";
import { Route } from "wouter";
import IndexPage from "~/routes/_index";
import { Center } from "~/components/Center";
import { ClientProvider } from "bknd/client";
const Admin = lazy(() => import("~/routes/admin"));
export default function () {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
async function onBuilt(app: App) {
document.startViewTransition(async () => {
setApp(app);
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ onBuilt })
.then((app) => console.log("setup", app?.version()))
.catch(console.error);
}, []);
if (!app)
return (
<Center>
<span className="opacity-20">Loading...</span>
</Center>
);
return (
<Router key={hash}>
<Switch>
<Route
path="/"
component={() => (
<ClientProvider api={app.getApi()}>
<IndexPage app={app} />
</ClientProvider>
)}
/>
<Route path="/admin/*?">
<Suspense>
<Admin config={{ basepath: "/admin", logo_return_path: "/../" }} app={app} />
</Suspense>
</Route>
<Route path="*">
<Center className="font-mono text-4xl">404</Center>
</Route>
</Switch>
</Router>
);
}
import { BkndBrowserApp, type BrowserBkndConfig } from "bknd/adapter/browser";
const schema = em({
todos: entity("todos", {
@@ -71,54 +16,41 @@ declare module "bknd" {
interface DB extends Database {}
}
let initialized = false;
async function setup(opts?: {
beforeBuild?: (app: App) => Promise<void>;
onBuilt?: (app: App) => Promise<void>;
}) {
if (initialized) return;
initialized = true;
const connection = new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
});
const app = App.create({
connection,
// an initial config is only applied if the database is empty
config: {
data: schema.toJSON(),
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
// @todo: auth is currently not working due to POST request
/*await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});*/
const config = {
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
});
},
adminConfig: {
basepath: "/admin",
logo_return_path: "/../",
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
if (opts?.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await opts.onBuilt?.(app);
},
"sync",
);
}
// @todo: auth is currently not working due to POST request
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});
},
},
} satisfies BrowserBkndConfig;
await opts?.beforeBuild?.(app);
await app.build({ sync: true });
return app;
export default function App() {
return (
<BkndBrowserApp {...config}>
<Route path="/" component={IndexPage} />
</BkndBrowserApp>
);
}

View File

@@ -1,10 +1,11 @@
import { Center } from "~/components/Center";
import type { App } from "bknd";
import { useEntityQuery } from "bknd/client";
import type { SQLocalConnection } from "@bknd/sqlocal/src";
import type { SQLocalConnection } from "bknd";
import { useApp } from "bknd/adapter/browser";
import { useEffect, useState } from "react";
import { Link } from "wouter";
export default function IndexPage({ app }: { app: App }) {
export default function IndexPage() {
//const user = app.getApi().getUser();
const limit = 5;
const { data: todos, ...$q } = useEntityQuery("todos", undefined, {
@@ -80,7 +81,7 @@ export default function IndexPage({ app }: { app: App }) {
</div>
<div className="flex flex-col items-center gap-1">
<a href="/admin">Go to Admin </a>
<Link to="/admin">Go to Admin </Link>
{/*<div className="opacity-50 text-sm">
{user ? (
<p>
@@ -91,12 +92,13 @@ export default function IndexPage({ app }: { app: App }) {
)}
</div>*/}
</div>
<Debug app={app} />
<Debug />
</Center>
);
}
function Debug({ app }: { app: App }) {
function Debug() {
const { app } = useApp();
const [info, setInfo] = useState<any>();
const connection = app.em.connection as SQLocalConnection;
@@ -128,6 +130,7 @@ function Debug({ app }: { app: App }) {
return (
<div className="flex flex-col gap-2 items-center">
<button
type="button"
className="bg-foreground/20 leading-none py-2 px-3.5 rounded-lg text-sm hover:bg-foreground/30 transition-colors cursor-pointer"
onClick={download}
>

View File

@@ -1,10 +1,6 @@
import { Admin, type BkndAdminProps } from "bknd/ui";
import type { App } from "bknd";
import "bknd/dist/styles.css";
export default function AdminPage({
app,
...props
}: Omit<BkndAdminProps, "withProvider"> & { app: App }) {
return <Admin {...props} withProvider={{ api: app.getApi() }} />;
export default function AdminPage(props: BkndAdminProps) {
return <Admin {...props} />;
}

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import sqlocal from "sqlocal/vite";
// https://vite.dev/config/
// https://sqlocal.dallashoffman.com/guide/setup#vite-configuration
@@ -9,11 +10,16 @@ export default defineConfig({
optimizeDeps: {
exclude: ["sqlocal"],
},
resolve: {
dedupe: ["react", "react-dom"],
},
plugins: [
sqlocal(),
react(),
tailwindcss(),
tsconfigPaths(),
{
/* {
name: "configure-response-headers",
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
@@ -22,6 +28,6 @@ export default defineConfig({
next();
});
},
},
}, */
],
});

View File

@@ -16,12 +16,12 @@
"prepublishOnly": "bun run typecheck && bun run test && bun run build"
},
"dependencies": {
"sqlocal": "^0.14.0"
"sqlocal": "^0.16.0"
},
"devDependencies": {
"@vitest/browser": "^3.0.8",
"@vitest/ui": "^3.0.8",
"@types/node": "^22.13.10",
"@types/node": "^24.10.1",
"bknd": "workspace:*",
"kysely": "^0.27.6",
"tsup": "^8.4.0",

View File

@@ -1,51 +0,0 @@
import { Kysely, ParseJSONResultsPlugin } from "kysely";
import { SqliteConnection, SqliteIntrospector } from "bknd/data";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig } from "sqlocal";
const plugins = [new ParseJSONResultsPlugin()];
export type SQLocalConnectionConfig = Omit<ClientConfig, "databasePath"> & {
// make it optional
databasePath?: ClientConfig["databasePath"];
};
export class SQLocalConnection extends SqliteConnection {
private _client: SQLocalKysely | undefined;
constructor(private config: SQLocalConnectionConfig) {
super(null as any, {}, plugins);
}
override async init() {
if (this.initialized) return;
await new Promise((resolve) => {
this._client = new SQLocalKysely({
...this.config,
databasePath: this.config.databasePath ?? "session",
onConnect: (r) => {
this.kysely = new Kysely<any>({
dialect: {
...this._client!.dialect,
createIntrospector: (db: Kysely<any>) => {
return new SqliteIntrospector(db, {
plugins,
});
},
},
plugins,
});
this.config.onConnect?.(r);
resolve(1);
},
});
});
super.init();
}
get client(): SQLocalKysely {
if (!this._client) throw new Error("Client not initialized");
return this._client!;
}
}

View File

@@ -1 +1 @@
export { SQLocalConnection, type SQLocalConnectionConfig } from "./SQLocalConnection";
export { SQLocalConnection } from "./SQLocalConnection";

View File

@@ -1,14 +1,15 @@
import { describe, expect, it } from "vitest";
import { SQLocalConnection, type SQLocalConnectionConfig } from "../src";
import { SQLocalConnection } from "../src";
import type { ClientConfig } from "sqlocal";
import { SQLocalKysely } from "sqlocal/kysely";
describe(SQLocalConnection, () => {
function create(config: SQLocalConnectionConfig = {}) {
return new SQLocalConnection(config);
function create(config: ClientConfig = { databasePath: ":memory:" }) {
return new SQLocalConnection(new SQLocalKysely(config));
}
it("constructs", async () => {
const connection = create();
expect(() => connection.client).toThrow();
await connection.init();
expect(connection.client).toBeDefined();
expect(await connection.client.sql`SELECT 1`).toEqual([{ "1": 1 }]);

View File

@@ -1,11 +1,12 @@
import { describe, expect, it } from "vitest";
import { SQLocalConnection, type SQLocalConnectionConfig } from "../src";
import { createApp } from "bknd";
import * as proto from "bknd/data";
import { describe, expect, it } from "bun:test";
import { SQLocalConnection } from "../src";
import { createApp, em, entity, text } from "bknd";
import type { ClientConfig } from "sqlocal";
import { SQLocalKysely } from "sqlocal/kysely";
describe("integration", () => {
function create(config: SQLocalConnectionConfig = { databasePath: ":memory:" }) {
return new SQLocalConnection(config);
function create(config: ClientConfig = { databasePath: ":memory:" }) {
return new SQLocalConnection(new SQLocalKysely(config));
}
it("should create app and ping", async () => {
@@ -19,14 +20,14 @@ describe("integration", () => {
});
it("should create a basic schema", async () => {
const schema = proto.em(
const schema = em(
{
posts: proto.entity("posts", {
title: proto.text().required(),
content: proto.text(),
posts: entity("posts", {
title: text().required(),
content: text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
comments: entity("comments", {
content: text(),
}),
},
(fns, s) => {

View File

@@ -1,6 +1,6 @@
/// <reference types="vitest" />
/// <reference types="@vitest/browser/providers/webdriverio" />
import { defineConfig } from "vite";
import { defineConfig } from "vitest/config";
// https://github.com/DallasHoff/sqlocal/blob/main/vite.config.ts
export default defineConfig({