@server-driven-impact/tanstack-query 0.5.0-cache-contract.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # @server-driven-impact/tanstack-query
2
+
3
+ ## 0.4.1
4
+
5
+ - Add validated, deduplicated TanStack Query invalidation execution.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Woohyun Park
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,31 @@
1
+ # @server-driven-impact/tanstack-query
2
+
3
+ [English](./README.md) | [한국어](./README.ko.md)
4
+
5
+ 서버 소유 무효화 응답을 검증하고 exact/partial 지시의 합집합을 한 번의 호출로 TanStack Query에 적용합니다.
6
+
7
+ ```ts
8
+ import { applyCacheInvalidations } from '@server-driven-impact/tanstack-query';
9
+
10
+ await applyCacheInvalidations(queryClient, response.cacheInvalidation, {
11
+ contract: {id: 'company-api', version: 1}, scope: currentTenant,
12
+ });
13
+ ```
14
+
15
+ 캐시를 건드리기 전에 계약과 요청 당시 scope가 현재 값과 같은지 검사합니다. 일치하는 active/inactive query를 모두 invalidate하고 기본 refetch 대상은 `active`입니다. mutation 완료에 active refetch 완료까지 포함하려면 반환 Promise를 기다리십시오. 별도로 보관하면 이미 커밋된 mutation과 후속 refetch 실패를 구분할 수 있습니다.
16
+
17
+ `@tanstack/query-core`는 peer dependency입니다. `QueryClient`와 캐시 생명주기는 애플리케이션이 소유합니다.
18
+
19
+ ## 실행기의 보장 범위
20
+
21
+ - 실제 TanStack `matchQuery`로 exact hash/중첩 부분 키를 매칭합니다. scope/계약이 다르면 취소·무효화 전에 거절합니다.
22
+ - `refetchType` 기본값은 `active`, `none`은 stale 표시만 합니다. disabled/static query는 TanStack의 재조회 제외 규칙을 따릅니다.
23
+ - `cancelRefetch`는 TanStack에 전달하지만, 캐시 데이터가 없는 **최초 조회**를 취소한다고 보장하지 않습니다. 기본 동작에서는 수정 전에 시작한 응답을 재사용할 수 있습니다.
24
+ - `cancelInFlight:true`는 일치하는 query를 먼저 `cancelQueries`로 취소합니다. 최초 조회의 뒤늦은 결과도 버리고 active query를 다시 시작할 수 있습니다. 실제 네트워크 요청 중단에는 queryFn의 AbortSignal 사용도 필요합니다. inactive/disabled query를 무조건 다시 시작하지는 않습니다.
25
+ - `throwOnError` 기본값은 `true`이며 refetch 실패 시 반환 Promise가 reject됩니다. 이미 커밋된 업무 작업의 실패를 의미하지 않습니다. `false`면 refetch 오류를 전파하지 않습니다.
26
+
27
+ 비동기 mutation callback 순서, 동시 command 조정, optimistic rollback, 로그인 응답 차단, artifact/persisted cache 정리는 앱 책임입니다. callback을 먼저 await한 뒤 무효화를 적용하고, 업무 성공과 callback/refetch 실패는 별도로 처리하십시오.
28
+
29
+ 요청 당시의 session generation과 QueryClient를 보관하고 응답 및 callback 이후에도 현재 generation인지 검사합니다. 같은 사용자로 재로그인해도 scope 문자열 비교만으로는 구분할 수 없습니다. 로그아웃/로그인 시 기존 client를 폐기·정리하고 이전 응답을 막아야 합니다. 서버 artifact/계약 변경 시 client reset과 persistence/hydration buster도 앱에서 관리합니다.
30
+
31
+ 후속 캐시 오류 때문에 command를 재실행하거나 커밋된 optimistic 상태를 rollback하지 마십시오. [통합 가이드](../../docs/migrations/cache-contract-0.5.md)에 예제가 있습니다. 최초 조회 경합은 실제 QueryObserver로 취소 유무 두 경로를 검증합니다.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @server-driven-impact/tanstack-query
2
+
3
+ [English](./README.md) | [한국어](./README.ko.md)
4
+
5
+ Validates a server-owned invalidation response and applies its exact/partial union to TanStack Query in one call.
6
+
7
+ ```ts
8
+ import { applyCacheInvalidations } from '@server-driven-impact/tanstack-query';
9
+
10
+ await applyCacheInvalidations(queryClient, response.cacheInvalidation, {
11
+ contract: {id: 'company-api', version: 1}, scope: currentTenant,
12
+ });
13
+ ```
14
+
15
+ The contract and request-time scope must match before the cache is touched. Matching active and inactive queries are invalidated; the default refetch target is `active`. Await the returned promise when mutation completion should include active refetch completion, or retain it separately to distinguish a committed mutation from a later refetch failure.
16
+
17
+ `@tanstack/query-core` is a peer dependency. The application owns the `QueryClient` and its cache lifecycle.
18
+
19
+ ## Execution boundaries
20
+
21
+ - Matching uses TanStack's own `matchQuery`, including exact hashing and partial nested keys. Scope/version mismatch rejects before cancellation or invalidation. Payloads are bounded JSON; they contain no executable predicate.
22
+ - `refetchType` defaults to `active`; `none` only marks stale. Disabled/static queries follow TanStack's refetch exclusions. This does not guarantee all views have refreshed.
23
+ - `cancelRefetch` is forwarded to TanStack. It does **not** reliably cancel an initial fetch without cached data. By default that in-flight result may be reused and may have started before the mutation.
24
+ - Opt into `cancelInFlight:true` to call `cancelQueries` for the matching union before invalidating it. This also cancels first fetches, discards their eventual results, and permits active queries to restart. Physical network cancellation additionally requires the query function to consume its AbortSignal. Inactive/disabled reads do not automatically restart.
25
+ - `throwOnError` defaults to `true`: an awaited refetch error rejects this function, **not** the already committed business operation. `throwOnError:false` suppresses refetch errors. Cancellation due to other app activity is not proof of fresh data.
26
+
27
+ The executor does not schedule mutation callbacks, serialize concurrent commands, manage optimistic rollback, block auth responses, or clear persisted/artifact caches. Await asynchronous application callbacks **before** applying invalidations. Keep command success and callback/refetch errors in separate handling paths.
28
+
29
+ Capture the request's session generation and QueryClient. After awaiting the response and callbacks, verify that generation is still current. On logout/login—even for the same user ID—retire and clear the old client and reject its responses. Scope equality alone does not detect such a change. Artifact/contract changes require an app-owned client reset and persistence/hydration buster; no automatic cache reset is performed here.
30
+
31
+ Do not nest cache-refresh failure into a mutation retry/rollback path. A generic integration example and local prerelease instructions are in the [migration guide](../../docs/migrations/cache-contract-0.5.md). Regression tests exercise real QueryObserver first-fetch overlap both with and without cancellation.
@@ -0,0 +1,21 @@
1
+ import { type Scalar } from '@server-driven-impact/core';
2
+ import { type CacheContractReference, type CacheInvalidationSet } from '@server-driven-impact/cache-contract';
3
+ import { type QueryClient } from '@tanstack/query-core';
4
+ export type RefetchType = 'active' | 'inactive' | 'all' | 'none';
5
+ export type QueryClientLike = Pick<QueryClient, 'invalidateQueries' | 'cancelQueries'>;
6
+ export interface ApplyCacheInvalidationsOptions {
7
+ contract: CacheContractReference;
8
+ scope: Scalar;
9
+ refetchType?: RefetchType;
10
+ cancelRefetch?: boolean;
11
+ /** Also cancels first reads that have no cached data. Default: false. */
12
+ cancelInFlight?: boolean;
13
+ /** Propagate refetch failures separately from the already committed command. Default: true. */
14
+ throwOnError?: boolean;
15
+ }
16
+ export declare function validateCacheInvalidationSet(payload: unknown, expected: Pick<ApplyCacheInvalidationsOptions, 'contract' | 'scope'>): asserts payload is CacheInvalidationSet;
17
+ /**
18
+ * Invalidates the union of all instructions through one TanStack Query call.
19
+ * Await the returned promise when mutation completion must include active refetches.
20
+ */
21
+ export declare function applyCacheInvalidations(queryClient: QueryClientLike, payload: unknown, options: ApplyCacheInvalidationsOptions): Promise<void>;
package/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ // @ts-self-types="./index.d.ts"
2
+ import { byteLength, canonical, isScalar } from '@server-driven-impact/core';
3
+ import { CACHE_LIMITS, isCacheValue } from '@server-driven-impact/cache-contract';
4
+ import { matchQuery } from '@tanstack/query-core';
5
+ function exactMatch(candidate, filter) {
6
+ try {
7
+ return canonical(candidate) === canonical(filter);
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export function validateCacheInvalidationSet(payload, expected) {
14
+ if (!payload || typeof payload !== 'object')
15
+ throw new Error('UNSUPPORTED_CACHE_INVALIDATION_PROTOCOL');
16
+ const candidate = payload;
17
+ validatePayload(candidate, expected);
18
+ }
19
+ function validatePayload(payload, expected) {
20
+ if (!payload || payload.protocolVersion !== 1)
21
+ throw new Error('UNSUPPORTED_CACHE_INVALIDATION_PROTOCOL');
22
+ if (typeof payload.contractId !== 'string' || !payload.contractId.length || !Number.isSafeInteger(payload.contractVersion) || payload.contractVersion < 1) {
23
+ throw new Error('INVALID_CACHE_CONTRACT_REFERENCE');
24
+ }
25
+ if (!isScalar(payload.scope) || !isScalar(expected.scope))
26
+ throw new Error('INVALID_CACHE_SCOPE');
27
+ if (payload.contractId !== expected.contract.id || payload.contractVersion !== expected.contract.version) {
28
+ throw new Error('CACHE_CONTRACT_MISMATCH');
29
+ }
30
+ if (!exactMatch(payload.scope, expected.scope))
31
+ throw new Error('CACHE_SCOPE_MISMATCH');
32
+ if (!Array.isArray(payload.invalidations) || payload.invalidations.length > CACHE_LIMITS.invalidations)
33
+ throw new Error('INVALID_CACHE_INVALIDATIONS');
34
+ for (const invalidation of payload.invalidations) {
35
+ if (!invalidation || !Array.isArray(invalidation.queryKey) || !invalidation.queryKey.length || !isCacheValue(invalidation.queryKey) || typeof invalidation.exact !== 'boolean') {
36
+ throw new Error('INVALID_CACHE_INVALIDATION');
37
+ }
38
+ }
39
+ if (byteLength(payload) > CACHE_LIMITS.responseBytes)
40
+ throw new Error('CACHE_INVALIDATION_BYTE_LIMIT');
41
+ }
42
+ /**
43
+ * Invalidates the union of all instructions through one TanStack Query call.
44
+ * Await the returned promise when mutation completion must include active refetches.
45
+ */
46
+ export async function applyCacheInvalidations(queryClient, payload, options) {
47
+ validateCacheInvalidationSet(payload, options);
48
+ const unique = structuredClone([...new Map(payload.invalidations.map(value => [`${value.exact}:${canonical(value.queryKey)}`, value])).values()]);
49
+ if (!unique.length)
50
+ return;
51
+ const predicate = query => unique.some(invalidation => matchQuery(invalidation, query));
52
+ if (options.cancelInFlight)
53
+ await queryClient.cancelQueries({ predicate });
54
+ await queryClient.invalidateQueries({
55
+ predicate,
56
+ refetchType: options.refetchType ?? 'active',
57
+ }, { cancelRefetch: options.cancelRefetch, throwOnError: options.throwOnError ?? true });
58
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@server-driven-impact/tanstack-query",
3
+ "version": "0.5.0-cache-contract.2",
4
+ "description": "TanStack Query executor for server-owned SDI invalidation instructions.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "types": "./dist/index.d.ts",
9
+ "module": "./dist/index.js",
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "README.ko.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "dependencies": {
24
+ "@server-driven-impact/cache-contract": "^0.5.0-cache-contract.2",
25
+ "@server-driven-impact/core": "^0.5.0-cache-contract.2"
26
+ },
27
+ "peerDependencies": {
28
+ "@tanstack/query-core": "^5.100.14"
29
+ },
30
+ "engines": {
31
+ "node": ">=22.18"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/woohyun-park/server-driven-impact.git",
39
+ "directory": "packages/sdi-tanstack-query"
40
+ },
41
+ "homepage": "https://github.com/woohyun-park/server-driven-impact/tree/main/packages/sdi-tanstack-query#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/woohyun-park/server-driven-impact/issues"
44
+ },
45
+ "keywords": [
46
+ "cache",
47
+ "invalidation",
48
+ "tanstack-query"
49
+ ],
50
+ "scripts": {
51
+ "build": "node ../../scripts/backend/clean-sdi-package.mjs && tsc -p tsconfig.json && node ../../scripts/backend/annotate-sdi-package.mjs",
52
+ "typecheck": "tsc -p tsconfig.check.json"
53
+ }
54
+ }