diff --git a/client/testfixture/config.textproto b/client/testfixture/config.textproto index 9a99426e2..d59201374 100644 --- a/client/testfixture/config.textproto +++ b/client/testfixture/config.textproto @@ -3,6 +3,7 @@ email {} server { application_name: "TrailBase" logs_retention_sec: 604800 + enable_record_transactions: true } auth { oauth_providers: [{ diff --git a/crates/assets/js/client/src/index.ts b/crates/assets/js/client/src/index.ts index 77fdc4b98..61b87adcd 100644 --- a/crates/assets/js/client/src/index.ts +++ b/crates/assets/js/client/src/index.ts @@ -186,54 +186,153 @@ export type Or = { export type FilterOrComposite = Filter | And | Or; -export interface RecordApi> { - list(opts?: { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; - }): Promise>; +// TODO: consider generating these types with ts-rs to reduce manual duplication. +export interface CreateOp { + Create: { + api_name: string; + value: Record; + }; +} - read( - id: string | number, - opt?: { - expand?: string[]; - }, - ): Promise; +export interface UpdateOp { + Update: { + api_name: string; + record_id: string | number; + value: Record; + }; +} - create(record: T): Promise; - createBulk(records: T[]): Promise<(string | number)[]>; +export interface DeleteOp { + Delete: { + api_name: string; + record_id: string | number; + }; +} - update(id: string | number, record: Partial): Promise; +interface DeferredOperation { + query(): Promise; +} - delete(id: string | number): Promise; +interface DeferredMutation + extends DeferredOperation { + toJSON(): CreateOp | UpdateOp | DeleteOp; +} - subscribe(id: string | number): Promise>; +export class CreateOperation> + implements DeferredMutation +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly record: Partial, + ) {} + async query(): Promise { + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}`, + { + method: "POST", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }, + ); + + return (await response.json()).ids[0]; + } + toJSON(): CreateOp { + return { + Create: { + api_name: this.apiName, + value: this.record, + }, + }; + } } -/// Provides CRUD access to records through TrailBase's record API. -export class RecordApiImpl> - implements RecordApi +export class UpdateOperation> + implements DeferredMutation { - private readonly _path: string; + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: string | number, + private readonly record: Partial, + ) {} + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "PATCH", + body: JSON.stringify(this.record), + headers: jsonContentTypeHeader, + }); + } + toJSON(): UpdateOp { + return { + Update: { + api_name: this.apiName, + record_id: this.id, + value: this.record, + }, + }; + } +} +export class DeleteOperation implements DeferredMutation { constructor( private readonly client: Client, - private readonly name: string, - ) { - this._path = `${recordApiBasePath}/${this.name}`; + private readonly apiName: string, + private readonly id: string | number, + ) {} + async query(): Promise { + await this.client.fetch(`${recordApiBasePath}/${this.apiName}/${this.id}`, { + method: "DELETE", + }); } + toJSON(): DeleteOp { + return { + Delete: { + api_name: this.apiName, + record_id: this.id, + }, + }; + } +} - public async list>(opts?: { - pagination?: Pagination; - order?: string[]; - filters?: FilterOrComposite[]; - count?: boolean; - expand?: string[]; - }): Promise> { +export class ReadOperation> + implements DeferredOperation +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly id: string | number, + private readonly opt?: { expand?: string[] }, + ) {} + async query(): Promise { + const expand = this.opt?.expand; + const response = await this.client.fetch( + expand + ? `${recordApiBasePath}/${this.apiName}/${this.id}?expand=${expand.join(",")}` + : `${recordApiBasePath}/${this.apiName}/${this.id}`, + ); + return (await response.json()) as T; + } +} + +export class ListOperation> + implements DeferredOperation> +{ + constructor( + private readonly client: Client, + private readonly apiName: string, + private readonly opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }, + ) {} + async query(): Promise> { const params = new URLSearchParams(); - const pagination = opts?.pagination; + const pagination = this.opts?.pagination; if (pagination) { const cursor = pagination.cursor; if (cursor) params.append("cursor", cursor); @@ -244,12 +343,12 @@ export class RecordApiImpl> const offset = pagination.offset; if (offset) params.append("offset", offset.toString()); } - const order = opts?.order; + const order = this.opts?.order; if (order) params.append("order", order.join(",")); - if (opts?.count) params.append("count", "true"); + if (this.opts?.count) params.append("count", "true"); - const expand = opts?.expand; + const expand = this.opts?.expand; if (expand) params.append("expand", expand.join(",")); function traverseFilters(path: string, filter: FilterOrComposite) { @@ -275,16 +374,94 @@ export class RecordApiImpl> } } - const filters = opts?.filters; + const filters = this.opts?.filters; if (filters) { for (const filter of filters) { traverseFilters("filter", filter); } } - const response = await this.client.fetch(`${this._path}?${params}`); + const response = await this.client.fetch( + `${recordApiBasePath}/${this.apiName}?${params}`, + ); return (await response.json()) as ListResponse; } +} + +export interface RecordApi> { + list(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): Promise>; + + listOp(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation; + + read( + id: string | number, + opt?: { + expand?: string[]; + }, + ): Promise; + + readOp( + id: string | number, + opt?: { + expand?: string[]; + }, + ): ReadOperation; + + create(record: T): Promise; + + createOp(record: T): CreateOperation; + + update(id: string | number, record: Partial): Promise; + + updateOp(id: string | number, record: Partial): UpdateOperation; + + delete(id: string | number): Promise; + + deleteOp(id: string | number): DeleteOperation; + + subscribe(id: string | number): Promise>; +} + +/// Provides CRUD access to records through TrailBase's record API. +export class RecordApiImpl> + implements RecordApi +{ + constructor( + private readonly client: Client, + private readonly name: string, + ) {} + + public async list(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): Promise> { + return new ListOperation(this.client, this.name, opts).query(); + } + + public listOp(opts?: { + pagination?: Pagination; + order?: string[]; + filters?: FilterOrComposite[]; + count?: boolean; + expand?: string[]; + }): ListOperation { + return new ListOperation(this.client, this.name, opts); + } public async read>( id: string | number, @@ -292,58 +469,46 @@ export class RecordApiImpl> expand?: string[]; }, ): Promise { - const expand = opt?.expand; - const response = await this.client.fetch( - expand - ? `${this._path}/${id}?expand=${expand.join(",")}` - : `${this._path}/${id}`, - ); - return (await response.json()) as T; + return new ReadOperation(this.client, this.name, id, opt).query(); } - public async create>( - record: T, - ): Promise { - const response = await this.client.fetch(this._path, { - method: "POST", - body: JSON.stringify(record), - headers: jsonContentTypeHeader, - }); + public readOp( + id: string | number, + opt?: { + expand?: string[]; + }, + ): ReadOperation { + return new ReadOperation(this.client, this.name, id, opt); + } - return (await response.json()).ids[0]; + public async create(record: T): Promise { + return new CreateOperation(this.client, this.name, record).query(); } - public async createBulk>( - records: T[], - ): Promise<(string | number)[]> { - const response = await this.client.fetch(this._path, { - method: "POST", - body: JSON.stringify(records), - headers: jsonContentTypeHeader, - }); + public createOp(record: T): CreateOperation { + return new CreateOperation(this.client, this.name, record); + } - return (await response.json()).ids; + public async update(id: string | number, record: Partial): Promise { + return new UpdateOperation(this.client, this.name, id, record).query(); } - public async update>( - id: string | number, - record: Partial, - ): Promise { - await this.client.fetch(`${this._path}/${id}`, { - method: "PATCH", - body: JSON.stringify(record), - headers: jsonContentTypeHeader, - }); + public updateOp(id: string | number, record: Partial): UpdateOperation { + return new UpdateOperation(this.client, this.name, id, record); } public async delete(id: string | number): Promise { - await this.client.fetch(`${this._path}/${id}`, { - method: "DELETE", - }); + return new DeleteOperation(this.client, this.name, id).query(); + } + + public deleteOp(id: string | number): DeleteOperation { + return new DeleteOperation(this.client, this.name, id); } public async subscribe(id: string | number): Promise> { - const response = await this.client.fetch(`${this._path}/subscribe/${id}`); + const response = await this.client.fetch( + `${recordApiBasePath}/${this.name}/subscribe/${id}`, + ); const body = response.body; if (!body) { throw Error("Subscription reader is null."); @@ -428,6 +593,12 @@ export interface Client { /// /// Unlike native fetch, will throw in case !response.ok. fetch(path: string, init?: FetchOptions): Promise; + + /// Excute a batch query. + execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction?: boolean, + ): Promise<(string | number)[]>; } /// Client for interacting with TrailBase auth and record APIs. @@ -485,6 +656,20 @@ class ClientImpl implements Client { return new RecordApiImpl(this, name); } + /// Excute a batch query. + async execute( + operations: (CreateOperation | UpdateOperation | DeleteOperation)[], + transaction: boolean = true, + ): Promise<(string | number)[]> { + const response = await this.fetch(transactionApiBasePath, { + method: "POST", + body: JSON.stringify({ operations, transaction }), + headers: jsonContentTypeHeader, + }); + + return (await response.json()).ids; + } + public avatarUrl(userId?: string): string | undefined { const id = userId ?? this.user()?.id; if (id) { @@ -681,6 +866,7 @@ export async function initClientFromCookies( const recordApiBasePath = "/api/records/v1"; const authApiBasePath = "/api/auth/v1"; +const transactionApiBasePath = "/api/transaction/v1/execute"; export function filePath( apiName: string, diff --git a/crates/assets/js/client/tests/integration/client_integration.test.ts b/crates/assets/js/client/tests/integration/client_integration.test.ts index 7f4974934..6baa8b321 100644 --- a/crates/assets/js/client/tests/integration/client_integration.test.ts +++ b/crates/assets/js/client/tests/integration/client_integration.test.ts @@ -79,10 +79,13 @@ test("Record integration tests", async () => { } { - const bulkIds = await api.createBulk([ - { text_not_null: "ts bulk create 0" }, - { text_not_null: "ts bulk create 1" }, - ]); + const bulkIds = await client.execute( + [ + api.createOp({ text_not_null: "ts bulk create 0" }), + api.createOp({ text_not_null: "ts bulk create 1" }), + ], + false, + ); expect(bulkIds.length).toBe(2); } @@ -121,7 +124,7 @@ test("Record integration tests", async () => { } { - const response = await api.list({ + const response = await api.list({ filters: [ { column: "text_not_null", @@ -136,20 +139,20 @@ test("Record integration tests", async () => { ).toStrictEqual(messages); } - const record: SimpleStrict = await api.read(ids[0]); + const record = await api.read(ids[0]); expect(record.id).toStrictEqual(ids[0]); expect(record.text_not_null).toStrictEqual(messages[0]); // Test 1:1 view-bases record API. const view_record: SimpleCompleteView = await client - .records("simple_complete_view") + .records("simple_complete_view") .read(ids[0]); expect(view_record.id).toStrictEqual(ids[0]); expect(view_record.text_not_null).toStrictEqual(messages[0]); // Test view-based record API with column renames. const subset_view_record: SimpleSubsetView = await client - .records("simple_subset_view") + .records("simple_subset_view") .read(ids[0]); expect(subset_view_record.id).toStrictEqual(ids[0]); expect(subset_view_record.t_not_null).toStrictEqual(messages[0]); @@ -160,7 +163,7 @@ test("Record integration tests", async () => { text_null: "updated null", }; await api.update(ids[1], updated_value); - const updated_record: SimpleStrict = await api.read(ids[1]); + const updated_record = await api.read(ids[1]); expect(updated_record).toEqual( expect.objectContaining({ id: ids[1], @@ -173,9 +176,7 @@ test("Record integration tests", async () => { expect(await client.logout()).toBe(true); expect(client.user()).toBe(undefined); - await expect( - async () => await api.read(ids[0]), - ).rejects.toThrowError( + await expect(async () => await api.read(ids[0])).rejects.toThrowError( expect.objectContaining({ status: status.FORBIDDEN, }), @@ -206,10 +207,10 @@ type Comment = { test("expand foreign records", async () => { const client = await connect(); - const api = client.records("comment"); + const api = client.records("comment"); { - const comment = await api.read(1); + const comment = await api.read(1); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -217,7 +218,7 @@ test("expand foreign records", async () => { } { - const comment = await api.read(1, { expand: ["post"] }); + const comment = await api.read(1, { expand: ["post"] }); expect(comment.id).toBe(1); expect(comment.body).toBe("first comment"); expect(comment.author.data).toBeUndefined(); @@ -225,7 +226,7 @@ test("expand foreign records", async () => { } { - const response = await api.list({ + const response = await api.list({ expand: ["author", "post"], order: ["-id"], pagination: { @@ -243,7 +244,7 @@ test("expand foreign records", async () => { } { - const response = await api.list({ + const response = await api.list({ expand: ["author", "post"], order: ["-id"], pagination: { @@ -254,7 +255,7 @@ test("expand foreign records", async () => { expect(response.records.length).toBe(2); const second = response.records[1]; - const offsetResponse = await api.list({ + const offsetResponse = await api.list({ expand: ["author", "post"], order: ["-id"], pagination: { @@ -278,7 +279,7 @@ test("record error tests", async () => { ); const nonExistantApi = client.records("non-existant"); await expect( - async () => await nonExistantApi.read(nonExistantId), + async () => await nonExistantApi.read(nonExistantId), ).rejects.toThrowError( expect.objectContaining({ status: status.METHOD_NOT_ALLOWED, @@ -286,16 +287,12 @@ test("record error tests", async () => { ); const api = client.records("simple_strict_table"); - await expect( - async () => await api.read("invalid id"), - ).rejects.toThrowError( + await expect(async () => await api.read("invalid id")).rejects.toThrowError( expect.objectContaining({ status: status.BAD_REQUEST, }), ); - await expect( - async () => await api.read(nonExistantId), - ).rejects.toThrowError( + await expect(async () => await api.read(nonExistantId)).rejects.toThrowError( expect.objectContaining({ status: status.NOT_FOUND, }), @@ -304,11 +301,11 @@ test("record error tests", async () => { test("realtime subscribe specific record tests", async () => { const client = await connect(); - const api = client.records("simple_strict_table"); + const api = client.records("simple_strict_table"); const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api.create({ + const id = (await api.create({ text_not_null: createMessage, })) as string; @@ -331,14 +328,57 @@ test("realtime subscribe specific record tests", async () => { expect(events[1]["Delete"]["text_not_null"]).equals(updatedMessage); }); +test("transaction tests", async () => { + const client = await connect(); + const api = client.records("simple_strict_table"); + const now = new Date().getTime(); + + // Test transaction with create operation + { + const record = { text_not_null: `ts transaction create test: =?&${now}` }; + const ids = await client.execute([api.createOp(record)]); + + expect(ids).toHaveLength(1); + + // Verify record was created + const createdRecord = await api.read(ids[0]); + expect(createdRecord.text_not_null).toBe(record.text_not_null); + } + + // Test transaction with update operation + { + const record = { + text_not_null: `ts transaction update test original: =?&${now}`, + }; + const id = await api.create(record); + const updatedRecord = { + text_not_null: `ts transaction update test modified: =?&${now}`, + }; + await client.execute([api.updateOp(id, updatedRecord)]); + + const readRecord = await api.read(id); + expect(readRecord.text_not_null).toBe(updatedRecord.text_not_null); + } + + // Test transaction with delete operation + { + const record = { text_not_null: `ts transaction delete test: =?&${now}` }; + const id = await api.create(record); + + await client.execute([api.deleteOp(id)]); + + await expect(api.read(id)).rejects.toThrow(); + } +}); + test("realtime subscribe table tests", async () => { const client = await connect(); - const api = client.records("simple_strict_table"); + const api = client.records("simple_strict_table"); const eventStream = await api.subscribe("*"); const now = new Date().getTime(); const createMessage = `ts client realtime test 0: =?&${now}`; - const id = (await api.create({ + const id = (await api.create({ text_not_null: createMessage, })) as string; diff --git a/crates/core/src/records/transaction.rs b/crates/core/src/records/transaction.rs index 332083993..835051e38 100644 --- a/crates/core/src/records/transaction.rs +++ b/crates/core/src/records/transaction.rs @@ -31,6 +31,7 @@ pub enum Operation { #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct TransactionRequest { operations: Vec, + transaction: Option, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] @@ -187,25 +188,42 @@ pub async fn record_transactions_handler( }) .collect::, _>>()?; - let ids = state - .conn() - .call( - move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { - let tx = conn.transaction()?; - - let mut ids: Vec = vec![]; - for op in operations { - if let Some(id) = op(&tx).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { - ids.push(id); + let ids = if request.transaction.unwrap_or(true) { + state + .conn() + .call( + move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { + let tx = conn.transaction()?; + + let mut ids: Vec = vec![]; + for op in operations { + if let Some(id) = op(&tx).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { + ids.push(id); + } } - } - tx.commit()?; + tx.commit()?; - return Ok(ids); - }, - ) - .await?; + return Ok(ids); + }, + ) + .await? + } else { + state + .conn() + .call( + move |conn: &mut rusqlite::Connection| -> Result, trailbase_sqlite::Error> { + let mut ids: Vec = vec![]; + for op in operations { + if let Some(id) = op(conn).map_err(|err| trailbase_sqlite::Error::Other(err.into()))? { + ids.push(id); + } + } + return Ok(ids); + }, + ) + .await? + }; return Ok(Json(TransactionResponse { ids })); } @@ -304,6 +322,7 @@ mod tests { value: json!({"value": 2}), }, ], + transaction: None, }), ) .await @@ -325,6 +344,7 @@ mod tests { value: json!({"value": 3}), }, ], + transaction: None, }), ) .await