diff --git a/.changeset/lazy-runtime-reference-identities.md b/.changeset/lazy-runtime-reference-identities.md new file mode 100644 index 0000000000..807b0f5ce7 --- /dev/null +++ b/.changeset/lazy-runtime-reference-identities.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Lazily initialize runtime reference identities to avoid generating random values during Cloudflare Worker module evaluation. diff --git a/.changeset/publish-applied-subset-coverage.md b/.changeset/publish-applied-subset-coverage.md new file mode 100644 index 0000000000..6cd45a8fe9 --- /dev/null +++ b/.changeset/publish-applied-subset-coverage.md @@ -0,0 +1,9 @@ +--- +'@tanstack/db': patch +'@tanstack/electric-db-collection': patch +'@tanstack/query-db-collection': patch +--- + +Publish exact applied `loadSubset` coverage with retry-safe ownership cleanup, +invalidate deduplicated request evidence when its rows are released, and +preserve eager Query DB observation after cache removal. diff --git a/docs/reference/classes/AggregateNotSupportedError.md b/docs/reference/classes/AggregateNotSupportedError.md index 31c5154bc2..2cc8d0db49 100644 --- a/docs/reference/classes/AggregateNotSupportedError.md +++ b/docs/reference/classes/AggregateNotSupportedError.md @@ -5,7 +5,7 @@ title: AggregateNotSupportedError # Class: AggregateNotSupportedError -Defined in: [packages/db/src/errors.ts:777](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L777) +Defined in: [packages/db/src/errors.ts:785](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L785) Error thrown when aggregate expressions are used outside of a GROUP BY context. @@ -21,7 +21,7 @@ Error thrown when aggregate expressions are used outside of a GROUP BY context. new AggregateNotSupportedError(): AggregateNotSupportedError; ``` -Defined in: [packages/db/src/errors.ts:778](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L778) +Defined in: [packages/db/src/errors.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L786) #### Returns diff --git a/docs/reference/classes/CannotCombineEmptyExpressionListError.md b/docs/reference/classes/CannotCombineEmptyExpressionListError.md index a5d461d68b..7a0fbc4c2d 100644 --- a/docs/reference/classes/CannotCombineEmptyExpressionListError.md +++ b/docs/reference/classes/CannotCombineEmptyExpressionListError.md @@ -5,7 +5,7 @@ title: CannotCombineEmptyExpressionListError # Class: CannotCombineEmptyExpressionListError -Defined in: [packages/db/src/errors.ts:740](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L740) +Defined in: [packages/db/src/errors.ts:748](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L748) ## Extends @@ -19,7 +19,7 @@ Defined in: [packages/db/src/errors.ts:740](https://github.com/TanStack/db/blob/ new CannotCombineEmptyExpressionListError(): CannotCombineEmptyExpressionListError; ``` -Defined in: [packages/db/src/errors.ts:741](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L741) +Defined in: [packages/db/src/errors.ts:749](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L749) #### Returns diff --git a/docs/reference/classes/DbClient.md b/docs/reference/classes/DbClient.md index e199a8d1f7..091112601a 100644 --- a/docs/reference/classes/DbClient.md +++ b/docs/reference/classes/DbClient.md @@ -5,7 +5,7 @@ title: DbClient # Class: DbClient -Defined in: [packages/db/src/client.ts:307](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L307) +Defined in: [packages/db/src/client.ts:308](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L308) ## Constructors @@ -54,7 +54,7 @@ Defined in: [packages/db/src/client.ts:342](https://github.com/TanStack/db/blob/ _consumeLiveQueryResult(queryHash, dehydratedAt): void; ``` -Defined in: [packages/db/src/client.ts:628](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L628) +Defined in: [packages/db/src/client.ts:635](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L635) **`Internal`** @@ -80,7 +80,7 @@ Defined in: [packages/db/src/client.ts:628](https://github.com/TanStack/db/blob/ _failPendingLiveQueries(error): void; ``` -Defined in: [packages/db/src/client.ts:671](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L671) +Defined in: [packages/db/src/client.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L678) **`Internal`** @@ -102,7 +102,7 @@ Defined in: [packages/db/src/client.ts:671](https://github.com/TanStack/db/blob/ _getLiveQuery(queryHash): DbClientLiveQuery | undefined; ``` -Defined in: [packages/db/src/client.ts:623](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L623) +Defined in: [packages/db/src/client.ts:630](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L630) **`Internal`** @@ -124,7 +124,7 @@ Defined in: [packages/db/src/client.ts:623](https://github.com/TanStack/db/blob/ _isSsrServerCleanupEnabled(): boolean; ``` -Defined in: [packages/db/src/client.ts:618](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L618) +Defined in: [packages/db/src/client.ts:625](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L625) **`Internal`** @@ -140,7 +140,7 @@ Defined in: [packages/db/src/client.ts:618](https://github.com/TanStack/db/blob/ _isSsrStreamingEnabled(): boolean; ``` -Defined in: [packages/db/src/client.ts:608](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L608) +Defined in: [packages/db/src/client.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L615) **`Internal`** @@ -196,7 +196,7 @@ Defined in: [packages/db/src/client.ts:432](https://github.com/TanStack/db/blob/ _registerLiveQuery(queryHash, promise): Promise; ``` -Defined in: [packages/db/src/client.ts:636](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L636) +Defined in: [packages/db/src/client.ts:643](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L643) **`Internal`** @@ -222,7 +222,7 @@ Defined in: [packages/db/src/client.ts:636](https://github.com/TanStack/db/blob/ _registerLiveQueryResource(owner, cleanup): () => void; ``` -Defined in: [packages/db/src/client.ts:658](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L658) +Defined in: [packages/db/src/client.ts:665](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L665) **`Internal`** @@ -254,7 +254,7 @@ Defined in: [packages/db/src/client.ts:658](https://github.com/TanStack/db/blob/ _setSsrServerCleanupEnabled(enabled): void; ``` -Defined in: [packages/db/src/client.ts:613](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L613) +Defined in: [packages/db/src/client.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L620) **`Internal`** @@ -276,7 +276,7 @@ Defined in: [packages/db/src/client.ts:613](https://github.com/TanStack/db/blob/ _setSsrStreamingEnabled(enabled): void; ``` -Defined in: [packages/db/src/client.ts:603](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L603) +Defined in: [packages/db/src/client.ts:610](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L610) **`Internal`** @@ -298,7 +298,7 @@ Defined in: [packages/db/src/client.ts:603](https://github.com/TanStack/db/blob/ applyCollectionChunk(chunk): void; ``` -Defined in: [packages/db/src/client.ts:593](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L593) +Defined in: [packages/db/src/client.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L600) #### Parameters @@ -318,7 +318,7 @@ Defined in: [packages/db/src/client.ts:593](https://github.com/TanStack/db/blob/ cleanup(): Promise; ``` -Defined in: [packages/db/src/client.ts:678](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L678) +Defined in: [packages/db/src/client.ts:685](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L685) #### Returns @@ -506,7 +506,7 @@ Defined in: [packages/db/src/client.ts:346](https://github.com/TanStack/db/blob/ dehydrate(options): DehydratedDbState; ``` -Defined in: [packages/db/src/client.ts:514](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L514) +Defined in: [packages/db/src/client.ts:521](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L521) #### Parameters @@ -552,7 +552,7 @@ Defined in: [packages/db/src/client.ts:328](https://github.com/TanStack/db/blob/ hydrate(state): void; ``` -Defined in: [packages/db/src/client.ts:575](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L575) +Defined in: [packages/db/src/client.ts:582](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L582) #### Parameters @@ -618,7 +618,7 @@ Defined in: [packages/db/src/client.ts:332](https://github.com/TanStack/db/blob/ subscribe(listener): () => void; ``` -Defined in: [packages/db/src/client.ts:597](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L597) +Defined in: [packages/db/src/client.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L604) #### Parameters diff --git a/docs/reference/classes/DeduplicatedLoadSubset.md b/docs/reference/classes/DeduplicatedLoadSubset.md index 029b0eb102..ef55d30881 100644 --- a/docs/reference/classes/DeduplicatedLoadSubset.md +++ b/docs/reference/classes/DeduplicatedLoadSubset.md @@ -5,7 +5,7 @@ title: DeduplicatedLoadSubset # Class: DeduplicatedLoadSubset -Defined in: [packages/db/src/query/subset-dedupe.ts:47](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L47) +Defined in: [packages/db/src/query/subset-dedupe.ts:48](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L48) Deduplicated wrapper for a loadSubset function. Tracks what data has been loaded and avoids redundant calls by applying @@ -50,7 +50,7 @@ dedupe.reset() new DeduplicatedLoadSubset(opts): DeduplicatedLoadSubset; ``` -Defined in: [packages/db/src/query/subset-dedupe.ts:77](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L77) +Defined in: [packages/db/src/query/subset-dedupe.ts:78](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L78) #### Parameters @@ -76,7 +76,7 @@ Defined in: [packages/db/src/query/subset-dedupe.ts:77](https://github.com/TanSt loadSubset(options): true | Promise; ``` -Defined in: [packages/db/src/query/subset-dedupe.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L95) +Defined in: [packages/db/src/query/subset-dedupe.ts:96](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L96) Load a subset of data, with automatic deduplication based on previously loaded predicates and in-flight requests. @@ -106,7 +106,7 @@ true if data is already loaded, or a Promise that resolves when data is loaded reset(): void; ``` -Defined in: [packages/db/src/query/subset-dedupe.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L221) +Defined in: [packages/db/src/query/subset-dedupe.ts:227](https://github.com/TanStack/db/blob/main/packages/db/src/query/subset-dedupe.ts#L227) Reset all tracking state. Clears the history of loaded predicates and in-flight calls. diff --git a/docs/reference/classes/MissingAliasInputsError.md b/docs/reference/classes/MissingAliasInputsError.md index fa46d80a83..197c361458 100644 --- a/docs/reference/classes/MissingAliasInputsError.md +++ b/docs/reference/classes/MissingAliasInputsError.md @@ -5,7 +5,7 @@ title: MissingAliasInputsError # Class: MissingAliasInputsError -Defined in: [packages/db/src/errors.ts:789](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L789) +Defined in: [packages/db/src/errors.ts:797](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L797) Internal error when the compiler returns aliases that don't have corresponding input streams. This should never happen since all aliases come from user declarations. @@ -22,7 +22,7 @@ This should never happen since all aliases come from user declarations. new MissingAliasInputsError(missingAliases): MissingAliasInputsError; ``` -Defined in: [packages/db/src/errors.ts:790](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L790) +Defined in: [packages/db/src/errors.ts:798](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L798) #### Parameters diff --git a/docs/reference/classes/QueryOptimizerError.md b/docs/reference/classes/QueryOptimizerError.md index a5c03bc178..90c7b08362 100644 --- a/docs/reference/classes/QueryOptimizerError.md +++ b/docs/reference/classes/QueryOptimizerError.md @@ -5,7 +5,7 @@ title: QueryOptimizerError # Class: QueryOptimizerError -Defined in: [packages/db/src/errors.ts:733](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L733) +Defined in: [packages/db/src/errors.ts:741](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L741) ## Extends @@ -24,7 +24,7 @@ Defined in: [packages/db/src/errors.ts:733](https://github.com/TanStack/db/blob/ new QueryOptimizerError(message): QueryOptimizerError; ``` -Defined in: [packages/db/src/errors.ts:734](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L734) +Defined in: [packages/db/src/errors.ts:742](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L742) #### Parameters diff --git a/docs/reference/classes/SetWindowRequiresOrderByError.md b/docs/reference/classes/SetWindowRequiresOrderByError.md index 05fb3aaea9..02e0feccfd 100644 --- a/docs/reference/classes/SetWindowRequiresOrderByError.md +++ b/docs/reference/classes/SetWindowRequiresOrderByError.md @@ -5,7 +5,7 @@ title: SetWindowRequiresOrderByError # Class: SetWindowRequiresOrderByError -Defined in: [packages/db/src/errors.ts:801](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L801) +Defined in: [packages/db/src/errors.ts:809](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L809) Error thrown when setWindow is called on a collection without an ORDER BY clause. @@ -21,7 +21,7 @@ Error thrown when setWindow is called on a collection without an ORDER BY clause new SetWindowRequiresOrderByError(): SetWindowRequiresOrderByError; ``` -Defined in: [packages/db/src/errors.ts:802](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L802) +Defined in: [packages/db/src/errors.ts:810](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L810) #### Returns diff --git a/docs/reference/classes/SubscriptionNotFoundError.md b/docs/reference/classes/SubscriptionNotFoundError.md index 172708b4ff..a5184494f4 100644 --- a/docs/reference/classes/SubscriptionNotFoundError.md +++ b/docs/reference/classes/SubscriptionNotFoundError.md @@ -5,7 +5,7 @@ title: SubscriptionNotFoundError # Class: SubscriptionNotFoundError -Defined in: [packages/db/src/errors.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L761) +Defined in: [packages/db/src/errors.ts:769](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L769) Error when a subscription cannot be found during lazy join processing. For subqueries, aliases may be remapped (e.g., 'activeUser' → 'user'). @@ -26,7 +26,7 @@ new SubscriptionNotFoundError( availableAliases): SubscriptionNotFoundError; ``` -Defined in: [packages/db/src/errors.ts:762](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L762) +Defined in: [packages/db/src/errors.ts:770](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L770) #### Parameters diff --git a/docs/reference/classes/SyncTransactionAbortedError.md b/docs/reference/classes/SyncTransactionAbortedError.md new file mode 100644 index 0000000000..b2593447c9 --- /dev/null +++ b/docs/reference/classes/SyncTransactionAbortedError.md @@ -0,0 +1,232 @@ +--- +id: SyncTransactionAbortedError +title: SyncTransactionAbortedError +--- + +# Class: SyncTransactionAbortedError + +Defined in: [packages/db/src/errors.ts:733](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L733) + +A sync transaction was canceled before its writes became visible. + +## Extends + +- `Error` + +## Constructors + +### Constructor + +```ts +new SyncTransactionAbortedError(): SyncTransactionAbortedError; +``` + +Defined in: [packages/db/src/errors.ts:734](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L734) + +#### Returns + +`SyncTransactionAbortedError` + +#### Overrides + +```ts +Error.constructor +``` + +## Properties + +### cause? + +```ts +optional cause: unknown; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es2022.error.d.ts:26 + +#### Inherited from + +```ts +Error.cause +``` + +*** + +### message + +```ts +message: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1077 + +#### Inherited from + +```ts +Error.message +``` + +*** + +### name + +```ts +name: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1076 + +#### Inherited from + +```ts +Error.name +``` + +*** + +### stack? + +```ts +optional stack: string; +``` + +Defined in: node\_modules/.pnpm/typescript@5.9.3/node\_modules/typescript/lib/lib.es5.d.ts:1078 + +#### Inherited from + +```ts +Error.stack +``` + +*** + +### stackTraceLimit + +```ts +static stackTraceLimit: number; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:67 + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +```ts +Error.stackTraceLimit +``` + +## Methods + +### captureStackTrace() + +```ts +static captureStackTrace(targetObject, constructorOpt?): void; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:51 + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +```ts +Error.captureStackTrace +``` + +*** + +### prepareStackTrace() + +```ts +static prepareStackTrace(err, stackTraces): any; +``` + +Defined in: node\_modules/.pnpm/@types+node@25.2.2/node\_modules/@types/node/globals.d.ts:55 + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +```ts +Error.prepareStackTrace +``` diff --git a/docs/reference/classes/UnhashableQueryIRError.md b/docs/reference/classes/UnhashableQueryIRError.md index 3d6695ff93..6002b0a84a 100644 --- a/docs/reference/classes/UnhashableQueryIRError.md +++ b/docs/reference/classes/UnhashableQueryIRError.md @@ -5,7 +5,7 @@ title: UnhashableQueryIRError # Class: UnhashableQueryIRError -Defined in: [packages/db/src/query/ir-stable-identity.ts:26](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L26) +Defined in: [packages/db/src/query/ir-stable-identity.ts:53](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L53) ## Extends @@ -19,7 +19,7 @@ Defined in: [packages/db/src/query/ir-stable-identity.ts:26](https://github.com/ new UnhashableQueryIRError(path, reason): UnhashableQueryIRError; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:27](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L27) +Defined in: [packages/db/src/query/ir-stable-identity.ts:54](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L54) #### Parameters @@ -97,7 +97,7 @@ Error.name readonly path: string; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:28](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L28) +Defined in: [packages/db/src/query/ir-stable-identity.ts:55](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L55) *** @@ -107,7 +107,7 @@ Defined in: [packages/db/src/query/ir-stable-identity.ts:28](https://github.com/ readonly reason: string; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:29](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L29) +Defined in: [packages/db/src/query/ir-stable-identity.ts:56](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L56) *** diff --git a/docs/reference/classes/WhereClauseConversionError.md b/docs/reference/classes/WhereClauseConversionError.md index 92b71432b4..31fa15fd17 100644 --- a/docs/reference/classes/WhereClauseConversionError.md +++ b/docs/reference/classes/WhereClauseConversionError.md @@ -5,7 +5,7 @@ title: WhereClauseConversionError # Class: WhereClauseConversionError -Defined in: [packages/db/src/errors.ts:749](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L749) +Defined in: [packages/db/src/errors.ts:757](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L757) Internal error when the query optimizer fails to convert a WHERE clause to a collection filter. @@ -21,7 +21,7 @@ Internal error when the query optimizer fails to convert a WHERE clause to a col new WhereClauseConversionError(collectionId, alias): WhereClauseConversionError; ``` -Defined in: [packages/db/src/errors.ts:750](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L750) +Defined in: [packages/db/src/errors.ts:758](https://github.com/TanStack/db/blob/main/packages/db/src/errors.ts#L758) #### Parameters diff --git a/docs/reference/electric-db-collection/functions/electricCollectionOptions.md b/docs/reference/electric-db-collection/functions/electricCollectionOptions.md index c240996785..1dd0a2a381 100644 --- a/docs/reference/electric-db-collection/functions/electricCollectionOptions.md +++ b/docs/reference/electric-db-collection/functions/electricCollectionOptions.md @@ -11,7 +11,7 @@ title: electricCollectionOptions function electricCollectionOptions(config): Omit, string | number, T, UtilsRecord>, "utils" | "onInsert" | "onUpdate" | "onDelete"> & Pick, T>, "onInsert" | "onUpdate" | "onDelete"> & object; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:722](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L722) +Defined in: [packages/electric-db-collection/src/electric.ts:740](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L740) Creates Electric collection options for use with a standard Collection @@ -43,7 +43,7 @@ Collection options with utilities function electricCollectionOptions(config): Omit, "utils" | "onInsert" | "onUpdate" | "onDelete"> & Pick, "onInsert" | "onUpdate" | "onDelete"> & object; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:740](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L740) +Defined in: [packages/electric-db-collection/src/electric.ts:758](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L758) Creates Electric collection options for use with a standard Collection diff --git a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md index 572c7e2388..ce3384411e 100644 --- a/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md +++ b/docs/reference/electric-db-collection/interfaces/ElectricCollectionConfig.md @@ -5,7 +5,7 @@ title: ElectricCollectionConfig # Interface: ElectricCollectionConfig\ -Defined in: [packages/electric-db-collection/src/electric.ts:280](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L280) +Defined in: [packages/electric-db-collection/src/electric.ts:281](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L281) Configuration interface for Electric collection options @@ -35,7 +35,7 @@ The schema type for validation optional [ELECTRIC_TEST_HOOKS]: ElectricTestHooks; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:303](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L303) +Defined in: [packages/electric-db-collection/src/electric.ts:304](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L304) Internal test hooks (for testing only) Hidden via Symbol to prevent accidental usage in production @@ -48,7 +48,7 @@ Hidden via Symbol to prevent accidental usage in production optional onDelete: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:420](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L420) +Defined in: [packages/electric-db-collection/src/electric.ts:421](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L421) Optional asynchronous handler function called before a delete operation @@ -100,7 +100,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:351](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L351) +Defined in: [packages/electric-db-collection/src/electric.ts:352](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L352) Optional asynchronous handler function called before an insert operation @@ -174,7 +174,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: (params) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:386](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L386) +Defined in: [packages/electric-db-collection/src/electric.ts:387](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L387) Optional asynchronous handler function called before an update operation @@ -227,7 +227,7 @@ onUpdate: async ({ transaction, collection }) => { shapeOptions: ShapeStreamOptions>; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:296](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L296) +Defined in: [packages/electric-db-collection/src/electric.ts:297](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L297) Configuration options for the ElectricSQL ShapeStream @@ -239,4 +239,4 @@ Configuration options for the ElectricSQL ShapeStream optional syncMode: ElectricSyncMode; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:297](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L297) +Defined in: [packages/electric-db-collection/src/electric.ts:298](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L298) diff --git a/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md b/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md index ed1befd0f5..9456204c0c 100644 --- a/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md +++ b/docs/reference/electric-db-collection/interfaces/ElectricCollectionUtils.md @@ -5,7 +5,7 @@ title: ElectricCollectionUtils # Interface: ElectricCollectionUtils\ -Defined in: [packages/electric-db-collection/src/electric.ts:704](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L704) +Defined in: [packages/electric-db-collection/src/electric.ts:722](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L722) Electric collection utilities type @@ -33,7 +33,7 @@ Electric collection utilities type awaitMatch: AwaitMatchFn; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:708](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L708) +Defined in: [packages/electric-db-collection/src/electric.ts:726](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L726) *** @@ -43,4 +43,4 @@ Defined in: [packages/electric-db-collection/src/electric.ts:708](https://github awaitTxId: AwaitTxIdFn; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:707](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L707) +Defined in: [packages/electric-db-collection/src/electric.ts:725](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L725) diff --git a/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md b/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md index a912ea5d92..309adefe87 100644 --- a/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md +++ b/docs/reference/electric-db-collection/type-aliases/AwaitTxIdFn.md @@ -9,7 +9,7 @@ title: AwaitTxIdFn type AwaitTxIdFn = (txId, timeout?) => Promise; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:691](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L691) +Defined in: [packages/electric-db-collection/src/electric.ts:709](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L709) Type for the awaitTxId utility function diff --git a/docs/reference/electric-db-collection/type-aliases/Txid.md b/docs/reference/electric-db-collection/type-aliases/Txid.md index 4650ab8104..493d448b3c 100644 --- a/docs/reference/electric-db-collection/type-aliases/Txid.md +++ b/docs/reference/electric-db-collection/type-aliases/Txid.md @@ -9,6 +9,6 @@ title: Txid type Txid = number; ``` -Defined in: [packages/electric-db-collection/src/electric.ts:92](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L92) +Defined in: [packages/electric-db-collection/src/electric.ts:93](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/src/electric.ts#L93) Type representing a transaction ID in ElectricSQL diff --git a/docs/reference/functions/canonicalizeQueryIR.md b/docs/reference/functions/canonicalizeQueryIR.md index d65f6b2fab..5198bb69a9 100644 --- a/docs/reference/functions/canonicalizeQueryIR.md +++ b/docs/reference/functions/canonicalizeQueryIR.md @@ -9,7 +9,7 @@ title: canonicalizeQueryIR function canonicalizeQueryIR(query): StableIdentityValue; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:50](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L50) +Defined in: [packages/db/src/query/ir-stable-identity.ts:176](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L176) ## Parameters diff --git a/docs/reference/functions/collectionOptions.md b/docs/reference/functions/collectionOptions.md index e9a5b6f577..bb0153b4d3 100644 --- a/docs/reference/functions/collectionOptions.md +++ b/docs/reference/functions/collectionOptions.md @@ -11,7 +11,7 @@ title: collectionOptions function collectionOptions(options): CollectionOptions, TKey, T, TUtils> & NonSingleResult; ``` -Defined in: [packages/db/src/client.ts:179](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L179) +Defined in: [packages/db/src/client.ts:180](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L180) ### Type Parameters @@ -43,7 +43,7 @@ Defined in: [packages/db/src/client.ts:179](https://github.com/TanStack/db/blob/ function collectionOptions(options): CollectionOptions, TKey, T, TUtils> & SingleResult; ``` -Defined in: [packages/db/src/client.ts:188](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L188) +Defined in: [packages/db/src/client.ts:189](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L189) ### Type Parameters @@ -75,7 +75,7 @@ Defined in: [packages/db/src/client.ts:188](https://github.com/TanStack/db/blob/ function collectionOptions(options): CollectionOptions & NonSingleResult; ``` -Defined in: [packages/db/src/client.ts:197](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L197) +Defined in: [packages/db/src/client.ts:198](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L198) ### Type Parameters @@ -107,7 +107,7 @@ Defined in: [packages/db/src/client.ts:197](https://github.com/TanStack/db/blob/ function collectionOptions(options): CollectionOptions & SingleResult; ``` -Defined in: [packages/db/src/client.ts:206](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L206) +Defined in: [packages/db/src/client.ts:207](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L207) ### Type Parameters @@ -139,7 +139,7 @@ Defined in: [packages/db/src/client.ts:206](https://github.com/TanStack/db/blob/ function collectionOptions(id, factory): DescriptorFromConfig; ``` -Defined in: [packages/db/src/client.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L215) +Defined in: [packages/db/src/client.ts:216](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L216) ### Type Parameters diff --git a/docs/reference/functions/compileQuery.md b/docs/reference/functions/compileQuery.md index ad54d4bbbf..6bfa983a10 100644 --- a/docs/reference/functions/compileQuery.md +++ b/docs/reference/functions/compileQuery.md @@ -21,7 +21,7 @@ function compileQuery( childCorrelationField?): CompilationResult; ``` -Defined in: [packages/db/src/query/compiler/index.ts:218](https://github.com/TanStack/db/blob/main/packages/db/src/query/compiler/index.ts#L218) +Defined in: [packages/db/src/query/compiler/index.ts:322](https://github.com/TanStack/db/blob/main/packages/db/src/query/compiler/index.ts#L322) Compiles a query IR into a D2 pipeline diff --git a/docs/reference/functions/getLoadSubsetDemandKey.md b/docs/reference/functions/getLoadSubsetDemandKey.md new file mode 100644 index 0000000000..4d9914cb49 --- /dev/null +++ b/docs/reference/functions/getLoadSubsetDemandKey.md @@ -0,0 +1,31 @@ +--- +id: getLoadSubsetDemandKey +title: getLoadSubsetDemandKey +--- + +# Function: getLoadSubsetDemandKey() + +```ts +function getLoadSubsetDemandKey(options): DemandKey | undefined; +``` + +Defined in: [packages/db/src/query/ir-stable-identity.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L111) + +Returns the exact semantic identity of a loadSubset request. + +Abort signals and subscriptions are owners of a request, not part of the +requested data, and therefore do not affect the key. A demand generation +scopes one asynchronous attempt rather than the data it requests. Code that +rejects stale work compares this key alongside its generation; query-db uses +the key alone so equivalent data demands can reuse one cache entry across +generations. + +## Parameters + +### options + +[`LoadSubsetOptions`](../type-aliases/LoadSubsetOptions.md) + +## Returns + +[`DemandKey`](../type-aliases/DemandKey.md) \| `undefined` diff --git a/docs/reference/functions/getQueryIdentity.md b/docs/reference/functions/getQueryIdentity.md new file mode 100644 index 0000000000..c22d2f2e57 --- /dev/null +++ b/docs/reference/functions/getQueryIdentity.md @@ -0,0 +1,29 @@ +--- +id: getQueryIdentity +title: getQueryIdentity +--- + +# Function: getQueryIdentity() + +```ts +function getQueryIdentity(query): QueryIdentity; +``` + +Defined in: [packages/db/src/query/ir-stable-identity.ts:85](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L85) + +Returns the semantic identity of a structured query. + +Logical conjunctions and disjunctions are associative, commutative, and +idempotent. Equality operands are commutative, while reversed inequalities +are normalized by inverting their operator. Order-sensitive clauses and +function arguments retain their original order. + +## Parameters + +### query + +[`QueryIR`](../@tanstack/namespaces/IR/interfaces/QueryIR.md) + +## Returns + +[`QueryIdentity`](../type-aliases/QueryIdentity.md) diff --git a/docs/reference/functions/getStableQueryBuilderHash.md b/docs/reference/functions/getStableQueryBuilderHash.md index f6a7dfdbcb..f44400237f 100644 --- a/docs/reference/functions/getStableQueryBuilderHash.md +++ b/docs/reference/functions/getStableQueryBuilderHash.md @@ -9,7 +9,7 @@ title: getStableQueryBuilderHash function getStableQueryBuilderHash(query): string; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:40](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L40) +Defined in: [packages/db/src/query/ir-stable-identity.ts:67](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L67) ## Parameters diff --git a/docs/reference/functions/getStableQueryIRHash.md b/docs/reference/functions/getStableQueryIRHash.md index a4ae140b33..bf69094aaf 100644 --- a/docs/reference/functions/getStableQueryIRHash.md +++ b/docs/reference/functions/getStableQueryIRHash.md @@ -9,7 +9,7 @@ title: getStableQueryIRHash function getStableQueryIRHash(query): string; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:36](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L36) +Defined in: [packages/db/src/query/ir-stable-identity.ts:63](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L63) ## Parameters diff --git a/docs/reference/functions/getStableValueHash.md b/docs/reference/functions/getStableValueHash.md index f932b273e8..a27be1aa76 100644 --- a/docs/reference/functions/getStableValueHash.md +++ b/docs/reference/functions/getStableValueHash.md @@ -9,7 +9,7 @@ title: getStableValueHash function getStableValueHash(value, path): string; ``` -Defined in: [packages/db/src/query/ir-stable-identity.ts:46](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L46) +Defined in: [packages/db/src/query/ir-stable-identity.ts:73](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L73) ## Parameters diff --git a/docs/reference/functions/isCollectionOptions.md b/docs/reference/functions/isCollectionOptions.md index 53182a7f9c..4cd34a660c 100644 --- a/docs/reference/functions/isCollectionOptions.md +++ b/docs/reference/functions/isCollectionOptions.md @@ -9,7 +9,7 @@ title: isCollectionOptions function isCollectionOptions(value): value is CollectionOptions; ``` -Defined in: [packages/db/src/client.ts:301](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L301) +Defined in: [packages/db/src/client.ts:302](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L302) ## Parameters diff --git a/docs/reference/functions/isLimitSubset.md b/docs/reference/functions/isLimitSubset.md index eb2b6ab3f8..6cc0d35f05 100644 --- a/docs/reference/functions/isLimitSubset.md +++ b/docs/reference/functions/isLimitSubset.md @@ -9,7 +9,7 @@ title: isLimitSubset function isLimitSubset(subset, superset): boolean; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L773) +Defined in: [packages/db/src/query/predicate-utils.ts:804](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L804) Check if one limit is a subset of another. Returns true if the subset limit requirements are satisfied by the superset limit. diff --git a/docs/reference/functions/isLoadSubsetRequestSubsumedBy.md b/docs/reference/functions/isLoadSubsetRequestSubsumedBy.md new file mode 100644 index 0000000000..0770a0143c --- /dev/null +++ b/docs/reference/functions/isLoadSubsetRequestSubsumedBy.md @@ -0,0 +1,32 @@ +--- +id: isLoadSubsetRequestSubsumedBy +title: isLoadSubsetRequestSubsumedBy +--- + +# Function: isLoadSubsetRequestSubsumedBy() + +```ts +function isLoadSubsetRequestSubsumedBy(demand, acquisitionRequest): boolean; +``` + +Defined in: [packages/db/src/query/predicate-utils.ts:953](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L953) + +Returns whether one acquisition request subsumes another demand. + +This is a directional relationship between request shapes, not proof of +applied or authoritative coverage. It must not be replaced with DemandKey +equality, which answers whether two exact requests are the same. + +## Parameters + +### demand + +[`LoadSubsetOptions`](../type-aliases/LoadSubsetOptions.md) + +### acquisitionRequest + +[`LoadSubsetOptions`](../type-aliases/LoadSubsetOptions.md) + +## Returns + +`boolean` diff --git a/docs/reference/functions/isOffsetLimitSubset.md b/docs/reference/functions/isOffsetLimitSubset.md index a3d63e97b5..cd253d3b3d 100644 --- a/docs/reference/functions/isOffsetLimitSubset.md +++ b/docs/reference/functions/isOffsetLimitSubset.md @@ -9,7 +9,7 @@ title: isOffsetLimitSubset function isOffsetLimitSubset(subset, superset): boolean; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:813](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L813) +Defined in: [packages/db/src/query/predicate-utils.ts:844](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L844) Check if one offset+limit range is a subset of another. Returns true if the subset range is fully contained within the superset range. diff --git a/docs/reference/functions/isOrderBySubset.md b/docs/reference/functions/isOrderBySubset.md index f6eb48bf8c..c09f6759ee 100644 --- a/docs/reference/functions/isOrderBySubset.md +++ b/docs/reference/functions/isOrderBySubset.md @@ -9,7 +9,7 @@ title: isOrderBySubset function isOrderBySubset(subset, superset): boolean; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:715](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L715) +Defined in: [packages/db/src/query/predicate-utils.ts:746](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L746) Check if one orderBy clause is a subset of another. Returns true if the subset ordering requirements are satisfied by the superset ordering. diff --git a/docs/reference/functions/isPredicateSubset.md b/docs/reference/functions/isPredicateSubset.md index 9574ec9c25..8f1eae3a99 100644 --- a/docs/reference/functions/isPredicateSubset.md +++ b/docs/reference/functions/isPredicateSubset.md @@ -9,7 +9,7 @@ title: isPredicateSubset function isPredicateSubset(subset, superset): boolean; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:856](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L856) +Defined in: [packages/db/src/query/predicate-utils.ts:887](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L887) Check if one predicate (where + orderBy + limit + offset) is a subset of another. Returns true if all aspects of the subset predicate are satisfied by the superset. diff --git a/docs/reference/functions/isWhereSubset.md b/docs/reference/functions/isWhereSubset.md index 4817c778e2..f312acd9ae 100644 --- a/docs/reference/functions/isWhereSubset.md +++ b/docs/reference/functions/isWhereSubset.md @@ -9,7 +9,7 @@ title: isWhereSubset function isWhereSubset(subset, superset): boolean; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:21](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L21) +Defined in: [packages/db/src/query/predicate-utils.ts:27](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L27) Check if one where clause is a logical subset of another. Returns true if the subset predicate is more restrictive than (or equal to) the superset predicate. diff --git a/docs/reference/functions/minusWherePredicates.md b/docs/reference/functions/minusWherePredicates.md index 5326ec322b..fda1448b35 100644 --- a/docs/reference/functions/minusWherePredicates.md +++ b/docs/reference/functions/minusWherePredicates.md @@ -11,7 +11,7 @@ function minusWherePredicates(fromPredicate, subtractPredicate): | null; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:340](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L340) +Defined in: [packages/db/src/query/predicate-utils.ts:371](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L371) Compute the difference between two where predicates: `fromPredicate AND NOT(subtractPredicate)`. Returns the simplified predicate, or null if the difference cannot be simplified diff --git a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md index b24121dfa6..8ea0cfff85 100644 --- a/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md +++ b/docs/reference/functions/shouldPreserveLiveQueryWindowPageCount.md @@ -33,7 +33,7 @@ Shared page-depth preservation policy for framework adapters. #### inputKind -`"query"` \| `"collection"` +`"collection"` \| `"query"` #### pageShapeChanged @@ -41,7 +41,7 @@ Shared page-depth preservation policy for framework adapters. #### previousInputKind -`"query"` \| `"collection"` \| `undefined` +`"collection"` \| `"query"` \| `undefined` #### sameCollection diff --git a/docs/reference/functions/unionWherePredicates.md b/docs/reference/functions/unionWherePredicates.md index 7792c606b5..1690f81c2b 100644 --- a/docs/reference/functions/unionWherePredicates.md +++ b/docs/reference/functions/unionWherePredicates.md @@ -9,7 +9,7 @@ title: unionWherePredicates function unionWherePredicates(predicates): BasicExpression; ``` -Defined in: [packages/db/src/query/predicate-utils.ts:297](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L297) +Defined in: [packages/db/src/query/predicate-utils.ts:328](https://github.com/TanStack/db/blob/main/packages/db/src/query/predicate-utils.ts#L328) Combine multiple where predicates with OR logic (union). Returns a predicate that is satisfied when any input predicate is satisfied. diff --git a/docs/reference/functions/withCollectionConfigFactory.md b/docs/reference/functions/withCollectionConfigFactory.md index 3740779562..75c127e490 100644 --- a/docs/reference/functions/withCollectionConfigFactory.md +++ b/docs/reference/functions/withCollectionConfigFactory.md @@ -9,7 +9,7 @@ title: withCollectionConfigFactory function withCollectionConfigFactory(config, factory): CollectionConfigWithFactory; ``` -Defined in: [packages/db/src/client.ts:80](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L80) +Defined in: [packages/db/src/client.ts:81](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L81) Adds a fresh-config materializer to an adapter options object. diff --git a/docs/reference/index.md b/docs/reference/index.md index 71ee396d26..dd0866a331 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -91,6 +91,7 @@ title: "@tanstack/db" - [SubQueryMustHaveFromClauseError](classes/SubQueryMustHaveFromClauseError.md) - [SubscriptionNotFoundError](classes/SubscriptionNotFoundError.md) - [SyncCleanupError](classes/SyncCleanupError.md) +- [SyncTransactionAbortedError](classes/SyncTransactionAbortedError.md) - [SyncTransactionAlreadyCommittedError](classes/SyncTransactionAlreadyCommittedError.md) - [SyncTransactionAlreadyCommittedWriteError](classes/SyncTransactionAlreadyCommittedWriteError.md) - [TanStackDBError](classes/TanStackDBError.md) @@ -165,6 +166,7 @@ title: "@tanstack/db" - [SubscribeChangesOptions](interfaces/SubscribeChangesOptions.md) - [SubscribeChangesSnapshotOptions](interfaces/SubscribeChangesSnapshotOptions.md) - [Subscription](interfaces/Subscription.md) +- [SubscriptionLoadSubsetErrorEvent](interfaces/SubscriptionLoadSubsetErrorEvent.md) - [SubscriptionStatusChangeEvent](interfaces/SubscriptionStatusChangeEvent.md) - [SubscriptionStatusEvent](interfaces/SubscriptionStatusEvent.md) - [SubscriptionUnsubscribedEvent](interfaces/SubscriptionUnsubscribedEvent.md) @@ -209,6 +211,7 @@ title: "@tanstack/db" - [DeleteMutationFnParams](type-aliases/DeleteMutationFnParams.md) - [DeltaEvent](type-aliases/DeltaEvent.md) - [DeltaType](type-aliases/DeltaType.md) +- [DemandKey](type-aliases/DemandKey.md) - [EffectQueryInput](type-aliases/EffectQueryInput.md) - [ExtractContext](type-aliases/ExtractContext.md) - [FieldPath](type-aliases/FieldPath.md) @@ -255,6 +258,7 @@ title: "@tanstack/db" - [OrderByCallback](type-aliases/OrderByCallback.md) - [Prettify](type-aliases/Prettify.md) - [QueryBuilder](type-aliases/QueryBuilder.md) +- [QueryIdentity](type-aliases/QueryIdentity.md) - [QueryResult](type-aliases/QueryResult.md) - [Ref](type-aliases/Ref.md) - [RefsForContext](type-aliases/RefsForContext.md) @@ -278,6 +282,7 @@ title: "@tanstack/db" - [StringCollationConfig](type-aliases/StringCollationConfig.md) - [SubscriptionEvents](type-aliases/SubscriptionEvents.md) - [SubscriptionStatus](type-aliases/SubscriptionStatus.md) +- [SyncAppliedReceipt](type-aliases/SyncAppliedReceipt.md) - [SyncConfigRes](type-aliases/SyncConfigRes.md) - [SyncMode](type-aliases/SyncMode.md) - [TransactionState](type-aliases/TransactionState.md) @@ -341,7 +346,9 @@ title: "@tanstack/db" - [getLiveQueryStatusFlags](functions/getLiveQueryStatusFlags.md) - [getLiveQueryWindowCollectionWarning](functions/getLiveQueryWindowCollectionWarning.md) - [getLiveQueryWindowInputKind](functions/getLiveQueryWindowInputKind.md) +- [getLoadSubsetDemandKey](functions/getLoadSubsetDemandKey.md) - [getPreparedLiveQueryIdentity](functions/getPreparedLiveQueryIdentity.md) +- [getQueryIdentity](functions/getQueryIdentity.md) - [getQueryPatterns](functions/getQueryPatterns.md) - [getStableQueryBuilderHash](functions/getStableQueryBuilderHash.md) - [getStableQueryIRHash](functions/getStableQueryIRHash.md) @@ -357,6 +364,7 @@ title: "@tanstack/db" - [isDevModeEnabled](functions/isDevModeEnabled.md) - [isLimitSubset](functions/isLimitSubset.md) - [isLiveQueryWindowCollection](functions/isLiveQueryWindowCollection.md) +- [isLoadSubsetRequestSubsumedBy](functions/isLoadSubsetRequestSubsumedBy.md) - [isNull](functions/isNull.md) - [isOffsetLimitSubset](functions/isOffsetLimitSubset.md) - [isOrderBySubset](functions/isOrderBySubset.md) diff --git a/docs/reference/interfaces/BaseCollectionConfig.md b/docs/reference/interfaces/BaseCollectionConfig.md index 3c096cd42b..b2883557a4 100644 --- a/docs/reference/interfaces/BaseCollectionConfig.md +++ b/docs/reference/interfaces/BaseCollectionConfig.md @@ -5,7 +5,7 @@ title: BaseCollectionConfig # Interface: BaseCollectionConfig\ -Defined in: [packages/db/src/types.ts:550](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L550) +Defined in: [packages/db/src/types.ts:591](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L591) ## Extended by @@ -42,7 +42,7 @@ Defined in: [packages/db/src/types.ts:550](https://github.com/TanStack/db/blob/m optional autoIndex: "off" | "eager"; ``` -Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L600) +Defined in: [packages/db/src/types.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L641) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -67,7 +67,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:625](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L625) +Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) Optional function to compare two items. This is used to order the items in the collection. @@ -107,7 +107,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor; ``` -Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) +Defined in: [packages/db/src/types.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L655) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -131,7 +131,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:771](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L771) +Defined in: [packages/db/src/types.ts:812](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L812) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -146,7 +146,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L579) +Defined in: [packages/db/src/types.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L620) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -159,7 +159,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) +Defined in: [packages/db/src/types.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L615) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -193,7 +193,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) +Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) *** @@ -203,7 +203,7 @@ Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:763](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L763) +Defined in: [packages/db/src/types.ts:804](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L804) Optional asynchronous handler function called before a delete operation @@ -267,7 +267,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L676) +Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) Optional asynchronous handler function called before an insert operation @@ -330,7 +330,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) +Defined in: [packages/db/src/types.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L761) Optional asynchronous handler function called before an update operation @@ -394,7 +394,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L564) +Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) *** @@ -404,7 +404,7 @@ Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:590](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L590) +Defined in: [packages/db/src/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L631) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -427,7 +427,7 @@ false optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:634](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L634) +Defined in: [packages/db/src/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L675) The mode of sync to use for the collection. @@ -449,4 +449,4 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L773) +Defined in: [packages/db/src/types.ts:814](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L814) diff --git a/docs/reference/interfaces/ChangeMessage.md b/docs/reference/interfaces/ChangeMessage.md index e442a3a16d..87757ac012 100644 --- a/docs/reference/interfaces/ChangeMessage.md +++ b/docs/reference/interfaces/ChangeMessage.md @@ -5,7 +5,7 @@ title: ChangeMessage # Interface: ChangeMessage\ -Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) +Defined in: [packages/db/src/types.ts:450](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L450) ## Type Parameters @@ -25,7 +25,7 @@ Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/m key: TKey; ``` -Defined in: [packages/db/src/types.ts:413](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L413) +Defined in: [packages/db/src/types.ts:454](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L454) *** @@ -35,7 +35,7 @@ Defined in: [packages/db/src/types.ts:413](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:417](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L417) +Defined in: [packages/db/src/types.ts:458](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L458) *** @@ -45,7 +45,7 @@ Defined in: [packages/db/src/types.ts:417](https://github.com/TanStack/db/blob/m optional previousValue: T; ``` -Defined in: [packages/db/src/types.ts:415](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L415) +Defined in: [packages/db/src/types.ts:456](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L456) *** @@ -55,7 +55,7 @@ Defined in: [packages/db/src/types.ts:415](https://github.com/TanStack/db/blob/m type: OperationType; ``` -Defined in: [packages/db/src/types.ts:416](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L416) +Defined in: [packages/db/src/types.ts:457](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L457) *** @@ -65,4 +65,4 @@ Defined in: [packages/db/src/types.ts:416](https://github.com/TanStack/db/blob/m value: T; ``` -Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) +Defined in: [packages/db/src/types.ts:455](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L455) diff --git a/docs/reference/interfaces/CollectionConfig.md b/docs/reference/interfaces/CollectionConfig.md index 42c20c0e10..01fbc7415c 100644 --- a/docs/reference/interfaces/CollectionConfig.md +++ b/docs/reference/interfaces/CollectionConfig.md @@ -5,7 +5,7 @@ title: CollectionConfig # Interface: CollectionConfig\ -Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L776) +Defined in: [packages/db/src/types.ts:817](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L817) ## Extends @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:776](https://github.com/TanStack/db/blob/m optional autoIndex: "off" | "eager"; ``` -Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L600) +Defined in: [packages/db/src/types.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L641) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -66,7 +66,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:625](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L625) +Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) Optional function to compare two items. This is used to order the items in the collection. @@ -110,7 +110,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor; ``` -Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) +Defined in: [packages/db/src/types.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L655) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -138,7 +138,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:771](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L771) +Defined in: [packages/db/src/types.ts:812](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L812) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -157,7 +157,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L579) +Defined in: [packages/db/src/types.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L620) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -174,7 +174,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) +Defined in: [packages/db/src/types.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L615) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -212,7 +212,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) +Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) #### Inherited from @@ -226,7 +226,7 @@ Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:763](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L763) +Defined in: [packages/db/src/types.ts:804](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L804) Optional asynchronous handler function called before a delete operation @@ -294,7 +294,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L676) +Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) Optional asynchronous handler function called before an insert operation @@ -361,7 +361,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) +Defined in: [packages/db/src/types.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L761) Optional asynchronous handler function called before an update operation @@ -429,7 +429,7 @@ onUpdate: async ({ transaction, collection }) => { optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L564) +Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) #### Inherited from @@ -443,7 +443,7 @@ Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:590](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L590) +Defined in: [packages/db/src/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L631) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -470,7 +470,7 @@ false sync: SyncConfig; ``` -Defined in: [packages/db/src/types.ts:782](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L782) +Defined in: [packages/db/src/types.ts:823](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L823) *** @@ -480,7 +480,7 @@ Defined in: [packages/db/src/types.ts:782](https://github.com/TanStack/db/blob/m optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:634](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L634) +Defined in: [packages/db/src/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L675) The mode of sync to use for the collection. @@ -506,7 +506,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: TUtils; ``` -Defined in: [packages/db/src/types.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L773) +Defined in: [packages/db/src/types.ts:814](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L814) #### Inherited from diff --git a/docs/reference/interfaces/CreateOptimisticActionsOptions.md b/docs/reference/interfaces/CreateOptimisticActionsOptions.md index c76b7a4d0e..e9610d1d57 100644 --- a/docs/reference/interfaces/CreateOptimisticActionsOptions.md +++ b/docs/reference/interfaces/CreateOptimisticActionsOptions.md @@ -5,7 +5,7 @@ title: CreateOptimisticActionsOptions # Interface: CreateOptimisticActionsOptions\ -Defined in: [packages/db/src/types.ts:181](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L181) +Defined in: [packages/db/src/types.ts:187](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L187) Options for the createOptimisticAction helper @@ -31,7 +31,7 @@ Options for the createOptimisticAction helper optional autoCommit: boolean; ``` -Defined in: [packages/db/src/types.ts:172](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L172) +Defined in: [packages/db/src/types.ts:178](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L178) #### Inherited from @@ -45,7 +45,7 @@ Defined in: [packages/db/src/types.ts:172](https://github.com/TanStack/db/blob/m optional id: string; ``` -Defined in: [packages/db/src/types.ts:170](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L170) +Defined in: [packages/db/src/types.ts:176](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L176) Unique identifier for the transaction @@ -61,7 +61,7 @@ Unique identifier for the transaction optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:175](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L175) +Defined in: [packages/db/src/types.ts:181](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L181) Custom metadata to associate with the transaction @@ -77,7 +77,7 @@ Custom metadata to associate with the transaction mutationFn: (vars, params) => Promise; ``` -Defined in: [packages/db/src/types.ts:188](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L188) +Defined in: [packages/db/src/types.ts:194](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L194) Function to execute the mutation on the server @@ -103,7 +103,7 @@ Function to execute the mutation on the server onMutate: (vars) => void; ``` -Defined in: [packages/db/src/types.ts:186](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L186) +Defined in: [packages/db/src/types.ts:192](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L192) Function to apply optimistic updates locally before the mutation completes diff --git a/docs/reference/interfaces/CurrentStateAsChangesOptions.md b/docs/reference/interfaces/CurrentStateAsChangesOptions.md index a0904b646a..c72e28679a 100644 --- a/docs/reference/interfaces/CurrentStateAsChangesOptions.md +++ b/docs/reference/interfaces/CurrentStateAsChangesOptions.md @@ -5,7 +5,7 @@ title: CurrentStateAsChangesOptions # Interface: CurrentStateAsChangesOptions -Defined in: [packages/db/src/types.ts:909](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L909) +Defined in: [packages/db/src/types.ts:952](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L952) Options for getting current state as changes @@ -17,7 +17,7 @@ Options for getting current state as changes optional limit: number; ``` -Defined in: [packages/db/src/types.ts:913](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L913) +Defined in: [packages/db/src/types.ts:956](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L956) *** @@ -27,7 +27,7 @@ Defined in: [packages/db/src/types.ts:913](https://github.com/TanStack/db/blob/m optional optimizedOnly: boolean; ``` -Defined in: [packages/db/src/types.ts:914](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L914) +Defined in: [packages/db/src/types.ts:957](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L957) *** @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:914](https://github.com/TanStack/db/blob/m optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:912](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L912) +Defined in: [packages/db/src/types.ts:955](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L955) *** @@ -47,6 +47,6 @@ Defined in: [packages/db/src/types.ts:912](https://github.com/TanStack/db/blob/m optional where: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:911](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L911) +Defined in: [packages/db/src/types.ts:954](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L954) Pre-compiled expression for filtering the current state diff --git a/docs/reference/interfaces/InsertConfig.md b/docs/reference/interfaces/InsertConfig.md index 2b213abc79..aefebd1e2b 100644 --- a/docs/reference/interfaces/InsertConfig.md +++ b/docs/reference/interfaces/InsertConfig.md @@ -5,7 +5,7 @@ title: InsertConfig # Interface: InsertConfig -Defined in: [packages/db/src/types.ts:467](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L467) +Defined in: [packages/db/src/types.ts:508](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L508) ## Properties @@ -15,7 +15,7 @@ Defined in: [packages/db/src/types.ts:467](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:468](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L468) +Defined in: [packages/db/src/types.ts:509](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L509) *** @@ -25,6 +25,6 @@ Defined in: [packages/db/src/types.ts:468](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:470](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L470) +Defined in: [packages/db/src/types.ts:511](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L511) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/LocalOnlyCollectionConfig.md b/docs/reference/interfaces/LocalOnlyCollectionConfig.md index c47b6a6346..685c041341 100644 --- a/docs/reference/interfaces/LocalOnlyCollectionConfig.md +++ b/docs/reference/interfaces/LocalOnlyCollectionConfig.md @@ -41,7 +41,7 @@ The type of the key returned by `getKey` optional autoIndex: "off" | "eager"; ``` -Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L600) +Defined in: [packages/db/src/types.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L641) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -70,7 +70,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:625](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L625) +Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) Optional function to compare two items. This is used to order the items in the collection. @@ -116,7 +116,7 @@ Omit.compare optional defaultIndexType: IndexConstructor; ``` -Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) +Defined in: [packages/db/src/types.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L655) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -146,7 +146,7 @@ Omit.defaultIndexType optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:771](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L771) +Defined in: [packages/db/src/types.ts:812](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L812) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -167,7 +167,7 @@ Omit.defaultStringCollation getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) +Defined in: [packages/db/src/types.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L615) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -207,7 +207,7 @@ Omit.getKey optional id: string; ``` -Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) +Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) #### Inherited from @@ -234,7 +234,7 @@ This data will be applied during the initial sync process optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:763](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L763) +Defined in: [packages/db/src/types.ts:804](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L804) Optional asynchronous handler function called before a delete operation @@ -304,7 +304,7 @@ Omit.onDelete optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L676) +Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) Optional asynchronous handler function called before an insert operation @@ -373,7 +373,7 @@ Omit.onInsert optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) +Defined in: [packages/db/src/types.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L761) Optional asynchronous handler function called before an update operation @@ -443,7 +443,7 @@ Omit.onUpdate optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L564) +Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) #### Inherited from @@ -459,7 +459,7 @@ Omit.schema optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:634](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L634) +Defined in: [packages/db/src/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L675) The mode of sync to use for the collection. @@ -485,7 +485,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: LocalOnlyCollectionUtils; ``` -Defined in: [packages/db/src/types.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L773) +Defined in: [packages/db/src/types.ts:814](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L814) #### Inherited from diff --git a/docs/reference/interfaces/LocalStorageCollectionConfig.md b/docs/reference/interfaces/LocalStorageCollectionConfig.md index eab51ff09a..6c950d78ae 100644 --- a/docs/reference/interfaces/LocalStorageCollectionConfig.md +++ b/docs/reference/interfaces/LocalStorageCollectionConfig.md @@ -41,7 +41,7 @@ The type of the key returned by `getKey` optional autoIndex: "off" | "eager"; ``` -Defined in: [packages/db/src/types.ts:600](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L600) +Defined in: [packages/db/src/types.ts:641](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L641) Auto-indexing mode for the collection. When enabled, indexes will be automatically created for simple where expressions. @@ -70,7 +70,7 @@ When enabled, indexes will be automatically created for simple where expressions optional compare: (x, y) => number; ``` -Defined in: [packages/db/src/types.ts:625](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L625) +Defined in: [packages/db/src/types.ts:666](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L666) Optional function to compare two items. This is used to order the items in the collection. @@ -114,7 +114,7 @@ compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime() optional defaultIndexType: IndexConstructor; ``` -Defined in: [packages/db/src/types.ts:614](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L614) +Defined in: [packages/db/src/types.ts:655](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L655) Default index type to use when creating indexes without an explicit type. Required for auto-indexing. Import from '@tanstack/db'. @@ -142,7 +142,7 @@ const collection = createCollection({ optional defaultStringCollation: StringCollationConfig; ``` -Defined in: [packages/db/src/types.ts:771](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L771) +Defined in: [packages/db/src/types.ts:812](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L812) Specifies how to compare data in the collection. This should be configured to match data ordering on the backend. @@ -161,7 +161,7 @@ E.g., when using the Electric DB collection these options optional gcTime: number; ``` -Defined in: [packages/db/src/types.ts:579](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L579) +Defined in: [packages/db/src/types.ts:620](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L620) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -178,7 +178,7 @@ when it has no active subscribers. Defaults to 5 minutes (300000ms). getKey: (item) => TKey; ``` -Defined in: [packages/db/src/types.ts:574](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L574) +Defined in: [packages/db/src/types.ts:615](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L615) Function to extract the ID from an object This is required for update/delete operations which now only accept IDs @@ -216,7 +216,7 @@ getKey: (item) => item.uuid optional id: string; ``` -Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L563) +Defined in: [packages/db/src/types.ts:604](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L604) #### Inherited from @@ -230,7 +230,7 @@ Defined in: [packages/db/src/types.ts:563](https://github.com/TanStack/db/blob/m optional onDelete: DeleteMutationFn; ``` -Defined in: [packages/db/src/types.ts:763](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L763) +Defined in: [packages/db/src/types.ts:804](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L804) Optional asynchronous handler function called before a delete operation @@ -298,7 +298,7 @@ onDelete: async ({ transaction, collection }) => { optional onInsert: InsertMutationFn; ``` -Defined in: [packages/db/src/types.ts:676](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L676) +Defined in: [packages/db/src/types.ts:717](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L717) Optional asynchronous handler function called before an insert operation @@ -365,7 +365,7 @@ onInsert: async ({ transaction, collection }) => { optional onUpdate: UpdateMutationFn; ``` -Defined in: [packages/db/src/types.ts:720](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L720) +Defined in: [packages/db/src/types.ts:761](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L761) Optional asynchronous handler function called before an update operation @@ -446,7 +446,7 @@ Defaults to JSON optional schema: TSchema; ``` -Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L564) +Defined in: [packages/db/src/types.ts:605](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L605) #### Inherited from @@ -460,7 +460,7 @@ Defined in: [packages/db/src/types.ts:564](https://github.com/TanStack/db/blob/m optional startSync: boolean; ``` -Defined in: [packages/db/src/types.ts:590](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L590) +Defined in: [packages/db/src/types.ts:631](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L631) Whether to eagerly start syncing on collection creation. When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches. @@ -525,7 +525,7 @@ The key to use for storing the collection data in localStorage/sessionStorage optional syncMode: SyncMode; ``` -Defined in: [packages/db/src/types.ts:634](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L634) +Defined in: [packages/db/src/types.ts:675](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L675) The mode of sync to use for the collection. @@ -551,7 +551,7 @@ The exact implementation of the sync mode is up to the sync implementation. optional utils: UtilsRecord; ``` -Defined in: [packages/db/src/types.ts:773](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L773) +Defined in: [packages/db/src/types.ts:814](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L814) #### Inherited from diff --git a/docs/reference/interfaces/OperationConfig.md b/docs/reference/interfaces/OperationConfig.md index 6e3611406e..4aa60a86e5 100644 --- a/docs/reference/interfaces/OperationConfig.md +++ b/docs/reference/interfaces/OperationConfig.md @@ -5,7 +5,7 @@ title: OperationConfig # Interface: OperationConfig -Defined in: [packages/db/src/types.ts:461](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L461) +Defined in: [packages/db/src/types.ts:502](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L502) ## Properties @@ -15,7 +15,7 @@ Defined in: [packages/db/src/types.ts:461](https://github.com/TanStack/db/blob/m optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:462](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L462) +Defined in: [packages/db/src/types.ts:503](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L503) *** @@ -25,6 +25,6 @@ Defined in: [packages/db/src/types.ts:462](https://github.com/TanStack/db/blob/m optional optimistic: boolean; ``` -Defined in: [packages/db/src/types.ts:464](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L464) +Defined in: [packages/db/src/types.ts:505](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L505) Whether to apply optimistic updates immediately. Defaults to true. diff --git a/docs/reference/interfaces/SubscribeChangesOptions.md b/docs/reference/interfaces/SubscribeChangesOptions.md index 8c28577fb8..8fdb341b92 100644 --- a/docs/reference/interfaces/SubscribeChangesOptions.md +++ b/docs/reference/interfaces/SubscribeChangesOptions.md @@ -5,7 +5,7 @@ title: SubscribeChangesOptions # Interface: SubscribeChangesOptions\ -Defined in: [packages/db/src/types.ts:851](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L851) +Defined in: [packages/db/src/types.ts:892](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L892) Options for subscribing to collection changes @@ -27,7 +27,7 @@ Options for subscribing to collection changes optional includeInitialState: boolean; ``` -Defined in: [packages/db/src/types.ts:856](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L856) +Defined in: [packages/db/src/types.ts:897](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L897) Whether to include the current state as initial changes @@ -39,7 +39,7 @@ Whether to include the current state as initial changes optional limit: number; ``` -Defined in: [packages/db/src/types.ts:889](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L889) +Defined in: [packages/db/src/types.ts:930](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L930) **`Internal`** @@ -47,13 +47,37 @@ Optional limit to include in loadSubset for query-specific cache keys. *** +### onLoadSubsetError()? + +```ts +optional onLoadSubsetError: (event) => void; +``` + +Defined in: [packages/db/src/types.ts:938](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L938) + +**`Internal`** + +Receives subset-load failures scoped to this subscription. + +#### Parameters + +##### event + +[`SubscriptionLoadSubsetErrorEvent`](SubscriptionLoadSubsetErrorEvent.md) + +#### Returns + +`void` + +*** + ### onLoadSubsetResult()? ```ts optional onLoadSubsetResult: (result) => void; ``` -Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) +Defined in: [packages/db/src/types.ts:936](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L936) **`Internal`** @@ -78,7 +102,7 @@ Allows the caller to directly track the loading promise for isReady status. optional onStatusChange: (event) => void; ``` -Defined in: [packages/db/src/types.ts:879](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L879) +Defined in: [packages/db/src/types.ts:920](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L920) **`Internal`** @@ -103,7 +127,7 @@ Registered BEFORE any snapshot is requested, ensuring no status transitions are optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:884](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L884) +Defined in: [packages/db/src/types.ts:925](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L925) **`Internal`** @@ -117,7 +141,7 @@ Optional orderBy to include in loadSubset for query-specific cache keys. optional where: (row) => any; ``` -Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) +Defined in: [packages/db/src/types.ts:912](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L912) Callback function for filtering changes using a row proxy. The callback receives a proxy object that records property access, @@ -151,6 +175,6 @@ collection.subscribeChanges(callback, { optional whereExpression: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:873](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L873) +Defined in: [packages/db/src/types.ts:914](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L914) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md index 2f011f5c0f..3e14fed580 100644 --- a/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md +++ b/docs/reference/interfaces/SubscribeChangesSnapshotOptions.md @@ -5,7 +5,7 @@ title: SubscribeChangesSnapshotOptions # Interface: SubscribeChangesSnapshotOptions\ -Defined in: [packages/db/src/types.ts:898](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L898) +Defined in: [packages/db/src/types.ts:941](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L941) ## Extends @@ -29,7 +29,7 @@ Defined in: [packages/db/src/types.ts:898](https://github.com/TanStack/db/blob/m optional limit: number; ``` -Defined in: [packages/db/src/types.ts:903](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L903) +Defined in: [packages/db/src/types.ts:946](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L946) **`Internal`** @@ -41,13 +41,43 @@ Optional limit to include in loadSubset for query-specific cache keys. *** +### onLoadSubsetError()? + +```ts +optional onLoadSubsetError: (event) => void; +``` + +Defined in: [packages/db/src/types.ts:938](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L938) + +**`Internal`** + +Receives subset-load failures scoped to this subscription. + +#### Parameters + +##### event + +[`SubscriptionLoadSubsetErrorEvent`](SubscriptionLoadSubsetErrorEvent.md) + +#### Returns + +`void` + +#### Inherited from + +```ts +Omit.onLoadSubsetError +``` + +*** + ### onLoadSubsetResult()? ```ts optional onLoadSubsetResult: (result) => void; ``` -Defined in: [packages/db/src/types.ts:895](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L895) +Defined in: [packages/db/src/types.ts:936](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L936) **`Internal`** @@ -78,7 +108,7 @@ Omit.onLoadSubsetResult optional onStatusChange: (event) => void; ``` -Defined in: [packages/db/src/types.ts:879](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L879) +Defined in: [packages/db/src/types.ts:920](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L920) **`Internal`** @@ -109,7 +139,7 @@ Omit.onStatusChange optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:902](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L902) +Defined in: [packages/db/src/types.ts:945](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L945) **`Internal`** @@ -127,7 +157,7 @@ Optional orderBy to include in loadSubset for query-specific cache keys. optional where: (row) => any; ``` -Defined in: [packages/db/src/types.ts:871](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L871) +Defined in: [packages/db/src/types.ts:912](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L912) Callback function for filtering changes using a row proxy. The callback receives a proxy object that records property access, @@ -167,7 +197,7 @@ Omit.where optional whereExpression: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:873](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L873) +Defined in: [packages/db/src/types.ts:914](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L914) Pre-compiled expression for filtering changes diff --git a/docs/reference/interfaces/Subscription.md b/docs/reference/interfaces/Subscription.md index b3ff2b6671..f48a85f09a 100644 --- a/docs/reference/interfaces/Subscription.md +++ b/docs/reference/interfaces/Subscription.md @@ -5,7 +5,7 @@ title: Subscription # Interface: Subscription -Defined in: [packages/db/src/types.ts:254](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L254) +Defined in: [packages/db/src/types.ts:269](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L269) Public interface for a collection subscription Used by sync implementations to track subscription lifecycle @@ -16,13 +16,25 @@ Used by sync implementations to track subscription lifecycle ## Properties +### lastError + +```ts +readonly lastError: unknown; +``` + +Defined in: [packages/db/src/types.ts:273](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L273) + +Most recent subset-load failure observed by this subscription. + +*** + ### status ```ts readonly status: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:256](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L256) +Defined in: [packages/db/src/types.ts:271](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L271) Current status of the subscription diff --git a/docs/reference/interfaces/SubscriptionLoadSubsetErrorEvent.md b/docs/reference/interfaces/SubscriptionLoadSubsetErrorEvent.md new file mode 100644 index 0000000000..ef10d20a56 --- /dev/null +++ b/docs/reference/interfaces/SubscriptionLoadSubsetErrorEvent.md @@ -0,0 +1,50 @@ +--- +id: SubscriptionLoadSubsetErrorEvent +title: SubscriptionLoadSubsetErrorEvent +--- + +# Interface: SubscriptionLoadSubsetErrorEvent + +Defined in: [packages/db/src/types.ts:239](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L239) + +Event emitted when a subset requested by this subscription fails to load. + +## Properties + +### error + +```ts +error: unknown; +``` + +Defined in: [packages/db/src/types.ts:243](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L243) + +*** + +### options + +```ts +options: LoadSubsetOptions; +``` + +Defined in: [packages/db/src/types.ts:242](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L242) + +*** + +### subscription + +```ts +subscription: Subscription; +``` + +Defined in: [packages/db/src/types.ts:241](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L241) + +*** + +### type + +```ts +type: "loadSubset:error"; +``` + +Defined in: [packages/db/src/types.ts:240](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L240) diff --git a/docs/reference/interfaces/SubscriptionStatusChangeEvent.md b/docs/reference/interfaces/SubscriptionStatusChangeEvent.md index 87c7e584a3..baffa9cbe6 100644 --- a/docs/reference/interfaces/SubscriptionStatusChangeEvent.md +++ b/docs/reference/interfaces/SubscriptionStatusChangeEvent.md @@ -5,7 +5,7 @@ title: SubscriptionStatusChangeEvent # Interface: SubscriptionStatusChangeEvent -Defined in: [packages/db/src/types.ts:215](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L215) +Defined in: [packages/db/src/types.ts:221](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L221) Event emitted when subscription status changes @@ -17,7 +17,7 @@ Event emitted when subscription status changes previousStatus: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:218](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L218) +Defined in: [packages/db/src/types.ts:224](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L224) *** @@ -27,7 +27,7 @@ Defined in: [packages/db/src/types.ts:218](https://github.com/TanStack/db/blob/m status: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:219](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L219) +Defined in: [packages/db/src/types.ts:225](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L225) *** @@ -37,7 +37,7 @@ Defined in: [packages/db/src/types.ts:219](https://github.com/TanStack/db/blob/m subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:217](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L217) +Defined in: [packages/db/src/types.ts:223](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L223) *** @@ -47,4 +47,4 @@ Defined in: [packages/db/src/types.ts:217](https://github.com/TanStack/db/blob/m type: "status:change"; ``` -Defined in: [packages/db/src/types.ts:216](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L216) +Defined in: [packages/db/src/types.ts:222](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L222) diff --git a/docs/reference/interfaces/SubscriptionStatusEvent.md b/docs/reference/interfaces/SubscriptionStatusEvent.md index 5a3aee984d..a8db29fb42 100644 --- a/docs/reference/interfaces/SubscriptionStatusEvent.md +++ b/docs/reference/interfaces/SubscriptionStatusEvent.md @@ -5,7 +5,7 @@ title: SubscriptionStatusEvent # Interface: SubscriptionStatusEvent\ -Defined in: [packages/db/src/types.ts:225](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L225) +Defined in: [packages/db/src/types.ts:231](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L231) Event emitted when subscription status changes to a specific status @@ -23,7 +23,7 @@ Event emitted when subscription status changes to a specific status previousStatus: SubscriptionStatus; ``` -Defined in: [packages/db/src/types.ts:228](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L228) +Defined in: [packages/db/src/types.ts:234](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L234) *** @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:228](https://github.com/TanStack/db/blob/m status: T; ``` -Defined in: [packages/db/src/types.ts:229](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L229) +Defined in: [packages/db/src/types.ts:235](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L235) *** @@ -43,7 +43,7 @@ Defined in: [packages/db/src/types.ts:229](https://github.com/TanStack/db/blob/m subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:227](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L227) +Defined in: [packages/db/src/types.ts:233](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L233) *** @@ -53,4 +53,4 @@ Defined in: [packages/db/src/types.ts:227](https://github.com/TanStack/db/blob/m type: `status:${T}`; ``` -Defined in: [packages/db/src/types.ts:226](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L226) +Defined in: [packages/db/src/types.ts:232](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L232) diff --git a/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md b/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md index a62ce55f61..00829571da 100644 --- a/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md +++ b/docs/reference/interfaces/SubscriptionUnsubscribedEvent.md @@ -5,7 +5,7 @@ title: SubscriptionUnsubscribedEvent # Interface: SubscriptionUnsubscribedEvent -Defined in: [packages/db/src/types.ts:235](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L235) +Defined in: [packages/db/src/types.ts:249](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L249) Event emitted when subscription is unsubscribed @@ -17,7 +17,7 @@ Event emitted when subscription is unsubscribed subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:237](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L237) +Defined in: [packages/db/src/types.ts:251](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L251) *** @@ -27,4 +27,4 @@ Defined in: [packages/db/src/types.ts:237](https://github.com/TanStack/db/blob/m type: "unsubscribed"; ``` -Defined in: [packages/db/src/types.ts:236](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L236) +Defined in: [packages/db/src/types.ts:250](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L250) diff --git a/docs/reference/interfaces/SyncConfig.md b/docs/reference/interfaces/SyncConfig.md index 6d487c3266..5a28ed8647 100644 --- a/docs/reference/interfaces/SyncConfig.md +++ b/docs/reference/interfaces/SyncConfig.md @@ -5,7 +5,7 @@ title: SyncConfig # Interface: SyncConfig\ -Defined in: [packages/db/src/types.ts:333](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L333) +Defined in: [packages/db/src/types.ts:365](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L365) ## Type Parameters @@ -25,7 +25,7 @@ Defined in: [packages/db/src/types.ts:333](https://github.com/TanStack/db/blob/m optional exportSyncMeta: () => unknown; ``` -Defined in: [packages/db/src/types.ts:368](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L368) +Defined in: [packages/db/src/types.ts:409](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L409) Export adapter-specific metadata that lets hydration/persistence resume sync. The payload shape is owned by the adapter. @@ -42,7 +42,7 @@ The payload shape is owned by the adapter. optional getSyncMetadata: () => Record; ``` -Defined in: [packages/db/src/types.ts:362](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L362) +Defined in: [packages/db/src/types.ts:403](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L403) Get the sync metadata for insert operations @@ -60,7 +60,7 @@ Record containing relation information optional importSyncMeta: (meta) => void; ``` -Defined in: [packages/db/src/types.ts:373](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L373) +Defined in: [packages/db/src/types.ts:414](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L414) Import adapter-specific metadata produced by exportSyncMeta. @@ -82,7 +82,7 @@ Import adapter-specific metadata produced by exportSyncMeta. optional mergeSyncMeta: (current, incoming) => unknown; ``` -Defined in: [packages/db/src/types.ts:378](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L378) +Defined in: [packages/db/src/types.ts:419](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L419) Merge two adapter-specific metadata payloads during hydration. @@ -108,7 +108,7 @@ Merge two adapter-specific metadata payloads during hydration. optional rowUpdateMode: "full" | "partial"; ``` -Defined in: [packages/db/src/types.ts:387](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L387) +Defined in: [packages/db/src/types.ts:428](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L428) The row update mode used to sync to the collection. @@ -132,7 +132,7 @@ sync: (params) => | SyncConfigRes; ``` -Defined in: [packages/db/src/types.ts:337](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L337) +Defined in: [packages/db/src/types.ts:369](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L369) #### Parameters @@ -150,7 +150,15 @@ Begin a new sync transaction. ###### commit -() => `void` +(`signal?`) => [`SyncAppliedReceipt`](../type-aliases/SyncAppliedReceipt.md) + +Commit the active sync transaction in FIFO order. +Returns `true` when the writes and events are already visible. Otherwise +returns a receipt that resolves after they become visible. If collection +cleanup or an optional request abort abandons the transaction first, the +receipt rejects with an error named `AbortError`. +Pass a signal only for request-scoped work that must not publish after +cancellation. Aborting after application has no effect. ###### markError diff --git a/docs/reference/interfaces/SyncMetadataApi.md b/docs/reference/interfaces/SyncMetadataApi.md index 148c34e0e0..88d61b1a99 100644 --- a/docs/reference/interfaces/SyncMetadataApi.md +++ b/docs/reference/interfaces/SyncMetadataApi.md @@ -5,7 +5,7 @@ title: SyncMetadataApi # Interface: SyncMetadataApi\ -Defined in: [packages/db/src/types.ts:390](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L390) +Defined in: [packages/db/src/types.ts:431](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L431) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [packages/db/src/types.ts:390](https://github.com/TanStack/db/blob/m collection: object; ``` -Defined in: [packages/db/src/types.ts:398](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L398) +Defined in: [packages/db/src/types.ts:439](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L439) #### delete() @@ -99,7 +99,7 @@ set: (key, value) => void; row: object; ``` -Defined in: [packages/db/src/types.ts:393](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L393) +Defined in: [packages/db/src/types.ts:434](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L434) #### delete() diff --git a/docs/reference/interfaces/TransactionConfig.md b/docs/reference/interfaces/TransactionConfig.md index c146f2f113..9c5d5bbe37 100644 --- a/docs/reference/interfaces/TransactionConfig.md +++ b/docs/reference/interfaces/TransactionConfig.md @@ -5,7 +5,7 @@ title: TransactionConfig # Interface: TransactionConfig\ -Defined in: [packages/db/src/types.ts:168](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L168) +Defined in: [packages/db/src/types.ts:174](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L174) ## Type Parameters @@ -21,7 +21,7 @@ Defined in: [packages/db/src/types.ts:168](https://github.com/TanStack/db/blob/m optional autoCommit: boolean; ``` -Defined in: [packages/db/src/types.ts:172](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L172) +Defined in: [packages/db/src/types.ts:178](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L178) *** @@ -31,7 +31,7 @@ Defined in: [packages/db/src/types.ts:172](https://github.com/TanStack/db/blob/m optional id: string; ``` -Defined in: [packages/db/src/types.ts:170](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L170) +Defined in: [packages/db/src/types.ts:176](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L176) Unique identifier for the transaction @@ -43,7 +43,7 @@ Unique identifier for the transaction optional metadata: Record; ``` -Defined in: [packages/db/src/types.ts:175](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L175) +Defined in: [packages/db/src/types.ts:181](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L181) Custom metadata to associate with the transaction @@ -55,4 +55,4 @@ Custom metadata to associate with the transaction mutationFn: MutationFn; ``` -Defined in: [packages/db/src/types.ts:173](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L173) +Defined in: [packages/db/src/types.ts:179](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L179) diff --git a/docs/reference/powersync-db-collection/functions/powerSyncCollectionOptions.md b/docs/reference/powersync-db-collection/functions/powerSyncCollectionOptions.md index ca73a4a4bc..9954738cc6 100644 --- a/docs/reference/powersync-db-collection/functions/powerSyncCollectionOptions.md +++ b/docs/reference/powersync-db-collection/functions/powerSyncCollectionOptions.md @@ -13,7 +13,7 @@ Implementation of powerSyncCollectionOptions that handles both schema and non-sc function powerSyncCollectionOptions(config): EnhancedPowerSyncCollectionConfig, never>; ``` -Defined in: [powersync.ts:78](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L78) +Defined in: [powersync.ts:79](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L79) Creates a PowerSync collection configuration with basic default validation. Input and Output types are the SQLite column types. @@ -66,7 +66,7 @@ const collection = createCollection( function powerSyncCollectionOptions(config): CollectionConfig, string, TSchema, PowerSyncCollectionUtils> & object & object; ``` -Defined in: [powersync.ts:135](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L135) +Defined in: [powersync.ts:136](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L136) Creates a PowerSync collection configuration with schema validation. @@ -141,7 +141,7 @@ const collection = createCollection( function powerSyncCollectionOptions(config): CollectionConfig, string, TSchema, PowerSyncCollectionUtils> & object & object; ``` -Defined in: [powersync.ts:203](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L203) +Defined in: [powersync.ts:204](https://github.com/TanStack/db/blob/main/packages/powersync-db-collection/src/powersync.ts#L204) Creates a PowerSync collection configuration with schema validation. diff --git a/docs/reference/query-db-collection/functions/queryCollectionOptions.md b/docs/reference/query-db-collection/functions/queryCollectionOptions.md index 8984ed90db..57effeb401 100644 --- a/docs/reference/query-db-collection/functions/queryCollectionOptions.md +++ b/docs/reference/query-db-collection/functions/queryCollectionOptions.md @@ -11,7 +11,7 @@ title: queryCollectionOptions function queryCollectionOptions(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:531](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L531) +Defined in: [packages/query-db-collection/src/query.ts:535](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L535) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -151,7 +151,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:566](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L566) +Defined in: [packages/query-db-collection/src/query.ts:570](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L570) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -291,7 +291,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig, TKey, T, QueryCollectionUtils, TKey, InferSchemaInput, TError>> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:599](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L599) +Defined in: [packages/query-db-collection/src/query.ts:603](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L603) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. @@ -423,7 +423,7 @@ const todosCollection = createCollection( function queryCollectionOptions(config): CollectionConfig> & object; ``` -Defined in: [packages/query-db-collection/src/query.ts:633](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L633) +Defined in: [packages/query-db-collection/src/query.ts:637](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L637) Creates query collection options for use with a standard Collection. This integrates TanStack Query with TanStack DB for automatic synchronization. diff --git a/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md b/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md index 123e7606e9..33a7362de2 100644 --- a/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md +++ b/docs/reference/query-db-collection/interfaces/QueryCollectionConfig.md @@ -5,7 +5,7 @@ title: QueryCollectionConfig # Interface: QueryCollectionConfig\ -Defined in: [packages/query-db-collection/src/query.ts:96](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L96) +Defined in: [packages/query-db-collection/src/query.ts:100](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L100) Configuration options for creating a Query Collection @@ -63,7 +63,7 @@ The schema type for validation optional enabled: Enabled; ``` -Defined in: [packages/query-db-collection/src/query.ts:125](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L125) +Defined in: [packages/query-db-collection/src/query.ts:129](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L129) Whether the query should automatically run (default: true) @@ -75,7 +75,7 @@ Whether the query should automatically run (default: true) optional gcTime: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:160](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L160) +Defined in: [packages/query-db-collection/src/query.ts:164](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L164) Time in milliseconds after which the collection will be garbage collected when it has no active subscribers. Defaults to 5 minutes (300000ms). @@ -94,7 +94,7 @@ BaseCollectionConfig.gcTime optional initialData: TQueryData | InitialDataFunction; ``` -Defined in: [packages/query-db-collection/src/query.ts:200](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L200) +Defined in: [packages/query-db-collection/src/query.ts:204](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L204) Data used to initialize the TanStack Query cache for an eager collection. The value has the original Query response shape and is projected through @@ -108,7 +108,7 @@ the collection's select option before rows are materialized. optional initialDataUpdatedAt: number | () => number | undefined; ``` -Defined in: [packages/query-db-collection/src/query.ts:208](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L208) +Defined in: [packages/query-db-collection/src/query.ts:212](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L212) The timestamp TanStack Query uses to determine initialData freshness. @@ -120,7 +120,7 @@ The timestamp TanStack Query uses to determine initialData freshness. optional meta: Record; ``` -Defined in: [packages/query-db-collection/src/query.ts:237](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L237) +Defined in: [packages/query-db-collection/src/query.ts:241](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L241) Metadata to pass to the query. Available in queryFn via context.meta @@ -152,7 +152,7 @@ meta: { optional networkMode: NetworkMode; ``` -Defined in: [packages/query-db-collection/src/query.ts:188](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L188) +Defined in: [packages/query-db-collection/src/query.ts:192](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L192) *** @@ -162,7 +162,7 @@ Defined in: [packages/query-db-collection/src/query.ts:188](https://github.com/T optional persistedGcTime: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:215](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L215) +Defined in: [packages/query-db-collection/src/query.ts:219](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L219) *** @@ -172,7 +172,7 @@ Defined in: [packages/query-db-collection/src/query.ts:215](https://github.com/T queryClient: QueryClient; ``` -Defined in: [packages/query-db-collection/src/query.ts:121](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L121) +Defined in: [packages/query-db-collection/src/query.ts:125](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L125) The TanStack Query client instance @@ -184,7 +184,7 @@ The TanStack Query client instance queryFn: TQueryFn extends (context) => any[] | Promise ? (context) => T[] | Promise : TQueryFn; ``` -Defined in: [packages/query-db-collection/src/query.ts:110](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L110) +Defined in: [packages/query-db-collection/src/query.ts:114](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L114) Function that fetches data from the server. Must return the complete collection state @@ -196,7 +196,7 @@ Function that fetches data from the server. Must return the complete collection queryKey: TQueryKey | TQueryKeyBuilder; ``` -Defined in: [packages/query-db-collection/src/query.ts:108](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L108) +Defined in: [packages/query-db-collection/src/query.ts:112](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L112) The query key used by TanStack Query to identify this query @@ -208,7 +208,7 @@ The query key used by TanStack Query to identify this query optional refetchInterval: number | false | (query) => number | false | undefined; ``` -Defined in: [packages/query-db-collection/src/query.ts:132](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L132) +Defined in: [packages/query-db-collection/src/query.ts:136](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L136) *** @@ -218,7 +218,7 @@ Defined in: [packages/query-db-collection/src/query.ts:132](https://github.com/T optional refetchOnMount: boolean | "always" | (query) => boolean | "always"; ``` -Defined in: [packages/query-db-collection/src/query.ts:181](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L181) +Defined in: [packages/query-db-collection/src/query.ts:185](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L185) *** @@ -228,7 +228,7 @@ Defined in: [packages/query-db-collection/src/query.ts:181](https://github.com/T optional refetchOnReconnect: boolean | "always" | (query) => boolean | "always"; ``` -Defined in: [packages/query-db-collection/src/query.ts:174](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L174) +Defined in: [packages/query-db-collection/src/query.ts:178](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L178) *** @@ -238,7 +238,7 @@ Defined in: [packages/query-db-collection/src/query.ts:174](https://github.com/T optional refetchOnWindowFocus: boolean | "always" | (query) => boolean | "always"; ``` -Defined in: [packages/query-db-collection/src/query.ts:167](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L167) +Defined in: [packages/query-db-collection/src/query.ts:171](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L171) *** @@ -248,7 +248,7 @@ Defined in: [packages/query-db-collection/src/query.ts:167](https://github.com/T optional retry: RetryValue; ``` -Defined in: [packages/query-db-collection/src/query.ts:139](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L139) +Defined in: [packages/query-db-collection/src/query.ts:143](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L143) *** @@ -258,7 +258,7 @@ Defined in: [packages/query-db-collection/src/query.ts:139](https://github.com/T optional retryDelay: RetryDelayValue; ``` -Defined in: [packages/query-db-collection/src/query.ts:146](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L146) +Defined in: [packages/query-db-collection/src/query.ts:150](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L150) *** @@ -268,7 +268,7 @@ Defined in: [packages/query-db-collection/src/query.ts:146](https://github.com/T optional select: (data) => T[]; ``` -Defined in: [packages/query-db-collection/src/query.ts:119](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L119) +Defined in: [packages/query-db-collection/src/query.ts:123](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L123) Extracts the row array TanStack DB materializes from the Query response. The Query cache keeps the original response shape. @@ -291,4 +291,4 @@ The Query cache keeps the original response shape. optional staleTime: StaleTimeFunction; ``` -Defined in: [packages/query-db-collection/src/query.ts:153](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L153) +Defined in: [packages/query-db-collection/src/query.ts:157](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L157) diff --git a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md index 2bfe98b3b5..b1c7271fc2 100644 --- a/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md +++ b/docs/reference/query-db-collection/interfaces/QueryCollectionUtils.md @@ -5,7 +5,7 @@ title: QueryCollectionUtils # Interface: QueryCollectionUtils\ -Defined in: [packages/query-db-collection/src/query.ts:256](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L256) +Defined in: [packages/query-db-collection/src/query.ts:260](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L260) Utility methods available on Query Collections for direct writes and manual operations. Direct writes bypass the normal query/mutation flow and write directly to the synced data store. @@ -54,7 +54,7 @@ The type of errors that can occur during queries clearError: () => Promise; ``` -Defined in: [packages/query-db-collection/src/query.ts:301](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L301) +Defined in: [packages/query-db-collection/src/query.ts:305](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L305) Clear the error state and trigger a refetch of the query @@ -76,7 +76,7 @@ Error if the refetch fails dataUpdatedAt: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:292](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L292) +Defined in: [packages/query-db-collection/src/query.ts:296](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L296) Get timestamp of last successful data update (in milliseconds) @@ -88,7 +88,7 @@ Get timestamp of last successful data update (in milliseconds) errorCount: number; ``` -Defined in: [packages/query-db-collection/src/query.ts:284](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L284) +Defined in: [packages/query-db-collection/src/query.ts:288](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L288) Get the number of consecutive sync failures. Incremented only when query fails completely (not per retry attempt); reset on success. @@ -101,7 +101,7 @@ Incremented only when query fails completely (not per retry attempt); reset on s fetchStatus: "idle" | "fetching" | "paused"; ``` -Defined in: [packages/query-db-collection/src/query.ts:294](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L294) +Defined in: [packages/query-db-collection/src/query.ts:298](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L298) Get current fetch status @@ -113,7 +113,7 @@ Get current fetch status isError: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:279](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L279) +Defined in: [packages/query-db-collection/src/query.ts:283](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L283) Check if the collection is in an error state @@ -125,7 +125,7 @@ Check if the collection is in an error state isFetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:286](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L286) +Defined in: [packages/query-db-collection/src/query.ts:290](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L290) Check if query is currently fetching (initial or background) @@ -137,7 +137,7 @@ Check if query is currently fetching (initial or background) isLoading: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:290](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L290) +Defined in: [packages/query-db-collection/src/query.ts:294](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L294) Check if query is loading for the first time (no data yet) @@ -149,7 +149,7 @@ Check if query is loading for the first time (no data yet) isRefetching: boolean; ``` -Defined in: [packages/query-db-collection/src/query.ts:288](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L288) +Defined in: [packages/query-db-collection/src/query.ts:292](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L292) Check if query is refetching in background (not initial fetch) @@ -161,7 +161,7 @@ Check if query is refetching in background (not initial fetch) lastError: TError | undefined; ``` -Defined in: [packages/query-db-collection/src/query.ts:277](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L277) +Defined in: [packages/query-db-collection/src/query.ts:281](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L281) Get the last error encountered by the query (if any); reset on success @@ -173,7 +173,7 @@ Get the last error encountered by the query (if any); reset on success refetch: RefetchFn; ``` -Defined in: [packages/query-db-collection/src/query.ts:263](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L263) +Defined in: [packages/query-db-collection/src/query.ts:267](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L267) Manually trigger a refetch of the query @@ -185,7 +185,7 @@ Manually trigger a refetch of the query writeBatch: (callback) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:273](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L273) +Defined in: [packages/query-db-collection/src/query.ts:277](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L277) Execute multiple write operations as a single atomic batch to the synced data store @@ -207,7 +207,7 @@ Execute multiple write operations as a single atomic batch to the synced data st writeDelete: (keys) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:269](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L269) +Defined in: [packages/query-db-collection/src/query.ts:273](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L273) Delete one or more items directly from the synced data store without triggering a query refetch or optimistic update @@ -229,7 +229,7 @@ Delete one or more items directly from the synced data store without triggering writeInsert: (data) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:265](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L265) +Defined in: [packages/query-db-collection/src/query.ts:269](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L269) Insert one or more items directly into the synced data store without triggering a query refetch or optimistic update @@ -251,7 +251,7 @@ Insert one or more items directly into the synced data store without triggering writeUpdate: (updates) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:267](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L267) +Defined in: [packages/query-db-collection/src/query.ts:271](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L271) Update one or more items directly in the synced data store without triggering a query refetch or optimistic update @@ -273,7 +273,7 @@ Update one or more items directly in the synced data store without triggering a writeUpsert: (data) => void; ``` -Defined in: [packages/query-db-collection/src/query.ts:271](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L271) +Defined in: [packages/query-db-collection/src/query.ts:275](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/query.ts#L275) Insert or update one or more items directly in the synced data store without triggering a query refetch or optimistic update diff --git a/docs/reference/query-db-collection/type-aliases/SyncOperation.md b/docs/reference/query-db-collection/type-aliases/SyncOperation.md index 9cb0b5eb9a..39261d7328 100644 --- a/docs/reference/query-db-collection/type-aliases/SyncOperation.md +++ b/docs/reference/query-db-collection/type-aliases/SyncOperation.md @@ -25,7 +25,7 @@ type SyncOperation = }; ``` -Defined in: [packages/query-db-collection/src/manual-sync.ts:20](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/manual-sync.ts#L20) +Defined in: [packages/query-db-collection/src/manual-sync.ts:24](https://github.com/TanStack/db/blob/main/packages/query-db-collection/src/manual-sync.ts#L24) ## Type Parameters diff --git a/docs/reference/type-aliases/ChangeListener.md b/docs/reference/type-aliases/ChangeListener.md index 67d9cc1d13..c1b49f6509 100644 --- a/docs/reference/type-aliases/ChangeListener.md +++ b/docs/reference/type-aliases/ChangeListener.md @@ -9,7 +9,7 @@ title: ChangeListener type ChangeListener = (changes) => void; ``` -Defined in: [packages/db/src/types.ts:948](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L948) +Defined in: [packages/db/src/types.ts:991](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L991) Function type for listening to collection changes diff --git a/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md b/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md index bdb10ceee5..15f2a6708a 100644 --- a/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md +++ b/docs/reference/type-aliases/ChangeMessageOrDeleteKeyMessage.md @@ -11,7 +11,7 @@ type ChangeMessageOrDeleteKeyMessage = | DeleteKeyMessage; ``` -Defined in: [packages/db/src/types.ts:425](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L425) +Defined in: [packages/db/src/types.ts:466](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L466) ## Type Parameters diff --git a/docs/reference/type-aliases/ChangesPayload.md b/docs/reference/type-aliases/ChangesPayload.md index b777136c36..2064494a6b 100644 --- a/docs/reference/type-aliases/ChangesPayload.md +++ b/docs/reference/type-aliases/ChangesPayload.md @@ -9,7 +9,7 @@ title: ChangesPayload type ChangesPayload = ChangeMessage, TKey>[]; ``` -Defined in: [packages/db/src/types.ts:808](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L808) +Defined in: [packages/db/src/types.ts:849](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L849) ## Type Parameters diff --git a/docs/reference/type-aliases/CleanupFn.md b/docs/reference/type-aliases/CleanupFn.md index 2eca22c191..c993e5c907 100644 --- a/docs/reference/type-aliases/CleanupFn.md +++ b/docs/reference/type-aliases/CleanupFn.md @@ -9,7 +9,7 @@ title: CleanupFn type CleanupFn = () => void; ``` -Defined in: [packages/db/src/types.ts:326](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L326) +Defined in: [packages/db/src/types.ts:358](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L358) ## Returns diff --git a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md index 2f8fde7563..ece7e087e9 100644 --- a/docs/reference/type-aliases/CollectionConfigSingleRowOption.md +++ b/docs/reference/type-aliases/CollectionConfigSingleRowOption.md @@ -9,7 +9,7 @@ title: CollectionConfigSingleRowOption type CollectionConfigSingleRowOption = CollectionConfig & MaybeSingleResult; ``` -Defined in: [packages/db/src/types.ts:801](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L801) +Defined in: [packages/db/src/types.ts:842](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L842) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionMaterializeOptions.md b/docs/reference/type-aliases/CollectionMaterializeOptions.md index 044e46c7b1..8e5331d31e 100644 --- a/docs/reference/type-aliases/CollectionMaterializeOptions.md +++ b/docs/reference/type-aliases/CollectionMaterializeOptions.md @@ -9,7 +9,7 @@ title: CollectionMaterializeOptions type CollectionMaterializeOptions = object; ``` -Defined in: [packages/db/src/client.ts:93](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L93) +Defined in: [packages/db/src/client.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L94) ## Type Parameters @@ -25,4 +25,4 @@ Defined in: [packages/db/src/client.ts:93](https://github.com/TanStack/db/blob/m optional initialData: T[]; ``` -Defined in: [packages/db/src/client.ts:94](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L94) +Defined in: [packages/db/src/client.ts:95](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L95) diff --git a/docs/reference/type-aliases/CollectionOptions.md b/docs/reference/type-aliases/CollectionOptions.md index 457d84f15c..f701d7d332 100644 --- a/docs/reference/type-aliases/CollectionOptions.md +++ b/docs/reference/type-aliases/CollectionOptions.md @@ -9,7 +9,7 @@ title: CollectionOptions type CollectionOptions = CollectionOptionsIdentity; ``` -Defined in: [packages/db/src/client.ts:38](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L38) +Defined in: [packages/db/src/client.ts:39](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L39) ## Type Parameters diff --git a/docs/reference/type-aliases/CollectionStatus.md b/docs/reference/type-aliases/CollectionStatus.md index a01044c831..26d8095249 100644 --- a/docs/reference/type-aliases/CollectionStatus.md +++ b/docs/reference/type-aliases/CollectionStatus.md @@ -9,7 +9,7 @@ title: CollectionStatus type CollectionStatus = "idle" | "loading" | "ready" | "error" | "cleaned-up"; ``` -Defined in: [packages/db/src/types.ts:536](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L536) +Defined in: [packages/db/src/types.ts:577](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L577) Collection status values for lifecycle management diff --git a/docs/reference/type-aliases/CursorExpressions.md b/docs/reference/type-aliases/CursorExpressions.md index 2f84898f30..2f14b5e515 100644 --- a/docs/reference/type-aliases/CursorExpressions.md +++ b/docs/reference/type-aliases/CursorExpressions.md @@ -9,7 +9,7 @@ title: CursorExpressions type CursorExpressions = object; ``` -Defined in: [packages/db/src/types.ts:266](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L266) +Defined in: [packages/db/src/types.ts:283](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L283) Cursor expressions for pagination, passed separately from the main `where` clause. The sync layer can choose to use cursor-based pagination (combining these with the where) @@ -25,7 +25,7 @@ Neither expression includes the main `where` clause - they are cursor-specific o optional lastKey: string | number; ``` -Defined in: [packages/db/src/types.ts:284](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L284) +Defined in: [packages/db/src/types.ts:301](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L301) The key of the last item that was loaded. Can be used by sync layers for tracking or deduplication. @@ -38,7 +38,7 @@ Can be used by sync layers for tracking or deduplication. whereCurrent: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:279](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L279) +Defined in: [packages/db/src/types.ts:296](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L296) Expression for rows equal to the current cursor value (first orderBy column only). Used to handle tie-breaking/duplicates at the boundary. @@ -52,7 +52,7 @@ Example: eq(col1, v1) or for Dates: and(gte(col1, v1), lt(col1, v1+1ms)) whereFrom: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:273](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L273) +Defined in: [packages/db/src/types.ts:290](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L290) Expression for rows greater than (after) the cursor value. For multi-column orderBy, this is a composite cursor using OR of conditions. diff --git a/docs/reference/type-aliases/DbClientEvent.md b/docs/reference/type-aliases/DbClientEvent.md index abbdee4b35..5481d5895d 100644 --- a/docs/reference/type-aliases/DbClientEvent.md +++ b/docs/reference/type-aliases/DbClientEvent.md @@ -17,4 +17,4 @@ type DbClientEvent = }; ``` -Defined in: [packages/db/src/client.ts:145](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L145) +Defined in: [packages/db/src/client.ts:146](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L146) diff --git a/docs/reference/type-aliases/DbClientLiveQuery.md b/docs/reference/type-aliases/DbClientLiveQuery.md index 6c091536cb..a1bcf85c2a 100644 --- a/docs/reference/type-aliases/DbClientLiveQuery.md +++ b/docs/reference/type-aliases/DbClientLiveQuery.md @@ -9,7 +9,7 @@ title: DbClientLiveQuery type DbClientLiveQuery = object; ``` -Defined in: [packages/db/src/client.ts:136](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L136) +Defined in: [packages/db/src/client.ts:137](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L137) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/client.ts:136](https://github.com/TanStack/db/blob/ readonly dehydratedAt: number; ``` -Defined in: [packages/db/src/client.ts:138](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L138) +Defined in: [packages/db/src/client.ts:139](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L139) *** @@ -29,7 +29,7 @@ Defined in: [packages/db/src/client.ts:138](https://github.com/TanStack/db/blob/ readonly optional error: unknown; ``` -Defined in: [packages/db/src/client.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L142) +Defined in: [packages/db/src/client.ts:143](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L143) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/client.ts:142](https://github.com/TanStack/db/blob/ readonly promise: Promise; ``` -Defined in: [packages/db/src/client.ts:140](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L140) +Defined in: [packages/db/src/client.ts:141](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L141) *** @@ -49,7 +49,7 @@ Defined in: [packages/db/src/client.ts:140](https://github.com/TanStack/db/blob/ readonly queryHash: string; ``` -Defined in: [packages/db/src/client.ts:137](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L137) +Defined in: [packages/db/src/client.ts:138](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L138) *** @@ -59,7 +59,7 @@ Defined in: [packages/db/src/client.ts:137](https://github.com/TanStack/db/blob/ readonly optional snapshot: DehydratedLiveQueryResult; ``` -Defined in: [packages/db/src/client.ts:141](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L141) +Defined in: [packages/db/src/client.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L142) *** @@ -69,4 +69,4 @@ Defined in: [packages/db/src/client.ts:141](https://github.com/TanStack/db/blob/ readonly status: DbClientLiveQueryState; ``` -Defined in: [packages/db/src/client.ts:139](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L139) +Defined in: [packages/db/src/client.ts:140](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L140) diff --git a/docs/reference/type-aliases/DbClientLiveQueryState.md b/docs/reference/type-aliases/DbClientLiveQueryState.md index 18d88b6c6e..44c35dddcf 100644 --- a/docs/reference/type-aliases/DbClientLiveQueryState.md +++ b/docs/reference/type-aliases/DbClientLiveQueryState.md @@ -9,4 +9,4 @@ title: DbClientLiveQueryState type DbClientLiveQueryState = "pending" | "success" | "error"; ``` -Defined in: [packages/db/src/client.ts:134](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L134) +Defined in: [packages/db/src/client.ts:135](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L135) diff --git a/docs/reference/type-aliases/DbClientOptions.md b/docs/reference/type-aliases/DbClientOptions.md index 37b88a4074..5bf1450350 100644 --- a/docs/reference/type-aliases/DbClientOptions.md +++ b/docs/reference/type-aliases/DbClientOptions.md @@ -9,4 +9,4 @@ title: DbClientOptions type DbClientOptions = Record; ``` -Defined in: [packages/db/src/client.ts:177](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L177) +Defined in: [packages/db/src/client.ts:178](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L178) diff --git a/docs/reference/type-aliases/DehydrateDbClientOptions.md b/docs/reference/type-aliases/DehydrateDbClientOptions.md index a8fa77611c..cd23499b1c 100644 --- a/docs/reference/type-aliases/DehydrateDbClientOptions.md +++ b/docs/reference/type-aliases/DehydrateDbClientOptions.md @@ -9,7 +9,7 @@ title: DehydrateDbClientOptions type DehydrateDbClientOptions = object; ``` -Defined in: [packages/db/src/client.ts:155](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L155) +Defined in: [packages/db/src/client.ts:156](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L156) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/client.ts:155](https://github.com/TanStack/db/blob/ optional shouldDehydrateCollection: (collection) => boolean; ``` -Defined in: [packages/db/src/client.ts:156](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L156) +Defined in: [packages/db/src/client.ts:157](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L157) #### Parameters @@ -39,7 +39,7 @@ Defined in: [packages/db/src/client.ts:156](https://github.com/TanStack/db/blob/ optional shouldDehydrateLiveQuery: (query) => boolean; ``` -Defined in: [packages/db/src/client.ts:157](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L157) +Defined in: [packages/db/src/client.ts:158](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L158) #### Parameters diff --git a/docs/reference/type-aliases/DehydratedCollectionChunk.md b/docs/reference/type-aliases/DehydratedCollectionChunk.md index 44bb44b96f..c6cbcb329a 100644 --- a/docs/reference/type-aliases/DehydratedCollectionChunk.md +++ b/docs/reference/type-aliases/DehydratedCollectionChunk.md @@ -9,7 +9,7 @@ title: DehydratedCollectionChunk type DehydratedCollectionChunk = object; ``` -Defined in: [packages/db/src/client.ts:106](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L106) +Defined in: [packages/db/src/client.ts:107](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L107) ## Type Parameters @@ -29,7 +29,7 @@ Defined in: [packages/db/src/client.ts:106](https://github.com/TanStack/db/blob/ collectionId: string; ``` -Defined in: [packages/db/src/client.ts:110](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L110) +Defined in: [packages/db/src/client.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L111) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/client.ts:110](https://github.com/TanStack/db/blob/ rows: DehydratedCollectionRow[]; ``` -Defined in: [packages/db/src/client.ts:111](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L111) +Defined in: [packages/db/src/client.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L112) *** @@ -49,4 +49,4 @@ Defined in: [packages/db/src/client.ts:111](https://github.com/TanStack/db/blob/ optional syncMeta: unknown; ``` -Defined in: [packages/db/src/client.ts:112](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L112) +Defined in: [packages/db/src/client.ts:113](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L113) diff --git a/docs/reference/type-aliases/DehydratedCollectionRow.md b/docs/reference/type-aliases/DehydratedCollectionRow.md index 22da6a6839..143761ff30 100644 --- a/docs/reference/type-aliases/DehydratedCollectionRow.md +++ b/docs/reference/type-aliases/DehydratedCollectionRow.md @@ -9,7 +9,7 @@ title: DehydratedCollectionRow type DehydratedCollectionRow = object; ``` -Defined in: [packages/db/src/client.ts:97](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L97) +Defined in: [packages/db/src/client.ts:98](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L98) ## Type Parameters @@ -29,7 +29,7 @@ Defined in: [packages/db/src/client.ts:97](https://github.com/TanStack/db/blob/m key: TKey; ``` -Defined in: [packages/db/src/client.ts:101](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L101) +Defined in: [packages/db/src/client.ts:102](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L102) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/client.ts:101](https://github.com/TanStack/db/blob/ optional metadata: unknown; ``` -Defined in: [packages/db/src/client.ts:103](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L103) +Defined in: [packages/db/src/client.ts:104](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L104) *** @@ -49,4 +49,4 @@ Defined in: [packages/db/src/client.ts:103](https://github.com/TanStack/db/blob/ value: T; ``` -Defined in: [packages/db/src/client.ts:102](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L102) +Defined in: [packages/db/src/client.ts:103](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L103) diff --git a/docs/reference/type-aliases/DehydratedDbState.md b/docs/reference/type-aliases/DehydratedDbState.md index 83c4a91b36..97de8572d3 100644 --- a/docs/reference/type-aliases/DehydratedDbState.md +++ b/docs/reference/type-aliases/DehydratedDbState.md @@ -9,7 +9,7 @@ title: DehydratedDbState type DehydratedDbState = object; ``` -Defined in: [packages/db/src/client.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L129) +Defined in: [packages/db/src/client.ts:130](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L130) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/client.ts:129](https://github.com/TanStack/db/blob/ collections: DehydratedCollectionChunk[]; ``` -Defined in: [packages/db/src/client.ts:130](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L130) +Defined in: [packages/db/src/client.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L131) *** @@ -29,4 +29,4 @@ Defined in: [packages/db/src/client.ts:130](https://github.com/TanStack/db/blob/ optional liveQueries: DehydratedLiveQuery[]; ``` -Defined in: [packages/db/src/client.ts:131](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L131) +Defined in: [packages/db/src/client.ts:132](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L132) diff --git a/docs/reference/type-aliases/DehydratedLiveQuery.md b/docs/reference/type-aliases/DehydratedLiveQuery.md index cc61a9f844..c24db1a55d 100644 --- a/docs/reference/type-aliases/DehydratedLiveQuery.md +++ b/docs/reference/type-aliases/DehydratedLiveQuery.md @@ -9,7 +9,7 @@ title: DehydratedLiveQuery type DehydratedLiveQuery = object; ``` -Defined in: [packages/db/src/client.ts:115](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L115) +Defined in: [packages/db/src/client.ts:116](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L116) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/client.ts:115](https://github.com/TanStack/db/blob/ dehydratedAt: number; ``` -Defined in: [packages/db/src/client.ts:117](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L117) +Defined in: [packages/db/src/client.ts:118](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L118) *** @@ -29,7 +29,7 @@ Defined in: [packages/db/src/client.ts:117](https://github.com/TanStack/db/blob/ optional promise: Promise; ``` -Defined in: [packages/db/src/client.ts:119](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L119) +Defined in: [packages/db/src/client.ts:120](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L120) *** @@ -39,7 +39,7 @@ Defined in: [packages/db/src/client.ts:119](https://github.com/TanStack/db/blob/ queryHash: string; ``` -Defined in: [packages/db/src/client.ts:116](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L116) +Defined in: [packages/db/src/client.ts:117](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L117) *** @@ -49,4 +49,4 @@ Defined in: [packages/db/src/client.ts:116](https://github.com/TanStack/db/blob/ optional snapshot: DehydratedLiveQueryResult; ``` -Defined in: [packages/db/src/client.ts:118](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L118) +Defined in: [packages/db/src/client.ts:119](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L119) diff --git a/docs/reference/type-aliases/DehydratedLiveQueryResult.md b/docs/reference/type-aliases/DehydratedLiveQueryResult.md index 5b6db980ab..d989ecaca6 100644 --- a/docs/reference/type-aliases/DehydratedLiveQueryResult.md +++ b/docs/reference/type-aliases/DehydratedLiveQueryResult.md @@ -9,7 +9,7 @@ title: DehydratedLiveQueryResult type DehydratedLiveQueryResult = object; ``` -Defined in: [packages/db/src/client.ts:122](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L122) +Defined in: [packages/db/src/client.ts:123](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L123) ## Type Parameters @@ -29,4 +29,4 @@ Defined in: [packages/db/src/client.ts:122](https://github.com/TanStack/db/blob/ rows: DehydratedCollectionRow[]; ``` -Defined in: [packages/db/src/client.ts:126](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L126) +Defined in: [packages/db/src/client.ts:127](https://github.com/TanStack/db/blob/main/packages/db/src/client.ts#L127) diff --git a/docs/reference/type-aliases/DeleteKeyMessage.md b/docs/reference/type-aliases/DeleteKeyMessage.md index 0663ddf992..3bf5e04b68 100644 --- a/docs/reference/type-aliases/DeleteKeyMessage.md +++ b/docs/reference/type-aliases/DeleteKeyMessage.md @@ -9,7 +9,7 @@ title: DeleteKeyMessage type DeleteKeyMessage = Omit, "value" | "previousValue" | "type"> & object; ``` -Defined in: [packages/db/src/types.ts:420](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L420) +Defined in: [packages/db/src/types.ts:461](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L461) ## Type Declaration diff --git a/docs/reference/type-aliases/DeleteMutationFn.md b/docs/reference/type-aliases/DeleteMutationFn.md index 95a6e48e6c..b035a7e491 100644 --- a/docs/reference/type-aliases/DeleteMutationFn.md +++ b/docs/reference/type-aliases/DeleteMutationFn.md @@ -9,7 +9,7 @@ title: DeleteMutationFn type DeleteMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:513](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L513) +Defined in: [packages/db/src/types.ts:554](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L554) ## Type Parameters diff --git a/docs/reference/type-aliases/DeleteMutationFnParams.md b/docs/reference/type-aliases/DeleteMutationFnParams.md index f25de7f437..df39a4d526 100644 --- a/docs/reference/type-aliases/DeleteMutationFnParams.md +++ b/docs/reference/type-aliases/DeleteMutationFnParams.md @@ -9,7 +9,7 @@ title: DeleteMutationFnParams type DeleteMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:490](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L490) +Defined in: [packages/db/src/types.ts:531](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L531) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:490](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L496) +Defined in: [packages/db/src/types.ts:537](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L537) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:496](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:495](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L495) +Defined in: [packages/db/src/types.ts:536](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L536) diff --git a/docs/reference/type-aliases/DemandKey.md b/docs/reference/type-aliases/DemandKey.md new file mode 100644 index 0000000000..32ad56ee05 --- /dev/null +++ b/docs/reference/type-aliases/DemandKey.md @@ -0,0 +1,22 @@ +--- +id: DemandKey +title: DemandKey +--- + +# Type Alias: DemandKey + +```ts +type DemandKey = string & object; +``` + +Defined in: [packages/db/src/query/ir-stable-identity.ts:49](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L49) + +Exact identity for one loadSubset demand, including its requested window. + +## Type Declaration + +### \[demandKeyBrand\] + +```ts +readonly [demandKeyBrand]: true; +``` diff --git a/docs/reference/type-aliases/ExtractContext.md b/docs/reference/type-aliases/ExtractContext.md index 74edadd12c..19c03629c5 100644 --- a/docs/reference/type-aliases/ExtractContext.md +++ b/docs/reference/type-aliases/ExtractContext.md @@ -9,7 +9,7 @@ title: ExtractContext type ExtractContext = T extends BaseQueryBuilder ? TContext : T extends QueryBuilder ? TContext : never; ``` -Defined in: [packages/db/src/query/builder/index.ts:1489](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1489) +Defined in: [packages/db/src/query/builder/index.ts:1642](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1642) ## Type Parameters diff --git a/docs/reference/type-aliases/InitialQueryBuilder.md b/docs/reference/type-aliases/InitialQueryBuilder.md index f13a056f89..6bbf74e4b6 100644 --- a/docs/reference/type-aliases/InitialQueryBuilder.md +++ b/docs/reference/type-aliases/InitialQueryBuilder.md @@ -9,4 +9,4 @@ title: InitialQueryBuilder type InitialQueryBuilder = Pick, "from" | "unionAll">; ``` -Defined in: [packages/db/src/query/builder/index.ts:1472](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1472) +Defined in: [packages/db/src/query/builder/index.ts:1625](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1625) diff --git a/docs/reference/type-aliases/InputRow.md b/docs/reference/type-aliases/InputRow.md index 99048767e1..1a30859e10 100644 --- a/docs/reference/type-aliases/InputRow.md +++ b/docs/reference/type-aliases/InputRow.md @@ -9,6 +9,6 @@ title: InputRow type InputRow = [unknown, Record]; ``` -Defined in: [packages/db/src/types.ts:816](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L816) +Defined in: [packages/db/src/types.ts:857](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L857) An input row from a collection diff --git a/docs/reference/type-aliases/InsertMutationFn.md b/docs/reference/type-aliases/InsertMutationFn.md index 2333994eec..622e13a5dd 100644 --- a/docs/reference/type-aliases/InsertMutationFn.md +++ b/docs/reference/type-aliases/InsertMutationFn.md @@ -9,7 +9,7 @@ title: InsertMutationFn type InsertMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:499](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L499) +Defined in: [packages/db/src/types.ts:540](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L540) ## Type Parameters diff --git a/docs/reference/type-aliases/InsertMutationFnParams.md b/docs/reference/type-aliases/InsertMutationFnParams.md index 3e9c17e75d..dc6e09af90 100644 --- a/docs/reference/type-aliases/InsertMutationFnParams.md +++ b/docs/reference/type-aliases/InsertMutationFnParams.md @@ -9,7 +9,7 @@ title: InsertMutationFnParams type InsertMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:482](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L482) +Defined in: [packages/db/src/types.ts:523](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L523) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:482](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:488](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L488) +Defined in: [packages/db/src/types.ts:529](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L529) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:488](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:487](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L487) +Defined in: [packages/db/src/types.ts:528](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L528) diff --git a/docs/reference/type-aliases/KeyedNamespacedRow.md b/docs/reference/type-aliases/KeyedNamespacedRow.md index ab0bf56964..ff81b38348 100644 --- a/docs/reference/type-aliases/KeyedNamespacedRow.md +++ b/docs/reference/type-aliases/KeyedNamespacedRow.md @@ -9,7 +9,7 @@ title: KeyedNamespacedRow type KeyedNamespacedRow = [unknown, NamespacedRow]; ``` -Defined in: [packages/db/src/types.ts:839](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L839) +Defined in: [packages/db/src/types.ts:880](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L880) A keyed namespaced row is a row with a key and a namespaced row This is the main representation of a row in a query pipeline diff --git a/docs/reference/type-aliases/KeyedStream.md b/docs/reference/type-aliases/KeyedStream.md index e1618ab6fe..a5b0b355a1 100644 --- a/docs/reference/type-aliases/KeyedStream.md +++ b/docs/reference/type-aliases/KeyedStream.md @@ -9,7 +9,7 @@ title: KeyedStream type KeyedStream = IStreamBuilder; ``` -Defined in: [packages/db/src/types.ts:822](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L822) +Defined in: [packages/db/src/types.ts:863](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L863) A keyed stream is a stream of rows This is used as the inputs from a collection to a query diff --git a/docs/reference/type-aliases/LiveQueryCollectionUtils.md b/docs/reference/type-aliases/LiveQueryCollectionUtils.md index badc09d5d4..620333d20e 100644 --- a/docs/reference/type-aliases/LiveQueryCollectionUtils.md +++ b/docs/reference/type-aliases/LiveQueryCollectionUtils.md @@ -52,6 +52,14 @@ Gets the current window (offset and limit) for an ordered query. The current window settings, or `undefined` if the query is not windowed +### lastSubsetError + +```ts +readonly lastSubsetError: unknown | undefined; +``` + +Most recent subset-load failure observed by this live query. + ### setWindow() ```ts diff --git a/docs/reference/type-aliases/LoadSubsetFn.md b/docs/reference/type-aliases/LoadSubsetFn.md index 69d6ce0b4e..89fccaad76 100644 --- a/docs/reference/type-aliases/LoadSubsetFn.md +++ b/docs/reference/type-aliases/LoadSubsetFn.md @@ -9,7 +9,13 @@ title: LoadSubsetFn type LoadSubsetFn = (options) => true | Promise; ``` -Defined in: [packages/db/src/types.ts:322](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L322) +Defined in: [packages/db/src/types.ts:346](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L346) + +Loads one subset and transfers its ongoing resource ownership only after +returning `true` or a promise. An implementation that throws synchronously +must release any partially acquired resource before throwing. A successful +implementation must await or return every applied receipt from the sync +`commit()` calls that establish the loaded subset. ## Parameters diff --git a/docs/reference/type-aliases/LoadSubsetOptions.md b/docs/reference/type-aliases/LoadSubsetOptions.md index 7a9ce74a6d..a2f8153c03 100644 --- a/docs/reference/type-aliases/LoadSubsetOptions.md +++ b/docs/reference/type-aliases/LoadSubsetOptions.md @@ -9,7 +9,7 @@ title: LoadSubsetOptions type LoadSubsetOptions = object; ``` -Defined in: [packages/db/src/types.ts:287](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L287) +Defined in: [packages/db/src/types.ts:304](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L304) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:287](https://github.com/TanStack/db/blob/m optional cursor: CursorExpressions; ``` -Defined in: [packages/db/src/types.ts:299](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L299) +Defined in: [packages/db/src/types.ts:316](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L316) Cursor expressions for cursor-based pagination. These are separate from `where` - the sync layer should combine them if using cursor-based pagination. @@ -33,7 +33,7 @@ Neither expression includes the main `where` clause. optional limit: number; ``` -Defined in: [packages/db/src/types.ts:293](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L293) +Defined in: [packages/db/src/types.ts:310](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L310) The limit of the data to load @@ -45,7 +45,7 @@ The limit of the data to load optional offset: number; ``` -Defined in: [packages/db/src/types.ts:304](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L304) +Defined in: [packages/db/src/types.ts:321](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L321) Row offset for offset-based pagination. The sync layer can use this instead of `cursor` if it prefers offset-based pagination. @@ -58,7 +58,7 @@ The sync layer can use this instead of `cursor` if it prefers offset-based pagin optional orderBy: OrderBy; ``` -Defined in: [packages/db/src/types.ts:291](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L291) +Defined in: [packages/db/src/types.ts:308](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L308) The order by clause to sort the data @@ -70,7 +70,7 @@ The order by clause to sort the data optional signal: AbortSignal; ``` -Defined in: [packages/db/src/types.ts:310](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L310) +Defined in: [packages/db/src/types.ts:327](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L327) Aborted when this exact subset request is no longer current. Cancellation is cooperative: async sync adapters must check the signal immediately @@ -84,7 +84,7 @@ before installing a baseline or later request-scoped rows. optional subscription: Subscription; ``` -Defined in: [packages/db/src/types.ts:319](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L319) +Defined in: [packages/db/src/types.ts:336](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L336) The subscription that triggered the load. Advanced sync implementations can use this for: @@ -104,6 +104,6 @@ Available when called from CollectionSubscription, may be undefined for direct c optional where: BasicExpression; ``` -Defined in: [packages/db/src/types.ts:289](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L289) +Defined in: [packages/db/src/types.ts:306](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L306) The where expression to filter the data (does NOT include cursor expressions) diff --git a/docs/reference/type-aliases/MakeOptional.md b/docs/reference/type-aliases/MakeOptional.md index b912dbe215..6a0d4c03a2 100644 --- a/docs/reference/type-aliases/MakeOptional.md +++ b/docs/reference/type-aliases/MakeOptional.md @@ -9,7 +9,7 @@ title: MakeOptional type MakeOptional = Omit & Partial>; ``` -Defined in: [packages/db/src/types.ts:1027](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1027) +Defined in: [packages/db/src/types.ts:1070](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1070) ## Type Parameters diff --git a/docs/reference/type-aliases/MaybeSingleResult.md b/docs/reference/type-aliases/MaybeSingleResult.md index ecc0c88ccb..58da749c14 100644 --- a/docs/reference/type-aliases/MaybeSingleResult.md +++ b/docs/reference/type-aliases/MaybeSingleResult.md @@ -9,7 +9,7 @@ title: MaybeSingleResult type MaybeSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:793](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L793) +Defined in: [packages/db/src/types.ts:834](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L834) ## Properties @@ -19,6 +19,6 @@ Defined in: [packages/db/src/types.ts:793](https://github.com/TanStack/db/blob/m optional singleResult: true; ``` -Defined in: [packages/db/src/types.ts:797](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L797) +Defined in: [packages/db/src/types.ts:838](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L838) If enabled the collection will return a single object instead of an array diff --git a/docs/reference/type-aliases/MutationFn.md b/docs/reference/type-aliases/MutationFn.md index 647bb2cc48..6c129683d4 100644 --- a/docs/reference/type-aliases/MutationFn.md +++ b/docs/reference/type-aliases/MutationFn.md @@ -9,7 +9,12 @@ title: MutationFn type MutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:129](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L129) +Defined in: [packages/db/src/types.ts:135](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L135) + +Persists an optimistic transaction. Do not start or await collection or +live-query preloads here. Sync commits queue behind this function, so waiting +for preload work that needs one of those commits can deadlock the mutation. +Use the collection adapter's mutation acknowledgement helper instead. ## Type Parameters diff --git a/docs/reference/type-aliases/NamespacedAndKeyedStream.md b/docs/reference/type-aliases/NamespacedAndKeyedStream.md index 95c69cf07c..a4e0e4877e 100644 --- a/docs/reference/type-aliases/NamespacedAndKeyedStream.md +++ b/docs/reference/type-aliases/NamespacedAndKeyedStream.md @@ -9,7 +9,7 @@ title: NamespacedAndKeyedStream type NamespacedAndKeyedStream = IStreamBuilder; ``` -Defined in: [packages/db/src/types.ts:846](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L846) +Defined in: [packages/db/src/types.ts:887](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L887) A namespaced and keyed stream is a stream of rows This is used throughout a query pipeline and as the output from a query without diff --git a/docs/reference/type-aliases/NamespacedRow.md b/docs/reference/type-aliases/NamespacedRow.md index 6addb4a708..87f8a1541d 100644 --- a/docs/reference/type-aliases/NamespacedRow.md +++ b/docs/reference/type-aliases/NamespacedRow.md @@ -9,6 +9,6 @@ title: NamespacedRow type NamespacedRow = Record>; ``` -Defined in: [packages/db/src/types.ts:833](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L833) +Defined in: [packages/db/src/types.ts:874](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L874) A namespaced row is a row withing a pipeline that had each table wrapped in its alias diff --git a/docs/reference/type-aliases/NonEmptyArray.md b/docs/reference/type-aliases/NonEmptyArray.md index 6b43145f5a..95138b1eb6 100644 --- a/docs/reference/type-aliases/NonEmptyArray.md +++ b/docs/reference/type-aliases/NonEmptyArray.md @@ -9,7 +9,7 @@ title: NonEmptyArray type NonEmptyArray = [T, ...T[]]; ``` -Defined in: [packages/db/src/types.ts:136](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L136) +Defined in: [packages/db/src/types.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L142) Represents a non-empty array (at least one element) diff --git a/docs/reference/type-aliases/NonSingleResult.md b/docs/reference/type-aliases/NonSingleResult.md index 3c87ac9f70..91098eb7b5 100644 --- a/docs/reference/type-aliases/NonSingleResult.md +++ b/docs/reference/type-aliases/NonSingleResult.md @@ -9,7 +9,7 @@ title: NonSingleResult type NonSingleResult = object; ``` -Defined in: [packages/db/src/types.ts:789](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L789) +Defined in: [packages/db/src/types.ts:830](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L830) ## Properties @@ -19,4 +19,4 @@ Defined in: [packages/db/src/types.ts:789](https://github.com/TanStack/db/blob/m optional singleResult: never; ``` -Defined in: [packages/db/src/types.ts:790](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L790) +Defined in: [packages/db/src/types.ts:831](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L831) diff --git a/docs/reference/type-aliases/OperationType.md b/docs/reference/type-aliases/OperationType.md index f6bdc31170..9b78ab07d4 100644 --- a/docs/reference/type-aliases/OperationType.md +++ b/docs/reference/type-aliases/OperationType.md @@ -9,4 +9,4 @@ title: OperationType type OperationType = "insert" | "update" | "delete"; ``` -Defined in: [packages/db/src/types.ts:205](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L205) +Defined in: [packages/db/src/types.ts:211](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L211) diff --git a/docs/reference/type-aliases/OptimisticChangeMessage.md b/docs/reference/type-aliases/OptimisticChangeMessage.md index f89269bcff..3df67a4e4a 100644 --- a/docs/reference/type-aliases/OptimisticChangeMessage.md +++ b/docs/reference/type-aliases/OptimisticChangeMessage.md @@ -11,7 +11,7 @@ type OptimisticChangeMessage = | DeleteKeyMessage & object; ``` -Defined in: [packages/db/src/types.ts:430](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L430) +Defined in: [packages/db/src/types.ts:471](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L471) ## Type Parameters diff --git a/docs/reference/type-aliases/QueryBuilder.md b/docs/reference/type-aliases/QueryBuilder.md index c275a2969e..545fac31ff 100644 --- a/docs/reference/type-aliases/QueryBuilder.md +++ b/docs/reference/type-aliases/QueryBuilder.md @@ -9,7 +9,7 @@ title: QueryBuilder type QueryBuilder = Omit, "from" | "unionAll" | "_getQuery">; ``` -Defined in: [packages/db/src/query/builder/index.ts:1479](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1479) +Defined in: [packages/db/src/query/builder/index.ts:1632](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1632) ## Type Parameters diff --git a/docs/reference/type-aliases/QueryIdentity.md b/docs/reference/type-aliases/QueryIdentity.md new file mode 100644 index 0000000000..8a86fa2cff --- /dev/null +++ b/docs/reference/type-aliases/QueryIdentity.md @@ -0,0 +1,22 @@ +--- +id: QueryIdentity +title: QueryIdentity +--- + +# Type Alias: QueryIdentity + +```ts +type QueryIdentity = string & object; +``` + +Defined in: [packages/db/src/query/ir-stable-identity.ts:44](https://github.com/TanStack/db/blob/main/packages/db/src/query/ir-stable-identity.ts#L44) + +Semantic identity for a query plan, independent of its runtime owners. + +## Type Declaration + +### \[queryIdentityBrand\] + +```ts +readonly [queryIdentityBrand]: true; +``` diff --git a/docs/reference/type-aliases/QueryResult.md b/docs/reference/type-aliases/QueryResult.md index 95fbd291da..c1c3d6fdf8 100644 --- a/docs/reference/type-aliases/QueryResult.md +++ b/docs/reference/type-aliases/QueryResult.md @@ -9,7 +9,7 @@ title: QueryResult type QueryResult = GetResult>; ``` -Defined in: [packages/db/src/query/builder/index.ts:1497](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1497) +Defined in: [packages/db/src/query/builder/index.ts:1650](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1650) ## Type Parameters diff --git a/docs/reference/type-aliases/ResultStream.md b/docs/reference/type-aliases/ResultStream.md index e91162c816..f6d973e921 100644 --- a/docs/reference/type-aliases/ResultStream.md +++ b/docs/reference/type-aliases/ResultStream.md @@ -9,7 +9,7 @@ title: ResultStream type ResultStream = IStreamBuilder<[unknown, [any, string | undefined]]>; ``` -Defined in: [packages/db/src/types.ts:828](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L828) +Defined in: [packages/db/src/types.ts:869](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L869) Result stream type representing the output of compiled queries Always returns [key, [result, orderByIndex]] where orderByIndex is undefined for unordered queries diff --git a/docs/reference/type-aliases/Row.md b/docs/reference/type-aliases/Row.md index c2039a5edb..b3a6be1d4d 100644 --- a/docs/reference/type-aliases/Row.md +++ b/docs/reference/type-aliases/Row.md @@ -9,7 +9,7 @@ title: Row type Row = Record>; ``` -Defined in: [packages/db/src/types.ts:203](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L203) +Defined in: [packages/db/src/types.ts:209](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L209) ## Type Parameters diff --git a/docs/reference/type-aliases/SingleResult.md b/docs/reference/type-aliases/SingleResult.md index 7747106ec0..5a3bfc5ee2 100644 --- a/docs/reference/type-aliases/SingleResult.md +++ b/docs/reference/type-aliases/SingleResult.md @@ -9,7 +9,7 @@ title: SingleResult type SingleResult = object; ``` -Defined in: [packages/db/src/types.ts:785](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L785) +Defined in: [packages/db/src/types.ts:826](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L826) ## Properties @@ -19,4 +19,4 @@ Defined in: [packages/db/src/types.ts:785](https://github.com/TanStack/db/blob/m singleResult: true; ``` -Defined in: [packages/db/src/types.ts:786](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L786) +Defined in: [packages/db/src/types.ts:827](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L827) diff --git a/docs/reference/type-aliases/Source.md b/docs/reference/type-aliases/Source.md index 7ab6defc0a..42970e6ea1 100644 --- a/docs/reference/type-aliases/Source.md +++ b/docs/reference/type-aliases/Source.md @@ -23,8 +23,8 @@ Example: `{ users: usersCollection }` ```ts [alias: string]: + | QueryBuilder | CollectionImpl, any> - | CollectionOptionsIdentity -| QueryBuilder +| CollectionOptionsIdentity ``` diff --git a/docs/reference/type-aliases/StandardSchema.md b/docs/reference/type-aliases/StandardSchema.md index 3c2b8c4996..29ea427be2 100644 --- a/docs/reference/type-aliases/StandardSchema.md +++ b/docs/reference/type-aliases/StandardSchema.md @@ -9,7 +9,7 @@ title: StandardSchema type StandardSchema = StandardSchemaV1 & object; ``` -Defined in: [packages/db/src/types.ts:447](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L447) +Defined in: [packages/db/src/types.ts:488](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L488) The Standard Schema interface. This follows the standard-schema specification: https://github.com/standard-schema/standard-schema diff --git a/docs/reference/type-aliases/StandardSchemaAlias.md b/docs/reference/type-aliases/StandardSchemaAlias.md index 8e0b4b4ff2..ac47b2719b 100644 --- a/docs/reference/type-aliases/StandardSchemaAlias.md +++ b/docs/reference/type-aliases/StandardSchemaAlias.md @@ -9,7 +9,7 @@ title: StandardSchemaAlias type StandardSchemaAlias = StandardSchema; ``` -Defined in: [packages/db/src/types.ts:459](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L459) +Defined in: [packages/db/src/types.ts:500](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L500) Type alias for StandardSchema diff --git a/docs/reference/type-aliases/SubscriptionEvents.md b/docs/reference/type-aliases/SubscriptionEvents.md index 15b3c11814..b5723e749b 100644 --- a/docs/reference/type-aliases/SubscriptionEvents.md +++ b/docs/reference/type-aliases/SubscriptionEvents.md @@ -9,19 +9,29 @@ title: SubscriptionEvents type SubscriptionEvents = object; ``` -Defined in: [packages/db/src/types.ts:243](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L243) +Defined in: [packages/db/src/types.ts:257](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L257) All subscription events ## Properties +### loadSubset:error + +```ts +loadSubset:error: SubscriptionLoadSubsetErrorEvent; +``` + +Defined in: [packages/db/src/types.ts:261](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L261) + +*** + ### status:change ```ts status:change: SubscriptionStatusChangeEvent; ``` -Defined in: [packages/db/src/types.ts:244](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L244) +Defined in: [packages/db/src/types.ts:258](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L258) *** @@ -31,7 +41,7 @@ Defined in: [packages/db/src/types.ts:244](https://github.com/TanStack/db/blob/m status:loadingSubset: SubscriptionStatusEvent<"loadingSubset">; ``` -Defined in: [packages/db/src/types.ts:246](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L246) +Defined in: [packages/db/src/types.ts:260](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L260) *** @@ -41,7 +51,7 @@ Defined in: [packages/db/src/types.ts:246](https://github.com/TanStack/db/blob/m status:ready: SubscriptionStatusEvent<"ready">; ``` -Defined in: [packages/db/src/types.ts:245](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L245) +Defined in: [packages/db/src/types.ts:259](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L259) *** @@ -51,4 +61,4 @@ Defined in: [packages/db/src/types.ts:245](https://github.com/TanStack/db/blob/m unsubscribed: SubscriptionUnsubscribedEvent; ``` -Defined in: [packages/db/src/types.ts:247](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L247) +Defined in: [packages/db/src/types.ts:262](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L262) diff --git a/docs/reference/type-aliases/SubscriptionStatus.md b/docs/reference/type-aliases/SubscriptionStatus.md index 32218fdc9a..aeb093c67f 100644 --- a/docs/reference/type-aliases/SubscriptionStatus.md +++ b/docs/reference/type-aliases/SubscriptionStatus.md @@ -9,6 +9,6 @@ title: SubscriptionStatus type SubscriptionStatus = "ready" | "loadingSubset"; ``` -Defined in: [packages/db/src/types.ts:210](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L210) +Defined in: [packages/db/src/types.ts:216](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L216) Subscription status values diff --git a/docs/reference/type-aliases/SyncAppliedReceipt.md b/docs/reference/type-aliases/SyncAppliedReceipt.md new file mode 100644 index 0000000000..30814fc720 --- /dev/null +++ b/docs/reference/type-aliases/SyncAppliedReceipt.md @@ -0,0 +1,17 @@ +--- +id: SyncAppliedReceipt +title: SyncAppliedReceipt +--- + +# Type Alias: SyncAppliedReceipt + +```ts +type SyncAppliedReceipt = true | Promise; +``` + +Defined in: [packages/db/src/types.ts:354](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L354) + +Confirms whether a committed sync transaction is visible or is waiting for +its turn in the collection's causal queue. A pending receipt rejects with an +error named `AbortError` if cancellation wins before application. Once the +writes are visible, later cancellation has no effect. diff --git a/docs/reference/type-aliases/SyncConfigRes.md b/docs/reference/type-aliases/SyncConfigRes.md index d1f5663ff1..d3ebc7f1ff 100644 --- a/docs/reference/type-aliases/SyncConfigRes.md +++ b/docs/reference/type-aliases/SyncConfigRes.md @@ -9,7 +9,7 @@ title: SyncConfigRes type SyncConfigRes = object; ``` -Defined in: [packages/db/src/types.ts:328](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L328) +Defined in: [packages/db/src/types.ts:360](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L360) ## Properties @@ -19,7 +19,7 @@ Defined in: [packages/db/src/types.ts:328](https://github.com/TanStack/db/blob/m optional cleanup: CleanupFn; ``` -Defined in: [packages/db/src/types.ts:329](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L329) +Defined in: [packages/db/src/types.ts:361](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L361) *** @@ -29,7 +29,7 @@ Defined in: [packages/db/src/types.ts:329](https://github.com/TanStack/db/blob/m optional loadSubset: LoadSubsetFn; ``` -Defined in: [packages/db/src/types.ts:330](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L330) +Defined in: [packages/db/src/types.ts:362](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L362) *** @@ -39,4 +39,4 @@ Defined in: [packages/db/src/types.ts:330](https://github.com/TanStack/db/blob/m optional unloadSubset: UnloadSubsetFn; ``` -Defined in: [packages/db/src/types.ts:331](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L331) +Defined in: [packages/db/src/types.ts:363](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L363) diff --git a/docs/reference/type-aliases/SyncMode.md b/docs/reference/type-aliases/SyncMode.md index ddeb2d7ee7..70d77c0e86 100644 --- a/docs/reference/type-aliases/SyncMode.md +++ b/docs/reference/type-aliases/SyncMode.md @@ -9,4 +9,4 @@ title: SyncMode type SyncMode = "eager" | "on-demand"; ``` -Defined in: [packages/db/src/types.ts:548](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L548) +Defined in: [packages/db/src/types.ts:589](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L589) diff --git a/docs/reference/type-aliases/TransactionWithMutations.md b/docs/reference/type-aliases/TransactionWithMutations.md index cb00d35567..7ec410db3d 100644 --- a/docs/reference/type-aliases/TransactionWithMutations.md +++ b/docs/reference/type-aliases/TransactionWithMutations.md @@ -9,7 +9,7 @@ title: TransactionWithMutations type TransactionWithMutations = Omit, "mutations"> & object; ``` -Defined in: [packages/db/src/types.ts:142](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L142) +Defined in: [packages/db/src/types.ts:148](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L148) Utility type for a Transaction with at least one mutation This is used internally by the Transaction.commit method diff --git a/docs/reference/type-aliases/UnloadSubsetFn.md b/docs/reference/type-aliases/UnloadSubsetFn.md index 4d576009dd..01db021dec 100644 --- a/docs/reference/type-aliases/UnloadSubsetFn.md +++ b/docs/reference/type-aliases/UnloadSubsetFn.md @@ -9,7 +9,7 @@ title: UnloadSubsetFn type UnloadSubsetFn = (options) => void; ``` -Defined in: [packages/db/src/types.ts:324](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L324) +Defined in: [packages/db/src/types.ts:356](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L356) ## Parameters diff --git a/docs/reference/type-aliases/UpdateMutationFn.md b/docs/reference/type-aliases/UpdateMutationFn.md index bccf34bcc3..39d5fa0bf6 100644 --- a/docs/reference/type-aliases/UpdateMutationFn.md +++ b/docs/reference/type-aliases/UpdateMutationFn.md @@ -9,7 +9,7 @@ title: UpdateMutationFn type UpdateMutationFn = (params) => Promise; ``` -Defined in: [packages/db/src/types.ts:506](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L506) +Defined in: [packages/db/src/types.ts:547](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L547) ## Type Parameters diff --git a/docs/reference/type-aliases/UpdateMutationFnParams.md b/docs/reference/type-aliases/UpdateMutationFnParams.md index 7ec5c7b3e2..4af74597ab 100644 --- a/docs/reference/type-aliases/UpdateMutationFnParams.md +++ b/docs/reference/type-aliases/UpdateMutationFnParams.md @@ -9,7 +9,7 @@ title: UpdateMutationFnParams type UpdateMutationFnParams = object; ``` -Defined in: [packages/db/src/types.ts:473](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L473) +Defined in: [packages/db/src/types.ts:514](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L514) ## Type Parameters @@ -33,7 +33,7 @@ Defined in: [packages/db/src/types.ts:473](https://github.com/TanStack/db/blob/m collection: Collection; ``` -Defined in: [packages/db/src/types.ts:479](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L479) +Defined in: [packages/db/src/types.ts:520](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L520) *** @@ -43,4 +43,4 @@ Defined in: [packages/db/src/types.ts:479](https://github.com/TanStack/db/blob/m transaction: TransactionWithMutations; ``` -Defined in: [packages/db/src/types.ts:478](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L478) +Defined in: [packages/db/src/types.ts:519](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L519) diff --git a/docs/reference/type-aliases/WritableDeep.md b/docs/reference/type-aliases/WritableDeep.md index fc9b915381..70e9398787 100644 --- a/docs/reference/type-aliases/WritableDeep.md +++ b/docs/reference/type-aliases/WritableDeep.md @@ -9,7 +9,7 @@ title: WritableDeep type WritableDeep = T extends BuiltIns ? T : T extends (...arguments_) => unknown ? object extends WritableObjectDeep ? T : HasMultipleCallSignatures extends true ? T : (...arguments_) => ReturnType & WritableObjectDeep : T extends ReadonlyMap ? WritableMapDeep : T extends ReadonlySet ? WritableSetDeep : T extends ReadonlyArray ? WritableArrayDeep : T extends object ? WritableObjectDeep : unknown; ``` -Defined in: [packages/db/src/types.ts:1008](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1008) +Defined in: [packages/db/src/types.ts:1051](https://github.com/TanStack/db/blob/main/packages/db/src/types.ts#L1051) ## Type Parameters diff --git a/docs/reference/variables/Query.md b/docs/reference/variables/Query.md index b8554a7a0d..bcf5fc2fa7 100644 --- a/docs/reference/variables/Query.md +++ b/docs/reference/variables/Query.md @@ -9,4 +9,4 @@ title: Query const Query: InitialQueryBuilderConstructor = BaseQueryBuilder; ``` -Defined in: [packages/db/src/query/builder/index.ts:1486](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1486) +Defined in: [packages/db/src/query/builder/index.ts:1639](https://github.com/TanStack/db/blob/main/packages/db/src/query/builder/index.ts#L1639) diff --git a/packages/db/package.json b/packages/db/package.json index 1857935bba..a184276415 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -21,7 +21,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest --run", - "test:oracles": "vitest --run tests/query/includes-oracle.property.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts" + "test:oracles": "vitest --run tests/collection-sync-reentrancy.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/query/coverage-registry-oracle.property.test.ts tests/query/load-subset-full-flow-oracle.property.test.ts tests/query/load-subset-projection-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts" }, "type": "module", "main": "dist/cjs/index.cjs", diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 3783422013..4904bb6b81 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -2,6 +2,7 @@ import { deepEquals } from '../utils' import { SortedMap } from '../SortedMap' import { enrichRowWithVirtualProps } from '../virtual-props.js' import { SyncTransactionAbortedError } from '../errors.js' +import { createDeferred } from '../deferred' import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { VirtualOrigin, @@ -132,6 +133,7 @@ export class CollectionStateManager< public recentlySyncedKeys = new Set() public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false + private isDrainingSyncTransactions = false public isLocalOnly = false /** @@ -829,6 +831,28 @@ export class CollectionStateManager< * This method processes operations from pending transactions and applies them to the synced data */ commitPendingTransactions = () => { + if (this.isDrainingSyncTransactions) return + + this.isDrainingSyncTransactions = true + let firstPublicationError: { error: unknown } | undefined + try { + let processed: boolean + do { + const result = this.commitNextPendingTransactionBatch() + processed = result.processed + if (processed) firstPublicationError ??= result.publicationError + } while (processed) + } finally { + this.isDrainingSyncTransactions = false + } + + if (firstPublicationError) throw firstPublicationError.error + } + + private commitNextPendingTransactionBatch(): { + processed: boolean + publicationError?: { error: unknown } + } { // Check if there are any persisting transaction let hasPersistingTransaction = false for (const transaction of this.transactions.values()) { @@ -875,6 +899,10 @@ export class CollectionStateManager< }, ) + if (committedSyncedTransactions.length === 0) { + return { processed: false } + } + // Process committed transactions if: // 1. No persisting user transaction (normal sync flow), OR // 2. There's a truncate operation (must be processed immediately), OR @@ -886,6 +914,10 @@ export class CollectionStateManager< // non-immediate transactions would be applied later and could overwrite newer state. // Processing all committed transactions together preserves causal ordering. if (!hasPersistingTransaction || hasTruncateSync || hasImmediateSync) { + // This queue remains authoritative while user callbacks run. Transactions + // opened by a callback must not be overwritten by this batch's snapshot. + this.pendingSyncedTransactions = uncommittedSyncedTransactions + // Application is now the point of no return. Event listeners run before // the receipts resolve, so a signal aborted from one of those listeners // must not cancel writes that are already becoming visible. @@ -1356,9 +1388,14 @@ export class CollectionStateManager< } // End batching and emit all events (combines any batched events with sync events) - this.changes.emitEvents(events, true, layoutChanged) - - this.pendingSyncedTransactions = uncommittedSyncedTransactions + let publicationError: { error: unknown } | undefined + try { + this.changes.emitEvents(events, true, layoutChanged) + } catch (error) { + // The state is already committed. Finish this batch and drain any work + // queued by earlier listeners before surfacing their publication error. + publicationError = { error } + } // Clear the pre-sync state since sync operations are complete this.preSyncVisibleState.clear() @@ -1376,7 +1413,45 @@ export class CollectionStateManager< for (const transaction of committedSyncedTransactions) { transaction.applied.resolve() } + + return { processed: true, publicationError } } + + return { processed: false } + } + + /** Apply source-row garbage collection through the normal sync boundary. */ + public deleteSyncedRows(keys: ReadonlyArray): true | Promise { + const existingKeys = keys.filter((key) => this.syncedData.has(key)) + if (existingKeys.length === 0) return true + + const applied = createDeferred() + // Some internal callers only need synchronous completion. Preserve the + // receipt for queued cases without allowing an ignored rejection to leak. + void applied.promise.catch(() => undefined) + const deletedKeys = new Set(existingKeys) + this.pendingSyncedTransactions.push({ + committed: true, + applicationStarted: false, + layoutChanged: false, + operations: existingKeys.map( + (key): OptimisticChangeMessage => ({ + type: `delete`, + key, + }), + ), + deletedKeys, + rowMetadataWrites: new Map( + existingKeys.map((key) => [key, { type: `delete` as const }]), + ), + collectionMetadataWrites: new Map(), + // Coverage GC is part of releasing source ownership, so it cannot wait + // behind a user mutation that may itself observe the released rows. + immediate: true, + applied, + }) + this.commitPendingTransactions() + return applied.isPending() ? applied.promise : true } /** Abandons one committed transaction before it becomes visible. */ diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 27e0d1c9a2..b6427bd0f5 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -33,7 +33,10 @@ type RequestSnapshotOptions = { /** Optional limit to pass to loadSubset for backend optimization */ limit?: number /** Callback that receives the normalized loadSubset result for internal tracking */ - onLoadSubsetResult?: (result: LoadSubsetRequestResult) => void + onLoadSubsetResult?: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void /** Called when the local snapshot must fall back from an index to a scan. */ onUnoptimized?: () => void } @@ -48,7 +51,10 @@ type RequestLimitedSnapshotOptions = { /** Whether to track the loadSubset promise on this subscription (default: true) */ trackLoadSubsetPromise?: boolean /** Callback that receives the normalized loadSubset result for internal tracking */ - onLoadSubsetResult?: (result: LoadSubsetRequestResult) => void + onLoadSubsetResult?: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void } type CollectionSubscriptionOptions = { @@ -76,9 +82,15 @@ type SubsetAcquisition = { removeRequestAbortListener?: () => void } +type ReplaySubsetAcquisition = SubsetAcquisition & { + abortController: AbortController +} + type SubsetDemand = SubsetAcquisition & { requestOptions: LoadSubsetOptions + pendingReplayAcquisitions: Set releaseFailed: boolean + releaseSettled: boolean } type TruncateReplayAttempt = { @@ -253,6 +265,9 @@ export class CollectionSubscription // the old work before it can install rows into the new generation. for (const demand of demandsToReload) { demand.abortController?.abort() + for (const pending of demand.pendingReplayAcquisitions) { + pending.abortController.abort() + } } // Start buffering before the truncate commit publishes its deletes. Every @@ -278,6 +293,7 @@ export class CollectionSubscription this.truncateReplaySession === session && session.currentAttempt === attempt const nextAcquisition = this.createSubsetAcquisition(demand) + demand.pendingReplayAcquisitions.add(nextAcquisition) let syncResult: LoadSubsetRequestResult try { syncResult = this.loadSubset( @@ -285,6 +301,7 @@ export class CollectionSubscription isCurrentAttempt, ) } catch { + demand.pendingReplayAcquisitions.delete(nextAcquisition) nextAcquisition.abortController.abort() nextAcquisition.removeRequestAbortListener?.() attempt.failed = true @@ -305,38 +322,36 @@ export class CollectionSubscription const pending = { promise: syncResult } attempt.pending.add(pending) void syncResult.then( - () => this.settleTruncateReplay(session, attempt, pending), () => { + this.completeReplayAcquisition( + session, + attempt, + demand, + nextAcquisition, + ) + this.settleTruncateReplay(session, attempt, pending) + }, + () => { + const failedCurrentDemand = + this.subsetDemands.includes(demand) && + !nextAcquisition.options.signal?.aborted // A released demand no longer participates in the current // replacement. Its cooperative AbortError must not discard the // successful rows from demands that are still active. - if ( - this.subsetDemands.includes(demand) && - !nextAcquisition.options.signal?.aborted - ) { + if (failedCurrentDemand) { attempt.failed = true } + this.discardReplayAcquisition(demand, nextAcquisition) this.settleTruncateReplay(session, attempt, pending) }, ) - } - - try { - this.replaceSubsetAcquisition(demand, nextAcquisition) - } catch (error) { - // The old lease is still owned because its release failed. Abort and - // release the new acquisition, but keep observing its work so rows - // from a non-cooperative adapter cannot escape the replay buffer. - nextAcquisition.abortController.abort() - nextAcquisition.removeRequestAbortListener?.() - try { - this.collection._sync.unloadSubset(nextAcquisition.options) - } catch { - // Preserve the first ownership error. The demand still retains the - // old acquisition so normal cleanup can retry that release. - } - this.recordLoadSubsetError(demand.options, error, true) - attempt.failed = true + } else { + this.completeReplayAcquisition( + session, + attempt, + demand, + nextAcquisition, + ) } } @@ -590,22 +605,101 @@ export class CollectionSubscription demand.options = next.options demand.abortController = next.abortController demand.removeRequestAbortListener = next.removeRequestAbortListener + demand.releaseFailed = false + demand.releaseSettled = false } - /** Abort and release one current adapter acquisition. */ - private releaseSubsetDemand(demand: SubsetDemand): void { - demand.abortController?.abort() + /** Attach a successful replay only while every owning authority is current. */ + private completeReplayAcquisition( + session: TruncateReplaySession, + attempt: TruncateReplayAttempt, + demand: SubsetDemand, + next: ReplaySubsetAcquisition, + ): void { + const mayReplace = + this.truncateReplaySession === session && + session.currentAttempt === attempt && + this.subsetDemands.includes(demand) && + demand.pendingReplayAcquisitions.has(next) && + !demand.releaseSettled && + !next.options.signal?.aborted + + if (mayReplace) { + this.tryReplaceSubsetAcquisition(demand, next, attempt) + } else { + this.discardReplayAcquisition(demand, next) + } + } + + private tryReplaceSubsetAcquisition( + demand: SubsetDemand, + next: ReplaySubsetAcquisition, + attempt: TruncateReplayAttempt, + ): void { try { - this.collection._sync.unloadSubset(demand.options) - demand.releaseFailed = false + this.replaceSubsetAcquisition(demand, next) + demand.pendingReplayAcquisitions.delete(next) } catch (error) { - demand.releaseFailed = true - throw error + // The old lease remains owned when its release fails. Release the new + // acquisition and keep the old one available for a cleanup retry. + this.discardReplayAcquisition(demand, next) + this.recordLoadSubsetError(demand.options, error, true) + attempt.failed = true + } + } + + private discardReplayAcquisition( + demand: SubsetDemand, + next: ReplaySubsetAcquisition, + ): void { + try { + this.releaseReplayAcquisition(demand, next) + } catch { + // Keep the failed acquisition on the demand. releaseSnapshot, + // unsubscribe, or collection cleanup will retry its exact owner route. + } + } + + private releaseReplayAcquisition( + demand: SubsetDemand, + next: ReplaySubsetAcquisition, + ): void { + if (!demand.pendingReplayAcquisitions.has(next)) return + next.abortController.abort() + try { + this.collection._sync.unloadSubset(next.options) + demand.pendingReplayAcquisitions.delete(next) } finally { - demand.removeRequestAbortListener?.() + next.removeRequestAbortListener?.() } } + /** Abort and release one current adapter acquisition. */ + private releaseSubsetDemand(demand: SubsetDemand): void { + demand.abortController?.abort() + let firstReleaseError: unknown + for (const pending of [...demand.pendingReplayAcquisitions]) { + try { + this.releaseReplayAcquisition(demand, pending) + } catch (error) { + firstReleaseError ??= error + } + } + if (!demand.releaseSettled) { + try { + this.collection._sync.unloadSubset(demand.options) + demand.releaseFailed = false + demand.releaseSettled = true + } catch (error) { + demand.releaseFailed = true + firstReleaseError ??= error + } finally { + demand.removeRequestAbortListener?.() + } + } + if (firstReleaseError !== undefined) throw firstReleaseError + } + /** Start and retain the first acquisition for one logical subset demand. */ private startSubsetDemand(requestOptions: LoadSubsetOptions): { demand: SubsetDemand @@ -614,12 +708,18 @@ export class CollectionSubscription const demand: SubsetDemand = { requestOptions, options: requestOptions, + pendingReplayAcquisitions: new Set(), releaseFailed: false, + releaseSettled: false, } const acquisition = this.createSubsetAcquisition(demand) demand.options = acquisition.options demand.abortController = acquisition.abortController demand.removeRequestAbortListener = acquisition.removeRequestAbortListener + if (acquisition.abortController.signal.aborted) { + acquisition.removeRequestAbortListener?.() + return { demand, result: true } + } // Reentrant release must see the exact acquisition before adapter work // starts. A genuine load throw removes this tentative logical owner below. this.subsetDemands.push(demand) @@ -732,7 +832,7 @@ export class CollectionSubscription if (opts?.where) this.requestedSubsetWhere.set(loadOptions, opts.where) // Pass the raw loadSubset result to the caller for external tracking - opts?.onLoadSubsetResult?.(syncResult) + opts?.onLoadSubsetResult?.(syncResult, demand.options) this.observeLoadSubsetResult( syncResult, @@ -1002,7 +1102,7 @@ export class CollectionSubscription const { demand, result: syncResult } = this.startSubsetDemand(loadOptions) // Pass the raw loadSubset result to the caller for external tracking - onLoadSubsetResult?.(syncResult) + onLoadSubsetResult?.(syncResult, demand.options) this.observeLoadSubsetResult( syncResult, demand.options, diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 440b02d56c..3c4ffe0f07 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -20,6 +20,9 @@ import { cloneLoadSubsetOptions, snapshotLoadSubsetDemand, } from '../query/load-subset-options.js' +import { createLoadSubsetCoverageRegistry } from '../query/coverage-registry.js' +import { getLoadSubsetDemandKey } from '../query/ir-stable-identity.js' +import { isLoadSubsetRequestSubsumedBy } from '../query/predicate-utils.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { AppliedLoadSubsetOutcome, @@ -39,6 +42,14 @@ import type { CollectionLifecycleManager } from './lifecycle' import type { CollectionEventsManager } from './events.js' import type { LiveQueryCollectionUtils } from '../query/live/collection-config-builder.js' import type { Deferred } from '../deferred' +import type { + AcquisitionToken, + AppliedLoadSubsetCoverage, + BorrowedCoverageEvidence, + CoverageRegistryResourceCounts, + DemandLease, +} from '../query/coverage-registry.js' +import type { DemandKey } from '../query/ir-stable-identity.js' type DeferredLoadSubset = { ownerOptions: LoadSubsetOptions @@ -48,11 +59,19 @@ type DeferredLoadSubset = { deferred: Deferred } +type SharedCoverageAcquisition = { + acquisition: AcquisitionToken +} + type DeferredAdapterAcquisition = { options: LoadSubsetOptions releaseFailed: boolean } +type PendingCoverageDemand = { + released: boolean +} + type LoadSubsetOperation = { pending: Set> outcomes: Map< @@ -66,6 +85,38 @@ type LoadSubsetOperation = { deferred?: Deferred } +export type LoadSubsetEvidenceWorkCounts = Readonly<{ + rowKeyCopies: number + demandSnapshots: number + demandKeyDerivations: number +}> + +type SatisfiedEvidenceAuthority = `applied` | `established` + +type SatisfiedEvidenceCandidate = Readonly<{ + authority: SatisfiedEvidenceAuthority + acquisition: AcquisitionToken + collectionId: string + sourceId: string | undefined + demand: LoadSubsetOptions + demandKey: DemandKey | undefined + sequenceKey: DemandKey | undefined + sourceExtent: AppliedLoadSubsetOutcome[`extent`] + rowKeys: ReadonlyArray | ReadonlySet + generation: number +}> + +type ProjectedSatisfiedEvidence = Readonly<{ + candidate: SatisfiedEvidenceCandidate + coverage: AppliedLoadSubsetCoverage | undefined + outcome: AppliedLoadSubsetOutcome +}> + +type SelectedSatisfiedEvidence = Readonly<{ + candidate: SatisfiedEvidenceCandidate + extent: AppliedLoadSubsetOutcome[`extent`] +}> + export class CollectionSyncManager< TOutput extends object = Record, TKey extends string | number = string | number, @@ -96,9 +147,28 @@ export class CollectionSyncManager< LoadSubsetOptions, Array >() + private pendingCoverageDemands = new WeakMap< + LoadSubsetOptions, + Array + >() private syncEpoch = 0 private loadSubsetSession = 0 private loadSubsetGeneration = 0 + private readonly coverageRegistry = createLoadSubsetCoverageRegistry() + private coverageLeasesByOwner = new WeakMap< + LoadSubsetOptions, + Array> + >() + private coverageAcquisitionsByPromise = new WeakMap< + Promise, + SharedCoverageAcquisition + >() + private readonly pendingCoverageRowsToRemove = new Set() + private evidenceWorkCounts = { + rowKeyCopies: 0, + demandSnapshots: 0, + demandKeyDerivations: 0, + } /** * Creates a new CollectionSyncManager instance @@ -119,6 +189,12 @@ export class CollectionSyncManager< this.state = deps.state this.lifecycle = deps.lifecycle this._events = deps.events + this._events.on(`truncate`, () => { + this.coverageRegistry.invalidateAppliedEvidence() + this.coverageAcquisitionsByPromise = new WeakMap() + // The truncate transaction already removed every source row. + this.pendingCoverageRowsToRemove.clear() + }) } /** Mark the active sync transaction as changing collection layout. */ @@ -420,32 +496,63 @@ export class CollectionSyncManager< loadSubset && this.syncUnloadSubsetFn ? this.retainDeferredAdapterOptions(ownerOptions, options) : undefined + const pendingCoverageDemand = loadSubset + ? this.retainPendingCoverageDemand(ownerOptions) + : undefined try { const result = loadSubset?.(options) ?? true + const retainsCoverageDemand = + pendingCoverageDemand !== undefined && !pendingCoverageDemand.released if (result instanceof Promise) { + const coverageOwnership = retainsCoverageDemand + ? this.addCoverageOwnership( + ownerOptions, + demand, + generation, + result, + ) + : undefined void result.then( - (sourceResult) => - deferred.resolve( - createAppliedLoadSubsetOutcome( - this.id, - demand, - generation, - isLoadSubsetResultForDemand(result, sourceResult, demand) - ? sourceResult - : undefined, - ), - ), - (error: unknown) => deferred.reject(error), + (sourceResult) => { + const outcome = createAppliedLoadSubsetOutcome( + this.id, + demand, + generation, + isLoadSubsetResultForDemand(result, sourceResult, demand) + ? sourceResult + : undefined, + ) + if (coverageOwnership) { + this.publishCoverageOutcome( + coverageOwnership.acquisition, + coverageOwnership.lease, + outcome, + ) + } + deferred.resolve(outcome) + }, + (error: unknown) => { + if (coverageOwnership) { + this.discardCoverageLease( + ownerOptions, + coverageOwnership.acquisition, + coverageOwnership.lease, + ) + } + deferred.reject(error) + }, ) } else { - deferred.resolve( - createAppliedLoadSubsetOutcome( - this.id, - demand, - generation, - undefined, - ), + if (retainsCoverageDemand) { + this.addSatisfiedCoverageOwnership(ownerOptions, demand, generation) + } + const outcome = createAppliedLoadSubsetOutcome( + this.id, + demand, + generation, + undefined, ) + deferred.resolve(outcome) } } catch (error) { // A reentrant release marks the tentative acquisition before its @@ -455,6 +562,10 @@ export class CollectionSyncManager< this.forgetDeferredAdapterOptions(ownerOptions, adapterAcquisition) } deferred.reject(error) + } finally { + if (pendingCoverageDemand) { + this.forgetPendingCoverageDemand(ownerOptions, pendingCoverageDemand) + } } } } @@ -750,19 +861,8 @@ export class CollectionSyncManager< : isAppliedLoadSubsetOutcome(outcome.result) ? [outcome.result] : [] - for (const result of results) { - let byCollection = operation.outcomes.get(result.sourceId) - if (!byCollection) { - byCollection = new Map() - operation.outcomes.set(result.sourceId, byCollection) - } - let byGeneration = byCollection.get(result.collectionId) - if (!byGeneration) { - byGeneration = new Map() - byCollection.set(result.collectionId, byGeneration) - } - byGeneration.set(result.generation, result) - } + for (const result of results) + this.recordOperationOutcome(operation, result) } if (!operation.waiting || operation.pending.size > 0) return @@ -804,6 +904,32 @@ export class CollectionSyncManager< ) } + /** @internal Project retained evidence into the active operation. */ + public trackLoadSubsetOperationOutcome( + outcome: AppliedLoadSubsetOutcome, + ): void { + const operation = this.activeLoadSubsetOperation + if (!operation || operation.completed) return + this.recordOperationOutcome(operation, outcome) + } + + private recordOperationOutcome( + operation: LoadSubsetOperation, + outcome: AppliedLoadSubsetOutcome, + ): void { + let byCollection = operation.outcomes.get(outcome.sourceId) + if (!byCollection) { + byCollection = new Map() + operation.outcomes.set(outcome.sourceId, byCollection) + } + let byGeneration = byCollection.get(outcome.collectionId) + if (!byGeneration) { + byGeneration = new Map() + byCollection.set(outcome.collectionId, byGeneration) + } + byGeneration.set(outcome.generation, outcome) + } + private async waitForPendingLoadSubset(): Promise { do { await Promise.all([...this.pendingLoadSubsetPromises]) @@ -871,11 +997,16 @@ export class CollectionSyncManager< this.syncStartRequested = true const deferred = createDeferred() const loadOptions = cloneLoadSubsetOptions(options) + const demand = this.snapshotEvidenceDemand(loadOptions) + // Demand identity is part of acquisition scope. Reject unsupported + // values before an adapter can perform irreversible work. + this.deriveEvidenceDemandKey(demand) + const generation = ++this.loadSubsetGeneration this.deferredLoadSubsets.push({ ownerOptions: options, options: loadOptions, - demand: snapshotLoadSubsetDemand(loadOptions), - generation: ++this.loadSubsetGeneration, + demand, + generation, deferred, }) this.trackLoadPromise(deferred.promise) @@ -883,24 +1014,64 @@ export class CollectionSyncManager< } if (this.syncLoadSubsetFn) { - const demand = snapshotLoadSubsetDemand(options) + const demand = this.snapshotEvidenceDemand(options) + // Validate and hash the retained scope before starting adapter work. + this.deriveEvidenceDemandKey(demand) const generation = ++this.loadSubsetGeneration - const result = this.syncLoadSubsetFn(options) + const pendingCoverageDemand = this.retainPendingCoverageDemand(options) + let result: ReturnType + try { + result = this.syncLoadSubsetFn(options) + } finally { + this.forgetPendingCoverageDemand(options, pendingCoverageDemand) + } + if (pendingCoverageDemand.released) { + if (result instanceof Promise) { + const outcome = result.then((sourceResult) => + createAppliedLoadSubsetOutcome( + this.id, + demand, + generation, + isLoadSubsetResultForDemand(result, sourceResult, demand) + ? sourceResult + : undefined, + ), + ) + this.trackLoadPromise(outcome) + return outcome + } + return true + } // If the result is a promise, track it if (result instanceof Promise) { - const outcome = result.then((sourceResult) => - createAppliedLoadSubsetOutcome( - this.id, - demand, - generation, - isLoadSubsetResultForDemand(result, sourceResult, demand) - ? sourceResult - : undefined, - ), + const { lease, acquisition } = this.addCoverageOwnership( + options, + demand, + generation, + result, + ) + const outcome = result.then( + (sourceResult) => { + const appliedOutcome = createAppliedLoadSubsetOutcome( + this.id, + demand, + generation, + isLoadSubsetResultForDemand(result, sourceResult, demand) + ? sourceResult + : undefined, + ) + this.publishCoverageOutcome(acquisition, lease, appliedOutcome) + return appliedOutcome + }, + (error: unknown) => { + this.discardCoverageLease(options, acquisition, lease) + throw error + }, ) this.trackLoadPromise(outcome) return outcome } + this.addSatisfiedCoverageOwnership(options, demand, generation) } return true @@ -943,6 +1114,464 @@ export class CollectionSyncManager< this.forgetDeferredAdapterOptions(options, acquisition) } } + if (!this.releaseCoverageLease(options)) { + this.releasePendingCoverageDemand(options) + } + } + + /** @internal Applied source coverage retained by active subset owners. */ + public getLoadSubsetCoverage(): ReadonlyArray< + AppliedLoadSubsetCoverage + > { + return this.coverageRegistry.coverageAntichain() + } + + /** @internal Resource accounting used by lifecycle oracles. */ + public getLoadSubsetResourceCounts(): CoverageRegistryResourceCounts { + return this.coverageRegistry.resourceCounts() + } + + /** @internal Work accounting used by evidence-path oracles. */ + public getLoadSubsetEvidenceWorkCounts(): LoadSubsetEvidenceWorkCounts { + const registry = this.coverageRegistry.evidenceWork() + return { + rowKeyCopies: + this.evidenceWorkCounts.rowKeyCopies + registry.rowKeyCopies, + demandSnapshots: + this.evidenceWorkCounts.demandSnapshots + registry.demandSnapshots, + demandKeyDerivations: + this.evidenceWorkCounts.demandKeyDerivations + + registry.demandKeyDerivations, + } + } + + /** @internal Resets work accounting without changing collection state. */ + public resetLoadSubsetEvidenceWorkCounts(): void { + this.evidenceWorkCounts = { + rowKeyCopies: 0, + demandSnapshots: 0, + demandKeyDerivations: 0, + } + this.coverageRegistry.resetEvidenceWork() + } + + /** @internal Exact active evidence for a physically skipped request. */ + public getLoadSubsetOutcome( + demand: LoadSubsetOptions, + ): AppliedLoadSubsetOutcome | undefined { + const demandKey = this.deriveEvidenceDemandKey(demand) + let selected: AppliedLoadSubsetOutcome | undefined + for (const evidence of this.coverageRegistry.borrowEvidence()) { + if (evidence.demandKey !== demandKey) continue + const outcome = + evidence.authority === `established` + ? ({ + collectionId: evidence.coverage.collectionId, + ...(evidence.coverage.sourceId === undefined + ? {} + : { sourceId: evidence.coverage.sourceId }), + demand: evidence.coverage.demand, + generation: evidence.generation, + extent: evidence.coverage.extent, + appliedRowKeys: evidence.coverage.rowKeys, + } satisfies AppliedLoadSubsetOutcome) + : evidence.authority === `retained` + ? evidence.outcome + : undefined + if ( + outcome?.collectionId === this.id && + (selected === undefined || outcome.generation > selected.generation) + ) { + selected = outcome + } + } + if (!selected) return undefined + + return { + collectionId: this.id, + ...(selected.sourceId === undefined + ? {} + : { sourceId: selected.sourceId }), + demand: this.snapshotEvidenceDemand(demand), + generation: selected.generation, + extent: selected.extent, + ...(selected.appliedRowKeys === undefined + ? {} + : { appliedRowKeys: this.copyEvidenceRows(selected.appliedRowKeys) }), + } + } + + private addCoverageOwnership( + ownerOptions: LoadSubsetOptions, + demand: LoadSubsetOptions, + generation: number, + physicalPromise: Promise, + ): { + lease: DemandLease + acquisition: AcquisitionToken + } { + const lease = this.coverageRegistry.addLease(demand) + const shared = this.coverageAcquisitionsByPromise.get(physicalPromise) + let acquisition: AcquisitionToken + if ( + shared !== undefined && + this.coverageRegistry.isAcquisitionAttachable(shared.acquisition) + ) { + acquisition = shared.acquisition + this.coverageRegistry.attachLease(lease, acquisition, { + generation, + scope: { collectionId: this.id, demand }, + settlementPending: true, + }) + } else { + acquisition = this.coverageRegistry.addAcquisition({ + generation, + scope: { collectionId: this.id, demand }, + leases: [lease], + // Adapter resource release remains owned by unloadSubset. The registry + // tracks the same lifetime without duplicating that side effect. + release: () => {}, + }) + this.coverageAcquisitionsByPromise.set(physicalPromise, { + acquisition, + }) + } + this.recordCoverageLease(ownerOptions, lease) + return { lease, acquisition } + } + + private addSatisfiedCoverageOwnership( + ownerOptions: LoadSubsetOptions, + demand: LoadSubsetOptions, + generation: number, + ): void { + const lease = this.coverageRegistry.addLease(demand) + const demandKey = this.deriveEvidenceDemandKey(demand) + const sequenceKey = this.deriveEvidenceSequenceKey(demand) + const selected = this.selectSatisfiedEvidence( + this.coverageRegistry.borrowEvidence(), + demand, + demandKey, + sequenceKey, + generation, + ) + if (selected) { + this.coverageRegistry.attachLease(lease, selected.candidate.acquisition, { + generation, + scope: { collectionId: this.id, demand }, + ...(selected.coverage === undefined + ? {} + : { coverage: selected.coverage }), + retainedOutcome: selected.outcome, + }) + } + this.recordCoverageLease(ownerOptions, lease) + } + + private selectSatisfiedEvidence( + evidence: Iterable< + BorrowedCoverageEvidence, TKey> + >, + demand: LoadSubsetOptions, + demandKey: DemandKey | undefined, + sequenceKey: DemandKey | undefined, + generation: number, + ): ProjectedSatisfiedEvidence | undefined { + let established: SelectedSatisfiedEvidence | undefined + let applied: SelectedSatisfiedEvidence | undefined + + for (const item of evidence) { + if (item.authority === `retained`) continue + if (item.authority === `established`) { + established = this.preferSatisfiedEvidence( + `established`, + established, + item, + demand, + demandKey, + sequenceKey, + ) + } else { + applied = this.preferSatisfiedEvidence( + `applied`, + applied, + item, + demand, + demandKey, + sequenceKey, + ) + } + } + + const selected = established ?? applied + return selected === undefined + ? undefined + : this.projectSatisfiedEvidence( + selected.candidate, + demand, + generation, + selected.extent, + ) + } + + private preferSatisfiedEvidence( + authority: SatisfiedEvidenceAuthority, + selected: SelectedSatisfiedEvidence | undefined, + evidence: BorrowedCoverageEvidence, TKey>, + demand: LoadSubsetOptions, + demandKey: DemandKey | undefined, + sequenceKey: DemandKey | undefined, + ): SelectedSatisfiedEvidence | undefined { + const candidate = this.toSatisfiedEvidenceCandidate(authority, evidence) + if (!candidate || candidate.collectionId !== this.id) return selected + const exact = candidate.demandKey === demandKey + if ( + authority === `established` && + !isLoadSubsetRequestSubsumedBy(demand, candidate.demand) + ) { + return selected + } + if ( + authority === `applied` && + !exact && + !isLoadSubsetRequestSubsumedBy(demand, candidate.demand) + ) { + return selected + } + + const extent = exact + ? authority === `established` + ? candidate.sourceExtent + : `unknown` + : this.provesRowsBeyondDemand(candidate, demand, sequenceKey) + ? `continues` + : authority === `established` + ? candidate.sourceExtent === `exhausted` + ? `exhausted` + : `unknown` + : undefined + if (extent === undefined) return selected + + const selectedExact = selected?.candidate.demandKey === demandKey + const selectedContinues = selected?.extent === `continues` + const candidateContinues = extent === `continues` + if ( + selected === undefined || + (exact && !selectedExact) || + (exact === selectedExact && candidateContinues && !selectedContinues) || + (exact === selectedExact && + candidateContinues === selectedContinues && + candidate.generation > selected.candidate.generation) + ) { + return { candidate, extent } + } + return selected + } + + private toSatisfiedEvidenceCandidate( + authority: SatisfiedEvidenceAuthority, + evidence: BorrowedCoverageEvidence, TKey>, + ): SatisfiedEvidenceCandidate | undefined { + if (authority === `established`) { + if (evidence.authority !== `established`) return undefined + return { + authority, + acquisition: evidence.acquisition, + collectionId: evidence.coverage.collectionId, + sourceId: evidence.coverage.sourceId, + demand: evidence.coverage.demand, + demandKey: evidence.demandKey, + sequenceKey: evidence.sequenceKey, + sourceExtent: evidence.coverage.extent, + rowKeys: evidence.coverage.rowKeys, + generation: evidence.generation, + } + } + if (evidence.authority !== `applied`) return undefined + return { + authority, + acquisition: evidence.acquisition, + collectionId: evidence.collectionId, + sourceId: evidence.sourceId, + demand: evidence.demand, + demandKey: evidence.demandKey, + sequenceKey: evidence.sequenceKey, + sourceExtent: `unknown`, + rowKeys: evidence.rowKeys, + generation: evidence.generation, + } + } + + private projectSatisfiedEvidence( + candidate: SatisfiedEvidenceCandidate, + demand: LoadSubsetOptions, + generation: number, + extent: AppliedLoadSubsetOutcome[`extent`], + ): ProjectedSatisfiedEvidence { + const retainedDemand = this.snapshotEvidenceDemand(demand) + const rowKeys = this.copyEvidenceRows(candidate.rowKeys) + const outcome: AppliedLoadSubsetOutcome = { + collectionId: candidate.collectionId, + ...(candidate.sourceId === undefined + ? {} + : { sourceId: candidate.sourceId }), + demand: retainedDemand, + generation, + extent, + appliedRowKeys: rowKeys, + } + + return { + candidate, + coverage: + extent === `unknown` + ? undefined + : { + collectionId: candidate.collectionId, + ...(candidate.sourceId === undefined + ? {} + : { sourceId: candidate.sourceId }), + demand: retainedDemand, + extent, + rowKeys, + }, + outcome, + } + } + + private provesRowsBeyondDemand( + covering: { + demand: LoadSubsetOptions + sequenceKey: DemandKey | undefined + sourceExtent: AppliedLoadSubsetOutcome[`extent`] + rowKeys: ReadonlyArray | ReadonlySet + }, + demand: LoadSubsetOptions, + demandSequenceKey: DemandKey | undefined, + ): boolean { + if (demand.limit === undefined) return false + if (covering.sequenceKey !== demandSequenceKey) return false + + const coveringOffset = covering.demand.offset ?? 0 + const demandEnd = (demand.offset ?? 0) + demand.limit + if (coveringOffset > (demand.offset ?? 0)) return false + + const coveringLimit = covering.demand.limit + if ( + covering.sourceExtent === `continues` && + coveringLimit !== undefined && + coveringOffset + coveringLimit >= demandEnd + ) { + return true + } + + const rowCount = + `size` in covering.rowKeys + ? covering.rowKeys.size + : covering.rowKeys.length + return rowCount > demandEnd - coveringOffset + } + + private snapshotEvidenceDemand(demand: LoadSubsetOptions): LoadSubsetOptions { + this.evidenceWorkCounts.demandSnapshots++ + return snapshotLoadSubsetDemand(demand) + } + + private deriveEvidenceDemandKey( + demand: LoadSubsetOptions, + ): ReturnType { + this.evidenceWorkCounts.demandKeyDerivations++ + return getLoadSubsetDemandKey(demand) + } + + private deriveEvidenceSequenceKey( + demand: LoadSubsetOptions, + ): ReturnType { + return this.deriveEvidenceDemandKey({ + ...demand, + limit: undefined, + offset: undefined, + }) + } + + private copyEvidenceRows( + rows: Iterable, + ): ReadonlyArray { + const snapshot = Object.freeze([...rows]) + this.evidenceWorkCounts.rowKeyCopies += snapshot.length + return snapshot + } + + private recordCoverageLease( + ownerOptions: LoadSubsetOptions, + lease: DemandLease, + ): void { + const leases = this.coverageLeasesByOwner.get(ownerOptions) ?? [] + leases.push(lease) + this.coverageLeasesByOwner.set(ownerOptions, leases) + } + + private publishCoverageOutcome( + acquisition: AcquisitionToken, + lease: DemandLease, + outcome: AppliedLoadSubsetOutcome, + ): void { + if (outcome.appliedRowKeys === undefined) { + this.coverageRegistry.settleLease(acquisition, lease) + return + } + this.removeCoverageRows( + this.coverageRegistry.publishOutcome(acquisition, lease, outcome) + .rowsToRemove, + ) + } + + private releaseCoverageLease(options: LoadSubsetOptions): boolean { + const leases = this.coverageLeasesByOwner.get(options) + const lease = leases?.shift() + if (!lease) { + this.flushCoverageRowsToRemove() + return false + } + if (leases?.length === 0) this.coverageLeasesByOwner.delete(options) + this.removeCoverageRows( + this.coverageRegistry.releaseLease(lease).rowsToRemove, + ) + return true + } + + private discardCoverageLease( + options: LoadSubsetOptions, + acquisition: AcquisitionToken, + lease: DemandLease, + ): void { + const leases = this.coverageLeasesByOwner.get(options) + if (leases) { + const index = leases.indexOf(lease) + if (index >= 0) leases.splice(index, 1) + if (leases.length === 0) this.coverageLeasesByOwner.delete(options) + } + this.removeCoverageRows( + this.coverageRegistry.releaseLease(lease).rowsToRemove, + ) + this.coverageRegistry.settleLease(acquisition, lease) + } + + private removeCoverageRows(rows: ReadonlyArray): void { + rows.forEach((row) => this.pendingCoverageRowsToRemove.add(row)) + this.flushCoverageRowsToRemove() + } + + private flushCoverageRowsToRemove(): void { + const unownedRows = [...this.pendingCoverageRowsToRemove].filter( + (row) => this.coverageRegistry.rowOwnerCount(row) === 0, + ) + if (unownedRows.length === 0) return + // During publication this immediate transaction queues behind the batch + // whose listener released the coverage. The state drain applies it before + // the outer commit returns and owns the ignored receipt's rejection path. + this.state.deleteSyncedRows(unownedRows) + unownedRows.forEach((row) => this.pendingCoverageRowsToRemove.delete(row)) } private retainDeferredAdapterOptions( @@ -959,6 +1588,38 @@ export class CollectionSyncManager< return acquisition } + private retainPendingCoverageDemand( + ownerOptions: LoadSubsetOptions, + ): PendingCoverageDemand { + const demand = { released: false } + const demands = this.pendingCoverageDemands.get(ownerOptions) + if (demands) { + demands.push(demand) + } else { + this.pendingCoverageDemands.set(ownerOptions, [demand]) + } + return demand + } + + private releasePendingCoverageDemand(ownerOptions: LoadSubsetOptions): void { + const demand = this.pendingCoverageDemands.get(ownerOptions)?.[0] + if (demand) demand.released = true + } + + private forgetPendingCoverageDemand( + ownerOptions: LoadSubsetOptions, + demand: PendingCoverageDemand, + ): void { + const demands = this.pendingCoverageDemands.get(ownerOptions) + if (!demands) return + + const index = demands.indexOf(demand) + if (index !== -1) demands.splice(index, 1) + if (demands.length === 0) { + this.pendingCoverageDemands.delete(ownerOptions) + } + } + private forgetDeferredAdapterOptions( ownerOptions: LoadSubsetOptions, acquisition: DeferredAdapterAcquisition, @@ -1003,6 +1664,11 @@ export class CollectionSyncManager< this.syncStartDeferred = false this.syncStartRequested = false this.deferredAdapterOptions.clear() + this.pendingCoverageDemands = new WeakMap() + this.flushCoverageRowsToRemove() + this.removeCoverageRows(this.coverageRegistry.dispose().rowsToRemove) + this.coverageLeasesByOwner = new WeakMap() + this.coverageAcquisitionsByPromise = new WeakMap() const wasLoadingSubset = this.pendingLoadSubsetPromises.size > 0 this.pendingLoadSubsetPromises.clear() if (wasLoadingSubset) { diff --git a/packages/db/src/query/builder/get-query-ir.ts b/packages/db/src/query/builder/get-query-ir.ts new file mode 100644 index 0000000000..37a6350a5e --- /dev/null +++ b/packages/db/src/query/builder/get-query-ir.ts @@ -0,0 +1,10 @@ +import type { QueryIR } from '../ir.js' + +type QueryBuilderWithIR = { + _getQuery: () => QueryIR +} + +/** Internal leaf accessor shared with query identity code. */ +export function getQueryIR(builder: unknown): QueryIR { + return (builder as QueryBuilderWithIR)._getQuery() +} diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 0cbfd4092d..40fb925769 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -35,6 +35,7 @@ import { MaterializeWrapper, ToArrayWrapper, } from './functions.js' +import { getQueryIR } from './get-query-ir.js' import type { SourceClauseContext } from '../../errors.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { CollectionOptionsIdentity } from '../../collection-options.js' @@ -1614,12 +1615,7 @@ export function buildQuery( return getQueryIR(result) } -// Internal function to get the QueryIR from a builder -export function getQueryIR( - builder: BaseQueryBuilder | QueryBuilder | InitialQueryBuilder, -): QueryIR { - return (builder as unknown as BaseQueryBuilder)._getQuery() -} +export { getQueryIR } from './get-query-ir.js' // Type-only exports for the query builder export type InitialQueryBuilder = Pick< diff --git a/packages/db/src/query/coverage-registry.ts b/packages/db/src/query/coverage-registry.ts new file mode 100644 index 0000000000..8a2dfb0911 --- /dev/null +++ b/packages/db/src/query/coverage-registry.ts @@ -0,0 +1,1192 @@ +import { compareKeys } from '@tanstack/db-ivm' +import { getLoadSubsetDemandKey } from './ir-stable-identity.js' +import { snapshotLoadSubsetDemand } from './load-subset-options.js' +import { isLoadSubsetRequestSubsumedBy } from './predicate-utils.js' +import type { AppliedLoadSubsetOutcome, LoadSubsetOptions } from '../types.js' +import type { DemandKey } from './ir-stable-identity.js' + +const demandLeaseBrand: unique symbol = Symbol(`DemandLease`) +const acquisitionTokenBrand: unique symbol = Symbol(`AcquisitionToken`) + +export type DemandLease = { + readonly [demandLeaseBrand]: TDemand +} + +export type AcquisitionToken = { + readonly [acquisitionTokenBrand]: true +} + +export type AuthoritativeAppliedLoadSubsetOutcome = AppliedLoadSubsetOutcome & { + extent: Exclude +} + +export type AppliedLoadSubsetCoverage = + Readonly<{ + collectionId: string + sourceId?: string + demand: LoadSubsetOptions + extent: AuthoritativeAppliedLoadSubsetOutcome['extent'] + rowKeys: ReadonlyArray + }> + +type LeaseRecord = { + demand: TDemand + acquisitions: Set +} + +type AcquisitionRecord = { + evidenceEpoch: number + leases: Set> + claims: Map, CoverageClaim> + release: () => void + releaseSettled: boolean + applied: boolean + rows: Set +} + +type CoverageClaim = { + sequence: number + generation: number + settlementPending: boolean + scopeKey: string + scope: { + collectionId: string + sourceId: string | undefined + demandKey: DemandKey | undefined + sequenceKey: DemandKey | undefined + demand: LoadSubsetOptions | undefined + } + coverage: TCoverage | undefined + retainedOutcome: AppliedLoadSubsetOutcome | undefined +} + +export type CoverageRegistryOptions = { + coversDemand: (coverage: TCoverage, demand: TDemand) => boolean + coversCoverage: (coverage: TCoverage, candidate: TCoverage) => boolean + /** Takes a defensive snapshot for storage and every public read. */ + snapshotCoverage: (coverage: TCoverage) => TCoverage + /** Counts retained row-key slots in one coverage snapshot. */ + coverageRowKeyCount?: (coverage: TCoverage) => number + /** + * Projects an exact applied source fact into the registry's coverage + * domain. Return undefined when that fact cannot prove coverage. + */ + projectAppliedCoverage: (evidence: { + outcome: AuthoritativeAppliedLoadSubsetOutcome + rows: ReadonlySet + }) => TCoverage | undefined +} + +export type RowOwnershipUpdate = { + accepted: boolean + rowsToRemove: Array +} + +export type CoveragePublicationResult = + RowOwnershipUpdate & { + published: boolean + } + +export type ReleaseResult = { + rowsToRemove: Array +} + +export type CoverageRegistryResourceCounts = Readonly<{ + liveLeases: number + acquisitions: number + claims: number + unsettledClaims: number + retainedDemands: number + retainedOutcomes: number + retainedRowKeySlots: number +}> + +export type CoverageEvidenceWorkCounts = Readonly<{ + rowKeyCopies: number + demandSnapshots: number + demandKeyDerivations: number +}> + +export type BorrowedCoverageEvidence< + TCoverage, + TRowKey extends string | number, +> = + | Readonly<{ + authority: `established` + acquisition: AcquisitionToken + demandKey: DemandKey | undefined + sequenceKey: DemandKey | undefined + generation: number + coverage: TCoverage + }> + | Readonly<{ + authority: `applied` + acquisition: AcquisitionToken + collectionId: string + sourceId: string | undefined + demand: LoadSubsetOptions + demandKey: DemandKey | undefined + sequenceKey: DemandKey | undefined + generation: number + rowKeys: ReadonlySet + }> + | Readonly<{ + authority: `retained` + acquisition: AcquisitionToken + demandKey: DemandKey | undefined + generation: number + outcome: AppliedLoadSubsetOutcome + }> + +/** + * Keeps logical demand, physical adapter resources, achieved coverage, and row + * provenance separate. Coverage compaction is a derived antichain over active + * facts, so it cannot erase the leases or acquisitions needed to restore a + * narrower fact later. + */ +export class CoverageRegistry< + TDemand, + TCoverage, + TRowKey extends string | number, +> { + private readonly coversDemand: ( + coverage: TCoverage, + demand: TDemand, + ) => boolean + private readonly coversCoverage: ( + coverage: TCoverage, + candidate: TCoverage, + ) => boolean + private readonly projectAppliedCoverage: (evidence: { + outcome: AuthoritativeAppliedLoadSubsetOutcome + rows: ReadonlySet + }) => TCoverage | undefined + private readonly snapshotCoverage: (coverage: TCoverage) => TCoverage + private readonly coverageRowKeyCount: (coverage: TCoverage) => number + private readonly leases = new Map< + DemandLease, + LeaseRecord + >() + private readonly acquisitions = new Map< + AcquisitionToken, + AcquisitionRecord + >() + private readonly rowOwners = new Map>() + private readonly currentAcquisitions = new Map< + string, + { + acquisition: AcquisitionToken + lease: DemandLease + generation: number + } + >() + private claimSequence = 0 + private evidenceEpoch = 0 + private evidenceWorkCounts = { + rowKeyCopies: 0, + demandSnapshots: 0, + demandKeyDerivations: 0, + } + + constructor(options: CoverageRegistryOptions) { + this.coversDemand = options.coversDemand + this.coversCoverage = options.coversCoverage + this.projectAppliedCoverage = options.projectAppliedCoverage + this.snapshotCoverage = options.snapshotCoverage + this.coverageRowKeyCount = options.coverageRowKeyCount ?? (() => 0) + } + + addLease(demand: TDemand): DemandLease { + const lease = {} as DemandLease + this.leases.set(lease, { demand, acquisitions: new Set() }) + return lease + } + + addAcquisition(options: { + generation: number + scope: { + collectionId: string + sourceId?: string + demand: LoadSubsetOptions + } + leases: ReadonlyArray> + release: () => void + }): AcquisitionToken { + if (options.leases.length === 0) { + throw new Error(`A physical acquisition must have a demand lease`) + } + + const leaseRecords = options.leases.map((lease) => { + const record = this.leases.get(lease) + if (!record) throw new Error(`Cannot attach an inactive demand lease`) + return record + }) + const acquisition = { + [acquisitionTokenBrand]: true, + } as AcquisitionToken + const claim = this.createClaim(options.generation, options.scope, true) + this.acquisitions.set(acquisition, { + evidenceEpoch: this.evidenceEpoch, + leases: new Set(options.leases as ReadonlyArray>), + claims: new Map( + options.leases.map((lease) => [ + lease as DemandLease, + { ...claim, sequence: this.claimSequence++ }, + ]), + ), + release: options.release, + releaseSettled: false, + applied: false, + rows: new Set(), + }) + leaseRecords.forEach((record) => record.acquisitions.add(acquisition)) + return acquisition + } + + isAcquisitionAttachable(acquisition: AcquisitionToken): boolean { + const record = this.acquisitions.get(acquisition) + return ( + record !== undefined && + !record.releaseSettled && + record.evidenceEpoch === this.evidenceEpoch + ) + } + + attachLease( + lease: DemandLease, + acquisition: AcquisitionToken, + options?: { + generation: number + scope: { + collectionId: string + sourceId?: string + demand: LoadSubsetOptions + } + coverage?: TCoverage + /** Caller-relative evidence retained from an already applied acquisition. */ + retainedOutcome?: AppliedLoadSubsetOutcome + /** Whether this lease still has an async outcome that may publish. */ + settlementPending?: boolean + }, + ): void { + const leaseRecord = this.leases.get(lease) + const acquisitionRecord = this.acquisitions.get(acquisition) + if (!leaseRecord) throw new Error(`Cannot attach an inactive demand lease`) + if (!acquisitionRecord) { + throw new Error(`Cannot attach to an inactive acquisition`) + } + if (acquisitionRecord.releaseSettled) { + throw new Error(`Cannot attach to a released acquisition`) + } + if (acquisitionRecord.leases.has(lease as DemandLease)) return + if (acquisitionRecord.evidenceEpoch !== this.evidenceEpoch) { + throw new Error(`Cannot attach to an invalidated acquisition`) + } + + const fallback = Array.from(acquisitionRecord.claims.entries()).find( + ([candidate]) => acquisitionRecord.leases.has(candidate), + )?.[1] + const claim = options + ? this.createClaim( + options.generation, + options.scope, + options.settlementPending ?? false, + ) + : fallback + ? { + generation: fallback.generation, + settlementPending: false, + scopeKey: fallback.scopeKey, + scope: { + ...fallback.scope, + demand: + fallback.scope.demand === undefined + ? undefined + : this.snapshotEvidenceDemand(fallback.scope.demand), + }, + coverage: undefined, + retainedOutcome: undefined, + } + : undefined + if (!claim) throw new Error(`Cannot attach to an unscoped acquisition`) + if ( + options?.retainedOutcome !== undefined && + !this.matchesEvidenceOutcome(claim, options.retainedOutcome) + ) { + throw new Error(`Retained outcome does not match the attached claim`) + } + + leaseRecord.acquisitions.add(acquisition) + acquisitionRecord.leases.add(lease as DemandLease) + acquisitionRecord.claims.set(lease as DemandLease, { + ...claim, + sequence: this.claimSequence++, + coverage: + options?.coverage === undefined + ? undefined + : this.snapshotEvidenceCoverage(options.coverage), + retainedOutcome: + options?.retainedOutcome === undefined + ? undefined + : this.snapshotEvidenceOutcome(options.retainedOutcome), + }) + if (options?.coverage !== undefined) { + this.restoreCurrentAcquisition(claim.scopeKey) + } + } + + coveringAcquisitions(demand: TDemand): Array<{ + acquisition: AcquisitionToken + coverage: TCoverage + generation: number + }> { + return Array.from(this.currentCoverageClaims()).flatMap( + ({ acquisition, record, claim }) => + !record.releaseSettled && + claim.coverage !== undefined && + this.coversDemand(claim.coverage, demand) + ? [ + { + acquisition, + coverage: this.snapshotEvidenceCoverage(claim.coverage), + generation: claim.generation, + }, + ] + : [], + ) + } + + /** + * Applied physical acquisitions projected as unknown operation evidence. + * This never creates a coverage fact or participates in covers(). + */ + appliedAcquisitionEvidence(): Array<{ + acquisition: AcquisitionToken + outcome: AppliedLoadSubsetOutcome + rowKeys: ReadonlyArray + }> { + return Array.from(this.acquisitions.entries()).flatMap( + ([acquisition, record]) => + record.applied && + record.evidenceEpoch === this.evidenceEpoch && + !record.releaseSettled && + record.leases.size > 0 + ? Array.from(record.claims.entries()).flatMap(([lease, claim]) => { + if ( + !record.leases.has(lease) || + claim.scope.demand === undefined + ) { + return [] + } + const rowKeys = this.copyEvidenceRows(record.rows) + return [ + { + acquisition, + outcome: this.snapshotEvidenceOutcome({ + collectionId: claim.scope.collectionId, + ...(claim.scope.sourceId === undefined + ? {} + : { sourceId: claim.scope.sourceId }), + demand: claim.scope.demand, + generation: claim.generation, + extent: `unknown`, + appliedRowKeys: rowKeys, + }), + rowKeys, + }, + ] + }) + : [], + ) + } + + /** + * Borrows immutable evidence for internal selection. References stay valid + * until the registry's next mutation; public readers use snapshot methods. + */ + *borrowEvidence(): IterableIterator< + BorrowedCoverageEvidence + > { + for (const { acquisition, record, claim } of this.currentCoverageClaims()) { + if (record.releaseSettled || claim.coverage === undefined) continue + yield Object.freeze({ + authority: `established` as const, + acquisition, + demandKey: claim.scope.demandKey, + sequenceKey: claim.scope.sequenceKey, + generation: claim.generation, + coverage: claim.coverage, + }) + } + + for (const [acquisition, record] of this.acquisitions) { + if ( + record.evidenceEpoch === this.evidenceEpoch && + record.applied && + !record.releaseSettled && + record.leases.size > 0 + ) { + for (const [lease, claim] of record.claims) { + if (!record.leases.has(lease) || claim.scope.demand === undefined) { + continue + } + yield Object.freeze({ + authority: `applied` as const, + acquisition, + collectionId: claim.scope.collectionId, + sourceId: claim.scope.sourceId, + demand: claim.scope.demand, + demandKey: claim.scope.demandKey, + sequenceKey: claim.scope.sequenceKey, + generation: claim.generation, + rowKeys: record.rows as ReadonlySet, + }) + } + } + + for (const [lease, claim] of record.claims) { + if (record.leases.has(lease) && claim.retainedOutcome !== undefined) { + yield Object.freeze({ + authority: `retained` as const, + acquisition, + demandKey: claim.scope.demandKey, + generation: claim.generation, + outcome: claim.retainedOutcome, + }) + } + } + } + } + + /** + * Publishes only coverage proved by an exact, applied source outcome. + * Unknown source extent is request state, not achieved coverage. + */ + publishOutcome( + acquisition: AcquisitionToken, + outcome: AppliedLoadSubsetOutcome, + ): CoveragePublicationResult + publishOutcome( + acquisition: AcquisitionToken, + lease: DemandLease, + outcome: AppliedLoadSubsetOutcome, + ): CoveragePublicationResult + publishOutcome( + acquisition: AcquisitionToken, + leaseOrOutcome: DemandLease | AppliedLoadSubsetOutcome, + maybeOutcome?: AppliedLoadSubsetOutcome, + ): CoveragePublicationResult { + const record = this.acquisitions.get(acquisition) + const outcome = maybeOutcome ?? (leaseOrOutcome as AppliedLoadSubsetOutcome) + const lease = maybeOutcome + ? (leaseOrOutcome as DemandLease) + : this.findMatchingLease(record, outcome) + const claim = + lease === undefined + ? undefined + : record?.claims.get(lease as DemandLease) + if ( + !record || + !claim || + record.releaseSettled || + !this.matchesEvidenceOutcome(claim, outcome) || + outcome.appliedRowKeys === undefined + ) { + return { accepted: false, published: false, rowsToRemove: [] } + } + const matchedLease = lease as DemandLease + + const canPublish = this.canPublish(acquisition, claim) + const nextRows = new Set(outcome.appliedRowKeys as ReadonlyArray) + const rowsToRemove = this.replaceRowsForRecord( + acquisition, + record, + nextRows, + ) + record.applied = true + claim.settlementPending = false + for (const [peerLease, peer] of record.claims) { + if (record.leases.has(peerLease)) peer.retainedOutcome = undefined + } + + // Generation currency controls reusable coverage, not physical row + // ownership. A stale acquisition still owns every row it applied until + // its physical resource is released. + if (!canPublish) { + for (const [peerLease, peer] of record.claims) { + if (record.leases.has(peerLease) && peer.scopeKey === claim.scopeKey) { + peer.coverage = undefined + } + } + this.removeSettledDormantClaim(record, matchedLease, claim) + return { accepted: false, published: false, rowsToRemove } + } + + const coverage = hasAuthoritativeExtent(outcome) + ? this.projectAppliedCoverage({ outcome, rows: nextRows }) + : undefined + const nextCoverage = + coverage === undefined + ? undefined + : this.snapshotEvidenceCoverage(coverage) + if (record.leases.has(lease as DemandLease)) { + claim.coverage = nextCoverage + } + if (nextCoverage !== undefined) { + // One physical result proves the same exact scope for every logical + // owner attached to that acquisition, even when only one observer + // publishes the adapter result. + for (const [peerLease, peer] of record.claims) { + if (record.leases.has(peerLease) && peer.scopeKey === claim.scopeKey) { + peer.coverage = this.snapshotEvidenceCoverage(nextCoverage) + } + } + } + + if (nextCoverage !== undefined) { + this.restoreCurrentAcquisition(claim.scopeKey) + } else { + const current = this.currentAcquisitions.get(claim.scopeKey) + if (current?.acquisition === acquisition && current.lease === lease) { + this.restoreCurrentAcquisition(claim.scopeKey) + } + } + + const result = { + accepted: true, + published: nextCoverage !== undefined, + rowsToRemove, + } + this.removeSettledDormantClaim(record, matchedLease, claim) + return result + } + + replaceRows( + acquisition: AcquisitionToken, + rows: Iterable, + ): RowOwnershipUpdate + replaceRows( + acquisition: AcquisitionToken, + lease: DemandLease, + rows: Iterable, + ): RowOwnershipUpdate + replaceRows( + acquisition: AcquisitionToken, + leaseOrRows: DemandLease | Iterable, + maybeRows?: Iterable, + ): RowOwnershipUpdate { + const record = this.acquisitions.get(acquisition) + const lease = maybeRows + ? (leaseOrRows as DemandLease) + : (Array.from(record?.claims.keys() ?? []).find((candidate) => + record?.leases.has(candidate), + ) as DemandLease | undefined) + const rows = maybeRows ?? (leaseOrRows as Iterable) + const claim = + lease === undefined + ? undefined + : record?.claims.get(lease as DemandLease) + if ( + !record || + !claim || + record.releaseSettled || + !this.canPublish(acquisition, claim) + ) { + return { accepted: false, rowsToRemove: [] } + } + + const nextRows = new Set(rows) + record.applied = false + const affectedScopes = new Set() + for (const [claimLease, existingClaim] of record.claims) { + existingClaim.coverage = undefined + existingClaim.retainedOutcome = undefined + const current = this.currentAcquisitions.get(existingClaim.scopeKey) + if ( + current?.acquisition === acquisition && + current.lease === claimLease + ) { + affectedScopes.add(existingClaim.scopeKey) + } + } + affectedScopes.forEach((scopeKey) => + this.restoreCurrentAcquisition(scopeKey), + ) + return { + accepted: true, + rowsToRemove: this.replaceRowsForRecord(acquisition, record, nextRows), + } + } + + private replaceRowsForRecord( + acquisition: AcquisitionToken, + record: AcquisitionRecord, + nextRows: Set, + ): Array { + const rowsToRemove = new Set() + for (const row of record.rows) { + if (nextRows.has(row)) continue + const owners = this.rowOwners.get(row) + owners?.delete(acquisition) + if (!owners?.size) { + this.rowOwners.delete(row) + rowsToRemove.add(row) + } + } + for (const row of nextRows) { + const owners = this.rowOwners.get(row) ?? new Set() + owners.add(acquisition) + this.rowOwners.set(row, owners) + } + record.rows = nextRows + return sortKeys(rowsToRemove) + } + + private canPublish( + acquisition: AcquisitionToken, + claim: CoverageClaim, + ): boolean { + const record = this.acquisitions.get(acquisition) + if (record?.evidenceEpoch !== this.evidenceEpoch) return false + const current = this.currentAcquisitions.get(claim.scopeKey) + return ( + current === undefined || + current.acquisition === acquisition || + claim.generation > current.generation + ) + } + + private isCurrent( + acquisition: AcquisitionToken, + lease: DemandLease, + claim: CoverageClaim, + ): boolean { + const current = this.currentAcquisitions.get(claim.scopeKey) + return current?.acquisition === acquisition && current.lease === lease + } + + covers(demand: TDemand): boolean { + return this.coverageAntichain().some((coverage) => + this.coversDemand(coverage, demand), + ) + } + + coverageAntichain(): Array { + const facts = Array.from(this.currentCoverageClaims()).flatMap( + ({ claim }) => + claim.coverage === undefined + ? [] + : [claim as CoverageClaim & { coverage: TCoverage }], + ) + + return facts + .filter((candidate) => + facts.every((covering) => { + if (candidate === covering) return true + if (!this.coversCoverage(covering.coverage, candidate.coverage)) { + return true + } + + // Equivalent facts retain the oldest stable representative. Strictly + // stronger facts dominate regardless of publication order. + const equivalent = this.coversCoverage( + candidate.coverage, + covering.coverage, + ) + return equivalent && candidate.sequence < covering.sequence + }), + ) + .map(({ coverage }) => this.snapshotEvidenceCoverage(coverage)) + } + + coverageEvidence(): Array<{ coverage: TCoverage; generation: number }> { + return Array.from(this.currentCoverageClaims()).flatMap(({ claim }) => + claim.coverage === undefined + ? [] + : [ + { + coverage: this.snapshotEvidenceCoverage(claim.coverage), + generation: claim.generation, + }, + ], + ) + } + + /** + * Active caller-relative outcomes retained by synchronous satisfied leases. + * Unknown outcomes are evidence only: they never enter the coverage + * antichain or make covers() return true. + */ + retainedOutcomeEvidence(): Array { + return Array.from(this.acquisitions.values()).flatMap((record) => + Array.from(record.claims.entries()).flatMap(([lease, claim]) => + record.leases.has(lease) && claim.retainedOutcome !== undefined + ? [this.snapshotEvidenceOutcome(claim.retainedOutcome)] + : [], + ), + ) + } + + rowOwnerCount(row: TRowKey): number { + return this.rowOwners.get(row)?.size ?? 0 + } + + /** @internal Resource accounting used by lifecycle oracles. */ + resourceCounts(): CoverageRegistryResourceCounts { + let claims = 0 + let unsettledClaims = 0 + let retainedDemands = 0 + let retainedOutcomes = 0 + let retainedRowKeySlots = 0 + + for (const record of this.acquisitions.values()) { + retainedRowKeySlots += record.rows.size + for (const claim of record.claims.values()) { + claims++ + if (claim.settlementPending) unsettledClaims++ + if (claim.scope.demand !== undefined) retainedDemands++ + if (claim.coverage !== undefined) { + retainedRowKeySlots += this.coverageRowKeyCount(claim.coverage) + } + if (claim.retainedOutcome !== undefined) { + retainedOutcomes++ + retainedRowKeySlots += + claim.retainedOutcome.appliedRowKeys?.length ?? 0 + } + } + } + + return { + liveLeases: this.leases.size, + acquisitions: this.acquisitions.size, + claims, + unsettledClaims, + retainedDemands, + retainedOutcomes, + retainedRowKeySlots, + } + } + + /** @internal Work accounting used by evidence-path oracles. */ + evidenceWork(): CoverageEvidenceWorkCounts { + return { ...this.evidenceWorkCounts } + } + + /** @internal Resets work accounting without changing registry state. */ + resetEvidenceWork(): void { + this.evidenceWorkCounts = { + rowKeyCopies: 0, + demandSnapshots: 0, + demandKeyDerivations: 0, + } + } + + /** Marks one acquisition observer's outcome-free or rejected result settled. */ + settleLease( + acquisition: AcquisitionToken, + lease: DemandLease, + ): void { + const record = this.acquisitions.get(acquisition) + const claim = record?.claims.get(lease as DemandLease) + if (!record || !claim) return + claim.settlementPending = false + this.removeSettledDormantClaim(record, lease, claim) + } + + /** + * Clears source evidence after a committed truncate without releasing the + * logical demands or their physical adapter acquisitions. + */ + invalidateAppliedEvidence(): void { + this.evidenceEpoch++ + this.currentAcquisitions.clear() + this.rowOwners.clear() + for (const record of this.acquisitions.values()) { + record.applied = false + // Borrowed evidence may still hold the old read-only Set during the + // current stack. Replace it instead of mutating that view in place. + record.rows = new Set() + for (const claim of record.claims.values()) { + claim.coverage = undefined + claim.retainedOutcome = undefined + } + } + } + + releaseLease(lease: DemandLease): ReleaseResult { + const leaseRecord = this.leases.get(lease) + if (!leaseRecord) return { rowsToRemove: [] } + + const acquisitions = Array.from(leaseRecord.acquisitions) + const finalAcquisitions = acquisitions.filter((acquisition) => { + const record = this.acquisitions.get(acquisition) + return record?.leases.size === 1 && record.leases.has(lease) + }) + const releaseErrors: Array = [] + this.settleReleases(finalAcquisitions, releaseErrors) + // A failed adapter cleanup leaves the logical graph unchanged. A later + // release retries only callbacks that did not settle successfully. + throwReleaseErrors(releaseErrors) + + const rowsToRemove = new Set() + for (const acquisition of acquisitions) { + const record = this.acquisitions.get(acquisition) + if (!record) continue + const claim = record.claims.get(lease as DemandLease) + record.leases.delete(lease as DemandLease) + if (claim) { + const current = this.currentAcquisitions.get(claim.scopeKey) + if (current?.acquisition === acquisition && current.lease === lease) { + this.restoreCurrentAcquisition(claim.scopeKey) + } + if (claim.settlementPending) { + this.compactDormantClaim(claim) + } else { + record.claims.delete(lease as DemandLease) + } + } + leaseRecord.acquisitions.delete(acquisition) + if (record.leases.size === 0) { + this.retireAcquisitionState(acquisition, rowsToRemove) + } + } + this.leases.delete(lease) + return { rowsToRemove: sortKeys(rowsToRemove) } + } + + releaseAcquisition(acquisition: AcquisitionToken): ReleaseResult { + if (!this.acquisitions.has(acquisition)) return { rowsToRemove: [] } + const releaseErrors: Array = [] + this.settleReleases([acquisition], releaseErrors) + throwReleaseErrors(releaseErrors) + + const rowsToRemove = new Set() + this.retireAcquisitionState(acquisition, rowsToRemove) + return { rowsToRemove: sortKeys(rowsToRemove) } + } + + dispose(): ReleaseResult { + const acquisitions = Array.from(this.acquisitions.keys()) + const releaseErrors: Array = [] + this.settleReleases(acquisitions, releaseErrors) + // Keep every logical owner intact until all physical cleanup callbacks + // settle. This preserves the full GC result for the successful retry. + throwReleaseErrors(releaseErrors) + + const rowsToRemove = new Set() + for (const acquisition of acquisitions) { + this.retireAcquisitionState(acquisition, rowsToRemove) + } + this.leases.clear() + return { rowsToRemove: sortKeys(rowsToRemove) } + } + + private settleReleases( + acquisitions: ReadonlyArray, + releaseErrors: Array, + ): void { + for (const acquisition of acquisitions) { + const record = this.acquisitions.get(acquisition) + if (!record || record.releaseSettled) continue + try { + record.release() + record.releaseSettled = true + } catch (error) { + releaseErrors.push(error) + } + } + } + + private compactDormantClaim(claim: CoverageClaim): void { + claim.scope.demand = undefined + claim.coverage = undefined + claim.retainedOutcome = undefined + } + + private removeSettledDormantClaim( + record: AcquisitionRecord, + lease: DemandLease, + claim: CoverageClaim, + ): void { + if ( + !claim.settlementPending && + !record.leases.has(lease as DemandLease) + ) { + record.claims.delete(lease as DemandLease) + } + } + + private retireAcquisitionState( + acquisition: AcquisitionToken, + rowsToRemove: Set, + ): void { + const record = this.acquisitions.get(acquisition) + if (!record) return + + this.acquisitions.delete(acquisition) + const affectedScopes = new Set() + for (const [lease, claim] of record.claims) { + const current = this.currentAcquisitions.get(claim.scopeKey) + if (current?.acquisition === acquisition && current.lease === lease) { + affectedScopes.add(claim.scopeKey) + } + } + affectedScopes.forEach((scopeKey) => + this.restoreCurrentAcquisition(scopeKey), + ) + for (const lease of record.leases) { + this.leases + .get(lease as DemandLease) + ?.acquisitions.delete(acquisition) + } + for (const row of record.rows) { + const owners = this.rowOwners.get(row) + owners?.delete(acquisition) + if (!owners?.size) { + this.rowOwners.delete(row) + rowsToRemove.add(row) + } + } + } + + private restoreCurrentAcquisition(scopeKey: string): void { + const candidate = Array.from(this.acquisitions.entries()) + .flatMap(([acquisition, record]) => + record.releaseSettled || record.evidenceEpoch !== this.evidenceEpoch + ? [] + : Array.from(record.claims.entries()).flatMap(([lease, claim]) => + record.leases.has(lease) && + claim.scopeKey === scopeKey && + claim.coverage !== undefined + ? [{ acquisition, lease, claim }] + : [], + ), + ) + .sort((left, right) => + left.claim.generation === right.claim.generation + ? right.claim.sequence - left.claim.sequence + : right.claim.generation - left.claim.generation, + )[0] + + if (candidate) { + this.currentAcquisitions.set(scopeKey, { + acquisition: candidate.acquisition, + lease: candidate.lease, + generation: candidate.claim.generation, + }) + } else { + this.currentAcquisitions.delete(scopeKey) + } + } + + private createClaim( + generation: number, + scope: { + collectionId: string + sourceId?: string + demand: LoadSubsetOptions + }, + settlementPending: boolean, + ): Omit, `sequence`> { + const demand = this.snapshotEvidenceDemand(scope.demand) + const demandKey = this.deriveEvidenceDemandKey(demand) + const sequenceKey = this.deriveEvidenceSequenceKey(demand) + return { + generation, + settlementPending, + scopeKey: createScopeKey(scope.collectionId, scope.sourceId, demandKey), + scope: { + collectionId: scope.collectionId, + sourceId: scope.sourceId, + demandKey, + sequenceKey, + demand, + }, + coverage: undefined, + retainedOutcome: undefined, + } + } + + private snapshotEvidenceCoverage(coverage: TCoverage): TCoverage { + this.evidenceWorkCounts.demandSnapshots++ + this.evidenceWorkCounts.rowKeyCopies += this.coverageRowKeyCount(coverage) + return this.snapshotCoverage(coverage) + } + + private snapshotEvidenceDemand(demand: LoadSubsetOptions): LoadSubsetOptions { + this.evidenceWorkCounts.demandSnapshots++ + return snapshotLoadSubsetDemand(demand) + } + + private deriveEvidenceDemandKey( + demand: LoadSubsetOptions, + ): DemandKey | undefined { + this.evidenceWorkCounts.demandKeyDerivations++ + return getLoadSubsetDemandKey(demand) + } + + private deriveEvidenceSequenceKey( + demand: LoadSubsetOptions, + ): DemandKey | undefined { + return this.deriveEvidenceDemandKey({ + ...demand, + limit: undefined, + offset: undefined, + }) + } + + private snapshotEvidenceOutcome( + outcome: AppliedLoadSubsetOutcome, + ): AppliedLoadSubsetOutcome { + this.evidenceWorkCounts.demandSnapshots++ + this.evidenceWorkCounts.rowKeyCopies += outcome.appliedRowKeys?.length ?? 0 + return snapshotAppliedOutcome(outcome) + } + + private copyEvidenceRows(rows: Iterable): ReadonlyArray { + const snapshot = Object.freeze([...rows]) + this.evidenceWorkCounts.rowKeyCopies += snapshot.length + return snapshot + } + + private matchesEvidenceOutcome( + claim: Pick, `generation` | `scope`>, + outcome: AppliedLoadSubsetOutcome, + ): boolean { + return ( + claim.generation === outcome.generation && + claim.scope.collectionId === outcome.collectionId && + claim.scope.sourceId === outcome.sourceId && + claim.scope.demandKey === this.deriveEvidenceDemandKey(outcome.demand) + ) + } + + private findMatchingLease( + record: AcquisitionRecord | undefined, + outcome: AppliedLoadSubsetOutcome, + ): DemandLease | undefined { + if (!record) return undefined + const matching = Array.from(record.claims.entries()).filter(([, claim]) => + this.matchesEvidenceOutcome(claim, outcome), + ) + return (matching.find(([lease]) => record.leases.has(lease)) ?? + matching[0])?.[0] as DemandLease | undefined + } + + private *currentCoverageClaims(): IterableIterator<{ + acquisition: AcquisitionToken + record: AcquisitionRecord + lease: DemandLease + claim: CoverageClaim + }> { + for (const [acquisition, record] of this.acquisitions) { + if (record.evidenceEpoch !== this.evidenceEpoch) continue + for (const [lease, claim] of record.claims) { + if ( + record.leases.has(lease) && + claim.coverage !== undefined && + this.isCurrent(acquisition, lease, claim) + ) { + yield { acquisition, record, lease, claim } + } + } + } + } +} + +/** + * Creates the conservative production registry used at the collection source + * boundary. It publishes only exact demand facts backed by acquisition-owned + * applied row keys. + */ +export function createLoadSubsetCoverageRegistry< + TRowKey extends string | number, +>(): CoverageRegistry< + LoadSubsetOptions, + AppliedLoadSubsetCoverage, + TRowKey +> { + return new CoverageRegistry({ + coversDemand: (coverage, demand) => + isLoadSubsetRequestSubsumedBy(demand, coverage.demand), + coversCoverage: (coverage, candidate) => + coverage.collectionId === candidate.collectionId && + coverage.sourceId === candidate.sourceId && + isLoadSubsetRequestSubsumedBy(candidate.demand, coverage.demand), + snapshotCoverage: snapshotAppliedCoverage, + coverageRowKeyCount: (coverage) => coverage.rowKeys.length, + projectAppliedCoverage: ({ outcome, rows }) => { + const limit = outcome.demand.limit + if (limit === undefined) { + if (outcome.extent !== `exhausted`) return undefined + } else if (rows.size < limit && outcome.extent !== `exhausted`) { + return undefined + } + + return { + collectionId: outcome.collectionId, + ...(outcome.sourceId === undefined + ? {} + : { sourceId: outcome.sourceId }), + demand: snapshotLoadSubsetDemand(outcome.demand), + extent: outcome.extent, + rowKeys: [...rows] as Array, + } + }, + }) +} + +function snapshotAppliedCoverage( + coverage: AppliedLoadSubsetCoverage, +): AppliedLoadSubsetCoverage { + return Object.freeze({ + collectionId: coverage.collectionId, + ...(coverage.sourceId === undefined ? {} : { sourceId: coverage.sourceId }), + demand: snapshotLoadSubsetDemand(coverage.demand), + extent: coverage.extent, + rowKeys: Object.freeze([...coverage.rowKeys]), + }) +} + +function snapshotAppliedOutcome( + outcome: AppliedLoadSubsetOutcome, +): AppliedLoadSubsetOutcome { + return Object.freeze({ + collectionId: outcome.collectionId, + ...(outcome.sourceId === undefined ? {} : { sourceId: outcome.sourceId }), + demand: snapshotLoadSubsetDemand(outcome.demand), + generation: outcome.generation, + extent: outcome.extent, + ...(outcome.appliedRowKeys === undefined + ? {} + : { appliedRowKeys: Object.freeze([...outcome.appliedRowKeys]) }), + }) +} + +function createScopeKey( + collectionId: string, + sourceId: string | undefined, + demandKey: string | undefined, +): string { + return JSON.stringify([collectionId, sourceId ?? null, demandKey ?? null]) +} + +function hasAuthoritativeExtent( + outcome: AppliedLoadSubsetOutcome, +): outcome is AuthoritativeAppliedLoadSubsetOutcome { + return outcome.extent !== `unknown` +} + +function sortKeys( + keys: Iterable, +): Array { + // Ownership removals feed sync publication. Keep their deterministic order + // aligned with the DBSP key order used by the rest of the data plane. + return Array.from(keys).sort(compareKeys) +} + +function throwReleaseErrors(errors: Array): void { + if (errors.length === 0) return + if (errors.length === 1) throw errors[0] + throw new AggregateError(errors, `Several acquisition releases failed`) +} diff --git a/packages/db/src/query/ir-stable-identity.ts b/packages/db/src/query/ir-stable-identity.ts index 1c07d0b748..425e35d5ab 100644 --- a/packages/db/src/query/ir-stable-identity.ts +++ b/packages/db/src/query/ir-stable-identity.ts @@ -1,6 +1,6 @@ import { normalizeValue } from '../utils/comparison.js' import { isRefProxy, toExpression } from './builder/ref-proxy.js' -import { getQueryIR } from './builder/index.js' +import { getQueryIR } from './builder/get-query-ir.js' import { getRuntimeReferenceIdentity } from './runtime-reference-identity.js' import type { Aggregate, @@ -31,6 +31,8 @@ type ValueIdentityContext = | `equality-operand` | `ordering-operand` +type OpaqueValueIdentity = `reject` | `runtime-reference` + type AliasScope = { bindings: ReadonlyMap hasUnqualifiedOutput: boolean @@ -152,12 +154,16 @@ export function getLoadSubsetDemandKey( `loadSubset.cursor.whereFrom`, seen, `exact-output`, + undefined, + `runtime-reference`, ), whereCurrent: canonicalizeExpression( options.cursor.whereCurrent, `loadSubset.cursor.whereCurrent`, seen, `exact-output`, + undefined, + `runtime-reference`, ), } if (options.cursor.lastKey !== undefined) { @@ -386,6 +392,8 @@ function canonicalizeLoadSubsetQuery( `${path}.where`, seen, `exact-output`, + undefined, + `runtime-reference`, ) } @@ -396,6 +404,8 @@ function canonicalizeLoadSubsetQuery( `${path}.orderBy[${index}]`, seen, `ordering-operand`, + undefined, + `runtime-reference`, ), ) } @@ -560,6 +570,7 @@ function canonicalizeOrderBy( seen: WeakSet, valueContext: ValueIdentityContext = `exact-output`, scope?: AliasScope, + opaqueValueIdentity: OpaqueValueIdentity = `reject`, ): StableIdentityValue { return { expression: canonicalizeExpression( @@ -568,6 +579,7 @@ function canonicalizeOrderBy( seen, valueContext, scope, + opaqueValueIdentity, ), compareOptions: canonicalizeRuntimeValue( orderBy.compareOptions, @@ -587,6 +599,7 @@ function canonicalizeExpression( seen: WeakSet, valueContext: ValueIdentityContext = `exact-output`, scope?: AliasScope, + opaqueValueIdentity: OpaqueValueIdentity = `reject`, ): StableIdentityValue { if (expression.type === `ref`) { const binding = resolveAliasBinding(scope, expression.path[0] ?? ``) @@ -622,17 +635,20 @@ function canonicalizeExpression( `${path}.value`, seen, scope, + opaqueValueIdentity, ) : valueContext === `ordering-operand` ? canonicalizeOrderingRuntimeValue( expression.value, `${path}.value`, seen, + opaqueValueIdentity, ) : canonicalizeExactOutputRuntimeValue( expression.value, `${path}.value`, seen, + opaqueValueIdentity, ), } } @@ -650,6 +666,7 @@ function canonicalizeExpression( `${path}.args[1].value[${index}]`, seen, scope, + opaqueValueIdentity, ), ) return canonicalizeFunction(expression.name, [ @@ -659,6 +676,7 @@ function canonicalizeExpression( seen, `equality-operand`, scope, + opaqueValueIdentity, ), { type: `val`, @@ -685,6 +703,7 @@ function canonicalizeExpression( seen, operandContext, scope, + opaqueValueIdentity, ), ) return canonicalizeFunction(expression.name, args) @@ -998,8 +1017,13 @@ function canonicalizeExactOutputRuntimeValue( value: unknown, path: string, seen: WeakSet, + opaqueValueIdentity: OpaqueValueIdentity = `reject`, ): StableIdentityValue { - if (typeof value === `object` && value !== null) { + if ( + (typeof value === `object` && value !== null) || + (opaqueValueIdentity === `runtime-reference` && + (typeof value === `function` || typeof value === `symbol`)) + ) { return getRuntimeReferenceIdentity(value) } @@ -1011,6 +1035,7 @@ function canonicalizeEqualityRuntimeValue( path: string, seen: WeakSet, scope?: AliasScope, + opaqueValueIdentity: OpaqueValueIdentity = `reject`, ): StableIdentityValue { if (isRefProxy(value)) { return canonicalizeExpression( @@ -1019,9 +1044,17 @@ function canonicalizeEqualityRuntimeValue( seen, `equality-operand`, scope, + opaqueValueIdentity, ) } + if ( + opaqueValueIdentity === `runtime-reference` && + (typeof value === `function` || typeof value === `symbol`) + ) { + return getRuntimeReferenceIdentity(value) + } + if (typeof value === `number` && Object.is(value, -0)) { return canonicalizeRuntimeValue(0, path, seen) } @@ -1051,7 +1084,14 @@ function canonicalizeOrderingRuntimeValue( value: unknown, path: string, seen: WeakSet, + opaqueValueIdentity: OpaqueValueIdentity = `reject`, ): StableIdentityValue { + if ( + opaqueValueIdentity === `runtime-reference` && + (typeof value === `function` || typeof value === `symbol`) + ) { + return getRuntimeReferenceIdentity(value) + } if (typeof value === `number` && Object.is(value, -0)) { return canonicalizeRuntimeValue(0, path, seen) } diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index 110e575dd5..2aaeeb425a 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -477,15 +477,80 @@ visible. Rejected, canceled, and obsolete acquisitions establish no coverage. Sources must honor cancellation before publishing request-scoped rows. After those writes are applied, `loadSubset` may resolve with -`{ hasMore: boolean | undefined }`. Core normalizes that source fact to -`continues`, `exhausted`, or `unknown` and binds it to the exact collection -demand and attempt generation; an omitted result also remains `unknown`. A -request reused for a narrower demand may +`{ hasMore: boolean | undefined, appliedRowKeys?: readonly Key[] }`. Core +normalizes the extent to `continues`, `exhausted`, or `unknown` and binds it to +the exact collection demand and attempt generation. The optional keys are the +rows established by that same applied acquisition, not a later scan of the +Collection. An omitted result remains `unknown`. A request reused for a narrower demand may settle that demand, but its raw extent does not become a fact about the narrower demand. Live-query plumbing preserves these outcomes through lazy demand and window coordination. Only the root paginated source may use them to replace a peek-based pagination decision. +The Collection sync boundary gives each logical owner a demand lease and each +physical attempt an acquisition token. Logical peers waiting on the same +physical promise attach their leases to that one acquisition even when their +canonical demands differ. Each lease keeps its own demand, generation, and +coverage claim, while the physical acquisition owns one applied row set until +its final lease releases. A released lease stops contributing active coverage +at once, but its immutable publication identity remains dormant until the +physical acquisition settles or retires. This lets an exact physical result +attach its applied rows when that exact caller released before settlement but a +peer still owns the physical work. A synchronous `true` result creates no +physical resource. It attaches the new logical lease to an active acquisition +whose published coverage proves the demand. Exact-scope reuse may also attach +ownership-only to a live applied acquisition whose extent remains unknown. +Non-exact applied evidence attaches only when its rows locally prove +caller-relative continuation. An exact or locally proven continuing projection +may become caller-relative evidence, but only the continuing projection becomes +a new coverage fact. Unknown evidence still owns the physical acquisition and +rows; it does not enter the coverage antichain or satisfy a later demand. +Starting a newer attempt does not supersede +viable coverage; the current generation advances only when that attempt +publishes authoritative coverage. + +The coverage registry accepts an exact applied outcome and its row keys as one +publication. It rejects stale or mismatched tokens for the same physical +collection, optional source, and canonical demand. An `unknown` extent records +the acquisition's applied row ownership but proves no coverage. A finite prefix +of `N` is established only by at least `N` applied authoritative rows, or fewer +rows plus exact source exhaustion. Callers cannot derive achieved coverage from +the requested limit or publish rows and coverage in separate steps. Failed, +canceled, and stale work publishes neither rows nor coverage. Public reads +return defensive snapshots; fact compaction never mutates or retires the +underlying leases, acquisitions, or row ownership. + +A truncate replay publishes replacement applied rows and their acquisition +ownership before it releases the prior lease. This handoff is one ownership +transition from the Collection's point of view: replacing a row with the same +key cannot let old-owner garbage collection delete the new value. A failed or +obsolete replacement leaves the old lease in place and retires only the new +attempt. + +An imperative load operation reports caller-relative evidence, not merely the +promises started while it was active. If a successful operation starts no new +physical request because exact active coverage already proves its demand, it +retains that applied outcome in the operation result instead of publishing an +empty outcome set. + +Adapter release and `unloadSubset` callbacks must be idempotent and +non-throwing. Core still treats a thrown callback defensively: it surfaces the +original error but preserves the acquisition, lease, coverage, and row owners. +A later cleanup retries callbacks that have not yet settled. Logical ownership +retires only after every callback required by that release step succeeds. Rows +whose final acquisition owner retires are deleted once through the normal +Collection sync boundary; shared rows remain until their final owner retires. +An adapter that uses `DeduplicatedLoadSubset` across live-query lifetimes must +also return the helper's paired `unloadSubset` callback. That callback +invalidates remembered request coverage when core may delete its establishing +rows. A dedupe hit cannot outlive the evidence it claims to reuse. + +An eager Query DB collection owns its base query for the Collection lifetime. +If TanStack Query removes that cache entry while the Collection has no public +listeners, the adapter must replace the detached observer without retiring the +base query's rows. Later cache updates and refetches must still flow through +that lifetime observer. + A transaction `mutationFn` must not start or await collection or live-query preloads. User persistence owns the causal queue while that function runs, so a preload that waits for a queued sync commit can wait on the mutation that is @@ -637,6 +702,8 @@ create recursive Collection machinery. | Collection facades, event coherence, and route activation | `packages/db/tests/query/includes-collection-oracle.property.test.ts` | | Correlated physical work | `packages/db/tests/query/includes-work-counter-oracle.test.ts` | | Route-context discovery and transport across recursive and join boundaries | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | +| Coverage leases, acquisitions, fact compaction, and row provenance | `packages/db/tests/query/coverage-registry-oracle.property.test.ts` | +| Applied coverage publication through the Collection sync boundary | `packages/db/tests/load-subset-outcome.test.ts` | | Query-db ownership | `packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts` | | Reachable nested shape | `packages/query-db-collection/tests/includes-work-counter-oracle.test.ts` | diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 7bcad9c3bf..870e370aac 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -349,7 +349,9 @@ export class CollectionConfigBuilder< windowOperationGeneration === this.windowOperationGeneration && this.currentSyncConfig !== undefined ) { - this.lastWindowOutcomes = loadOperation?.getOutcomes() ?? [] + this.lastWindowOutcomes = this.resolveWindowOutcomes( + loadOperation?.getOutcomes() ?? [], + ) } return true } @@ -362,7 +364,9 @@ export class CollectionConfigBuilder< ) { return } - this.lastWindowOutcomes = loadOperation!.getOutcomes() + this.lastWindowOutcomes = this.resolveWindowOutcomes( + loadOperation!.getOutcomes(), + ) }, () => { // The original promise carries the failure to the caller. This @@ -372,6 +376,14 @@ export class CollectionConfigBuilder< return ready } + private resolveWindowOutcomes( + outcomes: ReadonlyArray, + ): ReadonlyArray { + if (outcomes.length > 0) return outcomes + if (this.lastWindowOutcomes.length > 0) return this.lastWindowOutcomes + return [...this.latestSubsetOutcomes.values()] + } + getWindow(): { offset: number; limit: number } | undefined { // Only return window if this is a windowed query (has orderBy and windowFn) const window = this.currentWindow ?? this.initialWindow @@ -485,6 +497,15 @@ export class CollectionConfigBuilder< this.liveQueryCollection!._sync.trackLoadPromise(tracked) } + trackRetainedSubsetOutcome( + outcome: AppliedLoadSubsetOutcome, + sourceId?: string, + ): void { + const scoped = sourceId === undefined ? outcome : { ...outcome, sourceId } + this.recordSubsetOutcome(scoped) + this.liveQueryCollection!._sync.trackLoadSubsetOperationOutcome(scoped) + } + trackSubsetLoadOperationPromise( promise: Promise, sourceId?: string, @@ -773,6 +794,15 @@ export class CollectionConfigBuilder< return this.runCount } + releaseSubscriptionReference( + sourceId: string, + subscription: CollectionSubscription, + ): void { + if (this.subscriptions[sourceId] === subscription) { + delete this.subscriptions[sourceId] + } + } + private syncFn(config: SyncMethods) { const syncSession = ++this.syncSession // Store reference to the live query collection for error state transitions @@ -793,65 +823,56 @@ export class CollectionConfigBuilder< unsubscribeCallbacks: new Set<() => void>(), } + let teardownStarted = false + let runtimeCleared = false let tornDown = false const teardown = () => { if (tornDown) return - tornDown = true - if (this.syncSession === syncSession) this.syncSession++ + if (!teardownStarted) { + teardownStarted = true + if (this.syncSession === syncSession) this.syncSession++ + } let firstCleanupError: unknown for (const unsubscribe of syncState.unsubscribeCallbacks) { try { unsubscribe() + syncState.unsubscribeCallbacks.delete(unsubscribe) } catch (error) { firstCleanupError ??= error } } - syncState.unsubscribeCallbacks.clear() - - // Clear current sync session state - this.currentSyncConfig = undefined - this.currentSyncState = undefined - this.maybeRunGraphFn = undefined - this.currentWindow = undefined - this.isInErrorState = false - this.fatalQueryError = false - this.erroredSourceIds.clear() - - // Clear all pending graph runs to prevent memory leaks from in-flight transactions - // that may flush after the sync session ends - this.pendingGraphRuns.clear() - - // Reset caches so a fresh graph/pipeline is compiled on next start - // This avoids reusing a finalized D2 graph across GC restarts - this.graphCache = undefined - this.inputsCache = undefined - this.pipelineCache = undefined - this.sourceWhereClausesCache = undefined - this.bucketFacadesCache = undefined - - // Reset lazy source alias state - this.lazySources.clear() - this.demandGenerations.clear() - this.activeDemands.clear() - this.latestSubsetOutcomes.clear() - this.lastWindowOutcomes = [] - this.optimizableOrderByCollections = {} - this.lazySourcesCallbacks = {} - - // Clear subscription references to prevent memory leaks - // Note: Individual subscriptions are already unsubscribed via unsubscribeCallbacks - Object.keys(this.subscriptions).forEach( - (key) => delete this.subscriptions[key], - ) - this.compiledAliasToCollectionId = {} - // Unregister from scheduler's onClear listener to prevent memory leaks - // The scheduler's listener Set would otherwise keep a strong reference to this builder - this.unsubscribeFromSchedulerClears?.() - this.unsubscribeFromSchedulerClears = undefined + if (!runtimeCleared) { + runtimeCleared = true + // Release callbacks may synchronously publish final source deletes. + // Keep the old graph alive through that pass, then retire its runtime + // even when one resource must be retried later. + this.currentSyncConfig = undefined + this.currentSyncState = undefined + this.maybeRunGraphFn = undefined + this.currentWindow = undefined + this.isInErrorState = false + this.fatalQueryError = false + this.erroredSourceIds.clear() + this.pendingGraphRuns.clear() + this.graphCache = undefined + this.inputsCache = undefined + this.pipelineCache = undefined + this.sourceWhereClausesCache = undefined + this.bucketFacadesCache = undefined + this.lazySources.clear() + this.demandGenerations.clear() + this.activeDemands.clear() + this.latestSubsetOutcomes.clear() + this.lastWindowOutcomes = [] + this.optimizableOrderByCollections = {} + this.lazySourcesCallbacks = {} + this.compiledAliasToCollectionId = {} + } if (firstCleanupError !== undefined) throw firstCleanupError + tornDown = true } try { @@ -864,11 +885,18 @@ export class CollectionConfigBuilder< // Listen for scheduler context clears to clean up our pending state // Re-register on each sync start so the listener is active for the sync session's lifetime - this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear( + const schedulerUnsubscribe = transactionScopedScheduler.onClear( (contextId) => { this.clearPendingGraphRun(contextId) }, ) + this.unsubscribeFromSchedulerClears = schedulerUnsubscribe + syncState.unsubscribeCallbacks.add(() => { + schedulerUnsubscribe() + if (this.unsubscribeFromSchedulerClears === schedulerUnsubscribe) { + this.unsubscribeFromSchedulerClears = undefined + } + }) // Listen for loadingSubset changes on the live query collection BEFORE subscribing. // This ensures we don't miss the event if subset loading completes synchronously. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index d1bdfc01d0..08a04573e1 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -15,6 +15,7 @@ import type { Collection } from '../../collection/index.js' import type { AppliedLoadSubsetOutcome, ChangeMessage, + LoadSubsetOptions, LoadSubsetRequestResult, SubscriptionLoadSubsetErrorEvent, SubscriptionStatusChangeEvent, @@ -55,7 +56,10 @@ export class CollectionSubscriber< // Direct load tracking callback for ordered path (set during subscribeToOrderedChanges, // used by loadNextItems for subsequent requestLimitedSnapshot calls) - private orderedLoadSubsetResult?: (result: LoadSubsetRequestResult) => void + private orderedLoadSubsetResult?: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void private pendingOrderedLoadPromise: | Promise | undefined @@ -88,7 +92,10 @@ export class CollectionSubscriber< // Direct load promise tracking: pipes loadSubset results straight to the // live query collection, avoiding the multi-hop deferred promise chain that // can break under microtask timing (e.g., queueMicrotask in TanStack Query). - const trackLoadResult = (result: LoadSubsetRequestResult) => { + const trackLoadResult = ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => { if (result instanceof Promise) { // Defer the tracked rejection by one microtask so the subscription's // error event can put an initial live query in error before loading @@ -110,6 +117,13 @@ export class CollectionSubscriber< ) } } else { + const outcome = this.collection._sync.getLoadSubsetOutcome(demand) + if (outcome) { + this.collectionConfigBuilder.trackRetainedSubsetOutcome( + outcome, + this.sourceId, + ) + } initialSubsetPending = false } } @@ -190,6 +204,10 @@ export class CollectionSubscriber< this.demand.clear() subscription.unsubscribe() + this.collectionConfigBuilder.releaseSubscriptionReference( + this.sourceId, + subscription, + ) } // currentSyncState is always defined when subscribe() is called // (called during sync session setup) @@ -286,7 +304,10 @@ export class CollectionSubscriber< whereExpression: BasicExpression | undefined, includeInitialState: boolean, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, - onLoadSubsetResult: (result: LoadSubsetRequestResult) => void, + onLoadSubsetResult: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void, onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { const sendChanges = ( @@ -321,7 +342,10 @@ export class CollectionSubscriber< whereExpression: BasicExpression | undefined, orderByInfo: OrderByOptimizationInfo, onStatusChange: (event: SubscriptionStatusChangeEvent) => void, - onLoadSubsetResult: (result: LoadSubsetRequestResult) => void, + onLoadSubsetResult: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void, onLoadSubsetError: (event: SubscriptionLoadSubsetErrorEvent) => void, ): CollectionSubscription { const { orderBy, offset, limit, index } = orderByInfo @@ -329,7 +353,10 @@ export class CollectionSubscriber< // Store the callback so loadNextItems can also use direct tracking. // Track in-flight ordered loads to avoid issuing redundant requests while // a previous snapshot is still pending. - const handleLoadSubsetResult = (result: LoadSubsetRequestResult) => { + const handleLoadSubsetResult = ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => { if (result instanceof Promise) { this.pendingOrderedLoadPromise = result const finish = () => { @@ -339,7 +366,7 @@ export class CollectionSubscriber< } void result.then(finish, finish) } - onLoadSubsetResult(result) + onLoadSubsetResult(result, demand) } this.orderedLoadSubsetResult = handleLoadSubsetResult @@ -520,7 +547,7 @@ export class CollectionSubscriber< limit: n, minValues: cursor.minValues, trackLoadSubsetPromise: false, - onLoadSubsetResult: (result) => { + onLoadSubsetResult: (result, demand) => { if (result instanceof Promise) { void result.then(undefined, () => { if (this.lastLoadRequestKey === loadRequestKey) { @@ -528,7 +555,7 @@ export class CollectionSubscriber< } }) } - this.orderedLoadSubsetResult?.(result) + this.orderedLoadSubsetResult?.(result, demand) }, }) } catch (error) { diff --git a/packages/db/src/query/load-subset-outcome.ts b/packages/db/src/query/load-subset-outcome.ts index ef3151f758..f95d72b94d 100644 --- a/packages/db/src/query/load-subset-outcome.ts +++ b/packages/db/src/query/load-subset-outcome.ts @@ -54,6 +54,7 @@ export function createAppliedLoadSubsetOutcome( generation: number, sourceResult: void | LoadSubsetResult, ): AppliedLoadSubsetOutcome { + const appliedRowKeys = sourceResult?.appliedRowKeys return { collectionId, demand, @@ -64,6 +65,9 @@ export function createAppliedLoadSubsetOutcome( : sourceResult?.hasMore === false ? `exhausted` : `unknown`, + ...(appliedRowKeys === undefined + ? {} + : { appliedRowKeys: Object.freeze([...appliedRowKeys]) }), } } diff --git a/packages/db/src/query/runtime-reference-identity.ts b/packages/db/src/query/runtime-reference-identity.ts index 15d7b82b6d..6f60b95554 100644 --- a/packages/db/src/query/runtime-reference-identity.ts +++ b/packages/db/src/query/runtime-reference-identity.ts @@ -5,24 +5,48 @@ export type RuntimeReferenceIdentity = [ ] export function createRuntimeReferenceIdentityFactory(): ( - value: object, + value: object | symbol, ) => RuntimeReferenceIdentity { - const namespace = createRuntimeReferenceNamespace() + let namespace: string | undefined const referenceIds = new WeakMap() + // Symbols cannot be WeakMap keys. Stable identity for the same live symbol + // therefore costs one strong entry for this factory's lifetime. Eviction + // would let a later lookup assign a different identity and corrupt cache + // equality, so keep this explicit until JavaScript offers weak symbol keys. + const symbolReferenceIds = new Map() let sequence = 0 return (value) => { - let referenceId = referenceIds.get(value) - if (referenceId === undefined) { - referenceId = ++sequence - referenceIds.set(value, referenceId) + namespace ??= createRuntimeReferenceNamespace() + let referenceId: number | undefined + if (typeof value === `symbol`) { + referenceId = symbolReferenceIds.get(value) + if (referenceId === undefined) { + referenceId = ++sequence + symbolReferenceIds.set(value, referenceId) + } + } else { + referenceId = referenceIds.get(value) + if (referenceId === undefined) { + referenceId = ++sequence + referenceIds.set(value, referenceId) + } } return [`runtimeReference`, namespace, referenceId] } } -export const getRuntimeReferenceIdentity = - createRuntimeReferenceIdentityFactory() +let runtimeReferenceIdentityFactory: + | ReturnType + | undefined + +export function getRuntimeReferenceIdentity( + value: object | symbol, +): RuntimeReferenceIdentity { + runtimeReferenceIdentityFactory ??= createRuntimeReferenceIdentityFactory() + + return runtimeReferenceIdentityFactory(value) +} function createRuntimeReferenceNamespace(): string { const randomValues = new Uint32Array(4) diff --git a/packages/db/src/query/subset-dedupe.ts b/packages/db/src/query/subset-dedupe.ts index 1e457ba097..d36ce0892d 100644 --- a/packages/db/src/query/subset-dedupe.ts +++ b/packages/db/src/query/subset-dedupe.ts @@ -254,6 +254,26 @@ export class DeduplicatedLoadSubset { } } + /** + * Invalidates request coverage when its Collection owner releases it. + * + * Deduplication is safe only while the rows established by the remembered + * requests remain available to the Collection. Core may delete those rows + * after the final subset owner releases, so adapters that retain this helper + * across live-query lifetimes must return this method as their unloadSubset + * callback. + * + * The reset is intentionally conservative. One released request may clear + * evidence still useful to another owner, causing a later refetch, but it can + * never reuse evidence for rows that core no longer retains. Until adapters + * report which retained rows came from which demand, the settled-case cost is + * bounded to one new physical request for each distinct demand revisited + * before deduplication state is rebuilt. + */ + unloadSubset = (_options: LoadSubsetOptions): void => { + this.reset() + } + /** * Reset all tracking state. * Clears the history of loaded predicates and in-flight calls. diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 1e070abcfe..fdf7f3bec4 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -344,6 +344,12 @@ export interface LoadSubsetResult { * direction. */ hasMore: boolean | undefined + /** + * Keys whose establishing writes were applied by this exact acquisition. + * @internal Coverage bookkeeping only; adapters omit this unless they can + * prove row provenance for the returned result. + */ + appliedRowKeys?: ReadonlyArray } /** @internal Normalized source extent for one applied subset demand. */ @@ -360,6 +366,8 @@ export interface AppliedLoadSubsetOutcome { demand: LoadSubsetOptions generation: number extent: SourceExtent + /** @internal Applied row provenance supplied by the exact acquisition. */ + appliedRowKeys?: ReadonlyArray } /** @internal Result returned by the collection's normalized subset boundary. */ @@ -973,7 +981,10 @@ export interface SubscribeChangesOptions< * Allows the caller to directly track the loading promise for isReady status. * @internal */ - onLoadSubsetResult?: (result: LoadSubsetRequestResult) => void + onLoadSubsetResult?: ( + result: LoadSubsetRequestResult, + demand: LoadSubsetOptions, + ) => void /** Receives subset-load failures scoped to this subscription. @internal */ onLoadSubsetError?: (event: SubscriptionLoadSubsetErrorEvent) => void } diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index eabf80b23e..b11d2622e6 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -77,6 +77,17 @@ type SequentialReplayScenario = { loads: ReadonlyArray } +type ReplayCompletionScenario = { + delivery: `return` | `resolve` + obsoleteBy: + | `stay-active` + | `release-snapshot` + | `unsubscribe` + | `request-abort` + | `newer-truncate` + failingUnload: `none` | `initial` | `first-replay` +} + type CleanupRestartScenario = { oldOutcome: `resolve` | `reject` newOutcome: `resolve` | `reject` @@ -217,6 +228,44 @@ const sequentialReplayScenarioArbitrary: fc.Arbitrary ), }) +const replayCompletionScenarioArbitrary: fc.Arbitrary = + fc.record({ + delivery: fc.constantFrom(`return` as const, `resolve` as const), + obsoleteBy: fc.constantFrom( + `stay-active` as const, + `release-snapshot` as const, + `unsubscribe` as const, + `request-abort` as const, + `newer-truncate` as const, + ), + failingUnload: fc.constantFrom( + `none` as const, + `initial` as const, + `first-replay` as const, + ), + }) + +const exhaustiveReplayCompletionScenarios: Array = [ + `return` as const, + `resolve` as const, +].flatMap((delivery) => + ( + [ + `stay-active`, + `release-snapshot`, + `unsubscribe`, + `request-abort`, + `newer-truncate`, + ] as const + ).flatMap((obsoleteBy) => + ([`none`, `initial`, `first-replay`] as const).map((failingUnload) => ({ + delivery, + obsoleteBy, + failingUnload, + })), + ), +) + const cleanupRestartScenarioArbitrary: fc.Arbitrary = fc.record({ oldOutcome: fc.constantFrom(`resolve` as const, `reject` as const), @@ -955,6 +1004,170 @@ async function runSequentialReplayScenario( } } +let replayCompletionHarnessId = 0 + +async function runReplayCompletionScenario( + scenario: ReplayCompletionScenario, +): Promise { + let begin!: () => void + let commit!: () => void + let truncate!: () => void + const requestAbortController = new AbortController() + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + const loads: Array = [] + const leases = new Map< + LoadSubsetOptions, + { index: number; attempts: number; accepted: number; active: boolean } + >() + const pending: Array>> = [] + let actionRan = false + let failedUnload = false + + const truncateSource = () => { + begin() + truncate() + commit() + } + + const runObsolescenceAction = () => { + if (actionRan) return + actionRan = true + try { + switch (scenario.obsoleteBy) { + case `stay-active`: + break + case `release-snapshot`: + subscription.releaseSnapshot(where) + break + case `unsubscribe`: + subscription.unsubscribe() + break + case `request-abort`: + requestAbortController.abort() + break + case `newer-truncate`: + truncateSource() + break + } + } catch { + // A failed physical release remains active and must be retried below. + } + } + + const collection = createCollection({ + id: `replay-completion-authority-${replayCompletionHarnessId++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + const index = loads.length + loads.push(options) + leases.set(options, { + index, + attempts: 0, + accepted: 0, + active: true, + }) + if (index === 0) return true + + if (scenario.delivery === `return`) { + if (index === 1) runObsolescenceAction() + return true + } + + const deferred = createDeferred() + pending.push(deferred) + return deferred.promise + }, + unloadSubset: (options) => { + const lease = leases.get(options) + if (!lease) throw new Error(`Unknown replay acquisition`) + lease.attempts++ + const shouldFail = + !failedUnload && + ((scenario.failingUnload === `initial` && lease.index === 0) || + (scenario.failingUnload === `first-replay` && + lease.index === 1)) + if (shouldFail) { + failedUnload = true + throw new Error(`Physical release failed`) + } + lease.accepted++ + lease.active = false + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + try { + subscription.requestSnapshot({ + where, + signal: requestAbortController.signal, + optimizedOnly: false, + }) + truncateSource() + await flushPromises() + + if (scenario.delivery === `resolve`) { + runObsolescenceAction() + await flushPromises() + const settlementOrder = + scenario.obsoleteBy === `newer-truncate` + ? [...pending].reverse() + : pending + for (const deferred of settlementOrder) { + deferred.resolve() + await flushPromises() + } + } + + await flushPromises() + expect(actionRan).toBe(true) + expect(loads).toHaveLength(scenario.obsoleteBy === `newer-truncate` ? 3 : 2) + + for (let retry = 0; retry < 3; retry++) { + try { + subscription.unsubscribe() + } catch { + // Retrying a failed exact release is required and remains idempotent. + } + await flushPromises() + if ([...leases.values()].every(({ active }) => !active)) break + } + + for (const lease of leases.values()) { + expect(lease.accepted).toBe(1) + expect(lease.active).toBe(false) + expect(lease.attempts).toBe( + 1 + + Number( + (scenario.failingUnload === `initial` && lease.index === 0) || + (scenario.failingUnload === `first-replay` && lease.index === 1), + ), + ) + } + } finally { + for (const deferred of pending) deferred.resolve() + await flushPromises() + try { + subscription.unsubscribe() + } catch { + subscription.unsubscribe() + } + await collection.cleanup() + } +} + async function runCleanupRestartScenario( scenario: CleanupRestartScenario, ): Promise { @@ -1974,6 +2187,28 @@ describe(`CollectionSubscription replay oracle`, () => { runSequentialReplayScenario, ) + it(`releases every exact acquisition once across bounded replay completion histories`, async () => { + for (const scenario of exhaustiveReplayCompletionScenarios) { + await runReplayCompletionScenario(scenario) + } + }) + + fcTest.prop([replayCompletionScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1761, + })( + `preserves replay completion authority for a fixed seed`, + runReplayCompletionScenario, + ) + + fcTest.prop( + [replayCompletionScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `preserves replay completion authority for a random or replayed seed`, + runReplayCompletionScenario, + ) + fcTest.prop([cleanupRestartScenarioArbitrary], { numRuns: generatedRuns, seed: 1757, diff --git a/packages/db/tests/collection-subscription.test.ts b/packages/db/tests/collection-subscription.test.ts index ed7bc380b8..fa178bcd54 100644 --- a/packages/db/tests/collection-subscription.test.ts +++ b/packages/db/tests/collection-subscription.test.ts @@ -567,7 +567,7 @@ describe(`CollectionSubscription status tracking`, () => { expect(loads).toHaveLength(2) expect(subscription.status).toBe(`loadingSubset`) - replay.reject(new DOMException(`replacement abandoned`, `AbortError`)) + replay.resolve() await flushPromises() expect(subscription.status).toBe(`ready`) expect(subscription.lastError).toEqual( @@ -584,6 +584,74 @@ describe(`CollectionSubscription status tracking`, () => { } }) + it.each([`return`, `resolve`] as const)( + `keeps same-key replay visible while only authoritative completion publishes ownership ($0)`, + async (delivery) => { + type Row = { id: string; value: number } + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + let loadCount = 0 + const collection = createCollection({ + id: `same-key-replay-ownership-${delivery}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: () => { + loadCount++ + begin() + write({ type: `insert`, value: { id: `same`, value: 1 } }) + commit() + const outcome = { + hasMore: false, + appliedRowKeys: [`same`], + } + return loadCount === 1 || delivery === `resolve` + ? Promise.resolve(outcome) + : true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + expect(Array.from(collection.keys())).toEqual([`same`]) + + begin() + truncate() + commit() + await flushPromises() + + expect(Array.from(collection.keys())).toEqual([`same`]) + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength( + delivery === `resolve` ? 1 : 0, + ) + + subscription.unsubscribe() + expect(Array.from(collection.keys())).toEqual( + delivery === `resolve` ? [] : [`same`], + ) + } finally { + await collection.cleanup() + } + }, + ) + it.each([`releaseSnapshot`, `unsubscribe`] as const)( `retries a failed deferred release through %s`, async (releaseMode) => { @@ -660,6 +728,74 @@ describe(`CollectionSubscription status tracking`, () => { }, ) + it(`retries the exact pending replay acquisition after release fails`, async () => { + const replay = createDeferred() + const loads: Array = [] + const unloads: Array = [] + const failure = new Error(`pending replay release failed`) + let failedPendingRelease = false + let begin!: () => void + let commit!: () => void + let truncate!: () => void + const collection = createCollection<{ id: string }>({ + id: `failed-pending-replay-release`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return loads.length === 1 ? Promise.resolve() : replay.promise + }, + unloadSubset: (options) => { + unloads.push(options) + if (options === loads[1] && !failedPendingRelease) { + failedPendingRelease = true + throw failure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + + try { + subscription.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + + begin() + truncate() + commit() + await flushPromises() + expect(loads).toHaveLength(2) + + expect(() => subscription.unsubscribe()).toThrow(failure) + expect(() => subscription.unsubscribe()).not.toThrow() + + const pendingUnloads = unloads.filter((options) => options === loads[1]) + expect(pendingUnloads).toEqual([loads[1], loads[1]]) + expect(unloads.filter((options) => options === loads[0])).toEqual([ + loads[0], + ]) + + replay.resolve() + await flushPromises() + expect(unloads.filter((options) => options === loads[1])).toHaveLength(2) + } finally { + replay.resolve() + subscription.unsubscribe() + await collection.cleanup() + } + }) + it.each([`return`, `resolve`] as const)( `publishes active subset ownership before a reentrant unsubscribe (%s)`, async (resultKind) => { @@ -850,7 +986,7 @@ describe(`CollectionSubscription status tracking`, () => { } }) - it(`uses the acquired options when deferred load reentrantly unsubscribes`, async () => { + it(`does not retain coverage when a deferred load reentrantly unsubscribes`, async () => { const loads: Array = [] const unloads: Array = [] let unsubscribeDuringLoad = () => {} @@ -866,7 +1002,7 @@ describe(`CollectionSubscription status tracking`, () => { loadSubset: (options) => { loads.push(options) unsubscribeDuringLoad() - return Promise.resolve() + return Promise.resolve({ hasMore: false, appliedRowKeys: [] }) }, unloadSubset: (options) => { // Model an adapter that silently ignores an unknown acquisition. @@ -890,6 +1026,7 @@ describe(`CollectionSubscription status tracking`, () => { expect(loads).toHaveLength(1) expect(unloads).toEqual([loads[0]]) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) subscription.unsubscribe() expect(unloads).toHaveLength(1) @@ -1005,6 +1142,62 @@ describe(`CollectionSubscription status tracking`, () => { await collection.cleanup() }) + it(`releases each acquisition once when a synchronous replay releases its demand`, async () => { + const loads: Array = [] + const unloads: Array = [] + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`requested`)]) + let truncateSource: () => void = () => { + throw new Error(`source has not started`) + } + let releaseReplayDemand: () => void = () => { + throw new Error(`subscription has not started`) + } + const collection = createCollection<{ id: string }>({ + id: `synchronous-replay-release`, + getKey: (item) => item.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, commit, markReady, truncate }) => { + markReady() + truncateSource = () => { + begin() + truncate() + commit() + } + return { + loadSubset: (options) => { + loads.push(options) + if (loads.length === 2) releaseReplayDemand() + return true + }, + unloadSubset: (options) => unloads.push(options), + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + releaseReplayDemand = () => subscription.releaseSnapshot(where) + + try { + subscription.requestSnapshot({ + where, + optimizedOnly: false, + }) + truncateSource() + await flushPromises() + + expect(loads).toHaveLength(2) + expect(unloads).toHaveLength(2) + expect(unloads[0]).toBe(loads[1]) + expect(unloads[1]).toBe(loads[0]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + it.each([`throw`, `reject`] as const)( `keeps the last published snapshot when truncate replay fails ($0)`, async (delivery) => { diff --git a/packages/db/tests/collection-sync-reentrancy.test.ts b/packages/db/tests/collection-sync-reentrancy.test.ts new file mode 100644 index 0000000000..a0239bf772 --- /dev/null +++ b/packages/db/tests/collection-sync-reentrancy.test.ts @@ -0,0 +1,617 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createDeferred } from '../src/deferred.js' +import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { flushPromises } from './utils.js' +import type { SyncConfig } from '../src/types.js' + +type Row = { + id: number + value: string +} + +type SyncOps = Parameters[`sync`]>[0] + +type ListenerAction = `commit` | `abort` + +type ListenerScenario = { + beforeOpen: ReadonlyArray + leaveOpen: boolean + afterOpen: ReadonlyArray +} + +const listenerActionArbitrary = fc.constantFrom( + `commit`, + `abort`, +) + +const listenerScenarioArbitrary: fc.Arbitrary = fc.record({ + beforeOpen: fc.array(listenerActionArbitrary, { maxLength: 2 }), + leaveOpen: fc.boolean(), + afterOpen: fc.array(listenerActionArbitrary, { maxLength: 2 }), +}) + +function enumerateActions(maxLength: number): Array> { + const histories: Array> = [[]] + for (let length = 1; length <= maxLength; length++) { + const previous = histories.filter( + (history) => history.length === length - 1, + ) + histories.push( + ...previous.flatMap((history) => + ([`commit`, `abort`] as const).map((action) => [...history, action]), + ), + ) + } + return histories +} + +const exhaustiveListenerScenarios: Array = enumerateActions( + 2, +).flatMap((beforeOpen) => + enumerateActions(2).flatMap((afterOpen) => + [false, true].map((leaveOpen) => ({ + beforeOpen, + leaveOpen, + afterOpen, + })), + ), +) + +let generatedHarnessId = 0 + +function createSyncHarness(id: string) { + let sync!: SyncOps + const collection = createCollection({ + id, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (ops) => { + sync = ops + ops.markReady() + }, + }, + }) + + return { + collection, + get sync() { + return sync + }, + } +} + +function stageInsert( + sync: SyncOps, + row: Row, + options?: { immediate?: boolean }, +): void { + sync.begin(options) + sync.write({ type: `insert`, value: row }) +} + +async function runListenerScenario(scenario: ListenerScenario): Promise { + const harness = createSyncHarness( + `generated-listener-sync-${generatedHarnessId++}`, + ) + const { collection } = harness + const appliedKeys: Array = [] + const originalSet = collection._state.syncedData.set.bind( + collection._state.syncedData, + ) + vi.spyOn(collection._state.syncedData, `set`).mockImplementation( + (key, value) => { + appliedKeys.push(key) + return originalSet(key, value) + }, + ) + const batches: Array> = [] + const committedKeys: Array = [] + const committedReceipts: Array> = [] + const abortedReceipts: Array> = [] + let openKey: number | undefined + let nextKey = 2 + let listenerDepth = 0 + let maxListenerDepth = 0 + let ranActions = false + + const runAction = (action: ListenerAction) => { + const key = nextKey++ + stageInsert(harness.sync, { id: key, value: action }) + if (action === `commit`) { + committedKeys.push(key) + const receipt = harness.sync.commit() + if (receipt !== true) committedReceipts.push(receipt) + return + } + + const controller = new AbortController() + controller.abort() + const receipt = harness.sync.commit(controller.signal) + if (receipt !== true) { + void receipt.then( + () => abortedReceipts.push({ status: `fulfilled`, value: undefined }), + (reason) => abortedReceipts.push({ status: `rejected`, reason }), + ) + } + } + + const subscription = collection.subscribeChanges((changes) => { + listenerDepth++ + maxListenerDepth = Math.max(maxListenerDepth, listenerDepth) + batches.push(changes.map((change) => change.key as number)) + + if (!ranActions && changes.some(({ key }) => key === 1)) { + ranActions = true + scenario.beforeOpen.forEach(runAction) + if (scenario.leaveOpen) { + openKey = nextKey++ + stageInsert(harness.sync, { id: openKey, value: `open` }) + } + scenario.afterOpen.forEach(runAction) + } + + listenerDepth-- + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(appliedKeys).toEqual([1, ...committedKeys]) + expect(batches).toEqual([ + [1], + ...(committedKeys.length > 0 ? [committedKeys] : []), + ]) + expect(maxListenerDepth).toBe(1) + await Promise.all(committedReceipts) + await flushPromises() + expect(committedReceipts).toHaveLength(committedKeys.length) + expect(abortedReceipts).toHaveLength( + scenario.beforeOpen.filter((action) => action === `abort`).length + + scenario.afterOpen.filter((action) => action === `abort`).length, + ) + expect(abortedReceipts.every(({ status }) => status === `rejected`)).toBe( + true, + ) + + if (openKey !== undefined) { + harness.sync.commit() + expect(appliedKeys).toEqual([1, ...committedKeys, openKey]) + expect(batches.at(-1)).toEqual([openKey]) + } + + expect(collection._state.pendingSyncedTransactions).toHaveLength(0) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } +} + +const { multiplier, replaySeed } = readOracleRunConfig() +const generatedRuns = 30 * multiplier + +describe(`sync publication reentrancy`, () => { + it(`preserves sync work opened by a listener until it is committed`, async () => { + const harness = createSyncHarness(`listener-opened-sync-work`) + const { collection } = harness + let openedInnerTransaction = false + const batches: Array> = [] + + const subscription = collection.subscribeChanges((changes) => { + batches.push(changes.map((change) => change.key as number)) + if (!openedInnerTransaction && changes.some(({ key }) => key === 1)) { + openedInnerTransaction = true + stageInsert(harness.sync, { id: 2, value: `inner` }) + } + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(openedInnerTransaction).toBe(true) + expect(collection.get(2)).toBeUndefined() + + expect(() => harness.sync.commit()).not.toThrow() + expect(collection.get(2)).toMatchObject({ id: 2, value: `inner` }) + expect(batches).toEqual([[1], [2]]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`publishes listener-committed sync work after the outer batch exactly once`, async () => { + const harness = createSyncHarness(`listener-committed-sync-work`) + const { collection } = harness + const appliedKeys: Array = [] + const originalSet = collection._state.syncedData.set.bind( + collection._state.syncedData, + ) + vi.spyOn(collection._state.syncedData, `set`).mockImplementation( + (key, value) => { + appliedKeys.push(key) + return originalSet(key, value) + }, + ) + const batches: Array> = [] + let listenerDepth = 0 + let maxListenerDepth = 0 + let committedInnerTransaction = false + + const subscription = collection.subscribeChanges((changes) => { + listenerDepth++ + maxListenerDepth = Math.max(maxListenerDepth, listenerDepth) + batches.push(changes.map((change) => change.key as number)) + + if (!committedInnerTransaction && changes.some(({ key }) => key === 1)) { + committedInnerTransaction = true + stageInsert(harness.sync, { id: 2, value: `inner` }) + harness.sync.commit() + } + + listenerDepth-- + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(appliedKeys).toEqual([1, 2]) + expect(batches).toEqual([[1], [2]]) + expect(maxListenerDepth).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`keeps callback work FIFO across committed, aborted, and open transactions`, async () => { + const harness = createSyncHarness(`listener-sync-action-order`) + const { collection } = harness + const batches: Array> = [] + let ranListenerActions = false + + const subscription = collection.subscribeChanges((changes) => { + batches.push(changes.map((change) => change.key as number)) + if (ranListenerActions || !changes.some(({ key }) => key === 1)) return + ranListenerActions = true + + stageInsert(harness.sync, { id: 2, value: `left-open` }) + + stageInsert(harness.sync, { id: 3, value: `committed` }) + harness.sync.metadata!.row.set(3, { source: `listener` }) + harness.sync.metadata!.collection.set(`listener:commit`, 3) + harness.sync.commit() + + stageInsert(harness.sync, { id: 4, value: `aborted` }) + const controller = new AbortController() + controller.abort() + const abortedReceipt = harness.sync.commit(controller.signal) + if (abortedReceipt !== true) { + void abortedReceipt.catch(() => undefined) + } + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(collection.get(3)).toMatchObject({ id: 3, value: `committed` }) + expect(collection.get(4)).toBeUndefined() + expect(collection._state.syncedMetadata.get(3)).toEqual({ + source: `listener`, + }) + expect( + collection._state.syncedCollectionMetadata.get(`listener:commit`), + ).toBe(3) + + harness.sync.commit() + expect(collection.get(2)).toMatchObject({ id: 2, value: `left-open` }) + expect(batches).toEqual([[1], [3], [2]]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`drains listener-committed transactions in staging order`, async () => { + const harness = createSyncHarness(`listener-sync-fifo`) + const { collection } = harness + const batches: Array> = [] + let stagedInnerTransactions = false + + const subscription = collection.subscribeChanges((changes) => { + batches.push(changes.map((change) => change.key as number)) + if (stagedInnerTransactions || !changes.some(({ key }) => key === 1)) { + return + } + stagedInnerTransactions = true + + stageInsert(harness.sync, { id: 2, value: `first` }) + harness.sync.commit() + stageInsert(harness.sync, { id: 3, value: `second` }) + harness.sync.commit() + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect([...collection._state.syncedData.keys()]).toEqual([1, 2, 3]) + expect(batches).toEqual([[1], [2, 3]]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`drains callback work before surfacing a listener error`, async () => { + const harness = createSyncHarness(`throwing-sync-listener`) + const { collection } = harness + const failure = new Error(`listener failed`) + const appliedKeys: Array = [] + const originalSet = collection._state.syncedData.set.bind( + collection._state.syncedData, + ) + vi.spyOn(collection._state.syncedData, `set`).mockImplementation( + (key, value) => { + appliedKeys.push(key) + return originalSet(key, value) + }, + ) + let queuedReceipt: Promise | undefined + const subscription = collection.subscribeChanges((changes) => { + if (!changes.some(({ key }) => key === 1)) return + stageInsert(harness.sync, { id: 2, value: `queued` }) + const receipt = harness.sync.commit() + if (receipt === true) { + throw new Error(`Expected callback-created work to queue`) + } + queuedReceipt = receipt + throw failure + }) + + try { + stageInsert(harness.sync, { id: 1, value: `first` }) + expect(() => harness.sync.commit()).toThrow(failure) + expect(collection.get(1)).toMatchObject({ id: 1, value: `first` }) + expect(collection.get(2)).toMatchObject({ id: 2, value: `queued` }) + expect(queuedReceipt).toBeDefined() + await expect(queuedReceipt).resolves.toBeUndefined() + + stageInsert(harness.sync, { id: 3, value: `second` }) + expect(() => harness.sync.commit()).not.toThrow() + + expect(appliedKeys).toEqual([1, 2, 3]) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`queues a listener truncate until the outer publication finishes`, async () => { + const harness = createSyncHarness(`listener-sync-truncate`) + const { collection } = harness + const appliedKeys: Array = [] + const originalSet = collection._state.syncedData.set.bind( + collection._state.syncedData, + ) + vi.spyOn(collection._state.syncedData, `set`).mockImplementation( + (key, value) => { + appliedKeys.push(key) + return originalSet(key, value) + }, + ) + let stagedTruncate = false + const subscription = collection.subscribeChanges((changes) => { + if (stagedTruncate || !changes.some(({ key }) => key === 1)) return + stagedTruncate = true + harness.sync.begin() + harness.sync.truncate() + harness.sync.write({ + type: `insert`, + value: { id: 2, value: `replacement` }, + }) + harness.sync.commit() + }) + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(appliedKeys).toEqual([1, 2]) + expect(collection.get(1)).toBeUndefined() + expect(collection.get(2)).toMatchObject({ + id: 2, + value: `replacement`, + }) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`queues listener-triggered source-row garbage collection`, async () => { + const harness = createSyncHarness(`listener-sync-row-gc`) + const { collection } = harness + stageInsert(harness.sync, { id: 2, value: `released` }) + harness.sync.commit() + + const appliedKeys: Array = [] + const originalSet = collection._state.syncedData.set.bind( + collection._state.syncedData, + ) + vi.spyOn(collection._state.syncedData, `set`).mockImplementation( + (key, value) => { + appliedKeys.push(key) + return originalSet(key, value) + }, + ) + const batches: Array> = [] + let queuedGarbageCollection = false + let listenerDepth = 0 + let maxListenerDepth = 0 + const subscription = collection.subscribeChanges( + (changes) => { + listenerDepth++ + maxListenerDepth = Math.max(maxListenerDepth, listenerDepth) + batches.push(changes.map((change) => change.key as number)) + if (!queuedGarbageCollection && changes.some(({ key }) => key === 1)) { + queuedGarbageCollection = true + void collection._state.deleteSyncedRows([2]) + } + listenerDepth-- + }, + { includeInitialState: true }, + ) + batches.length = 0 + + try { + stageInsert(harness.sync, { id: 1, value: `outer` }) + harness.sync.commit() + + expect(appliedKeys).toEqual([1]) + expect(collection.get(2)).toBeUndefined() + expect(batches).toEqual([[1], [2]]) + expect(maxListenerDepth).toBe(1) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`releases applied subset coverage from inside its publication callback`, async () => { + let sync!: SyncOps + const unloadSubset = vi.fn() + const collection = createCollection({ + id: `listener-subset-release-row-gc`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (ops) => { + sync = ops + ops.markReady() + return { + loadSubset: async () => { + stageInsert(ops, { id: 2, value: `owned` }) + const receipt = ops.commit() + if (receipt !== true) await receipt + return { hasMore: false, appliedRowKeys: [2] } + }, + unloadSubset, + } + }, + }, + }) + let ownerUnsubscribed = false + const owner = collection.subscribeChanges((changes) => { + if (ownerUnsubscribed || !changes.some(({ key }) => key === 1)) return + ownerUnsubscribed = true + owner.unsubscribe() + }) + owner.requestSnapshot({ optimizedOnly: false }) + await flushPromises() + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + + const batches: Array> = [] + let listenerDepth = 0 + let maxListenerDepth = 0 + const observer = collection.subscribeChanges( + (changes) => { + listenerDepth++ + maxListenerDepth = Math.max(maxListenerDepth, listenerDepth) + batches.push(changes.map((change) => change.key as number)) + listenerDepth-- + }, + { includeInitialState: true }, + ) + batches.length = 0 + + try { + stageInsert(sync, { id: 1, value: `outer` }) + expect(() => sync.commit()).not.toThrow() + + expect(ownerUnsubscribed).toBe(true) + expect(unloadSubset).toHaveBeenCalledOnce() + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + expect(collection.get(2)).toBeUndefined() + expect(batches).toEqual([[1], [2]]) + expect(maxListenerDepth).toBe(1) + } finally { + owner.unsubscribe() + observer.unsubscribe() + await collection.cleanup() + } + }) + + it(`keeps normal listener sync work queued behind optimistic persistence`, async () => { + let sync!: SyncOps + const mutation = createDeferred() + const collection = createCollection({ + id: `listener-sync-with-optimistic-work`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (ops) => { + sync = ops + ops.markReady() + }, + }, + onInsert: () => mutation.promise, + }) + const optimisticTransaction = collection.insert({ + id: 2, + value: `optimistic`, + }) + let stagedInnerTransaction = false + const subscription = collection.subscribeChanges((changes) => { + if (stagedInnerTransaction || !changes.some(({ key }) => key === 1)) { + return + } + stagedInnerTransaction = true + stageInsert(sync, { id: 3, value: `queued` }) + sync.commit() + }) + + try { + stageInsert(sync, { id: 1, value: `outer` }, { immediate: true }) + sync.commit() + + expect(stagedInnerTransaction).toBe(true) + expect(collection.get(3)).toBeUndefined() + + mutation.resolve() + await optimisticTransaction.isPersisted.promise + + expect(collection.get(3)).toMatchObject({ id: 3, value: `queued` }) + } finally { + mutation.resolve() + subscription.unsubscribe() + await collection.cleanup() + } + }) + + it(`matches every bounded reentrant listener history`, async () => { + for (const scenario of exhaustiveListenerScenarios) { + await runListenerScenario(scenario) + } + }) + + fcTest.prop([listenerScenarioArbitrary], { + numRuns: generatedRuns, + seed: 1774, + })(`matches the reentrant drain laws for a fixed seed`, runListenerScenario) + + fcTest.prop( + [listenerScenarioArbitrary], + oracleRandomParameters(generatedRuns, replaySeed), + )( + `matches the reentrant drain laws for a random or replayed seed`, + runListenerScenario, + ) +}) diff --git a/packages/db/tests/load-subset-full-flow-model.ts b/packages/db/tests/load-subset-full-flow-model.ts new file mode 100644 index 0000000000..a688240cd0 --- /dev/null +++ b/packages/db/tests/load-subset-full-flow-model.ts @@ -0,0 +1,188 @@ +export type FullFlowOwnerId = string +export type FullFlowSessionId = string +export type FullFlowDemandId = string + +export type LoadSubsetFullFlowEvent = + | { + type: `requestDemand` + ownerId: FullFlowOwnerId + sessionId: FullFlowSessionId + demandId: FullFlowDemandId + alreadyAborted: boolean + } + | { + type: `applyAuthoritativeRows` + ownerId: FullFlowOwnerId + demandId: FullFlowDemandId + rowKeys: ReadonlyArray + } + | { + type: `applyUnprovenRows` + ownerId: FullFlowOwnerId + demandId: FullFlowDemandId + rowKeys: ReadonlyArray + } + | { + type: `rejectDemand` + ownerId: FullFlowOwnerId + demandId: FullFlowDemandId + } + | { + type: `truncateSource` + sessionId: FullFlowSessionId + } + | { + type: `releaseDemand` + ownerId: FullFlowOwnerId + demandId: FullFlowDemandId + rowKeys: ReadonlyArray + finalRowOwner: boolean + invalidatesAdapterEvidence: boolean + } + | { + type: `restartSession` + previousSessionId: FullFlowSessionId + nextSessionId: FullFlowSessionId + } + +export type ExpectedAdapterLifecycleEvent = { + type: `invoke` | `release` + ownerId: FullFlowOwnerId +} + +/** + * Projects logical adapter callback obligations. + * + * An already-aborted request never crosses the adapter boundary, so its later + * logical release has no adapter callback. This projection intentionally says + * nothing about physical transport deduplication. + */ +export function projectAdapterLifecycle( + history: ReadonlyArray, +): Array { + const invokedOwners = new Set() + const projected: Array = [] + + for (const event of history) { + if (event.type === `requestDemand` && !event.alreadyAborted) { + invokedOwners.add(event.ownerId) + projected.push({ type: `invoke`, ownerId: event.ownerId }) + } + if (event.type === `releaseDemand` && invokedOwners.delete(event.ownerId)) { + projected.push({ type: `release`, ownerId: event.ownerId }) + } + } + + return projected +} + +/** + * Projects physical transport work from adapter evidence lifetime. + * + * Request settlement alone is not evidence. Only an applied authoritative row + * publication makes the exact demand reusable, and an unload that invalidates + * that evidence forces the next owner to fetch again. + */ +export function projectTransportLoads( + history: ReadonlyArray, +): number { + const reusableDemands = new Set() + const requestEpochs = new Map() + let sourceEpoch = 0 + let loads = 0 + + for (const event of history) { + switch (event.type) { + case `requestDemand`: + if (!event.alreadyAborted && !reusableDemands.has(event.demandId)) { + loads++ + } + if (!event.alreadyAborted) { + requestEpochs.set(event.ownerId, sourceEpoch) + } + break + case `applyAuthoritativeRows`: + if (requestEpochs.get(event.ownerId) === sourceEpoch) { + reusableDemands.add(event.demandId) + } + break + case `truncateSource`: + sourceEpoch++ + reusableDemands.clear() + break + case `applyUnprovenRows`: + case `rejectDemand`: + break + case `releaseDemand`: + if (event.invalidatesAdapterEvidence) { + reusableDemands.delete(event.demandId) + } + break + case `restartSession`: + break + } + } + + return loads +} + +/** Projects reusable demand evidence without using registry state. */ +export function projectReusableDemands( + history: ReadonlyArray, +): Array { + const reusableDemands = new Set() + const requestEpochs = new Map() + let sourceEpoch = 0 + + for (const event of history) { + switch (event.type) { + case `requestDemand`: + if (!event.alreadyAborted) { + requestEpochs.set(event.ownerId, sourceEpoch) + } + break + case `applyAuthoritativeRows`: + if (requestEpochs.get(event.ownerId) === sourceEpoch) { + reusableDemands.add(event.demandId) + } + break + case `truncateSource`: + sourceEpoch++ + reusableDemands.clear() + break + case `releaseDemand`: + if (event.invalidatesAdapterEvidence) { + reusableDemands.delete(event.demandId) + } + break + case `applyUnprovenRows`: + case `rejectDemand`: + case `restartSession`: + break + } + } + + return [...reusableDemands].sort() +} + +/** Derives visible row identity without consulting Collection implementation. */ +export function projectRetainedRowKeys( + history: ReadonlyArray, +): Array { + const retainedRows = new Set() + + for (const event of history) { + if ( + event.type === `applyAuthoritativeRows` || + event.type === `applyUnprovenRows` + ) { + event.rowKeys.forEach((key) => retainedRows.add(key)) + } + if (event.type === `truncateSource`) retainedRows.clear() + if (event.type === `releaseDemand` && event.finalRowOwner) { + event.rowKeys.forEach((key) => retainedRows.delete(key)) + } + } + + return [...retainedRows].sort() +} diff --git a/packages/db/tests/load-subset-lifecycle-model.ts b/packages/db/tests/load-subset-lifecycle-model.ts new file mode 100644 index 0000000000..fd634e8e28 --- /dev/null +++ b/packages/db/tests/load-subset-lifecycle-model.ts @@ -0,0 +1,147 @@ +/** + * Component model for CoverageRegistry ownership and publication only. + * + * It deliberately does not model CollectionSubscription start/skip behavior, + * session-owned continuations, adapter dedupe retention, transaction + * visibility, or public query results. Full-flow histories exercise those + * boundaries through real production objects. + */ +export type LoadSubsetLifecycleState = + | `initial` + | `provisional` + | `active` + | `applied` + | `release-pending` + | `failed` + | `released` + | `disposed` + +export type LoadSubsetReleaseMode = `lease` | `dispose` + +export type LoadSubsetLifecycleEvent = + | { type: `startDemand` } + | { type: `activateDemand` } + | { type: `applyOutcome` } + | { type: `failProvisional` } + | { type: `publishStaleGeneration` } + | { type: `requestRelease` } + | { type: `retryPendingRelease` } + | { type: `acceptPendingRelease` } + | { type: `dispose` } + | { type: `publishLateOutcome` } + +export type LoadSubsetLifecycleModel = { + state: LoadSubsetLifecycleState + applied: boolean + releaseAccepted: boolean + releaseCalls: number + releaseMode?: LoadSubsetReleaseMode +} + +export function createLoadSubsetLifecycleModel(): LoadSubsetLifecycleModel { + return { + state: `initial`, + applied: false, + releaseAccepted: false, + releaseCalls: 0, + } +} + +export function canApplyLoadSubsetLifecycleEvent( + model: Readonly, + event: LoadSubsetLifecycleEvent, +): boolean { + switch (event.type) { + case `startDemand`: + return model.state === `initial` + case `activateDemand`: + case `failProvisional`: + return model.state === `provisional` + case `applyOutcome`: + case `publishStaleGeneration`: + return model.state === `active` + case `requestRelease`: + return model.state === `active` || model.state === `applied` + case `retryPendingRelease`: + case `acceptPendingRelease`: + return model.state === `release-pending` && !model.releaseAccepted + case `dispose`: + return ( + model.state === `initial` || + model.state === `provisional` || + model.state === `active` || + model.state === `applied` + ) + case `publishLateOutcome`: + return model.state === `released` || model.state === `disposed` + } +} + +export function applyLoadSubsetLifecycleEvent( + model: LoadSubsetLifecycleModel, + event: LoadSubsetLifecycleEvent, +): void { + if (!canApplyLoadSubsetLifecycleEvent(model, event)) { + throw new Error(`Cannot apply ${event.type} while ${model.state}`) + } + + switch (event.type) { + case `startDemand`: + model.state = `provisional` + return + case `activateDemand`: + model.state = `active` + return + case `applyOutcome`: + model.state = `applied` + model.applied = true + return + case `failProvisional`: + model.state = `failed` + return + case `publishStaleGeneration`: + case `publishLateOutcome`: + return + case `requestRelease`: + model.state = `release-pending` + model.releaseMode = `lease` + model.releaseCalls++ + return + case `retryPendingRelease`: + model.releaseCalls++ + return + case `acceptPendingRelease`: + model.releaseAccepted = true + model.releaseCalls++ + model.state = model.releaseMode === `dispose` ? `disposed` : `released` + return + case `dispose`: + if (model.state === `active` || model.state === `applied`) { + model.state = `release-pending` + model.releaseMode = `dispose` + model.releaseCalls++ + } else { + model.state = `disposed` + } + } +} + +export function lifecycleOwnsAppliedRows( + model: Readonly, +): boolean { + return ( + model.applied && + model.state !== `released` && + model.state !== `disposed` && + model.state !== `failed` + ) +} + +export function lifecyclePublishesCoverage( + model: Readonly, +): boolean { + return ( + lifecycleOwnsAppliedRows(model) && + (model.state === `applied` || model.state === `release-pending`) + ) +} diff --git a/packages/db/tests/load-subset-outcome.test.ts b/packages/db/tests/load-subset-outcome.test.ts index 5c13f1a8c6..cfe048df06 100644 --- a/packages/db/tests/load-subset-outcome.test.ts +++ b/packages/db/tests/load-subset-outcome.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { DeduplicatedLoadSubset } from '../src/query/subset-dedupe.js' import { SubsetDemandController } from '../src/query/live/subset-demand-controller.js' @@ -7,7 +7,9 @@ import { createLiveQueryCollection } from '../src/query/index.js' import { LIVE_QUERY_INTERNAL } from '../src/query/live/internal.js' import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' import { Func, PropRef, Value } from '../src/query/ir.js' +import { eq } from '../src/query/builder/functions.js' import { getLoadSubsetDemandKey } from '../src/query/ir-stable-identity.js' +import { recordLoadSubsetPromiseDemandMatcher } from '../src/query/load-subset-outcome.js' import { createDeferred } from '../src/deferred.js' import type { LazyDemandPlan } from '../src/query/compiler/joins.js' import type { @@ -17,6 +19,1084 @@ import type { } from '../src/types.js' describe(`loadSubset outcomes`, () => { + it(`invalidates applied subset coverage when its source truncates`, async () => { + let stageTruncate: () => void = () => { + throw new Error(`source has not started`) + } + let commitSource: () => true | Promise = () => { + throw new Error(`source has not started`) + } + const unloadSubset = vi.fn() + const collection = createCollection<{ id: string }>({ + id: `load-subset-outcome-truncate-coverage`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, truncate, markReady }) => { + markReady() + stageTruncate = () => { + begin() + truncate() + } + commitSource = commit + return { + loadSubset: async () => { + begin() + write({ type: `insert`, value: { id: `a` } }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: false, appliedRowKeys: [`a`] } + }, + unloadSubset, + } + }, + }, + }) + const options = { limit: 1 } + + try { + await collection._sync.loadSubset(options) + expect(Array.from(collection.keys())).toEqual([`a`]) + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + + stageTruncate() + expect(Array.from(collection.keys())).toEqual([`a`]) + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + + const truncated = commitSource() + if (truncated !== true) await truncated + + expect(Array.from(collection.keys())).toEqual([]) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + expect(collection._sync.getLoadSubsetOutcome(options)).toBeUndefined() + + collection._sync.unloadSubset(options) + expect(unloadSubset).toHaveBeenCalledOnce() + } finally { + await collection.cleanup() + } + }) + + it(`retires an outcome-free observer after it releases before settlement`, async () => { + const deferred = createDeferred() + const collection = createCollection<{ id: string }>({ + id: `load-subset-outcome-free-observer`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => deferred.promise } + }, + }, + }) + const first = { limit: 1 } + const second = { limit: 1 } + + try { + const firstReady = collection._sync.loadSubset(first) + const secondReady = collection._sync.loadSubset(second) + expect(collection._sync.getLoadSubsetResourceCounts()).toEqual({ + liveLeases: 2, + acquisitions: 1, + claims: 2, + unsettledClaims: 2, + retainedDemands: 2, + retainedOutcomes: 0, + retainedRowKeySlots: 0, + }) + + collection._sync.unloadSubset(first) + expect(collection._sync.getLoadSubsetResourceCounts()).toEqual({ + liveLeases: 1, + acquisitions: 1, + claims: 2, + unsettledClaims: 2, + retainedDemands: 1, + retainedOutcomes: 0, + retainedRowKeySlots: 0, + }) + + deferred.resolve(undefined) + await Promise.all([firstReady, secondReady]) + expect(collection._sync.getLoadSubsetResourceCounts()).toEqual({ + liveLeases: 1, + acquisitions: 1, + claims: 1, + unsettledClaims: 0, + retainedDemands: 1, + retainedOutcomes: 0, + retainedRowKeySlots: 0, + }) + + collection._sync.unloadSubset(second) + expect(collection._sync.getLoadSubsetResourceCounts()).toEqual({ + liveLeases: 0, + acquisitions: 0, + claims: 0, + unsettledClaims: 0, + retainedDemands: 0, + retainedOutcomes: 0, + retainedRowKeySlots: 0, + }) + } finally { + await collection.cleanup() + } + }) + + it(`publishes exact applied coverage through the collection sync boundary`, async () => { + const unloadError = new Error(`unload failed`) + let unloadShouldFail = true + const unloadSubset = vi.fn(() => { + if (unloadShouldFail) throw unloadError + }) + const collection = createCollection<{ id: string }>({ + id: `load-subset-outcome-coverage-registry`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + begin() + write({ type: `insert`, value: { id: `a` } }) + write({ type: `insert`, value: { id: `b` } }) + const applied = commit() + if (applied !== true) await applied + return { + hasMore: true, + appliedRowKeys: [`a`, `b`], + } + }, + unloadSubset, + } + }, + }, + }) + + try { + const options = { limit: 2 } + await collection._sync.loadSubset(options) + + expect(Array.from(collection.keys()).sort()).toEqual([`a`, `b`]) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([ + { + collectionId: collection.id, + demand: { limit: 2 }, + extent: `continues`, + rowKeys: [`a`, `b`], + }, + ]) + + expect(() => collection._sync.unloadSubset(options)).toThrow(unloadError) + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + + unloadShouldFail = false + collection._sync.unloadSubset(options) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + expect(Array.from(collection.keys())).toEqual([]) + expect(unloadSubset).toHaveBeenCalledTimes(2) + } finally { + await collection.cleanup() + } + }) + + it(`retries post-commit row cleanup without applying the delete twice`, async () => { + const unloadSubset = vi.fn() + const collection = createCollection<{ id: string }>({ + id: `load-subset-coverage-gc-retry`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + begin() + write({ type: `insert`, value: { id: `a` } }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: false, appliedRowKeys: [`a`] } + }, + unloadSubset, + } + }, + }, + }) + + try { + const options = { limit: 1 } + await collection._sync.loadSubset(options) + const deleteSyncedRows = collection._state.deleteSyncedRows.bind( + collection._state, + ) + const cleanupError = new Error(`cleanup observer failed`) + const cleanup = vi + .spyOn(collection._state, `deleteSyncedRows`) + .mockImplementationOnce((keys) => { + expect(deleteSyncedRows(keys)).toBe(true) + throw cleanupError + }) + + expect(() => collection._sync.unloadSubset(options)).toThrow(cleanupError) + expect(Array.from(collection.keys())).toEqual([]) + expect(cleanup).toHaveBeenCalledOnce() + + collection._sync.unloadSubset(options) + expect(Array.from(collection.keys())).toEqual([]) + expect(cleanup).toHaveBeenCalledTimes(2) + expect(unloadSubset).toHaveBeenCalledTimes(2) + } finally { + await collection.cleanup() + } + }) + + it.each([`first`, `second`] as const)( + `keeps one physical exact-peer acquisition until the %s lease releases last`, + async (lastLease) => { + let resolveLoad!: (result: { + hasMore: false + appliedRowKeys: ReadonlyArray + }) => void + const sharedLoad = new Promise<{ + hasMore: false + appliedRowKeys: ReadonlyArray + }>((resolve) => { + resolveLoad = resolve + }) + let wrote = false + const collection = createCollection<{ id: string }>({ + id: `load-subset-exact-peer-${lastLease}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: async () => { + const result = await sharedLoad + if (!wrote) { + wrote = true + begin() + write({ type: `insert`, value: { id: `a` } }) + const applied = commit() + if (applied !== true) await applied + } + return result + }, + }) + return { + loadSubset: deduplicated.loadSubset, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const first = { limit: 1 } + const second = { limit: 1 } + const firstLoad = collection._sync.loadSubset(first) + const secondLoad = collection._sync.loadSubset(second) + resolveLoad({ hasMore: false, appliedRowKeys: [`a`] }) + if (firstLoad !== true) await firstLoad + if (secondLoad !== true) await secondLoad + + const firstRelease = lastLease === `first` ? second : first + const finalRelease = lastLease === `first` ? first : second + collection._sync.unloadSubset(firstRelease) + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + expect(Array.from(collection.keys())).toEqual([`a`]) + + collection._sync.unloadSubset(finalRelease) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }, + ) + + it.each([`wide`, `narrow`] as const)( + `keeps one physical promise acquisition across different demands when the %s lease releases first`, + async (firstRelease) => { + let resolveLoad!: (result: { + hasMore: false + appliedRowKeys: ReadonlyArray + }) => void + const physicalPromise = new Promise<{ + hasMore: false + appliedRowKeys: ReadonlyArray + }>((resolve) => { + resolveLoad = resolve + }) + let installed = false + const collection = createCollection<{ id: string }>({ + id: `load-subset-different-demand-peer-${firstRelease}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + if (!installed) { + installed = true + begin() + write({ type: `insert`, value: { id: `shared` } }) + commit() + } + return physicalPromise + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const owners = { + wide: { limit: 10 }, + narrow: { limit: 5 }, + } + const wide = collection._sync.loadSubset(owners.wide) + const narrow = collection._sync.loadSubset(owners.narrow) + resolveLoad({ hasMore: false, appliedRowKeys: [`shared`] }) + if (wide !== true) await wide + if (narrow !== true) await narrow + + const finalRelease = firstRelease === `wide` ? `narrow` : `wide` + collection._sync.unloadSubset(owners[firstRelease]) + expect(Array.from(collection.keys())).toEqual([`shared`]) + + collection._sync.unloadSubset(owners[finalRelease]) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + resolveLoad({ hasMore: false, appliedRowKeys: [`shared`] }) + await collection.cleanup() + } + }, + ) + + it(`keeps physical row ownership when its exact caller releases before settlement`, async () => { + let resolveLoad!: (result: { + hasMore: false + appliedRowKeys: ReadonlyArray + }) => void + const physicalPromise = new Promise<{ + hasMore: false + appliedRowKeys: ReadonlyArray + }>((resolve) => { + resolveLoad = resolve + }) + const wideDemand = { limit: 10 } + recordLoadSubsetPromiseDemandMatcher( + physicalPromise, + (candidate) => candidate.limit === wideDemand.limit, + ) + let installed = false + const collection = createCollection<{ id: string }>({ + id: `load-subset-released-physical-publisher`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + if (!installed) { + installed = true + begin() + write({ type: `insert`, value: { id: `shared` } }) + commit() + } + return physicalPromise + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const narrowDemand = { limit: 5 } + const wide = collection._sync.loadSubset(wideDemand) + const narrow = collection._sync.loadSubset(narrowDemand) + collection._sync.unloadSubset(wideDemand) + + resolveLoad({ hasMore: false, appliedRowKeys: [`shared`] }) + if (wide !== true) await wide + if (narrow !== true) await narrow + expect(Array.from(collection.keys())).toEqual([`shared`]) + + collection._sync.unloadSubset(narrowDemand) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + resolveLoad({ hasMore: false, appliedRowKeys: [`shared`] }) + await collection.cleanup() + } + }) + + it.each([`loaded`, `satisfied`] as const)( + `retains exact applied ownership through synchronous true reuse when the %s lease releases first`, + async (firstRelease) => { + let loadCount = 0 + const collection = createCollection<{ id: string }>({ + id: `load-subset-true-reuse-${firstRelease}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1) return true + begin() + write({ type: `insert`, value: { id: `shared` } }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: [`shared`], + }) + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const owners = { + loaded: { limit: 1 }, + satisfied: { limit: 1 }, + } + await collection._sync.loadSubset(owners.loaded) + expect(collection._sync.loadSubset(owners.satisfied)).toBe(true) + + const finalRelease = firstRelease === `loaded` ? `satisfied` : `loaded` + collection._sync.unloadSubset(owners[firstRelease]) + expect(Array.from(collection.keys())).toEqual([`shared`]) + expect(collection._sync.getLoadSubsetOutcome({ limit: 1 })).toEqual( + expect.objectContaining({ + extent: `exhausted`, + appliedRowKeys: [`shared`], + }), + ) + + collection._sync.unloadSubset(owners[finalRelease]) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }, + ) + + it.each( + ([`continues`, `exhausted`, `unknown`] as const).flatMap((sourceExtent) => + ([`exact`, `covering`, `narrower`] as const).flatMap((relationship) => + ([`loaded`, `satisfied`] as const).map((firstRelease) => ({ + sourceExtent, + relationship, + firstRelease, + })), + ), + ), + )( + `projects $sourceExtent evidence through $relationship synchronous true reuse when $firstRelease releases first`, + async ({ sourceExtent, relationship, firstRelease }) => { + let loadCount = 0 + const rowIds = Array.from({ length: 10 }, (_, index) => `row-${index}`) + const collection = createCollection<{ id: string }>({ + id: `load-subset-true-projection-${sourceExtent}-${relationship}-${firstRelease}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount > 1) return true + begin() + rowIds.forEach((id) => write({ type: `insert`, value: { id } })) + commit() + return Promise.resolve({ + hasMore: + sourceExtent === `unknown` + ? undefined + : sourceExtent === `continues`, + appliedRowKeys: rowIds, + }) + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + const owners = { + loaded: { limit: 10 }, + satisfied: + relationship === `exact` + ? { limit: 10 } + : relationship === `covering` + ? { offset: 5, limit: 5 } + : { limit: 5 }, + } + const satisfiedEnd = + (owners.satisfied.offset ?? 0) + owners.satisfied.limit + const expectedExtent = + relationship === `exact` + ? sourceExtent + : sourceExtent === `continues` || rowIds.length > satisfiedEnd + ? `continues` + : sourceExtent === `exhausted` + ? `exhausted` + : `unknown` + const ownsAppliedAcquisition = + sourceExtent !== `unknown` || + relationship === `exact` || + relationship === `narrower` + + try { + await collection._sync.loadSubset(owners.loaded) + expect(collection._sync.loadSubset(owners.satisfied)).toBe(true) + if (ownsAppliedAcquisition) { + expect( + collection._sync.getLoadSubsetOutcome(owners.satisfied), + ).toEqual( + expect.objectContaining({ + demand: owners.satisfied, + extent: expectedExtent, + appliedRowKeys: rowIds, + }), + ) + } else { + expect( + collection._sync.getLoadSubsetOutcome(owners.satisfied), + ).toBeUndefined() + } + if (sourceExtent === `unknown`) { + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength( + relationship === `narrower` ? 1 : 0, + ) + } + + const finalRelease = firstRelease === `loaded` ? `satisfied` : `loaded` + collection._sync.unloadSubset(owners[firstRelease]) + expect(Array.from(collection.keys()).sort()).toEqual( + firstRelease === `loaded` && !ownsAppliedAcquisition + ? [] + : [...rowIds].sort(), + ) + if (firstRelease === `loaded`) { + if (ownsAppliedAcquisition) { + expect( + collection._sync.getLoadSubsetOutcome(owners.satisfied), + ).toEqual(expect.objectContaining({ extent: expectedExtent })) + } else { + expect( + collection._sync.getLoadSubsetOutcome(owners.satisfied), + ).toBeUndefined() + } + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength( + !ownsAppliedAcquisition || expectedExtent === `unknown` ? 0 : 1, + ) + } else if (sourceExtent === `unknown`) { + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(0) + } + + collection._sync.unloadSubset(owners[finalRelease]) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }, + ) + + it(`retries only failed live-query source releases on later cleanup`, async () => { + const failure = new Error(`left source unload failed`) + let leftShouldFail = true + const leftUnload = vi.fn(() => { + if (leftShouldFail) throw failure + }) + const rightUnload = vi.fn() + const createSource = (id: string, unloadSubset: () => void) => + createCollection<{ id: number }>({ + id, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BasicIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + begin() + write({ type: `insert`, value: { id: 1 } }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: false, appliedRowKeys: [1] } + }, + unloadSubset, + } + }, + }, + }) + const left = createSource(`live-cleanup-retry-left`, leftUnload) + const right = createSource(`live-cleanup-retry-right`, rightUnload) + const live = createLiveQueryCollection({ + id: `live-cleanup-retry`, + query: (q) => + q + .from({ left }) + .leftJoin({ right }, ({ left: leftRow, right: rightRow }) => + eq(leftRow.id, rightRow.id), + ), + startSync: true, + }) + const originalQueueMicrotask = globalThis.queueMicrotask + const queuedMicrotasks: Array<() => void> = [] + + try { + await live.preload() + expect(Array.from(left.keys())).toEqual([1]) + expect(Array.from(right.keys())).toEqual([1]) + expect(left._sync.getLoadSubsetCoverage()).toHaveLength(1) + expect(right._sync.getLoadSubsetCoverage()).toHaveLength(1) + + globalThis.queueMicrotask = (callback) => { + queuedMicrotasks.push(callback) + } + await live.cleanup() + + expect(leftUnload).toHaveBeenCalledOnce() + expect(rightUnload).toHaveBeenCalledOnce() + expect(Array.from(left.keys())).toEqual([1]) + expect(left._sync.getLoadSubsetCoverage()).toHaveLength(1) + expect(Array.from(right.keys())).toEqual([]) + expect(right._sync.getLoadSubsetCoverage()).toEqual([]) + expect(queuedMicrotasks).toHaveLength(1) + + let surfacedError: unknown + try { + queuedMicrotasks[0]!() + } catch (error) { + surfacedError = error + } + expect(surfacedError).toMatchObject({ cause: failure }) + + leftShouldFail = false + await live.cleanup() + + expect(leftUnload).toHaveBeenCalledTimes(2) + expect(rightUnload).toHaveBeenCalledOnce() + expect(Array.from(left.keys())).toEqual([]) + expect(left._sync.getLoadSubsetCoverage()).toEqual([]) + expect(queuedMicrotasks).toHaveLength(1) + } finally { + globalThis.queueMicrotask = originalQueueMicrotask + leftShouldFail = false + await Promise.all([live.cleanup(), left.cleanup(), right.cleanup()]) + } + }) + + it(`keeps prior applied coverage when a newer exact attempt fails`, async () => { + type PendingLoad = { + succeed: (rowId: string) => Promise + reject: (error: Error) => void + } + const pending: Array = [] + const collection = createCollection<{ id: string }>({ + id: `load-subset-failed-exact-retry`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => + new Promise((resolve, reject) => { + pending.push({ + succeed: async (rowId) => { + begin() + write({ type: `insert`, value: { id: rowId } }) + const applied = commit() + if (applied !== true) await applied + resolve({ + hasMore: false, + appliedRowKeys: [rowId], + }) + }, + reject, + }) + }), + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const firstOptions = { limit: 1 } + const retryOptions = { limit: 1 } + const first = collection._sync.loadSubset(firstOptions) + const retry = collection._sync.loadSubset(retryOptions) + if (first === true || retry === true) { + throw new Error(`Expected asynchronous loads`) + } + void retry.catch(() => undefined) + + await pending[0]!.succeed(`first`) + await first + expect(collection._sync.getLoadSubsetCoverage()).toHaveLength(1) + + pending[1]!.reject(new Error(`retry failed`)) + await expect(retry).rejects.toThrow(`retry failed`) + expect(collection._sync.getLoadSubsetCoverage()).toMatchObject([ + { rowKeys: [`first`] }, + ]) + } finally { + await collection.cleanup() + } + }) + + it(`keeps rows applied by an older active acquisition after a newer owner releases`, async () => { + type PendingLoad = { + succeed: () => Promise + } + const pending: Array = [] + let hasWrittenRow = false + const collection = createCollection<{ id: string }>({ + id: `load-subset-stale-generation-row-owner`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => + new Promise((resolve) => { + pending.push({ + succeed: async () => { + begin() + write({ + type: hasWrittenRow ? `update` : `insert`, + value: { id: `shared` }, + }) + hasWrittenRow = true + const applied = commit() + if (applied !== true) await applied + resolve({ + hasMore: false, + appliedRowKeys: [`shared`], + }) + }, + }) + }), + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const olderOptions = { limit: 1 } + const newerOptions = { limit: 1 } + const older = collection._sync.loadSubset(olderOptions) + const newer = collection._sync.loadSubset(newerOptions) + if (older === true || newer === true) { + throw new Error(`Expected asynchronous loads`) + } + + await pending[1]!.succeed() + await newer + await pending[0]!.succeed() + await older + + expect(Array.from(collection.keys())).toEqual([`shared`]) + collection._sync.unloadSubset(newerOptions) + expect(Array.from(collection.keys())).toEqual([`shared`]) + + collection._sync.unloadSubset(olderOptions) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }) + + it(`owns applied rows even when source extent is unknown`, async () => { + const collection = createCollection<{ id: string }>({ + id: `load-subset-unknown-row-provenance`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + begin() + write({ type: `insert`, value: { id: `a` } }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: undefined, appliedRowKeys: [`a`] } + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const options = { limit: 1 } + await collection._sync.loadSubset(options) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + expect(Array.from(collection.keys())).toEqual([`a`]) + + collection._sync.unloadSubset(options) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }) + + it.each([ + [`narrow`, `wide`], + [`wide`, `narrow`], + ] as const)( + `garbage-collects overlapping acquisition rows only after the %s owner releases last`, + async (firstRelease, finalRelease) => { + const collection = createCollection<{ id: string }>({ + id: `load-subset-overlapping-row-owners-${firstRelease}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async (options) => { + begin() + write({ type: `insert`, value: { id: `shared` } }) + if (options.limit === 2) { + write({ type: `insert`, value: { id: `wide-only` } }) + } + const applied = commit() + if (applied !== true) await applied + return { + hasMore: false, + appliedRowKeys: + options.limit === 2 ? [`shared`, `wide-only`] : [`shared`], + } + }, + unloadSubset: vi.fn(), + } + }, + }, + }) + + try { + const owners = { + narrow: { limit: 1 }, + wide: { limit: 2 }, + } + await collection._sync.loadSubset(owners.narrow) + await collection._sync.loadSubset(owners.wide) + expect(Array.from(collection.keys()).sort()).toEqual([ + `shared`, + `wide-only`, + ]) + + collection._sync.unloadSubset(owners[firstRelease]) + expect(collection.has(`shared`)).toBe(true) + expect(collection.has(`wide-only`)).toBe(finalRelease === `wide`) + + collection._sync.unloadSubset(owners[finalRelease]) + expect(Array.from(collection.keys())).toEqual([]) + } finally { + await collection.cleanup() + } + }, + ) + + it(`tracks opaque demand values by runtime reference`, async () => { + const loadSubset = vi.fn((_options: LoadSubsetOptions) => + Promise.resolve({ hasMore: false }), + ) + const unloadSubset = vi.fn((_options: LoadSubsetOptions) => {}) + const collection = createCollection<{ id: string }>({ + id: `load-subset-opaque-demand`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset, unloadSubset } + }, + }, + }) + + try { + const field = new PropRef([`item`, `value`]) + const createDemands = (value: unknown): Array => [ + { where: new Func(`eq`, [field, new Value(value)]) }, + { where: new Func(`in`, [field, new Value([value])]) }, + { + orderBy: [ + { + expression: new Func(`coalesce`, [field, new Value(value)]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + }, + { + cursor: { + whereFrom: new Func(`gt`, [field, new Value(value)]), + whereCurrent: new Func(`eq`, [field, new Value(value)]), + }, + }, + ] + const demands = [ + ...createDemands(() => `opaque`), + ...createDemands(Symbol(`opaque`)), + ] + + for (const options of demands) { + const outcome = await collection._sync.loadSubset(options) + expect(outcome).toMatchObject({ extent: `exhausted` }) + collection._sync.unloadSubset(options) + } + + expect(loadSubset.mock.calls.map(([options]) => options)).toEqual(demands) + expect(unloadSubset.mock.calls.map(([options]) => options)).toEqual( + demands, + ) + } finally { + await collection.cleanup() + } + }) + + it(`snapshots nested ordering options across load and coverage reads`, async () => { + let resolveLoad!: (value: { + hasMore: false + appliedRowKeys: ReadonlyArray + }) => void + const pending = new Promise<{ + hasMore: false + appliedRowKeys: ReadonlyArray + }>((resolve) => { + resolveLoad = resolve + }) + const collection = createCollection<{ id: string }>({ + id: `load-subset-nested-demand-snapshot`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async () => { + const result = await pending + begin() + write({ type: `insert`, value: { id: `a` } }) + const applied = commit() + if (applied !== true) await applied + return result + }, + } + }, + }, + }) + + try { + const localeOptions = { numeric: true } + const options = { + limit: 1, + orderBy: [ + { + expression: new PropRef([`item`, `name`]), + compareOptions: { + direction: `asc` as const, + nulls: `first` as const, + stringSort: `locale` as const, + locale: `en`, + localeOptions, + }, + }, + ], + } + const load = collection._sync.loadSubset(options) + localeOptions.numeric = false + resolveLoad({ hasMore: false, appliedRowKeys: [`a`] }) + if (load !== true) await load + + const fact = collection._sync.getLoadSubsetCoverage()[0]! + expect( + ( + fact.demand.orderBy![0]!.compareOptions as { + localeOptions: { numeric: boolean } + } + ).localeOptions.numeric, + ).toBe(true) + ;( + fact.demand.orderBy![0]!.compareOptions as { + localeOptions: { numeric: boolean } + } + ).localeOptions.numeric = false + expect( + ( + collection._sync.getLoadSubsetCoverage()[0]!.demand.orderBy![0]! + .compareOptions as { localeOptions: { numeric: boolean } } + ).localeOptions.numeric, + ).toBe(true) + } finally { + await collection.cleanup() + } + }) + + it(`does not publish a continuing prefix without applied rows`, async () => { + const collection = createCollection<{ id: string }>({ + id: `load-subset-outcome-rowless-coverage`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => + Promise.resolve({ hasMore: true, appliedRowKeys: [] }), + } + }, + }, + }) + + try { + await collection._sync.loadSubset({ limit: 2 }) + expect(collection._sync.getLoadSubsetCoverage()).toEqual([]) + } finally { + await collection.cleanup() + } + }) + it.each([ [{ hasMore: true }, `continues`], [{ hasMore: false }, `exhausted`], @@ -510,6 +1590,8 @@ describe(`loadSubset outcomes`, () => { it(`retains source-scoped outcomes at the live-query window boundary`, async () => { type Row = { id: number; rank: number } let nextId = 1 + let loadCount = 0 + let skipPhysicalLoad = false const source = createCollection({ id: `load-subset-outcome-live-source`, getKey: (row) => row.id, @@ -521,15 +1603,20 @@ describe(`loadSubset outcomes`, () => { sync: ({ begin, write, commit, markReady }) => { markReady() return { - loadSubset: async () => { - begin() - write({ - type: `insert`, - value: { id: nextId, rank: nextId++ }, - }) - const applied = commit() - if (applied !== true) await applied - return { hasMore: false } + loadSubset: () => { + if (skipPhysicalLoad) return true + loadCount++ + const id = nextId++ + return (async () => { + begin() + write({ + type: `insert`, + value: { id, rank: id }, + }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: false, appliedRowKeys: [id] } + })() }, } }, @@ -567,6 +1654,22 @@ describe(`loadSubset outcomes`, () => { expect( controller[LIVE_QUERY_INTERNAL].getLatestAppliedOutcomes(), ).toEqual(internal.getLastWindowOutcomes()) + + const callsBeforeNoop = loadCount + const retainedOutcomes = internal.getLastWindowOutcomes() + skipPhysicalLoad = true + const noOp = live.utils.setWindow({ offset: 0, limit: 3 }) + if (noOp !== true) await noOp + expect(loadCount).toBe(callsBeforeNoop) + expect(internal.getLastWindowOutcomes()).toEqual(retainedOutcomes) + expect(retainedOutcomes).toEqual([ + expect.objectContaining({ + collectionId: source.id, + sourceId: expect.any(String), + extent: `exhausted`, + appliedRowKeys: expect.any(Array), + }), + ]) } finally { controller.dispose() await Promise.all([live.cleanup(), source.cleanup()]) diff --git a/packages/db/tests/query/coverage-registry-oracle.property.test.ts b/packages/db/tests/query/coverage-registry-oracle.property.test.ts new file mode 100644 index 0000000000..8c2e8fc253 --- /dev/null +++ b/packages/db/tests/query/coverage-registry-oracle.property.test.ts @@ -0,0 +1,2012 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { compareKeys } from '@tanstack/db-ivm' +import { describe, expect, it, vi } from 'vitest' +import { + CoverageRegistry, + createLoadSubsetCoverageRegistry, +} from '../../src/query/coverage-registry.js' +import { oraclePropertyOptions } from '../oracle-config.js' +import type { AppliedLoadSubsetOutcome } from '../../src/types.js' +import type { Command } from 'fast-check' + +type Prefix = number +type PrefixCoverage = Readonly<{ prefix: Prefix }> +type RowKey = string | number + +function createPrefixRegistry(): CoverageRegistry< + Prefix, + PrefixCoverage, + RowKey +> { + return new CoverageRegistry({ + coversDemand: (coverage, demand) => coverage.prefix >= demand, + coversCoverage: (coverage, candidate) => + coverage.prefix >= candidate.prefix, + snapshotCoverage: (coverage) => Object.freeze({ ...coverage }), + projectAppliedCoverage: ({ outcome, rows }) => { + const prefix = outcome.demand.limit + if (outcome.collectionId !== `prefixes` || prefix === undefined) { + return undefined + } + if (rows.size < prefix && outcome.extent !== `exhausted`) { + return undefined + } + return { prefix } + }, + }) +} + +function createPrefixOutcome( + generation: number, + prefix: Prefix, + extent: AppliedLoadSubsetOutcome['extent'] = `exhausted`, + collectionId = `prefixes`, + sourceId = `items`, + rows: ReadonlyArray = [], +): AppliedLoadSubsetOutcome { + return { + collectionId, + sourceId, + demand: { limit: prefix }, + generation, + extent, + appliedRowKeys: rows, + } +} + +function addPrefixAcquisition( + registry: CoverageRegistry, + options: { + generation: number + leases: ReadonlyArray> + release: () => void + prefix: Prefix + sourceId?: string + }, +) { + return registry.addAcquisition({ + generation: options.generation, + leases: options.leases, + release: options.release, + scope: { + collectionId: `prefixes`, + sourceId: options.sourceId ?? `items`, + demand: { limit: options.prefix }, + }, + }) +} + +function publishPrefix( + registry: CoverageRegistry, + acquisition: ReturnType, + generation: number, + coverage: Prefix, + rows: ReadonlyArray = [], +): void { + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome( + generation, + coverage, + `exhausted`, + `prefixes`, + `items`, + rows, + ), + ), + ).toMatchObject({ accepted: true, published: true }) +} + +type ClaimChurn = `defer` | `outcome-free` | `release-first` | `settle-first` + +function runClaimChurn(history: ReadonlyArray, rowCount: number) { + const registry = createLoadSubsetCoverageRegistry() + const release = vi.fn() + const demand = { limit: rowCount } + const rows = Array.from({ length: rowCount }, (_, index) => `row-${index}`) + const physical = registry.addLease(demand) + expect(Reflect.ownKeys(physical)).toEqual([]) + const acquisition = registry.addAcquisition({ + generation: 1, + scope: { collectionId: `items`, sourceId: `source`, demand }, + leases: [physical], + release, + }) + const initialOutcome = { + collectionId: `items`, + sourceId: `source`, + demand, + generation: 1, + extent: `exhausted` as const, + appliedRowKeys: rows, + } + expect( + registry.publishOutcome(acquisition, physical, initialOutcome), + ).toEqual({ accepted: true, published: true, rowsToRemove: [] }) + + const pending: Array<{ + lease: ReturnType + outcome: AppliedLoadSubsetOutcome + }> = [] + const baselineRowKeySlots = rowCount * 2 + const expectBounded = () => { + expect(registry.resourceCounts()).toEqual({ + liveLeases: 1, + acquisitions: 1, + claims: 1 + pending.length, + unsettledClaims: pending.length, + retainedDemands: 1, + retainedOutcomes: 0, + retainedRowKeySlots: baselineRowKeySlots, + }) + expect(registry.appliedAcquisitionEvidence()).toHaveLength(1) + } + + history.forEach((mode, index) => { + const generation = index + 2 + const lease = registry.addLease(demand) + const outcome = { ...initialOutcome, generation } + registry.attachLease(lease, acquisition, { + generation, + scope: { collectionId: `items`, sourceId: `source`, demand }, + coverage: { + collectionId: `items`, + sourceId: `source`, + demand, + extent: `exhausted`, + rowKeys: rows, + }, + retainedOutcome: outcome, + settlementPending: true, + }) + + if (mode === `settle-first`) { + expect( + registry.publishOutcome(acquisition, lease, outcome), + ).toMatchObject({ accepted: true, published: true }) + expect(registry.releaseLease(lease)).toEqual({ rowsToRemove: [] }) + expectBounded() + return + } + + expect(registry.releaseLease(lease)).toEqual({ rowsToRemove: [] }) + pending.push({ lease, outcome }) + expectBounded() + if (mode === `release-first`) { + expect( + registry.publishOutcome(acquisition, lease, outcome), + ).toMatchObject({ accepted: true, published: true }) + pending.pop() + expectBounded() + } else if (mode === `outcome-free`) { + registry.settleLease(acquisition, lease) + pending.pop() + expectBounded() + } + }) + + while (pending.length > 0) { + const next = pending.pop()! + expect( + registry.publishOutcome(acquisition, next.lease, next.outcome), + ).toMatchObject({ accepted: true, published: true }) + expectBounded() + } + + expect(registry.releaseLease(physical)).toEqual({ rowsToRemove: rows }) + expect(release).toHaveBeenCalledOnce() + expect(registry.resourceCounts()).toEqual({ + liveLeases: 0, + acquisitions: 0, + claims: 0, + unsettledClaims: 0, + retainedDemands: 0, + retainedOutcomes: 0, + retainedRowKeySlots: 0, + }) +} + +type ModelLease = { + active: boolean + prefix: Prefix + acquisitions: Set +} + +type ModelClaim = { + generation: number + settlementPending: boolean + prefix: Prefix + sourceId: string + coverage: Prefix | undefined + retainedOutcome: AppliedLoadSubsetOutcome | undefined + sequence: number +} + +type ModelAcquisition = { + active: boolean + applied: boolean + evidenceEpoch: number + generation: number + prefix: Prefix + sourceId: string + leases: Set + claims: Map + rows: Set + releaseCalls: number + releaseFailuresRemaining: number + releaseSettled: boolean +} + +type RegistryModel = { + leases: Array + acquisitions: Array + currentByScope: Map + claimSequence: number + evidenceEpoch: number +} + +type ReleaseProbe = { + calls: number + failuresRemaining: number + error: Error + release: () => void +} + +type RegistryReal = { + registry: CoverageRegistry + leases: Array< + ReturnType[`addLease`]> + > + acquisitions: Array< + ReturnType< + CoverageRegistry[`addAcquisition`] + > + > + releases: Array +} + +const modelRows = [`a`, `ä`, 2, 10] as const satisfies ReadonlyArray + +function activeIndex( + records: ReadonlyArray, + rawIndex: number, +): number | undefined { + const active = records.flatMap((record, index) => + record.active ? [index] : [], + ) + return active.length === 0 ? undefined : active[rawIndex % active.length] +} + +function activeAcquisitionWithLeaseIndex( + model: RegistryModel, + rawIndex: number, +): number | undefined { + const candidates = model.acquisitions.flatMap((acquisition, index) => + acquisition.active && + [...acquisition.leases].some((lease) => model.leases[lease]?.active) + ? [index] + : [], + ) + return candidates.length === 0 + ? undefined + : candidates[rawIndex % candidates.length] +} + +function scopeKey(sourceId: string, prefix: Prefix): string { + return `${sourceId}:${prefix}` +} + +function addModelAcquisition( + model: RegistryModel, + options: { + generation: number + prefix: Prefix + sourceId: string + leaseIndex: number + failFirstRelease: boolean + }, +): number { + const index = model.acquisitions.length + model.acquisitions.push({ + active: true, + applied: false, + evidenceEpoch: model.evidenceEpoch, + generation: options.generation, + prefix: options.prefix, + sourceId: options.sourceId, + leases: new Set([options.leaseIndex]), + claims: new Map([ + [ + options.leaseIndex, + { + generation: options.generation, + settlementPending: true, + prefix: options.prefix, + sourceId: options.sourceId, + coverage: undefined, + retainedOutcome: undefined, + sequence: model.claimSequence++, + }, + ], + ]), + rows: new Set(), + releaseCalls: 0, + releaseFailuresRemaining: options.failFirstRelease ? 1 : 0, + releaseSettled: false, + }) + model.leases[options.leaseIndex]!.acquisitions.add(index) + return index +} + +function canPublishModelAcquisition( + model: RegistryModel, + acquisitionIndex: number, + leaseIndex: number, +): boolean { + const acquisition = model.acquisitions[acquisitionIndex]! + const claim = acquisition.claims.get(leaseIndex) + if (!claim) return false + if (!acquisition.active || acquisition.releaseSettled) return false + if (acquisition.evidenceEpoch !== model.evidenceEpoch) return false + const currentIndex = model.currentByScope.get( + scopeKey(claim.sourceId, claim.prefix), + ) + if ( + currentIndex === undefined || + currentIndex.acquisition === acquisitionIndex + ) { + return true + } + const currentClaim = model.acquisitions[currentIndex.acquisition]!.claims.get( + currentIndex.lease, + )! + return claim.generation > currentClaim.generation +} + +function restoreModelCurrent(model: RegistryModel, scope: string): void { + const candidate = model.acquisitions + .flatMap((acquisition, acquisitionIndex) => + !acquisition.active || + acquisition.releaseSettled || + acquisition.evidenceEpoch !== model.evidenceEpoch + ? [] + : Array.from(acquisition.claims.entries()).map(([lease, claim]) => ({ + acquisition, + acquisitionIndex, + lease, + claim, + })), + ) + .filter( + ({ acquisition, lease, claim }) => + acquisition.leases.has(lease) && + claim.coverage !== undefined && + scopeKey(claim.sourceId, claim.prefix) === scope, + ) + .sort((left, right) => + left.claim.generation === right.claim.generation + ? right.claim.sequence - left.claim.sequence + : right.claim.generation - left.claim.generation, + )[0] + + if (candidate) { + model.currentByScope.set(scope, { + acquisition: candidate.acquisitionIndex, + lease: candidate.lease, + }) + } else model.currentByScope.delete(scope) +} + +function replaceModelRows( + model: RegistryModel, + acquisitionIndex: number, + nextRows: ReadonlySet, +): Array { + const acquisition = model.acquisitions[acquisitionIndex]! + const rowsToRemove = [...acquisition.rows].filter( + (row) => + !nextRows.has(row) && + model.acquisitions.filter( + (candidate) => candidate.active && candidate.rows.has(row), + ).length === 1, + ) + acquisition.rows = new Set(nextRows) + return rowsToRemove.sort(compareKeys) +} + +function retireModelAcquisition( + model: RegistryModel, + acquisitionIndex: number, +): Array { + const acquisition = model.acquisitions[acquisitionIndex]! + if (!acquisition.active) return [] + const rowsToRemove = replaceModelRows(model, acquisitionIndex, new Set()) + acquisition.active = false + acquisition.applied = false + const affectedScopes = new Set() + for (const [lease, claim] of acquisition.claims) { + const scope = scopeKey(claim.sourceId, claim.prefix) + const current = model.currentByScope.get(scope) + if (current?.acquisition === acquisitionIndex && current.lease === lease) { + affectedScopes.add(scope) + } + claim.coverage = undefined + claim.retainedOutcome = undefined + } + for (const leaseIndex of acquisition.leases) { + model.leases[leaseIndex]?.acquisitions.delete(acquisitionIndex) + } + for (const scope of affectedScopes) restoreModelCurrent(model, scope) + return rowsToRemove +} + +function settleModelRelease(acquisition: ModelAcquisition): boolean { + if (acquisition.releaseSettled) return true + acquisition.releaseCalls++ + if (acquisition.releaseFailuresRemaining > 0) { + acquisition.releaseFailuresRemaining-- + return false + } + acquisition.releaseSettled = true + return true +} + +function createReleaseProbe(failFirst: boolean): ReleaseProbe { + const probe: ReleaseProbe = { + calls: 0, + failuresRemaining: failFirst ? 1 : 0, + error: new Error(`release failed`), + release: () => { + probe.calls++ + if (probe.failuresRemaining > 0) { + probe.failuresRemaining-- + throw probe.error + } + }, + } + return probe +} + +function expectReleaseFailure(release: () => unknown): void { + let threw = false + try { + release() + } catch { + threw = true + } + expect(threw).toBe(true) +} + +function assertRegistryModel(model: RegistryModel, real: RegistryReal): void { + const activeCoverage = model.acquisitions.flatMap( + (acquisition, acquisitionIndex) => + acquisition.active + ? Array.from(acquisition.claims.entries()).flatMap(([lease, claim]) => { + const current = model.currentByScope.get( + scopeKey(claim.sourceId, claim.prefix), + ) + return acquisition.leases.has(lease) && + claim.coverage !== undefined && + current?.acquisition === acquisitionIndex && + current.lease === lease + ? [claim.coverage] + : [] + }) + : [], + ) + expect(real.registry.coverageAntichain()).toEqual( + activeCoverage.length === 0 + ? [] + : [{ prefix: Math.max(...activeCoverage) }], + ) + const retainedOutcomes = model.acquisitions.flatMap((acquisition) => + acquisition.active + ? Array.from(acquisition.claims.entries()).flatMap(([lease, claim]) => + acquisition.leases.has(lease) && claim.retainedOutcome !== undefined + ? [claim.retainedOutcome] + : [], + ) + : [], + ) + expect(real.registry.retainedOutcomeEvidence()).toEqual(retainedOutcomes) + const appliedEvidence = model.acquisitions.flatMap( + (acquisition, acquisitionIndex) => + acquisition.active && + acquisition.applied && + acquisition.evidenceEpoch === model.evidenceEpoch && + !acquisition.releaseSettled && + acquisition.leases.size > 0 + ? Array.from(acquisition.claims.entries()).flatMap(([lease, claim]) => + acquisition.leases.has(lease) + ? [ + { + acquisition: real.acquisitions[acquisitionIndex], + rowKeys: [...acquisition.rows], + outcome: createPrefixOutcome( + claim.generation, + claim.prefix, + `unknown`, + `prefixes`, + claim.sourceId, + [...acquisition.rows], + ), + }, + ] + : [], + ) + : [], + ) + expect(real.registry.appliedAcquisitionEvidence()).toEqual(appliedEvidence) + const activeAcquisitions = model.acquisitions.filter(({ active }) => active) + const expectedResourceCounts = { + liveLeases: model.leases.filter(({ active }) => active).length, + acquisitions: activeAcquisitions.length, + claims: activeAcquisitions.reduce( + (count, acquisition) => count + acquisition.claims.size, + 0, + ), + unsettledClaims: activeAcquisitions.reduce( + (count, acquisition) => + count + + Array.from(acquisition.claims.values()).filter( + ({ settlementPending }) => settlementPending, + ).length, + 0, + ), + retainedDemands: activeAcquisitions.reduce( + (count, acquisition) => count + acquisition.leases.size, + 0, + ), + retainedOutcomes: retainedOutcomes.length, + retainedRowKeySlots: activeAcquisitions.reduce( + (count, acquisition) => + count + + acquisition.rows.size + + Array.from(acquisition.claims.values()).reduce( + (claimCount, claim) => + claimCount + (claim.retainedOutcome?.appliedRowKeys?.length ?? 0), + 0, + ), + 0, + ), + } + const resourceCounts = real.registry.resourceCounts() + expect(resourceCounts).toEqual(expectedResourceCounts) + expect(resourceCounts.claims).toBeLessThanOrEqual( + resourceCounts.liveLeases + resourceCounts.unsettledClaims, + ) + expect(resourceCounts.retainedDemands).toBeLessThanOrEqual( + resourceCounts.liveLeases + resourceCounts.unsettledClaims, + ) + expect(resourceCounts.retainedOutcomes).toBeLessThanOrEqual( + resourceCounts.liveLeases + resourceCounts.unsettledClaims, + ) + for (const row of modelRows) { + expect(real.registry.rowOwnerCount(row)).toBe( + model.acquisitions.filter( + (acquisition) => acquisition.active && acquisition.rows.has(row), + ).length, + ) + } + model.acquisitions.forEach((acquisition, index) => { + expect(real.releases[index]?.calls).toBe(acquisition.releaseCalls) + }) +} + +class AddLeaseCommand implements Command { + constructor(private readonly prefix: Prefix) {} + + check = () => true + + run(model: RegistryModel, real: RegistryReal): void { + model.leases.push({ + active: true, + prefix: this.prefix, + acquisitions: new Set(), + }) + real.leases.push(real.registry.addLease(this.prefix)) + assertRegistryModel(model, real) + } + + toString = () => `addLease(${this.prefix})` +} + +class AddAcquisitionCommand implements Command { + constructor( + private readonly rawLease: number, + private readonly generation: number, + private readonly sourceSlot: number, + private readonly failFirstRelease: boolean, + ) {} + + check(model: Readonly): boolean { + return model.leases.some(({ active }) => active) + } + + run(model: RegistryModel, real: RegistryReal): void { + const leaseIndex = activeIndex(model.leases, this.rawLease)! + const prefix = model.leases[leaseIndex]!.prefix + const sourceId = `source-${this.sourceSlot}` + const release = createReleaseProbe(this.failFirstRelease) + addModelAcquisition(model, { + generation: this.generation, + prefix, + sourceId, + leaseIndex, + failFirstRelease: this.failFirstRelease, + }) + real.acquisitions.push( + addPrefixAcquisition(real.registry, { + generation: this.generation, + leases: [real.leases[leaseIndex]!], + release: () => release.release(), + prefix, + sourceId, + }), + ) + real.releases.push(release) + assertRegistryModel(model, real) + } + + toString = () => + `addAcquisition(lease=${this.rawLease}, generation=${this.generation}, source=${this.sourceSlot}, failFirst=${this.failFirstRelease})` +} + +class AttachLeaseCommand implements Command { + constructor( + private readonly rawLease: number, + private readonly rawAcquisition: number, + private readonly retainedExtent: + | AppliedLoadSubsetOutcome[`extent`] + | undefined, + ) {} + + check(model: Readonly): boolean { + return ( + model.leases.some(({ active }) => active) && + model.acquisitions.some(({ active }) => active) + ) + } + + run(model: RegistryModel, real: RegistryReal): void { + const leaseIndex = activeIndex(model.leases, this.rawLease)! + const acquisitionIndex = activeIndex( + model.acquisitions, + this.rawAcquisition, + )! + const acquisition = model.acquisitions[acquisitionIndex]! + if (acquisition.releaseSettled) { + expect(() => + real.registry.attachLease( + real.leases[leaseIndex]!, + real.acquisitions[acquisitionIndex]!, + ), + ).toThrow(`Cannot attach to a released acquisition`) + } else if (acquisition.leases.has(leaseIndex)) { + assertRegistryModel(model, real) + return + } else if (acquisition.evidenceEpoch !== model.evidenceEpoch) { + expect(() => + real.registry.attachLease( + real.leases[leaseIndex]!, + real.acquisitions[acquisitionIndex]!, + ), + ).toThrow(`Cannot attach to an invalidated acquisition`) + } else { + model.leases[leaseIndex]!.acquisitions.add(acquisitionIndex) + acquisition.leases.add(leaseIndex) + const prefix = model.leases[leaseIndex]!.prefix + const retainedOutcome = + this.retainedExtent === undefined + ? undefined + : createPrefixOutcome( + acquisition.generation, + prefix, + this.retainedExtent, + `prefixes`, + acquisition.sourceId, + [...acquisition.rows], + ) + acquisition.claims.set(leaseIndex, { + generation: acquisition.generation, + settlementPending: false, + prefix, + sourceId: acquisition.sourceId, + coverage: undefined, + retainedOutcome, + sequence: model.claimSequence++, + }) + real.registry.attachLease( + real.leases[leaseIndex]!, + real.acquisitions[acquisitionIndex]!, + { + generation: acquisition.generation, + scope: { + collectionId: `prefixes`, + sourceId: acquisition.sourceId, + demand: { limit: prefix }, + }, + ...(retainedOutcome === undefined ? {} : { retainedOutcome }), + }, + ) + } + assertRegistryModel(model, real) + } + + toString = () => + `attachLease(lease=${this.rawLease}, acquisition=${this.rawAcquisition}, retainedExtent=${this.retainedExtent})` +} + +class RetryAcquisitionCommand implements Command { + constructor(private readonly rawAcquisition: number) {} + + check(model: Readonly): boolean { + return activeAcquisitionWithLeaseIndex(model, 0) !== undefined + } + + run(model: RegistryModel, real: RegistryReal): void { + const oldIndex = activeAcquisitionWithLeaseIndex( + model, + this.rawAcquisition, + )! + const old = model.acquisitions[oldIndex]! + const leaseIndex = [...old.leases].find( + (index) => model.leases[index]?.active, + )! + const claim = old.claims.get(leaseIndex)! + const release = createReleaseProbe(false) + addModelAcquisition(model, { + generation: claim.generation + 1, + prefix: claim.prefix, + sourceId: claim.sourceId, + leaseIndex, + failFirstRelease: false, + }) + real.acquisitions.push( + addPrefixAcquisition(real.registry, { + generation: claim.generation + 1, + leases: [real.leases[leaseIndex]!], + release: () => release.release(), + prefix: claim.prefix, + sourceId: claim.sourceId, + }), + ) + real.releases.push(release) + assertRegistryModel(model, real) + } + + toString = () => `retry(acquisition=${this.rawAcquisition})` +} + +class ReplaceRowsCommand implements Command { + constructor( + private readonly rawAcquisition: number, + private readonly rows: ReadonlyArray, + ) {} + + check(model: Readonly): boolean { + return model.acquisitions.some(({ active }) => active) + } + + run(model: RegistryModel, real: RegistryReal): void { + const acquisitionIndex = activeIndex( + model.acquisitions, + this.rawAcquisition, + )! + const acquisition = model.acquisitions[acquisitionIndex]! + const leaseIndex = Array.from(acquisition.claims.keys()).find((candidate) => + acquisition.leases.has(candidate), + ) + const accepted = + leaseIndex !== undefined && + canPublishModelAcquisition(model, acquisitionIndex, leaseIndex) + const rowsToRemove = accepted + ? replaceModelRows(model, acquisitionIndex, new Set(this.rows)) + : [] + if (accepted) { + acquisition.applied = false + const affectedScopes = new Set() + for (const [claimLease, existingClaim] of acquisition.claims) { + existingClaim.coverage = undefined + existingClaim.retainedOutcome = undefined + const scope = scopeKey(existingClaim.sourceId, existingClaim.prefix) + const current = model.currentByScope.get(scope) + if ( + current?.acquisition === acquisitionIndex && + current.lease === claimLease + ) { + affectedScopes.add(scope) + } + } + for (const scope of affectedScopes) restoreModelCurrent(model, scope) + } + expect( + real.registry.replaceRows( + real.acquisitions[acquisitionIndex]!, + this.rows, + ), + ).toEqual({ accepted, rowsToRemove }) + assertRegistryModel(model, real) + } + + toString = () => + `replaceRows(acquisition=${this.rawAcquisition}, rows=${this.rows.join(``)})` +} + +class PublishCommand implements Command { + constructor( + private readonly rawAcquisition: number, + private readonly rows: ReadonlyArray, + private readonly generationDelta: number, + private readonly exactScope: boolean, + private readonly extent: AppliedLoadSubsetOutcome[`extent`], + ) {} + + check(model: Readonly): boolean { + return model.acquisitions.some(({ active }) => active) + } + + run(model: RegistryModel, real: RegistryReal): void { + const acquisitionIndex = activeIndex( + model.acquisitions, + this.rawAcquisition, + )! + const acquisition = model.acquisitions[acquisitionIndex]! + const claimEntry = acquisition.claims.entries().next().value + const leaseIndex = claimEntry?.[0] + const claim = claimEntry?.[1] + const outcome = createPrefixOutcome( + (claim?.generation ?? acquisition.generation) + this.generationDelta, + claim?.prefix ?? acquisition.prefix, + this.extent, + this.exactScope ? `prefixes` : `other`, + claim?.sourceId ?? acquisition.sourceId, + this.rows, + ) + const matchesClaim = + leaseIndex !== undefined && this.generationDelta === 0 && this.exactScope + const receivesOutcome = matchesClaim && !acquisition.releaseSettled + const accepted = + receivesOutcome && + canPublishModelAcquisition(model, acquisitionIndex, leaseIndex) + const rowsToRemove = receivesOutcome + ? replaceModelRows(model, acquisitionIndex, new Set(this.rows)) + : [] + const published = + accepted && + this.extent !== `unknown` && + (this.rows.length >= claim!.prefix || this.extent === `exhausted`) + if (receivesOutcome) { + acquisition.applied = true + claim!.settlementPending = false + for (const [peerLease, peer] of acquisition.claims) { + if (acquisition.leases.has(peerLease)) { + peer.retainedOutcome = undefined + } + } + const scope = scopeKey(claim!.sourceId, claim!.prefix) + if (!accepted) { + for (const [peerLease, peer] of acquisition.claims) { + if ( + acquisition.leases.has(peerLease) && + scopeKey(peer.sourceId, peer.prefix) === scope + ) { + peer.coverage = undefined + } + } + } else if (published) { + if (acquisition.leases.has(leaseIndex)) { + claim!.coverage = claim!.prefix + } + for (const [peerLease, peer] of acquisition.claims) { + if ( + acquisition.leases.has(peerLease) && + scopeKey(peer.sourceId, peer.prefix) === scope + ) { + peer.coverage = claim!.prefix + } + } + restoreModelCurrent(model, scope) + } else { + claim!.coverage = undefined + const current = model.currentByScope.get(scope) + if ( + current?.acquisition === acquisitionIndex && + current.lease === leaseIndex + ) { + restoreModelCurrent(model, scope) + } + } + if (!acquisition.leases.has(leaseIndex)) { + acquisition.claims.delete(leaseIndex) + } + } + expect( + real.registry.publishOutcome( + real.acquisitions[acquisitionIndex]!, + real.leases[leaseIndex!]!, + outcome, + ), + ).toEqual({ accepted, published, rowsToRemove }) + assertRegistryModel(model, real) + } + + toString = () => + `publish(acquisition=${this.rawAcquisition}, rows=${this.rows.join(``)}, generationDelta=${this.generationDelta}, exact=${this.exactScope}, extent=${this.extent})` +} + +class InvalidateEvidenceCommand implements Command< + RegistryModel, + RegistryReal +> { + check = () => true + + run(model: RegistryModel, real: RegistryReal): void { + model.evidenceEpoch++ + model.currentByScope.clear() + for (const acquisition of model.acquisitions) { + if (!acquisition.active) continue + acquisition.applied = false + acquisition.rows.clear() + for (const claim of acquisition.claims.values()) { + claim.coverage = undefined + claim.retainedOutcome = undefined + } + } + real.registry.invalidateAppliedEvidence() + assertRegistryModel(model, real) + } + + toString = () => `invalidateAppliedEvidence()` +} + +class ReleaseAcquisitionCommand implements Command< + RegistryModel, + RegistryReal +> { + constructor(private readonly rawAcquisition: number) {} + + check(model: Readonly): boolean { + return model.acquisitions.some(({ active }) => active) + } + + run(model: RegistryModel, real: RegistryReal): void { + const acquisitionIndex = activeIndex( + model.acquisitions, + this.rawAcquisition, + )! + const acquisition = model.acquisitions[acquisitionIndex]! + if (!settleModelRelease(acquisition)) { + expectReleaseFailure(() => + real.registry.releaseAcquisition(real.acquisitions[acquisitionIndex]!), + ) + } else { + const rowsToRemove = retireModelAcquisition(model, acquisitionIndex) + expect( + real.registry.releaseAcquisition(real.acquisitions[acquisitionIndex]!), + ).toEqual({ rowsToRemove }) + } + assertRegistryModel(model, real) + } + + toString = () => `releaseAcquisition(${this.rawAcquisition})` +} + +class ReleaseLeaseCommand implements Command { + constructor(private readonly rawLease: number) {} + + check(model: Readonly): boolean { + return model.leases.some(({ active }) => active) + } + + run(model: RegistryModel, real: RegistryReal): void { + const leaseIndex = activeIndex(model.leases, this.rawLease)! + const lease = model.leases[leaseIndex]! + const finalAcquisitions = [...lease.acquisitions].filter((index) => { + const acquisition = model.acquisitions[index]! + return acquisition.active && acquisition.leases.size === 1 + }) + const releaseFailed = finalAcquisitions + .map((index) => settleModelRelease(model.acquisitions[index]!)) + .some((settled) => !settled) + if (releaseFailed) { + expectReleaseFailure(() => + real.registry.releaseLease(real.leases[leaseIndex]!), + ) + assertRegistryModel(model, real) + return + } + + const rowsToRemove = new Set() + for (const acquisitionIndex of [...lease.acquisitions]) { + const acquisition = model.acquisitions[acquisitionIndex]! + const claim = acquisition.claims.get(leaseIndex) + acquisition.leases.delete(leaseIndex) + if (claim) { + const scope = scopeKey(claim.sourceId, claim.prefix) + const current = model.currentByScope.get(scope) + if ( + current?.acquisition === acquisitionIndex && + current.lease === leaseIndex + ) { + restoreModelCurrent(model, scope) + } + claim.coverage = undefined + claim.retainedOutcome = undefined + if (!claim.settlementPending) { + acquisition.claims.delete(leaseIndex) + } + } + if (acquisition.leases.size === 0) { + retireModelAcquisition(model, acquisitionIndex).forEach((row) => + rowsToRemove.add(row), + ) + } + } + lease.active = false + lease.acquisitions.clear() + expect(real.registry.releaseLease(real.leases[leaseIndex]!)).toEqual({ + rowsToRemove: [...rowsToRemove].sort(compareKeys), + }) + assertRegistryModel(model, real) + } + + toString = () => `releaseLease(${this.rawLease})` +} + +class DisposeCommand implements Command { + check = () => true + + run(model: RegistryModel, real: RegistryReal): void { + const releaseFailed = model.acquisitions + .filter(({ active }) => active) + .map(settleModelRelease) + .some((settled) => !settled) + if (releaseFailed) { + expectReleaseFailure(() => real.registry.dispose()) + assertRegistryModel(model, real) + return + } + + const rowsToRemove = new Set() + model.acquisitions.forEach((acquisition, index) => { + if (!acquisition.active) return + retireModelAcquisition(model, index).forEach((row) => + rowsToRemove.add(row), + ) + }) + model.leases.forEach((lease) => { + lease.active = false + lease.acquisitions.clear() + }) + expect(real.registry.dispose()).toEqual({ + rowsToRemove: [...rowsToRemove].sort(compareKeys), + }) + assertRegistryModel(model, real) + } + + toString = () => `dispose()` +} + +describe(`coverage registry oracle`, () => { + it(`fences old evidence while retaining its physical release obligation`, () => { + const registry = createPrefixRegistry() + const oldRelease = vi.fn() + const oldLease = registry.addLease(1) + const oldAcquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [oldLease], + release: oldRelease, + prefix: 1, + }) + publishPrefix(registry, oldAcquisition, 1, 1, [`a`]) + + registry.invalidateAppliedEvidence() + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.retainedOutcomeEvidence()).toEqual([]) + expect(registry.appliedAcquisitionEvidence()).toEqual([]) + expect(registry.rowOwnerCount(`a`)).toBe(0) + expect(registry.isAcquisitionAttachable(oldAcquisition)).toBe(false) + + const lateLease = registry.addLease(1) + expect(() => registry.attachLease(lateLease, oldAcquisition)).toThrow( + `Cannot attach to an invalidated acquisition`, + ) + registry.releaseLease(lateLease) + + expect( + registry.publishOutcome( + oldAcquisition, + createPrefixOutcome(1, 1, `exhausted`, `prefixes`, `items`, [`old`]), + ), + ).toEqual({ accepted: false, published: false, rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.rowOwnerCount(`old`)).toBe(1) + + const freshRelease = vi.fn() + const freshLease = registry.addLease(1) + const freshAcquisition = addPrefixAcquisition(registry, { + generation: 2, + leases: [freshLease], + release: freshRelease, + prefix: 1, + }) + publishPrefix(registry, freshAcquisition, 2, 1, [`fresh`]) + expect(registry.coverageAntichain()).toEqual([{ prefix: 1 }]) + + expect(registry.releaseLease(oldLease)).toEqual({ + rowsToRemove: [`old`], + }) + expect(oldRelease).toHaveBeenCalledOnce() + expect(registry.releaseLease(freshLease)).toEqual({ + rowsToRemove: [`fresh`], + }) + expect(freshRelease).toHaveBeenCalledOnce() + }) + + it(`keeps caller-relative claims on one physical acquisition`, () => { + const registry = createPrefixRegistry() + const release = vi.fn() + const first = registry.addLease(20) + const second = registry.addLease(10) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [first], + release, + prefix: 20, + }) + + publishPrefix(registry, acquisition, 1, 20, [`a`, `b`]) + registry.attachLease(second, acquisition, { + generation: 2, + scope: { + collectionId: `prefixes`, + sourceId: `items`, + demand: { limit: 10 }, + }, + }) + expect( + registry.publishOutcome( + acquisition, + second, + createPrefixOutcome(2, 10, `exhausted`, `prefixes`, `items`, [ + `a`, + `b`, + ]), + ), + ).toMatchObject({ accepted: true, published: true }) + + expect(registry.releaseLease(first)).toEqual({ rowsToRemove: [] }) + expect(release).not.toHaveBeenCalled() + expect(registry.covers(10)).toBe(true) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + expect(registry.releaseLease(second)).toEqual({ + rowsToRemove: [`a`, `b`], + }) + expect(release).toHaveBeenCalledOnce() + expect(registry.covers(10)).toBe(false) + expect(registry.rowOwnerCount(`a`)).toBe(0) + + expect(registry.releaseLease(second)).toEqual({ rowsToRemove: [] }) + registry.dispose() + expect(release).toHaveBeenCalledOnce() + }) + + it(`retains a released claim as dormant physical publication identity`, () => { + const registry = createPrefixRegistry() + const release = vi.fn() + const physical = registry.addLease(20) + const peer = registry.addLease(10) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [physical], + release, + prefix: 20, + }) + registry.attachLease(peer, acquisition, { + generation: 1, + scope: { + collectionId: `prefixes`, + sourceId: `items`, + demand: { limit: 10 }, + }, + }) + + expect(registry.releaseLease(physical)).toEqual({ rowsToRemove: [] }) + expect( + registry.publishOutcome( + acquisition, + physical, + createPrefixOutcome(1, 20, `exhausted`, `prefixes`, `items`, [ + `a`, + `b`, + ]), + ), + ).toEqual({ accepted: true, published: true, rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + expect(registry.releaseLease(peer)).toEqual({ + rowsToRemove: [`a`, `b`], + }) + expect(release).toHaveBeenCalledOnce() + }) + + it(`forgets settled claims released from a surviving acquisition`, () => { + const registry = createPrefixRegistry() + const physical = registry.addLease(1) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [physical], + release: vi.fn(), + prefix: 1, + }) + publishPrefix(registry, acquisition, 1, 1, [`a`]) + + for (let generation = 2; generation <= 9; generation++) { + const peer = registry.addLease(1) + registry.attachLease(peer, acquisition, { + generation, + scope: { + collectionId: `prefixes`, + sourceId: `items`, + demand: { limit: 1 }, + }, + }) + expect( + registry.publishOutcome( + acquisition, + peer, + createPrefixOutcome(generation, 1, `exhausted`, `prefixes`, `items`, [ + `a`, + ]), + ), + ).toMatchObject({ accepted: true, published: true }) + expect(registry.releaseLease(peer)).toEqual({ rowsToRemove: [] }) + } + + expect(registry.appliedAcquisitionEvidence()).toHaveLength(1) + }) + + it(`settles only the matching acquisition claim during a retry`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(1) + const first = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 1, + }) + addPrefixAcquisition(registry, { + generation: 2, + leases: [lease], + release: vi.fn(), + prefix: 1, + }) + + registry.settleLease(first, lease) + + expect(registry.resourceCounts().unsettledClaims).toBe(1) + expect( + registry.publishOutcome(first, lease, createPrefixOutcome(1, 1)), + ).toEqual({ accepted: true, published: true, rowsToRemove: [] }) + }) + + it(`bounds claim evidence across every short settlement history`, () => { + const modes: ReadonlyArray = [ + `settle-first`, + `release-first`, + `outcome-free`, + `defer`, + ] + for (const first of modes) { + for (const second of modes) { + for (const third of modes) { + for (const rowCount of [1, 4]) { + runClaimChurn([first, second, third], rowCount) + } + } + } + } + }) + + const claimChurnArbitrary = fc.array( + fc.constantFrom( + `settle-first`, + `release-first`, + `outcome-free`, + `defer`, + ), + { minLength: 24, maxLength: 96 }, + ) + + fcTest.prop([claimChurnArbitrary, fc.integer({ min: 1, max: 8 })], { + numRuns: 20, + seed: 1775, + })(`bounds long claim churn for a fixed seed`, runClaimChurn) + + fcTest.prop( + [claimChurnArbitrary, fc.integer({ min: 1, max: 8 })], + oraclePropertyOptions(20), + )(`bounds long claim churn for a random or replayed seed`, runClaimChurn) + + it(`restores a compacted narrower fact when the wider acquisition retires`, () => { + const registry = createPrefixRegistry() + const narrowLease = registry.addLease(20) + const wideLease = registry.addLease(100) + const narrowAcquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [narrowLease], + release: vi.fn(), + prefix: 20, + }) + const wideAcquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [wideLease], + release: vi.fn(), + prefix: 100, + }) + + publishPrefix(registry, narrowAcquisition, 1, 20) + publishPrefix(registry, wideAcquisition, 1, 100) + expect(registry.coverageAntichain()).toEqual([{ prefix: 100 }]) + + registry.releaseLease(wideLease) + expect(registry.coverageAntichain()).toEqual([{ prefix: 20 }]) + expect(registry.covers(20)).toBe(true) + expect(registry.covers(21)).toBe(false) + }) + + it(`keeps shared rows through overlapping destructive snapshots and GC`, () => { + const registry = createPrefixRegistry() + const firstLease = registry.addLease(20) + const secondLease = registry.addLease(20) + const first = addPrefixAcquisition(registry, { + generation: 1, + leases: [firstLease], + release: vi.fn(), + prefix: 20, + }) + const second = addPrefixAcquisition(registry, { + generation: 1, + leases: [secondLease], + release: vi.fn(), + prefix: 20, + sourceId: `secondary`, + }) + + expect(registry.replaceRows(first, [`shared`, `first`])).toEqual({ + accepted: true, + rowsToRemove: [], + }) + expect(registry.replaceRows(second, [`shared`, `second`])).toEqual({ + accepted: true, + rowsToRemove: [], + }) + + expect(registry.replaceRows(first, [])).toEqual({ + accepted: true, + rowsToRemove: [`first`], + }) + expect(registry.rowOwnerCount(`shared`)).toBe(1) + + expect(registry.releaseLease(secondLease)).toEqual({ + rowsToRemove: [`second`, `shared`], + }) + }) + + it(`orders released mixed keys with the shared key comparator`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(1) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 1, + }) + const rows: ReadonlyArray = [10, `ä`, 2, `z`] + // Pins both shared laws: strings precede numbers, and strings use direct + // code-point order rather than locale-sensitive order. + const canonicalOrder: ReadonlyArray = [`z`, `ä`, 2, 10] + expect([...rows].sort(compareKeys)).toEqual(canonicalOrder) + + expect(registry.replaceRows(acquisition, rows)).toEqual({ + accepted: true, + rowsToRemove: [], + }) + expect(registry.releaseLease(lease)).toEqual({ + rowsToRemove: canonicalOrder, + }) + }) + + it(`keeps the last successful generation current while a newer attempt is pending`, () => { + const registry = createPrefixRegistry() + const priorLease = registry.addLease(1) + const retryLease = registry.addLease(1) + const prior = addPrefixAcquisition(registry, { + generation: 1, + leases: [priorLease], + release: vi.fn(), + prefix: 1, + }) + publishPrefix(registry, prior, 1, 1, [`prior`]) + + const retry = addPrefixAcquisition(registry, { + generation: 2, + leases: [retryLease], + release: vi.fn(), + prefix: 1, + }) + expect(registry.coverageAntichain()).toEqual([{ prefix: 1 }]) + expect(registry.rowOwnerCount(`prior`)).toBe(1) + + registry.releaseAcquisition(retry) + expect(registry.coverageAntichain()).toEqual([{ prefix: 1 }]) + expect(registry.rowOwnerCount(`prior`)).toBe(1) + }) + + it(`keeps rows owned by every active acquisition when stale coverage cannot publish`, () => { + const registry = createPrefixRegistry() + const olderLease = registry.addLease(1) + const newerLease = registry.addLease(1) + const older = addPrefixAcquisition(registry, { + generation: 1, + leases: [olderLease], + release: vi.fn(), + prefix: 1, + }) + const newer = addPrefixAcquisition(registry, { + generation: 2, + leases: [newerLease], + release: vi.fn(), + prefix: 1, + }) + + expect( + registry.publishOutcome( + newer, + createPrefixOutcome(2, 1, `exhausted`, `prefixes`, `items`, [`a`]), + ), + ).toEqual({ accepted: true, published: true, rowsToRemove: [] }) + expect( + registry.publishOutcome( + older, + createPrefixOutcome(1, 1, `exhausted`, `prefixes`, `items`, [`a`]), + ), + ).toEqual({ accepted: false, published: false, rowsToRemove: [] }) + + expect(registry.rowOwnerCount(`a`)).toBe(2) + expect(registry.releaseLease(newerLease)).toEqual({ rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + expect(registry.releaseLease(olderLease)).toEqual({ + rowsToRemove: [`a`], + }) + }) + + it(`keeps the same row safe when acquisition generations settle in either order`, () => { + for (const newerSettlesFirst of [false, true]) { + const registry = createPrefixRegistry() + const olderLease = registry.addLease(1) + const newerLease = registry.addLease(1) + const older = addPrefixAcquisition(registry, { + generation: 1, + leases: [olderLease], + release: vi.fn(), + prefix: 1, + }) + const newer = addPrefixAcquisition(registry, { + generation: 2, + leases: [newerLease], + release: vi.fn(), + prefix: 1, + }) + const settle = (acquisition: typeof older, generation: number) => + registry.publishOutcome( + acquisition, + createPrefixOutcome(generation, 1, `exhausted`, `prefixes`, `items`, [ + `a`, + ]), + ) + + if (newerSettlesFirst) { + settle(newer, 2) + settle(older, 1) + } else { + settle(older, 1) + settle(newer, 2) + } + + expect(registry.rowOwnerCount(`a`)).toBe(2) + expect(registry.releaseLease(newerLease)).toEqual({ rowsToRemove: [] }) + expect(registry.rowOwnerCount(`a`)).toBe(1) + expect(registry.releaseLease(olderLease)).toEqual({ + rowsToRemove: [`a`], + }) + } + }) + + it(`records unknown-extent row ownership without publishing coverage`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(1) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 1, + }) + + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome(1, 1, `unknown`, `prefixes`, `items`, [`owned`]), + ), + ).toEqual({ accepted: true, published: false, rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.rowOwnerCount(`owned`)).toBe(1) + expect(registry.releaseLease(lease)).toEqual({ + rowsToRemove: [`owned`], + }) + }) + + it(`keeps projected unknown evidence outside coverage while its lease owns the acquisition`, () => { + const registry = createPrefixRegistry() + const physical = registry.addLease(20) + const satisfied = registry.addLease(10) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [physical], + release: vi.fn(), + prefix: 20, + }) + publishPrefix(registry, acquisition, 1, 20, [`a`, `b`]) + const retainedOutcome = createPrefixOutcome( + 2, + 10, + `unknown`, + `prefixes`, + `items`, + [`a`, `b`], + ) + + registry.attachLease(satisfied, acquisition, { + generation: 2, + scope: { + collectionId: `prefixes`, + sourceId: `items`, + demand: { limit: 10 }, + }, + retainedOutcome, + }) + expect(registry.retainedOutcomeEvidence()).toEqual([retainedOutcome]) + + expect(registry.releaseLease(physical)).toEqual({ rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.covers(10)).toBe(false) + expect(registry.retainedOutcomeEvidence()).toEqual([retainedOutcome]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + expect(registry.releaseLease(satisfied)).toEqual({ + rowsToRemove: [`a`, `b`], + }) + expect(registry.retainedOutcomeEvidence()).toEqual([]) + }) + + it(`exposes exact applied unknown ownership without creating coverage`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(2) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 2, + }) + const outcome = createPrefixOutcome(1, 2, `unknown`, `prefixes`, `items`, [ + `a`, + ]) + + expect(registry.publishOutcome(acquisition, lease, outcome)).toEqual({ + accepted: true, + published: false, + rowsToRemove: [], + }) + expect(registry.appliedAcquisitionEvidence()).toEqual([ + { acquisition, outcome, rowKeys: [`a`] }, + ]) + expect(registry.coverageAntichain()).toEqual([]) + expect(registry.covers(2)).toBe(false) + }) + + it(`keeps a final lease intact when adapter release throws and retries it`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(1) + const releaseError = new Error(`release failed`) + let shouldFail = true + const release = vi.fn(() => { + if (shouldFail) throw releaseError + }) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release, + prefix: 1, + }) + publishPrefix(registry, acquisition, 1, 1, [`a`]) + + let caught: unknown + try { + registry.releaseLease(lease) + } catch (error) { + caught = error + } + expect(caught).toBe(releaseError) + expect(registry.coverageAntichain()).toEqual([{ prefix: 1 }]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + shouldFail = false + expect(registry.releaseLease(lease)).toEqual({ rowsToRemove: [`a`] }) + expect(release).toHaveBeenCalledTimes(2) + expect(registry.releaseLease(lease)).toEqual({ rowsToRemove: [] }) + expect(release).toHaveBeenCalledTimes(2) + }) + + it(`keeps an acquisition intact when its direct release throws`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(1) + const releaseError = new Error(`release failed`) + let shouldFail = true + const release = vi.fn(() => { + if (shouldFail) throw releaseError + }) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release, + prefix: 1, + }) + publishPrefix(registry, acquisition, 1, 1, [`a`]) + + expect(() => registry.releaseAcquisition(acquisition)).toThrow(releaseError) + expect(registry.coverageAntichain()).toEqual([{ prefix: 1 }]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + + shouldFail = false + expect(registry.releaseAcquisition(acquisition)).toEqual({ + rowsToRemove: [`a`], + }) + expect(release).toHaveBeenCalledTimes(2) + }) + + it(`keeps disposal atomic across successful and failed adapter releases`, () => { + const registry = createPrefixRegistry() + const firstLease = registry.addLease(1) + const secondLease = registry.addLease(2) + const firstRelease = vi.fn() + const releaseError = new Error(`release failed`) + let shouldFail = true + const secondRelease = vi.fn(() => { + if (shouldFail) throw releaseError + }) + const first = addPrefixAcquisition(registry, { + generation: 1, + leases: [firstLease], + release: firstRelease, + prefix: 1, + }) + const second = addPrefixAcquisition(registry, { + generation: 1, + leases: [secondLease], + release: secondRelease, + prefix: 2, + }) + publishPrefix(registry, first, 1, 1, [`a`]) + publishPrefix(registry, second, 1, 2, [`b`]) + + expect(() => registry.dispose()).toThrow(releaseError) + expect(registry.coverageAntichain()).toEqual([{ prefix: 2 }]) + expect(registry.rowOwnerCount(`a`)).toBe(1) + expect(registry.rowOwnerCount(`b`)).toBe(1) + + shouldFail = false + expect(registry.dispose()).toEqual({ rowsToRemove: [`a`, `b`] }) + expect(firstRelease).toHaveBeenCalledOnce() + expect(secondRelease).toHaveBeenCalledTimes(2) + }) + + it(`does not attach a new lease to an acquisition whose release settled`, () => { + const registry = createPrefixRegistry() + const settledLease = registry.addLease(1) + const failingLease = registry.addLease(2) + const settled = addPrefixAcquisition(registry, { + generation: 1, + leases: [settledLease], + release: vi.fn(), + prefix: 1, + }) + let fail = true + const failing = addPrefixAcquisition(registry, { + generation: 1, + leases: [failingLease], + release: () => { + if (fail) throw new Error(`release failed`) + }, + prefix: 2, + }) + expect(() => registry.dispose()).toThrow(`release failed`) + + const lateLease = registry.addLease(1) + expect(() => registry.attachLease(lateLease, settled)).toThrow( + `Cannot attach to a released acquisition`, + ) + expect(registry.replaceRows(settled, [`late`])).toEqual({ + accepted: false, + rowsToRemove: [], + }) + expect( + registry.publishOutcome( + settled, + createPrefixOutcome(1, 1, `exhausted`, `prefixes`, `items`, [`late`]), + ), + ).toEqual({ accepted: false, published: false, rowsToRemove: [] }) + + fail = false + registry.releaseAcquisition(failing) + registry.releaseLease(lateLease) + }) + + it(`publishes only current authoritative coverage projected from an applied outcome`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(20) + const acquisition = addPrefixAcquisition(registry, { + generation: 2, + leases: [lease], + release: vi.fn(), + prefix: 30, + }) + + expect( + registry.publishOutcome(acquisition, createPrefixOutcome(1, 20)), + ).toMatchObject({ accepted: false, published: false }) + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome(2, 20, `unknown`), + ), + ).toMatchObject({ accepted: false, published: false }) + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome(2, 20, `exhausted`, `other`), + ), + ).toMatchObject({ accepted: false, published: false }) + expect(registry.coverageAntichain()).toEqual([]) + + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome( + 2, + 30, + `continues`, + `prefixes`, + `items`, + Array.from({ length: 30 }, (_, index) => `row-${index}`), + ), + ), + ).toMatchObject({ accepted: true, published: true }) + expect(registry.coverageAntichain()).toEqual([{ prefix: 30 }]) + }) + + it(`does not derive a requested prefix from a rowless continuing result`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(30) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 30, + }) + + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome(1, 30, `continues`), + ), + ).toEqual({ accepted: true, published: false, rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([]) + + const rows = Array.from({ length: 30 }, (_, index) => `row-${index}`) + expect( + registry.publishOutcome( + acquisition, + createPrefixOutcome(1, 30, `continues`, `prefixes`, `items`, rows), + ), + ).toEqual({ accepted: true, published: true, rowsToRemove: [] }) + expect(registry.rowOwnerCount(`row-0`)).toBe(1) + expect(registry.coverageAntichain()).toEqual([{ prefix: 30 }]) + }) + + it(`rejects a late outcome from the old token after an exact-scope retry`, () => { + const registry = createPrefixRegistry() + const oldLease = registry.addLease(100) + const nextLease = registry.addLease(100) + const oldAcquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [oldLease], + release: vi.fn(), + prefix: 100, + }) + const nextAcquisition = addPrefixAcquisition(registry, { + generation: 2, + leases: [nextLease], + release: vi.fn(), + prefix: 100, + }) + + expect( + registry.publishOutcome(nextAcquisition, createPrefixOutcome(2, 100)), + ).toMatchObject({ accepted: true, published: true }) + expect( + registry.publishOutcome(oldAcquisition, createPrefixOutcome(1, 100)), + ).toEqual({ accepted: false, published: false, rowsToRemove: [] }) + expect(registry.coverageAntichain()).toEqual([{ prefix: 100 }]) + }) + + it(`returns defensive coverage snapshots`, () => { + const registry = createPrefixRegistry() + const lease = registry.addLease(20) + const acquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [lease], + release: vi.fn(), + prefix: 20, + }) + publishPrefix(registry, acquisition, 1, 20) + + const fact = registry.coverageAntichain()[0]! + try { + ;(fact as { prefix: number }).prefix = 1_000 + } catch { + // Frozen snapshots may reject mutation instead of ignoring it. + } + + expect(registry.covers(1_000)).toBe(false) + expect(registry.coverageAntichain()).toEqual([{ prefix: 20 }]) + }) + + it(`reads borrowed established evidence lazily`, () => { + const registry = createPrefixRegistry() + const firstLease = registry.addLease(1) + const firstAcquisition = addPrefixAcquisition(registry, { + generation: 1, + leases: [firstLease], + release: vi.fn(), + prefix: 1, + }) + publishPrefix(registry, firstAcquisition, 1, 1) + + const evidence = registry.borrowEvidence() + expect(evidence.next().value).toMatchObject({ + authority: `established`, + acquisition: firstAcquisition, + }) + + const secondLease = registry.addLease(2) + const secondAcquisition = addPrefixAcquisition(registry, { + generation: 2, + leases: [secondLease], + release: vi.fn(), + prefix: 2, + }) + publishPrefix(registry, secondAcquisition, 2, 2) + expect( + Array.from(evidence).filter( + (candidate) => candidate.authority === `established`, + ), + ).toEqual([expect.objectContaining({ acquisition: secondAcquisition })]) + }) + + const rowSet = fc.uniqueArray(fc.constantFrom(...modelRows), { + maxLength: modelRows.length, + }) + const commandArbitraries = [ + fc.integer({ min: 1, max: 4 }).map((prefix) => new AddLeaseCommand(prefix)), + fc + .record({ + rawLease: fc.nat(), + generation: fc.integer({ min: 1, max: 4 }), + sourceSlot: fc.integer({ min: 0, max: 1 }), + failFirstRelease: fc.boolean(), + }) + .map( + ({ rawLease, generation, sourceSlot, failFirstRelease }) => + new AddAcquisitionCommand( + rawLease, + generation, + sourceSlot, + failFirstRelease, + ), + ), + fc + .tuple( + fc.nat(), + fc.nat(), + fc.option( + fc.constantFrom( + `unknown`, + `continues`, + `exhausted`, + ), + { nil: undefined }, + ), + ) + .map( + ([lease, acquisition, retainedExtent]) => + new AttachLeaseCommand(lease, acquisition, retainedExtent), + ), + fc.nat().map((acquisition) => new RetryAcquisitionCommand(acquisition)), + fc + .tuple(fc.nat(), rowSet) + .map(([acquisition, rows]) => new ReplaceRowsCommand(acquisition, rows)), + fc + .record({ + acquisition: fc.nat(), + rows: rowSet, + generationDelta: fc.integer({ min: -1, max: 1 }), + exactScope: fc.boolean(), + extent: fc.constantFrom( + `unknown`, + `continues`, + `exhausted`, + ), + }) + .map( + ({ acquisition, rows, generationDelta, exactScope, extent }) => + new PublishCommand( + acquisition, + rows, + generationDelta, + exactScope, + extent, + ), + ), + fc.nat().map((acquisition) => new ReleaseAcquisitionCommand(acquisition)), + fc.nat().map((lease) => new ReleaseLeaseCommand(lease)), + fc.constant(new InvalidateEvidenceCommand()), + fc.constant(new DisposeCommand()), + ] + + fcTest.prop( + [ + fc.commands(commandArbitraries, { + maxCommands: 40, + }), + ], + oraclePropertyOptions(100), + )( + `matches the lease, retry, settlement, publication, ownership, and disposal state machine`, + (commands) => { + fc.modelRun( + () => ({ + model: { + leases: [], + acquisitions: [], + currentByScope: new Map(), + claimSequence: 0, + evidenceEpoch: 0, + }, + real: { + registry: createPrefixRegistry(), + leases: [], + acquisitions: [], + releases: [], + }, + }), + commands, + ) + }, + ) +}) diff --git a/packages/db/tests/query/ir-stable-identity.test.ts b/packages/db/tests/query/ir-stable-identity.test.ts index ca745d9de5..c3968b0f5a 100644 --- a/packages/db/tests/query/ir-stable-identity.test.ts +++ b/packages/db/tests/query/ir-stable-identity.test.ts @@ -50,7 +50,14 @@ import { toBooleanPredicate, } from '../../src/query/compiler/evaluators.js' import { isLoadSubsetRequestSubsumedBy } from '../../src/query/predicate-utils.js' -import { createRuntimeReferenceIdentityFactory } from '../../src/query/runtime-reference-identity.js' +import { + createRuntimeReferenceIdentityFactory, + getRuntimeReferenceIdentity, +} from '../../src/query/runtime-reference-identity.js' +import { + cloneLoadSubsetOptions, + snapshotLoadSubsetDemand, +} from '../../src/query/load-subset-options.js' import type { BasicExpression, QueryIR } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -309,6 +316,27 @@ describe(`semantic expression identity`, () => { }, ) + it(`does not initialize runtime reference identities during module evaluation`, async () => { + const getRandomValues = vi.fn((values: Uint32Array) => values) + vi.stubGlobal(`crypto`, { getRandomValues }) + vi.resetModules() + + try { + const { getRuntimeReferenceIdentity: getIdentity } = await import( + `../../src/query/runtime-reference-identity.js` + ) + + expect(getRandomValues).not.toHaveBeenCalled() + + getIdentity({}) + getIdentity({}) + + expect(getRandomValues).toHaveBeenCalledOnce() + } finally { + vi.unstubAllGlobals() + } + }) + it(`does not reuse reference identities across runtimes`, () => { const firstRuntime = createRuntimeReferenceIdentityFactory() const secondRuntime = createRuntimeReferenceIdentityFactory() @@ -316,6 +344,38 @@ describe(`semantic expression identity`, () => { expect(firstRuntime({ a: 1 })).not.toEqual(secondRuntime({ b: 2 })) }) + it(`defers runtime entropy until an identity is requested`, () => { + const getRandomValues = vi.fn((values: Uint32Array) => values) + vi.stubGlobal(`crypto`, { getRandomValues }) + try { + const runtime = createRuntimeReferenceIdentityFactory() + + expect(getRandomValues).not.toHaveBeenCalled() + + runtime({ a: 1 }) + + expect(getRandomValues).toHaveBeenCalledOnce() + } finally { + vi.unstubAllGlobals() + } + }) + + it(`keeps each symbol identity stable for the factory lifetime`, () => { + const runtime = createRuntimeReferenceIdentityFactory() + const symbol = Symbol(`same description`) + + expect(runtime(symbol)).toEqual(runtime(symbol)) + expect(runtime(Symbol(`same description`))).not.toEqual(runtime(symbol)) + }) + + it(`accepts symbols through the shared runtime identity getter`, () => { + const symbol = Symbol(`shared runtime`) + + expect(getRuntimeReferenceIdentity(symbol)).toEqual( + getRuntimeReferenceIdentity(symbol), + ) + }) + it(`falls back when the runtime crypto object lacks getRandomValues`, () => { vi.stubGlobal(`crypto`, {}) try { @@ -439,6 +499,59 @@ describe(`loadSubset demand identity`, () => { ) }) + it(`uses runtime reference identity for opaque demand values`, () => { + const field = new PropRef([`row`, `value`]) + const firstFunction = () => `value` + const secondFunction = () => `value` + const firstSymbol = Symbol(`value`) + const secondSymbol = Symbol(`value`) + const createDemands = (value: unknown): Array => [ + { where: new Func(`eq`, [field, new Value(value)]) }, + { where: new Func(`in`, [field, new Value([value])]) }, + { + orderBy: [ + { + expression: new Func(`coalesce`, [field, new Value(value)]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + }, + { + cursor: { + whereFrom: new Func(`gt`, [field, new Value(value)]), + whereCurrent: new Func(`eq`, [field, new Value(value)]), + }, + }, + ] + + for (const [firstValue, secondValue] of [ + [firstFunction, secondFunction], + [firstSymbol, secondSymbol], + ] as const) { + const firstDemands = createDemands(firstValue) + const secondDemands = createDemands(secondValue) + + firstDemands.forEach((demand, index) => { + const demandKey = getLoadSubsetDemandKey(demand) + expect(getLoadSubsetDemandKey(cloneLoadSubsetOptions(demand))).toBe( + demandKey, + ) + expect(getLoadSubsetDemandKey(snapshotLoadSubsetDemand(demand))).toBe( + demandKey, + ) + expect(getLoadSubsetDemandKey(secondDemands[index]!)).not.toBe( + demandKey, + ) + }) + } + + expect(() => + getStableExpressionHash( + new Func(`eq`, [field, new Value(firstFunction)]), + ), + ).toThrow(/function value/) + }) + it.each([ [`signed zero`, -0, 0], [`invalid Date`, new Date(Number.NaN), new Date(Number.NaN)], diff --git a/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts b/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts new file mode 100644 index 0000000000..be4e53c632 --- /dev/null +++ b/packages/db/tests/query/load-subset-full-flow-oracle.property.test.ts @@ -0,0 +1,462 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { expect, it, vi } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection } from '../../src/query/index.js' +import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' +import { createDeferred } from '../../src/deferred.js' +import { + projectAdapterLifecycle, + projectRetainedRowKeys, + projectReusableDemands, + projectTransportLoads, +} from '../load-subset-full-flow-model.js' +import { + oracleRandomParameters, + readOracleRunConfig, +} from '../oracle-config.js' +import type { LoadSubsetOptions } from '../../src/types.js' +import type { LoadSubsetFullFlowEvent } from '../load-subset-full-flow-model.js' + +type AdapterLifecycleEvent = + | { type: `start`; options: LoadSubsetOptions } + | { type: `release`; options: LoadSubsetOptions } + +function eventTypes( + events: ReadonlyArray, +): Array { + return events.map((event) => event.type) +} + +function visibleRows( + values: Iterable, +): Array<{ id: string; value: number }> { + return Array.from(values, ({ id, value }) => ({ id, value })) +} + +type TruncateCoverageScenario = { + oldRequest: `none` | `settles-late` + freshResult: `authoritative` | `unknown` | `reject` + settlementOrder: `old-first` | `fresh-first` +} + +const truncateCoverageScenarioArbitrary: fc.Arbitrary = + fc.record({ + oldRequest: fc.constantFrom(`none` as const, `settles-late` as const), + freshResult: fc.constantFrom( + `authoritative` as const, + `unknown` as const, + `reject` as const, + ), + settlementOrder: fc.constantFrom( + `old-first` as const, + `fresh-first` as const, + ), + }) + +const exhaustiveTruncateCoverageScenarios: Array = [ + `none` as const, + `settles-late` as const, +].flatMap((oldRequest) => + ([`authoritative`, `unknown`, `reject`] as const).flatMap((freshResult) => + ([`old-first`, `fresh-first`] as const).map((settlementOrder) => ({ + oldRequest, + freshResult, + settlementOrder, + })), + ), +) + +const { multiplier: truncateMultiplier, replaySeed: truncateReplaySeed } = + readOracleRunConfig() + +let truncateCoverageHarnessId = 0 + +async function runTruncateCoverageScenario( + scenario: TruncateCoverageScenario, +): Promise { + type Row = { id: string; value: number } + type AdapterResult = { + hasMore: boolean | undefined + appliedRowKeys: ReadonlyArray + } + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + let truncate!: () => void + const pending = new Map< + LoadSubsetOptions, + ReturnType> + >() + const unloadSubset = vi.fn() + const source = createCollection({ + id: `truncate-coverage-oracle-${truncateCoverageHarnessId++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + params.markReady() + return { + loadSubset: (options) => { + const request = createDeferred() + pending.set(options, request) + return request.promise + }, + unloadSubset, + } + }, + }, + }) + const initialOptions = { limit: 1 } + const oldOptions = { limit: 2 } + const freshOptions = { limit: 3 } + const histories: Array = [] + const activeOptions: Array = [] + + const request = (ownerId: string, options: LoadSubsetOptions) => { + histories.push({ + type: `requestDemand`, + ownerId, + sessionId: `session`, + demandId: `prefix-${options.limit}`, + alreadyAborted: false, + }) + activeOptions.push(options) + const result = source._sync.loadSubset(options) + if (result === true) throw new Error(`Expected a controlled async request`) + return result + } + + const apply = async ( + ownerId: string, + options: LoadSubsetOptions, + rows: ReadonlyArray, + hasMore: boolean | undefined, + ) => { + begin() + for (const row of rows) write({ type: `insert`, value: row }) + const applied = commit() + if (applied !== true) await applied + pending.get(options)!.resolve({ + hasMore, + appliedRowKeys: rows.map(({ id }) => id), + }) + histories.push({ + type: + hasMore === undefined ? `applyUnprovenRows` : `applyAuthoritativeRows`, + ownerId, + demandId: `prefix-${options.limit}`, + rowKeys: rows.map(({ id }) => id), + }) + } + + const reject = (ownerId: string, options: LoadSubsetOptions) => { + pending.get(options)!.reject(new Error(`fresh replay failed`)) + histories.push({ + type: `rejectDemand`, + ownerId, + demandId: `prefix-${options.limit}`, + }) + } + + const expectModel = () => { + const actualReusable = activeOptions + .filter( + (options) => source._sync.getLoadSubsetOutcome(options) !== undefined, + ) + .map((options) => `prefix-${options.limit}`) + .sort() + expect(actualReusable).toEqual(projectReusableDemands(histories)) + expect(Array.from(source.keys()).sort()).toEqual( + projectRetainedRowKeys(histories), + ) + } + + try { + const initialLoad = request(`initial`, initialOptions) + await apply(`initial`, initialOptions, [{ id: `initial`, value: 1 }], false) + await initialLoad + expectModel() + + const oldLoad = + scenario.oldRequest === `settles-late` + ? request(`old`, oldOptions) + : undefined + + begin() + truncate() + const truncated = commit() + if (truncated !== true) await truncated + histories.push({ type: `truncateSource`, sessionId: `session` }) + expectModel() + + const freshLoad = request(`fresh`, freshOptions) + const settleOld = async () => { + if (!oldLoad) return + await apply(`old`, oldOptions, [{ id: `old`, value: 2 }], false) + await oldLoad + expectModel() + } + const settleFresh = async () => { + if (scenario.freshResult === `reject`) { + reject(`fresh`, freshOptions) + await expect(freshLoad).rejects.toThrow(`fresh replay failed`) + } else { + await apply( + `fresh`, + freshOptions, + [{ id: `fresh`, value: 3 }], + scenario.freshResult === `authoritative` ? false : undefined, + ) + await freshLoad + } + expectModel() + } + + if (scenario.settlementOrder === `fresh-first`) { + await settleFresh() + await settleOld() + } else { + await settleOld() + await settleFresh() + } + + for (const options of activeOptions) { + source._sync.unloadSubset(options) + histories.push({ + type: `releaseDemand`, + ownerId: + options === initialOptions + ? `initial` + : options === oldOptions + ? `old` + : `fresh`, + demandId: `prefix-${options.limit}`, + rowKeys: + options === initialOptions + ? [`initial`] + : options === oldOptions + ? [`old`] + : scenario.freshResult === `reject` + ? [] + : [`fresh`], + finalRowOwner: true, + invalidatesAdapterEvidence: true, + }) + } + expect(unloadSubset.mock.calls.map(([options]) => options)).toEqual( + activeOptions, + ) + expectModel() + } finally { + for (const pendingRequest of pending.values()) { + pendingRequest.reject(new Error(`test cleanup`)) + } + await source.cleanup() + } +} + +it(`does not release physical work when an already-aborted demand skips adapter start`, async () => { + const ownerId = `aborted-owner` + const requestEvent: LoadSubsetFullFlowEvent = { + type: `requestDemand`, + ownerId, + sessionId: `session-1`, + demandId: `all-rows`, + alreadyAborted: true, + } + const history: ReadonlyArray = [ + requestEvent, + { + type: `releaseDemand`, + ownerId, + demandId: `all-rows`, + rowKeys: [], + finalRowOwner: false, + invalidatesAdapterEvidence: false, + }, + ] + const adapterEvents: Array = [] + const collection = createCollection<{ id: string }>({ + id: `full-flow-aborted-before-start`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + adapterEvents.push({ type: `start`, options }) + return true + }, + unloadSubset: (options) => { + adapterEvents.push({ type: `release`, options }) + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + const request = new AbortController() + request.abort() + + try { + subscription.requestSnapshot({ + signal: request.signal, + optimizedOnly: false, + }) + expect(eventTypes(adapterEvents)).toEqual( + projectAdapterLifecycle([requestEvent]).map(({ type }) => + type === `invoke` ? `start` : `release`, + ), + ) + + subscription.unsubscribe() + + // A skipped adapter call creates no physical resource to release. + expect(eventTypes(adapterEvents)).toEqual( + projectAdapterLifecycle(history).map(({ type }) => + type === `invoke` ? `start` : `release`, + ), + ) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } +}) + +it(`reloads authoritative rows after final-owner cleanup invalidates retained adapter coverage`, async () => { + type Row = { id: string; value: number } + const row: Row = { id: `row`, value: 1 } + const history: ReadonlyArray = [ + { + type: `requestDemand`, + ownerId: `owner-1`, + sessionId: `session-1`, + demandId: `all-rows`, + alreadyAborted: false, + }, + { + type: `applyAuthoritativeRows`, + ownerId: `owner-1`, + demandId: `all-rows`, + rowKeys: [row.id], + }, + { + type: `releaseDemand`, + ownerId: `owner-1`, + demandId: `all-rows`, + rowKeys: [row.id], + finalRowOwner: true, + invalidatesAdapterEvidence: true, + }, + { + type: `restartSession`, + previousSessionId: `session-1`, + nextSessionId: `session-2`, + }, + { + type: `requestDemand`, + ownerId: `owner-2`, + sessionId: `session-2`, + demandId: `all-rows`, + alreadyAborted: false, + }, + { + type: `applyAuthoritativeRows`, + ownerId: `owner-2`, + demandId: `all-rows`, + rowKeys: [row.id], + }, + ] + let transportLoads = 0 + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => true | Promise + + const deduplicated = new DeduplicatedLoadSubset({ + loadSubset: async () => { + transportLoads++ + begin() + write({ type: `insert`, value: row }) + const applied = commit() + if (applied !== true) await applied + return { hasMore: false, appliedRowKeys: [row.id] } + }, + }) + const source = createCollection({ + id: `full-flow-dedupe-remount-source`, + getKey: (value) => value.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: deduplicated.loadSubset, + unloadSubset: deduplicated.unloadSubset, + } + }, + }, + }) + const createLive = (id: string) => + createLiveQueryCollection({ + id, + query: (q) => q.from({ row: source }), + startSync: true, + }) + const first = createLive(`full-flow-dedupe-remount-first`) + let second: ReturnType | undefined + + try { + await first.preload() + expect(visibleRows(first.values())).toEqual([row]) + expect(transportLoads).toBe(1) + + await first.cleanup() + expect(Array.from(source.values())).toEqual([]) + + second = createLive(`full-flow-dedupe-remount-second`) + await second.preload() + + // The adapter must either replay retained evidence or fetch it again. + expect(transportLoads).toBe(projectTransportLoads(history)) + expect(visibleRows(second.values()).map(({ id }) => id)).toEqual( + projectRetainedRowKeys(history), + ) + } finally { + await Promise.all([ + first.cleanup(), + second?.cleanup() ?? Promise.resolve(), + source.cleanup(), + ]) + } +}) + +it(`matches the truncate evidence model across every bounded settlement history`, async () => { + for (const scenario of exhaustiveTruncateCoverageScenarios) { + await runTruncateCoverageScenario(scenario) + } +}) + +fcTest.prop([truncateCoverageScenarioArbitrary], { + numRuns: 12 * truncateMultiplier, + seed: 1774, +})(`fences pre-truncate evidence for a fixed seed`, runTruncateCoverageScenario) + +fcTest.prop( + [truncateCoverageScenarioArbitrary], + oracleRandomParameters(12 * truncateMultiplier, truncateReplaySeed), +)( + `fences pre-truncate evidence for a random or replayed seed`, + runTruncateCoverageScenario, +) diff --git a/packages/db/tests/query/load-subset-lifecycle-oracle.property.test.ts b/packages/db/tests/query/load-subset-lifecycle-oracle.property.test.ts new file mode 100644 index 0000000000..433881dde7 --- /dev/null +++ b/packages/db/tests/query/load-subset-lifecycle-oracle.property.test.ts @@ -0,0 +1,362 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { expect, it } from 'vitest' +import { CoverageRegistry } from '../../src/query/coverage-registry.js' +import { + applyLoadSubsetLifecycleEvent, + canApplyLoadSubsetLifecycleEvent, + createLoadSubsetLifecycleModel, + lifecycleOwnsAppliedRows, + lifecyclePublishesCoverage, +} from '../load-subset-lifecycle-model.js' +import { oraclePropertyOptions } from '../oracle-config.js' +import type { AppliedLoadSubsetOutcome } from '../../src/types.js' +import type { + LoadSubsetLifecycleEvent, + LoadSubsetLifecycleModel, +} from '../load-subset-lifecycle-model.js' +import type { Command } from 'fast-check' + +type PrefixCoverage = Readonly<{ prefix: number }> +type LifecycleModel = LoadSubsetLifecycleModel + +type ReleaseProbe = { + accepted: boolean + calls: number + release: () => void +} + +type PrefixRegistry = CoverageRegistry + +type LifecycleReal = { + registry: PrefixRegistry + lease?: ReturnType + acquisition?: ReturnType + release: ReleaseProbe +} + +function createRegistry(): PrefixRegistry { + return new CoverageRegistry({ + coversDemand: (coverage, demand) => coverage.prefix >= demand, + coversCoverage: (coverage, candidate) => + coverage.prefix >= candidate.prefix, + snapshotCoverage: (coverage) => Object.freeze({ ...coverage }), + projectAppliedCoverage: ({ outcome, rows }) => + outcome.extent === `exhausted` && rows.size >= 1 + ? { prefix: 1 } + : undefined, + }) +} + +function createReleaseProbe(): ReleaseProbe { + const probe: ReleaseProbe = { + accepted: false, + calls: 0, + release: () => { + probe.calls++ + if (!probe.accepted) throw new Error(`release not durably accepted`) + }, + } + return probe +} + +function appliedOutcome(generation = 1): AppliedLoadSubsetOutcome { + return { + collectionId: `scheduled-lifecycle`, + sourceId: `items`, + demand: { limit: 1 }, + generation, + extent: `exhausted`, + appliedRowKeys: [`row`], + } +} + +function expectReleasePending(operation: () => unknown): void { + expect(operation).toThrow(`release not durably accepted`) +} + +function assertLifecycle(model: LifecycleModel, real: LifecycleReal): void { + const ownsAppliedRow = lifecycleOwnsAppliedRows(model) + const publishesCoverage = lifecyclePublishesCoverage(model) + + expect(real.registry.rowOwnerCount(`row`)).toBe(ownsAppliedRow ? 1 : 0) + expect(real.registry.coverageAntichain()).toEqual( + publishesCoverage ? [{ prefix: 1 }] : [], + ) + expect(real.release.calls).toBe(model.releaseCalls) +} + +abstract class LifecycleCommand implements Command< + LifecycleModel, + LifecycleReal +> { + abstract event: LoadSubsetLifecycleEvent + abstract check(model: Readonly): boolean + abstract run(model: LifecycleModel, real: LifecycleReal): void + abstract toString(): string + + protected assert(model: LifecycleModel, real: LifecycleReal): void { + assertLifecycle(model, real) + } + + protected apply(model: LifecycleModel): void { + applyLoadSubsetLifecycleEvent(model, this.event) + } +} + +class StartDemandCommand extends LifecycleCommand { + event = { type: `startDemand` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + real.lease = real.registry.addLease(1) + this.apply(model) + this.assert(model, real) + } + + toString = () => `startDemand` +} + +class ActivateDemandCommand extends LifecycleCommand { + event = { type: `activateDemand` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + real.acquisition = real.registry.addAcquisition({ + generation: 1, + scope: { + collectionId: `scheduled-lifecycle`, + sourceId: `items`, + demand: { limit: 1 }, + }, + leases: [real.lease!], + release: real.release.release, + }) + this.apply(model) + this.assert(model, real) + } + + toString = () => `activateDemand` +} + +class ApplyOutcomeCommand extends LifecycleCommand { + event = { type: `applyOutcome` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + real.registry.replaceRows(real.acquisition!, new Set([`row`])) + expect( + real.registry.publishOutcome(real.acquisition!, appliedOutcome()), + ).toMatchObject({ accepted: true, published: true }) + this.apply(model) + this.assert(model, real) + } + + toString = () => `applyOutcome` +} + +class FailProvisionalCommand extends LifecycleCommand { + event = { type: `failProvisional` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + expect(real.registry.releaseLease(real.lease!)).toEqual({ + rowsToRemove: [], + }) + this.apply(model) + this.assert(model, real) + } + + toString = () => `failProvisional` +} + +class PublishStaleGenerationCommand extends LifecycleCommand { + event = { type: `publishStaleGeneration` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + expect( + real.registry.publishOutcome(real.acquisition!, appliedOutcome(0)), + ).toMatchObject({ accepted: false, published: false }) + this.apply(model) + this.assert(model, real) + } + + toString = () => `publishStaleGeneration` +} + +class RequestReleaseCommand extends LifecycleCommand { + event = { type: `requestRelease` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + expectReleasePending(() => real.registry.releaseLease(real.lease!)) + this.apply(model) + this.assert(model, real) + } + + toString = () => `requestRelease` +} + +class RetryPendingReleaseCommand extends LifecycleCommand { + event = { type: `retryPendingRelease` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + expectReleasePending(() => + model.releaseMode === `dispose` + ? real.registry.dispose() + : real.registry.releaseLease(real.lease!), + ) + this.apply(model) + this.assert(model, real) + } + + toString = () => `retryPendingRelease` +} + +class AcceptPendingReleaseCommand extends LifecycleCommand { + event = { type: `acceptPendingRelease` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + real.release.accepted = true + const result = + model.releaseMode === `dispose` + ? real.registry.dispose() + : real.registry.releaseLease(real.lease!) + expect(result.rowsToRemove).toEqual(model.applied ? [`row`] : []) + this.apply(model) + this.assert(model, real) + } + + toString = () => `acceptPendingRelease` +} + +class DisposeCommand extends LifecycleCommand { + event = { type: `dispose` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + if (model.state === `active` || model.state === `applied`) { + expectReleasePending(() => real.registry.dispose()) + } else { + expect(real.registry.dispose()).toEqual({ rowsToRemove: [] }) + } + this.apply(model) + this.assert(model, real) + } + + toString = () => `dispose` +} + +class PublishLateOutcomeCommand extends LifecycleCommand { + event = { type: `publishLateOutcome` } as const + check = (model: Readonly) => + canApplyLoadSubsetLifecycleEvent(model, this.event) + + run(model: LifecycleModel, real: LifecycleReal): void { + if (real.acquisition) { + expect( + real.registry.publishOutcome(real.acquisition, appliedOutcome(2)), + ).toMatchObject({ accepted: false, published: false }) + } + this.apply(model) + this.assert(model, real) + } + + toString = () => `publishLateOutcome` +} + +const commandArbitraries = [ + fc.constant(new StartDemandCommand()), + fc.constant(new ActivateDemandCommand()), + fc.constant(new ApplyOutcomeCommand()), + fc.constant(new FailProvisionalCommand()), + fc.constant(new PublishStaleGenerationCommand()), + fc.constant(new RequestReleaseCommand()), + fc.constant(new RetryPendingReleaseCommand()), + fc.constant(new AcceptPendingReleaseCommand()), + fc.constant(new DisposeCommand()), + fc.constant(new PublishLateOutcomeCommand()), +] + +function createLifecyclePair(): { + model: LifecycleModel + real: LifecycleReal +} { + return { + model: createLoadSubsetLifecycleModel(), + real: { + registry: createRegistry(), + release: createReleaseProbe(), + }, + } +} + +function runHistory(commands: ReadonlyArray): void { + const { model, real } = createLifecyclePair() + for (const command of commands) { + expect(command.check(model)).toBe(true) + command.run(model, real) + } +} + +it(`keeps applied ownership until release is durably accepted`, () => { + runHistory([ + new StartDemandCommand(), + new ActivateDemandCommand(), + new ApplyOutcomeCommand(), + new RequestReleaseCommand(), + new RetryPendingReleaseCommand(), + new AcceptPendingReleaseCommand(), + new PublishLateOutcomeCommand(), + ]) +}) + +it(`keeps teardown retryable while physical release is not accepted`, () => { + runHistory([ + new StartDemandCommand(), + new ActivateDemandCommand(), + new ApplyOutcomeCommand(), + new DisposeCommand(), + new AcceptPendingReleaseCommand(), + new PublishLateOutcomeCommand(), + ]) +}) + +it(`publishes neither provisional nor stale-generation coverage`, () => { + runHistory([new StartDemandCommand(), new FailProvisionalCommand()]) + runHistory([ + new StartDemandCommand(), + new ActivateDemandCommand(), + new PublishStaleGenerationCommand(), + ]) +}) + +fcTest.prop( + [ + fc.commands(commandArbitraries, { + maxCommands: 20, + }), + ], + oraclePropertyOptions(100), +)( + `matches the scheduled acquisition, coverage, release, teardown, and stale-settlement lifecycle`, + (commands) => { + fc.modelRun( + () => ({ + ...createLifecyclePair(), + }), + commands, + ) + }, +) diff --git a/packages/db/tests/query/load-subset-projection-oracle.property.test.ts b/packages/db/tests/query/load-subset-projection-oracle.property.test.ts new file mode 100644 index 0000000000..d17a396b40 --- /dev/null +++ b/packages/db/tests/query/load-subset-projection-oracle.property.test.ts @@ -0,0 +1,308 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { oraclePropertyOptions } from '../oracle-config.js' +import type { + AppliedLoadSubsetOutcome, + LoadSubsetOptions, +} from '../../src/types.js' + +type Row = { id: number } + +type EvidenceCandidate = Readonly<{ + demand: LoadSubsetOptions + extent: AppliedLoadSubsetOutcome[`extent`] + rowIds: ReadonlyArray +}> + +let collectionSequence = 0 + +async function measureSynchronousEvidenceWork( + authority: `applied` | `established`, + candidateCount: number, +) { + const rows = Array.from({ length: 32 }, (_, id) => ({ id })) + const physicalDemands = Array.from({ length: candidateCount }, (_, index) => + Object.freeze({ limit: 16 + index }), + ) + let loadCount = 0 + const collection = createCollection({ + id: `load-subset-${authority}-evidence-work-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + loadCount++ + if (loadCount > candidateCount) return true + if (loadCount === 1) { + begin() + rows.forEach((row) => write({ type: `insert`, value: row })) + commit() + } + return Promise.resolve({ + hasMore: authority === `established` ? false : undefined, + appliedRowKeys: rows.map(({ id }) => id), + }) + }, + } + }, + }, + }) + + try { + for (const demand of physicalDemands) { + const result = collection._sync.loadSubset(demand) + if (result !== true) await result + } + + const satisfiedDemand = Object.freeze({ limit: 1 }) + collection._sync.resetLoadSubsetEvidenceWorkCounts() + expect(collection._sync.loadSubset(satisfiedDemand)).toBe(true) + const satisfaction = collection._sync.getLoadSubsetEvidenceWorkCounts() + + collection._sync.resetLoadSubsetEvidenceWorkCounts() + expect(collection._sync.getLoadSubsetOutcome(satisfiedDemand)).toEqual( + expect.objectContaining({ demand: satisfiedDemand }), + ) + const outcomeRead = collection._sync.getLoadSubsetEvidenceWorkCounts() + + return { satisfaction, outcomeRead } + } finally { + await collection.cleanup() + } +} + +async function selectSynchronousEvidence( + candidates: ReadonlyArray, + demand: LoadSubsetOptions, +) { + let nextCandidate = 0 + const collection = createCollection({ + id: `load-subset-evidence-selection-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options) => { + const candidate = candidates[nextCandidate++] + if (!candidate) return true + expect(options).toEqual(candidate.demand) + begin() + candidate.rowIds.forEach((id) => + write({ type: `insert`, value: { id } }), + ) + commit() + return Promise.resolve({ + hasMore: + candidate.extent === `unknown` + ? undefined + : candidate.extent === `continues`, + appliedRowKeys: candidate.rowIds, + }) + }, + } + }, + }, + }) + + try { + for (const candidate of candidates) { + const result = collection._sync.loadSubset(candidate.demand) + expect(result).not.toBe(true) + if (result !== true) await result + } + expect(collection._sync.loadSubset(demand)).toBe(true) + expect(nextCandidate).toBe(candidates.length + 1) + return collection._sync.getLoadSubsetOutcome(demand) + } finally { + await collection.cleanup() + } +} + +test.each([`established`, `applied`] as const)( + `bounds synchronous %s evidence work independently of candidate count`, + async (authority) => { + const oneCandidate = await measureSynchronousEvidenceWork(authority, 1) + const eightCandidates = await measureSynchronousEvidenceWork(authority, 8) + + expect(eightCandidates).toEqual(oneCandidate) + // Count copied row-key slots, not copy operations. The fixed budget includes + // the selected projection and the coverage registry's stored snapshots. + expect(eightCandidates).toEqual({ + satisfaction: { + rowKeyCopies: 96, + demandSnapshots: 5, + demandKeyDerivations: 6, + }, + outcomeRead: { + rowKeyCopies: 32, + demandSnapshots: 1, + demandKeyDerivations: 1, + }, + }) + }, +) + +test.each([ + { + name: `exact evidence over newer covering evidence`, + candidates: [ + { + demand: { limit: 5 }, + extent: `exhausted`, + rowIds: [100, 101, 102, 103, 104], + }, + { + demand: { limit: 10 }, + extent: `continues`, + rowIds: [200, 201, 202, 203, 204, 205, 206, 207, 208, 209], + }, + ], + demand: { limit: 5 }, + expectedExtent: `exhausted`, + expectedRowIds: [100, 101, 102, 103, 104], + }, + { + name: `continuing evidence over newer exhausted evidence`, + candidates: [ + { + demand: { limit: 10 }, + extent: `continues`, + rowIds: [300, 301, 302, 303, 304, 305, 306, 307, 308, 309], + }, + { + demand: { limit: 12 }, + extent: `exhausted`, + rowIds: [400], + }, + ], + demand: { limit: 5 }, + expectedExtent: `continues`, + expectedRowIds: [300, 301, 302, 303, 304, 305, 306, 307, 308, 309], + }, + { + name: `newer generation when exactness and extent tie`, + candidates: [ + { + demand: { limit: 10 }, + extent: `exhausted`, + rowIds: [500], + }, + { + demand: { limit: 12 }, + extent: `exhausted`, + rowIds: [600], + }, + ], + demand: { offset: 5, limit: 3 }, + expectedExtent: `exhausted`, + expectedRowIds: [600], + }, + { + name: `established evidence over newer exact applied evidence`, + candidates: [ + { + demand: { limit: 10 }, + extent: `exhausted`, + rowIds: [700], + }, + { + demand: { offset: 5, limit: 3 }, + extent: `unknown`, + rowIds: [800, 801, 802], + }, + ], + demand: { offset: 5, limit: 3 }, + expectedExtent: `exhausted`, + expectedRowIds: [700], + }, +] satisfies ReadonlyArray<{ + name: string + candidates: ReadonlyArray + demand: LoadSubsetOptions + expectedExtent: AppliedLoadSubsetOutcome[`extent`] + expectedRowIds: ReadonlyArray +}>)( + `selects $name`, + async ({ candidates, demand, expectedExtent, expectedRowIds }) => { + await expect( + selectSynchronousEvidence(candidates, demand), + ).resolves.toEqual( + expect.objectContaining({ + demand, + extent: expectedExtent, + appliedRowKeys: expectedRowIds, + }), + ) + }, +) + +const projectionScenarioArbitrary = fc + .record({ + sourceSize: fc.integer({ min: 1, max: 8 }), + rawOffset: fc.nat(7), + rawLimit: fc.nat(7), + }) + .map(({ sourceSize, rawOffset, rawLimit }) => { + const callerOffset = rawOffset % sourceSize + const callerLimit = 1 + (rawLimit % (sourceSize - callerOffset)) + return { sourceSize, callerOffset, callerLimit } + }) + +fcTest.prop([projectionScenarioArbitrary], oraclePropertyOptions(50))( + `projects covering exhaustion relative to a finite source world`, + async ({ sourceSize, callerOffset, callerLimit }) => { + const rows = Array.from({ length: sourceSize }, (_, id) => ({ id })) + let physicalLoads = 0 + const collection = createCollection({ + id: `load-subset-projection-oracle-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + physicalLoads++ + if (physicalLoads > 1) return true + begin() + for (const row of rows) write({ type: `insert`, value: row }) + commit() + return Promise.resolve({ + hasMore: false, + appliedRowKeys: rows.map(({ id }) => id), + }) + }, + } + }, + }, + }) + + try { + const physicalDemand = { offset: 0, limit: sourceSize } + await collection._sync.loadSubset(physicalDemand) + + const callerDemand = { offset: callerOffset, limit: callerLimit } + expect(collection._sync.loadSubset(callerDemand)).toBe(true) + + const callerEnd = callerOffset + callerLimit + const expectedExtent = callerEnd < sourceSize ? `continues` : `exhausted` + expect(collection._sync.getLoadSubsetOutcome(callerDemand)).toEqual( + expect.objectContaining({ + demand: callerDemand, + extent: expectedExtent, + }), + ) + } finally { + await collection.cleanup() + } + }, +) diff --git a/packages/db/tests/query/subset-dedupe.test.ts b/packages/db/tests/query/subset-dedupe.test.ts index c99b1b9605..5ea056658c 100644 --- a/packages/db/tests/query/subset-dedupe.test.ts +++ b/packages/db/tests/query/subset-dedupe.test.ts @@ -45,6 +45,98 @@ function not(expression: BasicExpression): Func { } describe(`createDeduplicatedLoadSubset`, () => { + it.each( + [ + { + name: `unbounded`, + createOptions: (): LoadSubsetOptions => ({}), + }, + { + name: `filtered`, + createOptions: (): LoadSubsetOptions => ({ + where: eq(ref(`status`), val(`active`)), + }), + }, + { + name: `limited`, + createOptions: (): LoadSubsetOptions => ({ limit: 2 }), + }, + ].flatMap((coverage) => + ([`sync`, `async`] as const).map((settlement) => ({ + ...coverage, + settlement, + })), + ), + )( + `invalidates $settlement $name settled coverage on unload`, + async ({ createOptions, settlement }) => { + const loadSubset = vi.fn(() => + settlement === `sync` ? (true as const) : Promise.resolve(), + ) + const deduplicated = new DeduplicatedLoadSubset({ loadSubset }) + const owner = createOptions() + + await deduplicated.loadSubset(owner) + expect(deduplicated.loadSubset(createOptions())).toBe(true) + expect(loadSubset).toHaveBeenCalledTimes(1) + + deduplicated.unloadSubset(owner) + await deduplicated.loadSubset(createOptions()) + + expect(loadSubset).toHaveBeenCalledTimes(2) + }, + ) + + it(`bounds conservative adapter-wide invalidation to one refetch per revisited demand`, async () => { + const loadSubset = vi.fn(() => Promise.resolve()) + const deduplicated = new DeduplicatedLoadSubset({ loadSubset }) + const demands = Array.from({ length: 6 }, (_, id) => ({ + where: eq(ref(`id`), val(id)), + limit: 1, + })) + + for (const demand of demands) await deduplicated.loadSubset(demand) + expect(loadSubset).toHaveBeenCalledTimes(demands.length) + + // Core may delete rows owned by any remembered request when one collection + // owner leaves. Without adapter row provenance, preserving the other five + // request facts would be unsafe, so one release invalidates all six. + deduplicated.unloadSubset(demands[0]!) + for (const demand of demands.slice(1)) { + await deduplicated.loadSubset(demand) + } + expect(loadSubset).toHaveBeenCalledTimes( + demands.length + demands.length - 1, + ) + + // Once those demands have rebuilt the cache, revisiting them is free again. + for (const demand of demands.slice(1)) { + expect(deduplicated.loadSubset(demand)).toBe(true) + } + expect(loadSubset).toHaveBeenCalledTimes( + demands.length + demands.length - 1, + ) + }) + + it(`does not restore invalidated coverage when unloaded work settles late`, async () => { + let resolveLoad: (() => void) | undefined + const loadSubset = vi.fn( + () => new Promise((resolve) => (resolveLoad = resolve)), + ) + const deduplicated = new DeduplicatedLoadSubset({ loadSubset }) + const owner = new AbortController() + const options = { limit: 2, signal: owner.signal } + const first = deduplicated.loadSubset(options) + + owner.abort() + deduplicated.unloadSubset(options) + resolveLoad?.() + await first + + deduplicated.loadSubset({ limit: 2 }) + expect(loadSubset).toHaveBeenCalledTimes(2) + }) + it(`shares in-flight work while any cancellation owner remains active`, async () => { let resolveLoad: (() => void) | undefined let sharedSignal: AbortSignal | undefined diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index 39e6540a6a..e47d1fc076 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -2064,6 +2064,7 @@ function createElectricSync>( // The loadSubset method is auto-bound, so it can be safely returned directly return { loadSubset: loadSubsetDedupe?.loadSubset, + unloadSubset: loadSubsetDedupe?.unloadSubset, cleanup: () => { // Unsubscribe from the stream unsubscribeStream() diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index e6d95e3f57..11d498b3e7 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -821,6 +821,11 @@ export function queryCollectionOptions( // 3. Decrements refcount and GCs rows where count reaches 0 const queryRefCounts = new Map() + // Eager mode owns its base query for the collection's whole lifetime. Query + // cache GC may remove the idle cache entry, but that is not a release of the + // collection's ownership or its materialized rows. + const collectionLifetimeQueries = new Set() + const addRowOwner = (rowKey: string | number, hashedQueryKey: string) => { const owners = rowToQueries.get(rowKey) || new Set() owners.add(hashedQueryKey) @@ -1822,6 +1827,7 @@ export function queryCollectionOptions( // If syncMode is eager, create the initial query without any predicates if (syncMode === `eager`) { + collectionLifetimeQueries.add(hashKey(generateQueryKeyFromOptions({}))) // Catch any errors to prevent unhandled rejections const initialResult = createQueryFromOpts({}) if (initialResult instanceof Promise) { @@ -1915,6 +1921,7 @@ export function queryCollectionOptions( queryToRows.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) + collectionLifetimeQueries.delete(hashedQueryKey) effectivePersistedGcTimes.delete(hashedQueryKey) } @@ -1928,6 +1935,10 @@ export function queryCollectionOptions( const effectivePersistedGcTime = effectivePersistedGcTimes.get(hashedQueryKey) + if (collectionLifetimeQueries.has(hashedQueryKey)) { + return + } + if (refcount <= 0) { // Drop our subscription so hasListeners reflects only active consumers unsubscribes.get(hashedQueryKey)?.() @@ -2009,6 +2020,22 @@ export function queryCollectionOptions( if (event.type === `removed`) { // Only cleanup if this is OUR query (we track it) if (hashToQueryKey.has(hashedKey)) { + if (collectionLifetimeQueries.has(hashedKey)) { + // Cache removal detaches the old observer. Eager mode still owns + // this query, so replace that observer without retiring its rows. + unsubscribes.get(hashedKey)?.() + unsubscribes.delete(hashedKey) + unsubscribePendingReadyListeners(hashedKey) + state.observers.delete(hashedKey) + queryRefCounts.set(hashedKey, 0) + const replacement = createQueryFromOpts({}) + if (replacement instanceof Promise) { + replacement.catch(() => { + // Errors are handled by the query result handler. + }) + } + return + } // TanStack Query GC'd this query after gcTime expired. // Use the guarded cleanup path to avoid deleting rows for active queries. cleanupQueryIfIdle(hashedKey) diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index ef9baa5da9..4dddbf3564 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -167,26 +167,6 @@ function asRecords({ } } -function classifyEagerOwnerLoss(difference: { - actual: unknown - expected: unknown -}): boolean { - const records = asRecords(difference) - if (!records) return false - const { observed, wanted } = records - return ( - observed.status === `ready` && - Array.isArray(observed.rows) && - observed.rows.length === 0 && - observed.owners === 0 && - wanted.status === `ready` && - Array.isArray(wanted.rows) && - wanted.rows.length === 1 && - wanted.rows[0] === shared.id && - wanted.owners === 1 - ) -} - function classifyInsertedOwnerMetadataLoss(difference: { actual: unknown expected: unknown @@ -559,16 +539,16 @@ describe(`query collection ownership lifecycle oracle`, () => { ) }) - it(`#1631 keeps the eager owner when its last collection listener departs`, async () => { - const id = `ownership-eager-listener-1631` - const { collection, maps, queryClient } = createOwnershipFixture({ + it(`keeps the eager owner when its last collection listener departs`, async () => { + const id = `ownership-eager-listener` + const { collection, maps, queryClient, queryFn } = createOwnershipFixture({ id, syncMode: `eager`, - results: [[shared]], + results: [[shared], [{ ...shared, name: `Refetched` }]], }) await collection.stateWhenReady() - const queryHash = onlyOwner(maps, shared.id) + onlyOwner(maps, shared.id) const subscription = collection.subscribeChanges(() => {}) assertCheckpoint( 0, @@ -589,31 +569,34 @@ describe(`query collection ownership lifecycle oracle`, () => { // without making the defect boundary depend on a timer. queryClient.removeQueries({ queryKey: [id], exact: true }) - const assertOwnerSurvives = expectAssertionFailure( - () => - Promise.resolve().then(() => { - assertCheckpoint( - 2, - { - status: collection.status, - rows: collectionRows(collection), - owners: ownersOf(maps, shared.id).length, - }, - { status: `ready`, rows: [shared.id], owners: 1 }, - ) - }), + assertCheckpoint( + 2, { - checkpoint: 2, - classify: classifyEagerOwnerLoss, + status: collection.status, + rows: collectionRows(collection), + owners: ownersOf(maps, shared.id).length, }, + { status: `ready`, rows: [shared.id], owners: 1 }, ) + expect(warning).not.toHaveBeenCalled() - await assertOwnerSurvives() - expect(warning).toHaveBeenCalledOnce() - expect(warning).toHaveBeenCalledWith( - expect.stringContaining(`[cleanupQueryIfIdle]`), - { hashedQueryKey: queryHash }, + await vi.waitFor(() => { + expect(observerCount(queryClient, onlyOwner(maps, shared.id))).toBe(1) + expect(queryFn).toHaveBeenCalledTimes(2) + expect(collection.get(shared.id)?.name).toBe(`Refetched`) + }) + + const remounted = collection.subscribeChanges(() => {}) + assertCheckpoint( + 3, + { + status: collection.status, + rows: collectionRows(collection), + owners: ownersOf(maps, shared.id).length, + }, + { status: `ready`, rows: [shared.id], owners: 1 }, ) + remounted.unsubscribe() } finally { warning.mockRestore() }