@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/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
- /** Well-known keys checked in order for list responses. */
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
- * Strict detail extractor. Checks well-known keys (`data`, `doc`, `item`,
82
- * `result`); falls back to returning the response as-is. Primitive responses
83
- * (string/number/boolean) pass through.
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
- if (typeof data !== "object") return data;
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), { data }),
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), { data }),
189
- getScopedDetail: (client, id, organizationId) => {
190
- return client.getQueryData(KEYS.scopedDetail(id, organizationId))?.data;
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 };