Skip to content

Commit f796b6c

Browse files
authored
enhance: Add GC counting to useQuery() (#3373)
* enhance: Add GC counting to useQuery() * enhance: Stronger controller.snapshot() method types
1 parent 679d76a commit f796b6c

File tree

12 files changed

+456
-40
lines changed

12 files changed

+456
-40
lines changed

.changeset/shaky-ties-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@data-client/core': patch
3+
---
4+
5+
Add Controller.getQueryMeta and Controller.getResponseMeta

.changeset/silver-paws-send.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@data-client/core': patch
3+
---
4+
5+
Controller.snapshot() methods have stronger argument typing

packages/core/src/controller/Controller.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export default class Controller<
372372
* Gets a snapshot (https://dataclient.io/docs/api/Snapshot)
373373
* @see https://dataclient.io/docs/api/Controller#snapshot
374374
*/
375-
snapshot = (state: State<unknown>, fetchedAt?: number): SnapshotInterface => {
375+
snapshot = (state: State<unknown>, fetchedAt?: number): Snapshot<unknown> => {
376376
return new Snapshot(this, state, fetchedAt);
377377
};
378378

@@ -451,6 +451,50 @@ export default class Controller<
451451
expiryStatus: ExpiryStatus;
452452
expiresAt: number;
453453
countRef: () => () => void;
454+
} {
455+
// TODO: breaking: only return data
456+
return this.getResponseMeta(endpoint, ...rest);
457+
}
458+
459+
/**
460+
* Gets the (globally referentially stable) response for a given endpoint/args pair from state given.
461+
* @see https://dataclient.io/docs/api/Controller#getResponseMeta
462+
*/
463+
getResponseMeta<E extends EndpointInterface>(
464+
endpoint: E,
465+
...rest:
466+
| readonly [null, State<unknown>]
467+
| readonly [...Parameters<E>, State<unknown>]
468+
): {
469+
data: DenormalizeNullable<E['schema']>;
470+
expiryStatus: ExpiryStatus;
471+
expiresAt: number;
472+
countRef: () => () => void;
473+
};
474+
475+
getResponseMeta<
476+
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
477+
>(
478+
endpoint: E,
479+
...rest: readonly [
480+
...(readonly [...Parameters<E['key']>] | readonly [null]),
481+
State<unknown>,
482+
]
483+
): {
484+
data: DenormalizeNullable<E['schema']>;
485+
expiryStatus: ExpiryStatus;
486+
expiresAt: number;
487+
countRef: () => () => void;
488+
};
489+
490+
getResponseMeta(
491+
endpoint: EndpointInterface,
492+
...rest: readonly [...unknown[], State<unknown>]
493+
): {
494+
data: unknown;
495+
expiryStatus: ExpiryStatus;
496+
expiresAt: number;
497+
countRef: () => () => void;
454498
} {
455499
const state = rest[rest.length - 1] as State<unknown>;
456500
// this is typescript generics breaking
@@ -548,6 +592,52 @@ export default class Controller<
548592
return this.memo.query(schema, args, state.entities as any, state.indexes);
549593
}
550594

595+
/**
596+
* Queries the store for a Querable schema; providing related metadata
597+
* @see https://dataclient.io/docs/api/Controller#getQueryMeta
598+
*/
599+
getQueryMeta<S extends Queryable>(
600+
schema: S,
601+
...rest: readonly [
602+
...SchemaArgs<S>,
603+
Pick<State<unknown>, 'entities' | 'entityMeta'>,
604+
]
605+
): {
606+
data: DenormalizeNullable<S> | undefined;
607+
countRef: () => () => void;
608+
} {
609+
const state = rest[rest.length - 1] as State<any>;
610+
// this is typescript generics breaking
611+
const args: any = rest
612+
.slice(0, rest.length - 1)
613+
.map(ensurePojo) as SchemaArgs<S>;
614+
615+
// TODO: breaking: Switch back to this.memo.query(schema, args, state.entities as any, state.indexes) to do
616+
// this logic
617+
const input = this.memo.buildQueryKey(
618+
schema,
619+
args,
620+
state.entities as any,
621+
state.indexes,
622+
JSON.stringify(args),
623+
);
624+
625+
if (!input) {
626+
return { data: undefined, countRef: () => () => undefined };
627+
}
628+
629+
const { data, paths } = this.memo.denormalize(
630+
schema,
631+
input,
632+
state.entities,
633+
args,
634+
);
635+
return {
636+
data: typeof data === 'symbol' ? undefined : (data as any),
637+
countRef: this.gcPolicy.createCountRef({ paths }),
638+
};
639+
}
640+
551641
private getSchemaResponse<T>(
552642
data: T,
553643
key: string,
@@ -689,6 +779,49 @@ class Snapshot<T = unknown> implements SnapshotInterface {
689779
return this.controller.getResponse(endpoint, ...args, this.state);
690780
}
691781

782+
/** @see https://dataclient.io/docs/api/Snapshot#getResponseMeta */
783+
getResponseMeta<E extends EndpointInterface>(
784+
endpoint: E,
785+
...args: readonly [null]
786+
): {
787+
data: DenormalizeNullable<E['schema']>;
788+
expiryStatus: ExpiryStatus;
789+
expiresAt: number;
790+
};
791+
792+
getResponseMeta<E extends EndpointInterface>(
793+
endpoint: E,
794+
...args: readonly [...Parameters<E>]
795+
): {
796+
data: DenormalizeNullable<E['schema']>;
797+
expiryStatus: ExpiryStatus;
798+
expiresAt: number;
799+
};
800+
801+
getResponseMeta<
802+
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
803+
>(
804+
endpoint: E,
805+
...args: readonly [...Parameters<E['key']>] | readonly [null]
806+
): {
807+
data: DenormalizeNullable<E['schema']>;
808+
expiryStatus: ExpiryStatus;
809+
expiresAt: number;
810+
};
811+
812+
getResponseMeta<
813+
E extends Pick<EndpointInterface, 'key' | 'schema' | 'invalidIfStale'>,
814+
>(
815+
endpoint: E,
816+
...args: readonly [...Parameters<E['key']>] | readonly [null]
817+
): {
818+
data: DenormalizeNullable<E['schema']>;
819+
expiryStatus: ExpiryStatus;
820+
expiresAt: number;
821+
} {
822+
return this.controller.getResponseMeta(endpoint, ...args, this.state);
823+
}
824+
692825
/** @see https://dataclient.io/docs/api/Snapshot#getError */
693826
getError<E extends EndpointInterface>(
694827
endpoint: E,
@@ -717,4 +850,18 @@ class Snapshot<T = unknown> implements SnapshotInterface {
717850
): DenormalizeNullable<S> | undefined {
718851
return this.controller.get(schema, ...args, this.state);
719852
}
853+
854+
/**
855+
* Queries the store for a Querable schema; providing related metadata
856+
* @see https://dataclient.io/docs/api/Snapshot#getQueryMeta
857+
*/
858+
getQueryMeta<S extends Queryable>(
859+
schema: S,
860+
...args: SchemaArgs<S>
861+
): {
862+
data: DenormalizeNullable<S> | undefined;
863+
countRef: () => () => void;
864+
} {
865+
return this.controller.getQueryMeta(schema, ...args, this.state);
866+
}
720867
}

packages/core/src/controller/__tests__/__snapshots__/get.ts.snap

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,10 @@ Tacos {
118118
"type": "foo",
119119
}
120120
`;
121+
122+
exports[`Snapshot.getQueryMeta() query Entity based on pk 1`] = `
123+
Tacos {
124+
"id": "1",
125+
"type": "foo",
126+
}
127+
`;

packages/core/src/controller/__tests__/__snapshots__/getResponse.ts.snap

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,18 @@ exports[`Controller.getResponse() infers schema with extra members but not set 1
3333
},
3434
}
3535
`;
36+
37+
exports[`Snapshot.getResponseMeta() denormalizes schema with extra members but not set 1`] = `
38+
{
39+
"data": [
40+
Tacos {
41+
"id": "1",
42+
"type": "foo",
43+
},
44+
Tacos {
45+
"id": "2",
46+
"type": "bar",
47+
},
48+
],
49+
}
50+
`;

packages/core/src/controller/__tests__/get.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,24 @@ import { Entity, schema } from '@data-client/endpoint';
33
import { initialState } from '../../state/reducer/createReducer';
44
import Controller from '../Controller';
55

6-
describe('Controller.get()', () => {
7-
class Tacos extends Entity {
8-
type = '';
9-
id = '';
10-
}
11-
const TacoList = new schema.Collection([Tacos]);
12-
const entities = {
13-
Tacos: {
14-
1: { id: '1', type: 'foo' },
15-
2: { id: '2', type: 'bar' },
16-
},
17-
[TacoList.key]: {
18-
[TacoList.pk(undefined, undefined, '', [{ type: 'foo' }])]: ['1'],
19-
[TacoList.pk(undefined, undefined, '', [{ type: 'bar' }])]: ['2'],
20-
[TacoList.pk(undefined, undefined, '', [])]: ['1', '2'],
21-
},
22-
};
6+
class Tacos extends Entity {
7+
type = '';
8+
id = '';
9+
}
10+
const TacoList = new schema.Collection([Tacos]);
11+
const entities = {
12+
Tacos: {
13+
1: { id: '1', type: 'foo' },
14+
2: { id: '2', type: 'bar' },
15+
},
16+
[TacoList.key]: {
17+
[TacoList.pk(undefined, undefined, '', [{ type: 'foo' }])]: ['1'],
18+
[TacoList.pk(undefined, undefined, '', [{ type: 'bar' }])]: ['2'],
19+
[TacoList.pk(undefined, undefined, '', [])]: ['1', '2'],
20+
},
21+
};
2322

23+
describe('Controller.get()', () => {
2424
it('query Entity based on pk', () => {
2525
const controller = new Controller();
2626
const state = {
@@ -273,3 +273,31 @@ describe('Controller.get()', () => {
273273
() => controller.get(queryPerson, { id: '1', doesnotexist: 5 }, state);
274274
});
275275
});
276+
277+
describe('Snapshot.getQueryMeta()', () => {
278+
it('query Entity based on pk', () => {
279+
const controller = new Controller();
280+
const state = {
281+
...initialState,
282+
entities,
283+
};
284+
const snapshot = controller.snapshot(state);
285+
const taco = snapshot.getQueryMeta(Tacos, { id: '1' }).data;
286+
expect(taco).toBeDefined();
287+
expect(taco).toBeInstanceOf(Tacos);
288+
expect(taco).toMatchSnapshot();
289+
const taco2 = snapshot.getQueryMeta(Tacos, { id: '2' }).data;
290+
expect(taco2).toBeDefined();
291+
expect(taco2).toBeInstanceOf(Tacos);
292+
expect(taco2).not.toEqual(taco);
293+
// should maintain referential equality
294+
expect(taco).toBe(snapshot.getQueryMeta(Tacos, { id: '1' }).data);
295+
296+
// @ts-expect-error
297+
() => snapshot.getQueryMeta(Tacos, { id: { bob: 5 } });
298+
// @ts-expect-error
299+
expect(snapshot.getQueryMeta(Tacos, 5).data).toBeUndefined();
300+
// @ts-expect-error
301+
() => snapshot.getQueryMeta(Tacos, { doesnotexist: 5 });
302+
});
303+
});

packages/core/src/controller/__tests__/getResponse.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,49 @@ describe('Controller.getResponse()', () => {
174174
expect(second.expiresAt).toBe(expiresAt);
175175
});
176176
});
177+
178+
describe('Snapshot.getResponseMeta()', () => {
179+
it('denormalizes schema with extra members but not set', () => {
180+
const controller = new Contoller();
181+
class Tacos extends Entity {
182+
type = '';
183+
id = '';
184+
}
185+
const ep = new Endpoint(() => Promise.resolve(), {
186+
key() {
187+
return 'mytest';
188+
},
189+
schema: {
190+
data: [Tacos],
191+
extra: '',
192+
page: {
193+
first: null,
194+
second: undefined,
195+
third: 0,
196+
complex: { complex: true, next: false },
197+
},
198+
},
199+
});
200+
const entities = {
201+
Tacos: {
202+
1: { id: '1', type: 'foo' },
203+
2: { id: '2', type: 'bar' },
204+
},
205+
};
206+
207+
const state = {
208+
...initialState,
209+
entities,
210+
endpoints: {
211+
[ep.key()]: {
212+
data: ['1', '2'],
213+
},
214+
},
215+
};
216+
const { data, expiryStatus } = controller
217+
.snapshot(state)
218+
.getResponseMeta(ep);
219+
expect(expiryStatus).toBe(ExpiryStatus.Valid);
220+
expect(data).toMatchSnapshot();
221+
});
222+
});

packages/core/src/state/GCPolicy.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export class GCPolicy implements GCInterface {
3232
clearInterval(this.intervalId);
3333
}
3434

35-
createCountRef({ key, paths = [] }: { key: string; paths?: EntityPath[] }) {
35+
createCountRef({ key, paths = [] }: { key?: string; paths?: EntityPath[] }) {
3636
// increment
3737
return () => {
38-
this.endpointCount.set(key, (this.endpointCount.get(key) ?? 0) + 1);
38+
if (key)
39+
this.endpointCount.set(key, (this.endpointCount.get(key) ?? 0) + 1);
3940
paths.forEach(path => {
4041
if (!this.entityCount.has(path.key)) {
4142
this.entityCount.set(path.key, new Map<string, number>());
@@ -46,14 +47,16 @@ export class GCPolicy implements GCInterface {
4647

4748
// decrement
4849
return () => {
49-
const currentCount = this.endpointCount.get(key)!;
50-
if (currentCount !== undefined) {
51-
if (currentCount <= 1) {
52-
this.endpointCount.delete(key);
53-
// queue for cleanup
54-
this.endpointsQ.add(key);
55-
} else {
56-
this.endpointCount.set(key, currentCount - 1);
50+
if (key) {
51+
const currentCount = this.endpointCount.get(key)!;
52+
if (currentCount !== undefined) {
53+
if (currentCount <= 1) {
54+
this.endpointCount.delete(key);
55+
// queue for cleanup
56+
this.endpointsQ.add(key);
57+
} else {
58+
this.endpointCount.set(key, currentCount - 1);
59+
}
5760
}
5861
}
5962
paths.forEach(path => {
@@ -139,7 +142,7 @@ export interface GCOptions {
139142
intervalMS?: number;
140143
}
141144
export interface CreateCountRef {
142-
({ key, paths }: { key: string; paths?: EntityPath[] }): () => () => void;
145+
({ key, paths }: { key?: string; paths?: EntityPath[] }): () => () => void;
143146
}
144147
export interface GCInterface {
145148
createCountRef: CreateCountRef;

0 commit comments

Comments
 (0)