diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea8c8d..727506c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 0.2.0 + +Adds the **entities data layer** — a Base44-parity data API over the gateway so +apps read/write data without touching Supabase, SQL, or credentials directly: + +```ts +const todos = await bool.entities.todos.list("-created_at"); +const one = await bool.entities.todos.create({ title: "hi" }); +await bool.entities.todos.update(one.id, { done: true }); +await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); +``` + +`bool.entities.` mirrors Base44's entity surface one-to-one: +- **Reads:** `list`, `filter`, `get` — with `sort` (`-col`), `limit`, `skip`, + and `fields` (column selection). +- **Writes:** `create`, `bulkCreate`, `update`, `bulkUpdate`, `delete`. +- **Bulk-by-query:** `updateMany(query, { $set })`, `deleteMany(query)`. +- **Import:** `importEntities(csvFile)` (parsed client-side → `bulkCreate`). +- **Realtime:** `subscribe(cb)` (gateway doorbell). +- **Filter DSL:** MongoDB-style — `$eq $ne $gt $gte $lt $lte $in $nin $exists + $regex $all $not` per field, `$and`/`$or`/`$nor` at the root, array shorthand, + and `null` → IS NULL. + +Methods return row data directly and throw on error. Additive and +backward-compatible — `bool.db` / `supabase` still work. + +Known gaps vs. Base44 (documented, follow-ups): `updateMany` with +`$inc/$mul/$push/$pull` is read-modify-write (not atomic under concurrent +writers — a Postgres RPC would make it atomic); `$size` (filter by array +length) isn't expressible over PostgREST and is omitted. + +**`EntitiesModule` is now an augmentable `interface`** (was a `type` alias), so +generated apps can type each entity via `declare module "bool-sdk"`: + +```ts +declare module "bool-sdk" { + interface EntitiesModule { board_games: EntityHandler } +} +``` + +That makes `bool.entities.board_games` typed (field names, enum values, types) +while the string index signature keeps un-declared tables usable as +`EntityHandler`. Bool's `define_entity` tool writes one such `.d.ts` per +model. No runtime change. + ## 0.1.1 Publishing now goes through npm OIDC trusted publishing (no long-lived token). diff --git a/README.md b/README.md index 3a9a1de..020097c 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,26 @@ tested, and upgradable independently of any one app. ## What it does +- **Entities data API.** `client.entities.
` is the recommended way to + read/write data — a one-to-one mirror of Base44's entity surface: `list`, + `filter`, `get`, `create`, `bulkCreate`, `update`, `bulkUpdate`, `updateMany`, + `delete`, `deleteMany`, `importEntities`, `subscribe`. It hides Supabase/SQL + entirely; methods return rows directly and throw on error: + ```ts + const todos = await bool.entities.todos.list("-created_at"); + const one = await bool.entities.todos.create({ title: "hi" }); + await bool.entities.todos.update(one.id, { done: true }); + await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); + await bool.entities.todos.updateMany({ done: false }, { $set: { done: true } }); + ``` + Filters use MongoDB-style operators (`$eq $ne $gt $gte $lt $lte $in $nin + $exists $regex $all $not`, plus `$and`/`$or`/`$nor`); sort is a `-col` string. - **Data + Storage through the Bool gateway.** `client.db` is a standard - [supabase-js](https://supabase.com/docs/reference/javascript) client whose - REST and Storage traffic is routed to the Bool gateway (`/_bool/v1/db`). The - gateway injects the real credential server-side and pins the app's private - Postgres schema — the anon key in the bundle has no data grants and can't - read anything directly. + [supabase-js](https://supabase.com/docs/reference/javascript) client (what + `entities` is built on) whose REST and Storage traffic is routed to the Bool + gateway (`/_bool/v1/db`). The gateway injects the real credential server-side + and pins the app's private Postgres schema — the anon key in the bundle has + no data grants and can't read anything directly. - **Realtime "doorbell".** Postgres changes broadcast a row-data-free `{table, op}` ping on the app's public channel; `subscribeToChanges` wraps the subscription. Refetch on each ping — the ping never carries row data. diff --git a/package.json b/package.json index b95438b..272fe72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.1.1", + "version": "0.2.0-next.4", "description": "Client SDK for apps built on Bool — gateway data access, end-user auth, and the React auth layer.", "type": "module", "main": "./dist/index.js", diff --git a/src/client.ts b/src/client.ts index 9a1ac5d..f2aedb7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,6 +17,7 @@ // Keep this in sync with the gateway data route (/_bool/v1/db) and users route // (/_bool/v1/users) in the Bool platform repo. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { createEntitiesModule, type EntitiesModule } from "./entities.js"; /** Matches the server's append-only gateway path version. */ const GATEWAY_API = "v1"; @@ -99,6 +100,9 @@ export type BoolClient = { * Use it exactly like a normal supabase-js client: `.from(...)`, `.storage`, * `.channel(...)`. Do NOT use `db.auth` — end-user auth is `client.auth`. */ db: BoolDb; + /** The data API: `entities.
.list/filter/get/create/update/delete`. + * The recommended way to read/write app data — hides Supabase entirely. */ + entities: EntitiesModule; /** End-user auth for this app (gateway users plane). */ auth: BoolAuth; /** This app's private Postgres schema name. */ @@ -423,26 +427,31 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { }, }; + // Realtime "doorbell": the app schema's grants are revoked, so Supabase + // `postgres_changes` never fires. Instead the server broadcasts a + // row-data-free ping on the PUBLIC channel "bool:" + schema whenever any + // row changes. Subscribe with the anon key (no token needed) and REFETCH + // on each ping. + const subscribeToChanges = ( + listener: (payload: BoolChangePayload) => void, + ): (() => void) => { + const channel = db + .channel("bool:" + schema) + .on("broadcast", { event: "*" }, (msg) => + listener((msg as { payload?: BoolChangePayload }).payload ?? {}), + ) + .subscribe(); + return () => { + void db.removeChannel(channel); + }; + }; + const client: BoolClient = { db, + entities: createEntitiesModule(db, subscribeToChanges), auth, schema, - // Realtime "doorbell": the app schema's grants are revoked, so Supabase - // `postgres_changes` never fires. Instead the server broadcasts a - // row-data-free ping on the PUBLIC channel "bool:" + schema whenever any - // row changes. Subscribe with the anon key (no token needed) and REFETCH - // on each ping. - subscribeToChanges(listener) { - const channel = db - .channel("bool:" + schema) - .on("broadcast", { event: "*" }, (msg) => - listener((msg as { payload?: BoolChangePayload }).payload ?? {}), - ) - .subscribe(); - return () => { - void db.removeChannel(channel); - }; - }, + subscribeToChanges, }; setDefaultBoolClient(client); diff --git a/src/entities.test.ts b/src/entities.test.ts new file mode 100644 index 0000000..bf93402 --- /dev/null +++ b/src/entities.test.ts @@ -0,0 +1,399 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createBoolClient, type BoolClientConfig } from "./client"; + +// These drive the entities layer through the REAL supabase-js query builder and +// assert the PostgREST request it produces (routed through the gateway). So we +// verify the actual translation — filter/sort/pagination → query string — not a +// hand-mocked builder. + +const CONFIG: BoolClientConfig = { + supabaseUrl: "https://upstream.supabase.test", + supabaseAnonKey: "anon-key", + schema: "bool_abc", + appOrigin: "https://bool.test", + slug: "my-app", +}; + +type Call = { url: string; init?: RequestInit }; +let calls: Call[] = []; +let respond: (url: string, init?: RequestInit) => Response; + +beforeEach(() => { + calls = []; + respond = () => new Response("[]", { headers: { "content-type": "application/json" } }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, init }); + return respond(url, init); + }) as unknown as typeof fetch; + (globalThis as any).sessionStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + delete (globalThis as any).location; +}); + +/** The gateway path prefix for this app's DB plane. */ +const DB = "https://bool.test/served/my-app/_bool/v1/db/rest/v1"; + +/** Decode the query string of the single recorded call for readable asserts. */ +function reqQuery(call: Call): URLSearchParams { + return new URL(call.url).searchParams; +} + +describe("entities: read paths → PostgREST", () => { + test("list() selects the table, defaults to newest-first, limit 50", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.list(); + expect(calls).toHaveLength(1); + const u = new URL(calls[0]!.url); + expect(u.pathname).toBe("/served/my-app/_bool/v1/db/rest/v1/todos"); + expect(reqQuery(calls[0]!).get("select")).toBe("*"); + expect(reqQuery(calls[0]!).get("order")).toBe("created_at.desc"); + // range(0, 49) → limit 50 from offset 0 + expect(reqQuery(calls[0]!).get("limit")).toBe("50"); + expect(reqQuery(calls[0]!).get("offset")).toBe("0"); + expect(calls[0]!.init?.credentials).toBe("include"); + }); + + test("list() honors ascending sort, custom limit + offset", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.list("title", 10, 20); + expect(reqQuery(calls[0]!).get("order")).toBe("title.asc"); + expect(reqQuery(calls[0]!).get("limit")).toBe("10"); + expect(reqQuery(calls[0]!).get("offset")).toBe("20"); + }); + + test("filter() maps scalar, $-operator, array, and null to PostgREST ops", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ + status: "active", + count: { $gte: 100 }, + priority: ["high", "urgent"], + archived_at: null, + }); + const q = reqQuery(calls[0]!); + expect(q.get("status")).toBe("eq.active"); + expect(q.get("count")).toBe("gte.100"); + expect(q.get("priority")).toBe("in.(high,urgent)"); + expect(q.get("archived_at")).toBe("is.null"); + }); + + test("filter() maps the richer Base44 operators", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ + a: { $ne: 1 }, + b: { $nin: [2, 3] }, + c: { $exists: true }, + d: { $exists: false }, + e: { $regex: "^x" }, + tags: { $all: ["red", "blue"] }, + }); + const q = reqQuery(calls[0]!); + expect(q.get("a")).toBe("neq.1"); + expect(q.get("b")).toBe("not.in.(2,3)"); + expect(q.get("c")).toBe("not.is.null"); + expect(q.get("d")).toBe("is.null"); + expect(q.get("e")).toBe("match.^x"); + expect(q.get("tags")).toBe("cs.{red,blue}"); + }); + + test("filter() supports root $or", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ $or: [{ status: "active" }, { priority: { $gte: 3 } }] }); + expect(reqQuery(calls[0]!).get("or")).toBe("(status.eq.active,priority.gte.3)"); + }); + + test("filter() $not negates a single inner operator", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ age: { $not: { $gt: 18 } } }); + expect(reqQuery(calls[0]!).get("age")).toBe("not.gt.18"); + }); + + test("filter() $nor negates every sub-condition (De Morgan, valid PostgREST)", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ $nor: [{ status: "done" }, { pinned: true }] }); + const q = reqQuery(calls[0]!); + // NOR = NOT status=done AND NOT pinned=true — two negated params, no garbage. + expect(q.get("status")).toBe("not.eq.done"); + expect(q.get("pinned")).toBe("not.eq.true"); + expect(new URL(calls[0]!.url).search).not.toContain("undefined"); + }); + + test("filter() $and merges nested sub-queries", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.filter({ $and: [{ status: "active" }, { priority: { $gte: 3 } }] }); + const q = reqQuery(calls[0]!); + expect(q.get("status")).toBe("eq.active"); + expect(q.get("priority")).toBe("gte.3"); + }); + + test("filter() ignores unsupported $size rather than emitting bad SQL", async () => { + const bool = createBoolClient(CONFIG); + // $size is intentionally unsupported (not in the type) — cast to prove it's + // silently dropped rather than emitting invalid SQL. + await bool.entities.todos.filter({ tags: { $size: 3 } } as any); + expect(new URL(calls[0]!.url).search).not.toContain("size"); + expect(new URL(calls[0]!.url).search).not.toContain("undefined"); + }); + + test("sort accepts an explicit + prefix for ascending", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.list("+title"); + expect(reqQuery(calls[0]!).get("order")).toBe("title.asc"); + }); + + test("list caps the limit at 1000", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.list("-created_at", 99999); + // range(0, 999) → limit 1000 + expect(reqQuery(calls[0]!).get("limit")).toBe("1000"); + }); + + test("fields limits the selected columns", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.list("-created_at", 50, 0, ["id", "title"]); + expect(reqQuery(calls[0]!).get("select")).toBe("id,title"); + }); + + test("get(id) fetches one row by id", async () => { + respond = () => Response.json({ id: "t1", title: "hi" }); + const bool = createBoolClient(CONFIG); + const row = await bool.entities.todos.get("t1"); + expect(reqQuery(calls[0]!).get("id")).toBe("eq.t1"); + expect(row).toEqual({ id: "t1", title: "hi" }); + }); + + test("list() returns the rows and unwraps the array", async () => { + respond = () => Response.json([{ id: "a" }, { id: "b" }]); + const bool = createBoolClient(CONFIG); + const rows = await bool.entities.todos.list(); + expect(rows).toEqual([{ id: "a" }, { id: "b" }]); + }); +}); + +describe("entities: write paths → PostgREST", () => { + test("create() POSTs the row and returns the created record", async () => { + respond = (_url, init) => + init?.method === "POST" + ? Response.json({ id: "new", title: "hi" }) + : new Response("[]"); + const bool = createBoolClient(CONFIG); + const created = await bool.entities.todos.create({ title: "hi" }); + expect(calls[0]!.init?.method).toBe("POST"); + expect(new URL(calls[0]!.url).pathname).toEndWith("/rest/v1/todos"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ title: "hi" }); + expect(created).toEqual({ id: "new", title: "hi" }); + }); + + test("update(id, patch) PATCHes the matched row", async () => { + respond = () => Response.json({ id: "t1", done: true }); + const bool = createBoolClient(CONFIG); + const updated = await bool.entities.todos.update("t1", { done: true }); + expect(calls[0]!.init?.method).toBe("PATCH"); + expect(reqQuery(calls[0]!).get("id")).toBe("eq.t1"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ done: true }); + expect(updated).toEqual({ id: "t1", done: true }); + }); + + test("delete(id) DELETEs the matched row and reports success", async () => { + const bool = createBoolClient(CONFIG); + const res = await bool.entities.todos.delete("t1"); + expect(calls[0]!.init?.method).toBe("DELETE"); + expect(reqQuery(calls[0]!).get("id")).toBe("eq.t1"); + expect(res).toEqual({ success: true }); + }); + + test("bulkCreate() inserts an array in one POST", async () => { + respond = () => Response.json([{ id: "a" }, { id: "b" }]); + const bool = createBoolClient(CONFIG); + const rows = await bool.entities.todos.bulkCreate([{ title: "a" }, { title: "b" }]); + expect(calls[0]!.init?.method).toBe("POST"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual([{ title: "a" }, { title: "b" }]); + expect(rows).toHaveLength(2); + }); + + test("bulkUpdate() upserts rows by id", async () => { + respond = () => Response.json([{ id: "a", done: true }]); + const bool = createBoolClient(CONFIG); + await bool.entities.todos.bulkUpdate([{ id: "a", done: true }]); + // Upsert is a POST with an on-conflict resolution / merge preference. + expect(calls[0]!.init?.method).toBe("POST"); + const prefer = new Headers(calls[0]!.init?.headers).get("prefer") ?? ""; + expect(prefer).toContain("resolution=merge-duplicates"); + }); + + test("updateMany() with $set is one atomic PATCH", async () => { + respond = () => Response.json([{ id: "a" }, { id: "b" }]); + const bool = createBoolClient(CONFIG); + const res = await bool.entities.todos.updateMany({ status: "pending" }, { $set: { status: "done" } }); + expect(calls).toHaveLength(1); + expect(calls[0]!.init?.method).toBe("PATCH"); + expect(reqQuery(calls[0]!).get("status")).toBe("eq.pending"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ status: "done" }); + expect(res).toEqual({ success: true, updated: 2, has_more: false }); + }); + + test("updateMany() with a plain object is treated as $set", async () => { + respond = () => Response.json([{ id: "a" }]); + const bool = createBoolClient(CONFIG); + await bool.entities.todos.updateMany({ id: "a" }, { done: true }); + expect(calls[0]!.init?.method).toBe("PATCH"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ done: true }); + }); + + test("updateMany() with $inc selects ids then calls the atomic RPC", async () => { + respond = (url) => + url.includes("/rpc/bool_apply_numeric") + ? new Response(null, { status: 204 }) + : Response.json([{ id: "a" }, { id: "b" }]); // the id SELECT + const bool = createBoolClient(CONFIG); + const res = await bool.entities.posts.updateMany({ topic: "x" }, { $inc: { views: 1 } }); + // 1) select matching ids + expect(new URL(calls[0]!.url).pathname).toEndWith("/rest/v1/posts"); + expect(reqQuery(calls[0]!).get("select")).toBe("id"); + // 2) POST the RPC with the ids + operators — the increment happens in SQL + const rpc = calls.find((c) => c.url.includes("/rpc/bool_apply_numeric"))!; + expect(rpc.init?.method).toBe("POST"); + expect(JSON.parse(String(rpc.init?.body))).toEqual({ + p_table: "posts", + p_ids: ["a", "b"], + p_inc: { views: 1 }, + p_mul: null, + }); + expect(res.updated).toBe(2); + }); + + test("updateMany() $inc falls back to read-modify-write when the RPC is missing (PGRST202)", async () => { + respond = (url) => { + if (url.includes("/rpc/bool_apply_numeric")) { + return Response.json({ code: "PGRST202", message: "function not found" }, { status: 404 }); + } + // the id SELECT, then the read of full rows, then the upsert + return Response.json([{ id: "a", views: 5 }]); + }; + const bool = createBoolClient(CONFIG); + const res = await bool.entities.posts.updateMany({ id: "a" }, { $inc: { views: 1 } }); + // After the RPC 404s, it reads the rows and upserts the incremented value. + const upsert = calls.find( + (c) => c.init?.method === "POST" && !c.url.includes("/rpc/"), + )!; + expect(JSON.parse(String(upsert.init?.body))).toEqual([{ id: "a", views: 6 }]); + expect(res.updated).toBe(1); + }); + + test("updateMany() with $mul goes through the atomic RPC", async () => { + respond = (url) => + url.includes("/rpc/bool_apply_numeric") + ? new Response(null, { status: 204 }) + : Response.json([{ id: "a" }]); + const bool = createBoolClient(CONFIG); + await bool.entities.posts.updateMany({ id: "a" }, { $mul: { score: 2 } }); + const rpc = calls.find((c) => c.url.includes("/rpc/bool_apply_numeric"))!; + expect(JSON.parse(String(rpc.init?.body))).toEqual({ + p_table: "posts", + p_ids: ["a"], + p_inc: null, + p_mul: { score: 2 }, + }); + }); + + test("updateMany() with $unset PATCHes the columns to null (one atomic call)", async () => { + respond = () => Response.json([{ id: "a" }]); + const bool = createBoolClient(CONFIG); + await bool.entities.todos.updateMany({ id: "a" }, { $unset: { note: true } }); + expect(calls).toHaveLength(1); + expect(calls[0]!.init?.method).toBe("PATCH"); + expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ note: null }); + }); + + test("updateMany() with $set + $inc PATCHes the set fields then calls the RPC for the arithmetic", async () => { + respond = (url) => + url.includes("/rpc/bool_apply_numeric") + ? new Response(null, { status: 204 }) + : Response.json([{ id: "a" }]); + const bool = createBoolClient(CONFIG); + await bool.entities.posts.updateMany({ id: "a" }, { $set: { status: "hot" }, $inc: { views: 1 } }); + const patch = calls.find((c) => c.init?.method === "PATCH")!; + expect(JSON.parse(String(patch.init?.body))).toEqual({ status: "hot" }); + const rpc = calls.find((c) => c.url.includes("/rpc/bool_apply_numeric"))!; + expect(JSON.parse(String(rpc.init?.body)).p_inc).toEqual({ views: 1 }); + }); + + test("updateMany() with $push read-modify-writes the array (documented non-atomic)", async () => { + respond = (url, init) => { + if (init?.method === "POST") return Response.json([{ id: "a", tags: ["x", "y"] }]); + return Response.json([{ id: "a", tags: ["x"] }]); // the SELECT + }; + const bool = createBoolClient(CONFIG); + await bool.entities.posts.updateMany({ id: "a" }, { $push: { tags: "y" } }); + const upsert = calls.find((c) => c.init?.method === "POST")!; + expect(JSON.parse(String(upsert.init?.body))).toEqual([{ id: "a", tags: ["x", "y"] }]); + }); + + test("updateMany() with $pull removes the value from the array", async () => { + respond = (url, init) => { + if (init?.method === "POST") return Response.json([{ id: "a", tags: ["x"] }]); + return Response.json([{ id: "a", tags: ["x", "y"] }]); + }; + const bool = createBoolClient(CONFIG); + await bool.entities.posts.updateMany({ id: "a" }, { $pull: { tags: "y" } }); + const upsert = calls.find((c) => c.init?.method === "POST")!; + expect(JSON.parse(String(upsert.init?.body))).toEqual([{ id: "a", tags: ["x"] }]); + }); + + test("deleteMany() deletes matching rows and counts them", async () => { + respond = () => Response.json([{ id: "a" }, { id: "b" }, { id: "c" }]); + const bool = createBoolClient(CONFIG); + const res = await bool.entities.todos.deleteMany({ done: true }); + expect(calls[0]!.init?.method).toBe("DELETE"); + expect(reqQuery(calls[0]!).get("done")).toBe("eq.true"); + expect(res).toEqual({ success: true, deleted: 3 }); + }); + + test("importEntities() parses CSV client-side and bulk-creates", async () => { + const created: unknown[] = []; + respond = (_url, init) => { + if (init?.method === "POST") { + const body = JSON.parse(String(init.body)); + created.push(...body); + return Response.json(body); + } + return new Response("[]"); + }; + const bool = createBoolClient(CONFIG); + const csv = 'title,done\n"Buy, milk",false\n"Say ""hi""",true\n'; + const file = new File([csv], "todos.csv", { type: "text/csv" }); + const res = await bool.entities.todos.importEntities(file); + expect(res.status).toBe("success"); + expect(created).toEqual([ + { title: "Buy, milk", done: "false" }, + { title: 'Say "hi"', done: "true" }, + ]); + }); +}); + +describe("entities: errors + ergonomics", () => { + test("a PostgREST error is thrown, not returned", async () => { + respond = () => + Response.json( + { message: "permission denied", code: "42501" }, + { status: 403, headers: { "content-type": "application/json" } }, + ); + const bool = createBoolClient(CONFIG); + await expect(bool.entities.todos.list()).rejects.toBeDefined(); + }); + + test("the same handler instance is reused per table name", () => { + const bool = createBoolClient(CONFIG); + expect(bool.entities.todos).toBe(bool.entities.todos); + expect(bool.entities.todos).not.toBe(bool.entities.notes); + }); + + test("awaiting the module itself doesn't create a `then` entity", () => { + const bool = createBoolClient(CONFIG); + expect((bool.entities as any).then).toBeUndefined(); + }); +}); diff --git a/src/entities.ts b/src/entities.ts new file mode 100644 index 0000000..4b2e238 --- /dev/null +++ b/src/entities.ts @@ -0,0 +1,500 @@ +// The Bool entities layer: a Base44-style data API over the gateway-routed +// Supabase client. App code (and the AI that writes it) works with +// `bool.entities.
` instead of raw `supabase.from(...)`, so a generated +// app never mentions Supabase, SQL, PostgREST, or credentials — the whole +// backend is invisible plumbing inside this package. +// +// const todos = await bool.entities.todos.list("-created_at"); +// const one = await bool.entities.todos.create({ title: "hi" }); +// await bool.entities.todos.update(one.id, { done: true }); +// await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); +// +// The method surface and filter DSL mirror Base44's entities module one-to-one +// (MongoDB-style `$`-operators, `-col` sort, bulk + *Many ops), so there's no +// need to drop down to raw SQL. Methods return row data directly and THROW on +// error (unlike supabase-js's `{ data, error }`), so app code reads clean. +import type { BoolChangePayload, BoolDb } from "./client.js"; + +/** Sort by a column, `-col` for descending (`+col`/`col` ascending). + * Entity tables always have `created_at`, so it's the default. */ +export type SortSpec = string; + +/** MongoDB-style comparison operators for a single field. Mirrors Base44. */ +export type FilterOperators = Partial<{ + $eq: unknown; + $ne: unknown; + $gt: unknown; + $gte: unknown; + $lt: unknown; + $lte: unknown; + $in: unknown[]; + $nin: unknown[]; + $exists: boolean; + /** POSIX regex (maps to PostgREST `match`/`~`). */ + $regex: string; + /** Array column contains all of these values. */ + $all: unknown[]; + /** Negate a single inner operator, e.g. `{ $not: { $eq: 5 } }`. */ + $not: Partial< + Record<"$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$in" | "$regex", unknown> + >; +}>; + +/** + * A filter query. Each key is a column; the value is: + * - a scalar → exact match (`{ status: "active" }`) + * - `null` → IS NULL (`{ archived_at: null }`) + * - an array → matches any of (`{ id: ["a", "b"] }`, i.e. `$in` shorthand) + * - an operator object → `{ count: { $gte: 100 } }` + * Root-level `$and`/`$or`/`$nor` combine sub-queries. + */ +export type FilterValue = + | string + | number + | boolean + | null + | Array + | FilterOperators; +export type FilterQuery = { + [column: string]: FilterValue | FilterQuery[] | undefined; + $and?: FilterQuery[]; + $or?: FilterQuery[]; + $nor?: FilterQuery[]; +}; + +/** MongoDB-style update operators. `$set` is applied as one atomic PATCH; the + * others (`$inc`/`$mul`/`$push`/`$pull`/`$unset`) are applied read-modify-write + * (see updateMany docs — not atomic under concurrent writers). */ +export type UpdateOps = Partial<{ + $set: Record; + $inc: Record; + $mul: Record; + $push: Record; + $pull: Record; + $unset: Record; +}>; + +export type DeleteResult = { success: boolean }; +export type DeleteManyResult = { success: boolean; deleted: number }; +export type UpdateManyResult = { success: boolean; updated: number; has_more: boolean }; +export type ImportResult = { + status: "success" | "error"; + details: string | null; + output: T[] | null; +}; + +/** CRUD + realtime for one entity table. `T` defaults to `any` (untyped); + * apps can pass a row type for autocomplete. Mirrors Base44's EntityHandler. */ +export interface EntityHandler { + /** All rows, newest first by default. `limit` defaults to 50, max 1000. + * `fields` restricts the columns returned. */ + list(sort?: SortSpec, limit?: number, skip?: number, fields?: (keyof T & string)[]): Promise; + /** Rows matching `query`. See {@link FilterQuery}. */ + filter( + query: FilterQuery, + sort?: SortSpec, + limit?: number, + skip?: number, + fields?: (keyof T & string)[], + ): Promise; + /** One row by id. Throws if it doesn't exist. */ + get(id: string): Promise; + /** Insert a row; returns the created row (with server-filled id/created_at). */ + create(values: Partial): Promise; + /** Insert many rows in one request; returns the created rows. */ + bulkCreate(values: Partial[]): Promise; + /** Patch a row by id; returns the updated row. */ + update(id: string, values: Partial): Promise; + /** Update many specific rows, each by its own `id`; returns the updated rows. */ + bulkUpdate(values: (Partial & { id: string })[]): Promise; + /** Apply the same update to every row matching `query`. Pass Mongo-style + * update operators (`{ $set: {...} }`) or a plain object (treated as `$set`). */ + updateMany(query: FilterQuery, ops: UpdateOps | Partial): Promise; + /** Delete a row by id. */ + delete(id: string): Promise; + /** Delete every row matching `query`. */ + deleteMany(query: FilterQuery): Promise; + /** Import rows from a CSV File (parsed client-side, then bulk-created). */ + importEntities(file: File): Promise>; + /** Fire `cb` whenever any row in THIS table changes (refetch on each ping — + * the payload carries no row data). Returns an unsubscribe function. */ + subscribe(cb: (change: BoolChangePayload) => void): () => void; +} + +/** + * Dynamic map: `entities.` yields a handler for that table. + * + * Declared as an `interface` (not a `type` alias) so a generated app can + * AUGMENT it with typed per-entity members via `declare module "bool-sdk"`. + * Bool's `define_entity` tool writes one `bool/entities/.d.ts` per model + * that does exactly that, e.g.: + * + * declare module "bool-sdk" { + * interface EntitiesModule { board_games: EntityHandler } + * } + * + * so `bool.entities.board_games.create({...})` is typed and typos are caught at + * build time (`tsc -b`). The string index signature keeps every table — including + * ones not yet declared — usable as `EntityHandler` by default, so the + * un-augmented SDK still works. A named member must be assignable to the index + * signature, which `EntityHandler` (→ `EntityHandler`) always is. + */ +export interface EntitiesModule { + [table: string]: EntityHandler; +} + +const DEFAULT_SORT = "-created_at"; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 1000; + +// A supabase-js filter/query builder is chainable and thenable; we only need +// the handful of methods below, so keep it loosely typed rather than importing +// supabase-js's internal builder generics. +type QueryBuilder = any; + +const MONGO_TO_PG: Record = { + $eq: "eq", + $ne: "neq", + $gt: "gt", + $gte: "gte", + $lt: "lt", + $lte: "lte", + $in: "in", + $regex: "match", +}; + +/** Map a Mongo comparison operator to the supabase-js builder call. */ +function applyOperator(q: QueryBuilder, column: string, op: string, value: unknown): QueryBuilder { + switch (op) { + case "$eq": + return value === null ? q.is(column, null) : q.eq(column, value); + case "$ne": + return value === null ? q.not(column, "is", null) : q.neq(column, value); + case "$gt": + return q.gt(column, value); + case "$gte": + return q.gte(column, value); + case "$lt": + return q.lt(column, value); + case "$lte": + return q.lte(column, value); + case "$in": + return q.in(column, value as unknown[]); + case "$nin": + return q.not(column, "in", `(${(value as unknown[]).join(",")})`); + case "$exists": + return value ? q.not(column, "is", null) : q.is(column, null); + case "$regex": + // PostgREST `match` operator (~). Supabase supports it. + return q.filter(column, "match", value); + case "$all": + return q.contains(column, value as unknown[]); + case "$not": { + // Single inner operator, e.g. { $not: { $eq: 5 } }. + const [innerOp, innerVal] = Object.entries(value as Record)[0] ?? []; + const pg = MONGO_TO_PG[innerOp as string]; + if (pg) return q.not(column, pg, innerVal); + return q; + } + default: + return q; // unsupported operator ($size) — silently ignored (documented) + } +} + +/** Serialize a flat sub-query to a PostgREST `or()` condition string, e.g. + * `{ status: "active", count: { $gte: 3 } }` → `status.eq.active,count.gte.3`. + * Only scalar equality + comparison operators are supported inside $or/$nor. */ +function toPgConditions(query: FilterQuery): string { + const parts: string[] = []; + for (const [column, value] of Object.entries(query)) { + if (column === "$and" || column === "$or" || column === "$nor") continue; + if (value === null) parts.push(`${column}.is.null`); + else if (Array.isArray(value)) parts.push(`${column}.in.(${(value as unknown[]).join(",")})`); + else if (typeof value === "object") { + for (const [op, v] of Object.entries(value as Record)) { + const pg = MONGO_TO_PG[op]; + if (pg) parts.push(`${column}.${pg}.${v}`); + } + } else { + parts.push(`${column}.eq.${value}`); + } + } + return parts.join(","); +} + +/** Apply the negation of a flat sub-query (used for `$nor`): every condition + * becomes its NOT, and the results AND together. Only scalar/comparison + * conditions are supported inside `$nor`. */ +function applyNegated(query: QueryBuilder, sub: FilterQuery): QueryBuilder { + let q = query; + for (const [col, cond] of Object.entries(sub)) { + if (col === "$and" || col === "$or" || col === "$nor") continue; + if (cond === null) q = q.not(col, "is", null); + else if (Array.isArray(cond)) q = q.not(col, "in", `(${(cond as unknown[]).join(",")})`); + else if (typeof cond === "object") { + for (const [op, v] of Object.entries(cond as Record)) { + const pg = MONGO_TO_PG[op]; + if (pg) q = q.not(col, pg, v); + } + } else { + q = q.not(col, "eq", cond); + } + } + return q; +} + +function applyFilter(query: QueryBuilder, filter: FilterQuery): QueryBuilder { + let q = query; + for (const [column, value] of Object.entries(filter)) { + if (value === undefined) continue; + if (column === "$and") { + for (const sub of value as FilterQuery[]) q = applyFilter(q, sub); + } else if (column === "$or") { + q = q.or((value as FilterQuery[]).map(toPgConditions).join(",")); + } else if (column === "$nor") { + // NOR = AND of negated conditions (De Morgan). `.not("or", …)` isn't a + // valid supabase-js call, so negate each sub-condition individually. + for (const sub of value as FilterQuery[]) q = applyNegated(q, sub); + } else if (value === null) { + q = q.is(column, null); + } else if (Array.isArray(value)) { + q = q.in(column, value as unknown[]); + } else if (typeof value === "object") { + for (const [op, operand] of Object.entries(value as Record)) { + if (operand === undefined) continue; + q = applyOperator(q, column, op, operand); + } + } else { + q = q.eq(column, value); + } + } + return q; +} + +function applySort(query: QueryBuilder, sort: SortSpec): QueryBuilder { + const ascending = !sort.startsWith("-"); + const column = sort.replace(/^[-+]/, ""); + return query.order(column, { ascending }); +} + +/** Minimal RFC-4180-ish CSV parser: handles quoted fields, escaped quotes, and + * embedded commas/newlines. Good enough for `importEntities`; swap for a full + * parser if apps need exotic dialects. */ +function parseCsv(text: string): Record[] { + const rows: string[][] = []; + let row: string[] = []; + let field = ""; + let inQuotes = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (inQuotes) { + if (c === '"') { + if (text[i + 1] === '"') { + field += '"'; + i++; + } else inQuotes = false; + } else field += c; + } else if (c === '"') inQuotes = true; + else if (c === ",") { + row.push(field); + field = ""; + } else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + row.push(field); + field = ""; + if (row.length > 1 || row[0] !== "") rows.push(row); + row = []; + } else field += c; + } + if (field !== "" || row.length) { + row.push(field); + rows.push(row); + } + if (rows.length === 0) return []; + const header = rows[0]!; + return rows.slice(1).map((r) => { + const obj: Record = {}; + header.forEach((h, i) => { + obj[h] = r[i] ?? ""; + }); + return obj; + }); +} + +function applyUpdateOps(row: Record, ops: UpdateOps): void { + if (ops.$set) Object.assign(row, ops.$set); + if (ops.$inc) for (const [k, v] of Object.entries(ops.$inc)) row[k] = (row[k] ?? 0) + v; + if (ops.$mul) for (const [k, v] of Object.entries(ops.$mul)) row[k] = (row[k] ?? 0) * v; + if (ops.$push) for (const [k, v] of Object.entries(ops.$push)) row[k] = [...(row[k] ?? []), v]; + if (ops.$pull) + for (const [k, v] of Object.entries(ops.$pull)) + row[k] = (row[k] ?? []).filter((x: unknown) => x !== v); + if (ops.$unset) for (const k of Object.keys(ops.$unset)) row[k] = null; +} + +const UPDATE_OP_KEYS = ["$set", "$inc", "$mul", "$push", "$pull", "$unset"]; +function isUpdateOps(ops: Record): ops is UpdateOps { + return Object.keys(ops).some((k) => UPDATE_OP_KEYS.includes(k)); +} + +function createEntityHandler( + db: BoolDb, + subscribeToChanges: (listener: (p: BoolChangePayload) => void) => () => void, + table: string, +): EntityHandler { + function select(fields?: string[]): string { + return fields && fields.length ? fields.join(",") : "*"; + } + function paginate(query: QueryBuilder, limit: number, skip: number): QueryBuilder { + const capped = Math.min(limit, MAX_LIMIT); + return query.range(skip, skip + capped - 1); + } + async function unwrap(query: QueryBuilder): Promise { + const { data, error } = await query; + if (error) throw error; + return data as R; + } + + const handler: EntityHandler = { + async list(sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, skip = 0, fields) { + return ( + (await unwrap( + paginate(applySort(db.from(table).select(select(fields)), sort), limit, skip), + )) ?? [] + ); + }, + async filter(filter, sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, skip = 0, fields) { + return ( + (await unwrap( + paginate( + applySort(applyFilter(db.from(table).select(select(fields)), filter), sort), + limit, + skip, + ), + )) ?? [] + ); + }, + async get(id) { + return unwrap(db.from(table).select("*").eq("id", id).single()); + }, + async create(values) { + return unwrap(db.from(table).insert(values).select().single()); + }, + async bulkCreate(values) { + return (await unwrap(db.from(table).insert(values).select())) ?? []; + }, + async update(id, values) { + return unwrap(db.from(table).update(values).eq("id", id).select().single()); + }, + async bulkUpdate(values) { + // Upsert on the primary key: each row must include `id`. + return (await unwrap(db.from(table).upsert(values).select())) ?? []; + }, + async updateMany(query, ops) { + const n: UpdateOps = isUpdateOps(ops as Record) + ? (ops as UpdateOps) + : { $set: ops as Record }; + // $set + $unset are absolute assignments → one atomic PATCH. + const setPart: Record = { ...(n.$set ?? {}) }; + if (n.$unset) for (const k of Object.keys(n.$unset)) setPart[k] = null; + const hasNumeric = !!(n.$inc || n.$mul); + const hasArray = !!(n.$push || n.$pull); + + // Array operators can't be expressed atomically over PostgREST → + // read-modify-write everything. Documented as non-atomic. + if (hasArray) { + const rows = (await unwrap(applyFilter(db.from(table).select("*"), query))) ?? []; + for (const row of rows) applyUpdateOps(row, n); + if (rows.length) await unwrap(db.from(table).upsert(rows).select("id")); + return { success: true, updated: rows.length, has_more: false }; + } + + // No arithmetic → a single atomic PATCH covers $set/$unset. + if (!hasNumeric) { + const rows = + (await unwrap(applyFilter(db.from(table).update(setPart), query).select("id"))) ?? []; + return { success: true, updated: rows.length, has_more: false }; + } + + // Arithmetic ($inc/$mul) → ATOMIC via the per-schema `bool_apply_numeric` + // function (the increment happens in SQL: `col = col + n`). Select the + // target ids first (reusing the filter), apply any $set/$unset as a PATCH, + // then call the function. Falls back to read-modify-write when the + // function isn't provisioned on this schema (PGRST202), so it stays + // correct on older schemas — just non-atomic there. + const targets = (await unwrap(applyFilter(db.from(table).select("id"), query))) ?? []; + const ids = targets.map((r) => String(r.id)); + if (Object.keys(setPart).length && ids.length) { + await unwrap(db.from(table).update(setPart).in("id", ids).select("id")); + } + if (ids.length) { + const { error } = await db.rpc("bool_apply_numeric", { + p_table: table, + p_ids: ids, + p_inc: n.$inc ?? null, + p_mul: n.$mul ?? null, + }); + if (error) { + if ((error as { code?: string }).code === "PGRST202") { + const rows = (await unwrap(db.from(table).select("*").in("id", ids))) ?? []; + for (const row of rows) applyUpdateOps(row, { $inc: n.$inc, $mul: n.$mul }); + if (rows.length) await unwrap(db.from(table).upsert(rows).select("id")); + } else { + throw error; + } + } + } + return { success: true, updated: ids.length, has_more: false }; + }, + async delete(id) { + await unwrap(db.from(table).delete().eq("id", id)); + return { success: true }; + }, + async deleteMany(query) { + const rows = + (await unwrap(applyFilter(db.from(table).delete(), query).select("id"))) ?? []; + return { success: true, deleted: rows.length }; + }, + async importEntities(file) { + try { + const rows = parseCsv(await file.text()); + if (!rows.length) return { status: "success", details: "No rows to import", output: [] }; + const output = await handler.bulkCreate(rows as any); + return { status: "success", details: `Imported ${output.length} rows`, output }; + } catch (err) { + return { + status: "error", + details: (err as Error)?.message ?? "Import failed", + output: null, + }; + } + }, + subscribe(cb) { + // The doorbell pings for the whole schema; forward only this table's. + return subscribeToChanges((payload) => { + if (!payload.table || payload.table === table) cb(payload); + }); + }, + }; + return handler; +} + +export function createEntitiesModule( + db: BoolDb, + subscribeToChanges: (listener: (p: BoolChangePayload) => void) => () => void, +): EntitiesModule { + const handlers = new Map(); + return new Proxy({} as EntitiesModule, { + get(_target, prop) { + // Guard non-string / thenable access so `await entities` or feature + // probes don't get mistaken for a table named "then". + if (typeof prop !== "string" || prop === "then") return undefined; + let handler = handlers.get(prop); + if (!handler) { + handler = createEntityHandler(db, subscribeToChanges, prop); + handlers.set(prop, handler); + } + return handler; + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 0ddb192..b14b3cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,16 @@ export { type AuthResult, type Credentials, } from "./client.js"; +export { + type EntitiesModule, + type EntityHandler, + type FilterQuery, + type FilterValue, + type FilterOperators, + type UpdateOps, + type SortSpec, + type DeleteResult, + type DeleteManyResult, + type UpdateManyResult, + type ImportResult, +} from "./entities.js";