From 9cc47e58ff4973c0262bd7ac9becbecc1597fd0e Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 21 Oct 2025 14:42:35 +0700 Subject: [PATCH 1/9] feat(js-sdk): add support for write conflict settings --- Makefile | 2 +- .../js/template/README_calling_api.mustache | 79 +++++++ config/clients/js/template/client.mustache | 72 ++++++- .../js/template/example/example1/example1.mjs | 5 +- .../js/template/tests/client.test.ts.mustache | 193 ++++++++++++++++++ 5 files changed, 342 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 2bdb907a6..ed80677ed 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Main config OPENFGA_DOCKER_TAG = v1 -OPEN_API_REF ?= e53c69cc55317404d02a6d8e418d626268f28a59 +OPEN_API_REF ?= 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2 OPEN_API_URL = https://raw.githubusercontent.com/openfga/api/${OPEN_API_REF}/docs/openapiv2/apidocs.swagger.json OPENAPI_GENERATOR_CLI_DOCKER_TAG ?= v6.4.0 NODE_DOCKER_TAG = 20-alpine diff --git a/config/clients/js/template/README_calling_api.mustache b/config/clients/js/template/README_calling_api.mustache index bde283af8..7ef7a4412 100644 --- a/config/clients/js/template/README_calling_api.mustache +++ b/config/clients/js/template/README_calling_api.mustache @@ -267,6 +267,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/config/clients/js/template/client.mustache b/config/clients/js/template/client.mustache index 233bc1e17..513b5dbb1 100644 --- a/config/clients/js/template/client.mustache +++ b/config/clients/js/template/client.mustache @@ -38,6 +38,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, + WriteRequestWritesOnDuplicateEnum, + WriteRequestDeletesOnMissingEnum, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -176,12 +178,55 @@ 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 ClientWriteRequestOpts { transaction?: { disable?: boolean; maxPerChunk?: number; maxParallelRequests?: number; } + conflict?: ClientWriteConflictOptions; +} + +export interface ClientWriteTuplesRequestOpts { + transaction?: { + disable?: boolean; + maxPerChunk?: number; + maxParallelRequests?: number; + }; + conflict?: { + onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; + }; +} + +export interface ClientDeleteTuplesRequestOpts { + transaction?: { + disable?: boolean; + maxPerChunk?: number; + maxParallelRequests?: number; + }; + conflict?: { + onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; + }; } export interface ClientWriteRequest { @@ -463,6 +508,9 @@ export class {{appShortName}}Client 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` @@ -473,7 +521,7 @@ export class {{appShortName}}Client 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, @@ -486,10 +534,16 @@ export class {{appShortName}}Client extends BaseAPI { authorization_model_id: authorizationModelId, }; if (writes?.length) { - apiBody.writes = { tuple_keys: writes }; + apiBody.writes = { + tuple_keys: writes, + on_duplicate: conflict?.onDuplicateWrites + }; } if (deletes?.length) { - apiBody.deletes = { tuple_keys: deletes }; + apiBody.deletes = { + tuple_keys: deletes, + on_missing: conflict?.onMissingDeletes + }; } await this.api.write(this.getStoreId(options)!, apiBody, options); return { @@ -553,8 +607,10 @@ export class {{appShortName}}Client 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` @@ -564,7 +620,7 @@ export class {{appShortName}}Client 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 }); @@ -573,8 +629,10 @@ export class {{appShortName}}Client 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` @@ -584,7 +642,7 @@ export class {{appShortName}}Client 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/config/clients/js/template/example/example1/example1.mjs b/config/clients/js/template/example/example1/example1.mjs index dae177fcc..e607674f3 100644 --- a/config/clients/js/template/example/example1/example1.mjs +++ b/config/clients/js/template/example/example1/example1.mjs @@ -145,7 +145,10 @@ async function main () { object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a" } ] - }, { authorizationModelId }); + }, { + authorizationModelId, + conflict: { onDuplicateWrites: 'ignore' } + }); console.log("Done Writing Tuples"); // Set the model ID diff --git a/config/clients/js/template/tests/client.test.ts.mustache b/config/clients/js/template/tests/client.test.ts.mustache index 1fdcb1ae5..bc5bb4b6a 100644 --- a/config/clients/js/template/tests/client.test.ts.mustache +++ b/config/clients/js/template/tests/client.test.ts.mustache @@ -13,6 +13,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, + OnDuplicateWrites, + OnMissingDeletes, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -463,6 +465,139 @@ describe("{{appTitleCaseName}} Client", () => { expect(scope.isDone()).toBe(true); 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 not include conflict options when not specified", async () => { + const tuple = { + user: "user:anne", + relation: "writer", + object: "document:2021-budget", + }; + + const mockWrite = jest.spyOn(fgaClient.api, 'write').mockResolvedValue({} as any); + + await fgaClient.write({ + writes: [tuple], + }); + + expect(mockWrite).toHaveBeenCalledWith( + baseConfig.storeId, + expect.objectContaining({ + writes: { + tuple_keys: [tuple], + on_duplicate: undefined, + }, + }), + expect.any(Object) + ); + + mockWrite.mockRestore(); + }); }); }); @@ -483,6 +618,35 @@ describe("{{appTitleCaseName}} 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(); // Restore immediately after this test + }); }); describe("DeleteTuples", () => { @@ -502,6 +666,35 @@ describe("{{appTitleCaseName}} 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(); // Restore immediately after this test + }); }); /* Relationship Queries */ From 7db1f77d11dbb9a8c71767f44410cf799b69d380 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 22 Oct 2025 19:45:30 +0700 Subject: [PATCH 2/9] fix: OnDuplicateWrites example --- config/clients/js/template/example/example1/example1.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/clients/js/template/example/example1/example1.mjs b/config/clients/js/template/example/example1/example1.mjs index e607674f3..0ff91dabb 100644 --- a/config/clients/js/template/example/example1/example1.mjs +++ b/config/clients/js/template/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 () { @@ -147,7 +147,7 @@ async function main () { ] }, { authorizationModelId, - conflict: { onDuplicateWrites: 'ignore' } + conflict: { onDuplicateWrites: OnDuplicateWrites.Ignore } }); console.log("Done Writing Tuples"); From 8adaad23bfd38f01da308f9473f76f68abb0abba Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 22 Oct 2025 20:01:50 +0700 Subject: [PATCH 3/9] fix: extract write transaction config into interface --- config/clients/js/template/client.mustache | 24 ++++++++-------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/config/clients/js/template/client.mustache b/config/clients/js/template/client.mustache index 513b5dbb1..3511b3fed 100644 --- a/config/clients/js/template/client.mustache +++ b/config/clients/js/template/client.mustache @@ -198,32 +198,26 @@ export interface ClientWriteConflictOptions { 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?: { - disable?: boolean; - maxPerChunk?: number; - maxParallelRequests?: number; - }; + transaction?: ClientWriteTransactionOptions; conflict?: { onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites]; }; } export interface ClientDeleteTuplesRequestOpts { - transaction?: { - disable?: boolean; - maxPerChunk?: number; - maxParallelRequests?: number; - }; + transaction?: ClientWriteTransactionOptions; conflict?: { onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes]; }; From ba0eecd29f794600c4c4d7d28b9ce109a484898c Mon Sep 17 00:00:00 2001 From: phamhieu Date: Thu, 23 Oct 2025 07:04:49 +0700 Subject: [PATCH 4/9] feat: send error as default for write conflict options --- config/clients/js/template/client.mustache | 4 +- .../js/template/tests/client.test.ts.mustache | 80 ++++++++++++++++--- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/config/clients/js/template/client.mustache b/config/clients/js/template/client.mustache index 3511b3fed..a261f7b80 100644 --- a/config/clients/js/template/client.mustache +++ b/config/clients/js/template/client.mustache @@ -530,13 +530,13 @@ export class {{appShortName}}Client extends BaseAPI { if (writes?.length) { apiBody.writes = { tuple_keys: writes, - on_duplicate: conflict?.onDuplicateWrites + on_duplicate: conflict?.onDuplicateWrites ?? OnDuplicateWrites.Error }; } if (deletes?.length) { apiBody.deletes = { tuple_keys: deletes, - on_missing: conflict?.onMissingDeletes + on_missing: conflict?.onMissingDeletes ?? OnMissingDeletes.Error }; } await this.api.write(this.getStoreId(options)!, apiBody, options); diff --git a/config/clients/js/template/tests/client.test.ts.mustache b/config/clients/js/template/tests/client.test.ts.mustache index bc5bb4b6a..3b954a998 100644 --- a/config/clients/js/template/tests/client.test.ts.mustache +++ b/config/clients/js/template/tests/client.test.ts.mustache @@ -572,25 +572,35 @@ describe("{{appTitleCaseName}} Client", () => { mockWrite.mockRestore(); }); - it("should not include conflict options when not specified", async () => { - const tuple = { - user: "user:anne", - relation: "writer", - object: "document:2021-budget", + 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: [tuple], + writes: [writeTuple], + deletes: [deleteTuple], }); expect(mockWrite).toHaveBeenCalledWith( baseConfig.storeId, expect.objectContaining({ writes: { - tuple_keys: [tuple], - on_duplicate: undefined, + tuple_keys: [writeTuple], + on_duplicate: 'error', + }, + deletes: { + tuple_keys: [deleteTuple], + on_missing: 'error', }, }), expect.any(Object) @@ -645,7 +655,32 @@ describe("{{appTitleCaseName}} Client", () => { expect.any(Object) ); - mockWrite.mockRestore(); // Restore immediately after this test + 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(); }); }); @@ -693,7 +728,32 @@ describe("{{appTitleCaseName}} Client", () => { expect.any(Object) ); - mockWrite.mockRestore(); // Restore immediately after this test + 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(); }); }); From 3b3d122e0c088db8436655ae8938fed1f1cce069 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 09:32:54 +0700 Subject: [PATCH 5/9] fix: align conflict option type and naming --- config/clients/js/config.overrides.json | 1 + config/clients/js/template/client.mustache | 41 ++++++++----------- .../js/template/tests/client.test.ts.mustache | 28 ++++++------- 3 files changed, 31 insertions(+), 39 deletions(-) diff --git a/config/clients/js/config.overrides.json b/config/clients/js/config.overrides.json index 2729ff053..d73c519d2 100644 --- a/config/clients/js/config.overrides.json +++ b/config/clients/js/config.overrides.json @@ -11,6 +11,7 @@ "supportsES6": true, "modelPropertyNaming": "original", "openTelemetryDocumentation": "opentelemetry.md", + "enumNameSuffix": "", "files": { "tests/jest.config.js": {}, "tests/tsconfig.spec.json": {}, diff --git a/config/clients/js/template/client.mustache b/config/clients/js/template/client.mustache index a261f7b80..13341bb71 100644 --- a/config/clients/js/template/client.mustache +++ b/config/clients/js/template/client.mustache @@ -38,8 +38,8 @@ import { WriteAuthorizationModelRequest, WriteAuthorizationModelResponse, WriteRequest, - WriteRequestWritesOnDuplicateEnum, - WriteRequestDeletesOnMissingEnum, + WriteRequestWritesOnDuplicate, + WriteRequestDeletesOnMissing, } from "./apiModel"; import { BaseAPI } from "./base"; import { CallResult, PromiseResult } from "./common"; @@ -178,24 +178,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 { @@ -212,14 +203,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; }; } @@ -503,8 +494,8 @@ export class {{appShortName}}Client 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` @@ -530,13 +521,13 @@ export class {{appShortName}}Client 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); @@ -604,7 +595,7 @@ export class {{appShortName}}Client 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` @@ -626,7 +617,7 @@ export class {{appShortName}}Client 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/config/clients/js/template/tests/client.test.ts.mustache b/config/clients/js/template/tests/client.test.ts.mustache index 3b954a998..722c226f3 100644 --- a/config/clients/js/template/tests/client.test.ts.mustache +++ b/config/clients/js/template/tests/client.test.ts.mustache @@ -13,8 +13,8 @@ import { ConsistencyPreference, ErrorCode, BatchCheckRequest, - OnDuplicateWrites, - OnMissingDeletes, + OnDuplicateWrite, + OnMissingDelete, } from "../index"; import { baseConfig, defaultConfiguration, getNocks } from "./helpers"; @@ -468,7 +468,7 @@ describe("{{appTitleCaseName}} 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", @@ -481,7 +481,7 @@ describe("{{appTitleCaseName}} Client", () => { writes: [tuple], }, { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }); @@ -499,7 +499,7 @@ describe("{{appTitleCaseName}} 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", @@ -512,7 +512,7 @@ describe("{{appTitleCaseName}} Client", () => { deletes: [tuple], }, { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }); @@ -549,8 +549,8 @@ describe("{{appTitleCaseName}} Client", () => { deletes: [deleteTuple], }, { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, - onMissingDeletes: OnMissingDeletes.Error, + onDuplicateWrite: OnDuplicateWrite.Ignore, + onMissingDelete: OnMissingDelete.Error, } }); @@ -629,7 +629,7 @@ describe("{{appTitleCaseName}} 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", @@ -640,7 +640,7 @@ describe("{{appTitleCaseName}} Client", () => { await fgaClient.writeTuples([tuple], { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }); @@ -658,7 +658,7 @@ describe("{{appTitleCaseName}} 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", @@ -702,7 +702,7 @@ describe("{{appTitleCaseName}} 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", @@ -713,7 +713,7 @@ describe("{{appTitleCaseName}} Client", () => { await fgaClient.deleteTuples([tuple], { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }); @@ -731,7 +731,7 @@ describe("{{appTitleCaseName}} 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 001a2cdf0e15aed2b640302ed13eaf3678f3d465 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 09:56:23 +0700 Subject: [PATCH 6/9] chore: add more test cases --- .../js/template/tests/client.test.ts.mustache | 383 +++++++++++++++++- 1 file changed, 378 insertions(+), 5 deletions(-) diff --git a/config/clients/js/template/tests/client.test.ts.mustache b/config/clients/js/template/tests/client.test.ts.mustache index 722c226f3..30203937c 100644 --- a/config/clients/js/template/tests/client.test.ts.mustache +++ b/config/clients/js/template/tests/client.test.ts.mustache @@ -467,8 +467,8 @@ describe("{{appTitleCaseName}} Client", () => { expect(data.deletes.length).toBe(0); }); - describe("with conflict options", () => { - it("should pass onDuplicateWrite option to API", async () => { + describe("with conflict options", () => { + it("should pass onDuplicateWrite Ignore option to API", async () => { const tuple = { user: "user:81684243-9356-4421-8fbf-a4f8d36aa31b", relation: "admin", @@ -499,7 +499,38 @@ describe("{{appTitleCaseName}} 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", @@ -530,6 +561,37 @@ describe("{{appTitleCaseName}} 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", @@ -608,6 +670,259 @@ describe("{{appTitleCaseName}} 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(); + }); + }); }); }); @@ -629,7 +944,7 @@ describe("{{appTitleCaseName}} 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", @@ -658,6 +973,35 @@ describe("{{appTitleCaseName}} 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", @@ -702,7 +1046,7 @@ describe("{{appTitleCaseName}} 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", @@ -731,6 +1075,35 @@ describe("{{appTitleCaseName}} 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 5d2ad8431131b054c0764369c83e73805d352a5b Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 10:21:05 +0700 Subject: [PATCH 7/9] chore: update README --- .../js/template/README_calling_api.mustache | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/config/clients/js/template/README_calling_api.mustache b/config/clients/js/template/README_calling_api.mustache index 7ef7a4412..920ab9130 100644 --- a/config/clients/js/template/README_calling_api.mustache +++ b/config/clients/js/template/README_calling_api.mustache @@ -278,9 +278,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) } }; @@ -310,7 +310,7 @@ const tuples = [{ const options = { conflict: { - onDuplicateWrites: OnDuplicateWrites.Ignore, + onDuplicateWrite: OnDuplicateWrite.Ignore, } }; @@ -327,7 +327,7 @@ const tuples = [{ const options = { conflict: { - onMissingDeletes: OnMissingDeletes.Ignore, + onMissingDelete: OnMissingDelete.Ignore, } }; @@ -336,13 +336,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. From b2e24b55b413e4c3550b3d7ca46dc2bccc405694 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Tue, 28 Oct 2025 10:47:37 +0700 Subject: [PATCH 8/9] chore: tidy up --- config/clients/js/template/modelGeneric.mustache | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/clients/js/template/modelGeneric.mustache b/config/clients/js/template/modelGeneric.mustache index df1e0fc83..a321ecb90 100644 --- a/config/clients/js/template/modelGeneric.mustache +++ b/config/clients/js/template/modelGeneric.mustache @@ -24,9 +24,9 @@ export interface {{classname}} {{#parent}}extends {{{parent}}} {{/parent}}{ {{#vars}} {{#isEnum}} /** - * @export - * @enum {string} - */ +* @export +* @enum {string} +*/ export enum {{enumName}} { {{#allowableValues}} {{#enumVars}} From 4517ab81b635c7c611c582be6c2a599b57d80ab8 Mon Sep 17 00:00:00 2001 From: phamhieu Date: Wed, 5 Nov 2025 13:39:34 +0700 Subject: [PATCH 9/9] chore: standardize write conflict naming --- .../js/template/README_calling_api.mustache | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/config/clients/js/template/README_calling_api.mustache b/config/clients/js/template/README_calling_api.mustache index 920ab9130..9666ba0f6 100644 --- a/config/clients/js/template/README_calling_api.mustache +++ b/config/clients/js/template/README_calling_api.mustache @@ -278,9 +278,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) } }; @@ -310,7 +310,7 @@ const tuples = [{ const options = { conflict: { - onDuplicateWrite: OnDuplicateWrite.Ignore, + onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, } }; @@ -327,7 +327,7 @@ const tuples = [{ const options = { conflict: { - onMissingDelete: OnMissingDelete.Ignore, + onMissingDeletes: OnMissingDelete.Ignore, } }; @@ -336,13 +336,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.