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";