From 36e1bb186799cde4ab8296b630bae244a3b55ae3 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 25 Nov 2025 16:21:16 +0100 Subject: [PATCH 1/4] init opfs and sqlocal as another browser adapter --- .../browser/OpfsStorageAdapter.spec.ts | 34 +++ app/src/adapter/browser/OpfsStorageAdapter.ts | 265 ++++++++++++++++++ app/src/adapter/browser/index.ts | 0 app/src/adapter/browser/mock.ts | 136 +++++++++ .../connection/sqlite/SqliteConnection.ts | 33 ++- .../storage/adapters/adapter-test-suite.ts | 15 +- examples/react/package.json | 4 +- examples/react/src/App.tsx | 69 +++-- examples/react/src/OpfsStorageAdapter.ts | 265 ++++++++++++++++++ examples/react/src/routes/admin.tsx | 8 +- examples/react/vite.config.ts | 10 +- packages/sqlocal/package.json | 4 +- packages/sqlocal/src/SQLocalConnection.ts | 73 +++-- packages/sqlocal/src/index.ts | 2 +- packages/sqlocal/test/connection.test.ts | 9 +- packages/sqlocal/test/integration.test.ts | 25 +- packages/sqlocal/vitest.config.ts | 2 +- 17 files changed, 844 insertions(+), 110 deletions(-) create mode 100644 app/src/adapter/browser/OpfsStorageAdapter.spec.ts create mode 100644 app/src/adapter/browser/OpfsStorageAdapter.ts create mode 100644 app/src/adapter/browser/index.ts create mode 100644 app/src/adapter/browser/mock.ts create mode 100644 examples/react/src/OpfsStorageAdapter.ts diff --git a/app/src/adapter/browser/OpfsStorageAdapter.spec.ts b/app/src/adapter/browser/OpfsStorageAdapter.spec.ts new file mode 100644 index 0000000..fa78a4f --- /dev/null +++ b/app/src/adapter/browser/OpfsStorageAdapter.spec.ts @@ -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); +}); diff --git a/app/src/adapter/browser/OpfsStorageAdapter.ts b/app/src/adapter/browser/OpfsStorageAdapter.ts new file mode 100644 index 0000000..af2c891 --- /dev/null +++ b/app/src/adapter/browser/OpfsStorageAdapter.ts @@ -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: "" }), + }, + { + title: "OPFS", + description: "Origin Private File System storage", + additionalProperties: false, + }, +); +export type OpfsAdapterConfig = s.Static; + +/** + * 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; + + constructor(config: Partial = {}) { + super(); + this.config = parse(opfsAdapterConfig, config); + this.rootPromise = this.initializeRoot(); + } + + private async initializeRoot(): Promise { + 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 { + 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 { + 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 { + 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 { + try { + const root = await this.rootPromise; + await root.removeEntry(key); + } catch { + // file doesn't exist, which is fine + } + } + + async objectExists(key: string): Promise { + 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 { + 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 { + 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, + }; + } +} diff --git a/app/src/adapter/browser/index.ts b/app/src/adapter/browser/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/app/src/adapter/browser/mock.ts b/app/src/adapter/browser/mock.ts new file mode 100644 index 0000000..f7787e8 --- /dev/null +++ b/app/src/adapter/browser/mock.ts @@ -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 { + return new File([this.content], this.name, { + lastModified: this.lastModified, + type: this.guessMimeType(), + }); + } + + async createWritable(): Promise { + 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 = { + 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 = new Map(); + private directories: Map = new Map(); + + constructor(name: string = "root") { + this.name = name; + } + + async getFileHandle( + name: string, + options?: FileSystemGetFileOptions, + ): Promise { + 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 { + 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 { + 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 { + for (const name of this.files.keys()) { + yield name; + } + for (const name of this.directories.keys()) { + yield name; + } + } + + async *values(): AsyncIterableIterator { + 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(); + } +} diff --git a/app/src/data/connection/sqlite/SqliteConnection.ts b/app/src/data/connection/sqlite/SqliteConnection.ts index afecc38..3335133 100644 --- a/app/src/data/connection/sqlite/SqliteConnection.ts +++ b/app/src/data/connection/sqlite/SqliteConnection.ts @@ -18,26 +18,39 @@ export type SqliteConnectionConfig< CustomDialect extends Constructor = Constructor, > = { excludeTables?: string[]; - dialect: CustomDialect; - dialectArgs?: ConstructorParameters; additionalPlugins?: KyselyPlugin[]; customFn?: Partial; -}; +} & ( + | { + dialect: CustomDialect; + dialectArgs?: ConstructorParameters; + } + | { + kysely: Kysely; + } +); export abstract class SqliteConnection extends Connection { 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; + 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, diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts index a6d1917..562367f 100644 --- a/app/src/media/storage/adapters/adapter-test-suite.ts +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -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) { diff --git a/examples/react/package.json b/examples/react/package.json index 568b72d..3d87404 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -14,7 +14,7 @@ "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 +26,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" } } diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index b933893..14f3f93 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,21 +1,25 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { checksum } from "bknd/utils"; -import { App, boolean, em, entity, text } from "bknd"; +import { App, boolean, em, entity, text, registries } from "bknd"; import { SQLocalConnection } from "@bknd/sqlocal"; import { Route, Router, Switch } from "wouter"; import IndexPage from "~/routes/_index"; import { Center } from "~/components/Center"; -import { ClientProvider } from "bknd/client"; +import { type Api, ClientProvider } from "bknd/client"; +import { SQLocalKysely } from "sqlocal/kysely"; +import { OpfsStorageAdapter } from "~/OpfsStorageAdapter"; const Admin = lazy(() => import("~/routes/admin")); export default function () { const [app, setApp] = useState(undefined); + const [api, setApi] = useState(undefined); const [hash, setHash] = useState(""); async function onBuilt(app: App) { document.startViewTransition(async () => { setApp(app); + setApi(app.getApi()); setHash(await checksum(app.toJSON())); }); } @@ -26,7 +30,7 @@ export default function () { .catch(console.error); }, []); - if (!app) + if (!app || !api) return (
Loading... @@ -34,27 +38,22 @@ export default function () { ); return ( - - - ( - - - - )} - /> + + + + } /> - - - - - - -
404
-
-
-
+ + + + + + +
404
+
+
+
+ ); } @@ -79,16 +78,26 @@ async function setup(opts?: { if (initialized) return; initialized = true; - const connection = new SQLocalConnection({ - databasePath: ":localStorage:", - verbose: true, - }); + const connection = new SQLocalConnection( + new SQLocalKysely({ + databasePath: ":localStorage:", + verbose: true, + }), + ); + + registries.media.register("opfs", OpfsStorageAdapter); const app = App.create({ connection, // an initial config is only applied if the database is empty config: { data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + secret: "secret", + }, + }, }, options: { // the seed option is only executed if the database was empty @@ -99,10 +108,10 @@ async function setup(opts?: { ]); // @todo: auth is currently not working due to POST request - /*await ctx.app.module.auth.createUser({ + await ctx.app.module.auth.createUser({ email: "test@bknd.io", password: "12345678", - });*/ + }); }, }, }); @@ -112,6 +121,8 @@ async function setup(opts?: { App.Events.AppBuiltEvent, async () => { await opts.onBuilt?.(app); + // @ts-ignore + window.sql = app.connection.client.sql; }, "sync", ); diff --git a/examples/react/src/OpfsStorageAdapter.ts b/examples/react/src/OpfsStorageAdapter.ts new file mode 100644 index 0000000..693451a --- /dev/null +++ b/examples/react/src/OpfsStorageAdapter.ts @@ -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; + +/** + * 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; + + constructor(config: Partial = {}) { + super(); + this.config = parse(opfsAdapterConfig, config); + this.rootPromise = this.initializeRoot(); + } + + private async initializeRoot(): Promise { + 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 { + 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 { + 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 { + 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 { + try { + const root = await this.rootPromise; + await root.removeEntry(key); + } catch { + // file doesn't exist, which is fine + } + } + + async objectExists(key: string): Promise { + 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 { + 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 { + 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, + }; + } +} diff --git a/examples/react/src/routes/admin.tsx b/examples/react/src/routes/admin.tsx index efb5d12..a76f672 100644 --- a/examples/react/src/routes/admin.tsx +++ b/examples/react/src/routes/admin.tsx @@ -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 & { app: App }) { - return ; +export default function AdminPage(props: BkndAdminProps) { + return ; } diff --git a/examples/react/vite.config.ts b/examples/react/vite.config.ts index b374507..cece754 100644 --- a/examples/react/vite.config.ts +++ b/examples/react/vite.config.ts @@ -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(); }); }, - }, + }, */ ], }); diff --git a/packages/sqlocal/package.json b/packages/sqlocal/package.json index 1285c13..b25041d 100644 --- a/packages/sqlocal/package.json +++ b/packages/sqlocal/package.json @@ -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", diff --git a/packages/sqlocal/src/SQLocalConnection.ts b/packages/sqlocal/src/SQLocalConnection.ts index 8b6bb98..d3ad04d 100644 --- a/packages/sqlocal/src/SQLocalConnection.ts +++ b/packages/sqlocal/src/SQLocalConnection.ts @@ -1,51 +1,44 @@ import { Kysely, ParseJSONResultsPlugin } from "kysely"; -import { SqliteConnection, SqliteIntrospector } from "bknd/data"; -import { SQLocalKysely } from "sqlocal/kysely"; -import type { ClientConfig } from "sqlocal"; +import { SqliteConnection, SqliteIntrospector, type DB } from "bknd"; +import type { SQLocalKysely } from "sqlocal/kysely"; const plugins = [new ParseJSONResultsPlugin()]; -export type SQLocalConnectionConfig = Omit & { - // make it optional - databasePath?: ClientConfig["databasePath"]; -}; +export class SQLocalConnection extends SqliteConnection { + private connected: boolean = false; -export class SQLocalConnection extends SqliteConnection { - private _client: SQLocalKysely | undefined; - - constructor(private config: SQLocalConnectionConfig) { - super(null as any, {}, plugins); + 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({ + dialect: { + ...client.dialect, + createIntrospector: (db: Kysely) => { + return new SqliteIntrospector(db as any, { + plugins, + }); + }, + }, + plugins, + }) as any, + }); + this.client = client; } 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({ - dialect: { - ...this._client!.dialect, - createIntrospector: (db: Kysely) => { - 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!; + 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; } } diff --git a/packages/sqlocal/src/index.ts b/packages/sqlocal/src/index.ts index 44642fe..9ed7173 100644 --- a/packages/sqlocal/src/index.ts +++ b/packages/sqlocal/src/index.ts @@ -1 +1 @@ -export { SQLocalConnection, type SQLocalConnectionConfig } from "./SQLocalConnection"; +export { SQLocalConnection } from "./SQLocalConnection"; diff --git a/packages/sqlocal/test/connection.test.ts b/packages/sqlocal/test/connection.test.ts index f43ccc9..d95c744 100644 --- a/packages/sqlocal/test/connection.test.ts +++ b/packages/sqlocal/test/connection.test.ts @@ -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 }]); diff --git a/packages/sqlocal/test/integration.test.ts b/packages/sqlocal/test/integration.test.ts index 4e4cfe8..3d08558 100644 --- a/packages/sqlocal/test/integration.test.ts +++ b/packages/sqlocal/test/integration.test.ts @@ -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) => { diff --git a/packages/sqlocal/vitest.config.ts b/packages/sqlocal/vitest.config.ts index f55215a..0e3faec 100644 --- a/packages/sqlocal/vitest.config.ts +++ b/packages/sqlocal/vitest.config.ts @@ -1,6 +1,6 @@ /// /// -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; // https://github.com/DallasHoff/sqlocal/blob/main/vite.config.ts export default defineConfig({ From e56fc9c368ad7093ef9be4622be3bac6104e9cd1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 2 Dec 2025 14:03:41 +0100 Subject: [PATCH 2/4] finalized sqlocal, added `BkndBrowserApp`, updated react example --- app/__test__/data/postgres.test.ts | 4 +- app/build.ts | 5 + app/package.json | 6 + app/src/adapter/browser/BkndBrowserApp.tsx | 152 ++++++++++ app/src/adapter/browser/OpfsStorageAdapter.ts | 2 +- app/src/adapter/browser/index.ts | 2 + app/src/data/connection/Connection.ts | 3 - app/src/data/connection/postgres/index.ts | 5 - .../sqlite/sqlocal}/SQLocalConnection.ts | 4 + app/src/index.ts | 3 + app/src/plugins/auth/email-otp.plugin.ts | 2 +- bun.lock | 23 +- examples/react/package.json | 1 - examples/react/src/App.tsx | 155 +++------- examples/react/src/OpfsStorageAdapter.ts | 265 ------------------ examples/react/src/routes/_index.tsx | 15 +- 16 files changed, 232 insertions(+), 415 deletions(-) create mode 100644 app/src/adapter/browser/BkndBrowserApp.tsx delete mode 100644 app/src/data/connection/postgres/index.ts rename {packages/sqlocal/src => app/src/data/connection/sqlite/sqlocal}/SQLocalConnection.ts (90%) delete mode 100644 examples/react/src/OpfsStorageAdapter.ts diff --git a/app/__test__/data/postgres.test.ts b/app/__test__/data/postgres.test.ts index f9016cd..1560204 100644 --- a/app/__test__/data/postgres.test.ts +++ b/app/__test__/data/postgres.test.ts @@ -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"; diff --git a/app/build.ts b/app/build.ts index 599ce82..9c01af2 100644 --- a/app/build.ts +++ b/app/build.ts @@ -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\:.*/], diff --git a/app/package.json b/app/package.json index 8e36a10..19e625b 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/adapter/browser/BkndBrowserApp.tsx b/app/src/adapter/browser/BkndBrowserApp.tsx new file mode 100644 index 0000000..8d9cc16 --- /dev/null +++ b/app/src/adapter/browser/BkndBrowserApp.tsx @@ -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 = Omit< + BkndConfig, + "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(undefined); + const [api, setApi] = useState(undefined); + const [hash, setHash] = useState(""); + 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 ?? ( +
+ Loading... +
+ ) + ); + } + + return ( + + + + + {children} + + + + + + + + {notFound ?? ( +
404
+ )} +
+
+
+
+
+ ); +} + +export function useApp() { + return useContext(BkndBrowserAppContext); +} + +const Center = (props: React.HTMLAttributes) => ( +
+); + +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; +} diff --git a/app/src/adapter/browser/OpfsStorageAdapter.ts b/app/src/adapter/browser/OpfsStorageAdapter.ts index af2c891..693451a 100644 --- a/app/src/adapter/browser/OpfsStorageAdapter.ts +++ b/app/src/adapter/browser/OpfsStorageAdapter.ts @@ -4,7 +4,7 @@ import { parse, s, isFile, isBlob } from "bknd/utils"; export const opfsAdapterConfig = s.object( { - root: s.string({ default: "" }), + root: s.string({ default: "" }).optional(), }, { title: "OPFS", diff --git a/app/src/adapter/browser/index.ts b/app/src/adapter/browser/index.ts index e69de29..46607dc 100644 --- a/app/src/adapter/browser/index.ts +++ b/app/src/adapter/browser/index.ts @@ -0,0 +1,2 @@ +export * from "./OpfsStorageAdapter"; +export * from "./BkndBrowserApp"; diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index 0328b35..c9353aa 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -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"; diff --git a/app/src/data/connection/postgres/index.ts b/app/src/data/connection/postgres/index.ts deleted file mode 100644 index 011270e..0000000 --- a/app/src/data/connection/postgres/index.ts +++ /dev/null @@ -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"; diff --git a/packages/sqlocal/src/SQLocalConnection.ts b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts similarity index 90% rename from packages/sqlocal/src/SQLocalConnection.ts rename to app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts index d3ad04d..b32a9d7 100644 --- a/packages/sqlocal/src/SQLocalConnection.ts +++ b/app/src/data/connection/sqlite/sqlocal/SQLocalConnection.ts @@ -42,3 +42,7 @@ export class SQLocalConnection extends SqliteConnection { this.initialized = true; } } + +export function sqlocal(instance: InstanceType): SQLocalConnection { + return new SQLocalConnection(instance); +} diff --git a/app/src/index.ts b/app/src/index.ts index 81b4c9a..c158502 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -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, diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 13f9a93..db00012 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -126,7 +126,7 @@ export function emailOTP({ ...entityConfig, }, "generated", - ), + ) as any, }, ({ index }, schema) => { const otp = schema[entityName]!; diff --git a/bun.lock b/bun.lock index 1a3d426..620c2d1 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/examples/react/package.json b/examples/react/package.json index 3d87404..06ea8de 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -10,7 +10,6 @@ "preview": "vite preview" }, "dependencies": { - "@bknd/sqlocal": "file:../../packages/sqlocal", "bknd": "file:../../app", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 14f3f93..9c9a330 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,61 +1,7 @@ -import { lazy, Suspense, useEffect, useState } from "react"; -import { checksum } from "bknd/utils"; -import { App, boolean, em, entity, text, registries } 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 { type Api, ClientProvider } from "bknd/client"; -import { SQLocalKysely } from "sqlocal/kysely"; -import { OpfsStorageAdapter } from "~/OpfsStorageAdapter"; - -const Admin = lazy(() => import("~/routes/admin")); - -export default function () { - const [app, setApp] = useState(undefined); - const [api, setApi] = useState(undefined); - const [hash, setHash] = useState(""); - - async function onBuilt(app: App) { - document.startViewTransition(async () => { - setApp(app); - setApi(app.getApi()); - setHash(await checksum(app.toJSON())); - }); - } - - useEffect(() => { - setup({ onBuilt }) - .then((app) => console.log("setup", app?.version())) - .catch(console.error); - }, []); - - if (!app || !api) - return ( -
- Loading... -
- ); - - return ( - - - - } /> - - - - - - - -
404
-
-
-
-
- ); -} +import { BkndBrowserApp, type BrowserBkndConfig } from "bknd/adapter/browser"; const schema = em({ todos: entity("todos", { @@ -70,66 +16,41 @@ declare module "bknd" { interface DB extends Database {} } -let initialized = false; -async function setup(opts?: { - beforeBuild?: (app: App) => Promise; - onBuilt?: (app: App) => Promise; -}) { - if (initialized) return; - initialized = true; +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 }, + ]); - const connection = new SQLocalConnection( - new SQLocalKysely({ - databasePath: ":localStorage:", - verbose: true, - }), + // @todo: auth is currently not working due to POST request + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} satisfies BrowserBkndConfig; + +export default function App() { + return ( + + + ); - - registries.media.register("opfs", OpfsStorageAdapter); - - const app = App.create({ - connection, - // an initial config is only applied if the database is empty - config: { - data: schema.toJSON(), - auth: { - enabled: true, - jwt: { - secret: "secret", - }, - }, - }, - 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", - }); - }, - }, - }); - - if (opts?.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async () => { - await opts.onBuilt?.(app); - // @ts-ignore - window.sql = app.connection.client.sql; - }, - "sync", - ); - } - - await opts?.beforeBuild?.(app); - await app.build({ sync: true }); - - return app; } diff --git a/examples/react/src/OpfsStorageAdapter.ts b/examples/react/src/OpfsStorageAdapter.ts deleted file mode 100644 index 693451a..0000000 --- a/examples/react/src/OpfsStorageAdapter.ts +++ /dev/null @@ -1,265 +0,0 @@ -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; - -/** - * 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; - - constructor(config: Partial = {}) { - super(); - this.config = parse(opfsAdapterConfig, config); - this.rootPromise = this.initializeRoot(); - } - - private async initializeRoot(): Promise { - 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 { - 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 { - 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 { - 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 { - try { - const root = await this.rootPromise; - await root.removeEntry(key); - } catch { - // file doesn't exist, which is fine - } - } - - async objectExists(key: string): Promise { - 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 { - 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 { - 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, - }; - } -} diff --git a/examples/react/src/routes/_index.tsx b/examples/react/src/routes/_index.tsx index ae905ec..79cd2e5 100644 --- a/examples/react/src/routes/_index.tsx +++ b/examples/react/src/routes/_index.tsx @@ -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 }) {
- Go to Admin ➝ + Go to Admin ➝ {/*
{user ? (

@@ -91,12 +92,13 @@ export default function IndexPage({ app }: { app: App }) { )}

*/}
- +
); } -function Debug({ app }: { app: App }) { +function Debug() { + const { app } = useApp(); const [info, setInfo] = useState(); const connection = app.em.connection as SQLocalConnection; @@ -128,6 +130,7 @@ function Debug({ app }: { app: App }) { return (