From 3aa00f05360cdab8e842a9785250d6ec0dda8ac8 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Tue, 6 Oct 2026 14:04:26 +0200 Subject: [PATCH 1/7] chore: publish package via GitHub Packages (#23) * chore: publish package via GitHub Packages Add repository, publishConfig and author to package.json, add MIT LICENSE file and switch release workflow to GitHub Packages with GITHUB_TOKEN. Co-Authored-By: Claude Sonnet 5.5 * chore: bump version to 3.5.1 Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Sonnet 5.5 --- .github/workflows/release.yml | 28 ++++++++++++++++------------ LICENSE | 21 +++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 11 +++++++++-- 4 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 LICENSE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e64845b..3eb7276 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,10 @@ on: tags: - 'v*' +permissions: + contents: write + packages: write + jobs: release: name: Create Release @@ -21,7 +25,8 @@ jobs: with: node-version: '20.x' cache: 'npm' - registry-url: 'https://registry.npmjs.org' + registry-url: 'https://npm.pkg.github.com' + scope: '@techdivision' - name: Install dependencies run: npm ci @@ -70,28 +75,27 @@ jobs: ## Installation ```bash - npm install git+ssh://git@github.com:techdivision/appsheet.git#${{ github.ref_name }} + echo "@techdivision:registry=https://npm.pkg.github.com" >> .npmrc + npm install @techdivision/appsheet@${{ steps.version.outputs.VERSION }} ``` draft: false prerelease: ${{ contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') }} - - name: Publish to npm + - name: Publish to GitHub Packages if: "!contains(github.ref, '-')" - run: npm publish --access public + run: npm publish env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - continue-on-error: true + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish pre-release to npm + - name: Publish pre-release to GitHub Packages if: contains(github.ref, '-alpha') || contains(github.ref, '-beta') || contains(github.ref, '-rc') run: | if [[ "${{ github.ref }}" == *"-alpha"* ]]; then - npm publish --access public --tag alpha + npm publish --tag alpha elif [[ "${{ github.ref }}" == *"-beta"* ]]; then - npm publish --access public --tag beta + npm publish --tag beta elif [[ "${{ github.ref }}" == *"-rc"* ]]; then - npm publish --access public --tag rc + npm publish --tag rc fi env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - continue-on-error: true + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6ad4fff --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TechDivision GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index d1141f4..c3a2913 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@techdivision/appsheet", - "version": "3.5.0", + "version": "3.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/appsheet", - "version": "3.5.0", + "version": "3.5.1", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", diff --git a/package.json b/package.json index c353ffb..28fb613 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/appsheet", - "version": "3.5.0", + "version": "3.5.1", "description": "Generic TypeScript library for AppSheet CRUD operations", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -36,8 +36,15 @@ "typescript", "mcp" ], - "author": "", + "author": "TechDivision GmbH", "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/techdivision/appsheet.git" + }, + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "devDependencies": { "@types/jest": "^29.0.0", "@types/node": "^20.0.0", From 343d262ebf10062c5ff478215e4d54834283153e Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:49:09 +0200 Subject: [PATCH 2/7] test: cover retry policy for reads and single-shot mutations Co-Authored-By: Claude Sonnet 5.5 --- tests/client/AppSheetClient.retry.test.ts | 185 ++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/client/AppSheetClient.retry.test.ts diff --git a/tests/client/AppSheetClient.retry.test.ts b/tests/client/AppSheetClient.retry.test.ts new file mode 100644 index 0000000..b2c535f --- /dev/null +++ b/tests/client/AppSheetClient.retry.test.ts @@ -0,0 +1,185 @@ +/** + * Test Suite: AppSheetClient - retry policy + * + * Verifies that only read access (Find) is retried and that mutations + * (Add, Edit, Delete) are sent exactly once, because a repeated write after a + * lost response could create duplicates or apply a change twice. + * + * @module tests/client + */ + +import axios from 'axios'; +import { AppSheetClient } from '../../src/client/AppSheetClient'; +import { + AppSheetError, + ConnectionDefinition, + NetworkError, + ValidationError, +} from '../../src/types'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const mockAxiosInstance = { post: jest.fn() }; + +const baseDefinition: ConnectionDefinition = { + appId: 'test-app-id', + applicationAccessKey: 'test-key', + tables: {}, +}; + +/** Builds an error shaped like an AxiosError. */ +function axiosError(options: { status?: number; code?: string; message?: string }): Error { + const error: any = new Error(options.message ?? 'request failed'); + error.isAxiosError = true; + error.code = options.code; + if (options.status !== undefined) { + error.response = { status: options.status, data: { error: 'server said no' } }; + } + return error; +} + +const serverError = () => axiosError({ status: 500 }); +const timeoutError = () => axiosError({ code: 'ECONNABORTED', message: 'timeout of 30000ms exceeded' }); +const networkError = () => axiosError({ code: 'ECONNRESET', message: 'socket hang up' }); + +function createClient(overrides: Partial = {}): AppSheetClient { + const client = new AppSheetClient({ ...baseDefinition, ...overrides }, 'user@example.com'); + // Replace the real backoff so tests do not wait 1s/2s. + jest.spyOn(client as any, 'sleep').mockResolvedValue(undefined); + return client; +} + +describe('AppSheetClient - retry policy', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.create.mockReturnValue(mockAxiosInstance as any); + mockedAxios.isAxiosError.mockImplementation((e: any) => !!e && e.isAxiosError === true); + }); + + describe('mutations are never retried', () => { + const mutations: Array<[string, (client: AppSheetClient) => Promise]> = [ + ['add', (c) => c.add({ tableName: 'T', rows: [{ a: 1 }] })], + ['update', (c) => c.update({ tableName: 'T', rows: [{ id: '1' }] })], + ['delete', (c) => c.delete({ tableName: 'T', rows: [{ id: '1' }] })], + ]; + const failures: Array<[string, () => Error, new (...args: any[]) => Error]> = [ + ['HTTP 500', serverError, AppSheetError], + ['timeout', timeoutError, NetworkError], + ['network error', networkError, NetworkError], + ]; + + for (const [operation, run] of mutations) { + for (const [label, makeError, expectedType] of failures) { + it(`sends ${operation} exactly once on ${label}`, async () => { + mockAxiosInstance.post.mockRejectedValue(makeError()); + const client = createClient({ retryAttempts: 5 }); + + await expect(run(client)).rejects.toBeInstanceOf(expectedType); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect((client as any).sleep).not.toHaveBeenCalled(); + }); + } + } + + it('keeps the converted API error for a failed mutation with status 500', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient(); + + await expect(client.add({ tableName: 'T', rows: [{}] })).rejects.toMatchObject({ + code: 'API_ERROR', + statusCode: 500, + }); + }); + }); + + describe('find retries up to retryAttempts', () => { + it('defaults to 3 attempts in total', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient(); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(AppSheetError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + expect((client as any).sleep).toHaveBeenNthCalledWith(1, 1000); + expect((client as any).sleep).toHaveBeenNthCalledWith(2, 2000); + }); + + it('does not retry when retryAttempts is 1', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient({ retryAttempts: 1 }); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(AppSheetError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('uses a configured retryAttempts of 5', async () => { + mockAxiosInstance.post.mockRejectedValue(timeoutError()); + const client = createClient({ retryAttempts: 5 }); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(NetworkError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(5); + }); + + it('caps the backoff delay at 10 seconds', async () => { + mockAxiosInstance.post.mockRejectedValue(networkError()); + const client = createClient({ retryAttempts: 6 }); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(NetworkError); + + const delays = ((client as any).sleep as jest.Mock).mock.calls.map((c) => c[0]); + expect(delays).toEqual([1000, 2000, 4000, 8000, 10000]); + }); + + it('returns the result once a retry succeeds', async () => { + mockAxiosInstance.post + .mockRejectedValueOnce(serverError()) + .mockResolvedValueOnce({ data: { Rows: [{ id: '1' }] } }); + const client = createClient(); + + const result = await client.find({ tableName: 'T' }); + + expect(result.rows).toEqual([{ id: '1' }]); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(2); + }); + + it('retries findAll and findOne as they use find internally', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient({ retryAttempts: 2 }); + + await expect(client.findAll('T')).rejects.toBeInstanceOf(AppSheetError); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(2); + + mockAxiosInstance.post.mockClear(); + await expect(client.findOne('T', '[a] = 1')).rejects.toBeInstanceOf(AppSheetError); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(2); + }); + + it('never retries a 4xx response', async () => { + mockAxiosInstance.post.mockRejectedValue(axiosError({ status: 400 })); + const client = createClient({ retryAttempts: 5 }); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(ValidationError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + }); + }); + + describe('retryAttempts validation', () => { + it.each([0, -1, 1.5, NaN, Infinity])('rejects %p', (value) => { + expect(() => createClient({ retryAttempts: value })).toThrow(ValidationError); + expect(() => createClient({ retryAttempts: value })).toThrow(/retryAttempts/); + }); + + it('rejects a non-number value', () => { + expect(() => createClient({ retryAttempts: '3' as any })).toThrow(ValidationError); + }); + + it.each([1, 3, 10])('accepts %p', (value) => { + expect(() => createClient({ retryAttempts: value })).not.toThrow(); + }); + }); +}); From a9196c4ac5329ab584258694173d9140f0bf8836 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:49:09 +0200 Subject: [PATCH 3/7] feat: add retryAttempts to ConnectionDefinition and never retry mutations BREAKING BEHAVIOR (non-API): add, update and delete are no longer retried automatically because they are not idempotent. find keeps retrying, now bounded by the configurable retryAttempts (default 3). Co-Authored-By: Claude Sonnet 5.5 --- src/client/AppSheetClient.ts | 58 +++++++++++++++++++++++++++++++----- src/types/config.ts | 7 ++++- src/types/schema.ts | 15 ++++++++++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/client/AppSheetClient.ts b/src/client/AppSheetClient.ts index 6db8465..8817faf 100644 --- a/src/client/AppSheetClient.ts +++ b/src/client/AppSheetClient.ts @@ -29,6 +29,29 @@ import { } from '../types'; import { SelectorBuilder } from '../utils/SelectorBuilder'; +const DEFAULT_RETRY_ATTEMPTS = 3; + +/** Total attempts for requests that must be sent exactly once (mutations). */ +const RETRY_NEVER = 1; + +/** + * Validates the configured attempt count and applies the default. + * + * Fails at construction time so that a misconfiguration surfaces immediately and + * not as a silently skipped request loop on the first call. + */ +function resolveRetryAttempts(configured: number | undefined): number { + if (configured === undefined) { + return DEFAULT_RETRY_ATTEMPTS; + } + if (!Number.isInteger(configured) || configured < 1) { + throw new ValidationError( + `Invalid retryAttempts: expected an integer >= 1 (total attempts including the first), got ${String(configured)}` + ); + } + return configured; +} + /** * AppSheet API client for performing CRUD operations on AppSheet tables. * @@ -78,6 +101,7 @@ export class AppSheetClient implements AppSheetClientInterface { * @param connectionDef - Full connection definition including app credentials and table schemas * @param runAsUserEmail - Email of the user to execute all operations as (required) * @param selectorBuilder - Optional custom SelectorBuilder for DI/AOP extensibility (defaults to SelectorBuilder) + * @throws {ValidationError} If `connectionDef.retryAttempts` is not an integer >= 1 * * @example * ```typescript @@ -98,7 +122,7 @@ export class AppSheetClient implements AppSheetClientInterface { ) { this.connectionDef = connectionDef; this.runAsUserEmail = runAsUserEmail; - this.retryAttempts = 3; // Default retry attempts + this.retryAttempts = resolveRetryAttempts(connectionDef.retryAttempts); this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); // Apply defaults @@ -119,6 +143,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Add (Create) one or more rows to a table. * + * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * * @template T - The type of the rows being added * @param options - Options for the add operation * @returns Promise resolving to the created rows with server-generated fields @@ -147,7 +173,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload); + const response = await this.request>(url, payload, RETRY_NEVER); return { rows: response.Rows || [], @@ -158,6 +184,9 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Find (Read) rows from a table with optional filtering. * + * Network errors, timeouts and 5xx responses are retried up to + * `ConnectionDefinition.retryAttempts` total attempts (default 3). + * * @template T - The type of the rows being retrieved * @param options - Options for the find operation * @returns Promise resolving to the found rows @@ -194,7 +223,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: [], }; - const response = await this.request>(url, payload); + const response = await this.request>(url, payload, this.retryAttempts); return { rows: response.Rows || [], @@ -205,6 +234,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Update (Edit) one or more rows in a table. * + * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * * Rows must include the key field (primary key) to identify which row to update. * * @template T - The type of the rows being updated @@ -235,7 +266,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload); + const response = await this.request>(url, payload, RETRY_NEVER); return { rows: response.Rows || [], @@ -246,6 +277,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Delete one or more rows from a table. * + * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * * Rows must include the key field (primary key) to identify which row to delete. * * @template T - The type of the rows being deleted @@ -276,7 +309,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload); + const response = await this.request>(url, payload, RETRY_NEVER); return { success: true, @@ -417,11 +450,20 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Execute request with retry logic and error handling. * + * The caller states the total attempts allowed instead of the client deciding + * globally: only Find is safe to repeat, while a repeated Add/Edit/Delete after a + * lost response could duplicate or double-apply a change. + * * Handles both response formats from AppSheet API: * - Standard format: { Rows: [...], Warnings?: [...] } * - Direct array format: [...] */ - private async request(url: string, payload: any, attempt = 1): Promise { + private async request( + url: string, + payload: any, + maxAttempts: number, + attempt = 1 + ): Promise { try { const response = await this.axios.post(url, payload); @@ -440,13 +482,13 @@ export class AppSheetClient implements AppSheetClientInterface { // Retry on network errors or 5xx server errors if ( - attempt < this.retryAttempts && + attempt < maxAttempts && (this.isRetryableError(axiosError) || this.isServerError(axiosError)) ) { // Exponential backoff const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); await this.sleep(delay); - return this.request(url, payload, attempt + 1); + return this.request(url, payload, maxAttempts, attempt + 1); } // Convert to appropriate error type diff --git a/src/types/config.ts b/src/types/config.ts index 20ae520..bb42f74 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -25,7 +25,12 @@ export interface AppSheetConfig { /** Request timeout in milliseconds (default: 30000) */ timeout?: number; - /** Number of retry attempts for failed requests (default: 3) */ + /** + * Not evaluated by the client. + * + * Retries are configured via `ConnectionDefinition.retryAttempts`, which only + * affects read requests (mutations are never retried). + */ retryAttempts?: number; /** Optional email of user to run all operations as (can be overridden per operation) */ diff --git a/src/types/schema.ts b/src/types/schema.ts index dd2e09c..d78ba1a 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -170,6 +170,21 @@ export interface ConnectionDefinition { /** Optional request timeout */ timeout?: number; + /** + * Optional total number of attempts for read requests (Find), including the first one. + * + * Defaults to 3; `1` disables retries. Must be an integer >= 1, otherwise the + * client constructor throws a `ValidationError`. + * + * Applies to read access only (`find`, `findAll`, `findOne` and everything built + * on them). `add`, `update` and `delete` are never retried: they are not + * idempotent, and repeating one after a lost response could create duplicates or + * apply a change twice. + * + * @default 3 + */ + retryAttempts?: number; + /** Optional global user email for all operations on this connection */ runAsUserEmail?: string; From 6ca10548e260f23d409aa4136c03135a339ee7f3 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:49:09 +0200 Subject: [PATCH 4/7] chore: release 3.6.0 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 18 ++++++++++++++++++ CLAUDE.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7078f..288c829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.6.0] - 2026-10-09 + +### Added + +- **`retryAttempts` in `ConnectionDefinition`** + - Optional total number of attempts for read requests, including the first one (default `3`, `1` = no retry) + - Validated in the `AppSheetClient` constructor: must be an integer >= 1, otherwise a `ValidationError` is thrown + - Passed through `AppSheetClientFactory` unchanged, as the client receives the whole `ConnectionDefinition` + +### Changed + +- **BEHAVIOR CHANGE: `add`, `update` and `delete` are no longer retried automatically** + - Network errors, timeouts and 5xx responses on a mutation are now thrown immediately (as `NetworkError` / `AppSheetError`) after a single request + - Reason: mutations are not idempotent, so a retry after a lost response could create duplicate rows or apply a change twice + - There is no switch to restore the old behavior; callers that need a retry must implement it themselves with their own idempotency handling + - `find`, `findAll` and `findOne` keep retrying with exponential backoff (1 s, 2 s, ... max 10 s), now bounded by `retryAttempts` instead of a hardcoded 3 +- `AppSheetConfig.retryAttempts` (deprecated since 3.0.0) was never evaluated; its documentation now says so and points to `ConnectionDefinition.retryAttempts` + ## [3.5.0] - 2026-03-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index e5d61c8..b16e056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,7 +395,7 @@ All errors extend `AppSheetError` with specific subtypes: - `RateLimitError` (429) - `NetworkError` (no response) -Retry logic applies to network errors and 5xx server errors (max 3 attempts by default). +Retry logic applies to read requests only (`find`, `findAll`, `findOne`): network errors, timeouts and 5xx server errors are retried up to `ConnectionDefinition.retryAttempts` total attempts (default 3, `1` = no retry). `add`, `update` and `delete` are never retried automatically because they are not idempotent. ## AppSheet API Details diff --git a/package-lock.json b/package-lock.json index c3a2913..22a4a51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@techdivision/appsheet", - "version": "3.5.1", + "version": "3.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@techdivision/appsheet", - "version": "3.5.1", + "version": "3.6.0", "license": "MIT", "dependencies": { "@types/uuid": "^10.0.0", diff --git a/package.json b/package.json index 28fb613..0b052ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@techdivision/appsheet", - "version": "3.5.1", + "version": "3.6.0", "description": "Generic TypeScript library for AppSheet CRUD operations", "main": "dist/index.js", "types": "dist/index.d.ts", From 67d8b010cf13061b8a49d155a2a07fb712f91ac3 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:56:44 +0200 Subject: [PATCH 5/7] feat: make write retries opt-out via retryWrites instead of removing them Default stays true so existing users see no behavior change. retryWrites: false sends add/update/delete exactly once, since mutations are not idempotent. Co-Authored-By: Claude Sonnet 5.5 --- src/client/AppSheetClient.ts | 53 ++++++++++++---- src/types/config.ts | 4 +- src/types/schema.ts | 26 +++++--- tests/client/AppSheetClient.retry.test.ts | 75 ++++++++++++++++++++--- 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/src/client/AppSheetClient.ts b/src/client/AppSheetClient.ts index 8817faf..382470c 100644 --- a/src/client/AppSheetClient.ts +++ b/src/client/AppSheetClient.ts @@ -31,8 +31,8 @@ import { SelectorBuilder } from '../utils/SelectorBuilder'; const DEFAULT_RETRY_ATTEMPTS = 3; -/** Total attempts for requests that must be sent exactly once (mutations). */ -const RETRY_NEVER = 1; +/** Total attempts for a request that must be sent exactly once. */ +const SINGLE_ATTEMPT = 1; /** * Validates the configured attempt count and applies the default. @@ -52,6 +52,24 @@ function resolveRetryAttempts(configured: number | undefined): number { return configured; } +/** + * Validates the optional `retryWrites` flag. + * + * A truthy non-boolean (e.g. the string "false" from an env file) would silently + * keep write retries on, which is the opposite of what the caller meant. + */ +function resolveRetryWrites(configured: unknown): boolean { + if (configured === undefined) { + return true; + } + if (typeof configured !== 'boolean') { + throw new ValidationError( + `Invalid retryWrites: expected a boolean, got ${typeof configured} (${String(configured)})` + ); + } + return configured; +} + /** * AppSheet API client for performing CRUD operations on AppSheet tables. * @@ -93,6 +111,7 @@ export class AppSheetClient implements AppSheetClientInterface { private readonly connectionDef: ConnectionDefinition; private readonly runAsUserEmail: string; private readonly retryAttempts: number; + private readonly retryWrites: boolean; private readonly selectorBuilder: SelectorBuilderInterface; /** @@ -102,6 +121,7 @@ export class AppSheetClient implements AppSheetClientInterface { * @param runAsUserEmail - Email of the user to execute all operations as (required) * @param selectorBuilder - Optional custom SelectorBuilder for DI/AOP extensibility (defaults to SelectorBuilder) * @throws {ValidationError} If `connectionDef.retryAttempts` is not an integer >= 1 + * or `connectionDef.retryWrites` is not a boolean * * @example * ```typescript @@ -123,6 +143,7 @@ export class AppSheetClient implements AppSheetClientInterface { this.connectionDef = connectionDef; this.runAsUserEmail = runAsUserEmail; this.retryAttempts = resolveRetryAttempts(connectionDef.retryAttempts); + this.retryWrites = resolveRetryWrites(connectionDef.retryWrites); this.selectorBuilder = selectorBuilder ?? new SelectorBuilder(); // Apply defaults @@ -143,7 +164,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Add (Create) one or more rows to a table. * - * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there + * for why disabling is recommended for mutations. * * @template T - The type of the rows being added * @param options - Options for the add operation @@ -173,7 +195,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload, RETRY_NEVER); + const response = await this.request>(url, payload, this.writeAttempts); return { rows: response.Rows || [], @@ -234,7 +256,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Update (Edit) one or more rows in a table. * - * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there + * for why disabling is recommended for mutations. * * Rows must include the key field (primary key) to identify which row to update. * @@ -266,7 +289,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload, RETRY_NEVER); + const response = await this.request>(url, payload, this.writeAttempts); return { rows: response.Rows || [], @@ -277,7 +300,8 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Delete one or more rows from a table. * - * Sent exactly once: never retried automatically, see `ConnectionDefinition.retryAttempts`. + * Retried like Find unless `ConnectionDefinition.retryWrites` is `false`; see there + * for why disabling is recommended for mutations. * * Rows must include the key field (primary key) to identify which row to delete. * @@ -309,7 +333,7 @@ export class AppSheetClient implements AppSheetClientInterface { Rows: options.rows, }; - const response = await this.request>(url, payload, RETRY_NEVER); + const response = await this.request>(url, payload, this.writeAttempts); return { success: true, @@ -450,9 +474,9 @@ export class AppSheetClient implements AppSheetClientInterface { /** * Execute request with retry logic and error handling. * - * The caller states the total attempts allowed instead of the client deciding - * globally: only Find is safe to repeat, while a repeated Add/Edit/Delete after a - * lost response could duplicate or double-apply a change. + * The caller states the total attempts allowed: Find is always safe to repeat, + * whereas a repeated Add/Edit/Delete after a lost response could duplicate or + * double-apply a change, so mutations may opt out via `retryWrites`. * * Handles both response formats from AppSheet API: * - Standard format: { Rows: [...], Warnings?: [...] } @@ -500,6 +524,13 @@ export class AppSheetClient implements AppSheetClientInterface { } } + /** + * Total attempts for add/update/delete. + */ + private get writeAttempts(): number { + return this.retryWrites ? this.retryAttempts : SINGLE_ATTEMPT; + } + /** * Check if error is retryable (network error) */ diff --git a/src/types/config.ts b/src/types/config.ts index bb42f74..b82f383 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -28,8 +28,8 @@ export interface AppSheetConfig { /** * Not evaluated by the client. * - * Retries are configured via `ConnectionDefinition.retryAttempts`, which only - * affects read requests (mutations are never retried). + * Retries are configured via `ConnectionDefinition.retryAttempts` and + * `ConnectionDefinition.retryWrites`. */ retryAttempts?: number; diff --git a/src/types/schema.ts b/src/types/schema.ts index d78ba1a..ec53dab 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -171,20 +171,32 @@ export interface ConnectionDefinition { timeout?: number; /** - * Optional total number of attempts for read requests (Find), including the first one. + * Optional total number of attempts per request, including the first one. * * Defaults to 3; `1` disables retries. Must be an integer >= 1, otherwise the - * client constructor throws a `ValidationError`. - * - * Applies to read access only (`find`, `findAll`, `findOne` and everything built - * on them). `add`, `update` and `delete` are never retried: they are not - * idempotent, and repeating one after a lost response could create duplicates or - * apply a change twice. + * client constructor throws a `ValidationError`. Applies to reads, and to + * mutations as long as {@link ConnectionDefinition.retryWrites} is not `false`. * * @default 3 */ retryAttempts?: number; + /** + * Optional switch for retrying mutations (`add`, `update`, `delete`). + * + * Defaults to `true`, which keeps the behavior of earlier versions. Reads (`find` + * and everything built on it) are always retried regardless of this flag. + * + * Mutations are not idempotent: if a request timed out or failed with 5xx after + * AppSheet had already processed it, a retry writes twice (duplicate rows, a + * change applied again). Setting `false` is therefore recommended for mutations; + * they are then sent exactly once and the error is thrown immediately. Must be a + * boolean, otherwise the client constructor throws a `ValidationError`. + * + * @default true + */ + retryWrites?: boolean; + /** Optional global user email for all operations on this connection */ runAsUserEmail?: string; diff --git a/tests/client/AppSheetClient.retry.test.ts b/tests/client/AppSheetClient.retry.test.ts index b2c535f..7bd14b5 100644 --- a/tests/client/AppSheetClient.retry.test.ts +++ b/tests/client/AppSheetClient.retry.test.ts @@ -1,9 +1,10 @@ /** * Test Suite: AppSheetClient - retry policy * - * Verifies that only read access (Find) is retried and that mutations - * (Add, Edit, Delete) are sent exactly once, because a repeated write after a - * lost response could create duplicates or apply a change twice. + * Verifies that Find is always retried, that mutations keep being retried by + * default (no behavior change), and that retryWrites: false sends mutations + * exactly once, because a repeated write after a lost response could create + * duplicates or apply a change twice. * * @module tests/client */ @@ -40,7 +41,8 @@ function axiosError(options: { status?: number; code?: string; message?: string } const serverError = () => axiosError({ status: 500 }); -const timeoutError = () => axiosError({ code: 'ECONNABORTED', message: 'timeout of 30000ms exceeded' }); +const timeoutError = () => + axiosError({ code: 'ECONNABORTED', message: 'timeout of 30000ms exceeded' }); const networkError = () => axiosError({ code: 'ECONNRESET', message: 'socket hang up' }); function createClient(overrides: Partial = {}): AppSheetClient { @@ -57,7 +59,7 @@ describe('AppSheetClient - retry policy', () => { mockedAxios.isAxiosError.mockImplementation((e: any) => !!e && e.isAxiosError === true); }); - describe('mutations are never retried', () => { + describe('retryWrites: false sends mutations exactly once', () => { const mutations: Array<[string, (client: AppSheetClient) => Promise]> = [ ['add', (c) => c.add({ tableName: 'T', rows: [{ a: 1 }] })], ['update', (c) => c.update({ tableName: 'T', rows: [{ id: '1' }] })], @@ -73,7 +75,7 @@ describe('AppSheetClient - retry policy', () => { for (const [label, makeError, expectedType] of failures) { it(`sends ${operation} exactly once on ${label}`, async () => { mockAxiosInstance.post.mockRejectedValue(makeError()); - const client = createClient({ retryAttempts: 5 }); + const client = createClient({ retryAttempts: 5, retryWrites: false }); await expect(run(client)).rejects.toBeInstanceOf(expectedType); @@ -85,7 +87,7 @@ describe('AppSheetClient - retry policy', () => { it('keeps the converted API error for a failed mutation with status 500', async () => { mockAxiosInstance.post.mockRejectedValue(serverError()); - const client = createClient(); + const client = createClient({ retryWrites: false }); await expect(client.add({ tableName: 'T', rows: [{}] })).rejects.toMatchObject({ code: 'API_ERROR', @@ -94,6 +96,45 @@ describe('AppSheetClient - retry policy', () => { }); }); + describe('mutations are retried by default (unchanged behavior)', () => { + const mutations: Array<[string, (client: AppSheetClient) => Promise]> = [ + ['add', (c) => c.add({ tableName: 'T', rows: [{ a: 1 }] })], + ['update', (c) => c.update({ tableName: 'T', rows: [{ id: '1' }] })], + ['delete', (c) => c.delete({ tableName: 'T', rows: [{ id: '1' }] })], + ]; + + for (const [operation, run] of mutations) { + it(`retries ${operation} 3 times by default on HTTP 500`, async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient(); + + await expect(run(client)).rejects.toBeInstanceOf(AppSheetError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + }); + + it(`retries ${operation} up to retryAttempts when retryWrites is true`, async () => { + mockAxiosInstance.post.mockRejectedValue(timeoutError()); + const client = createClient({ retryAttempts: 4, retryWrites: true }); + + await expect(run(client)).rejects.toBeInstanceOf(NetworkError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(4); + }); + } + + it('does not retry a 4xx mutation even with retries enabled', async () => { + mockAxiosInstance.post.mockRejectedValue(axiosError({ status: 400 })); + const client = createClient(); + + await expect(client.add({ tableName: 'T', rows: [{}] })).rejects.toBeInstanceOf( + ValidationError + ); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + }); + }); + describe('find retries up to retryAttempts', () => { it('defaults to 3 attempts in total', async () => { mockAxiosInstance.post.mockRejectedValue(serverError()); @@ -146,6 +187,15 @@ describe('AppSheetClient - retry policy', () => { expect(mockAxiosInstance.post).toHaveBeenCalledTimes(2); }); + it('still retries find when retryWrites is false', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient({ retryWrites: false }); + + await expect(client.find({ tableName: 'T' })).rejects.toBeInstanceOf(AppSheetError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(3); + }); + it('retries findAll and findOne as they use find internally', async () => { mockAxiosInstance.post.mockRejectedValue(serverError()); const client = createClient({ retryAttempts: 2 }); @@ -182,4 +232,15 @@ describe('AppSheetClient - retry policy', () => { expect(() => createClient({ retryAttempts: value })).not.toThrow(); }); }); + + describe('retryWrites validation', () => { + it.each(['false', 0, 1, null])('rejects non-boolean %p', (value) => { + expect(() => createClient({ retryWrites: value as any })).toThrow(ValidationError); + expect(() => createClient({ retryWrites: value as any })).toThrow(/retryWrites/); + }); + + it.each([true, false])('accepts %p', (value) => { + expect(() => createClient({ retryWrites: value })).not.toThrow(); + }); + }); }); From 1f76793e694a5a45a4830320355c6473b15af16c Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:56:44 +0200 Subject: [PATCH 6/7] docs: document retryAttempts and retryWrites for 3.6.0 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 19 +++++++++++-------- CLAUDE.md | 2 +- README.md | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288c829..d299b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.6.0] - 2026-10-09 +No behavior change for existing users: without the new options the client behaves exactly as in 3.5.x. + ### Added - **`retryAttempts` in `ConnectionDefinition`** - - Optional total number of attempts for read requests, including the first one (default `3`, `1` = no retry) + - Optional total number of attempts per request, including the first one (default `3`, `1` = no retry) - Validated in the `AppSheetClient` constructor: must be an integer >= 1, otherwise a `ValidationError` is thrown - - Passed through `AppSheetClientFactory` unchanged, as the client receives the whole `ConnectionDefinition` + - Replaces the previously hardcoded 3 attempts; passed through `AppSheetClientFactory` unchanged +- **`retryWrites` in `ConnectionDefinition`** + - Optional boolean, default `true` (mutations are retried as before) + - With `false`, `add`, `update` and `delete` are sent exactly once and network errors, timeouts and 5xx are thrown immediately as `NetworkError` / `AppSheetError` + - Recommended for mutations: they are not idempotent, so a retry after a timeout or 5xx can write twice (duplicate rows, a change applied again) + - Reads (`find`, `findAll`, `findOne`) are always retried, bounded by `retryAttempts` + - A non-boolean value throws a `ValidationError` in the constructor ### Changed -- **BEHAVIOR CHANGE: `add`, `update` and `delete` are no longer retried automatically** - - Network errors, timeouts and 5xx responses on a mutation are now thrown immediately (as `NetworkError` / `AppSheetError`) after a single request - - Reason: mutations are not idempotent, so a retry after a lost response could create duplicate rows or apply a change twice - - There is no switch to restore the old behavior; callers that need a retry must implement it themselves with their own idempotency handling - - `find`, `findAll` and `findOne` keep retrying with exponential backoff (1 s, 2 s, ... max 10 s), now bounded by `retryAttempts` instead of a hardcoded 3 -- `AppSheetConfig.retryAttempts` (deprecated since 3.0.0) was never evaluated; its documentation now says so and points to `ConnectionDefinition.retryAttempts` +- `AppSheetConfig.retryAttempts` (deprecated since 3.0.0) was never evaluated; its documentation now says so and points to the `ConnectionDefinition` options ## [3.5.0] - 2026-03-12 diff --git a/CLAUDE.md b/CLAUDE.md index b16e056..273606d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -395,7 +395,7 @@ All errors extend `AppSheetError` with specific subtypes: - `RateLimitError` (429) - `NetworkError` (no response) -Retry logic applies to read requests only (`find`, `findAll`, `findOne`): network errors, timeouts and 5xx server errors are retried up to `ConnectionDefinition.retryAttempts` total attempts (default 3, `1` = no retry). `add`, `update` and `delete` are never retried automatically because they are not idempotent. +Retry logic applies to network errors, timeouts and 5xx server errors, up to `ConnectionDefinition.retryAttempts` total attempts (default 3, `1` = no retry). Mutations (`add`, `update`, `delete`) are retried by default as before; set `ConnectionDefinition.retryWrites: false` (recommended) to send them exactly once, since they are not idempotent and a retry after a timeout can write twice. Reads are always retried. ## AppSheet API Details diff --git a/README.md b/README.md index e7c1f58..c198e9e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,23 @@ await client.updateOne('Users', { id: '123', name: 'John Updated' }); await client.deleteOne('Users', { id: '123' }); ``` +## Retries + +Network errors, timeouts and 5xx responses are retried with exponential backoff (1 s, 2 s, ... max 10 s). Both options live in the `ConnectionDefinition`: + +- `retryAttempts` - total attempts including the first one (default `3`, `1` = no retry, integer >= 1) +- `retryWrites` - retry `add`, `update` and `delete` (default `true`, unchanged behavior). Mutations are not idempotent: a retry after a timeout or 5xx can write twice. Set `retryWrites: false` (recommended) to send mutations exactly once. `find` is always retried. + +```typescript +const connectionDef = { + appId: '...', + applicationAccessKey: '...', + retryAttempts: 3, + retryWrites: false, + tables: { /* ... */ }, +}; +``` + ## Multi-Instance Support Manage multiple AppSheet apps in one project: From c46f788ca1f45e00a008b73e7368e34d2d5a4a33 Mon Sep 17 00:00:00 2001 From: Tim Wagner Date: Fri, 9 Oct 2026 17:59:28 +0200 Subject: [PATCH 7/7] docs: note literal values for retry options; test default write backoff Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ tests/client/AppSheetClient.retry.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/README.md b/README.md index c198e9e..2c51db2 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ Network errors, timeouts and 5xx responses are retried with exponential backoff - `retryAttempts` - total attempts including the first one (default `3`, `1` = no retry, integer >= 1) - `retryWrites` - retry `add`, `update` and `delete` (default `true`, unchanged behavior). Mutations are not idempotent: a retry after a timeout or 5xx can write twice. Set `retryWrites: false` (recommended) to send mutations exactly once. `find` is always retried. +In YAML/JSON schemas use literal values (`true`/`false`, numbers), not `${ENV}` placeholders: the schema loader delivers those as strings, which results in a `ValidationError`. + ```typescript const connectionDef = { appId: '...', diff --git a/tests/client/AppSheetClient.retry.test.ts b/tests/client/AppSheetClient.retry.test.ts index 7bd14b5..6dd2b14 100644 --- a/tests/client/AppSheetClient.retry.test.ts +++ b/tests/client/AppSheetClient.retry.test.ts @@ -123,6 +123,19 @@ describe('AppSheetClient - retry policy', () => { }); } + it('backs off 1s and 2s between default write attempts', async () => { + mockAxiosInstance.post.mockRejectedValue(serverError()); + const client = createClient(); + + await expect(client.add({ tableName: 'T', rows: [{}] })).rejects.toBeInstanceOf( + AppSheetError + ); + + expect((client as any).sleep).toHaveBeenCalledTimes(2); + expect((client as any).sleep).toHaveBeenNthCalledWith(1, 1000); + expect((client as any).sleep).toHaveBeenNthCalledWith(2, 2000); + }); + it('does not retry a 4xx mutation even with retries enabled', async () => { mockAxiosInstance.post.mockRejectedValue(axiosError({ status: 400 })); const client = createClient();