add sqlocal connection including example

This commit is contained in:
dswbx
2025-03-15 14:40:41 +01:00
parent 5697b7891a
commit 622a7b2b9a
22 changed files with 1047 additions and 56 deletions

View File

@@ -75,6 +75,7 @@ export type DbFunctions = {
const CONN_SYMBOL = Symbol.for("bknd:connection");
export abstract class Connection<DB = any> {
protected initialized = false;
kysely: Kysely<DB>;
protected readonly supported = {
batching: false,
@@ -89,6 +90,11 @@ export abstract class Connection<DB = any> {
this[CONN_SYMBOL] = true;
}
// @todo: consider moving constructor logic here, required by sqlocal
async init(): Promise<void> {
this.initialized = true;
}
/**
* This is a helper function to manage Connection classes
* coming from different places

View File

@@ -401,6 +401,7 @@ export class ModuleManager {
async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {

View File

@@ -1,38 +1,48 @@
import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core";
import { createContext, useContext } from "react";
import { createContext, type ReactNode, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined,
} as any);
export type ClientProviderProps = {
children?: any;
baseUrl?: string;
user?: TApiUser | null | undefined;
};
children?: ReactNode;
} & (
| { baseUrl?: string; user?: TApiUser | null | undefined }
| {
api: Api;
}
);
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
let api: Api;
try {
if (!baseUrl) {
if (_ctx_baseUrl) {
actualBaseUrl = _ctx_baseUrl;
console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
//console.log("setting from window", actualBaseUrl);
if (props && "api" in props) {
api = props.api;
} else {
const winCtx = useBkndWindowContext();
const _ctx_baseUrl = useBaseUrl();
const { baseUrl, user } = props;
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
try {
if (!baseUrl) {
if (_ctx_baseUrl) {
actualBaseUrl = _ctx_baseUrl;
console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
//console.log("setting from window", actualBaseUrl);
}
}
} catch (e) {
console.error("Error in ClientProvider", e);
}
} catch (e) {
console.error("Error in ClientProvider", e);
}
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
}
return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>

539
bun.lock

File diff suppressed because it is too large Load Diff

24
examples/react/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
examples/react/README.md Normal file
View File

@@ -0,0 +1 @@
# local bknd POC (WIP)

13
examples/react/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"name": "react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"bknd": "file:../../app",
"@bknd/sqlocal": "file:../../packages/sqlocal",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sqlocal": "^0.14.0"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { App } from "bknd";
import { Admin } from "bknd/ui";
import { checksum } from "bknd/utils";
import { em, entity, text } from "bknd/data";
import { SQLocalConnection } from "@bknd/sqlocal";
import "bknd/dist/styles.css";
export default function () {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
async function onBuilt(app: App) {
setApp(app);
setHash(await checksum(app.toJSON()));
}
useEffect(() => {
setup({
onBuilt,
})
.then((app) => console.log("setup", app?.version()))
.catch(console.error);
}, []);
if (!app) return null;
return (
// @ts-ignore
<Admin key={hash} withProvider={{ api: app.getApi() }} />
);
}
let initialized = false;
export async function setup(opts?: {
beforeBuild?: (app: App) => Promise<void>;
onBuilt?: (app: App) => Promise<void>;
}) {
if (initialized) return;
initialized = true;
const connection = new SQLocalConnection({
verbose: true,
});
const app = App.create({
connection,
initialConfig: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
});
if (opts?.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await opts.onBuilt?.(app);
},
"sync",
);
}
await opts?.beforeBuild?.(app);
await app.build({ sync: true });
return app;
}

View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

1
examples/react/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
// https://sqlocal.dallashoffman.com/guide/setup#vite-configuration
export default defineConfig({
optimizeDeps: {
exclude: ["sqlocal"],
},
plugins: [
react(),
{
name: "configure-response-headers",
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
next();
});
},
},
],
});

View File

@@ -0,0 +1,29 @@
# SQLocal adapter for `bknd` (experimental)
This packages adds an adapter to use a SQLocal database with `bknd`. It is based on [`sqlocal`](https://github.com/DallasHoff/sqlocal) and the driver included for `kysely`.
## Installation
Install the adapter with:
```bash
npm install @bknd/sqlocal
```
## Usage
Create a connection:
```ts
import { SQLocalConnection } from "@bknd/sqlocal";
const connection = new SQLocalConnection({
databasePath: "db.sqlite"
});
```
Use the connection depending on which framework or runtime you are using. E.g., when using `createApp`, you can use the connection as follows:
```ts
import { createApp } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
const connection = new SQLocalConnection();
const app = createApp({ connection });
```

View File

@@ -0,0 +1,39 @@
{
"name": "@bknd/sqlocal",
"version": "0.0.1",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup",
"test": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "bun run test && bun run typecheck && bun run build"
},
"dependencies": {
"sqlocal": "^0.14.0"
},
"devDependencies": {
"@vitest/browser": "^3.0.8",
"@vitest/ui": "^3.0.8",
"@types/node": "^22.13.10",
"bknd": "workspace:*",
"kysely": "^0.27.6",
"tsup": "^8.4.0",
"typescript": "^5.6.3",
"vitest": "^3.0.8",
"webdriverio": "^9.12.0"
},
"tsup": {
"entry": ["src/index.ts"],
"format": ["esm"],
"target": "es2022",
"clean": true,
"minify": true,
"dts": true,
"metafile": true,
"external": ["bknd", "kysely"]
},
"files": ["dist", "README.md", "!*.map", "!metafile*.json"]
}

View File

@@ -0,0 +1,51 @@
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

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

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { SQLocal } from "sqlocal";
describe("base", () => {
const { sql } = new SQLocal(":memory:");
it("works", async () => {
expect(await sql`SELECT 1`).toEqual([{ "1": 1 }]);
});
});

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { SQLocalConnection, type SQLocalConnectionConfig } from "../src";
describe(SQLocalConnection, () => {
function create(config: SQLocalConnectionConfig = {}) {
return new SQLocalConnection(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

@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { SQLocalConnection, type SQLocalConnectionConfig } from "../src";
import { createApp } from "bknd";
import * as proto from "bknd/data";
describe("integration", () => {
function create(config: SQLocalConnectionConfig = { databasePath: ":memory:" }) {
return new SQLocalConnection(config);
}
it("should create app and ping", async () => {
const app = createApp({
connection: create(),
});
await app.build();
expect(app.version()).toBeDefined();
expect(await app.em.ping()).toBe(true);
});
it("should create a basic schema", async () => {
const schema = proto.em(
{
posts: proto.entity("posts", {
title: proto.text().required(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
(fns, s) => {
fns.relation(s.comments).manyToOne(s.posts);
fns.index(s.posts).on(["title"], true);
},
);
const app = createApp({
connection: create(),
initialConfig: {
data: schema.toJSON(),
},
});
await app.build();
expect(app.em.entities.length).toBe(2);
expect(app.em.entities.map((e) => e.name)).toEqual(["posts", "comments"]);
const api = app.getApi();
expect(
(
await api.data.createMany("posts", [
{
title: "Hello",
content: "World",
},
{
title: "Hello 2",
content: "World 2",
},
])
).data,
).toEqual([
{
id: 1,
title: "Hello",
content: "World",
},
{
id: 2,
title: "Hello 2",
content: "World 2",
},
] as any);
// try to create an existing
expect(
(
await api.data.createOne("posts", {
title: "Hello",
})
).ok,
).toBe(false);
// add a comment to a post
await api.data.createOne("comments", {
content: "Hello",
posts_id: 1,
});
// and then query using a `with` property
const result = await api.data.readMany("posts", { with: ["comments"] });
expect(result.length).toBe(2);
expect(result[0].comments.length).toBe(1);
expect(result[0].comments[0].content).toBe("Hello");
expect(result[1].comments.length).toBe(0);
});
});

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"composite": false,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"target": "ES2022",
"noImplicitAny": false,
"allowJs": true,
"verbatimModuleSyntax": true,
"declaration": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,36 @@
/// <reference types="vitest" />
/// <reference types="@vitest/browser/providers/webdriverio" />
import { defineConfig } from "vite";
// https://github.com/DallasHoff/sqlocal/blob/main/vite.config.ts
export default defineConfig({
test: {
testTimeout: 1000,
hookTimeout: 1000,
teardownTimeout: 1000,
includeTaskLocation: true,
browser: {
enabled: true,
headless: true,
screenshotFailures: false,
provider: "webdriverio",
instances: [{ browser: "chrome" }],
},
},
optimizeDeps: {
exclude: ["@sqlite.org/sqlite-wasm"],
},
plugins: [
{
enforce: "pre",
name: "configure-response-headers",
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
next();
});
},
},
],
});