From 94c0ba9191b007180873d9fce5e65d9c5dd238db Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 12:55:36 -0500 Subject: [PATCH 1/6] Add entities data layer (bool.entities.X); v0.2.0 A Base44-style CRUD API over the gateway client so generated apps read/write data without touching Supabase, SQL, or credentials: bool.entities..{list,filter,get,create,update,delete,subscribe} - Dynamic Proxy keyed by table name; returns rows directly, throws on error. - filter() maps scalar/null/array/operator queries to PostgREST; sort is a '-col' string; list/filter paginate via limit+offset. - Additive and backward-compatible: bool.db / supabase / auth unchanged. - Tests assert the real supabase-js -> PostgREST translation through the gateway (URL + query params), not a mocked builder. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++ README.md | 20 +++-- package.json | 2 +- src/client.ts | 41 ++++++---- src/entities.test.ts | 153 ++++++++++++++++++++++++++++++++++ src/entities.ts | 191 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 8 ++ 7 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 src/entities.test.ts create mode 100644 src/entities.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ea8c8d..8fbbaa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.2.0 + +Adds the **entities data layer** — a Base44-style CRUD 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.
` exposes `list`, `filter`, `get`, `create`, `update`, +`delete`, and `subscribe`. Methods return row data directly and throw on error. +Additive and backward-compatible — `bool.db` / `supabase` still work. + ## 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..0e1be0c 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,22 @@ tested, and upgradable independently of any one app. ## What it does +- **Entities data API.** `client.entities.
` is the recommended way to + read/write data — `list`, `filter`, `get`, `create`, `update`, `delete`, + `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 } }); + ``` - **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..7182790 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.1.1", + "version": "0.2.0", "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..f820133 --- /dev/null +++ b/src/entities.test.ts @@ -0,0 +1,153 @@ +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("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", async () => { + const bool = createBoolClient(CONFIG); + await bool.entities.todos.delete("t1"); + expect(calls[0]!.init?.method).toBe("DELETE"); + expect(reqQuery(calls[0]!).get("id")).toBe("eq.t1"); + }); +}); + +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..5c70d94 --- /dev/null +++ b/src/entities.ts @@ -0,0 +1,191 @@ +// 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 }); +// +// Methods return the row data directly and THROW on error (unlike supabase-js's +// `{ data, error }`), so app code reads clean. Every method is a thin, +// well-understood translation to what `db` (the gateway client) already does. +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; + +/** PostgREST comparison operators usable in a filter query. */ +export type FilterOperators = Partial<{ + eq: unknown; + neq: unknown; + gt: unknown; + gte: unknown; + lt: unknown; + lte: unknown; + like: string; + ilike: string; + in: 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"] }`) + * - an operator object → `{ count: { gte: 100 } }` + */ +export type FilterValue = + | string + | number + | boolean + | null + | Array + | FilterOperators; +export type FilterQuery = Record; + +/** CRUD + realtime for one entity table. `T` defaults to `any` (untyped); + * apps can pass a row type for autocomplete. */ +export interface EntityHandler { + /** All rows, newest first by default. `limit` defaults to 50. */ + list(sort?: SortSpec, limit?: number, offset?: number): Promise; + /** Rows matching `query`. See {@link FilterQuery}. */ + filter( + query: FilterQuery, + sort?: SortSpec, + limit?: number, + offset?: number, + ): 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; + /** Patch a row by id; returns the updated row. */ + update(id: string, values: Partial): Promise; + /** Delete a row by id. */ + delete(id: string): 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. */ +export type EntitiesModule = { [table: string]: EntityHandler }; + +const DEFAULT_SORT = "-created_at"; +const DEFAULT_LIMIT = 50; + +// 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; + +function applySort(query: QueryBuilder, sort: SortSpec): QueryBuilder { + const ascending = !sort.startsWith("-"); + const column = sort.replace(/^[-+]/, ""); + return query.order(column, { ascending }); +} + +function applyFilter(query: QueryBuilder, filter: FilterQuery): QueryBuilder { + let q = query; + for (const [column, value] of Object.entries(filter)) { + if (value === null) { + q = q.is(column, null); + } else if (Array.isArray(value)) { + q = q.in(column, value); + } else if (typeof value === "object") { + for (const [op, operand] of Object.entries(value)) { + if (operand === undefined) continue; + // op is one of the PostgREST methods on the builder (eq/gt/in/like/…). + q = q[op](column, operand); + } + } else { + q = q.eq(column, value); + } + } + return q; +} + +function createEntityHandler( + db: BoolDb, + subscribeToChanges: (listener: (p: BoolChangePayload) => void) => () => void, + table: string, +): EntityHandler { + function paginate(query: QueryBuilder, limit: number, offset: number): QueryBuilder { + return query.range(offset, offset + limit - 1); + } + return { + async list(sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, offset = 0) { + const query = paginate( + applySort(db.from(table).select("*"), sort), + limit, + offset, + ); + const { data, error } = await query; + if (error) throw error; + return data ?? []; + }, + async filter(filter, sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, offset = 0) { + const query = paginate( + applySort(applyFilter(db.from(table).select("*"), filter), sort), + limit, + offset, + ); + const { data, error } = await query; + if (error) throw error; + return data ?? []; + }, + async get(id) { + const { data, error } = await db.from(table).select("*").eq("id", id).single(); + if (error) throw error; + return data; + }, + async create(values) { + const { data, error } = await db.from(table).insert(values).select().single(); + if (error) throw error; + return data; + }, + async update(id, values) { + const { data, error } = await db + .from(table) + .update(values) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data; + }, + async delete(id) { + const { error } = await db.from(table).delete().eq("id", id); + if (error) throw error; + }, + 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); + }); + }, + }; +} + +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..5b11f7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,3 +12,11 @@ export { type AuthResult, type Credentials, } from "./client.js"; +export { + type EntitiesModule, + type EntityHandler, + type FilterQuery, + type FilterValue, + type FilterOperators, + type SortSpec, +} from "./entities.js"; From b96be085b50c5db78cb60745266895290d7bc9af Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 13:17:43 -0500 Subject: [PATCH 2/6] v0.2.0-next.0 (canary of the entities data layer) Co-Authored-By: Claude Fable 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7182790..2960019 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0", + "version": "0.2.0-next.0", "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", From 775d74656c395f9c8d2b40f7223c19bb8d5726de Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 14:16:22 -0500 Subject: [PATCH 3/6] entities: full Base44 parity (bulk/many ops, fields, $-operator filters, CSV import); v0.2.0-next.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings bool.entities to one-to-one with Base44's entity surface so apps never need a raw-SQL escape hatch: - Methods: + bulkCreate, bulkUpdate (upsert), updateMany, deleteMany, importEntities (client-side CSV parse -> bulkCreate), + fields selection on list/filter. Base44-shaped return types (DeleteResult/UpdateManyResult/...). - Filter DSL switched to Base44's MongoDB-style $-operators: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex $all $not + root $and/$or/$nor. - updateMany $set = one atomic PATCH; $inc/$mul/$push/$pull = read-modify-write (documented non-atomic; RPC follow-up). $size omitted (not expressible over PostgREST). importEntities parses CSV client-side — no gateway endpoint needed. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 25 ++- README.md | 12 +- package.json | 2 +- src/entities.test.ts | 127 ++++++++++++++- src/entities.ts | 366 +++++++++++++++++++++++++++++++++++-------- src/index.ts | 5 + 6 files changed, 458 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbbaa6..c4a6bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,34 @@ ## 0.2.0 -Adds the **entities data layer** — a Base44-style CRUD API over the gateway so +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 } }); +await bool.entities.todos.filter({ status: "active", count: { $gte: 10 } }); ``` -`bool.entities.
` exposes `list`, `filter`, `get`, `create`, `update`, -`delete`, and `subscribe`. Methods return row data directly and throw on error. -Additive and backward-compatible — `bool.db` / `supabase` still work. +`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. ## 0.1.1 diff --git a/README.md b/README.md index 0e1be0c..020097c 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,19 @@ tested, and upgradable independently of any one app. ## What it does - **Entities data API.** `client.entities.
` is the recommended way to - read/write data — `list`, `filter`, `get`, `create`, `update`, `delete`, - `subscribe`. It hides Supabase/SQL entirely; methods return rows directly and - throw on error: + 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.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 (what `entities` is built on) whose REST and Storage traffic is routed to the Bool diff --git a/package.json b/package.json index 2960019..d5b2a51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.0", + "version": "0.2.0-next.1", "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/entities.test.ts b/src/entities.test.ts index f820133..89e5e79 100644 --- a/src/entities.test.ts +++ b/src/entities.test.ts @@ -66,11 +66,11 @@ describe("entities: read paths → PostgREST", () => { expect(reqQuery(calls[0]!).get("offset")).toBe("20"); }); - test("filter() maps scalar, operator, array, and null to PostgREST ops", async () => { + 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 }, + count: { $gte: 100 }, priority: ["high", "urgent"], archived_at: null, }); @@ -81,6 +81,37 @@ describe("entities: read paths → PostgREST", () => { 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("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); @@ -121,11 +152,99 @@ describe("entities: write paths → PostgREST", () => { expect(updated).toEqual({ id: "t1", done: true }); }); - test("delete(id) DELETEs the matched row", async () => { + test("delete(id) DELETEs the matched row and reports success", async () => { const bool = createBoolClient(CONFIG); - await bool.entities.todos.delete("t1"); + 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 reads then writes (read-modify-write)", async () => { + let phase = 0; + respond = (_url, init) => { + // First call: the SELECT of matching rows. Second: the upsert. + if (init?.method === "GET" || (!init?.method && phase === 0)) { + phase = 1; + return Response.json([{ id: "a", views: 5 }]); + } + return Response.json([{ id: "a", views: 6 }]); + }; + const bool = createBoolClient(CONFIG); + const res = await bool.entities.posts.updateMany({ id: "a" }, { $inc: { views: 1 } }); + // A read (GET) followed by a write (POST upsert). + expect(calls[0]!.init?.method ?? "GET").toBe("GET"); + expect(calls[1]!.init?.method).toBe("POST"); + expect(JSON.parse(String(calls[1]!.init?.body))).toEqual([{ id: "a", views: 6 }]); + expect(res.updated).toBe(1); + }); + + 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" }, + ]); }); }); diff --git a/src/entities.ts b/src/entities.ts index 5c70d94..e83a46a 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -7,35 +7,46 @@ // 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 } }); // -// Methods return the row data directly and THROW on error (unlike supabase-js's -// `{ data, error }`), so app code reads clean. Every method is a thin, -// well-understood translation to what `db` (the gateway client) already does. +// 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; -/** PostgREST comparison operators usable in a filter query. */ +/** MongoDB-style comparison operators for a single field. Mirrors Base44. */ export type FilterOperators = Partial<{ - eq: unknown; - neq: unknown; - gt: unknown; - gte: unknown; - lt: unknown; - lte: unknown; - like: string; - ilike: string; - in: unknown[]; + $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"] }`) - * - an operator object → `{ count: { gte: 100 } }` + * - 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 @@ -44,28 +55,67 @@ export type FilterValue = | null | Array | FilterOperators; -export type FilterQuery = Record; +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. */ + * 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. */ - list(sort?: SortSpec, limit?: number, offset?: number): Promise; + /** 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, - offset?: 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(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; @@ -76,30 +126,101 @@ export type 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; -function applySort(query: QueryBuilder, sort: SortSpec): QueryBuilder { - const ascending = !sort.startsWith("-"); - const column = sort.replace(/^[-+]/, ""); - return query.order(column, { ascending }); +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(","); } function applyFilter(query: QueryBuilder, filter: FilterQuery): QueryBuilder { let q = query; for (const [column, value] of Object.entries(filter)) { - if (value === null) { + 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") { + q = q.not("or", `(${(value as FilterQuery[]).map(toPgConditions).join(",")})`); + } else if (value === null) { q = q.is(column, null); } else if (Array.isArray(value)) { - q = q.in(column, value); + q = q.in(column, value as unknown[]); } else if (typeof value === "object") { - for (const [op, operand] of Object.entries(value)) { + for (const [op, operand] of Object.entries(value as Record)) { if (operand === undefined) continue; - // op is one of the PostgREST methods on the builder (eq/gt/in/like/…). - q = q[op](column, operand); + q = applyOperator(q, column, op, operand); } } else { q = q.eq(column, value); @@ -108,58 +229,172 @@ function applyFilter(query: QueryBuilder, filter: FilterQuery): QueryBuilder { 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 paginate(query: QueryBuilder, limit: number, offset: number): QueryBuilder { - return query.range(offset, offset + limit - 1); + function select(fields?: string[]): string { + return fields && fields.length ? fields.join(",") : "*"; } - return { - async list(sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, offset = 0) { - const query = paginate( - applySort(db.from(table).select("*"), sort), - limit, - offset, + 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), + )) ?? [] ); - const { data, error } = await query; - if (error) throw error; - return data ?? []; }, - async filter(filter, sort = DEFAULT_SORT, limit = DEFAULT_LIMIT, offset = 0) { - const query = paginate( - applySort(applyFilter(db.from(table).select("*"), filter), sort), - limit, - offset, + 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, + ), + )) ?? [] ); - const { data, error } = await query; - if (error) throw error; - return data ?? []; }, async get(id) { - const { data, error } = await db.from(table).select("*").eq("id", id).single(); - if (error) throw error; - return data; + return unwrap(db.from(table).select("*").eq("id", id).single()); }, async create(values) { - const { data, error } = await db.from(table).insert(values).select().single(); - if (error) throw error; - return data; + 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) { - const { data, error } = await db - .from(table) - .update(values) - .eq("id", id) - .select() - .single(); - if (error) throw error; - return data; + 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 normalized: UpdateOps = isUpdateOps(ops as Record) + ? (ops as UpdateOps) + : { $set: ops as Record }; + const setOnly = + !normalized.$inc && + !normalized.$mul && + !normalized.$push && + !normalized.$pull && + !normalized.$unset; + if (setOnly) { + // One atomic PATCH across all matching rows. + const rows = + (await unwrap( + applyFilter(db.from(table).update(normalized.$set ?? {}), query).select("id"), + )) ?? []; + return { success: true, updated: rows.length, has_more: false }; + } + // Read-modify-write for arithmetic/array operators. NOT atomic under + // concurrent writers (a Postgres RPC would be — tracked as a follow-up). + const rows = (await unwrap(applyFilter(db.from(table).select("*"), query))) ?? []; + for (const row of rows) applyUpdateOps(row, normalized); + if (rows.length) await unwrap(db.from(table).upsert(rows).select("id")); + return { success: true, updated: rows.length, has_more: false }; }, async delete(id) { - const { error } = await db.from(table).delete().eq("id", id); - if (error) throw error; + 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. @@ -168,6 +403,7 @@ function createEntityHandler( }); }, }; + return handler; } export function createEntitiesModule( diff --git a/src/index.ts b/src/index.ts index 5b11f7c..b14b3cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,5 +18,10 @@ export { type FilterQuery, type FilterValue, type FilterOperators, + type UpdateOps, type SortSpec, + type DeleteResult, + type DeleteManyResult, + type UpdateManyResult, + type ImportResult, } from "./entities.js"; From 71216c6e23775d53bd00cc8871b6582afa1e07c0 Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 14:28:24 -0500 Subject: [PATCH 4/6] entities.updateMany: atomic $inc/$mul via bool_apply_numeric RPC (fallback to RMW); v0.2.0-next.2 $inc/$mul now select target ids then call the per-schema bool_apply_numeric function so the arithmetic is atomic in SQL. Falls back to read-modify-write on PGRST202 (function not provisioned), so it's safe on any schema. $set/$unset stay a single atomic PATCH; $push/$pull remain read-modify-write (documented). Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/entities.test.ts | 45 +++++++++++++++++++++-------- src/entities.ts | 67 ++++++++++++++++++++++++++++++++------------ 3 files changed, 83 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index d5b2a51..df833ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.1", + "version": "0.2.0-next.2", "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/entities.test.ts b/src/entities.test.ts index 89e5e79..dabc3ac 100644 --- a/src/entities.test.ts +++ b/src/entities.test.ts @@ -198,22 +198,43 @@ describe("entities: write paths → PostgREST", () => { expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ done: true }); }); - test("updateMany() with $inc reads then writes (read-modify-write)", async () => { - let phase = 0; - respond = (_url, init) => { - // First call: the SELECT of matching rows. Second: the upsert. - if (init?.method === "GET" || (!init?.method && phase === 0)) { - phase = 1; - return Response.json([{ id: "a", views: 5 }]); + 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 }); } - return Response.json([{ id: "a", views: 6 }]); + // 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 } }); - // A read (GET) followed by a write (POST upsert). - expect(calls[0]!.init?.method ?? "GET").toBe("GET"); - expect(calls[1]!.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[1]!.init?.body))).toEqual([{ id: "a", views: 6 }]); + // 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); }); diff --git a/src/entities.ts b/src/entities.ts index e83a46a..730270e 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -349,29 +349,60 @@ function createEntityHandler( return (await unwrap(db.from(table).upsert(values).select())) ?? []; }, async updateMany(query, ops) { - const normalized: UpdateOps = isUpdateOps(ops as Record) + const n: UpdateOps = isUpdateOps(ops as Record) ? (ops as UpdateOps) : { $set: ops as Record }; - const setOnly = - !normalized.$inc && - !normalized.$mul && - !normalized.$push && - !normalized.$pull && - !normalized.$unset; - if (setOnly) { - // One atomic PATCH across all matching rows. + // $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(normalized.$set ?? {}), query).select("id"), - )) ?? []; + (await unwrap(applyFilter(db.from(table).update(setPart), query).select("id"))) ?? []; return { success: true, updated: rows.length, has_more: false }; } - // Read-modify-write for arithmetic/array operators. NOT atomic under - // concurrent writers (a Postgres RPC would be — tracked as a follow-up). - const rows = (await unwrap(applyFilter(db.from(table).select("*"), query))) ?? []; - for (const row of rows) applyUpdateOps(row, normalized); - if (rows.length) await unwrap(db.from(table).upsert(rows).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)); From 1448c18028b806044f7099211a714a9779f40adb Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 14:43:39 -0500 Subject: [PATCH 5/6] entities: fix $nor (was emitting invalid PostgREST) + broaden test coverage; v0.2.0-next.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $nor was producing garbage (or=not.(...).undefined). Reimplemented via De Morgan — NOR = AND of negated conditions — which is valid PostgREST. Caught by adding a test that asserts the actual query string. Test coverage expanded to 51 tests: $not, $nor, $and, $size-ignored, +sort, limit cap, $mul RPC, $unset PATCH, mixed $set+$inc, $push/$pull read-modify-write, plus the existing read/write/bulk/filter/import paths. Co-Authored-By: Claude Fable 5 --- package.json | 2 +- src/entities.test.ts | 106 +++++++++++++++++++++++++++++++++++++++++++ src/entities.ts | 25 +++++++++- 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index df833ba..47ba100 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.2", + "version": "0.2.0-next.3", "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/entities.test.ts b/src/entities.test.ts index dabc3ac..bf93402 100644 --- a/src/entities.test.ts +++ b/src/entities.test.ts @@ -106,6 +106,52 @@ describe("entities: read paths → PostgREST", () => { 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"]); @@ -238,6 +284,66 @@ describe("entities: write paths → PostgREST", () => { 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); diff --git a/src/entities.ts b/src/entities.ts index 730270e..21d4d04 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -203,6 +203,27 @@ function toPgConditions(query: FilterQuery): string { 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)) { @@ -212,7 +233,9 @@ function applyFilter(query: QueryBuilder, filter: FilterQuery): QueryBuilder { } else if (column === "$or") { q = q.or((value as FilterQuery[]).map(toPgConditions).join(",")); } else if (column === "$nor") { - q = q.not("or", `(${(value as FilterQuery[]).map(toPgConditions).join(",")})`); + // 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)) { From 94ca70c3f66fd04449da973452bc1d635a0303ab Mon Sep 17 00:00:00 2001 From: Jack Singer Date: Wed, 15 Jul 2026 16:59:03 -0500 Subject: [PATCH 6/6] entities: make EntitiesModule an augmentable interface (typed bool.entities) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the EntitiesModule map from a `type` alias to an `interface` with a string index signature, so a generated app can augment it per-entity via `declare module "bool-sdk"` and get typed bool.entities. (field names, enum unions, value types) — caught at the app's `tsc -b` build. The index signature keeps un-declared tables usable as EntityHandler, so the un-augmented SDK is unchanged. Runtime untouched. v0.2.0-next.4 (canary). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- src/entities.ts | 23 +++++++++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a6bff..727506c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,20 @@ Known gaps vs. Base44 (documented, follow-ups): `updateMany` with 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/package.json b/package.json index 47ba100..272fe72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.2.0-next.3", + "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/entities.ts b/src/entities.ts index 21d4d04..4b2e238 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -121,8 +121,27 @@ export interface EntityHandler { subscribe(cb: (change: BoolChangePayload) => void): () => void; } -/** Dynamic map: `entities.` yields a handler for that table. */ -export type EntitiesModule = { [table: string]: EntityHandler }; +/** + * 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;