Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/dev-playground/config/database/orders.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Tables and seed data for the operations example (config/database/routes/orders.ts).
create table if not exists orders (
id serial primary key,
team_id text not null,
created_by text not null,
status varchar(16) not null,
total_cents integer not null,
approved_by text
);

create table if not exists team_members (
id serial primary key,
user_id text not null,
team_id text not null,
role varchar(16) not null,
approval_limit_cents integer not null,
unique (user_id, team_id)
);

insert into team_members (user_id, team_id, role, approval_limit_cents) values
('alice', 'red', 'approver', 100000),
('bob', 'red', 'member', 0),
('carol', 'blue', 'approver', 100000);

insert into orders (team_id, created_by, status, total_cents) values
('red', 'bob', 'pending', 25000),
('red', 'bob', 'pending', 500000),
('blue', 'carol', 'pending', 9900);
47 changes: 47 additions & 0 deletions apps/dev-playground/config/database/policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { definePolicy } from "@databricks/appkit/beta";

/** Listing one team requires membership; listing without a team is scoped in the query. */
export const canReadTeam = definePolicy<{ teamId?: string }>(
async ({ actor, input, db }) => {
if (input.teamId === undefined) return true;
const rows = await db.sql`
select 1 from team_members
where user_id = ${actor.userId} and team_id = ${input.teamId}`;
return rows.length > 0 || { deny: "Not a member of this team" };
},
);

/**
* An approver of the order's team, within their limit, who did not create it.
* Runs inside the mutation's transaction, and locks what it reads: `for update`
* pins the order's team, creator and total, `for share` pins the membership,
* so neither can change between this decision and the approval's commit.
*/
export const canApproveOrder = definePolicy<{ id: number }>(
async ({ actor, input, db }) => {
const [order] = await db.sql<{
team_id: string;
created_by: string;
total_cents: number;
}>`
select team_id, created_by, total_cents from orders
where id = ${input.id}
for update`;
const [member] = order
? await db.sql<{ role: string; approval_limit_cents: number }>`
select role, approval_limit_cents from team_members
where user_id = ${actor.userId} and team_id = ${order.team_id}
for share`
: [];
// Same answer for "missing" and "another team's": existence is not leaked.
if (!order || !member) return { deny: "Order not found" };
if (member.role !== "approver") return { deny: "Approver role required" };
if (order.created_by === actor.userId) {
return { deny: "You cannot approve your own order" };
}
if (order.total_cents > member.approval_limit_cents) {
return { deny: "Order total exceeds your approval limit" };
}
return true;
},
);
48 changes: 48 additions & 0 deletions apps/dev-playground/config/database/routes/orders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
defineMutation,
defineQuery,
OperationError,
} from "@databricks/appkit/beta";
import { z } from "zod";

import { canApproveOrder, canReadTeam } from "../policies";

interface Order {
id: number;
team_id: string;
created_by: string;
status: string;
total_cents: number;
approved_by: string | null;
}

export const listOrders = defineQuery({
input: z.object({ teamId: z.string().min(1).optional() }),
authorize: canReadTeam,
// The membership join is the access rule: rows of other teams are never read.
execute: ({ actor, input, db }) => db.sql<Order>`
select o.* from orders o
join team_members m on m.team_id = o.team_id and m.user_id = ${actor.userId}
where ${input.teamId ?? null}::text is null or o.team_id = ${input.teamId ?? null}
order by o.id`,
});

export const approveOrder = defineMutation({
input: z.object({ id: z.coerce.number().int().positive() }),
authorize: canApproveOrder,
execute: async ({ actor, input, db }) => {
// canApproveOrder holds the order's row lock, so a concurrent approval
// waits for this commit, then finds the order no longer pending: a 409.
const [order] = await db.sql<Order>`
update orders set status = 'approved', approved_by = ${actor.userId}
where id = ${input.id} and status = 'pending'
returning *`;
if (!order) throw new OperationError(409, "Order is not pending");
return order;
},
});

export default {
"GET /orders": listOrders,
"POST /orders/:id/approve": approveOrder,
};
24 changes: 23 additions & 1 deletion apps/dev-playground/config/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
defineSchema,
fk,
id,
integer,
text,
timestamp,
varchar,
Expand Down Expand Up @@ -40,5 +41,26 @@ export const schema = defineSchema(({ table }) => {
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
});

return { boards, notes, note_events };
// Operations example (config/database/routes/orders.ts). Exposed only through
// operations, never generated CRUD; see orders.sql for the tables and seed.
const orders = table("orders", {
id: id(),
team_id: text().notNull(),
created_by: text().notNull(),
status: varchar(16).notNull(),
total_cents: integer().notNull(),
approved_by: text(),
});

// Example permission data. AppKit has no team or role source; an app owns
// whatever its policies read.
const team_members = table("team_members", {
id: id(),
user_id: text().notNull(),
team_id: text().notNull(),
role: varchar(16).notNull(),
approval_limit_cents: integer().notNull(),
});

return { boards, notes, note_events, orders, team_members };
});
7 changes: 5 additions & 2 deletions apps/dev-playground/server/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,20 +86,23 @@ afterEach(() => {
const beforeCreate = () => hooks.beforeCreate(values, context);

describe("inline playground database registration", () => {
test("declares three tables in the conventional schema file", () => {
test("declares the note and order tables in the conventional schema file", () => {
expect(Object.keys(schema.$tables)).toEqual([
"boards",
"notes",
"note_events",
"orders",
"team_members",
]);
});

test("leaves schema discovery to config/database/schema.ts", () => {
expect(databaseConfig.schema).toBeUndefined();
});

test("enables reads for all tables and HTTP writes only for boards and notes", () => {
test("generates CRUD only for the note tables and writes only for boards and notes", () => {
expect(databaseConfig.api).toEqual({
tables: ["boards", "notes", "note_events"],
writes: { tables: ["boards", "notes"] },
});
});
Expand Down
11 changes: 8 additions & 3 deletions apps/dev-playground/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,14 @@ createApp({
? [
// The schema is loaded from config/database/schema.ts.
database({
// Reads are generated for all three tables. Only boards and notes
// accept HTTP writes; the audit trail is written by the hook.
api: { writes: { tables: ["boards", "notes"] } },
// Reads are generated for the three note tables. Only boards and
// notes accept HTTP writes; the audit trail is written by the hook.
// orders and team_members get no generated routes: they are reached
// only through the authorized operations in config/database/routes.
api: {
tables: ["boards", "notes", "note_events"],
writes: { tables: ["boards", "notes"] },
},
// Example of Hooks integration between Database and Agents
hooks: {
// Hook per entity
Expand Down
Loading
Loading