From bb062f539122d8cb817abfeb34b1dcc41fa8387c Mon Sep 17 00:00:00 2001 From: phamhieu Date: Mon, 27 Oct 2025 08:58:49 +0700 Subject: [PATCH 1/9] feat: add support for write conflict settings --- README.md | 79 +++++++++++ api.ts | 8 +- apiModel.ts | 32 +++++ client.ts | 76 ++++++++-- example/example1/example1.mjs | 7 +- tests/client.test.ts | 253 ++++++++++++++++++++++++++++++++++ 6 files changed, 437 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index aa199e4..3b79a20 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 + onDuplicateWrites: OnDuplicateWrites.Ignore, // or OnDuplicateWrites.Error (the current default behavior) + // Control what happens when deleting a tuple that doesn't exist + onMissingDeletes: OnMissingDeletes.Ignore, // or OnMissingDeletes.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: { + onDuplicateWrites: OnDuplicateWrites.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: { + onMissingDeletes: OnMissingDeletes.Ignore, + } +}; + +const response = await fgaClient.deleteTuples(tuples, options); +``` + +##### Conflict Options Behavior + +- **`onDuplicateWrites`**: + - `OnDuplicateWrites.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition) + - `OnDuplicateWrites.Ignore`: Treats duplicate writes as no-ops, allowing idempotent write operations + +- **`onMissingDeletes`**: + - `OnMissingDeletes.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist + - `OnMissingDeletes.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..45e1088 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?: WriteRequestDeletesOnMissingEnum; } + +/** + * @export + * @enum {string} + */ +export enum WriteRequestDeletesOnMissingEnum { + 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?: WriteRequestWritesOnDuplicateEnum; } +/** + * @export + * @enum {string} + */ +export enum WriteRequestWritesOnDuplicateEnum { + Error = 'error', + Ignore = 'ignore' +} + + diff --git a/client.ts b/client.ts index c5cdf28..c0e0b81 100644 --- a/client.ts +++ b/client.ts @@ -49,6 +49,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, + WriteRequestWritesOnDuplicateEnum, + WriteRequestDeletesOnMissingEnum, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -187,12 +189,49 @@ export interface ClientBatchCheckResponse { result: ClientBatchCheckSingleResponse[]; } +export const OnDuplicateWrites = WriteRequestWritesOnDuplicateEnum; + +export const OnMissingDeletes = WriteRequestDeletesOnMissingEnum; + +export interface ClientWriteConflictOptions { + /** + * Controls behavior when writing a tuple that already exists + * - `OnDuplicateWrites.Error`: Return error on duplicates (default) + * - `OnDuplicateWrites.Ignore`: Silently skip duplicate writes + */ + onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; + + /** + * Controls behavior when deleting a tuple that doesn't exist + * - `OnMissingDeletes.Error`: Return error on missing deletes (default) + * - `OnMissingDeletes.Ignore`: Silently skip missing deletes + */ + onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; +} + +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?: { + onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; + }; +} + +export interface ClientDeleteTuplesRequestOpts { + transaction?: ClientWriteTransactionOptions; + conflict?: { + onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; + }; } export interface ClientWriteRequest { @@ -474,6 +513,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 {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.Error` + * @param {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.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` @@ -484,7 +526,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, @@ -497,10 +539,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?.onDuplicateWrites ?? OnDuplicateWrites.Error + }; } if (deletes?.length) { - apiBody.deletes = { tuple_keys: deletes }; + apiBody.deletes = { + tuple_keys: deletes, + on_missing: conflict?.onMissingDeletes ?? OnMissingDeletes.Error + }; } await this.api.write(this.getStoreId(options)!, apiBody, options); return { @@ -564,8 +612,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 {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.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` @@ -575,7 +625,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 }); @@ -584,8 +634,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 {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.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` @@ -595,7 +647,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..0ff91da 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, OnDuplicateWrites } 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: { onDuplicateWrites: OnDuplicateWrites.Ignore } + }); console.log("Done Writing Tuples"); // Set the model ID diff --git a/tests/client.test.ts b/tests/client.test.ts index 39c09c2..062332b 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -24,6 +24,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, + OnDuplicateWrites, + OnMissingDeletes, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -475,6 +477,149 @@ describe("OpenFGA Client", () => { expect(data.writes.length).toBe(1); expect(data.deletes.length).toBe(0); }); + + describe("with conflict options", () => { + it("should pass onDuplicateWrites 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: { + onDuplicateWrites: OnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass onMissingDeletes 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: { + onMissingDeletes: OnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + 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: { + onDuplicateWrites: OnDuplicateWrites.Ignore, + onMissingDeletes: OnMissingDeletes.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("WriteTuples", () => { @@ -494,6 +639,60 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onDuplicateWrites 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: { + onDuplicateWrites: OnDuplicateWrites.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onDuplicateWrites 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", () => { @@ -513,6 +712,60 @@ describe("OpenFGA Client", () => { expect(scope.isDone()).toBe(true); expect(data).toMatchObject({}); }); + + it("should pass onMissingDeletes 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: { + onMissingDeletes: OnMissingDeletes.Ignore, + } + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should default to error conflict handling when onMissingDeletes 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 */ From 84dc1218854605e579f8b7e3f2cb8d332b5dcd25 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 09:33:13 +0700 Subject: [PATCH 2/9] fix: align conflict option type and naming --- apiModel.ts | 8 ++++---- client.ts | 41 ++++++++++++++++------------------------- tests/client.test.ts | 28 ++++++++++++++-------------- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/apiModel.ts b/apiModel.ts index 45e1088..d267b7a 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -1925,14 +1925,14 @@ export interface WriteRequestDeletes { * @type {string} * @memberof WriteRequestDeletes */ - on_missing?: WriteRequestDeletesOnMissingEnum; + on_missing?: WriteRequestDeletesOnMissing; } /** * @export * @enum {string} */ -export enum WriteRequestDeletesOnMissingEnum { +export enum WriteRequestDeletesOnMissing { Error = 'error', Ignore = 'ignore' } @@ -1954,14 +1954,14 @@ export interface WriteRequestWrites { * @type {string} * @memberof WriteRequestWrites */ - on_duplicate?: WriteRequestWritesOnDuplicateEnum; + on_duplicate?: WriteRequestWritesOnDuplicate; } /** * @export * @enum {string} */ -export enum WriteRequestWritesOnDuplicateEnum { +export enum WriteRequestWritesOnDuplicate { Error = 'error', Ignore = 'ignore' } diff --git a/client.ts b/client.ts index c0e0b81..c35b910 100644 --- a/client.ts +++ b/client.ts @@ -49,8 +49,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, - WriteRequestWritesOnDuplicateEnum, - WriteRequestDeletesOnMissingEnum, + WriteRequestWritesOnDuplicate, + WriteRequestDeletesOnMissing, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -189,24 +189,15 @@ export interface ClientBatchCheckResponse { result: ClientBatchCheckSingleResponse[]; } -export const OnDuplicateWrites = WriteRequestWritesOnDuplicateEnum; +export const OnDuplicateWrite = WriteRequestWritesOnDuplicate; +export const OnMissingDelete = WriteRequestDeletesOnMissing; -export const OnMissingDeletes = WriteRequestDeletesOnMissingEnum; +export type OnDuplicateWrite = WriteRequestWritesOnDuplicate; +export type OnMissingDelete = WriteRequestDeletesOnMissing; export interface ClientWriteConflictOptions { - /** - * Controls behavior when writing a tuple that already exists - * - `OnDuplicateWrites.Error`: Return error on duplicates (default) - * - `OnDuplicateWrites.Ignore`: Silently skip duplicate writes - */ - onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; - - /** - * Controls behavior when deleting a tuple that doesn't exist - * - `OnMissingDeletes.Error`: Return error on missing deletes (default) - * - `OnMissingDeletes.Ignore`: Silently skip missing deletes - */ - onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; + onDuplicateWrite?: OnDuplicateWrite; + onMissingDelete?: OnMissingDelete; } export interface ClientWriteTransactionOptions { @@ -223,14 +214,14 @@ export interface ClientWriteRequestOpts { export interface ClientWriteTuplesRequestOpts { transaction?: ClientWriteTransactionOptions; conflict?: { - onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; + onDuplicateWrite?: OnDuplicateWrite; }; } export interface ClientDeleteTuplesRequestOpts { transaction?: ClientWriteTransactionOptions; conflict?: { - onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; + onMissingDelete?: OnMissingDelete; }; } @@ -514,8 +505,8 @@ export class OpenFgaClient extends BaseAPI { * @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration * @param {object} [options.conflict] - Conflict handling options - * @param {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.Error` - * @param {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.Error` + * @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` @@ -541,13 +532,13 @@ export class OpenFgaClient extends BaseAPI { if (writes?.length) { apiBody.writes = { tuple_keys: writes, - on_duplicate: conflict?.onDuplicateWrites ?? OnDuplicateWrites.Error + on_duplicate: conflict?.onDuplicateWrite ?? OnDuplicateWrite.Error }; } if (deletes?.length) { apiBody.deletes = { tuple_keys: deletes, - on_missing: conflict?.onMissingDeletes ?? OnMissingDeletes.Error + on_missing: conflict?.onMissingDelete ?? OnMissingDelete.Error }; } await this.api.write(this.getStoreId(options)!, apiBody, options); @@ -615,7 +606,7 @@ export class OpenFgaClient extends BaseAPI { * @param {ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration * @param {object} [options.conflict] - Conflict handling options - * @param {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.Error` + * @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` @@ -637,7 +628,7 @@ export class OpenFgaClient extends BaseAPI { * @param {ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts} [options] * @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration * @param {object} [options.conflict] - Conflict handling options - * @param {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.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` diff --git a/tests/client.test.ts b/tests/client.test.ts index 062332b..f0019ed 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -24,8 +24,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, - OnDuplicateWrites, - OnMissingDeletes, + OnDuplicateWrite, + OnMissingDelete, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -479,7 +479,7 @@ describe("OpenFGA Client", () => { }); describe("with conflict options", () => { - it("should pass onDuplicateWrites option to API", async () => { + it("should pass onDuplicateWrite option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -492,7 +492,7 @@ describe("OpenFGA Client", () => { writes: [tuple], }, { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }); @@ -510,7 +510,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onMissingDeletes option to API", async () => { + it("should pass onMissingDelete option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -523,7 +523,7 @@ describe("OpenFGA Client", () => { deletes: [tuple], }, { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }); @@ -560,8 +560,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, - onMissingDeletes: OnMissingDeletes.Error, + onDuplicateWrite: OnDuplicateWrite.Ignore, + onMissingDelete: OnMissingDelete.Error, } }); @@ -640,7 +640,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onDuplicateWrites option to write method", async () => { + it("should pass onDuplicateWrite option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -651,7 +651,7 @@ describe("OpenFGA Client", () => { await fgaClient.writeTuples([tuple], { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }); @@ -669,7 +669,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should default to error conflict handling when onDuplicateWrites option is not specified", async () => { + 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", @@ -713,7 +713,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onMissingDeletes option to write method", async () => { + it("should pass onMissingDelete option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -724,7 +724,7 @@ describe("OpenFGA Client", () => { await fgaClient.deleteTuples([tuple], { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }); @@ -742,7 +742,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should default to error conflict handling when onMissingDeletes option is not specified", async () => { + 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", From 120c42b30b5af12479b71a29ad9bf7af1033f619 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 09:58:34 +0700 Subject: [PATCH 3/9] chore: add more test cases --- tests/client.test.ts | 381 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 377 insertions(+), 4 deletions(-) diff --git a/tests/client.test.ts b/tests/client.test.ts index f0019ed..d51e0cd 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -479,7 +479,7 @@ describe("OpenFGA Client", () => { }); describe("with conflict options", () => { - it("should pass onDuplicateWrite option to API", async () => { + it("should pass onDuplicateWrite Ignore option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -510,7 +510,38 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onMissingDelete option to API", async () => { + 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", @@ -541,6 +572,37 @@ describe("OpenFGA Client", () => { 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", @@ -619,6 +681,259 @@ describe("OpenFGA Client", () => { 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(); + }); + }); }); }); @@ -640,7 +955,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onDuplicateWrite option to write method", async () => { + it("should pass onDuplicateWrite Ignore option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -669,6 +984,35 @@ describe("OpenFGA Client", () => { 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", @@ -713,7 +1057,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onMissingDelete option to write method", async () => { + it("should pass onMissingDelete Ignore option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -742,6 +1086,35 @@ describe("OpenFGA Client", () => { 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", From 474b87f3ecb695e2612ab6fde6211d36890844ce Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 10:22:47 +0700 Subject: [PATCH 4/9] chore: update README and example --- README.md | 20 ++++++++++---------- example/example1/example1.mjs | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 689c88a..5d1c3af 100644 --- a/README.md +++ b/README.md @@ -472,9 +472,9 @@ The SDK supports conflict options for write operations, allowing you to control const options = { conflict: { // Control what happens when writing a tuple that already exists - onDuplicateWrites: OnDuplicateWrites.Ignore, // or OnDuplicateWrites.Error (the current default behavior) + onDuplicateWrite: OnDuplicateWrite.Ignore, // or OnDuplicateWrite.Error (the current default behavior) // Control what happens when deleting a tuple that doesn't exist - onMissingDeletes: OnMissingDeletes.Ignore, // or OnMissingDeletes.Error (the current default behavior) + onMissingDelete: OnMissingDelete.Ignore, // or OnMissingDelete.Error (the current default behavior) } }; @@ -504,7 +504,7 @@ const tuples = [{ const options = { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }; @@ -521,7 +521,7 @@ const tuples = [{ const options = { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }; @@ -530,13 +530,13 @@ const response = await fgaClient.deleteTuples(tuples, options); ##### Conflict Options Behavior -- **`onDuplicateWrites`**: - - `OnDuplicateWrites.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition) - - `OnDuplicateWrites.Ignore`: Treats duplicate writes as no-ops, allowing idempotent write operations +- **`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 -- **`onMissingDeletes`**: - - `OnMissingDeletes.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist - - `OnMissingDeletes.Ignore`: Treats deletes of non-existent tuples as no-ops, allowing idempotent delete 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. diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index 0ff91da..4c61aff 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -1,4 +1,4 @@ -import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, OnDuplicateWrites } from "@openfga/sdk"; +import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, OnDuplicateWrite } from "@openfga/sdk"; import { randomUUID } from "crypto"; async function main () { @@ -147,7 +147,7 @@ async function main () { ] }, { authorizationModelId, - conflict: { onDuplicateWrites: OnDuplicateWrites.Ignore } + conflict: { onDuplicateWrite: OnDuplicateWrite.Ignore } }); console.log("Done Writing Tuples"); From f276bdf4db7fb7c9cc6c47bf5604be9e67b0235d Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 10:47:41 +0700 Subject: [PATCH 5/9] chore: tidy up --- apiModel.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apiModel.ts b/apiModel.ts index d267b7a..b5a8090 100644 --- a/apiModel.ts +++ b/apiModel.ts @@ -1929,9 +1929,9 @@ export interface WriteRequestDeletes { } /** - * @export - * @enum {string} - */ +* @export +* @enum {string} +*/ export enum WriteRequestDeletesOnMissing { Error = 'error', Ignore = 'ignore' @@ -1958,9 +1958,9 @@ export interface WriteRequestWrites { } /** - * @export - * @enum {string} - */ +* @export +* @enum {string} +*/ export enum WriteRequestWritesOnDuplicate { Error = 'error', Ignore = 'ignore' From b9b2f3e4e8e87195181be014f9e1756097655c3a Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 5 Nov 2025 13:40:52 +0700 Subject: [PATCH 6/9] chore: standardize write conflict naming --- README.md | 20 ++++----- client.ts | 28 ++++++------- example/example1/example1.mjs | 4 +- tests/client.test.ts | 76 +++++++++++++++++------------------ 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 5d1c3af..70c5b63 100644 --- a/README.md +++ b/README.md @@ -472,9 +472,9 @@ The SDK supports conflict options for write operations, allowing you to control const options = { conflict: { // Control what happens when writing a tuple that already exists - onDuplicateWrite: OnDuplicateWrite.Ignore, // or OnDuplicateWrite.Error (the current default behavior) + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, // or ClientWriteRequestOnDuplicateWrites.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) + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, // or ClientWriteRequestOnMissingDeletes.Error (the current default behavior) } }; @@ -504,7 +504,7 @@ const tuples = [{ const options = { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, } }; @@ -521,7 +521,7 @@ const tuples = [{ const options = { conflict: { - onMissingDelete: OnMissingDelete.Ignore, + onMissingDeletes: OnMissingDelete.Ignore, } }; @@ -530,13 +530,13 @@ 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 +- **`onDuplicateWrites`**: + - `ClientWriteRequestOnDuplicateWrites.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition) + - `ClientWriteRequestOnDuplicateWrites.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 +- **`onMissingDeletes`**: + - `ClientWriteRequestOnMissingDeletes.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist + - `ClientWriteRequestOnMissingDeletes.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. diff --git a/client.ts b/client.ts index 2e2d83b..ae37458 100644 --- a/client.ts +++ b/client.ts @@ -177,15 +177,15 @@ export interface ClientBatchCheckResponse { result: ClientBatchCheckSingleResponse[]; } -export const OnDuplicateWrite = WriteRequestWritesOnDuplicate; -export const OnMissingDelete = WriteRequestDeletesOnMissing; +export const ClientWriteRequestOnDuplicateWrites = WriteRequestWritesOnDuplicate; +export const ClientWriteRequestOnMissingDeletes = WriteRequestDeletesOnMissing; -export type OnDuplicateWrite = WriteRequestWritesOnDuplicate; -export type OnMissingDelete = WriteRequestDeletesOnMissing; +export type ClientWriteRequestOnDuplicateWrites = WriteRequestWritesOnDuplicate; +export type ClientWriteRequestOnMissingDeletes = WriteRequestDeletesOnMissing; export interface ClientWriteConflictOptions { - onDuplicateWrite?: OnDuplicateWrite; - onMissingDelete?: OnMissingDelete; + onDuplicateWrites?: ClientWriteRequestOnDuplicateWrites; + onMissingDeletes?: ClientWriteRequestOnMissingDeletes; } export interface ClientWriteTransactionOptions { @@ -202,14 +202,14 @@ export interface ClientWriteRequestOpts { export interface ClientWriteTuplesRequestOpts { transaction?: ClientWriteTransactionOptions; conflict?: { - onDuplicateWrite?: OnDuplicateWrite; + onDuplicateWrites?: ClientWriteRequestOnDuplicateWrites; }; } export interface ClientDeleteTuplesRequestOpts { transaction?: ClientWriteTransactionOptions; conflict?: { - onMissingDelete?: OnMissingDelete; + onMissingDeletes?: ClientWriteRequestOnMissingDeletes; }; } @@ -493,8 +493,8 @@ export class OpenFgaClient extends BaseAPI { * @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 {ClientWriteRequestOnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `ClientWriteRequestOnDuplicateWrites.Error` + * @param {ClientWriteRequestOnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `ClientWriteRequestOnMissingDeletes.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` @@ -520,13 +520,13 @@ export class OpenFgaClient extends BaseAPI { if (writes?.length) { apiBody.writes = { tuple_keys: writes, - on_duplicate: conflict?.onDuplicateWrite ?? OnDuplicateWrite.Error + on_duplicate: conflict?.onDuplicateWrites ?? ClientWriteRequestOnDuplicateWrites.Error }; } if (deletes?.length) { apiBody.deletes = { tuple_keys: deletes, - on_missing: conflict?.onMissingDelete ?? OnMissingDelete.Error + on_missing: conflict?.onMissingDeletes ?? ClientWriteRequestOnMissingDeletes.Error }; } await this.api.write(this.getStoreId(options)!, apiBody, options); @@ -594,7 +594,7 @@ export class OpenFgaClient extends BaseAPI { * @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 {ClientWriteRequestOnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `ClientWriteRequestOnDuplicateWrites.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` @@ -616,7 +616,7 @@ export class OpenFgaClient extends BaseAPI { * @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 {ClientWriteRequestOnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `ClientWriteRequestOnMissingDeletes.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` diff --git a/example/example1/example1.mjs b/example/example1/example1.mjs index 4c61aff..5591da7 100644 --- a/example/example1/example1.mjs +++ b/example/example1/example1.mjs @@ -1,4 +1,4 @@ -import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, OnDuplicateWrite } from "@openfga/sdk"; +import { CredentialsMethod, FgaApiValidationError, OpenFgaClient, TypeName, ClientWriteRequestOnDuplicateWrites } from "@openfga/sdk"; import { randomUUID } from "crypto"; async function main () { @@ -147,7 +147,7 @@ async function main () { ] }, { authorizationModelId, - conflict: { onDuplicateWrite: OnDuplicateWrite.Ignore } + conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } }); console.log("Done Writing Tuples"); diff --git a/tests/client.test.ts b/tests/client.test.ts index 8d68497..97e43cb 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -11,8 +11,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, - OnDuplicateWrite, - OnMissingDelete, + ClientWriteRequestOnDuplicateWrites, + ClientWriteRequestOnMissingDeletes, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -466,7 +466,7 @@ describe("OpenFGA Client", () => { }); describe("with conflict options", () => { - it("should pass onDuplicateWrite Ignore option to API", async () => { + it("should pass onDuplicateWrites Ignore option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -479,7 +479,7 @@ describe("OpenFGA Client", () => { writes: [tuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, } }); @@ -497,7 +497,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onDuplicateWrite Error option to API", async () => { + it("should pass onDuplicateWrites Error option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -510,7 +510,7 @@ describe("OpenFGA Client", () => { writes: [tuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, } }); @@ -528,7 +528,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onMissingDelete Ignore option to API", async () => { + it("should pass onMissingDeletes Ignore option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -541,7 +541,7 @@ describe("OpenFGA Client", () => { deletes: [tuple], }, { conflict: { - onMissingDelete: OnMissingDelete.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, } }); @@ -559,7 +559,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onMissingDelete Error option to API", async () => { + it("should pass onMissingDeletes Error option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -572,7 +572,7 @@ describe("OpenFGA Client", () => { deletes: [tuple], }, { conflict: { - onMissingDelete: OnMissingDelete.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -609,8 +609,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, - onMissingDelete: OnMissingDelete.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -676,14 +676,14 @@ describe("OpenFGA Client", () => { object: "workspace:1", }; - it("should handle writes only with onDuplicateWrite Error", async () => { + it("should handle writes only with onDuplicateWrites Error", async () => { const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); await fgaClient.write({ writes: [writeTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, } }); @@ -701,14 +701,14 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should handle writes only with onDuplicateWrite Ignore", async () => { + it("should handle writes only with onDuplicateWrites Ignore", async () => { const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); await fgaClient.write({ writes: [writeTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, } }); @@ -734,14 +734,14 @@ describe("OpenFGA Client", () => { object: "workspace:2", }; - it("should handle deletes only with onMissingDelete Error", async () => { + it("should handle deletes only with onMissingDeletes Error", async () => { const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); await fgaClient.write({ deletes: [deleteTuple], }, { conflict: { - onMissingDelete: OnMissingDelete.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -759,14 +759,14 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should handle deletes only with onMissingDelete Ignore", async () => { + it("should handle deletes only with onMissingDeletes Ignore", async () => { const mockWrite = jest.spyOn(fgaClient.api, "write").mockResolvedValue({} as any); await fgaClient.write({ deletes: [deleteTuple], }, { conflict: { - onMissingDelete: OnMissingDelete.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, } }); @@ -805,8 +805,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, - onMissingDelete: OnMissingDelete.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, } }); @@ -836,8 +836,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, - onMissingDelete: OnMissingDelete.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -867,8 +867,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Error, - onMissingDelete: OnMissingDelete.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, } }); @@ -898,8 +898,8 @@ describe("OpenFGA Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrite: OnDuplicateWrite.Error, - onMissingDelete: OnMissingDelete.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -942,7 +942,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onDuplicateWrite Ignore option to write method", async () => { + it("should pass onDuplicateWrites Ignore option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -953,7 +953,7 @@ describe("OpenFGA Client", () => { await fgaClient.writeTuples([tuple], { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, } }); @@ -971,7 +971,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onDuplicateWrite Error option to write method", async () => { + it("should pass onDuplicateWrites Error option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -982,7 +982,7 @@ describe("OpenFGA Client", () => { await fgaClient.writeTuples([tuple], { conflict: { - onDuplicateWrite: OnDuplicateWrite.Error, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Error, } }); @@ -1000,7 +1000,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should default to error conflict handling when onDuplicateWrite option is not specified", async () => { + it("should default to error conflict handling when onDuplicateWrites option is not specified", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -1044,7 +1044,7 @@ describe("OpenFGA Client", () => { expect(data).toMatchObject({}); }); - it("should pass onMissingDelete Ignore option to write method", async () => { + it("should pass onMissingDeletes Ignore option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -1055,7 +1055,7 @@ describe("OpenFGA Client", () => { await fgaClient.deleteTuples([tuple], { conflict: { - onMissingDelete: OnMissingDelete.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, } }); @@ -1073,7 +1073,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should pass onMissingDelete Error option to write method", async () => { + it("should pass onMissingDeletes Error option to write method", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -1084,7 +1084,7 @@ describe("OpenFGA Client", () => { await fgaClient.deleteTuples([tuple], { conflict: { - onMissingDelete: OnMissingDelete.Error, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, } }); @@ -1102,7 +1102,7 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); - it("should default to error conflict handling when onMissingDelete option is not specified", async () => { + it("should default to error conflict handling when onMissingDeletes option is not specified", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", From c8294ee7b52addce50693cd86132753a47d23993 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 5 Nov 2025 13:58:17 +0700 Subject: [PATCH 7/9] chore: update CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bfad73..d1bd0b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## [Unreleased](https://github.com/openfga/js-sdk/compare/v0.9.0...HEAD) - feat: add support for handling Retry-After header (#267) +- feat: add support for conflict options for Write operations**: (#276) + The client now supports setting `conflict` on `ClientWriteRequestOpts` to control behavior when writing duplicate tuples or deleting non-existent tuples. This feature requires OpenFGA server [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later. + See [Conflict Options for Write Operations](./README.md#conflict-options-for-write-operations) for more. ## v0.9.0 From f591b332cb40a62f87f190ad67b7890310daa178 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 5 Nov 2025 14:14:35 +0700 Subject: [PATCH 8/9] fix: pass conflict options through when transaction.disable is true --- client.ts | 4 +- tests/client.test.ts | 168 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/client.ts b/client.ts index ae37458..f55b301 100644 --- a/client.ts +++ b/client.ts @@ -548,7 +548,7 @@ export class OpenFgaClient extends BaseAPI { const writeResponses: ClientWriteSingleResponse[][] = []; if (writes?.length) { for await (const singleChunkResponse of asyncPool(maxParallelRequests, chunkArray(writes, maxPerChunk), - (chunk) => this.writeTuples(chunk,{ ...options, headers, transaction: undefined }).catch(err => { + (chunk) => this.writeTuples(chunk,{ ...options, headers, conflict, transaction: undefined }).catch(err => { if (err instanceof FgaApiAuthenticationError) { throw err; } @@ -568,7 +568,7 @@ export class OpenFgaClient extends BaseAPI { const deleteResponses: ClientWriteSingleResponse[][] = []; if (deletes?.length) { for await (const singleChunkResponse of asyncPool(maxParallelRequests, chunkArray(deletes, maxPerChunk), - (chunk) => this.deleteTuples(chunk, { ...options, headers, transaction: undefined }).catch(err => { + (chunk) => this.deleteTuples(chunk, { ...options, headers, conflict, transaction: undefined }).catch(err => { if (err instanceof FgaApiAuthenticationError) { throw err; } diff --git a/tests/client.test.ts b/tests/client.test.ts index 97e43cb..4b2cdc8 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -921,6 +921,174 @@ describe("OpenFGA Client", () => { mockWrite.mockRestore(); }); }); + + describe("with transaction.disable and conflict options", () => { + it("should pass conflict options when transaction is disabled for writes", 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], + }, { + transaction: { disable: true }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + // Should be called for the write + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass conflict options when transaction is disabled for deletes", 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], + }, { + transaction: { disable: true }, + conflict: { + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore, + } + }); + + // Should be called for the delete + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [tuple], + on_missing: "ignore", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should pass both conflict options when transaction is disabled with mixed writes and deletes", 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], + }, { + transaction: { disable: true }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + onMissingDeletes: ClientWriteRequestOnMissingDeletes.Error, + } + }); + + // Should be called twice - once for writes, once for deletes + expect(mockWrite).toHaveBeenCalledTimes(2); + + // Check that write call included conflict option + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [writeTuple], + on_duplicate: "ignore", + }, + }), + expect.any(Object) + ); + + // Check that delete call included conflict option + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + deletes: { + tuple_keys: [deleteTuple], + on_missing: "error", + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + + it("should handle multiple chunks with conflict options in non-transaction mode", async () => { + const tuples = [ + { + user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", + relation: "admin", + object: "workspace:1", + }, + { + 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: tuples, + }, { + transaction: { + disable: true, + maxPerChunk: 1, // Force 2 separate calls + }, + conflict: { + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, + } + }); + + // Should be called twice (one per chunk) + expect(mockWrite).toHaveBeenCalledTimes(2); + + // Both calls should include the conflict option + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: expect.objectContaining({ + on_duplicate: "ignore", + }), + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); + }); }); }); From 06d2d27b0ec690f5d8b9f3dcd0f71c9621605851 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 5 Nov 2025 14:23:09 +0700 Subject: [PATCH 9/9] chore: tidy up --- tests/client.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/client.test.ts b/tests/client.test.ts index 4b2cdc8..77119cf 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -941,7 +941,6 @@ describe("OpenFGA Client", () => { } }); - // Should be called for the write expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({ @@ -974,7 +973,6 @@ describe("OpenFGA Client", () => { } }); - // Should be called for the delete expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({ @@ -1014,10 +1012,8 @@ describe("OpenFGA Client", () => { } }); - // Should be called twice - once for writes, once for deletes expect(mockWrite).toHaveBeenCalledTimes(2); - // Check that write call included conflict option expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({ @@ -1029,7 +1025,6 @@ describe("OpenFGA Client", () => { expect.any(Object) ); - // Check that delete call included conflict option expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({ @@ -1072,10 +1067,8 @@ describe("OpenFGA Client", () => { } }); - // Should be called twice (one per chunk) expect(mockWrite).toHaveBeenCalledTimes(2); - // Both calls should include the conflict option expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({