@classytic/arc-next 0.5.0 → 0.7.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/README.md +148 -9
- package/dist/api.d.ts +59 -75
- package/dist/api.js +25 -6
- package/dist/cache.d.ts +48 -4
- package/dist/cache.js +132 -26
- package/dist/client.d.ts +340 -72
- package/dist/client.js +500 -91
- package/dist/hooks.d.ts +72 -8
- package/dist/hooks.js +109 -33
- package/dist/prefetch.d.ts +23 -0
- package/dist/prefetch.js +20 -0
- package/dist/presets/bulk.d.ts +5 -4
- package/dist/presets/search.d.ts +5 -4
- package/dist/presets/slug.d.ts +2 -2
- package/dist/presets/soft-delete.d.ts +4 -3
- package/dist/presets/tree.d.ts +4 -3
- package/dist/query.d.ts +87 -32
- package/dist/query.js +72 -46
- package/dist/sse.js +71 -5
- package/dist/upload.js +33 -2
- package/dist/ws.js +42 -5
- package/package.json +9 -2
package/dist/cache.js
CHANGED
|
@@ -7,28 +7,32 @@ const DEFAULT_QUERY_CONFIG = {
|
|
|
7
7
|
};
|
|
8
8
|
/** Pre-built query config presets for common data freshness patterns. */
|
|
9
9
|
const QUERY_CONFIGS = {
|
|
10
|
+
/** Live data: 20s stale, 30s polling */
|
|
10
11
|
realtime: {
|
|
11
12
|
staleTime: 2e4,
|
|
12
13
|
refetchInterval: 3e4
|
|
13
14
|
},
|
|
15
|
+
/** Frequently updated: 60s stale */
|
|
14
16
|
frequent: { staleTime: 6e4 },
|
|
17
|
+
/** Stable data: 5min stale (same as default) */
|
|
15
18
|
stable: { staleTime: 3e5 },
|
|
19
|
+
/** Rarely changes: 10min stale */
|
|
16
20
|
static: { staleTime: 6e5 }
|
|
17
21
|
};
|
|
18
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Well-known keys checked in order for list responses.
|
|
24
|
+
*
|
|
25
|
+
* Arc emits `{data: T[]}` for both paginated and bare-list endpoints, so
|
|
26
|
+
* `docs` is the canonical key. `items` / `results` cover non-arc backends
|
|
27
|
+
* the permissive detector still supports — the any-array fallback below
|
|
28
|
+
* keeps `{products: [...]}` / `{users: [...]}` working without per-resource
|
|
29
|
+
* configuration.
|
|
30
|
+
*/
|
|
19
31
|
const LIST_KEYS = [
|
|
20
|
-
"docs",
|
|
21
32
|
"data",
|
|
22
33
|
"items",
|
|
23
34
|
"results"
|
|
24
35
|
];
|
|
25
|
-
/** Well-known keys checked in order for detail responses. */
|
|
26
|
-
const DETAIL_KEYS = [
|
|
27
|
-
"data",
|
|
28
|
-
"doc",
|
|
29
|
-
"item",
|
|
30
|
-
"result"
|
|
31
|
-
];
|
|
32
36
|
/**
|
|
33
37
|
* Extract `_id` or `id` from any item. Returns `null` if neither exists.
|
|
34
38
|
* Coerces numeric IDs to strings so cache keys stay consistent.
|
|
@@ -78,16 +82,14 @@ function extractItems(data) {
|
|
|
78
82
|
return [];
|
|
79
83
|
}
|
|
80
84
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
85
|
+
* Detail extractor. Arc emits the doc directly (no envelope wrapper) — this
|
|
86
|
+
* function is identity-with-null-guard. Kept as a named helper so callers
|
|
87
|
+
* have a stable seam if a future backend ever ships an envelope, and so
|
|
88
|
+
* `null` / `undefined` responses normalize to `null` consistently.
|
|
84
89
|
*/
|
|
85
90
|
function extractItem(data) {
|
|
86
91
|
if (data == null) return null;
|
|
87
|
-
|
|
88
|
-
const d = data;
|
|
89
|
-
for (const key of DETAIL_KEYS) if (d[key] != null) return d[key];
|
|
90
|
-
return d;
|
|
92
|
+
return data;
|
|
91
93
|
}
|
|
92
94
|
/**
|
|
93
95
|
* Optimistic-update helper that mutates the items array of a list cache
|
|
@@ -113,6 +115,7 @@ function updateListCache(listData, updater) {
|
|
|
113
115
|
if (!arrayField) return listData;
|
|
114
116
|
const updated = updater(d[arrayField]);
|
|
115
117
|
const original = d[arrayField];
|
|
118
|
+
if (updated === original) return listData;
|
|
116
119
|
const delta = updated.length - original.length;
|
|
117
120
|
const result = {
|
|
118
121
|
...d,
|
|
@@ -124,6 +127,95 @@ function updateListCache(listData, updater) {
|
|
|
124
127
|
}
|
|
125
128
|
return result;
|
|
126
129
|
}
|
|
130
|
+
/** Item-identity helper that respects a custom idField, falling back to `_id` / `id`. */
|
|
131
|
+
function resolveId(item, idField) {
|
|
132
|
+
if (!item || typeof item !== "object") return null;
|
|
133
|
+
if (idField) {
|
|
134
|
+
const v = item[idField];
|
|
135
|
+
if (v != null) return String(v);
|
|
136
|
+
}
|
|
137
|
+
return getItemId(item);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Shallow-merge updater that preserves the receiver's key set. Detail payloads
|
|
141
|
+
* are often richer than list payloads (populated relations, full body fields).
|
|
142
|
+
* If we replaced wholesale we'd bloat list caches with detail-only fields and
|
|
143
|
+
* force a full re-render on every value change. Merge keeps lists lean while
|
|
144
|
+
* still picking up updates to fields the list already cares about.
|
|
145
|
+
*/
|
|
146
|
+
function shallowMergeKept(receiver, source) {
|
|
147
|
+
let changed = false;
|
|
148
|
+
const next = { ...receiver };
|
|
149
|
+
for (const key of Object.keys(receiver)) if (key in source && !Object.is(receiver[key], source[key])) {
|
|
150
|
+
next[key] = source[key];
|
|
151
|
+
changed = true;
|
|
152
|
+
}
|
|
153
|
+
return changed ? next : receiver;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* After a detail fetch lands, propagate the fresh values into every cached
|
|
157
|
+
* list entry that contains this item. The item's ID is resolved via
|
|
158
|
+
* `idField`, falling back to `_id` / `id`. Handles both flat list payloads
|
|
159
|
+
* and infinite-query page arrays.
|
|
160
|
+
*
|
|
161
|
+
* Only updates EXISTING list entries — never creates new caches or new items
|
|
162
|
+
* in lists. If the item moved between filter buckets (e.g. status changed),
|
|
163
|
+
* the relevant lists will refetch on their own; we don't try to predict
|
|
164
|
+
* filter membership.
|
|
165
|
+
*
|
|
166
|
+
* Returns the number of cache entries updated (useful for tests + telemetry).
|
|
167
|
+
*
|
|
168
|
+
* @param qc TanStack QueryClient
|
|
169
|
+
* @param listsKey Prefix key for this entity's lists (typically `KEYS.lists()`)
|
|
170
|
+
* @param item Fresh entity to merge into matching list items
|
|
171
|
+
* @param opts.idField Custom ID field name (defaults to `_id` / `id` lookup)
|
|
172
|
+
*/
|
|
173
|
+
function syncDetailToLists(qc, listsKey, item, opts = {}) {
|
|
174
|
+
const targetId = resolveId(item, opts.idField);
|
|
175
|
+
if (!targetId) return 0;
|
|
176
|
+
let updates = 0;
|
|
177
|
+
const entries = qc.getQueriesData({ queryKey: listsKey });
|
|
178
|
+
for (const [qKey, raw] of entries) {
|
|
179
|
+
if (!raw) continue;
|
|
180
|
+
if (typeof raw === "object" && raw !== null && Array.isArray(raw.pages)) {
|
|
181
|
+
const inf = raw;
|
|
182
|
+
let pagesChanged = false;
|
|
183
|
+
const nextPages = inf.pages.map((page) => {
|
|
184
|
+
const merged = mergeItemIntoListPage(page, targetId, item, opts.idField);
|
|
185
|
+
if (merged !== page) pagesChanged = true;
|
|
186
|
+
return merged;
|
|
187
|
+
});
|
|
188
|
+
if (pagesChanged) {
|
|
189
|
+
qc.setQueryData(qKey, {
|
|
190
|
+
...inf,
|
|
191
|
+
pages: nextPages
|
|
192
|
+
});
|
|
193
|
+
updates += 1;
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const merged = mergeItemIntoListPage(raw, targetId, item, opts.idField);
|
|
198
|
+
if (merged !== raw) {
|
|
199
|
+
qc.setQueryData(qKey, merged);
|
|
200
|
+
updates += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return updates;
|
|
204
|
+
}
|
|
205
|
+
/** Internal — merge an item into a single list payload (one filter result or one infinite page). */
|
|
206
|
+
function mergeItemIntoListPage(page, targetId, item, idField) {
|
|
207
|
+
return updateListCache(page, (items) => {
|
|
208
|
+
let changed = false;
|
|
209
|
+
const next = items.map((listItem) => {
|
|
210
|
+
if (!listItem || typeof listItem !== "object") return listItem;
|
|
211
|
+
if (resolveId(listItem, idField) !== targetId) return listItem;
|
|
212
|
+
const merged = shallowMergeKept(listItem, item);
|
|
213
|
+
if (merged !== listItem) changed = true;
|
|
214
|
+
return merged;
|
|
215
|
+
});
|
|
216
|
+
return changed ? next : items;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
127
219
|
/**
|
|
128
220
|
* Build a hierarchical query-key factory for a resource. The returned shape
|
|
129
221
|
* is identical between server (prefetch) and client (hooks), so RSC SSR
|
|
@@ -166,6 +258,17 @@ function createQueryKeys(entityKey) {
|
|
|
166
258
|
_scope: scope,
|
|
167
259
|
...params
|
|
168
260
|
}
|
|
261
|
+
],
|
|
262
|
+
aggregations: () => [entityKey, "aggregation"],
|
|
263
|
+
aggregation: (name, filter) => filter !== void 0 ? [
|
|
264
|
+
entityKey,
|
|
265
|
+
"aggregation",
|
|
266
|
+
name,
|
|
267
|
+
filter
|
|
268
|
+
] : [
|
|
269
|
+
entityKey,
|
|
270
|
+
"aggregation",
|
|
271
|
+
name
|
|
169
272
|
]
|
|
170
273
|
};
|
|
171
274
|
}
|
|
@@ -173,25 +276,28 @@ function createQueryKeys(entityKey) {
|
|
|
173
276
|
* Build cache read/write/invalidate helpers bound to the given key factory.
|
|
174
277
|
* Server-safe — operates on a `QueryClient` instance which can be a per-request
|
|
175
278
|
* server client (during prefetch) or the browser singleton.
|
|
279
|
+
*
|
|
280
|
+
* **Wire shape:** Arc 2.13+ emits raw documents on `GET /:resource/:id` — no
|
|
281
|
+
* `{ data: ... }` envelope. `setDetail` / `getDetail` write and read the raw
|
|
282
|
+
* doc directly so the cache shape matches `useDetail`'s `queryFn` output,
|
|
283
|
+
* `useNavigation`'s pre-populated entries, and `prefetchDetail`'s server seed.
|
|
284
|
+
* All four paths converge on the same shape: TDoc, not `{ data: TDoc }`.
|
|
176
285
|
*/
|
|
177
286
|
function createCacheUtils(KEYS) {
|
|
178
287
|
return {
|
|
179
288
|
invalidateAll: (client) => client.invalidateQueries({ queryKey: KEYS.all }),
|
|
180
289
|
invalidateLists: (client) => client.invalidateQueries({ queryKey: KEYS.lists() }),
|
|
181
290
|
invalidateDetail: (client, id) => client.invalidateQueries({ queryKey: KEYS.detail(id) }),
|
|
182
|
-
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id),
|
|
183
|
-
getDetail: (client, id) =>
|
|
184
|
-
return client.getQueryData(KEYS.detail(id))?.data;
|
|
185
|
-
},
|
|
291
|
+
setDetail: (client, id, data) => client.setQueryData(KEYS.detail(id), data),
|
|
292
|
+
getDetail: (client, id) => client.getQueryData(KEYS.detail(id)) ?? void 0,
|
|
186
293
|
removeDetail: (client, id) => client.removeQueries({ queryKey: KEYS.detail(id) }),
|
|
187
294
|
invalidateScopedDetail: (client, id, organizationId) => client.invalidateQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
188
|
-
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId),
|
|
189
|
-
getScopedDetail: (client, id, organizationId) =>
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) })
|
|
295
|
+
setScopedDetail: (client, id, organizationId, data) => client.setQueryData(KEYS.scopedDetail(id, organizationId), data),
|
|
296
|
+
getScopedDetail: (client, id, organizationId) => client.getQueryData(KEYS.scopedDetail(id, organizationId)) ?? void 0,
|
|
297
|
+
removeScopedDetail: (client, id, organizationId) => client.removeQueries({ queryKey: KEYS.scopedDetail(id, organizationId) }),
|
|
298
|
+
invalidateAggregations: (client, name) => client.invalidateQueries({ queryKey: name ? KEYS.aggregation(name) : KEYS.aggregations() })
|
|
193
299
|
};
|
|
194
300
|
}
|
|
195
301
|
|
|
196
302
|
//#endregion
|
|
197
|
-
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, updateListCache };
|
|
303
|
+
export { DEFAULT_QUERY_CONFIG, QUERY_CONFIGS, createCacheUtils, createQueryKeys, extractItem, extractItems, getItemId, normalizePagination, syncDetailToLists, updateListCache };
|