Skip to content

Commit 9cc47e5

Browse files
committed
feat(js-sdk): add support for write conflict settings
1 parent 75dbea2 commit 9cc47e5

5 files changed

Lines changed: 342 additions & 9 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Main config
22
OPENFGA_DOCKER_TAG = v1
3-
OPEN_API_REF ?= e53c69cc55317404d02a6d8e418d626268f28a59
3+
OPEN_API_REF ?= 0ac19aac54f21f3c78970126b84b4c69c6e3b9a2
44
OPEN_API_URL = https://raw.githubusercontent.com/openfga/api/${OPEN_API_REF}/docs/openapiv2/apidocs.swagger.json
55
OPENAPI_GENERATOR_CLI_DOCKER_TAG ?= v6.4.0
66
NODE_DOCKER_TAG = 20-alpine

config/clients/js/template/README_calling_api.mustache

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,85 @@ response = {
267267
*/
268268
```
269269

270+
#### Conflict Options for Write Operations
271+
272+
The SDK supports conflict options for write operations, allowing you to control how the API handles duplicate writes and missing deletes.
273+
274+
> **Note**: This requires OpenFGA [v1.10.0](https://github.com/openfga/openfga/releases/tag/v1.10.0) or later.
275+
276+
##### Using Conflict Options with Write
277+
```javascript
278+
const options = {
279+
conflict: {
280+
// Control what happens when writing a tuple that already exists
281+
onDuplicateWrites: OnDuplicateWrites.Ignore, // or OnDuplicateWrites.Error (the current default behavior)
282+
// Control what happens when deleting a tuple that doesn't exist
283+
onMissingDeletes: OnMissingDeletes.Ignore, // or OnMissingDeletes.Error (the current default behavior)
284+
}
285+
};
286+
287+
const body = {
288+
writes: [{
289+
user: 'user:anne',
290+
relation: 'writer',
291+
object: 'document:2021-budget',
292+
}],
293+
deletes: [{
294+
user: 'user:bob',
295+
relation: 'reader',
296+
object: 'document:2021-budget',
297+
}],
298+
};
299+
300+
const response = await fgaClient.write(body, options);
301+
```
302+
303+
##### Using Conflict Options with WriteTuples
304+
```javascript
305+
const tuples = [{
306+
user: 'user:anne',
307+
relation: 'writer',
308+
object: 'document:2021-budget',
309+
}];
310+
311+
const options = {
312+
conflict: {
313+
onDuplicateWrites: OnDuplicateWrites.Ignore,
314+
}
315+
};
316+
317+
const response = await fgaClient.writeTuples(tuples, options);
318+
```
319+
320+
##### Using Conflict Options with DeleteTuples
321+
```javascript
322+
const tuples = [{
323+
user: 'user:bob',
324+
relation: 'reader',
325+
object: 'document:2021-budget',
326+
}];
327+
328+
const options = {
329+
conflict: {
330+
onMissingDeletes: OnMissingDeletes.Ignore,
331+
}
332+
};
333+
334+
const response = await fgaClient.deleteTuples(tuples, options);
335+
```
336+
337+
##### Conflict Options Behavior
338+
339+
- **`onDuplicateWrites`**:
340+
- `OnDuplicateWrites.Error` (default): Returns an error if an identical tuple already exists (matching on user, relation, object, and condition)
341+
- `OnDuplicateWrites.Ignore`: Treats duplicate writes as no-ops, allowing idempotent write operations
342+
343+
- **`onMissingDeletes`**:
344+
- `OnMissingDeletes.Error` (default): Returns an error when attempting to delete a tuple that doesn't exist
345+
- `OnMissingDeletes.Ignore`: Treats deletes of non-existent tuples as no-ops, allowing idempotent delete operations
346+
347+
> **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.
348+
270349
#### Relationship Queries
271350
272351
##### Check

config/clients/js/template/client.mustache

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
WriteAuthorizationModelRequest,
3939
WriteAuthorizationModelResponse,
4040
WriteRequest,
41+
WriteRequestWritesOnDuplicateEnum,
42+
WriteRequestDeletesOnMissingEnum,
4143
} from "./apiModel";
4244
import { BaseAPI } from "./base";
4345
import { CallResult, PromiseResult } from "./common";
@@ -176,12 +178,55 @@ export interface ClientBatchCheckResponse {
176178
result: ClientBatchCheckSingleResponse[];
177179
}
178180

181+
export const OnDuplicateWrites = WriteRequestWritesOnDuplicateEnum;
182+
183+
export const OnMissingDeletes = WriteRequestDeletesOnMissingEnum;
184+
185+
export interface ClientWriteConflictOptions {
186+
/**
187+
* Controls behavior when writing a tuple that already exists
188+
* - `OnDuplicateWrites.Error`: Return error on duplicates (default)
189+
* - `OnDuplicateWrites.Ignore`: Silently skip duplicate writes
190+
*/
191+
onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites];
192+
193+
/**
194+
* Controls behavior when deleting a tuple that doesn't exist
195+
* - `OnMissingDeletes.Error`: Return error on missing deletes (default)
196+
* - `OnMissingDeletes.Ignore`: Silently skip missing deletes
197+
*/
198+
onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes];
199+
}
200+
179201
export interface ClientWriteRequestOpts {
180202
transaction?: {
181203
disable?: boolean;
182204
maxPerChunk?: number;
183205
maxParallelRequests?: number;
184206
}
207+
conflict?: ClientWriteConflictOptions;
208+
}
209+
210+
export interface ClientWriteTuplesRequestOpts {
211+
transaction?: {
212+
disable?: boolean;
213+
maxPerChunk?: number;
214+
maxParallelRequests?: number;
215+
};
216+
conflict?: {
217+
onDuplicateWrites?: typeof OnDuplicateWrites[keyof typeof OnDuplicateWrites];
218+
};
219+
}
220+
221+
export interface ClientDeleteTuplesRequestOpts {
222+
transaction?: {
223+
disable?: boolean;
224+
maxPerChunk?: number;
225+
maxParallelRequests?: number;
226+
};
227+
conflict?: {
228+
onMissingDeletes?: typeof OnMissingDeletes[keyof typeof OnMissingDeletes];
229+
};
185230
}
186231
187232
export interface ClientWriteRequest {
@@ -463,6 +508,9 @@ export class {{appShortName}}Client extends BaseAPI {
463508
* @param {ClientWriteRequest} body
464509
* @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options]
465510
* @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration
511+
* @param {object} [options.conflict] - Conflict handling options
512+
* @param {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.Error`
513+
* @param {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.Error`
466514
* @param {object} [options.transaction]
467515
* @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false`
468516
* @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 {
473521
* @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated
474522
*/
475523
async write(body: ClientWriteRequest, options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise<ClientWriteResponse> {
476-
const { transaction = {}, headers = {} } = options;
524+
const { transaction = {}, headers = {}, conflict } = options;
477525
const {
478526
maxPerChunk = 1, // 1 has to be the default otherwise the chunks will be sent in transactions
479527
maxParallelRequests = DEFAULT_MAX_METHOD_PARALLEL_REQS,
@@ -486,10 +534,16 @@ export class {{appShortName}}Client extends BaseAPI {
486534
authorization_model_id: authorizationModelId,
487535
};
488536
if (writes?.length) {
489-
apiBody.writes = { tuple_keys: writes };
537+
apiBody.writes = {
538+
tuple_keys: writes,
539+
on_duplicate: conflict?.onDuplicateWrites
540+
};
490541
}
491542
if (deletes?.length) {
492-
apiBody.deletes = { tuple_keys: deletes };
543+
apiBody.deletes = {
544+
tuple_keys: deletes,
545+
on_missing: conflict?.onMissingDeletes
546+
};
493547
}
494548
await this.api.write(this.getStoreId(options)!, apiBody, options);
495549
return {
@@ -553,8 +607,10 @@ export class {{appShortName}}Client extends BaseAPI {
553607
/**
554608
* WriteTuples - Utility method to write tuples, wraps Write
555609
* @param {TupleKey[]} tuples
556-
* @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options]
610+
* @param {ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts} [options]
557611
* @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration
612+
* @param {object} [options.conflict] - Conflict handling options
613+
* @param {OnDuplicateWrites} [options.conflict.onDuplicateWrites] - Controls behavior when writing duplicate tuples. Defaults to `OnDuplicateWrites.Error`
558614
* @param {object} [options.transaction]
559615
* @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false`
560616
* @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 {
564620
* @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request
565621
* @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated
566622
*/
567-
async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise<ClientWriteResponse> {
623+
async writeTuples(tuples: TupleKey[], options: ClientRequestOptsWithAuthZModelId & ClientWriteTuplesRequestOpts = {}): Promise<ClientWriteResponse> {
568624
const { headers = {} } = options;
569625
setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "WriteTuples");
570626
return this.write({ writes: tuples }, { ...options, headers });
@@ -573,8 +629,10 @@ export class {{appShortName}}Client extends BaseAPI {
573629
/**
574630
* DeleteTuples - Utility method to delete tuples, wraps Write
575631
* @param {TupleKeyWithoutCondition[]} tuples
576-
* @param {ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts} [options]
632+
* @param {ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts} [options]
577633
* @param {string} [options.authorizationModelId] - Overrides the authorization model id in the configuration
634+
* @param {object} [options.conflict] - Conflict handling options
635+
* @param {OnMissingDeletes} [options.conflict.onMissingDeletes] - Controls behavior when deleting non-existent tuples. Defaults to `OnMissingDeletes.Error`
578636
* @param {object} [options.transaction]
579637
* @param {boolean} [options.transaction.disable] - Disables running the write in a transaction mode. Defaults to `false`
580638
* @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 {
584642
* @param {number} [options.retryParams.maxRetry] - Override the max number of retries on each API request
585643
* @param {number} [options.retryParams.minWaitInMs] - Override the minimum wait before a retry is initiated
586644
*/
587-
async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientWriteRequestOpts = {}): Promise<ClientWriteResponse> {
645+
async deleteTuples(tuples: TupleKeyWithoutCondition[], options: ClientRequestOptsWithAuthZModelId & ClientDeleteTuplesRequestOpts = {}): Promise<ClientWriteResponse> {
588646
const { headers = {} } = options;
589647
setHeaderIfNotSet(headers, CLIENT_METHOD_HEADER, "DeleteTuples");
590648
return this.write({ deletes: tuples }, { ...options, headers });

config/clients/js/template/example/example1/example1.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,10 @@ async function main () {
145145
object: "document:7772ab2a-d83f-756d-9397-c5ed9f3cb69a"
146146
}
147147
]
148-
}, { authorizationModelId });
148+
}, {
149+
authorizationModelId,
150+
conflict: { onDuplicateWrites: 'ignore' }
151+
});
149152
console.log("Done Writing Tuples");
150153

151154
// Set the model ID

0 commit comments

Comments
 (0)