@go-avro/avro-js 0.0.30 → 0.0.31

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.
@@ -537,6 +537,14 @@ export declare class AvroQueryClient {
537
537
  * Called once from AvroQueryClientProvider when the tanstack
538
538
  * QueryClient is available. Also handles auto-joining the
539
539
  * company room on connect so consumer apps don't have to.
540
+ *
541
+ * Targeted events (single-entity CRUD) receive `{ id }` from the
542
+ * backend and surgically create / update / remove only the
543
+ * affected cache entries. Bulk events fall back to full
544
+ * query-key invalidation.
545
+ *
546
+ * Backward-compatible: if a targeted event arrives without an id
547
+ * (old backend), we fall back to full invalidation.
540
548
  */
541
549
  setupSocketInvalidation(queryClient: QueryClient): void;
542
550
  /**
@@ -4,83 +4,98 @@ import { v4 as uuidv4 } from "uuid";
4
4
  import { LoginResponse } from '../types/api';
5
5
  import { AuthState } from '../types/auth';
6
6
  import { StandardError } from '../types/error';
7
+ function isBulkEvent(c) {
8
+ return 'invalidateKeys' in c;
9
+ }
7
10
  /**
8
- * Maps socket event names to the query keys that should be invalidated
9
- * when that event is received. Chat events are excluded — they carry
10
- * their own data and are handled separately by consumer apps.
11
+ * Maps socket event names to cache-update strategies.
12
+ *
13
+ * *Targeted* events receive `{ id }` from the backend and surgically
14
+ * update / insert / remove only the affected cache entries.
15
+ *
16
+ * *Bulk* events (plural mutations, scheduling, etc.) fall back to
17
+ * full query-key invalidation because the backend doesn't emit per-item ids.
11
18
  */
12
- const SOCKET_INVALIDATION_MAP = {
13
- // Company
14
- create_company: [['companies']],
15
- update_company: [['companies']],
16
- delete_company: [['companies']],
17
- // Users
18
- user_updated: [['users']],
19
- update_users: [['users']],
20
- // Jobs
21
- create_job: [['jobs']],
22
- update_job: [['jobs']],
23
- update_jobs: [['jobs']],
24
- delete_job: [['jobs']],
25
- delete_jobs: [['jobs']],
26
- // Routes
27
- create_route: [['routes']],
28
- update_route: [['routes']],
29
- delete_route: [['routes']],
30
- // Events
31
- create_event: [['events']],
32
- update_event: [['events']],
33
- update_events: [['events']],
34
- delete_event: [['events']],
35
- // Teams
36
- create_team: [['teams']],
37
- update_team: [['teams']],
38
- update_teams: [['teams']],
39
- delete_team: [['teams']],
40
- // Bills
41
- create_bill: [['bills']],
42
- update_bills: [['bills']],
43
- delete_bill: [['bills']],
44
- // Sessions
45
- create_session: [['sessions']],
46
- update_session: [['sessions']],
47
- // Catalog
48
- create_catalog_item: [['catalog_items']],
49
- update_catalog_item: [['catalog_items']],
50
- delete_catalog_item: [['catalog_items']],
51
- // Groups
52
- create_group: [['groups']],
53
- update_group: [['groups']],
54
- delete_group: [['groups']],
55
- // Labels
56
- create_label: [['labels']],
57
- update_label: [['labels']],
58
- delete_label: [['labels']],
59
- // Skills
60
- create_skill: [['skills']],
61
- update_skill: [['skills']],
62
- delete_skill: [['skills']],
63
- // Proposals
64
- create_proposal: [['proposals']],
65
- update_proposal: [['proposals']],
66
- delete_proposal: [['proposals']],
67
- // Service Months
68
- create_month: [['months']],
69
- update_months: [['months']],
70
- delete_months: [['months']],
71
- // Tasks
72
- create_task: [['jobs']],
73
- update_task: [['jobs']],
74
- delete_task: [['jobs']],
75
- // Scheduling
76
- schedule_complete: [['routes'], ['jobs']],
77
- // Location
78
- location_update: [['teams']],
79
- // Prepayments
80
- update_prepayments: [['prepayments']],
81
- // Chats
82
- new_message: [['chats'], ['messages']],
19
+ const SOCKET_EVENT_CONFIG = {
20
+ // ── Company ──
21
+ create_company: { entityKey: 'companies', action: 'create', fetchPath: (id) => `/company/${id}` },
22
+ update_company: { entityKey: 'companies', action: 'update', fetchPath: (id) => `/company/${id}` },
23
+ delete_company: { entityKey: 'companies', action: 'delete', fetchPath: null },
24
+ // ── Users (no single-entity socket events) ──
25
+ user_updated: { invalidateKeys: [['users'], ['user']] },
26
+ update_users: { invalidateKeys: [['users'], ['user']] },
27
+ // ── Jobs ──
28
+ create_job: { entityKey: 'jobs', action: 'create', fetchPath: (id) => `/job/${id}` },
29
+ update_job: { entityKey: 'jobs', action: 'update', fetchPath: (id) => `/job/${id}` },
30
+ delete_job: { entityKey: 'jobs', action: 'delete', fetchPath: null },
31
+ update_jobs: { invalidateKeys: [['jobs'], ['infinite', 'jobs']] },
32
+ delete_jobs: { invalidateKeys: [['jobs'], ['infinite', 'jobs']] },
33
+ // ── Routes ──
34
+ create_route: { entityKey: 'routes', action: 'create', fetchPath: (id) => `/route/${id}` },
35
+ update_route: { entityKey: 'routes', action: 'update', fetchPath: (id) => `/route/${id}` },
36
+ delete_route: { entityKey: 'routes', action: 'delete', fetchPath: null },
37
+ // ── Events ──
38
+ create_event: { entityKey: 'events', action: 'create', fetchPath: (id) => `/event/${id}` },
39
+ update_event: { entityKey: 'events', action: 'update', fetchPath: (id) => `/event/${id}` },
40
+ delete_event: { entityKey: 'events', action: 'delete', fetchPath: null },
41
+ update_events: { invalidateKeys: [['events']] },
42
+ // ── Teams ──
43
+ create_team: { entityKey: 'teams', action: 'create', fetchPath: null },
44
+ update_team: { entityKey: 'teams', action: 'update', fetchPath: null },
45
+ delete_team: { entityKey: 'teams', action: 'delete', fetchPath: null },
46
+ update_teams: { invalidateKeys: [['teams']] },
47
+ // ── Bills ──
48
+ create_bill: { entityKey: 'bills', action: 'create', fetchPath: (id) => `/bill/${id}` },
49
+ delete_bill: { entityKey: 'bills', action: 'delete', fetchPath: null },
50
+ update_bills: { invalidateKeys: [['bills']] },
51
+ // ── Sessions ──
52
+ create_session: { entityKey: 'sessions', action: 'create', fetchPath: null },
53
+ update_session: { entityKey: 'sessions', action: 'update', fetchPath: null },
54
+ // ── Catalog Items ──
55
+ create_catalog_item: { entityKey: 'catalog_items', action: 'create', fetchPath: (id) => `/catalog_item/${id}` },
56
+ update_catalog_item: { entityKey: 'catalog_items', action: 'update', fetchPath: (id) => `/catalog_item/${id}` },
57
+ delete_catalog_item: { entityKey: 'catalog_items', action: 'delete', fetchPath: null },
58
+ // ── Groups ──
59
+ create_group: { entityKey: 'groups', action: 'create', fetchPath: null },
60
+ update_group: { entityKey: 'groups', action: 'update', fetchPath: null },
61
+ delete_group: { entityKey: 'groups', action: 'delete', fetchPath: null },
62
+ // ── Labels ──
63
+ create_label: { entityKey: 'labels', action: 'create', fetchPath: null },
64
+ update_label: { entityKey: 'labels', action: 'update', fetchPath: null },
65
+ delete_label: { entityKey: 'labels', action: 'delete', fetchPath: null },
66
+ // ── Skills ──
67
+ create_skill: { entityKey: 'skills', action: 'create', fetchPath: null },
68
+ update_skill: { entityKey: 'skills', action: 'update', fetchPath: null },
69
+ delete_skill: { entityKey: 'skills', action: 'delete', fetchPath: null },
70
+ // ── Proposals ──
71
+ create_proposal: { entityKey: 'proposals', action: 'create', fetchPath: (id) => `/proposal/${id}` },
72
+ update_proposal: { entityKey: 'proposals', action: 'update', fetchPath: (id) => `/proposal/${id}` },
73
+ delete_proposal: { entityKey: 'proposals', action: 'delete', fetchPath: null },
74
+ // ── Service Months ──
75
+ create_month: { entityKey: 'months', action: 'create', fetchPath: null },
76
+ update_months: { invalidateKeys: [['months']] },
77
+ delete_months: { invalidateKeys: [['months']] },
78
+ // ── Tasks (nested under jobs — target the parent job) ──
79
+ create_task: { entityKey: 'jobs', action: 'update', fetchPath: (id) => `/job/${id}`, idField: 'job_id' },
80
+ update_task: { entityKey: 'jobs', action: 'update', fetchPath: (id) => `/job/${id}`, idField: 'job_id' },
81
+ delete_task: { entityKey: 'jobs', action: 'update', fetchPath: (id) => `/job/${id}`, idField: 'job_id' },
82
+ // ── Scheduling ──
83
+ schedule_complete: { invalidateKeys: [['routes'], ['jobs'], ['infinite', 'jobs']] },
84
+ // ── Location ──
85
+ location_update: { invalidateKeys: [['teams']] },
86
+ // ── Prepayments ──
87
+ update_prepayments: { invalidateKeys: [['prepayments']] },
88
+ // ── Chats ──
89
+ new_message: { invalidateKeys: [['chats'], ['messages']] },
83
90
  };
91
+ /**
92
+ * Returns true when `query.queryKey` belongs to the given entity,
93
+ * including the `['infinite', entityKey, ...]` variant used by jobs.
94
+ */
95
+ function matchesEntityKey(query, entityKey) {
96
+ const k = query.queryKey;
97
+ return k[0] === entityKey || (k[0] === 'infinite' && k[1] === entityKey);
98
+ }
84
99
  export class AvroQueryClient {
85
100
  constructor(config) {
86
101
  this._authState = AuthState.UNKNOWN;
@@ -157,6 +172,14 @@ export class AvroQueryClient {
157
172
  * Called once from AvroQueryClientProvider when the tanstack
158
173
  * QueryClient is available. Also handles auto-joining the
159
174
  * company room on connect so consumer apps don't have to.
175
+ *
176
+ * Targeted events (single-entity CRUD) receive `{ id }` from the
177
+ * backend and surgically create / update / remove only the
178
+ * affected cache entries. Bulk events fall back to full
179
+ * query-key invalidation.
180
+ *
181
+ * Backward-compatible: if a targeted event arrives without an id
182
+ * (old backend), we fall back to full invalidation.
160
183
  */
161
184
  setupSocketInvalidation(queryClient) {
162
185
  // Prevent double-setup
@@ -164,15 +187,142 @@ export class AvroQueryClient {
164
187
  return;
165
188
  this._queryClient = queryClient;
166
189
  const handlers = [];
167
- // Register invalidation listeners for every mapped event
168
- for (const [event, queryKeys] of Object.entries(SOCKET_INVALIDATION_MAP)) {
169
- const handler = () => {
170
- for (const key of queryKeys) {
171
- queryClient.invalidateQueries({ queryKey: key });
172
- }
173
- };
174
- this.socket.on(event, handler);
175
- handlers.push({ event, handler });
190
+ const client = this; // stable reference for async closures
191
+ /** Full-invalidate every key that matches the entity (including 'infinite' prefix). */
192
+ const invalidateEntity = (entityKey) => {
193
+ queryClient.invalidateQueries({ predicate: (q) => matchesEntityKey(q, entityKey) });
194
+ };
195
+ for (const [event, config] of Object.entries(SOCKET_EVENT_CONFIG)) {
196
+ if (isBulkEvent(config)) {
197
+ // ── Bulk: invalidate all specified keys ──────────────
198
+ const handler = () => {
199
+ for (const key of config.invalidateKeys) {
200
+ queryClient.invalidateQueries({ queryKey: key });
201
+ }
202
+ };
203
+ this.socket.on(event, handler);
204
+ handlers.push({ event, handler });
205
+ }
206
+ else {
207
+ // ── Targeted: surgical cache update ──────────────────
208
+ const { entityKey, action, fetchPath, idField, alsoInvalidate } = config;
209
+ const handler = async (data) => {
210
+ const id = data?.[idField ?? 'id'];
211
+ // No id → old backend or malformed payload → full invalidation
212
+ if (!id || typeof id !== 'string') {
213
+ invalidateEntity(entityKey);
214
+ alsoInvalidate?.forEach((k) => queryClient.invalidateQueries({ queryKey: k }));
215
+ return;
216
+ }
217
+ const entityPredicate = (q) => matchesEntityKey(q, entityKey);
218
+ switch (action) {
219
+ // ─── CREATE ─────────────────────────────────
220
+ case 'create': {
221
+ if (fetchPath) {
222
+ try {
223
+ const item = await queryClient.fetchQuery({
224
+ queryKey: [entityKey, id],
225
+ queryFn: () => client.get({ path: fetchPath(id) }),
226
+ staleTime: 0,
227
+ });
228
+ queryClient.setQueriesData({ predicate: entityPredicate, type: 'active' }, (old) => {
229
+ if (!old)
230
+ return old;
231
+ if (old.pages && Array.isArray(old.pages)) {
232
+ // Skip if already present (avoids duplicates
233
+ // when the creating user also gets the event)
234
+ if (old.pages.some((p) => p.some((x) => x?.id === id)))
235
+ return old;
236
+ return {
237
+ ...old,
238
+ pages: [
239
+ [item, ...(old.pages[0] || [])],
240
+ ...old.pages.slice(1),
241
+ ],
242
+ };
243
+ }
244
+ if (Array.isArray(old)) {
245
+ if (old.some((x) => x?.id === id))
246
+ return old;
247
+ return [...old, item];
248
+ }
249
+ return old;
250
+ });
251
+ }
252
+ catch {
253
+ invalidateEntity(entityKey);
254
+ }
255
+ }
256
+ else {
257
+ // No individual GET endpoint → invalidate lists
258
+ invalidateEntity(entityKey);
259
+ }
260
+ break;
261
+ }
262
+ // ─── UPDATE ─────────────────────────────────
263
+ case 'update': {
264
+ if (fetchPath) {
265
+ try {
266
+ // Fetch fresh copy (also updates the [entity, id] cache)
267
+ const item = await queryClient.fetchQuery({
268
+ queryKey: [entityKey, id],
269
+ queryFn: () => client.get({ path: fetchPath(id) }),
270
+ staleTime: 0,
271
+ });
272
+ // Patch it into every active list / infinite-query cache
273
+ queryClient.setQueriesData({ predicate: entityPredicate, type: 'active' }, (old) => {
274
+ if (!old)
275
+ return old;
276
+ if (old.pages && Array.isArray(old.pages)) {
277
+ return {
278
+ ...old,
279
+ pages: old.pages.map((page) => page.map((x) => (x?.id === id ? item : x))),
280
+ };
281
+ }
282
+ if (Array.isArray(old)) {
283
+ return old.map((x) => (x?.id === id ? item : x));
284
+ }
285
+ return old;
286
+ });
287
+ }
288
+ catch {
289
+ invalidateEntity(entityKey);
290
+ }
291
+ }
292
+ else {
293
+ // No individual GET → invalidate everything for this entity
294
+ invalidateEntity(entityKey);
295
+ }
296
+ break;
297
+ }
298
+ // ─── DELETE ─────────────────────────────────
299
+ case 'delete': {
300
+ // Remove the individual-item cache entry
301
+ queryClient.removeQueries({ queryKey: [entityKey, id], exact: true });
302
+ // Remove from every active list / infinite-query cache
303
+ queryClient.setQueriesData({ predicate: entityPredicate, type: 'active' }, (old) => {
304
+ if (!old)
305
+ return old;
306
+ if (old.pages && Array.isArray(old.pages)) {
307
+ return {
308
+ ...old,
309
+ pages: old.pages.map((page) => page.filter((x) => x?.id !== id)),
310
+ };
311
+ }
312
+ if (Array.isArray(old)) {
313
+ return old.filter((x) => x?.id !== id);
314
+ }
315
+ return old;
316
+ });
317
+ break;
318
+ }
319
+ }
320
+ // Invalidate any additional keys (e.g. companies → /company/list)
321
+ alsoInvalidate?.forEach((k) => queryClient.invalidateQueries({ queryKey: k }));
322
+ };
323
+ this.socket.on(event, handler);
324
+ handlers.push({ event, handler });
325
+ }
176
326
  }
177
327
  // Auto join/leave company room
178
328
  const joinCompanyRoom = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@go-avro/avro-js",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "JS client for Avro backend integration.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",