@server-driven-impact/cache-contract 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/cache-contract
2
+
3
+ ## 0.4.1
4
+
5
+ - Add versioned cache contracts, query-key construction, OpenAPI metadata, and ImpactSet conversion.
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,55 @@
1
+ # @server-driven-impact/cache-contract
2
+
3
+ [English](./README.md) | [한국어](./README.ko.md)
4
+
5
+ SDI endpoint, OpenAPI operation, 정규화 입력, 캐시 키의 관계를 버전된 계약으로 정의합니다. 브라우저 캐시를 조회하지 않고 `ImpactSet`을 데이터 전용 무효화 지시로 변환합니다.
6
+
7
+ ```ts
8
+ import { buildQueryKey, defineCacheContract } from '@server-driven-impact/cache-contract';
9
+
10
+ const contract = defineCacheContract({
11
+ id: 'company-api', version: 1,
12
+ queries: [{
13
+ operationId: 'getOrder', endpoint: 'orders.detail', kind: 'query',
14
+ input: {id: {type: 'string', required: true, exclude: ['list']}},
15
+ key: {prefix: ['orders'], path: ['id']}, fallback: ['orders'],
16
+ }],
17
+ });
18
+
19
+ buildQueryKey(contract, 'getOrder', {id: 'one'}); // ['orders', 'one']
20
+ ```
21
+
22
+ `cacheContractOpenApiExtension(contract)`의 결과를 루트 `x-sdi-cache` 확장 값으로 사용합니다. 생성 클라이언트는 mutation 요청에 `{id, version}`을 전달합니다. 서버의 `createCacheContractRegistry()`는 지원하지 않는 버전을 거절하며 `compileCacheInvalidations()`는 `{contractId, contractVersion, scope, invalidations}`를 만듭니다.
23
+
24
+ 결과에 영향을 주는 identity 입력은 모두 선언하고 키에 포함해야 합니다. 선언하지 않은 입력은 버리지 않고 거절합니다. 기존 `prefix/path/params`와 중첩 선언형 template을 지원합니다. `fallback`은 생성되는 모든 키를 포함하는 TanStack 부분 키여야 하며 등록 시 검증합니다. query/infinite 키가 충돌할 가능성이 있으면 거절합니다.
25
+
26
+ `['orders', 'list', params]` 같은 키에서는 params 객체가 존재할 때 특정 필드가 반드시 있다는 입력 검증이 있을 때만 `paramsAnchor`를 지정합니다. 이 증명이 있으면 protocol-1의 missing-field 의미를 좁은 부분 키로 보존할 수 있고, 없으면 변환기는 안전하게 `fallback`으로 넓힙니다.
27
+
28
+ ## 중첩 키와 입력
29
+
30
+ `key: {template: ...}`에 `literal`, `input`, `inputs`, `array`, `object` 노드를 조합합니다. oRPC 의존성이나 앱별 compiler 없이 기존 `[path, {input, type:'query'}]` 키를 표현할 수 있습니다. 실제 코드 예제는 [영문 README](./README.md#nested-keys-and-inputs), 전환 절차는 [통합 가이드](../../docs/migrations/cache-contract-0.5.md)에 있습니다.
31
+
32
+ - 배열: `{type:'array',items:...,order:'preserve'|'set'}`. 기본은 순서·중복 보존입니다. 명시적 `set`만 canonical JSON 기준 정렬·중복 제거를 합니다.
33
+ - 객체: `{type:'object',properties:...}`. 중첩 필드도 모두 선언하며 알 수 없는 필드는 거절합니다.
34
+ - 생략된 optional 필드는 없습니다. `default`를 선언한 경우에만 채웁니다. `null`은 `nullable:true`로 허용하며 생략과 구별합니다.
35
+ - 날짜는 timezone이 명시된 값만 받아 UTC ISO로 바꿉니다. `format:'uuid'`는 검증 후 소문자로 바꿉니다.
36
+
37
+ `prepareCacheQuery(contract, operationId, input)`의 `input`을 API에 전달하고 `queryKey`를 캐시에 사용하십시오. `normalizeCacheInput`도 제공합니다. 키만 정규화해서 요청 의미와 어긋나게 해서는 안 됩니다. 기존 키 유지가 목적이면 기존 API와 같은 정규화 정책을 선택합니다.
38
+
39
+ Infinite query는 `type:'infinite'` 같은 별도 literal로 구분합니다. `input`은 목록 identity, `pageInput`은 실행 전용 cursor/page 필드입니다. `buildQueryExecutionInput(contract, operationId, identity, page)`는 정규화한 실행 입력을 합칩니다. `buildQueryKey`는 page 전용 필드를 거절하며 모든 페이지가 목록 키 하나를 공유합니다.
40
+
41
+ ## 누락 검증과 정밀도
42
+
43
+ `validateCacheContractCoverage(contract, [{endpoint, cache:'cacheable'|'no-store'}])`로 생성·시작 단계에 검증합니다. Runtime은 등록된 **각 계약**에 자동 적용합니다. 모든 endpoint는 query 또는 `excludedEndpoints:[{endpoint,reason:'no-store'|'not-consumed'}]`로 명시해야 합니다. no-store 조회에는 재사용 키를 등록할 수 없습니다. 계약에 없는 impact endpoint는 `CACHE_ENDPOINT_UNCOVERED` 오류이며 명시적으로 제외한 경우에만 생략합니다.
44
+
45
+ `compileCacheInvalidations`의 네 번째 인자 또는 runtime의 `cacheInvalidationOptions`에 `{explain, maxInvalidations, maxBytes}`를 전달합니다. 진단에는 endpoint/operation, selector 필드명, 확대 이유와 필터 수만 포함하며 사용자 값은 넣지 않습니다. 실제 범위는 반환된 filters와 함께 확인합니다. callback이 던진 오류도 커밋 후 실패로 처리되므로 로깅 callback은 예외를 던지지 않아야 합니다.
46
+
47
+ 주요 확대 이유는 `missing-field`, `comparison-unproven`, `page-input`, `input-unrepresentable`, `endpoint-policy`, `budget`입니다. Protocol 1은 생략 필드와 보수적 DB equality까지 매칭합니다. 따라서 unrestricted string은 대소문자/후행 공백 때문에 `required:true`만으로 정밀 변환하지 않습니다. 숫자·boolean·정규화된 UUID 또는 일치 값이 하나인 유한 `enum`처럼 비교를 증명할 수 있어야 합니다. optional/default 필드는 보통 fallback으로 넓어집니다.
48
+
49
+ Runtime은 필수·비-coerce 문자열에서 입력 필드가 그대로 전달되고, 직접 Query equality binding과 실제 컬럼의 exact 비교에 대한 adapter 검증이 모두 있으면 자동으로 정밀화합니다. Runtime은 매 요청에서 parser 전후 값을 검사합니다. 독립 `compileCacheInvalidations()`는 runtime/DB 증명이 없으므로 보수적인 동작을 유지합니다. DB 비교가 미지원이거나 검증 전이면 `comparison-unproven`으로 확대합니다.
50
+
51
+ `paramsAnchor:['id']`는 `{page:2}`를 유효 입력으로 받지 않는 경우에만 사용합니다. 키 준비 함수가 이를 검사하지만, 기존 외부 캐시까지 보증하지는 않습니다. 잘못된 기존 키는 먼저 지우거나 이전해야 합니다. anchor가 있어도 optional page selector나 증명할 수 없는 문자열 비교는 넓힙니다.
52
+
53
+ Endpoint 대신 domain 단위 fallback도 지정할 수 있습니다. 예: `[['data','profile']]`. `invalidation:'endpoint'`는 항상 fallback을 선택합니다. 예산 초과 시 지시를 자르지 않고 넓히며, 최소 안전 지시도 담지 못하면 오류로 알립니다.
54
+
55
+ 배열·객체 **키 표현**을 지원하는 것이 ImpactSet v1의 IN/range/중첩 selector 추론을 추가한다는 뜻은 아닙니다. 해당 정밀도는 후속 범위이며 현재는 안전한 fallback을 유지합니다.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @server-driven-impact/cache-contract
2
+
3
+ [English](./README.md) | [한국어](./README.ko.md)
4
+
5
+ Defines a versioned relationship between SDI endpoints, OpenAPI operations, normalized inputs, and cache keys. It converts an `ImpactSet` into data-only invalidation instructions; it does not inspect a browser cache.
6
+
7
+ ```ts
8
+ import { buildQueryKey, defineCacheContract } from '@server-driven-impact/cache-contract';
9
+
10
+ const contract = defineCacheContract({
11
+ id: 'company-api', version: 1,
12
+ queries: [{
13
+ operationId: 'getOrder', endpoint: 'orders.detail', kind: 'query',
14
+ input: {id: {type: 'string', required: true, exclude: ['list']}},
15
+ key: {prefix: ['orders'], path: ['id']}, fallback: ['orders'],
16
+ }],
17
+ });
18
+
19
+ buildQueryKey(contract, 'getOrder', {id: 'one'}); // ['orders', 'one']
20
+ ```
21
+
22
+ Use `cacheContractOpenApiExtension(contract)` as the value of a root-level `x-sdi-cache` extension. Generated clients send `{id, version}` with mutations. On the server, `createCacheContractRegistry()` rejects unsupported versions and `compileCacheInvalidations()` returns `{contractId, contractVersion, scope, invalidations}`.
23
+
24
+ Every result-affecting identity input must be declared and represented in the key. Unknown input fields are rejected, not dropped. Flat `prefix/path/params` and recursive declarative templates are supported. `fallback` must be a TanStack partial key proven to cover every generated key. Potentially colliding query/infinite templates are rejected.
25
+
26
+ For a key such as `['orders', 'list', params]`, use `paramsAnchor` only when input validation guarantees those fields whenever the params object exists. This proof lets protocol-1 missing-field matching preserve a narrow partial key; otherwise conversion safely widens to `fallback`.
27
+
28
+ ## Nested keys and inputs
29
+
30
+ No oRPC dependency or application-specific compiler is needed for its key shape:
31
+
32
+ ```ts
33
+ const rpc = defineCacheContract({
34
+ id: 'rpc', version: 1,
35
+ queries: [{
36
+ operationId: 'profile', endpoint: 'profile.read', kind: 'query',
37
+ input: {userId: {type: 'string', required: true}},
38
+ key: {template: {kind: 'array', items: [
39
+ {kind: 'literal', value: ['data', 'profile', 'read']},
40
+ {kind: 'object', fields: {
41
+ input: {kind: 'inputs'},
42
+ type: {kind: 'literal', value: 'query'},
43
+ }},
44
+ ]}},
45
+ fallback: [['data', 'profile', 'read'], {type: 'query'}],
46
+ }],
47
+ });
48
+ // [['data', 'profile', 'read'], {input: {userId: 'user-a'}, type: 'query'}]
49
+ buildQueryKey(rpc, 'profile', {userId: 'user-a'});
50
+ ```
51
+
52
+ Nodes are `literal`, `input` (one top-level field, including a structured value), `inputs` (all or named fields), `object`, and `array`. An omitted optional object field is absent; null remains null. Use `inputs.omitEmpty` when an empty input object should be absent. Optional array elements must not shift positions.
53
+
54
+ Recursive fields use `{type:'array', items:..., order:'preserve'|'set'}` and `{type:'object', properties:...}`. Arrays preserve order and duplicates by default; explicit `set` deduplicates and sorts by canonical JSON. Object property ordering does not affect identity; unknown nested fields are rejected. Optional fields stay absent unless a default is declared; null requires `nullable:true`. Dates require an explicit timezone and become ISO UTC. `format:'uuid'` validates and lowercases strings.
55
+
56
+ Use `prepareCacheQuery(contract, operationId, input)` and send its **input** to the API while using its **queryKey**. `normalizeCacheInput` is also exported. Do not normalize only the key when the request's meaning would differ. Preserving existing keys requires choosing normalization that matches the existing API.
57
+
58
+ Infinite operations use a separate literal such as `type:'infinite'` and may declare `pageInput` alongside identity `input`. `buildQueryExecutionInput(contract, operationId, identity, page)` merges both normalized inputs for the request; `buildQueryKey` rejects page-only fields. All pages share the infinite query key. Page fields in an impact widen to fallback.
59
+
60
+ ## Coverage and diagnostics
61
+
62
+ `validateCacheContractCoverage(contract, [{endpoint, cache:'cacheable'|'no-store'}])` validates an inventory at startup/generation. The runtime does this automatically for **each** configured contract. Every endpoint must have a query or an explicit `excludedEndpoints: [{endpoint, reason:'no-store'|'not-consumed'}]` entry. No-store reads cannot receive reusable keys. Unknown impact endpoints throw `CACHE_ENDPOINT_UNCOVERED`; explicit exclusions alone may produce no instructions.
63
+
64
+ Pass `{explain(diagnostic), maxInvalidations, maxBytes}` to `compileCacheInvalidations`, or `cacheInvalidationOptions` to the runtime. Diagnostics contain endpoint/operation, selector field names, reason, and filter count—not user values. Reasons include `missing-field`, `comparison-unproven`, `page-input`, `input-unrepresentable`, `endpoint-policy`, and `budget`. Compare these with the returned filters when debugging; do not log raw keys in production. An explain callback must not throw; failures remain explicit post-commit errors.
65
+
66
+ Protocol 1 matches absent fields and conservatively handles database equality. Unrestricted strings can match different case/trailing-space keys, so precise conversion requires a proven domain (numbers, booleans, normalized UUIDs, or a finite `enum` with one matching value). A mere `required:true` string is not sufficient. Optional/defaulted fields generally widen. `invalidation:'endpoint'` deliberately always uses fallback. A domain prefix such as `[['data','profile']]` may be used as fallback if it covers every operation key.
67
+
68
+ The runtime automatically refines required, non-coerced string inputs when it has an unchanged input field, a direct Query equality binding, and adapter validation of the live column's exact comparison semantics. Runtime enforces the unchanged input on every request. Standalone `compileCacheInvalidations()` remains conservative because it has no runtime/database proof. Unsupported or unvalidated comparison reports `comparison-unproven`.
69
+
70
+ `paramsAnchor:['id']` is appropriate for `['orders','list',{id,page?}]` only if `{page:2}` is invalid. Key preparation enforces this. Declaring an anchor that older cached requests do not honor is unsafe: clear/migrate them first. Selecting optional `page` still widens even with an ID anchor. Anchors do not prove string comparison semantics.
71
+
72
+ Structured **key identity** does not add IN/range/nested selector inference to ImpactSet v1. Unsupported precision widens safely; it never silently removes an endpoint or truncates instructions. If even broad instructions exceed the budget, compilation throws.
@@ -0,0 +1,36 @@
1
+ import { type ImpactSet, type Scalar } from '@server-driven-impact/core';
2
+ import type { CacheCompileOptions, CacheContract, CacheContractReference, CacheEndpoint, CacheInvalidationSet, CacheValue, QueryKey, StringComparisonStatus } from './types.js';
3
+ export type * from './types.js';
4
+ export { isCacheValue } from './input.js';
5
+ export declare const CACHE_LIMITS: Readonly<{
6
+ contracts: 32;
7
+ queries: 512;
8
+ invalidations: 512;
9
+ contractBytes: 1048576;
10
+ responseBytes: 131072;
11
+ }>;
12
+ export declare function defineCacheContract<const T extends CacheContract>(contract: T): T;
13
+ /** Inventory is supplied by a runtime or SDK generator, not inferred from impacts. */
14
+ export declare function validateCacheContractCoverage(candidate: CacheContract, endpoints: readonly CacheEndpoint[]): void;
15
+ export declare function normalizeCacheInput(contract: CacheContract, operationId: string, input: Record<string, unknown>): Record<string, CacheValue>;
16
+ /** Send this normalized input to the API as well as using its queryKey. */
17
+ export declare function prepareCacheQuery(contract: CacheContract, operationId: string, input: Record<string, unknown>): {
18
+ input: Record<string, CacheValue>;
19
+ queryKey: QueryKey;
20
+ };
21
+ export declare function buildQueryKey(contract: CacheContract, operationId: string, input: Record<string, unknown>): QueryKey;
22
+ export declare function buildQueryExecutionInput(contract: CacheContract, operationId: string, input: Record<string, unknown>, pageInput: Record<string, unknown>): Record<string, CacheValue>;
23
+ type StringComparison = (endpoint: string, input: string) => StringComparisonStatus;
24
+ export declare function validateCacheCompileOptions(options: CacheCompileOptions): void;
25
+ export declare function compileCacheInvalidations(candidate: CacheContract, impact: ImpactSet, scope: Scalar, options?: CacheCompileOptions): CacheInvalidationSet;
26
+ export declare function cacheContractOpenApiExtension(candidate: CacheContract): Readonly<Record<string, unknown>>;
27
+ export interface CacheContractRegistry {
28
+ resolve(reference: CacheContractReference): CacheContract;
29
+ compile(reference: CacheContractReference, impact: ImpactSet, scope: Scalar): CacheInvalidationSet;
30
+ }
31
+ export declare function createCacheContractRegistry(contracts: readonly CacheContract[], options?: {
32
+ endpoints?: readonly CacheEndpoint[];
33
+ compile?: CacheCompileOptions;
34
+ /** Runtime-owned evidence. Ordinary consumers should use compileCacheInvalidations. */
35
+ stringComparison?: StringComparison;
36
+ }): CacheContractRegistry;
package/dist/index.js ADDED
@@ -0,0 +1,262 @@
1
+ // @ts-self-types="./index.d.ts"
2
+ import { byteLength, canonical, isScalar, LIMITS } from '@server-driven-impact/core';
3
+ import { isCacheValue, isRecord, normalizeFields, own, selectorValue, validateFields } from './input.js';
4
+ import { renderKey, validateFallback, validateKeySeparation, validateTemplate } from './template.js';
5
+ export { isCacheValue } from './input.js';
6
+ export const CACHE_LIMITS = Object.freeze({ contracts: 32, queries: 512, invalidations: 512, contractBytes: 1_048_576, responseBytes: 131_072 });
7
+ const validated = new WeakSet();
8
+ function deepFreeze(value) {
9
+ if (value && typeof value === 'object') {
10
+ Object.freeze(value);
11
+ for (const child of Object.values(value))
12
+ deepFreeze(child);
13
+ }
14
+ return value;
15
+ }
16
+ function name(value, code) {
17
+ if (typeof value !== 'string' || !value.length)
18
+ throw new Error(code);
19
+ }
20
+ export function defineCacheContract(contract) {
21
+ if (validated.has(contract))
22
+ return contract;
23
+ if (!isCacheValue(contract))
24
+ throw new Error('INVALID_CACHE_CONTRACT_JSON');
25
+ name(contract.id, 'INVALID_CACHE_CONTRACT_ID');
26
+ if (!Number.isSafeInteger(contract.version) || contract.version < 1)
27
+ throw new Error('INVALID_CACHE_CONTRACT_VERSION');
28
+ if (!Array.isArray(contract.queries))
29
+ throw new Error('CACHE_QUERIES_REQUIRED');
30
+ if (contract.queries.length > CACHE_LIMITS.queries)
31
+ throw new Error('CACHE_QUERY_LIMIT');
32
+ if (byteLength(contract) > CACHE_LIMITS.contractBytes)
33
+ throw new Error('CACHE_CONTRACT_BYTE_LIMIT');
34
+ const snapshot = structuredClone(contract);
35
+ const operations = new Set();
36
+ for (const query of snapshot.queries) {
37
+ name(query.operationId, 'INVALID_CACHE_OPERATION_ID');
38
+ name(query.endpoint, 'INVALID_CACHE_ENDPOINT');
39
+ if (operations.has(query.operationId))
40
+ throw new Error('DUPLICATE_CACHE_OPERATION:' + query.operationId);
41
+ operations.add(query.operationId);
42
+ if (!['query', 'infinite'].includes(query.kind))
43
+ throw new Error('INVALID_CACHE_QUERY_KIND');
44
+ if (query.invalidation !== undefined && !['preserve', 'endpoint'].includes(query.invalidation))
45
+ throw new Error('INVALID_CACHE_INVALIDATION_POLICY');
46
+ validateFields(query.input);
47
+ if (query.pageInput) {
48
+ if (query.kind !== 'infinite')
49
+ throw new Error('CACHE_PAGE_INPUT_REQUIRES_INFINITE');
50
+ validateFields(query.pageInput);
51
+ if (Object.keys(query.pageInput).some(field => own(query.input, field)))
52
+ throw new Error('CACHE_PAGE_INPUT_OVERLAP');
53
+ }
54
+ validateTemplate(query);
55
+ validateFallback(query);
56
+ }
57
+ validateKeySeparation(snapshot.queries);
58
+ const excluded = new Set();
59
+ if (snapshot.excludedEndpoints !== undefined && !Array.isArray(snapshot.excludedEndpoints))
60
+ throw new Error('INVALID_CACHE_EXCLUSIONS');
61
+ for (const entry of snapshot.excludedEndpoints ?? []) {
62
+ name(entry.endpoint, 'INVALID_CACHE_ENDPOINT');
63
+ if (!['no-store', 'not-consumed'].includes(entry.reason))
64
+ throw new Error('INVALID_CACHE_EXCLUSION_REASON');
65
+ if (excluded.has(entry.endpoint) || snapshot.queries.some(query => query.endpoint === entry.endpoint))
66
+ throw new Error('DUPLICATE_CACHE_ENDPOINT_POLICY:' + entry.endpoint);
67
+ excluded.add(entry.endpoint);
68
+ }
69
+ validated.add(snapshot);
70
+ return deepFreeze(snapshot);
71
+ }
72
+ /** Inventory is supplied by a runtime or SDK generator, not inferred from impacts. */
73
+ export function validateCacheContractCoverage(candidate, endpoints) {
74
+ const contract = defineCacheContract(candidate);
75
+ const inventory = new Map();
76
+ for (const endpoint of endpoints) {
77
+ name(endpoint.endpoint, 'INVALID_CACHE_ENDPOINT');
78
+ if (!['cacheable', 'no-store'].includes(endpoint.cache))
79
+ throw new Error('INVALID_CACHE_ENDPOINT_POLICY');
80
+ if (inventory.has(endpoint.endpoint))
81
+ throw new Error('DUPLICATE_CACHE_INVENTORY_ENDPOINT:' + endpoint.endpoint);
82
+ inventory.set(endpoint.endpoint, endpoint);
83
+ const excluded = contract.excludedEndpoints?.find(entry => entry.endpoint === endpoint.endpoint);
84
+ const registered = contract.queries.some(query => query.endpoint === endpoint.endpoint);
85
+ if (!excluded && !registered)
86
+ throw new Error('CACHE_ENDPOINT_UNCOVERED:' + endpoint.endpoint);
87
+ if (endpoint.cache === 'no-store' && registered)
88
+ throw new Error('CACHE_ENDPOINT_REQUIRES_NO_STORE:' + endpoint.endpoint);
89
+ if (endpoint.cache === 'cacheable' && excluded?.reason === 'no-store')
90
+ throw new Error('CACHE_ENDPOINT_NOT_NO_STORE:' + endpoint.endpoint);
91
+ }
92
+ for (const entry of [...contract.queries, ...contract.excludedEndpoints ?? []]) {
93
+ if (!inventory.has(entry.endpoint))
94
+ throw new Error('CACHE_ENDPOINT_NOT_REGISTERED:' + entry.endpoint);
95
+ }
96
+ }
97
+ function operation(contract, operationId) {
98
+ const query = defineCacheContract(contract).queries.find(candidate => candidate.operationId === operationId);
99
+ if (!query)
100
+ throw new Error('UNKNOWN_CACHE_OPERATION:' + operationId);
101
+ return query;
102
+ }
103
+ function normalize(query, input) {
104
+ const normalized = normalizeFields(query.input, input);
105
+ if (!('template' in query.key)) {
106
+ const params = query.key.params?.filter(field => own(normalized, field)) ?? [];
107
+ if (params.length && query.key.paramsAnchor?.some(field => !own(normalized, field)))
108
+ throw new Error('CACHE_PARAMS_ANCHOR_REQUIRED');
109
+ }
110
+ return normalized;
111
+ }
112
+ export function normalizeCacheInput(contract, operationId, input) {
113
+ return normalize(operation(contract, operationId), input);
114
+ }
115
+ /** Send this normalized input to the API as well as using its queryKey. */
116
+ export function prepareCacheQuery(contract, operationId, input) {
117
+ const query = operation(contract, operationId);
118
+ const normalized = normalize(query, input);
119
+ return { input: normalized, queryKey: renderKey(query, normalized).queryKey };
120
+ }
121
+ export function buildQueryKey(contract, operationId, input) {
122
+ return prepareCacheQuery(contract, operationId, input).queryKey;
123
+ }
124
+ export function buildQueryExecutionInput(contract, operationId, input, pageInput) {
125
+ const query = operation(contract, operationId);
126
+ if (query.kind !== 'infinite')
127
+ throw new Error('CACHE_PAGE_INPUT_REQUIRES_INFINITE');
128
+ return { ...normalize(query, input), ...normalizeFields(query.pageInput ?? {}, pageInput, 'pageInput') };
129
+ }
130
+ function broad(query, reason) {
131
+ return { filters: [{ queryKey: query.fallback, exact: false }], reason };
132
+ }
133
+ function forValue(query, value, stringComparison) {
134
+ const known = Object.create(null);
135
+ const flat = 'template' in query.key ? undefined : query.key;
136
+ const anchors = flat?.paramsAnchor ?? [];
137
+ for (const [field, expected] of Object.entries(value)) {
138
+ if (query.pageInput && own(query.pageInput, field))
139
+ return broad(query, 'page-input');
140
+ if (!own(query.input, field))
141
+ return broad(query, 'input-unrepresentable');
142
+ const definition = query.input[field];
143
+ // A default can hide an omitted field, which protocol 1 must also match.
144
+ if (own(definition, 'default') || (!definition.required && !anchors.includes(field)))
145
+ return broad(query, 'missing-field');
146
+ const stringStatus = definition.type === 'string' && definition.required && !definition.coerce && definition.format !== 'uuid'
147
+ ? stringComparison?.(query.endpoint, field) : undefined;
148
+ const normalized = selectorValue(definition, expected, stringStatus === 'verified');
149
+ if (normalized === undefined)
150
+ return broad(query, 'comparison-unproven');
151
+ known[field] = normalized;
152
+ }
153
+ if (flat) {
154
+ if ((flat.path ?? []).some(field => !own(known, field)))
155
+ return broad(query, 'missing-field');
156
+ const base = [...flat.prefix, ...(flat.path ?? []).map(field => known[field])];
157
+ const selectedParams = (flat.params ?? []).filter(field => own(known, field));
158
+ if (!selectedParams.length)
159
+ return { filters: [{ queryKey: base, exact: !flat.params }], reason: 'precise' };
160
+ if (selectedParams.some(field => !query.input[field].required)) {
161
+ if (!anchors.length || !anchors.every(field => own(known, field)))
162
+ return broad(query, 'missing-field');
163
+ return { filters: [
164
+ { queryKey: flat.omitEmptyParams ? base : [...base, {}], exact: true },
165
+ { queryKey: [...base, Object.fromEntries(selectedParams.map(field => [field, known[field]]))], exact: false },
166
+ ], reason: 'precise' };
167
+ }
168
+ }
169
+ return { filters: [renderKey(query, known, true)], reason: 'precise' };
170
+ }
171
+ export function validateCacheCompileOptions(options) {
172
+ for (const [value, maximum] of [[options.maxInvalidations, CACHE_LIMITS.invalidations], [options.maxBytes, CACHE_LIMITS.responseBytes]]) {
173
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 1 || value > maximum))
174
+ throw new Error('INVALID_CACHE_COMPILE_LIMIT');
175
+ }
176
+ if (options.explain !== undefined && typeof options.explain !== 'function')
177
+ throw new Error('INVALID_CACHE_EXPLAIN');
178
+ }
179
+ function compile(candidate, impact, scope, options, stringComparison) {
180
+ const contract = defineCacheContract(candidate);
181
+ validateCacheCompileOptions(options);
182
+ if (!isScalar(scope))
183
+ throw new Error('INVALID_CACHE_SCOPE');
184
+ if (!impact || impact.protocolVersion !== 1)
185
+ throw new Error('UNSUPPORTED_IMPACT_VERSION');
186
+ if (!Array.isArray(impact.targets) || impact.targets.length > LIMITS.endpoints)
187
+ throw new Error('INVALID_IMPACT_TARGETS');
188
+ const invalidations = [];
189
+ const affected = new Set();
190
+ for (const target of impact.targets) {
191
+ if (!target || typeof target.endpoint !== 'string' || !['caller', 'global'].includes(target.scope))
192
+ throw new Error('INVALID_IMPACT_TARGET');
193
+ const queries = contract.queries.filter(query => query.endpoint === target.endpoint);
194
+ if (!queries.length) {
195
+ if (!contract.excludedEndpoints?.some(entry => entry.endpoint === target.endpoint))
196
+ throw new Error('CACHE_ENDPOINT_UNCOVERED:' + target.endpoint);
197
+ options.explain?.({ endpoint: target.endpoint, reason: 'excluded', filterCount: 0 });
198
+ continue;
199
+ }
200
+ const selector = target.selector;
201
+ // Mirror protocol-1's fail-open selector matching, never silently drop it.
202
+ const values = selector?.kind === 'inputs' && Array.isArray(selector.values) && selector.values.length > 0 &&
203
+ selector.values.length <= LIMITS.selectors && selector.values.every(value => isRecord(value) && Object.values(value).every(isScalar))
204
+ ? selector.values : undefined;
205
+ for (const query of queries) {
206
+ affected.add(query);
207
+ const decisions = query.invalidation === 'endpoint' ? [broad(query, 'endpoint-policy')]
208
+ : values ? values.map(value => forValue(query, value, stringComparison)) : [broad(query, 'all')];
209
+ for (const decision of decisions) {
210
+ invalidations.push(...decision.filters);
211
+ options.explain?.({ endpoint: query.endpoint, operationId: query.operationId, reason: decision.reason,
212
+ selectorKind: values ? 'inputs' : 'all', selectorFields: values ? [...new Set(values.flatMap(Object.keys))].sort() : [],
213
+ filterCount: decision.filters.length });
214
+ }
215
+ }
216
+ }
217
+ const unique = (values) => [...new Map(values.map(value => [value.exact + ':' + canonical(value.queryKey), value])).values()];
218
+ const payload = (values) => ({ protocolVersion: 1, contractId: contract.id, contractVersion: contract.version, scope, invalidations: unique(values) });
219
+ const fits = (value) => value.invalidations.length <= (options.maxInvalidations ?? CACHE_LIMITS.invalidations) && byteLength(value) <= (options.maxBytes ?? CACHE_LIMITS.responseBytes);
220
+ let result = payload(invalidations);
221
+ if (!fits(result)) {
222
+ result = payload([...affected].flatMap(query => {
223
+ options.explain?.({ endpoint: query.endpoint, operationId: query.operationId, reason: 'budget', filterCount: 1 });
224
+ return broad(query, 'budget').filters;
225
+ }));
226
+ }
227
+ if (!fits(result))
228
+ throw new Error('CACHE_INVALIDATION_LIMIT');
229
+ return deepFreeze(result);
230
+ }
231
+ export function compileCacheInvalidations(candidate, impact, scope, options = {}) {
232
+ return compile(candidate, impact, scope, options);
233
+ }
234
+ export function cacheContractOpenApiExtension(candidate) {
235
+ const contract = defineCacheContract(candidate);
236
+ return deepFreeze({ contractId: contract.id, contractVersion: contract.version, queries: contract.queries, excludedEndpoints: contract.excludedEndpoints ?? [] });
237
+ }
238
+ export function createCacheContractRegistry(contracts, options = {}) {
239
+ if (contracts.length > CACHE_LIMITS.contracts)
240
+ throw new Error('CACHE_CONTRACT_LIMIT');
241
+ const compileOptions = { ...options.compile };
242
+ validateCacheCompileOptions(compileOptions);
243
+ const entries = new Map();
244
+ for (const candidate of contracts) {
245
+ const contract = defineCacheContract(candidate);
246
+ if (options.endpoints)
247
+ validateCacheContractCoverage(contract, options.endpoints);
248
+ const key = contract.id + ':' + contract.version;
249
+ if (entries.has(key))
250
+ throw new Error('DUPLICATE_CACHE_CONTRACT:' + key);
251
+ entries.set(key, contract);
252
+ }
253
+ const resolve = (reference) => {
254
+ const contract = entries.get(reference.id + ':' + reference.version);
255
+ if (!contract)
256
+ throw new Error('UNSUPPORTED_CACHE_CONTRACT:' + reference.id + ':' + reference.version);
257
+ return contract;
258
+ };
259
+ return Object.freeze({ resolve, compile(reference, impact, scope) {
260
+ return compile(resolve(reference), impact, scope, compileOptions, options.stringComparison);
261
+ } });
262
+ }
@@ -0,0 +1,12 @@
1
+ import { type Scalar } from '@server-driven-impact/core';
2
+ import type { CacheInputField, CacheValue } from './types.js';
3
+ export declare const own: (value: object, name: string) => boolean;
4
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
5
+ /** Bounded JSON validation, including sparse arrays and cyclic/non-JSON input. */
6
+ export declare function isCacheValue(value: unknown, depth?: number): value is CacheValue;
7
+ export declare const OMIT: unique symbol;
8
+ export declare function normalizeField(field: CacheInputField, value: unknown, path: string): CacheValue | typeof OMIT;
9
+ export declare function normalizeFields(fields: Readonly<Record<string, CacheInputField>>, value: unknown, path?: string): Record<string, CacheValue>;
10
+ export declare function validateFields(fields: Readonly<Record<string, CacheInputField>>, depth?: number): void;
11
+ /** A sufficient proof relative to the existing conservative protocol-1 matcher. */
12
+ export declare function selectorValue(field: CacheInputField, expected: Scalar, verifiedExactString?: boolean): CacheValue | undefined;
package/dist/input.js ADDED
@@ -0,0 +1,170 @@
1
+ // @ts-self-types="./input.d.ts"
2
+ import { canonical, isScalar, matchesInputSelector } from '@server-driven-impact/core';
3
+ export const own = (value, name) => Object.hasOwn(value, name);
4
+ export const isRecord = (value) => value !== null && typeof value === 'object' &&
5
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
6
+ /** Bounded JSON validation, including sparse arrays and cyclic/non-JSON input. */
7
+ export function isCacheValue(value, depth = 0) {
8
+ if (depth > 32)
9
+ return false;
10
+ if (isScalar(value))
11
+ return true;
12
+ if (Array.isArray(value))
13
+ return Array.from(value).every(item => isCacheValue(item, depth + 1));
14
+ return isRecord(value) && Object.values(value).every(item => isCacheValue(item, depth + 1));
15
+ }
16
+ export const OMIT = Symbol('omitted');
17
+ const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
18
+ export function normalizeField(field, value, path) {
19
+ if (value === undefined) {
20
+ if (own(field, 'default'))
21
+ value = field.default;
22
+ else if (!field.required)
23
+ return OMIT;
24
+ else
25
+ throw new Error('CACHE_INPUT_REQUIRED:' + path);
26
+ }
27
+ if (value === null) {
28
+ if (!field.nullable)
29
+ throw new Error('CACHE_INPUT_NULL:' + path);
30
+ if ('enum' in field && field.enum && !field.enum.includes(null))
31
+ throw new Error('CACHE_INPUT_ENUM:' + path);
32
+ if ('exclude' in field && field.exclude?.includes(null))
33
+ throw new Error('CACHE_INPUT_EXCLUDED:' + path);
34
+ return null;
35
+ }
36
+ if (field.type === 'array') {
37
+ if (!Array.isArray(value))
38
+ throw new Error('INVALID_CACHE_INPUT:' + path);
39
+ const items = Array.from(value).map(item => {
40
+ const normalized = normalizeField(field.items, item, path + '[]');
41
+ if (normalized === OMIT)
42
+ throw new Error('CACHE_ARRAY_ITEM_REQUIRED:' + path);
43
+ return normalized;
44
+ });
45
+ if (field.order !== 'set')
46
+ return items;
47
+ return [...new Map(items.map(item => [canonical(item), item])).entries()]
48
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, item]) => item);
49
+ }
50
+ if (field.type === 'object')
51
+ return normalizeFields(field.properties, value, path);
52
+ let result;
53
+ if (field.type === 'date-time') {
54
+ // Require an explicit timezone; local-time parsing would differ across hosts.
55
+ if (!(value instanceof Date) && (typeof value !== 'string' || !/T.*(?:Z|[+-]\d{2}:\d{2})$/i.test(value))) {
56
+ throw new Error('CACHE_DATE_TIMEZONE_REQUIRED:' + path);
57
+ }
58
+ const date = value instanceof Date ? value : new Date(value);
59
+ if (!Number.isFinite(date.valueOf()))
60
+ throw new Error('INVALID_CACHE_INPUT:' + path);
61
+ result = date.toISOString();
62
+ }
63
+ else if (typeof value === field.type && isScalar(value))
64
+ result = value;
65
+ else if (field.coerce && field.type === 'number' && typeof value === 'string' && value.trim() && Number.isFinite(Number(value)))
66
+ result = Number(value);
67
+ else if (field.coerce && field.type === 'string' && (typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))))
68
+ result = String(value);
69
+ else if (field.coerce && field.type === 'boolean' && (value === 'true' || value === 'false'))
70
+ result = value === 'true';
71
+ else
72
+ throw new Error('INVALID_CACHE_INPUT:' + path);
73
+ if (field.format === 'uuid') {
74
+ if (typeof result !== 'string' || !uuid.test(result))
75
+ throw new Error('INVALID_CACHE_UUID:' + path);
76
+ result = result.toLowerCase();
77
+ }
78
+ if (field.exclude?.some(item => canonical(item) === canonical(result)))
79
+ throw new Error('CACHE_INPUT_EXCLUDED:' + path);
80
+ if (field.enum && !field.enum.some(item => canonical(item) === canonical(result)))
81
+ throw new Error('CACHE_INPUT_ENUM:' + path);
82
+ return result;
83
+ }
84
+ export function normalizeFields(fields, value, path = 'input') {
85
+ if (!isRecord(value))
86
+ throw new Error('INVALID_CACHE_INPUT:' + path);
87
+ for (const name of Object.keys(value))
88
+ if (!own(fields, name))
89
+ throw new Error('UNKNOWN_CACHE_INPUT:' + path + '.' + name);
90
+ return Object.fromEntries(Object.entries(fields).flatMap(([name, field]) => {
91
+ const normalized = normalizeField(field, own(value, name) ? value[name] : undefined, path + '.' + name);
92
+ return normalized === OMIT ? [] : [[name, normalized]];
93
+ }));
94
+ }
95
+ export function validateFields(fields, depth = 0) {
96
+ if (!isRecord(fields) || depth > 24)
97
+ throw new Error('INVALID_CACHE_INPUT_SCHEMA');
98
+ for (const [name, field] of Object.entries(fields)) {
99
+ if (!name || !isRecord(field))
100
+ throw new Error('INVALID_CACHE_INPUT_SCHEMA');
101
+ for (const flag of ['required', 'nullable', 'coerce']) {
102
+ if (own(field, flag) && typeof field[flag] !== 'boolean')
103
+ throw new Error('INVALID_CACHE_INPUT_SCHEMA');
104
+ }
105
+ if (field.type === 'array') {
106
+ if (field.order !== undefined && !['preserve', 'set'].includes(field.order))
107
+ throw new Error('INVALID_CACHE_ARRAY_ORDER');
108
+ validateFields({ item: field.items }, depth + 1);
109
+ }
110
+ else if (field.type === 'object')
111
+ validateFields(field.properties, depth + 1);
112
+ else {
113
+ if (!['string', 'number', 'boolean', 'date-time'].includes(field.type))
114
+ throw new Error('INVALID_CACHE_INPUT_TYPE');
115
+ if (field.format !== undefined && (field.type !== 'string' || field.format !== 'uuid'))
116
+ throw new Error('INVALID_CACHE_INPUT_FORMAT');
117
+ for (const values of [field.enum, field.exclude])
118
+ if (values !== undefined) {
119
+ if (!Array.isArray(values) || !values.every(isScalar))
120
+ throw new Error('INVALID_CACHE_INPUT_DOMAIN');
121
+ for (const item of values) {
122
+ const normalized = normalizeField({ ...field, enum: undefined, exclude: undefined }, item, name);
123
+ if (normalized === OMIT || canonical(normalized) !== canonical(item))
124
+ throw new Error('CACHE_INPUT_DOMAIN_NOT_NORMALIZED:' + name);
125
+ }
126
+ }
127
+ if (field.enum && !field.enum.length)
128
+ throw new Error('EMPTY_CACHE_INPUT_ENUM');
129
+ }
130
+ if (own(field, 'default')) {
131
+ if (!isCacheValue(field.default))
132
+ throw new Error('INVALID_CACHE_DEFAULT');
133
+ normalizeField(field, field.default, name);
134
+ }
135
+ }
136
+ }
137
+ /** A sufficient proof relative to the existing conservative protocol-1 matcher. */
138
+ export function selectorValue(field, expected, verifiedExactString = false) {
139
+ if (field.type === 'array' || field.type === 'object')
140
+ return undefined;
141
+ if (verifiedExactString && field.type === 'string' && typeof expected === 'string' && !field.coerce) {
142
+ if (field.enum && !field.enum.some(value => canonical(value) === canonical(expected)))
143
+ return undefined;
144
+ if (field.exclude?.some(value => canonical(value) === canonical(expected)))
145
+ return undefined;
146
+ return expected;
147
+ }
148
+ if (field.enum) {
149
+ const matches = field.enum.filter(value => matchesInputSelector({ v: value }, { kind: 'inputs', values: [{ v: expected }] }));
150
+ return matches.length === 1 ? matches[0] : undefined;
151
+ }
152
+ if (expected === null)
153
+ return field.nullable ? null : undefined;
154
+ if (field.type === 'number') {
155
+ // Numeric strings in v1 may describe numeric API inputs without coerce enabled.
156
+ if (typeof expected === 'string' && expected.trim() && Number.isFinite(Number(expected)))
157
+ return Number(expected);
158
+ return typeof expected === 'number' ? expected : undefined;
159
+ }
160
+ if (field.type === 'boolean') {
161
+ if (typeof expected === 'boolean')
162
+ return expected;
163
+ if (typeof expected === 'string' && /^(true|false)$/i.test(expected))
164
+ return expected.toLowerCase() === 'true';
165
+ }
166
+ if (field.format === 'uuid' && typeof expected === 'string' && uuid.test(expected))
167
+ return expected.toLowerCase();
168
+ // Unrestricted strings may match distinct NOCASE/RTRIM/numeric representations.
169
+ return undefined;
170
+ }
@@ -0,0 +1,9 @@
1
+ import type { CacheKeyNode, CacheQueryContract, CacheValue, QueryKey } from './types.js';
2
+ export declare function queryTemplate(query: CacheQueryContract): CacheKeyNode;
3
+ export declare function validateTemplate(query: CacheQueryContract): void;
4
+ export declare function validateKeySeparation(queries: readonly CacheQueryContract[]): void;
5
+ export declare function validateFallback(query: CacheQueryContract): void;
6
+ export declare function renderKey(query: CacheQueryContract, input: Record<string, CacheValue>, partial?: boolean): {
7
+ queryKey: QueryKey;
8
+ exact: boolean;
9
+ };
@@ -0,0 +1,209 @@
1
+ // @ts-self-types="./template.d.ts"
2
+ import { canonical } from '@server-driven-impact/core';
3
+ import { isCacheValue, normalizeField, OMIT, own } from './input.js';
4
+ export function queryTemplate(query) {
5
+ if ('template' in query.key)
6
+ return query.key.template;
7
+ const { prefix, path = [], params, omitEmptyParams } = query.key;
8
+ return { kind: 'array', items: [
9
+ ...prefix.map(value => ({ kind: 'literal', value })),
10
+ ...path.map(field => ({ kind: 'input', field })),
11
+ ...(params ? [{ kind: 'inputs', fields: params, omitEmpty: omitEmptyParams ?? false }] : []),
12
+ ] };
13
+ }
14
+ export function validateTemplate(query) {
15
+ const used = new Set();
16
+ function field(name) {
17
+ if (!own(query.input, name))
18
+ throw new Error('UNKNOWN_CACHE_KEY_INPUT:' + name);
19
+ used.add(name);
20
+ }
21
+ function walk(node, depth, inArray, last) {
22
+ if (!node || depth > 24)
23
+ throw new Error('INVALID_CACHE_KEY_TEMPLATE');
24
+ switch (node.kind) {
25
+ case 'literal':
26
+ if (!isCacheValue(node.value))
27
+ throw new Error('INVALID_CACHE_KEY_LITERAL');
28
+ break;
29
+ case 'input':
30
+ field(node.field);
31
+ if (inArray && !query.input[node.field].required && !own(query.input[node.field], 'default')) {
32
+ throw new Error('CACHE_ARRAY_INPUT_MUST_BE_REQUIRED:' + node.field);
33
+ }
34
+ break;
35
+ case 'inputs':
36
+ (node.fields ?? Object.keys(query.input)).forEach(field);
37
+ if (node.omitEmpty !== undefined && typeof node.omitEmpty !== 'boolean')
38
+ throw new Error('INVALID_CACHE_KEY_TEMPLATE');
39
+ if (inArray && node.omitEmpty && !last)
40
+ throw new Error('CACHE_OPTIONAL_ARRAY_ITEM_MUST_BE_LAST');
41
+ break;
42
+ case 'object':
43
+ if (!node.fields || Array.isArray(node.fields))
44
+ throw new Error('INVALID_CACHE_KEY_TEMPLATE');
45
+ for (const child of Object.values(node.fields))
46
+ walk(child, depth + 1, false, true);
47
+ break;
48
+ case 'array':
49
+ if (!Array.isArray(node.items))
50
+ throw new Error('INVALID_CACHE_KEY_TEMPLATE');
51
+ node.items.forEach((child, index) => walk(child, depth + 1, true, index === node.items.length - 1));
52
+ break;
53
+ default: throw new Error('INVALID_CACHE_KEY_TEMPLATE');
54
+ }
55
+ }
56
+ const template = queryTemplate(query);
57
+ if (template.kind !== 'array' || !template.items.length)
58
+ throw new Error('CACHE_KEY_ROOT_MUST_BE_ARRAY');
59
+ walk(template, 0, false, true);
60
+ for (const name of Object.keys(query.input))
61
+ if (!used.has(name))
62
+ throw new Error('CACHE_INPUT_NOT_IN_KEY:' + name);
63
+ if (!('template' in query.key)) {
64
+ const { path = [], params = [], paramsAnchor } = query.key;
65
+ if (new Set([...path, ...params]).size !== path.length + params.length)
66
+ throw new Error('DUPLICATE_CACHE_KEY_FIELD');
67
+ if (paramsAnchor && (!paramsAnchor.length || paramsAnchor.some(name => !params.includes(name))))
68
+ throw new Error('INVALID_CACHE_PARAMS_ANCHOR');
69
+ }
70
+ }
71
+ function literalShape(value) {
72
+ if (Array.isArray(value))
73
+ return { kind: 'array', items: value.map(literalShape) };
74
+ if (value && typeof value === 'object')
75
+ return { kind: 'object', fields: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, literalShape(v)])) };
76
+ return { kind: 'literal', value };
77
+ }
78
+ function shape(node, query) {
79
+ switch (node.kind) {
80
+ case 'literal': return literalShape(node.value);
81
+ case 'input': {
82
+ const schema = query.input[node.field];
83
+ return { kind: 'dynamic', schema, optional: !schema.required && !own(schema, 'default') };
84
+ }
85
+ case 'inputs': return {
86
+ kind: 'object', optional: node.omitEmpty,
87
+ fields: Object.fromEntries((node.fields ?? Object.keys(query.input)).map(name => [name, shape({ kind: 'input', field: name }, query)])),
88
+ };
89
+ case 'object': return { kind: 'object', fields: Object.fromEntries(Object.entries(node.fields).map(([k, v]) => [k, shape(v, query)])) };
90
+ case 'array': return { kind: 'array', items: node.items.map(child => shape(child, query)) };
91
+ }
92
+ }
93
+ function dynamicMayEqual(schema, value) {
94
+ try {
95
+ const result = normalizeField(schema, value, 'collision');
96
+ return result !== OMIT && canonical(result) === canonical(value);
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ function intersects(a, b) {
103
+ if (a.kind === 'dynamic' || b.kind === 'dynamic') {
104
+ const dynamic = a.kind === 'dynamic' ? a : b;
105
+ const other = a.kind === 'dynamic' ? b : a;
106
+ if (other.kind === 'literal')
107
+ return dynamicMayEqual(dynamic.schema, other.value);
108
+ if (other.kind === 'array')
109
+ return dynamic.schema.type === 'array';
110
+ if (other.kind === 'object')
111
+ return dynamic.schema.type === 'object';
112
+ return true;
113
+ }
114
+ if (a.kind !== b.kind)
115
+ return false;
116
+ if (a.kind === 'literal' && b.kind === 'literal')
117
+ return canonical(a.value) === canonical(b.value);
118
+ if (a.kind === 'array' && b.kind === 'array') {
119
+ const minA = a.items.filter(item => !item.optional).length;
120
+ const minB = b.items.filter(item => !item.optional).length;
121
+ if (minA > b.items.length || minB > a.items.length)
122
+ return false;
123
+ return a.items.every((item, i) => !b.items[i] || (item.optional && b.items[i].optional) || intersects(item, b.items[i]));
124
+ }
125
+ if (a.kind === 'object' && b.kind === 'object') {
126
+ for (const name of new Set([...Object.keys(a.fields), ...Object.keys(b.fields)])) {
127
+ const x = a.fields[name], y = b.fields[name];
128
+ if (!x || !y) {
129
+ if (!(x ?? y).optional)
130
+ return false;
131
+ }
132
+ else if (!(x.optional && y.optional) && !intersects(x, y))
133
+ return false;
134
+ }
135
+ }
136
+ return true;
137
+ }
138
+ export function validateKeySeparation(queries) {
139
+ const shapes = queries.map(query => shape(queryTemplate(query), query));
140
+ for (let i = 0; i < queries.length; i++)
141
+ for (let j = i + 1; j < queries.length; j++) {
142
+ if (intersects(shapes[i], shapes[j]))
143
+ throw new Error('CACHE_KEY_COLLISION:' + queries[i].operationId + ':' + queries[j].operationId);
144
+ }
145
+ }
146
+ function guarantees(node, filter) {
147
+ if (node.optional)
148
+ return false;
149
+ if (node.kind === 'literal')
150
+ return canonical(node.value) === canonical(filter);
151
+ if (node.kind === 'dynamic') {
152
+ return 'enum' in node.schema && !!node.schema.enum?.length &&
153
+ node.schema.enum.every(value => canonical(value) === canonical(filter));
154
+ }
155
+ if (node.kind === 'array')
156
+ return Array.isArray(filter) &&
157
+ filter.every((value, index) => !!node.items[index] && guarantees(node.items[index], value));
158
+ return !!filter && !Array.isArray(filter) && typeof filter === 'object' &&
159
+ Object.entries(filter).every(([name, value]) => !!node.fields[name] && guarantees(node.fields[name], value));
160
+ }
161
+ export function validateFallback(query) {
162
+ if (!Array.isArray(query.fallback) || !query.fallback.length || !isCacheValue(query.fallback) ||
163
+ !guarantees(shape(queryTemplate(query), query), query.fallback))
164
+ throw new Error('CACHE_FALLBACK_NOT_PROVEN:' + query.operationId);
165
+ }
166
+ const UNKNOWN = Symbol('unknown');
167
+ function render(node, query, input, partial) {
168
+ switch (node.kind) {
169
+ case 'literal': return { value: node.value, complete: true };
170
+ case 'input': return own(input, node.field)
171
+ ? { value: input[node.field], complete: true } : { value: partial ? UNKNOWN : OMIT, complete: !partial };
172
+ case 'inputs': {
173
+ const names = node.fields ?? Object.keys(query.input);
174
+ const value = Object.fromEntries(names.filter(name => own(input, name)).map(name => [name, input[name]]));
175
+ const complete = !partial || names.every(name => own(input, name));
176
+ return { value: !Object.keys(value).length && node.omitEmpty ? (partial ? UNKNOWN : OMIT) : value, complete };
177
+ }
178
+ case 'object': {
179
+ const result = Object.create(null);
180
+ let complete = true;
181
+ for (const [name, child] of Object.entries(node.fields)) {
182
+ const value = render(child, query, input, partial);
183
+ complete &&= value.complete;
184
+ if (value.value !== OMIT && value.value !== UNKNOWN)
185
+ result[name] = value.value;
186
+ }
187
+ return { value: result, complete };
188
+ }
189
+ case 'array': {
190
+ const result = [];
191
+ let complete = true;
192
+ for (const child of node.items) {
193
+ const value = render(child, query, input, partial);
194
+ complete &&= value.complete;
195
+ if (value.value === UNKNOWN)
196
+ break; // Partial arrays can express only a prefix.
197
+ if (value.value !== OMIT)
198
+ result.push(value.value);
199
+ }
200
+ return { value: result, complete };
201
+ }
202
+ }
203
+ }
204
+ export function renderKey(query, input, partial = false) {
205
+ const result = render(queryTemplate(query), query, input, partial);
206
+ if (!Array.isArray(result.value) || !result.value.length)
207
+ return { queryKey: query.fallback, exact: false };
208
+ return { queryKey: result.value, exact: result.complete };
209
+ }
@@ -0,0 +1,113 @@
1
+ import type { Scalar } from '@server-driven-impact/core';
2
+ export type CacheValue = Scalar | readonly CacheValue[] | {
3
+ readonly [key: string]: CacheValue;
4
+ };
5
+ export type QueryKey = readonly CacheValue[];
6
+ export type CacheInputType = 'string' | 'number' | 'boolean' | 'date-time' | 'array' | 'object';
7
+ interface FieldOptions {
8
+ required?: boolean;
9
+ nullable?: boolean;
10
+ default?: CacheValue;
11
+ }
12
+ export type CacheInputField = (FieldOptions & {
13
+ type: 'string' | 'number' | 'boolean' | 'date-time';
14
+ coerce?: boolean;
15
+ exclude?: readonly Scalar[];
16
+ /** Finite normalized domain; also permits exhaustive equality proofs. */
17
+ enum?: readonly Scalar[];
18
+ /** UUID input is validated and lowercased on both request and key paths. */
19
+ format?: 'uuid';
20
+ }) | (FieldOptions & {
21
+ type: 'array';
22
+ items: CacheInputField;
23
+ order?: 'preserve' | 'set';
24
+ }) | (FieldOptions & {
25
+ type: 'object';
26
+ properties: Readonly<Record<string, CacheInputField>>;
27
+ });
28
+ /** Tagged nodes avoid confusing literal user objects with template instructions. */
29
+ export type CacheKeyNode = {
30
+ kind: 'literal';
31
+ value: CacheValue;
32
+ } | {
33
+ kind: 'input';
34
+ field: string;
35
+ } | {
36
+ kind: 'inputs';
37
+ fields?: readonly string[];
38
+ omitEmpty?: boolean;
39
+ } | {
40
+ kind: 'array';
41
+ items: readonly CacheKeyNode[];
42
+ } | {
43
+ kind: 'object';
44
+ fields: Readonly<Record<string, CacheKeyNode>>;
45
+ };
46
+ export interface FlatCacheKeyTemplate {
47
+ prefix: QueryKey;
48
+ path?: readonly string[];
49
+ params?: readonly string[];
50
+ /** Required whenever a nonempty params object is present. */
51
+ paramsAnchor?: readonly string[];
52
+ omitEmptyParams?: boolean;
53
+ }
54
+ export type CacheKeyTemplate = FlatCacheKeyTemplate | {
55
+ template: CacheKeyNode;
56
+ };
57
+ export interface CacheQueryContract {
58
+ operationId: string;
59
+ endpoint: string;
60
+ kind: 'query' | 'infinite';
61
+ input: Readonly<Record<string, CacheInputField>>;
62
+ /** Execution-only page values, never accepted by buildQueryKey. Infinite only. */
63
+ pageInput?: Readonly<Record<string, CacheInputField>>;
64
+ key: CacheKeyTemplate;
65
+ /** Partial TanStack filter proven to cover all generated operation keys. */
66
+ fallback: QueryKey;
67
+ invalidation?: 'preserve' | 'endpoint';
68
+ }
69
+ export interface CacheContract {
70
+ id: string;
71
+ version: number;
72
+ queries: readonly CacheQueryContract[];
73
+ excludedEndpoints?: readonly {
74
+ endpoint: string;
75
+ reason: 'no-store' | 'not-consumed';
76
+ }[];
77
+ }
78
+ export interface CacheContractReference {
79
+ id: string;
80
+ version: number;
81
+ }
82
+ export interface CacheInvalidation {
83
+ queryKey: QueryKey;
84
+ exact: boolean;
85
+ }
86
+ export interface CacheInvalidationSet {
87
+ protocolVersion: 1;
88
+ contractId: string;
89
+ contractVersion: number;
90
+ scope: Scalar;
91
+ invalidations: readonly CacheInvalidation[];
92
+ }
93
+ export type CacheDiagnosticReason = 'all' | 'endpoint-policy' | 'precise' | 'missing-field' | 'comparison-unproven' | 'input-unrepresentable' | 'page-input' | 'budget' | 'excluded';
94
+ export interface CacheDiagnostic {
95
+ endpoint: string;
96
+ operationId?: string;
97
+ reason: CacheDiagnosticReason;
98
+ selectorKind?: 'all' | 'inputs';
99
+ selectorFields?: readonly string[];
100
+ filterCount: number;
101
+ }
102
+ export interface CacheCompileOptions {
103
+ /** Metadata only: no selector values or user input are sent to this callback. */
104
+ explain?: (diagnostic: CacheDiagnostic) => void;
105
+ maxInvalidations?: number;
106
+ maxBytes?: number;
107
+ }
108
+ export interface CacheEndpoint {
109
+ endpoint: string;
110
+ cache: 'cacheable' | 'no-store';
111
+ }
112
+ export type StringComparisonStatus = 'verified' | 'comparison-unproven';
113
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // @ts-self-types="./types.d.ts"
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@server-driven-impact/cache-contract",
3
+ "version": "0.5.0-cache-contract.2",
4
+ "description": "Versioned cache-key contracts and invalidation compilation for Server-Driven Impact.",
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/core": "^0.5.0-cache-contract.2"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.18"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/woohyun-park/server-driven-impact.git",
35
+ "directory": "packages/sdi-cache-contract"
36
+ },
37
+ "homepage": "https://github.com/woohyun-park/server-driven-impact/tree/main/packages/sdi-cache-contract#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/woohyun-park/server-driven-impact/issues"
40
+ },
41
+ "keywords": [
42
+ "cache",
43
+ "invalidation",
44
+ "openapi",
45
+ "query-key"
46
+ ],
47
+ "scripts": {
48
+ "build": "node ../../scripts/backend/clean-sdi-package.mjs && tsc -p tsconfig.json && node ../../scripts/backend/annotate-sdi-package.mjs",
49
+ "typecheck": "tsc -p tsconfig.check.json"
50
+ }
51
+ }