@blocksdiy/blocks-client-sdk 1.8.14 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ClientSdk.js +1 -1
- package/dist/Entity.d.ts +3 -1
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +3 -2
- package/dist/EntityCache.d.ts +162 -0
- package/dist/EntityCache.d.ts.map +1 -0
- package/dist/EntityCache.js +377 -0
- package/dist/EntityLiveSync.d.ts +57 -0
- package/dist/EntityLiveSync.d.ts.map +1 -0
- package/dist/EntityLiveSync.js +182 -0
- package/dist/ReactClientSdk.d.ts +16 -9
- package/dist/ReactClientSdk.d.ts.map +1 -1
- package/dist/ReactClientSdk.jsx +106 -40
- package/package.json +4 -4
package/dist/ClientSdk.js
CHANGED
|
@@ -80,7 +80,7 @@ export class ClientSdk {
|
|
|
80
80
|
*/
|
|
81
81
|
entity(entityConfig) {
|
|
82
82
|
if (!this.entities.has(entityConfig.tableBlockId)) {
|
|
83
|
-
this.entities.set(entityConfig.tableBlockId, new Entity(entityConfig, { token: this.token }));
|
|
83
|
+
this.entities.set(entityConfig.tableBlockId, new Entity(entityConfig, { token: this.token, appId: this.appId }));
|
|
84
84
|
}
|
|
85
85
|
return this.entities.get(entityConfig.tableBlockId);
|
|
86
86
|
}
|
package/dist/Entity.d.ts
CHANGED
|
@@ -58,9 +58,11 @@ export declare class Entity<EC extends EntityConfig = EntityConfig> {
|
|
|
58
58
|
* Creates a new Entity instance
|
|
59
59
|
* @param {EC} config - Configuration for the entity
|
|
60
60
|
* @param {string} token - The token for the application
|
|
61
|
+
* @param {string} appId - The application id, sent as x-app-id on every request
|
|
61
62
|
*/
|
|
62
|
-
constructor(config: EC, { token }?: {
|
|
63
|
+
constructor(config: EC, { token, appId }?: {
|
|
63
64
|
token?: string;
|
|
65
|
+
appId?: string;
|
|
64
66
|
});
|
|
65
67
|
/**
|
|
66
68
|
* Retrieves a single entity by filters
|
package/dist/Entity.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Entity.d.ts","sourceRoot":"","sources":["../src/Entity.ts"],"names":[],"mappings":"AAaA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,CAAC,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,CAAC,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,cAAc,CAAC,CAAC;AAE9E;;;GAGG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,YAAY,IAAI,cAAc,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC;AAE3F;;;;;;;;GAQG;AACH,qBAAa,MAAM,CAAC,EAAE,SAAS,YAAY,GAAG,YAAY;IACxD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,cAAc,CAAiB;IAEvC
|
|
1
|
+
{"version":3,"file":"Entity.d.ts","sourceRoot":"","sources":["../src/Entity.ts"],"names":[],"mappings":"AAaA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,uCAAuC;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE;IAClE,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,CAAC,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,MAAM,qBAAqB,CAAC,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,cAAc,CAAC,CAAC;AAE9E;;;GAGG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,SAAS,YAAY,IAAI,cAAc,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC;AAE3F;;;;;;;;GAQG;AACH,qBAAa,MAAM,CAAC,EAAE,SAAS,YAAY,GAAG,YAAY;IACxD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,cAAc,CAAiB;IAEvC;;;;;OAKG;gBACS,MAAM,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,GAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAO;IAKjF;;;;;;;;;;;;;;;;;;;;OAoBG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;IAU1C;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACG,QAAQ,CAAC,OAAO,CAAC,EAAE,GAAG;IAY5B;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACG,MAAM,CAAC,IAAI,EAAE,qBAAqB,CAAC,EAAE,CAAC;IAU5C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACG,UAAU,CAAC,IAAI,EAAE,qBAAqB,CAAC,EAAE,CAAC,EAAE;IAUlD;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACG,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,qBAAqB,CAAC,EAAE,CAAC,CAAC;IAUvE;;;;;;;;;;;;;;;OAeG;IACG,SAAS,CAAC,EAAE,EAAE,MAAM;IAS1B;;;;;;;;;;;;;;;;;;OAkBG;IACG,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE;CAS/B"}
|
package/dist/Entity.js
CHANGED
|
@@ -23,10 +23,11 @@ export class Entity {
|
|
|
23
23
|
* Creates a new Entity instance
|
|
24
24
|
* @param {EC} config - Configuration for the entity
|
|
25
25
|
* @param {string} token - The token for the application
|
|
26
|
+
* @param {string} appId - The application id, sent as x-app-id on every request
|
|
26
27
|
*/
|
|
27
|
-
constructor(config, { token } = {}) {
|
|
28
|
+
constructor(config, { token, appId } = {}) {
|
|
28
29
|
this.tableBlockId = config.tableBlockId;
|
|
29
|
-
this.dataApiService = new DataApiService({ token });
|
|
30
|
+
this.dataApiService = new DataApiService({ token, appId });
|
|
30
31
|
}
|
|
31
32
|
/**
|
|
32
33
|
* Retrieves a single entity by filters
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
2
|
+
export declare const ENTITIES_QUERY_KEY = "entities";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal shape of a server row required for cache reconciliation.
|
|
5
|
+
* Server rows always carry an id; updatedAt is used to discard out-of-order writes.
|
|
6
|
+
*/
|
|
7
|
+
export interface EntityCacheRow {
|
|
8
|
+
id: string | number;
|
|
9
|
+
updatedAt?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface EntityWritePreparation {
|
|
13
|
+
/**
|
|
14
|
+
* Keys of table queries that must be (re-)invalidated after the cache write:
|
|
15
|
+
* queries with a fetch in flight (their response is of unknowable freshness) and
|
|
16
|
+
* queries already marked invalidated (writing to them would wrongly mark them fresh).
|
|
17
|
+
*/
|
|
18
|
+
staleKeys: QueryKey[];
|
|
19
|
+
}
|
|
20
|
+
export type EntityQueriesSnapshot = [QueryKey, unknown][];
|
|
21
|
+
export declare const entityTableKey: (tableBlockId: string) => QueryKey;
|
|
22
|
+
/**
|
|
23
|
+
* Normalizes a mutation response into an array of cacheable rows.
|
|
24
|
+
*
|
|
25
|
+
* Server endpoints are inconsistent: create returns a single row, createMany returns
|
|
26
|
+
* an array, and the update endpoint returns an array of rows at runtime even though
|
|
27
|
+
* it is typed as a single entity. Anything we cannot recognize as row data is dropped,
|
|
28
|
+
* letting callers fall back to plain invalidation.
|
|
29
|
+
*/
|
|
30
|
+
export declare const normalizeRowsResult: (result: unknown) => EntityCacheRow[];
|
|
31
|
+
export declare const sameId: (a: unknown, b: unknown) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Returns true when the incoming server row should replace the cached one.
|
|
34
|
+
* Rows updated concurrently can resolve out of order; updatedAt decides the winner.
|
|
35
|
+
* When timestamps are missing or unparsable we accept the incoming row (server data
|
|
36
|
+
* is more trustworthy than an undecidable comparison).
|
|
37
|
+
*/
|
|
38
|
+
export declare const isIncomingRowNewer: (existing: unknown, incoming: EntityCacheRow) => boolean;
|
|
39
|
+
export declare const isListQueryKey: (queryKey: QueryKey, tableBlockId: string) => boolean;
|
|
40
|
+
export declare const isUnfilteredListQueryKey: (queryKey: QueryKey, tableBlockId: string) => boolean;
|
|
41
|
+
export declare const isFilteredListQueryKey: (queryKey: QueryKey, tableBlockId: string) => boolean;
|
|
42
|
+
export declare const isOneQueryKey: (queryKey: QueryKey, tableBlockId: string) => boolean;
|
|
43
|
+
/** Returns the id of a one-query keyed strictly by { id }, undefined for any other filter shape. */
|
|
44
|
+
export declare const getOneQueryKeyId: (queryKey: QueryKey) => string | number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Writes canonical server rows into every cached query of the table.
|
|
47
|
+
*
|
|
48
|
+
* - Rows already present in a list (matched by id) are replaced when newer.
|
|
49
|
+
* - New rows are appended to unfiltered lists only (`appendNewToUnfilteredLists`),
|
|
50
|
+
* matching the server's default `id ASC` ordering. Filtered lists never gain or
|
|
51
|
+
* lose membership here - that is decided by `invalidateEntityQueriesAfterWrite`.
|
|
52
|
+
* - One-queries holding a row with the same id are replaced when newer; one-queries
|
|
53
|
+
* keyed by `{ id }` that cached `null` are filled in with the row.
|
|
54
|
+
*/
|
|
55
|
+
export declare const applyEntityRowsUpserted: (queryClient: QueryClient, tableBlockId: string, rows: EntityCacheRow[], { appendNewToUnfilteredLists }: {
|
|
56
|
+
appendNewToUnfilteredLists: boolean;
|
|
57
|
+
}) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Applies an optimistic partial patch to every cached copy of a row (lists and
|
|
60
|
+
* one-queries). The cached id and updatedAt always win over patch values: id so the
|
|
61
|
+
* row identity cannot change, and updatedAt so a caller-supplied timestamp (e.g. a
|
|
62
|
+
* whole row spread into the update payload, with a client clock ahead of the
|
|
63
|
+
* server) can never make `isIncomingRowNewer` reject the canonical server response.
|
|
64
|
+
*/
|
|
65
|
+
export declare const applyEntityRowPatch: (queryClient: QueryClient, tableBlockId: string, rowId: string | number, patch: Record<string, unknown>) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Removes rows from every cached list of the table and nulls out one-queries that
|
|
68
|
+
* held a deleted row. Removal by id is exact for filtered lists too - a deleted row
|
|
69
|
+
* can never re-match a filter.
|
|
70
|
+
*/
|
|
71
|
+
export declare const applyEntityRowsDeleted: (queryClient: QueryClient, tableBlockId: string, rowIds: (string | number)[]) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Seeds one-query cache entries for freshly written rows so a follow-up
|
|
74
|
+
* `useEntityGetOne(Entity, { id })` (or `useEntityGetOne(Entity, id)`) renders
|
|
75
|
+
* instantly without a fetch. Seeds both the raw id shape and the parseInt-normalized
|
|
76
|
+
* shape `useEntityGetOne` produces for string/number ids.
|
|
77
|
+
*/
|
|
78
|
+
export declare const seedEntityOneCaches: (queryClient: QueryClient, tableBlockId: string, rows: EntityCacheRow[]) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Whether the cache holds any query that direct row writes can keep fresh
|
|
81
|
+
* (unfiltered lists or one-queries). When none exist, fetching row data has nothing
|
|
82
|
+
* to update - filtered lists are refetched via invalidation regardless.
|
|
83
|
+
*/
|
|
84
|
+
export declare const hasDirectlyMaintainedEntityQueries: (queryClient: QueryClient, tableBlockId: string) => boolean;
|
|
85
|
+
export declare const snapshotEntityQueries: (queryClient: QueryClient, tableBlockId: string) => EntityQueriesSnapshot;
|
|
86
|
+
export declare const restoreEntityQueriesSnapshot: (queryClient: QueryClient, snapshot: EntityQueriesSnapshot) => void;
|
|
87
|
+
/**
|
|
88
|
+
* Prepares the table cache for a direct write:
|
|
89
|
+
* - records which queries have a fetch in flight or are already invalidated (a
|
|
90
|
+
* `setQueryData` write would mark them fresh, so they must be re-invalidated
|
|
91
|
+
* afterwards via `invalidateEntityQueriesAfterWrite`),
|
|
92
|
+
* - cancels in-flight fetches of queries that already hold data, so a response
|
|
93
|
+
* snapshotted before the mutation cannot overwrite the rows we are about to write.
|
|
94
|
+
*
|
|
95
|
+
* Fetches of queries without data (initial loads) are left running: cancelling them
|
|
96
|
+
* would strand the query in a non-fetching pending state.
|
|
97
|
+
*/
|
|
98
|
+
export declare const prepareEntityCacheWrite: (queryClient: QueryClient, tableBlockId: string) => Promise<EntityWritePreparation>;
|
|
99
|
+
/**
|
|
100
|
+
* Single invalidation pass that re-syncs everything a direct cache write cannot
|
|
101
|
+
* decide on its own:
|
|
102
|
+
* - filtered lists (membership/order/limit semantics live on the server),
|
|
103
|
+
* - one-queries keyed by non-id filters (an update/create may change which row matches),
|
|
104
|
+
* - queries that had a fetch in flight while we wrote (their in-flight response is
|
|
105
|
+
* of unknowable freshness and may carry an external change we cancelled).
|
|
106
|
+
*
|
|
107
|
+
* Unfiltered lists and id-keyed one-queries are NOT invalidated - they were fully
|
|
108
|
+
* reconciled from the server response, which is what saves the refetch round trips.
|
|
109
|
+
*/
|
|
110
|
+
export declare const invalidateEntityQueriesAfterWrite: (queryClient: QueryClient, tableBlockId: string, { staleKeys, filteredLists, nonIdOneQueries, deletedRowIds, }: {
|
|
111
|
+
staleKeys?: QueryKey[];
|
|
112
|
+
filteredLists?: boolean;
|
|
113
|
+
nonIdOneQueries?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* When set (delete flow), non-id one-queries are only invalidated if they cached
|
|
116
|
+
* one of these rows or currently hold null (which may be the optimistic removal
|
|
117
|
+
* of a deleted row, and another row may match the filter on the server).
|
|
118
|
+
*/
|
|
119
|
+
deletedRowIds?: (string | number)[];
|
|
120
|
+
}) => void;
|
|
121
|
+
/** Full-table invalidation - the pre-optimistic behavior, kept as the safety fallback. */
|
|
122
|
+
export declare const invalidateEntityTable: (queryClient: QueryClient, tableBlockId: string) => void;
|
|
123
|
+
export interface EntityOptimisticContext {
|
|
124
|
+
snapshot: EntityQueriesSnapshot;
|
|
125
|
+
staleKeys: QueryKey[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Optimistic phase of an update/delete mutation: cancel clobbering fetches, snapshot
|
|
129
|
+
* the table cache for rollback, then apply the optimistic change. Never throws - on
|
|
130
|
+
* any failure the optimistic change is rolled back and skipped, and the mutation
|
|
131
|
+
* proceeds (the success/error handlers still reconcile the cache).
|
|
132
|
+
*/
|
|
133
|
+
export declare const beginEntityOptimisticWrite: (queryClient: QueryClient, tableBlockId: string, applyOptimistic: () => void) => Promise<EntityOptimisticContext | undefined>;
|
|
134
|
+
/**
|
|
135
|
+
* Error phase: restore the snapshot taken in `beginEntityOptimisticWrite`, then
|
|
136
|
+
* refetch the table since the server state is unknown after a failed request.
|
|
137
|
+
* `skipInvalidation` is for mutations rejected before any request was sent
|
|
138
|
+
* (e.g. client-side rate limiting) where the restored snapshot is already correct.
|
|
139
|
+
*/
|
|
140
|
+
export declare const rollbackEntityOptimisticWrite: (queryClient: QueryClient, tableBlockId: string, context: EntityOptimisticContext | undefined, { skipInvalidation }?: {
|
|
141
|
+
skipInvalidation?: boolean;
|
|
142
|
+
}) => void;
|
|
143
|
+
/**
|
|
144
|
+
* Success phase of a create/update mutation: writes the canonical rows returned by
|
|
145
|
+
* the server into the cache and invalidates only what a direct write cannot decide
|
|
146
|
+
* (filtered lists, non-id one-queries, queries that were mid-fetch). Falls back to
|
|
147
|
+
* full-table invalidation whenever the response is unusable or anything throws, so
|
|
148
|
+
* the worst case is exactly the previous always-refetch behavior.
|
|
149
|
+
*/
|
|
150
|
+
export declare const applyEntityCreateResult: (queryClient: QueryClient, tableBlockId: string, result: unknown, { appendNewToUnfilteredLists, extraStaleKeys, }: {
|
|
151
|
+
appendNewToUnfilteredLists: boolean;
|
|
152
|
+
extraStaleKeys?: QueryKey[];
|
|
153
|
+
}) => Promise<void>;
|
|
154
|
+
/**
|
|
155
|
+
* Success phase of a delete mutation: removes the rows from every cached query.
|
|
156
|
+
* Filtered lists are still refetched because server-side filters may involve
|
|
157
|
+
* limits/pagination that removal alone cannot reconcile.
|
|
158
|
+
*/
|
|
159
|
+
export declare const applyEntityDeleteResult: (queryClient: QueryClient, tableBlockId: string, rowIds: (string | number)[], { extraStaleKeys }?: {
|
|
160
|
+
extraStaleKeys?: QueryKey[];
|
|
161
|
+
}) => Promise<void>;
|
|
162
|
+
//# sourceMappingURL=EntityCache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EntityCache.d.ts","sourceRoot":"","sources":["../src/EntityCache.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { hashKey } from "@tanstack/react-query";
|
|
2
|
+
export const ENTITIES_QUERY_KEY = "entities";
|
|
3
|
+
const ONE_SEGMENT = "one";
|
|
4
|
+
export const entityTableKey = (tableBlockId) => [ENTITIES_QUERY_KEY, tableBlockId];
|
|
5
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
const isValidRow = (value) => isRecord(value) && (typeof value.id === "string" || typeof value.id === "number");
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes a mutation response into an array of cacheable rows.
|
|
9
|
+
*
|
|
10
|
+
* Server endpoints are inconsistent: create returns a single row, createMany returns
|
|
11
|
+
* an array, and the update endpoint returns an array of rows at runtime even though
|
|
12
|
+
* it is typed as a single entity. Anything we cannot recognize as row data is dropped,
|
|
13
|
+
* letting callers fall back to plain invalidation.
|
|
14
|
+
*/
|
|
15
|
+
export const normalizeRowsResult = (result) => {
|
|
16
|
+
if (Array.isArray(result)) {
|
|
17
|
+
return result.filter(isValidRow);
|
|
18
|
+
}
|
|
19
|
+
return isValidRow(result) ? [result] : [];
|
|
20
|
+
};
|
|
21
|
+
export const sameId = (a, b) => (typeof a === "string" || typeof a === "number") &&
|
|
22
|
+
(typeof b === "string" || typeof b === "number") &&
|
|
23
|
+
String(a) === String(b);
|
|
24
|
+
/**
|
|
25
|
+
* Returns true when the incoming server row should replace the cached one.
|
|
26
|
+
* Rows updated concurrently can resolve out of order; updatedAt decides the winner.
|
|
27
|
+
* When timestamps are missing or unparsable we accept the incoming row (server data
|
|
28
|
+
* is more trustworthy than an undecidable comparison).
|
|
29
|
+
*/
|
|
30
|
+
export const isIncomingRowNewer = (existing, incoming) => {
|
|
31
|
+
if (!isRecord(existing) || typeof existing.updatedAt !== "string" || typeof incoming.updatedAt !== "string") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
const existingTime = Date.parse(existing.updatedAt);
|
|
35
|
+
const incomingTime = Date.parse(incoming.updatedAt);
|
|
36
|
+
if (Number.isNaN(existingTime) || Number.isNaN(incomingTime)) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return incomingTime >= existingTime;
|
|
40
|
+
};
|
|
41
|
+
/* Query key classification.
|
|
42
|
+
* List queries: [ENTITIES_QUERY_KEY, tableBlockId, filters?] (length 3, filters may be undefined)
|
|
43
|
+
* One queries: [ENTITIES_QUERY_KEY, tableBlockId, "one", filters] (length 4)
|
|
44
|
+
*/
|
|
45
|
+
const isTableKey = (queryKey, tableBlockId) => Array.isArray(queryKey) && queryKey[0] === ENTITIES_QUERY_KEY && queryKey[1] === tableBlockId;
|
|
46
|
+
export const isListQueryKey = (queryKey, tableBlockId) => isTableKey(queryKey, tableBlockId) && queryKey.length === 3 && queryKey[2] !== ONE_SEGMENT;
|
|
47
|
+
export const isUnfilteredListQueryKey = (queryKey, tableBlockId) => isListQueryKey(queryKey, tableBlockId) && queryKey[2] == null;
|
|
48
|
+
export const isFilteredListQueryKey = (queryKey, tableBlockId) => isListQueryKey(queryKey, tableBlockId) && queryKey[2] != null;
|
|
49
|
+
export const isOneQueryKey = (queryKey, tableBlockId) => isTableKey(queryKey, tableBlockId) && queryKey.length === 4 && queryKey[2] === ONE_SEGMENT;
|
|
50
|
+
/** Returns the id of a one-query keyed strictly by { id }, undefined for any other filter shape. */
|
|
51
|
+
export const getOneQueryKeyId = (queryKey) => {
|
|
52
|
+
const filters = queryKey[3];
|
|
53
|
+
if (!isRecord(filters)) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const keys = Object.keys(filters);
|
|
57
|
+
if (keys.length !== 1 || keys[0] !== "id") {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const id = filters.id;
|
|
61
|
+
return typeof id === "string" || typeof id === "number" ? id : undefined;
|
|
62
|
+
};
|
|
63
|
+
const updateListData = (oldData, transform) => {
|
|
64
|
+
if (!Array.isArray(oldData)) {
|
|
65
|
+
// Bail out (undefined keeps the cache untouched) instead of corrupting non-list data.
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return transform(oldData);
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Writes canonical server rows into every cached query of the table.
|
|
72
|
+
*
|
|
73
|
+
* - Rows already present in a list (matched by id) are replaced when newer.
|
|
74
|
+
* - New rows are appended to unfiltered lists only (`appendNewToUnfilteredLists`),
|
|
75
|
+
* matching the server's default `id ASC` ordering. Filtered lists never gain or
|
|
76
|
+
* lose membership here - that is decided by `invalidateEntityQueriesAfterWrite`.
|
|
77
|
+
* - One-queries holding a row with the same id are replaced when newer; one-queries
|
|
78
|
+
* keyed by `{ id }` that cached `null` are filled in with the row.
|
|
79
|
+
*/
|
|
80
|
+
export const applyEntityRowsUpserted = (queryClient, tableBlockId, rows, { appendNewToUnfilteredLists }) => {
|
|
81
|
+
if (rows.length === 0) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
queryClient.setQueriesData({
|
|
85
|
+
queryKey: entityTableKey(tableBlockId),
|
|
86
|
+
predicate: (query) => isListQueryKey(query.queryKey, tableBlockId),
|
|
87
|
+
}, (oldData) => updateListData(oldData, (oldRows) => {
|
|
88
|
+
let changed = false;
|
|
89
|
+
const nextRows = oldRows.map((oldRow) => {
|
|
90
|
+
if (!isValidRow(oldRow)) {
|
|
91
|
+
return oldRow;
|
|
92
|
+
}
|
|
93
|
+
const incoming = rows.find((row) => sameId(row.id, oldRow.id));
|
|
94
|
+
if (!incoming || !isIncomingRowNewer(oldRow, incoming)) {
|
|
95
|
+
return oldRow;
|
|
96
|
+
}
|
|
97
|
+
changed = true;
|
|
98
|
+
return incoming;
|
|
99
|
+
});
|
|
100
|
+
return changed ? nextRows : undefined;
|
|
101
|
+
}));
|
|
102
|
+
if (appendNewToUnfilteredLists) {
|
|
103
|
+
queryClient.setQueriesData({
|
|
104
|
+
queryKey: entityTableKey(tableBlockId),
|
|
105
|
+
predicate: (query) => isUnfilteredListQueryKey(query.queryKey, tableBlockId),
|
|
106
|
+
}, (oldData) => updateListData(oldData, (oldRows) => {
|
|
107
|
+
const missingRows = rows.filter((row) => !oldRows.some((oldRow) => isValidRow(oldRow) && sameId(oldRow.id, row.id)));
|
|
108
|
+
return missingRows.length > 0 ? [...oldRows, ...missingRows] : undefined;
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
queryClient
|
|
112
|
+
.getQueryCache()
|
|
113
|
+
.findAll({
|
|
114
|
+
queryKey: entityTableKey(tableBlockId),
|
|
115
|
+
predicate: (query) => isOneQueryKey(query.queryKey, tableBlockId),
|
|
116
|
+
})
|
|
117
|
+
.forEach((query) => {
|
|
118
|
+
queryClient.setQueryData(query.queryKey, (oldData) => {
|
|
119
|
+
if (isValidRow(oldData)) {
|
|
120
|
+
const incoming = rows.find((row) => sameId(row.id, oldData.id));
|
|
121
|
+
return incoming && isIncomingRowNewer(oldData, incoming) ? incoming : undefined;
|
|
122
|
+
}
|
|
123
|
+
if (oldData === null) {
|
|
124
|
+
const keyId = getOneQueryKeyId(query.queryKey);
|
|
125
|
+
const incoming = keyId === undefined ? undefined : rows.find((row) => sameId(row.id, keyId));
|
|
126
|
+
return incoming ?? undefined;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* Applies an optimistic partial patch to every cached copy of a row (lists and
|
|
134
|
+
* one-queries). The cached id and updatedAt always win over patch values: id so the
|
|
135
|
+
* row identity cannot change, and updatedAt so a caller-supplied timestamp (e.g. a
|
|
136
|
+
* whole row spread into the update payload, with a client clock ahead of the
|
|
137
|
+
* server) can never make `isIncomingRowNewer` reject the canonical server response.
|
|
138
|
+
*/
|
|
139
|
+
export const applyEntityRowPatch = (queryClient, tableBlockId, rowId, patch) => {
|
|
140
|
+
const mergePatch = (row) => ({
|
|
141
|
+
...row,
|
|
142
|
+
...patch,
|
|
143
|
+
id: row.id,
|
|
144
|
+
updatedAt: row.updatedAt,
|
|
145
|
+
});
|
|
146
|
+
queryClient.setQueriesData({
|
|
147
|
+
queryKey: entityTableKey(tableBlockId),
|
|
148
|
+
predicate: (query) => isListQueryKey(query.queryKey, tableBlockId),
|
|
149
|
+
}, (oldData) => updateListData(oldData, (oldRows) => {
|
|
150
|
+
const hasMatch = oldRows.some((row) => isValidRow(row) && sameId(row.id, rowId));
|
|
151
|
+
if (!hasMatch) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return oldRows.map((row) => (isValidRow(row) && sameId(row.id, rowId) ? mergePatch(row) : row));
|
|
155
|
+
}));
|
|
156
|
+
queryClient.setQueriesData({
|
|
157
|
+
queryKey: entityTableKey(tableBlockId),
|
|
158
|
+
predicate: (query) => isOneQueryKey(query.queryKey, tableBlockId),
|
|
159
|
+
}, (oldData) => (isValidRow(oldData) && sameId(oldData.id, rowId) ? mergePatch(oldData) : undefined));
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Removes rows from every cached list of the table and nulls out one-queries that
|
|
163
|
+
* held a deleted row. Removal by id is exact for filtered lists too - a deleted row
|
|
164
|
+
* can never re-match a filter.
|
|
165
|
+
*/
|
|
166
|
+
export const applyEntityRowsDeleted = (queryClient, tableBlockId, rowIds) => {
|
|
167
|
+
if (rowIds.length === 0) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const isDeleted = (id) => rowIds.some((rowId) => sameId(rowId, id));
|
|
171
|
+
queryClient.setQueriesData({
|
|
172
|
+
queryKey: entityTableKey(tableBlockId),
|
|
173
|
+
predicate: (query) => isListQueryKey(query.queryKey, tableBlockId),
|
|
174
|
+
}, (oldData) => updateListData(oldData, (oldRows) => {
|
|
175
|
+
const nextRows = oldRows.filter((row) => !(isValidRow(row) && isDeleted(row.id)));
|
|
176
|
+
return nextRows.length !== oldRows.length ? nextRows : undefined;
|
|
177
|
+
}));
|
|
178
|
+
queryClient.setQueriesData({
|
|
179
|
+
queryKey: entityTableKey(tableBlockId),
|
|
180
|
+
predicate: (query) => isOneQueryKey(query.queryKey, tableBlockId),
|
|
181
|
+
}, (oldData) => {
|
|
182
|
+
if (isValidRow(oldData) && isDeleted(oldData.id)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Seeds one-query cache entries for freshly written rows so a follow-up
|
|
190
|
+
* `useEntityGetOne(Entity, { id })` (or `useEntityGetOne(Entity, id)`) renders
|
|
191
|
+
* instantly without a fetch. Seeds both the raw id shape and the parseInt-normalized
|
|
192
|
+
* shape `useEntityGetOne` produces for string/number ids.
|
|
193
|
+
*/
|
|
194
|
+
export const seedEntityOneCaches = (queryClient, tableBlockId, rows) => {
|
|
195
|
+
rows.forEach((row) => {
|
|
196
|
+
const idVariants = new Set([row.id]);
|
|
197
|
+
const numericId = typeof row.id === "string" && /^\d+$/.test(row.id) ? parseInt(row.id, 10) : row.id;
|
|
198
|
+
idVariants.add(numericId);
|
|
199
|
+
idVariants.forEach((id) => {
|
|
200
|
+
queryClient.setQueryData([ENTITIES_QUERY_KEY, tableBlockId, ONE_SEGMENT, { id }], (oldData) => oldData === undefined || oldData === null || (isValidRow(oldData) && isIncomingRowNewer(oldData, row))
|
|
201
|
+
? row
|
|
202
|
+
: undefined);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* Whether the cache holds any query that direct row writes can keep fresh
|
|
208
|
+
* (unfiltered lists or one-queries). When none exist, fetching row data has nothing
|
|
209
|
+
* to update - filtered lists are refetched via invalidation regardless.
|
|
210
|
+
*/
|
|
211
|
+
export const hasDirectlyMaintainedEntityQueries = (queryClient, tableBlockId) => queryClient.getQueryCache().findAll({
|
|
212
|
+
queryKey: entityTableKey(tableBlockId),
|
|
213
|
+
predicate: (query) => isUnfilteredListQueryKey(query.queryKey, tableBlockId) || isOneQueryKey(query.queryKey, tableBlockId),
|
|
214
|
+
}).length > 0;
|
|
215
|
+
export const snapshotEntityQueries = (queryClient, tableBlockId) => queryClient.getQueriesData({ queryKey: entityTableKey(tableBlockId) });
|
|
216
|
+
export const restoreEntityQueriesSnapshot = (queryClient, snapshot) => {
|
|
217
|
+
snapshot.forEach(([queryKey, data]) => {
|
|
218
|
+
if (data !== undefined) {
|
|
219
|
+
queryClient.setQueryData(queryKey, data);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Prepares the table cache for a direct write:
|
|
225
|
+
* - records which queries have a fetch in flight or are already invalidated (a
|
|
226
|
+
* `setQueryData` write would mark them fresh, so they must be re-invalidated
|
|
227
|
+
* afterwards via `invalidateEntityQueriesAfterWrite`),
|
|
228
|
+
* - cancels in-flight fetches of queries that already hold data, so a response
|
|
229
|
+
* snapshotted before the mutation cannot overwrite the rows we are about to write.
|
|
230
|
+
*
|
|
231
|
+
* Fetches of queries without data (initial loads) are left running: cancelling them
|
|
232
|
+
* would strand the query in a non-fetching pending state.
|
|
233
|
+
*/
|
|
234
|
+
export const prepareEntityCacheWrite = async (queryClient, tableBlockId) => {
|
|
235
|
+
const tableKey = entityTableKey(tableBlockId);
|
|
236
|
+
const staleKeys = queryClient
|
|
237
|
+
.getQueryCache()
|
|
238
|
+
.findAll({
|
|
239
|
+
queryKey: tableKey,
|
|
240
|
+
predicate: (query) => query.state.fetchStatus === "fetching" || query.state.isInvalidated,
|
|
241
|
+
})
|
|
242
|
+
.map((query) => query.queryKey);
|
|
243
|
+
await queryClient.cancelQueries({
|
|
244
|
+
queryKey: tableKey,
|
|
245
|
+
predicate: (query) => query.state.data !== undefined,
|
|
246
|
+
});
|
|
247
|
+
return { staleKeys };
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Single invalidation pass that re-syncs everything a direct cache write cannot
|
|
251
|
+
* decide on its own:
|
|
252
|
+
* - filtered lists (membership/order/limit semantics live on the server),
|
|
253
|
+
* - one-queries keyed by non-id filters (an update/create may change which row matches),
|
|
254
|
+
* - queries that had a fetch in flight while we wrote (their in-flight response is
|
|
255
|
+
* of unknowable freshness and may carry an external change we cancelled).
|
|
256
|
+
*
|
|
257
|
+
* Unfiltered lists and id-keyed one-queries are NOT invalidated - they were fully
|
|
258
|
+
* reconciled from the server response, which is what saves the refetch round trips.
|
|
259
|
+
*/
|
|
260
|
+
export const invalidateEntityQueriesAfterWrite = (queryClient, tableBlockId, { staleKeys = [], filteredLists = false, nonIdOneQueries = false, deletedRowIds, }) => {
|
|
261
|
+
const staleHashes = new Set(staleKeys.map((queryKey) => hashKey(queryKey)));
|
|
262
|
+
void queryClient.invalidateQueries({
|
|
263
|
+
queryKey: entityTableKey(tableBlockId),
|
|
264
|
+
predicate: (query) => {
|
|
265
|
+
if (staleHashes.has(hashKey(query.queryKey))) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
if (filteredLists && isFilteredListQueryKey(query.queryKey, tableBlockId)) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
if (nonIdOneQueries &&
|
|
272
|
+
isOneQueryKey(query.queryKey, tableBlockId) &&
|
|
273
|
+
getOneQueryKeyId(query.queryKey) === undefined) {
|
|
274
|
+
if (!deletedRowIds) {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
const data = query.state.data;
|
|
278
|
+
return data === null || (isValidRow(data) && deletedRowIds.some((rowId) => sameId(rowId, data.id)));
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
/** Full-table invalidation - the pre-optimistic behavior, kept as the safety fallback. */
|
|
285
|
+
export const invalidateEntityTable = (queryClient, tableBlockId) => {
|
|
286
|
+
void queryClient.invalidateQueries({ queryKey: entityTableKey(tableBlockId) });
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Optimistic phase of an update/delete mutation: cancel clobbering fetches, snapshot
|
|
290
|
+
* the table cache for rollback, then apply the optimistic change. Never throws - on
|
|
291
|
+
* any failure the optimistic change is rolled back and skipped, and the mutation
|
|
292
|
+
* proceeds (the success/error handlers still reconcile the cache).
|
|
293
|
+
*/
|
|
294
|
+
export const beginEntityOptimisticWrite = async (queryClient, tableBlockId, applyOptimistic) => {
|
|
295
|
+
try {
|
|
296
|
+
const { staleKeys } = await prepareEntityCacheWrite(queryClient, tableBlockId);
|
|
297
|
+
const snapshot = snapshotEntityQueries(queryClient, tableBlockId);
|
|
298
|
+
try {
|
|
299
|
+
applyOptimistic();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// A partially applied optimistic change must not linger in the cache.
|
|
303
|
+
restoreEntityQueriesSnapshot(queryClient, snapshot);
|
|
304
|
+
}
|
|
305
|
+
return { snapshot, staleKeys };
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* Error phase: restore the snapshot taken in `beginEntityOptimisticWrite`, then
|
|
313
|
+
* refetch the table since the server state is unknown after a failed request.
|
|
314
|
+
* `skipInvalidation` is for mutations rejected before any request was sent
|
|
315
|
+
* (e.g. client-side rate limiting) where the restored snapshot is already correct.
|
|
316
|
+
*/
|
|
317
|
+
export const rollbackEntityOptimisticWrite = (queryClient, tableBlockId, context, { skipInvalidation = false } = {}) => {
|
|
318
|
+
try {
|
|
319
|
+
if (context) {
|
|
320
|
+
restoreEntityQueriesSnapshot(queryClient, context.snapshot);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!skipInvalidation) {
|
|
328
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* Success phase of a create/update mutation: writes the canonical rows returned by
|
|
333
|
+
* the server into the cache and invalidates only what a direct write cannot decide
|
|
334
|
+
* (filtered lists, non-id one-queries, queries that were mid-fetch). Falls back to
|
|
335
|
+
* full-table invalidation whenever the response is unusable or anything throws, so
|
|
336
|
+
* the worst case is exactly the previous always-refetch behavior.
|
|
337
|
+
*/
|
|
338
|
+
export const applyEntityCreateResult = async (queryClient, tableBlockId, result, { appendNewToUnfilteredLists, extraStaleKeys = [], }) => {
|
|
339
|
+
try {
|
|
340
|
+
const rows = normalizeRowsResult(result);
|
|
341
|
+
if (rows.length === 0) {
|
|
342
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const { staleKeys } = await prepareEntityCacheWrite(queryClient, tableBlockId);
|
|
346
|
+
applyEntityRowsUpserted(queryClient, tableBlockId, rows, { appendNewToUnfilteredLists });
|
|
347
|
+
seedEntityOneCaches(queryClient, tableBlockId, rows);
|
|
348
|
+
invalidateEntityQueriesAfterWrite(queryClient, tableBlockId, {
|
|
349
|
+
staleKeys: [...extraStaleKeys, ...staleKeys],
|
|
350
|
+
filteredLists: true,
|
|
351
|
+
nonIdOneQueries: true,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Success phase of a delete mutation: removes the rows from every cached query.
|
|
360
|
+
* Filtered lists are still refetched because server-side filters may involve
|
|
361
|
+
* limits/pagination that removal alone cannot reconcile.
|
|
362
|
+
*/
|
|
363
|
+
export const applyEntityDeleteResult = async (queryClient, tableBlockId, rowIds, { extraStaleKeys = [] } = {}) => {
|
|
364
|
+
try {
|
|
365
|
+
const { staleKeys } = await prepareEntityCacheWrite(queryClient, tableBlockId);
|
|
366
|
+
applyEntityRowsDeleted(queryClient, tableBlockId, rowIds);
|
|
367
|
+
invalidateEntityQueriesAfterWrite(queryClient, tableBlockId, {
|
|
368
|
+
staleKeys: [...extraStaleKeys, ...staleKeys],
|
|
369
|
+
filteredLists: true,
|
|
370
|
+
nonIdOneQueries: true,
|
|
371
|
+
deletedRowIds: rowIds,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
export type EntityDataOperation = "insert" | "update" | "delete";
|
|
3
|
+
/** Fetches the current server rows for the given ids (typically `findMany({ id: ids })`). */
|
|
4
|
+
export type EntityRowsByIdsFetcher = (tableBlockId: string, ids: (string | number)[]) => Promise<unknown>;
|
|
5
|
+
export interface EntityDataEventPayload {
|
|
6
|
+
table?: string;
|
|
7
|
+
affectedRowsData?: {
|
|
8
|
+
id?: unknown;
|
|
9
|
+
}[];
|
|
10
|
+
}
|
|
11
|
+
export interface EntityLiveSyncOptions {
|
|
12
|
+
queryClient: QueryClient;
|
|
13
|
+
fetchRowsByIds: EntityRowsByIdsFetcher;
|
|
14
|
+
/** How long to coalesce a burst of events for the same table before syncing once. */
|
|
15
|
+
batchDelayMs?: number;
|
|
16
|
+
/** Above this many affected rows, refetching the queries is cheaper than fetching rows one batch. */
|
|
17
|
+
maxRowFetchCount?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Pulls the canonical state of specific rows from the server and reconciles the
|
|
21
|
+
* cache with it - the "fetch a little data instead of refetching everything" path.
|
|
22
|
+
*
|
|
23
|
+
* - `deleteIds` are removed from every cached query without any fetch.
|
|
24
|
+
* - `insertIds`/`updateIds` are fetched in a single by-id request; the returned rows
|
|
25
|
+
* are written into every cached query (insert-origin rows are appended to
|
|
26
|
+
* unfiltered lists, update-origin rows are only replaced in place).
|
|
27
|
+
* - The fetch is skipped entirely when the cache holds no unfiltered lists or
|
|
28
|
+
* one-queries - there would be nothing for the row data to update.
|
|
29
|
+
* - Requested ids the server no longer returns are treated as deleted (the by-id
|
|
30
|
+
* fetch runs through the same pipeline and policies as lists, so a missing row is
|
|
31
|
+
* either gone or was never visible to this session in the first place).
|
|
32
|
+
* - Filtered lists and non-id one-queries are still invalidated, since membership
|
|
33
|
+
* can only be decided server-side.
|
|
34
|
+
*
|
|
35
|
+
* Any failure falls back to invalidating the whole table, so the worst case is the
|
|
36
|
+
* previous refetch-everything behavior.
|
|
37
|
+
*/
|
|
38
|
+
export declare const syncEntityRowsFromServer: (queryClient: QueryClient, tableBlockId: string, fetchRowsByIds: EntityRowsByIdsFetcher, { insertIds, updateIds, deleteIds, }: {
|
|
39
|
+
insertIds?: (string | number)[];
|
|
40
|
+
updateIds?: (string | number)[];
|
|
41
|
+
deleteIds?: (string | number)[];
|
|
42
|
+
}) => Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Turns websocket data events into minimal cache syncs.
|
|
45
|
+
*
|
|
46
|
+
* Events for the same table are coalesced for `batchDelayMs` and then processed in
|
|
47
|
+
* one pass: one small by-id fetch for inserted/updated rows, zero fetches for
|
|
48
|
+
* deletes, instead of invalidating (and refetching) every query of the table.
|
|
49
|
+
* Events without row ids and oversized batches keep the old full-invalidation
|
|
50
|
+
* behavior.
|
|
51
|
+
*/
|
|
52
|
+
export declare const createEntityLiveSync: ({ queryClient, fetchRowsByIds, batchDelayMs, maxRowFetchCount, }: EntityLiveSyncOptions) => {
|
|
53
|
+
handleDataEvent: (operation: EntityDataOperation, payload: EntityDataEventPayload) => void;
|
|
54
|
+
dispose: () => void;
|
|
55
|
+
};
|
|
56
|
+
export type EntityLiveSync = ReturnType<typeof createEntityLiveSync>;
|
|
57
|
+
//# sourceMappingURL=EntityLiveSync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EntityLiveSync.d.ts","sourceRoot":"","sources":["../src/EntityLiveSync.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAapD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjE,6FAA6F;AAC7F,MAAM,MAAM,sBAAsB,GAAG,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE1G,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gBAAgB,CAAC,EAAE;QAAE,EAAE,CAAC,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;CACvC;AAED,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,WAAW,CAAC;IACzB,cAAc,EAAE,sBAAsB,CAAC;IACvC,qFAAqF;IACrF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qGAAqG;IACrG,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAiBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,wBAAwB,GACnC,aAAa,WAAW,EACxB,cAAc,MAAM,EACpB,gBAAgB,sBAAsB,EACtC,sCAIG;IACD,SAAS,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAChC,SAAS,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAChC,SAAS,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CACjC,KACA,OAAO,CAAC,IAAI,CA6Cd,CAAC;AAkBF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAAI,kEAKlC,qBAAqB;iCA4Ec,mBAAmB,WAAW,sBAAsB,KAAG,IAAI;mBA2B3E,IAAI;CAiBzB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { applyEntityRowsDeleted, applyEntityRowsUpserted, hasDirectlyMaintainedEntityQueries, invalidateEntityQueriesAfterWrite, invalidateEntityTable, normalizeRowsResult, prepareEntityCacheWrite, sameId, } from "./EntityCache.js";
|
|
2
|
+
const DEFAULT_BATCH_DELAY_MS = 150;
|
|
3
|
+
const DEFAULT_MAX_ROW_FETCH_COUNT = 100;
|
|
4
|
+
const isIdValue = (value) => (typeof value === "string" && value.length > 0) || (typeof value === "number" && Number.isFinite(value));
|
|
5
|
+
const extractEventRowIds = (payload) => (payload.affectedRowsData ?? []).map((row) => row?.id).filter(isIdValue);
|
|
6
|
+
const dedupeIds = (ids) => {
|
|
7
|
+
const byKey = new Map();
|
|
8
|
+
ids.forEach((id) => byKey.set(String(id), id));
|
|
9
|
+
return [...byKey.values()];
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Pulls the canonical state of specific rows from the server and reconciles the
|
|
13
|
+
* cache with it - the "fetch a little data instead of refetching everything" path.
|
|
14
|
+
*
|
|
15
|
+
* - `deleteIds` are removed from every cached query without any fetch.
|
|
16
|
+
* - `insertIds`/`updateIds` are fetched in a single by-id request; the returned rows
|
|
17
|
+
* are written into every cached query (insert-origin rows are appended to
|
|
18
|
+
* unfiltered lists, update-origin rows are only replaced in place).
|
|
19
|
+
* - The fetch is skipped entirely when the cache holds no unfiltered lists or
|
|
20
|
+
* one-queries - there would be nothing for the row data to update.
|
|
21
|
+
* - Requested ids the server no longer returns are treated as deleted (the by-id
|
|
22
|
+
* fetch runs through the same pipeline and policies as lists, so a missing row is
|
|
23
|
+
* either gone or was never visible to this session in the first place).
|
|
24
|
+
* - Filtered lists and non-id one-queries are still invalidated, since membership
|
|
25
|
+
* can only be decided server-side.
|
|
26
|
+
*
|
|
27
|
+
* Any failure falls back to invalidating the whole table, so the worst case is the
|
|
28
|
+
* previous refetch-everything behavior.
|
|
29
|
+
*/
|
|
30
|
+
export const syncEntityRowsFromServer = async (queryClient, tableBlockId, fetchRowsByIds, { insertIds = [], updateIds = [], deleteIds = [], }) => {
|
|
31
|
+
try {
|
|
32
|
+
const { staleKeys } = await prepareEntityCacheWrite(queryClient, tableBlockId);
|
|
33
|
+
if (deleteIds.length > 0) {
|
|
34
|
+
applyEntityRowsDeleted(queryClient, tableBlockId, deleteIds);
|
|
35
|
+
}
|
|
36
|
+
// A row that was also deleted in the same batch is gone - no point fetching it.
|
|
37
|
+
const wantedIds = dedupeIds([...insertIds, ...updateIds].filter((id) => !deleteIds.some((deletedId) => sameId(deletedId, id))));
|
|
38
|
+
let missingIds = [];
|
|
39
|
+
// Only fetch row data when the cache holds queries that direct writes can keep
|
|
40
|
+
// fresh - otherwise the invalidation below already covers everything cached.
|
|
41
|
+
if (wantedIds.length > 0 && hasDirectlyMaintainedEntityQueries(queryClient, tableBlockId)) {
|
|
42
|
+
const fetched = normalizeRowsResult(await fetchRowsByIds(tableBlockId, wantedIds));
|
|
43
|
+
// Only trust rows we actually asked for, in case the fetcher over-returns.
|
|
44
|
+
const rows = fetched.filter((row) => wantedIds.some((id) => sameId(id, row.id)));
|
|
45
|
+
const insertRows = rows.filter((row) => insertIds.some((id) => sameId(id, row.id)));
|
|
46
|
+
const updateRows = rows.filter((row) => !insertRows.includes(row));
|
|
47
|
+
applyEntityRowsUpserted(queryClient, tableBlockId, updateRows, { appendNewToUnfilteredLists: false });
|
|
48
|
+
applyEntityRowsUpserted(queryClient, tableBlockId, insertRows, { appendNewToUnfilteredLists: true });
|
|
49
|
+
// Ids the server did not return are gone (deleted or no longer accessible):
|
|
50
|
+
// the by-id fetch runs through the same query pipeline and policies as lists.
|
|
51
|
+
missingIds = wantedIds.filter((id) => !rows.some((row) => sameId(row.id, id)));
|
|
52
|
+
if (missingIds.length > 0) {
|
|
53
|
+
applyEntityRowsDeleted(queryClient, tableBlockId, missingIds);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const allDeletedIds = [...deleteIds, ...missingIds];
|
|
57
|
+
invalidateEntityQueriesAfterWrite(queryClient, tableBlockId, {
|
|
58
|
+
staleKeys,
|
|
59
|
+
filteredLists: true,
|
|
60
|
+
nonIdOneQueries: true,
|
|
61
|
+
deletedRowIds: wantedIds.length === 0 && allDeletedIds.length > 0 ? allDeletedIds : undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
invalidateEntityTable(queryClient, tableBlockId);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Turns websocket data events into minimal cache syncs.
|
|
70
|
+
*
|
|
71
|
+
* Events for the same table are coalesced for `batchDelayMs` and then processed in
|
|
72
|
+
* one pass: one small by-id fetch for inserted/updated rows, zero fetches for
|
|
73
|
+
* deletes, instead of invalidating (and refetching) every query of the table.
|
|
74
|
+
* Events without row ids and oversized batches keep the old full-invalidation
|
|
75
|
+
* behavior.
|
|
76
|
+
*/
|
|
77
|
+
export const createEntityLiveSync = ({ queryClient, fetchRowsByIds, batchDelayMs = DEFAULT_BATCH_DELAY_MS, maxRowFetchCount = DEFAULT_MAX_ROW_FETCH_COUNT, }) => {
|
|
78
|
+
const tables = new Map();
|
|
79
|
+
let disposed = false;
|
|
80
|
+
const getOrCreateState = (table) => {
|
|
81
|
+
const existing = tables.get(table);
|
|
82
|
+
if (existing) {
|
|
83
|
+
return existing;
|
|
84
|
+
}
|
|
85
|
+
const created = {
|
|
86
|
+
insertIds: new Map(),
|
|
87
|
+
updateIds: new Map(),
|
|
88
|
+
deleteIds: new Map(),
|
|
89
|
+
fullInvalidate: false,
|
|
90
|
+
flushing: false,
|
|
91
|
+
};
|
|
92
|
+
tables.set(table, created);
|
|
93
|
+
return created;
|
|
94
|
+
};
|
|
95
|
+
const hasPendingEvents = (state) => state.fullInvalidate || state.insertIds.size > 0 || state.updateIds.size > 0 || state.deleteIds.size > 0;
|
|
96
|
+
const takeBatch = (state) => {
|
|
97
|
+
const batch = {
|
|
98
|
+
insertIds: [...state.insertIds.values()],
|
|
99
|
+
updateIds: [...state.updateIds.values()],
|
|
100
|
+
deleteIds: [...state.deleteIds.values()],
|
|
101
|
+
fullInvalidate: state.fullInvalidate,
|
|
102
|
+
};
|
|
103
|
+
state.insertIds = new Map();
|
|
104
|
+
state.updateIds = new Map();
|
|
105
|
+
state.deleteIds = new Map();
|
|
106
|
+
state.fullInvalidate = false;
|
|
107
|
+
return batch;
|
|
108
|
+
};
|
|
109
|
+
const scheduleFlush = (table, state) => {
|
|
110
|
+
state.timer = setTimeout(() => {
|
|
111
|
+
void flush(table);
|
|
112
|
+
}, batchDelayMs);
|
|
113
|
+
};
|
|
114
|
+
const flush = async (table) => {
|
|
115
|
+
const state = tables.get(table);
|
|
116
|
+
if (!state || state.flushing) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
state.timer = undefined;
|
|
120
|
+
state.flushing = true;
|
|
121
|
+
const batch = takeBatch(state);
|
|
122
|
+
try {
|
|
123
|
+
const rowFetchCount = batch.insertIds.length + batch.updateIds.length;
|
|
124
|
+
if (batch.fullInvalidate || rowFetchCount > maxRowFetchCount) {
|
|
125
|
+
invalidateEntityTable(queryClient, table);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
await syncEntityRowsFromServer(queryClient, table, fetchRowsByIds, batch);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
invalidateEntityTable(queryClient, table);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
state.flushing = false;
|
|
136
|
+
if (disposed || !hasPendingEvents(state)) {
|
|
137
|
+
tables.delete(table);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Events arrived while we were syncing - process them in the next window.
|
|
141
|
+
scheduleFlush(table, state);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const handleDataEvent = (operation, payload) => {
|
|
146
|
+
if (disposed) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const table = payload?.table;
|
|
150
|
+
if (!table) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const state = getOrCreateState(table);
|
|
154
|
+
const ids = extractEventRowIds(payload);
|
|
155
|
+
if (ids.length === 0) {
|
|
156
|
+
// No row ids in the event - we cannot sync selectively.
|
|
157
|
+
state.fullInvalidate = true;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const target = operation === "insert" ? state.insertIds : operation === "update" ? state.updateIds : state.deleteIds;
|
|
161
|
+
ids.forEach((id) => target.set(String(id), id));
|
|
162
|
+
}
|
|
163
|
+
if (!state.timer && !state.flushing) {
|
|
164
|
+
scheduleFlush(table, state);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const dispose = () => {
|
|
168
|
+
disposed = true;
|
|
169
|
+
tables.forEach((state, table) => {
|
|
170
|
+
if (state.timer) {
|
|
171
|
+
clearTimeout(state.timer);
|
|
172
|
+
state.timer = undefined;
|
|
173
|
+
}
|
|
174
|
+
// Pending events cannot be synced during teardown - fall back to marking stale.
|
|
175
|
+
if (hasPendingEvents(state)) {
|
|
176
|
+
invalidateEntityTable(queryClient, table);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
tables.clear();
|
|
180
|
+
};
|
|
181
|
+
return { handleDataEvent, dispose };
|
|
182
|
+
};
|
package/dist/ReactClientSdk.d.ts
CHANGED
|
@@ -86,12 +86,12 @@ export declare const useClient: () => ReactClientSdk;
|
|
|
86
86
|
* @returns {EntityType<E>[] | undefined} returns.data - The fetched entities
|
|
87
87
|
* @returns {boolean} returns.isLoading - Whether the query is loading
|
|
88
88
|
* @returns {Error | null} returns.error - Any error that occurred
|
|
89
|
-
* @returns {Function} returns.refetch -
|
|
89
|
+
* @returns {Function} returns.refetch - Intentional no-op kept for API compatibility; returns the current state without fetching. Data is kept fresh automatically (mutation cache writes + websocket live sync), so manual refetching is unsupported.
|
|
90
90
|
* @returns {boolean} returns.isError - Whether the query resulted in an error
|
|
91
91
|
* @returns {boolean} returns.isFetched - Whether the query has been fetched
|
|
92
92
|
* @returns {boolean} returns.isFetching - Whether the query is currently fetching
|
|
93
93
|
* @returns {boolean} returns.isSuccess - Whether the query was successful
|
|
94
|
-
* @returns {string} returns.status - Current status of the query: '
|
|
94
|
+
* @returns {string} returns.status - Current status of the query: 'pending', 'error', or 'success'
|
|
95
95
|
* @example
|
|
96
96
|
* ```tsx
|
|
97
97
|
* import { ItemEntity } from '@/product-types';
|
|
@@ -121,7 +121,7 @@ export declare const useEntityGetAll: <E extends EntityConfig>(entityConfig: E,
|
|
|
121
121
|
isFetched: boolean;
|
|
122
122
|
isFetching: boolean;
|
|
123
123
|
isSuccess: boolean;
|
|
124
|
-
status: "
|
|
124
|
+
status: "pending" | "error" | "success";
|
|
125
125
|
};
|
|
126
126
|
/**
|
|
127
127
|
* Hook to fetch a single entity by filters
|
|
@@ -139,12 +139,12 @@ export declare const useEntityGetAll: <E extends EntityConfig>(entityConfig: E,
|
|
|
139
139
|
* @returns {EntityType<EC> | undefined | null} returns.data - The fetched entity
|
|
140
140
|
* @returns {boolean} returns.isLoading - Whether the query is loading
|
|
141
141
|
* @returns {Error | null} returns.error - Any error that occurred
|
|
142
|
-
* @returns {Function} returns.refetch -
|
|
142
|
+
* @returns {Function} returns.refetch - Intentional no-op kept for API compatibility; returns the current state without fetching. Data is kept fresh automatically (mutation cache writes + websocket live sync), so manual refetching is unsupported.
|
|
143
143
|
* @returns {boolean} returns.isError - Whether the query resulted in an error
|
|
144
144
|
* @returns {boolean} returns.isFetched - Whether the query has been fetched
|
|
145
145
|
* @returns {boolean} returns.isFetching - Whether the query is currently fetching
|
|
146
146
|
* @returns {boolean} returns.isSuccess - Whether the query was successful
|
|
147
|
-
* @returns {string} returns.status - Current status of the query: '
|
|
147
|
+
* @returns {string} returns.status - Current status of the query: 'pending', 'error', or 'success'
|
|
148
148
|
* @example
|
|
149
149
|
* ```tsx
|
|
150
150
|
* import { UserEntity } from '@/product-types';
|
|
@@ -171,13 +171,15 @@ export declare const useEntityGetOne: <EC extends EntityConfig = EntityConfig>(e
|
|
|
171
171
|
isFetched: boolean;
|
|
172
172
|
isFetching: boolean;
|
|
173
173
|
isSuccess: boolean;
|
|
174
|
-
status: "
|
|
174
|
+
status: "pending" | "error" | "success";
|
|
175
175
|
};
|
|
176
176
|
/**
|
|
177
177
|
* Hook to create a new entity
|
|
178
178
|
*
|
|
179
179
|
* Creates a new entity instance in the data store.
|
|
180
180
|
* EntityTypeOnlyMutable<EC> represents the writable properties of the entity.
|
|
181
|
+
* The created entity returned by the server is written directly into the query cache,
|
|
182
|
+
* so `useEntityGetAll`/`useEntityGetOne` consumers update instantly without refetching.
|
|
181
183
|
*
|
|
182
184
|
* @template EC - Entity configuration type
|
|
183
185
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -226,7 +228,8 @@ export declare const useEntityCreate: <EC extends EntityConfig = EntityConfig>(e
|
|
|
226
228
|
* Creates multiple entity instances in the data store in one batch operation.
|
|
227
229
|
* This is more efficient than calling useEntityCreate multiple times when you need
|
|
228
230
|
* to create several entities at once. EntityTypeOnlyMutable<EC> represents the
|
|
229
|
-
* writable properties of the entity.
|
|
231
|
+
* writable properties of the entity. The created entities returned by the server are
|
|
232
|
+
* written directly into the query cache, so consumers update without refetching.
|
|
230
233
|
*
|
|
231
234
|
* @template EC - Entity configuration type
|
|
232
235
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -269,6 +272,8 @@ export declare const useEntityCreateMany: <EC extends EntityConfig = EntityConfi
|
|
|
269
272
|
*
|
|
270
273
|
* Updates an existing entity with the provided partial data.
|
|
271
274
|
* Only the properties included in the data object will be updated.
|
|
275
|
+
* The change is applied to the query cache optimistically (and rolled back on error),
|
|
276
|
+
* then confirmed with the canonical row returned by the server - no refetch needed.
|
|
272
277
|
*
|
|
273
278
|
* @template EC - Entity configuration type
|
|
274
279
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -310,7 +315,8 @@ export declare const useEntityUpdate: <EC extends EntityConfig = EntityConfig>(e
|
|
|
310
315
|
* Hook to delete an entity
|
|
311
316
|
*
|
|
312
317
|
* Permanently removes an entity from the data store by its ID.
|
|
313
|
-
*
|
|
318
|
+
* The row is removed from the query cache optimistically (and restored on error),
|
|
319
|
+
* so dependent queries update instantly without refetching.
|
|
314
320
|
*
|
|
315
321
|
* @template EC - Entity configuration type
|
|
316
322
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -347,7 +353,8 @@ export declare const useEntityDelete: <EC extends EntityConfig = EntityConfig>(e
|
|
|
347
353
|
* Hook to delete multiple entities at once
|
|
348
354
|
*
|
|
349
355
|
* Permanently removes multiple entities from the data store by their IDs.
|
|
350
|
-
*
|
|
356
|
+
* The rows are removed from the query cache optimistically (and restored on error),
|
|
357
|
+
* so dependent queries update instantly without refetching.
|
|
351
358
|
*
|
|
352
359
|
* @template EC - Entity configuration type
|
|
353
360
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ReactClientSdk.d.ts","sourceRoot":"","sources":["../src/ReactClientSdk.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"ReactClientSdk.d.ts","sourceRoot":"","sources":["../src/ReactClientSdk.tsx"],"names":[],"mappings":"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"}
|
package/dist/ReactClientSdk.jsx
CHANGED
|
@@ -5,10 +5,13 @@ import { useAiStreamChunks } from "@blocksdiy/react-common/useAiStreamChunks";
|
|
|
5
5
|
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
6
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
7
7
|
import { ClientSdk } from "./ClientSdk.js";
|
|
8
|
+
import { applyEntityCreateResult, applyEntityDeleteResult, applyEntityRowPatch, applyEntityRowsDeleted, beginEntityOptimisticWrite, ENTITIES_QUERY_KEY, rollbackEntityOptimisticWrite, } from "./EntityCache.js";
|
|
9
|
+
import { createEntityLiveSync } from "./EntityLiveSync.js";
|
|
8
10
|
export class ReactClientSdk extends ClientSdk {
|
|
9
11
|
}
|
|
10
|
-
const ENTITIES_QUERY_KEY = "entities";
|
|
11
12
|
const CURRENT_USER_QUERY_KEY = "currentUser";
|
|
13
|
+
// Avoids redundant refetches on quick remounts and window focus flaps without meaningfully delaying anything.
|
|
14
|
+
const ENTITIES_STALE_TIME = 5_000;
|
|
12
15
|
const ClientContext = createContext(null);
|
|
13
16
|
const ThemeModeContext = createContext({
|
|
14
17
|
themeMode: "system",
|
|
@@ -157,7 +160,17 @@ const useEntityRowMutationRateLimiting = (entityConfig, functionName) => {
|
|
|
157
160
|
* ```
|
|
158
161
|
*/
|
|
159
162
|
export const ClientProvider = ({ client, children, themeMode, setThemeMode, }) => {
|
|
160
|
-
const [queryClient] = useState(() => new QueryClient(
|
|
163
|
+
const [queryClient] = useState(() => new QueryClient({
|
|
164
|
+
defaultOptions: {
|
|
165
|
+
queries: {
|
|
166
|
+
staleTime: ENTITIES_STALE_TIME,
|
|
167
|
+
// TEMP: disable React Query's default refetch-on-window-focus so switching
|
|
168
|
+
// browser tabs never triggers fetches. Remove this line to restore the
|
|
169
|
+
// default focus revalidation (refetch stale queries on refocus).
|
|
170
|
+
refetchOnWindowFocus: false,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
161
174
|
const user = client.getUser();
|
|
162
175
|
const userIdInNumber = useMemo(() => {
|
|
163
176
|
if (user?.id) {
|
|
@@ -171,22 +184,25 @@ export const ClientProvider = ({ client, children, themeMode, setThemeMode, }) =
|
|
|
171
184
|
}, [user?.id]);
|
|
172
185
|
useWebsockets({ userId: userIdInNumber, token: client.token });
|
|
173
186
|
useWebsocketsAppSubscribe({ appId: client.appId });
|
|
174
|
-
|
|
175
|
-
queryClient.invalidateQueries({ queryKey: [ENTITIES_QUERY_KEY, data.table] });
|
|
176
|
-
data.affectRows?.forEach((row) => {
|
|
177
|
-
queryClient.invalidateQueries({ queryKey: [ENTITIES_QUERY_KEY, data.table, "one", { id: row.id }] });
|
|
178
|
-
});
|
|
179
|
-
}, [queryClient]);
|
|
187
|
+
// Websocket events update cache for specific rows after external changes
|
|
180
188
|
useEffect(() => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
189
|
+
const liveSync = createEntityLiveSync({
|
|
190
|
+
queryClient,
|
|
191
|
+
fetchRowsByIds: (tableBlockId, ids) => client.entity({ tableBlockId, instanceType: {} }).findMany({ id: ids }),
|
|
192
|
+
});
|
|
193
|
+
const onInsert = async (data) => liveSync.handleDataEvent("insert", data);
|
|
194
|
+
const onUpdate = async (data) => liveSync.handleDataEvent("update", data);
|
|
195
|
+
const onDelete = async (data) => liveSync.handleDataEvent("delete", data);
|
|
196
|
+
websocketsService.listen(AppDataEventTypes.APP_DATA_INSERT, onInsert);
|
|
197
|
+
websocketsService.listen(AppDataEventTypes.APP_DATA_UPDATE, onUpdate);
|
|
198
|
+
websocketsService.listen(AppDataEventTypes.APP_DATA_DELETE, onDelete);
|
|
184
199
|
return () => {
|
|
185
|
-
websocketsService.unlisten(AppDataEventTypes.APP_DATA_INSERT,
|
|
186
|
-
websocketsService.unlisten(AppDataEventTypes.APP_DATA_UPDATE,
|
|
187
|
-
websocketsService.unlisten(AppDataEventTypes.APP_DATA_DELETE,
|
|
200
|
+
websocketsService.unlisten(AppDataEventTypes.APP_DATA_INSERT, onInsert);
|
|
201
|
+
websocketsService.unlisten(AppDataEventTypes.APP_DATA_UPDATE, onUpdate);
|
|
202
|
+
websocketsService.unlisten(AppDataEventTypes.APP_DATA_DELETE, onDelete);
|
|
203
|
+
liveSync.dispose();
|
|
188
204
|
};
|
|
189
|
-
}, [
|
|
205
|
+
}, [queryClient, client]);
|
|
190
206
|
return (<ClientContext value={client}>
|
|
191
207
|
<ThemeModeContext value={{ themeMode, setThemeMode }}>
|
|
192
208
|
{/* <QueryClientProvider client={queryClient}>
|
|
@@ -244,12 +260,12 @@ export const useClient = () => {
|
|
|
244
260
|
* @returns {EntityType<E>[] | undefined} returns.data - The fetched entities
|
|
245
261
|
* @returns {boolean} returns.isLoading - Whether the query is loading
|
|
246
262
|
* @returns {Error | null} returns.error - Any error that occurred
|
|
247
|
-
* @returns {Function} returns.refetch -
|
|
263
|
+
* @returns {Function} returns.refetch - Intentional no-op kept for API compatibility; returns the current state without fetching. Data is kept fresh automatically (mutation cache writes + websocket live sync), so manual refetching is unsupported.
|
|
248
264
|
* @returns {boolean} returns.isError - Whether the query resulted in an error
|
|
249
265
|
* @returns {boolean} returns.isFetched - Whether the query has been fetched
|
|
250
266
|
* @returns {boolean} returns.isFetching - Whether the query is currently fetching
|
|
251
267
|
* @returns {boolean} returns.isSuccess - Whether the query was successful
|
|
252
|
-
* @returns {string} returns.status - Current status of the query: '
|
|
268
|
+
* @returns {string} returns.status - Current status of the query: 'pending', 'error', or 'success'
|
|
253
269
|
* @example
|
|
254
270
|
* ```tsx
|
|
255
271
|
* import { ItemEntity } from '@/product-types';
|
|
@@ -280,6 +296,8 @@ export const useEntityGetAll = (entityConfig, filters, queryOptions = {}) => {
|
|
|
280
296
|
data,
|
|
281
297
|
isLoading,
|
|
282
298
|
error,
|
|
299
|
+
// Deliberately NOT React Query's refetch: app code must not be able to force
|
|
300
|
+
// refetches - the cache stays fresh via mutation writes and websocket live sync.
|
|
283
301
|
refetch: () => ({ data, isLoading, error, isError, isFetched, isFetching, isSuccess, status }),
|
|
284
302
|
isError,
|
|
285
303
|
isFetched,
|
|
@@ -304,12 +322,12 @@ export const useEntityGetAll = (entityConfig, filters, queryOptions = {}) => {
|
|
|
304
322
|
* @returns {EntityType<EC> | undefined | null} returns.data - The fetched entity
|
|
305
323
|
* @returns {boolean} returns.isLoading - Whether the query is loading
|
|
306
324
|
* @returns {Error | null} returns.error - Any error that occurred
|
|
307
|
-
* @returns {Function} returns.refetch -
|
|
325
|
+
* @returns {Function} returns.refetch - Intentional no-op kept for API compatibility; returns the current state without fetching. Data is kept fresh automatically (mutation cache writes + websocket live sync), so manual refetching is unsupported.
|
|
308
326
|
* @returns {boolean} returns.isError - Whether the query resulted in an error
|
|
309
327
|
* @returns {boolean} returns.isFetched - Whether the query has been fetched
|
|
310
328
|
* @returns {boolean} returns.isFetching - Whether the query is currently fetching
|
|
311
329
|
* @returns {boolean} returns.isSuccess - Whether the query was successful
|
|
312
|
-
* @returns {string} returns.status - Current status of the query: '
|
|
330
|
+
* @returns {string} returns.status - Current status of the query: 'pending', 'error', or 'success'
|
|
313
331
|
* @example
|
|
314
332
|
* ```tsx
|
|
315
333
|
* import { UserEntity } from '@/product-types';
|
|
@@ -344,6 +362,8 @@ export const useEntityGetOne = (entityConfig, filters, queryOptions = {}) => {
|
|
|
344
362
|
data,
|
|
345
363
|
isLoading,
|
|
346
364
|
error,
|
|
365
|
+
// Deliberately NOT React Query's refetch: app code must not be able to force
|
|
366
|
+
// refetches - the cache stays fresh via mutation writes and websocket live sync.
|
|
347
367
|
refetch: () => ({ data, isLoading, error, isError, isFetched, isFetching, isSuccess, status }),
|
|
348
368
|
isError,
|
|
349
369
|
isFetched,
|
|
@@ -357,6 +377,8 @@ export const useEntityGetOne = (entityConfig, filters, queryOptions = {}) => {
|
|
|
357
377
|
*
|
|
358
378
|
* Creates a new entity instance in the data store.
|
|
359
379
|
* EntityTypeOnlyMutable<EC> represents the writable properties of the entity.
|
|
380
|
+
* The created entity returned by the server is written directly into the query cache,
|
|
381
|
+
* so `useEntityGetAll`/`useEntityGetOne` consumers update instantly without refetching.
|
|
360
382
|
*
|
|
361
383
|
* @template EC - Entity configuration type
|
|
362
384
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -401,9 +423,11 @@ export const useEntityCreate = (entityConfig) => {
|
|
|
401
423
|
validateExceededMaxCount({ data });
|
|
402
424
|
return client.entity(entityConfig).create(data);
|
|
403
425
|
},
|
|
404
|
-
onSuccess: () => {
|
|
405
|
-
//
|
|
406
|
-
queryClient
|
|
426
|
+
onSuccess: async (result) => {
|
|
427
|
+
// The server returns the canonical created row, so write it into the cache
|
|
428
|
+
await applyEntityCreateResult(queryClient, entityConfig.tableBlockId, result, {
|
|
429
|
+
appendNewToUnfilteredLists: true,
|
|
430
|
+
});
|
|
407
431
|
},
|
|
408
432
|
});
|
|
409
433
|
if (error instanceof FatalAppError) {
|
|
@@ -417,7 +441,8 @@ export const useEntityCreate = (entityConfig) => {
|
|
|
417
441
|
* Creates multiple entity instances in the data store in one batch operation.
|
|
418
442
|
* This is more efficient than calling useEntityCreate multiple times when you need
|
|
419
443
|
* to create several entities at once. EntityTypeOnlyMutable<EC> represents the
|
|
420
|
-
* writable properties of the entity.
|
|
444
|
+
* writable properties of the entity. The created entities returned by the server are
|
|
445
|
+
* written directly into the query cache, so consumers update without refetching.
|
|
421
446
|
*
|
|
422
447
|
* @template EC - Entity configuration type
|
|
423
448
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -457,9 +482,11 @@ export const useEntityCreateMany = (entityConfig) => {
|
|
|
457
482
|
validateExceededMaxCount({ data });
|
|
458
483
|
return client.entity(entityConfig).createMany(data);
|
|
459
484
|
},
|
|
460
|
-
onSuccess: () => {
|
|
461
|
-
//
|
|
462
|
-
queryClient
|
|
485
|
+
onSuccess: async (result) => {
|
|
486
|
+
// The server returns the canonical created rows, so write them into the cache
|
|
487
|
+
await applyEntityCreateResult(queryClient, entityConfig.tableBlockId, result, {
|
|
488
|
+
appendNewToUnfilteredLists: true,
|
|
489
|
+
});
|
|
463
490
|
},
|
|
464
491
|
});
|
|
465
492
|
if (error instanceof FatalAppError) {
|
|
@@ -472,6 +499,8 @@ export const useEntityCreateMany = (entityConfig) => {
|
|
|
472
499
|
*
|
|
473
500
|
* Updates an existing entity with the provided partial data.
|
|
474
501
|
* Only the properties included in the data object will be updated.
|
|
502
|
+
* The change is applied to the query cache optimistically (and rolled back on error),
|
|
503
|
+
* then confirmed with the canonical row returned by the server - no refetch needed.
|
|
475
504
|
*
|
|
476
505
|
* @template EC - Entity configuration type
|
|
477
506
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -506,14 +535,28 @@ export const useEntityUpdate = (entityConfig) => {
|
|
|
506
535
|
const queryClient = useQueryClient();
|
|
507
536
|
const { validateExceededMaxCount } = useEntityRowMutationRateLimiting(entityConfig, "useEntityUpdate");
|
|
508
537
|
const { mutateAsync: updateFunction, isPending: isLoading, error, } = useMutation({
|
|
538
|
+
onMutate: ({ id, data }) =>
|
|
539
|
+
// Optimistically patch every cached copy of the row for instant UI feedback.
|
|
540
|
+
beginEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, () => {
|
|
541
|
+
applyEntityRowPatch(queryClient, entityConfig.tableBlockId, id, data);
|
|
542
|
+
}),
|
|
509
543
|
mutationFn: ({ id, data }) => {
|
|
510
544
|
validateExceededMaxCount({ rowId: id, data });
|
|
511
545
|
return client.entity(entityConfig).updateOne(id, data);
|
|
512
546
|
},
|
|
513
|
-
onSuccess: (
|
|
514
|
-
//
|
|
515
|
-
queryClient
|
|
516
|
-
|
|
547
|
+
onSuccess: async (result, _variables, context) => {
|
|
548
|
+
// Replace the optimistic patch with the canonical updated row from the server
|
|
549
|
+
await applyEntityCreateResult(queryClient, entityConfig.tableBlockId, result, {
|
|
550
|
+
appendNewToUnfilteredLists: false,
|
|
551
|
+
extraStaleKeys: context?.staleKeys,
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
onError: (mutationError, _variables, context) => {
|
|
555
|
+
rollbackEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, context, {
|
|
556
|
+
// Rate-limit rejections happen before the request is sent, so the restored
|
|
557
|
+
// snapshot is already the correct server state.
|
|
558
|
+
skipInvalidation: mutationError instanceof FatalAppError,
|
|
559
|
+
});
|
|
517
560
|
},
|
|
518
561
|
});
|
|
519
562
|
if (error instanceof FatalAppError) {
|
|
@@ -525,7 +568,8 @@ export const useEntityUpdate = (entityConfig) => {
|
|
|
525
568
|
* Hook to delete an entity
|
|
526
569
|
*
|
|
527
570
|
* Permanently removes an entity from the data store by its ID.
|
|
528
|
-
*
|
|
571
|
+
* The row is removed from the query cache optimistically (and restored on error),
|
|
572
|
+
* so dependent queries update instantly without refetching.
|
|
529
573
|
*
|
|
530
574
|
* @template EC - Entity configuration type
|
|
531
575
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -556,14 +600,25 @@ export const useEntityDelete = (entityConfig) => {
|
|
|
556
600
|
const queryClient = useQueryClient();
|
|
557
601
|
const { validateExceededMaxCount } = useEntityRowMutationRateLimiting(entityConfig, "useEntityDelete");
|
|
558
602
|
const { mutateAsync: deleteFunction, isPending: isLoading, error, } = useMutation({
|
|
603
|
+
onMutate: ({ id }) =>
|
|
604
|
+
// Optimistically remove the row from every cached query for instant UI feedback.
|
|
605
|
+
beginEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, () => {
|
|
606
|
+
applyEntityRowsDeleted(queryClient, entityConfig.tableBlockId, [id]);
|
|
607
|
+
}),
|
|
559
608
|
mutationFn: ({ id }) => {
|
|
560
609
|
validateExceededMaxCount({ rowId: id });
|
|
561
610
|
return client.entity(entityConfig).deleteOne(id);
|
|
562
611
|
},
|
|
563
|
-
onSuccess: (_result, { id }) => {
|
|
564
|
-
//
|
|
565
|
-
queryClient
|
|
566
|
-
|
|
612
|
+
onSuccess: async (_result, { id }, context) => {
|
|
613
|
+
// Confirm the removal directly in the cache instead of refetching every query.
|
|
614
|
+
await applyEntityDeleteResult(queryClient, entityConfig.tableBlockId, [id], {
|
|
615
|
+
extraStaleKeys: context?.staleKeys,
|
|
616
|
+
});
|
|
617
|
+
},
|
|
618
|
+
onError: (mutationError, _variables, context) => {
|
|
619
|
+
rollbackEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, context, {
|
|
620
|
+
skipInvalidation: mutationError instanceof FatalAppError,
|
|
621
|
+
});
|
|
567
622
|
},
|
|
568
623
|
});
|
|
569
624
|
if (error instanceof FatalAppError) {
|
|
@@ -575,7 +630,8 @@ export const useEntityDelete = (entityConfig) => {
|
|
|
575
630
|
* Hook to delete multiple entities at once
|
|
576
631
|
*
|
|
577
632
|
* Permanently removes multiple entities from the data store by their IDs.
|
|
578
|
-
*
|
|
633
|
+
* The rows are removed from the query cache optimistically (and restored on error),
|
|
634
|
+
* so dependent queries update instantly without refetching.
|
|
579
635
|
*
|
|
580
636
|
* @template EC - Entity configuration type
|
|
581
637
|
* @param {EC} entityConfig - Configuration for the entity type
|
|
@@ -606,14 +662,24 @@ export const useEntityDeleteMany = (entityConfig) => {
|
|
|
606
662
|
const queryClient = useQueryClient();
|
|
607
663
|
const { validateExceededMaxCount } = useEntityMutationRateLimiting(entityConfig, "useEntityDeleteMany");
|
|
608
664
|
const { mutateAsync: deleteManyFunction, isPending: isLoading, error, } = useMutation({
|
|
665
|
+
onMutate: ({ ids }) =>
|
|
666
|
+
// Optimistically remove the rows from every cached query for instant UI feedback.
|
|
667
|
+
beginEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, () => {
|
|
668
|
+
applyEntityRowsDeleted(queryClient, entityConfig.tableBlockId, ids);
|
|
669
|
+
}),
|
|
609
670
|
mutationFn: ({ ids }) => {
|
|
610
671
|
validateExceededMaxCount({ data: ids });
|
|
611
672
|
return client.entity(entityConfig).deleteMany(ids);
|
|
612
673
|
},
|
|
613
|
-
onSuccess: (_result, { ids }) => {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
674
|
+
onSuccess: async (_result, { ids }, context) => {
|
|
675
|
+
// Confirm the removal directly in the cache instead of refetching every query.
|
|
676
|
+
await applyEntityDeleteResult(queryClient, entityConfig.tableBlockId, ids, {
|
|
677
|
+
extraStaleKeys: context?.staleKeys,
|
|
678
|
+
});
|
|
679
|
+
},
|
|
680
|
+
onError: (mutationError, _variables, context) => {
|
|
681
|
+
rollbackEntityOptimisticWrite(queryClient, entityConfig.tableBlockId, context, {
|
|
682
|
+
skipInvalidation: mutationError instanceof FatalAppError,
|
|
617
683
|
});
|
|
618
684
|
},
|
|
619
685
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blocksdiy/blocks-client-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Blocks client sdk",
|
|
6
6
|
"keywords": [],
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@tanstack/react-query": "^5.
|
|
50
|
-
"@blocksdiy/blocks-client-api": "1.
|
|
51
|
-
"@blocksdiy/react-common": "1.
|
|
49
|
+
"@tanstack/react-query": "^5.101.0",
|
|
50
|
+
"@blocksdiy/blocks-client-api": "1.5.0",
|
|
51
|
+
"@blocksdiy/react-common": "1.5.2"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"tsc-alias": "^1.8.10",
|