From 73f5b997192f78d08b2581e3319f34257ea30bf7 Mon Sep 17 00:00:00 2001 From: Katie West Date: Fri, 25 Sep 2026 09:24:42 -0700 Subject: [PATCH] feat(directory-sync): Add on-demand sync (FEAT-3084) --- src/directory-sync/directory-sync.spec.ts | 75 +++++++++++++++++++ src/directory-sync/directory-sync.ts | 24 ++++++ .../directory-sync-response.interface.ts | 4 + src/directory-sync/interfaces/index.ts | 1 + 4 files changed, 104 insertions(+) create mode 100644 src/directory-sync/interfaces/directory-sync-response.interface.ts diff --git a/src/directory-sync/directory-sync.spec.ts b/src/directory-sync/directory-sync.spec.ts index 76dabc2f3..c7af72a5e 100644 --- a/src/directory-sync/directory-sync.spec.ts +++ b/src/directory-sync/directory-sync.spec.ts @@ -194,6 +194,81 @@ describe('DirectorySync', () => { }); }); + describe('syncDirectory', () => { + const client = new WorkOS('sk_test', { maxRetries: 0 }); + + it('returns the queued response from a 202 without sending a request body', async () => { + fetchOnce({ status: 'queued' }, { status: 202 }); + + const result = await client.directorySync.syncDirectory('directory_123'); + + expect(result).toEqual({ status: 'queued' }); + expect(fetchURL()).toBe( + 'https://api.workos.com/directories/directory_123/sync', + ); + expect(fetchMethod()).toBe('POST'); + expect(fetch.mock.calls[0][1]?.body).toBe(''); + }); + + it('encodes the directory ID as a single path segment', async () => { + fetchOnce({ status: 'queued' }, { status: 202 }); + + await client.directorySync.syncDirectory( + 'directory/with?reserved#characters', + ); + + expect(fetchURL()).toContain( + '/directories/directory%2Fwith%3Freserved%23characters/sync', + ); + }); + + it('exposes the cooldown through the existing rate-limit exception', async () => { + fetchOnce( + { + code: 'directory_sync_rate_limited', + message: 'Wait before requesting another sync.', + retry_after_seconds: 120, + }, + { + status: 429, + headers: { 'Retry-After': '120', 'X-Request-ID': 'req_sync' }, + }, + ); + + await expect( + client.directorySync.syncDirectory('directory_123'), + ).rejects.toMatchObject({ + name: 'RateLimitExceededException', + status: 429, + retryAfter: 120, + requestID: 'req_sync', + }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + [409, 'directory_sync_in_progress'], + [422, 'directory_sync_unsupported'], + [503, 'directory_sync_disabled'], + ])( + 'propagates a %s response instead of reporting a queued sync', + async (status, code) => { + fetchOnce( + { code, message: 'The sync was not queued.' }, + { status: Number(status) }, + ); + + await expect( + client.directorySync.syncDirectory('directory_123'), + ).rejects.toMatchObject({ + status, + code, + }); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + }); + describe('deleteDirectory', () => { it('sends a request to delete the directory', async () => { fetchOnce({}, { status: 202 }); diff --git a/src/directory-sync/directory-sync.ts b/src/directory-sync/directory-sync.ts index 2952d2bbc..e6aa0847a 100644 --- a/src/directory-sync/directory-sync.ts +++ b/src/directory-sync/directory-sync.ts @@ -6,6 +6,7 @@ import { DirectoryGroup, DirectoryGroupResponse, DirectoryResponse, + DirectorySyncResponse, DirectoryUserWithGroups, DirectoryUserWithGroupsResponse, ListDirectoriesOptions, @@ -76,6 +77,29 @@ export class DirectorySync { return deserializeDirectory(data); } + /** + * Request an asynchronous sync of a directory. + * + * Currently supports Google Workspace directories in active or validating + * state. Manual requests share a five-minute cooldown across the API, + * Dashboard, Admin Portal, and MCP. A queued response does not indicate + * that the sync has started or completed. + * + * @param id - Unique identifier for the Directory. + * @throws {ConflictException} A sync is already running (409). + * @throws {UnprocessableEntityException} Unsupported provider or state (422). + * @throws {RateLimitExceededException} Cooldown active; inspect retryAfter (429). + * @throws {GenericServerException} Directory syncing is paused (503). + */ + async syncDirectory(id: string): Promise { + const { data } = await this.workos.post( + `/directories/${encodePathParameter(id)}/sync`, + undefined, + ); + + return data; + } + /** * Delete a Directory * diff --git a/src/directory-sync/interfaces/directory-sync-response.interface.ts b/src/directory-sync/interfaces/directory-sync-response.interface.ts new file mode 100644 index 000000000..d32dd4e8b --- /dev/null +++ b/src/directory-sync/interfaces/directory-sync-response.interface.ts @@ -0,0 +1,4 @@ +export interface DirectorySyncResponse { + /** Accepted for asynchronous processing, not confirmation of completion. */ + status: 'queued'; +} diff --git a/src/directory-sync/interfaces/index.ts b/src/directory-sync/interfaces/index.ts index 344c60f45..f9bcc1474 100644 --- a/src/directory-sync/interfaces/index.ts +++ b/src/directory-sync/interfaces/index.ts @@ -1,5 +1,6 @@ export * from './directory.interface'; export * from './directory-group.interface'; +export * from './directory-sync-response.interface'; export * from './list-directories-options.interface'; export * from './list-groups-options.interface'; export * from './list-directory-users-options.interface';