diff --git a/README.md b/README.md index eea0edd..5d1c3af 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,85 @@ response = { */ ``` +#### Conflict Options for Write Operations + +The SDK supports conflict options for write operations, allowing you to control how the API handles duplicate writes and missing deletes. + +> **Note**: This requires OpenFGA [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + +##### Using Conflict Options with Write +```javascript +const options = { + conflict: { + // Control what happens when writing a tuple that already exists + onDuplicateWrite: OnDuplicateWrite.Ignore, // or OnDuplicateWrite.Error (the current default behavior) + // Control what happens when deleting a tuple that doesn't exist + onMissingDelete: OnMissingDelete.Ignore, // or OnMissingDelete.Error (the current default behavior) + } +}; + +const body = { + writes: [{ + user: 'user:anne', + relation: 'writer', + object: 'document:2021-budget', + }], + deletes: [{ + user: 'user:bob', + relation: 'reader', + object: 'document:2021-budget', + }], +}; + +const response = await fgaClient.write(body, options); +``` + +##### Using Conflict Options with WriteTuples +```javascript +const tuples = [{ + user: 'user:anne', + relation: 'writer', + object: 'document:2021-budget', +}]; + +const options = { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + } +}; + +const response = await fgaClient.writeTuples(tuples, options); +``` + +##### Using Conflict Options with DeleteTuples +```javascript +const tuples = [{ + user: 'user:bob', + relation: 'reader', + object: 'document:2021-budget', +}]; + +const options = { + conflict: { + onMissingDelete: OnMissingDelete.Ignore, + } +}; + +const response = await fgaClient.deleteTuples(tuples, options); +``` + +##### Conflict Options Behavior + +- **`onDuplicateWrite`**: + - `OnDuplicateWrite.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition) + - `OnDuplicateWrite.Ignore`: Treats duplicate writes as no-ops, allowing idempotent write operations + +- **`onMissingDelete`**: + - `OnMissingDelete.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist + - `OnMissingDelete.Ignore`: Treats deletes of non-existent tuples as no-ops, allowing idempotent delete operations + +> **Important**: If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. + #### Relationship Queries ##### Check diff --git a/api.ts b/api.ts index 4a08e83..bee86ba 100644 --- a/api.ts +++ b/api.ts @@ -677,7 +677,7 @@ export const OpenFgaApiAxiosParamCreator = function (configuration: Configuratio }; }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1024,7 +1024,7 @@ export const OpenFgaApiFp = function(configuration: Configuration, credentials: }); }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1239,7 +1239,7 @@ export const OpenFgaApiFactory = function (configuration: Configuration, credent return localVarFp.readChanges(storeId, type, pageSize, continuationToken, startTime, options).then((request) => request(axios)); }, /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body @@ -1467,7 +1467,7 @@ export class OpenFgaApi extends BaseAPI { } /** - * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ] }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ] } } ``` + * The Write API will transactionally update the tuples for a certain store. Tuples and type definitions allow OpenFGA to determine whether a relationship exists between an object and an user. In the body, `writes` adds new tuples and `deletes` removes existing tuples. When deleting a tuple, any `condition` specified with it is ignored. The API is not idempotent by default: if, later on, you try to add the same tuple key (even if the `condition` is different), or if you try to delete a non-existing tuple, it will throw an error. To allow writes when an identical tuple already exists in the database, set `\"on_duplicate\": \"ignore\"` on the `writes` object. To allow deletes when a tuple was already removed from the database, set `\"on_missing\": \"ignore\"` on the `deletes` object. If a Write request contains both idempotent (ignore) and non-idempotent (error) operations, the most restrictive action (error) will take precedence. If a condition fails for a sub-request with an error flag, the entire transaction will be rolled back. This gives developers explicit control over the atomicity of the requests. The API will not allow you to write tuples such as `document:2021-budget#viewer@document:2021-budget#viewer`, because they are implicit. An `authorization_model_id` may be specified in the body. If it is, it will be used to assert that each written tuple (not deleted) is valid for the model specified. If it is not specified, the latest authorization model ID will be used. ## Example ### Adding relationships To add `user:anne` as a `writer` for `document:2021-budget`, call write API with the following ```json { \"writes\": { \"tuple_keys\": [ { \"user\": \"user:anne\", \"relation\": \"writer\", \"object\": \"document:2021-budget\" } ], \"on_duplicate\": \"ignore\" }, \"authorization_model_id\": \"01G50QVV17PECNVAHX1GG4Y5NC\" } ``` ### Removing relationships To remove `user:bob` as a `reader` for `document:2021-budget`, call write API with the following ```json { \"deletes\": { \"tuple_keys\": [ { \"user\": \"user:bob\", \"relation\": \"reader\", \"object\": \"document:2021-budget\" } ], \"on_missing\": \"ignore\" } } ``` * @summary Add or delete tuples from the store * @param {string} storeId * @param {WriteRequest} body diff --git a/apiModel.ts b/apiModel.ts index 1678c09..b5a8090 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -1920,7 +1920,23 @@ export interface WriteRequestDeletes { * @memberof WriteRequestDeletes */ tuple_keys: Array; + /** + * On \'error\', the API returns an error when deleting a tuple that does not exist. On \'ignore\', deletes of non-existent tuples are treated as no-ops. + * @type {string} + * @memberof WriteRequestDeletes + */ + on_missing?: WriteRequestDeletesOnMissing; } + +/** +* @export +* @enum {string} +*/ +export enum WriteRequestDeletesOnMissing { + Error = 'error', + Ignore = 'ignore' +} + /** * * @export @@ -1933,5 +1949,21 @@ export interface WriteRequestWrites { * @memberof WriteRequestWrites */ tuple_keys: Array; + /** + * On \'error\' ( or unspecified ), the API returns an error if an identical tuple already exists. On \'ignore\', identical writes are treated as no-ops (matching on user, relation, object, and RelationshipCondition). + * @type {string} + * @memberof WriteRequestWrites + */ + on_duplicate?: WriteRequestWritesOnDuplicate; } +/** +* @export +* @enum {string} +*/ +export enum WriteRequestWritesOnDuplicate { + Error = 'error', + Ignore = 'ignore' +} + + diff --git a/client.ts b/client.ts index d6278fa..2e2d83b 100644 --- a/client.ts +++ b/client.ts @@ -36,6 +36,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, + WriteRequestWritesOnDuplicate, + WriteRequestDeletesOnMissing, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -175,12 +177,40 @@ export interface ClientBatchCheckResponse { result: ClientBatchCheckSingleResponse[]; } +export const OnDuplicateWrite = WriteRequestWritesOnDuplicate; +export const OnMissingDelete = WriteRequestDeletesOnMissing; + +export type OnDuplicateWrite = WriteRequestWritesOnDuplicate; +export type OnMissingDelete = WriteRequestDeletesOnMissing; + +export interface ClientWriteConflictOptions { + onDuplicateWrite?: OnDuplicateWrite; + onMissingDelete?: OnMissingDelete; +} + +export interface ClientWriteTransactionOptions { + disable?: boolean; + maxPerChunk?: number; + maxParallelRequests?: number; +} + export interface ClientWriteRequestOpts { - transaction?: { - disable?: boolean; - maxPerChunk?: number; - maxParallelRequests?: number; - } + transaction?: ClientWriteTransactionOptions; + conflict?: ClientWriteConflictOptions; +} + +export interface ClientWriteTuplesRequestOpts { + transaction?: ClientWriteTransactionOptions; + conflict?: { + onDuplicateWrite?: OnDuplicateWrite; + }; +} + +export interface ClientDeleteTuplesRequestOpts { + transaction?: ClientWriteTransactionOptions; + conflict?: { + onMissingDelete?: OnMissingDelete; + }; } export interface ClientWriteRequest { @@ -462,6 +492,9 @@ export class OpenFgaClient extends BaseAPI { * @param {ClientWriteRequest} body * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {OnDuplicateWrite} [options.conflict.onDuplicateWrite] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrite.Error` + * @param {OnMissingDelete} [options.conflict.onMissingDelete] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDelete.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -472,7 +505,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ async write(body: ClientWriteRequest, options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { - const { transaction = {}, headers = {} } = options; + const { transaction = {}, headers = {}, conflict } = options; const { maxPerChunk = 1, // 1 has to be the default otherwise the chunks will be sent in transactions maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS, @@ -485,10 +518,16 @@ export class OpenFgaClient extends BaseAPI { authorization_model_id: authorizationModelId, }; if (writes?.length) { - apiBody.writes = { tuple_keys: writes }; + apiBody.writes = { + tuple_keys: writes, + on_duplicate: conflict?.onDuplicateWrite ?? OnDuplicateWrite.Error + }; } if (deletes?.length) { - apiBody.deletes = { tuple_keys: deletes }; + apiBody.deletes = { + tuple_keys: deletes, + on_missing: conflict?.onMissingDelete ?? OnMissingDelete.Error + }; } await this.api.write(this.getStoreId(options)!, apiBody, options); return { @@ -552,8 +591,10 @@ export class OpenFgaClient extends BaseAPI { /** * WriteTuples - Utility method to write tuples, wraps Write * @param {TupleKey[]} tuples - * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] + * @param {ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {OnDuplicateWrite} [options.conflict.onDuplicateWrite] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrite.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -563,7 +604,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { + async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts = {}): Promise { const { headers = {} } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "WriteTuples"); return this.write({ writes: tuples }, { ...options, headers }); @@ -572,8 +613,10 @@ export class OpenFgaClient extends BaseAPI { /** * DeleteTuples - Utility method to delete tuples, wraps Write * @param {TupleKeyWithoutCondition[]} tuples - * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] + * @param {ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration + * @param {object} [options.conflict] - Conflict handling options + * @param {OnMissingDelete} [options.conflict.onMissingDelete] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDelete.Error` * @param {object} [options.transaction] * @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false` * @param {number} [options.transaction.maxPerChunk] - Max number of items to send in a single transaction chunk. Defaults to `1` @@ -583,7 +626,7 @@ export class OpenFgaClient extends BaseAPI { * @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request * @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated */ - async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise { + async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts = {}): Promise { const { headers = {} } = options; setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "DeleteTuples"); return this.write({ deletes: tuples }, { ...options, headers }); diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index dae177f..4c61aff 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -1,4 +1,4 @@ -import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName } from "@openfga/sdk"; +import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, OnDuplicateWrite } from "@openfga/sdk"; import { randomUUID } from "crypto"; async function main () { @@ -145,7 +145,10 @@ async function main () { object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" } ] - }, { authorizationModelId }); + }, { + authorizationModelId, + conflict: { onDuplicateWrite: OnDuplicateWrite.Ignore } + }); console.log("Done Writing Tuples"); // Set the model ID diff --git a/tests/client.test.ts b/tests/client.test.ts index a8c06fc..8d68497 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -11,6 +11,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, + OnDuplicateWrite, + OnMissingDelete, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -462,6 +464,464 @@ describe("OpenFGA Client", () => { expect(data.writes.length).toBe(1); expect(data.deletes.length).toBe(0); }); + + describe("with conflict options", () => { + it("should pass onDuplicateWrite Ignore option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onDuplicateWrite Error option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDelete Ignore option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [tuple], + }, { + conflict: { + onMissingDelete: OnMissingDelete.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDelete Error option to API", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [tuple], + }, { + conflict: { + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass both conflict options to API", async () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when conflict options are not specified", async () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + describe("matrix tests for writes only", () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + it("should handle writes only with onDuplicateWrite Error", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle writes only with onDuplicateWrite Ignore", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + + describe("matrix tests for deletes only", () => { + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + it("should handle deletes only with onMissingDelete Error", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [deleteTuple], + }, { + conflict: { + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle deletes only with onMissingDelete Ignore", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + deletes: [deleteTuple], + }, { + conflict: { + onMissingDelete: OnMissingDelete.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + + describe("matrix tests for mixed writes and deletes", () => { + const writeTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + const deleteTuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:2", + }; + + it("should handle mixed writes and deletes with (Ignore, Ignore)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + onMissingDelete: OnMissingDelete.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Ignore, Error)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Error, Ignore)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Error, + onMissingDelete: OnMissingDelete.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle mixed writes and deletes with (Error, Error)", async () => { + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [writeTuple], + deletes: [deleteTuple], + }, { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Error, + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "error", + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); + }); }); describe("WriteTuples", () => { @@ -481,6 +941,89 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onDuplicateWrite Ignore option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple], { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onDuplicateWrite Error option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple], { + conflict: { + onDuplicateWrite: OnDuplicateWrite.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onDuplicateWrite option is not specified", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.writeTuples([tuple]); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); }); describe("DeleteTuples", () => { @@ -500,6 +1043,89 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onMissingDelete Ignore option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple], { + conflict: { + onMissingDelete: OnMissingDelete.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDelete Error option to write method", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple], { + conflict: { + onMissingDelete: OnMissingDelete.Error, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onMissingDelete option is not specified", async () => { + const tuple = { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }; + + const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); + + await fgaClient.deleteTuples([tuple]); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); }); /* Relationship Queries */