Skip to content

Commit 5b44478

Browse files
committed
Give records a reference to the model manager which loaded them
We have a few upcoming things that require records to be able to do stuff to themselves -- reload, select new fields from the backend, maybe `record.save` or similar, and I suspect more in the future. I still think they should act mostly as DTOs that don't have a lot of business logic on them, but if we want to be able to even know what model they are for, we need a reference to the thing that produced them! This passes along the model manager which instantiated a record to the record in both imperative API land and in React land. In order to make this change work in React land, we need a new little bit of metadata exported from the generated client. Our React hooks get passed functions from the api client like `useAction(api.post.create)`, so we need to be able to hop back from the `create` function that we get by reference to the `api.post` object. The generated client will need to decorate the `create` function with a reference, which is not super hard, but means that we have a backwards compatibility issue. `@gadgetinc/react` can't assume that it is upgraded at the same time as the api client, so it can't assume it is working against a newly generated api client that has this metadata. For this reason, the model manager property on `GadgetRecord` is optional, which reflects the way it will be used in the real world without necessarily having that metadata available. When we go to build things like `record.reload()`, we can make that fail at runtime with a message saying "regenerate your client to get this to work!", instead of just assuming it is present.
1 parent ddc7ba1 commit 5b44478

22 files changed

+183
-101
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"lint:eslint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint --quiet --ext ts,tsx packages scripts",
1212
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --write --check \"(packages|scripts)/**/*.{js,ts,tsx}\" && eslint --ext ts,tsx --fix packages scripts",
1313
"typecheck": "pnpm -r --no-bail run --if-present typecheck",
14-
"build": "pnpm -r --no-bail run --if-present build",
14+
"build": "pnpm -r run --if-present build",
1515
"prerelease": "pnpm -r --no-bail run --if-present prerelease",
1616
"watch": "run-p --print-label watch:*",
1717
"watch:client": "pnpm --filter=@gadgetinc/api-client-core watch",

packages/api-client-core/spec/GadgetRecord.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AnyPublicModelManager } from "../src/AnyModelManager.js";
12
import { ChangeTracking, GadgetRecord } from "../src/GadgetRecord.js";
23
interface SampleBaseRecord {
34
id?: string;
@@ -38,6 +39,8 @@ const expectPersistedChanges = (record: GadgetRecord<SampleBaseRecord>, ...prope
3839
return _expectChanges(record, ChangeTracking.SinceLastPersisted, ...properties);
3940
};
4041

42+
const mockModelManager: AnyPublicModelManager = {} as any;
43+
4144
describe("GadgetRecord", () => {
4245
let productBaseRecord: SampleBaseRecord;
4346
beforeAll(() => {
@@ -48,6 +51,18 @@ describe("GadgetRecord", () => {
4851
};
4952
});
5053

54+
it("can be constructed with a base record and no model manager for backwards compatibility", () => {
55+
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
56+
expect(product.id).toEqual("123");
57+
expect(product.name).toEqual("A cool product");
58+
expect(product.modelManager).toEqual(null);
59+
});
60+
61+
it("can be constructed with a base record and a model manager", () => {
62+
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord, mockModelManager);
63+
expect(product.modelManager).toEqual(mockModelManager);
64+
});
65+
5166
it("should respond toJSON, which returns the inner __gadget.fields properties", () => {
5267
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
5368
expect(product.toJSON()).toEqual({

packages/api-client-core/spec/operationRunners.spec.ts

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import nock from "nock";
2-
import type { GadgetErrorGroup } from "../src/index.js";
2+
import type { AnyPublicModelManager, GadgetErrorGroup } from "../src/index.js";
33
import { GadgetConnection, actionRunner } from "../src/index.js";
44
import { mockUrqlClient } from "./mockUrqlClient.js";
55

@@ -8,17 +8,17 @@ nock.disableNetConnect();
88
// eslint-disable-next-line jest/no-export
99
describe("operationRunners", () => {
1010
let connection: GadgetConnection;
11+
let manager: AnyPublicModelManager;
1112
beforeEach(() => {
1213
connection = new GadgetConnection({ endpoint: "https://someapp.gadget.app" });
1314
jest.spyOn(connection, "currentClient", "get").mockReturnValue(mockUrqlClient as any);
15+
manager = { connection } as AnyPublicModelManager;
1416
});
1517

1618
describe("actionRunner", () => {
1719
test("can run a single create action", async () => {
1820
const promise = actionRunner<{ id: string; name: string }>(
19-
{
20-
connection,
21-
},
21+
manager,
2222
"createWidget",
2323
{ id: true, name: true },
2424
"widget",
@@ -58,9 +58,7 @@ describe("operationRunners", () => {
5858

5959
test("can run a single update action", async () => {
6060
const promise = actionRunner<{ id: string; name: string }>(
61-
{
62-
connection,
63-
},
61+
manager,
6462
"updateWidget",
6563
{ id: true, name: true },
6664
"widget",
@@ -105,9 +103,7 @@ describe("operationRunners", () => {
105103

106104
test("can throw the error returned by the server for a single action", async () => {
107105
const promise = actionRunner<{ id: string; name: string }>(
108-
{
109-
connection,
110-
},
106+
manager,
111107
"updateWidget",
112108
{ id: true, name: true },
113109
"widget",
@@ -152,9 +148,7 @@ describe("operationRunners", () => {
152148

153149
test("can run a bulk action by ids", async () => {
154150
const promise = actionRunner<{ id: string; name: string }>(
155-
{
156-
connection,
157-
},
151+
manager,
158152
"bulkFlipWidgets",
159153
{ id: true, name: true },
160154
"widget",
@@ -202,9 +196,7 @@ describe("operationRunners", () => {
202196

203197
test("can run a bulk action with params", async () => {
204198
const promise = actionRunner<{ id: string; name: string }>(
205-
{
206-
connection,
207-
},
199+
manager,
208200
"bulkCreateWidgets",
209201
{ id: true, name: true },
210202
"widget",
@@ -252,9 +244,7 @@ describe("operationRunners", () => {
252244

253245
test("throws a nice error when a bulk action returns errors", async () => {
254246
const promise = actionRunner<{ id: string; name: string }>(
255-
{
256-
connection,
257-
},
247+
manager,
258248
"bulkCreateWidgets",
259249
{ id: true, name: true },
260250
"widget",
@@ -292,9 +282,7 @@ describe("operationRunners", () => {
292282

293283
test("throws a nice error when a bulk action returns errors and data", async () => {
294284
const promise = actionRunner<{ id: string; name: string }>(
295-
{
296-
connection,
297-
},
285+
manager,
298286
"bulkCreateWidgets",
299287
{ id: true, name: true },
300288
"widget",
@@ -339,9 +327,7 @@ describe("operationRunners", () => {
339327

340328
test("returns undefined when bulk action does not have a result", async () => {
341329
const promise = actionRunner<{ id: string; name: string }>(
342-
{
343-
connection,
344-
},
330+
manager,
345331
"bulkDeleteWidgets",
346332
{ id: true, name: true },
347333
"widget",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { GadgetConnection } from "./GadgetConnection";
2+
import type { GadgetRecord } from "./GadgetRecord";
3+
import type { GadgetRecordList } from "./GadgetRecordList";
4+
import type { InternalModelManager } from "./InternalModelManager";
5+
6+
/**
7+
* The manager class for a given model that uses the Public API, like `api.post` or `api.user`
8+
**/
9+
export interface AnyPublicModelManager {
10+
connection: GadgetConnection;
11+
apiIdentifier?: string;
12+
findOne(id: string, options: any): Promise<GadgetRecord<any>>;
13+
maybeFindOne(id: string, options: any): Promise<GadgetRecord<any> | null>;
14+
findMany(options: any): Promise<GadgetRecordList<any>>;
15+
findFirst(options: any): Promise<GadgetRecord<any>>;
16+
maybeFindFirst(options: any): Promise<GadgetRecord<any> | null>;
17+
}
18+
19+
/**
20+
* The manager class for a given single model that uses the Public API, like `api.session`
21+
**/
22+
export interface AnyPublicSingletonModelManager {
23+
connection: GadgetConnection;
24+
apiIdentifier?: string;
25+
get(): Promise<GadgetRecord<any>>;
26+
}
27+
28+
/**
29+
* Any model manager, either public or internal
30+
*/
31+
export type AnyModelManager = AnyPublicModelManager | AnyPublicSingletonModelManager | InternalModelManager;

packages/api-client-core/src/GadgetFunctions.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AnyPublicModelManager, AnyPublicSingletonModelManager } from "./AnyModelManager.js";
12
import type { GadgetRecord, RecordShape } from "./GadgetRecord.js";
23
import type { GadgetRecordList } from "./GadgetRecordList.js";
34
import type { LimitToKnownKeys, VariablesOptions } from "./types.js";
@@ -17,6 +18,7 @@ export interface FindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
1718
selectionType: SelectionT;
1819
optionsType: OptionsT;
1920
schemaType: SchemaT | null;
21+
modelManager?: AnyPublicModelManager;
2022
}
2123

2224
export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
@@ -30,6 +32,7 @@ export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT>
3032
selectionType: SelectionT;
3133
optionsType: OptionsT;
3234
schemaType: SchemaT | null;
35+
modelManager?: AnyPublicModelManager;
3336
}
3437

3538
export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
@@ -42,6 +45,7 @@ export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
4245
selectionType: SelectionT;
4346
optionsType: OptionsT;
4447
schemaType: SchemaT | null;
48+
modelManager?: AnyPublicModelManager;
4549
}
4650

4751
export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
@@ -54,6 +58,7 @@ export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
5458
selectionType: SelectionT;
5559
optionsType: OptionsT;
5660
schemaType: SchemaT | null;
61+
modelManager?: AnyPublicModelManager;
5762
}
5863

5964
export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
@@ -66,6 +71,7 @@ export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT
6671
selectionType: SelectionT;
6772
optionsType: OptionsT;
6873
schemaType: SchemaT | null;
74+
modelManager?: AnyPublicModelManager;
6975
}
7076

7177
interface ActionWithIdAndVariables<OptionsT, VariablesT> {
@@ -113,6 +119,7 @@ interface ActionFunctionMetadata<OptionsT, VariablesT, SelectionT, SchemaT, Defa
113119
acceptsModelInput?: boolean;
114120
paramOnlyVariables?: readonly string[];
115121
hasReturnType?: boolean;
122+
modelManager?: AnyPublicModelManager;
116123
}
117124

118125
export type ActionFunction<OptionsT, VariablesT, SelectionT, SchemaT, DefaultsT> = ActionFunctionMetadata<
@@ -150,6 +157,7 @@ export interface GetFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
150157
selectionType: SelectionT;
151158
optionsType: OptionsT;
152159
schemaType: SchemaT | null;
160+
modelManager?: AnyPublicSingletonModelManager;
153161
}
154162

155163
export interface GlobalActionFunction<VariablesT> {

packages/api-client-core/src/GadgetRecord.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { klona as cloneDeep } from "klona";
22
import type { Jsonify } from "type-fest";
3+
import type { AnyModelManager } from "./AnyModelManager.js";
34
import { isEqual, toPrimitiveObject } from "./support.js";
45

56
export enum ChangeTracking {
@@ -22,7 +23,7 @@ export class GadgetRecordImplementation<Shape extends RecordShape> {
2223

2324
private empty = false;
2425

25-
constructor(data: Shape) {
26+
constructor(data: Shape, readonly modelManager: AnyModelManager | null = null) {
2627
this.__gadget.instantiatedFields = cloneDeep(data);
2728
this.__gadget.persistedFields = cloneDeep(data);
2829
Object.assign(this.__gadget.fields, data);
@@ -192,6 +193,8 @@ export class GadgetRecordImplementation<Shape extends RecordShape> {
192193
*/
193194

194195
/** Instantiate a `GadgetRecord` with the attributes of your model. A `GadgetRecord` can be used to track changes to your model and persist those changes via Gadget actions. */
195-
export const GadgetRecord: new <Shape extends RecordShape>(data: Shape) => GadgetRecordImplementation<Shape> & Shape =
196-
GadgetRecordImplementation as any;
196+
export const GadgetRecord: new <Shape extends RecordShape>(
197+
data: Shape,
198+
modelManager?: AnyModelManager
199+
) => GadgetRecordImplementation<Shape> & Shape = GadgetRecordImplementation as any;
197200
export type GadgetRecord<Shape extends RecordShape> = GadgetRecordImplementation<Shape> & Shape;

packages/api-client-core/src/GadgetRecordList.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* eslint-disable no-throw-literal */
22
/* eslint-disable @typescript-eslint/require-await */
33
import type { Jsonify } from "type-fest";
4+
import type { AnyPublicModelManager } from "./AnyModelManager.js";
45
import type { GadgetRecord, RecordShape } from "./GadgetRecord.js";
56
import type { InternalModelManager } from "./InternalModelManager.js";
6-
import type { AnyModelManager } from "./ModelManager.js";
77
import type { PaginationOptions } from "./operationBuilders.js";
88
import { GadgetClientError, GadgetOperationError } from "./support.js";
99

@@ -14,12 +14,12 @@ type PaginationConfig = {
1414

1515
/** Represents a list of objects returned from the API. Facilitates iterating and paginating. */
1616
export class GadgetRecordList<Shape extends RecordShape> extends Array<GadgetRecord<Shape>> {
17-
modelManager!: AnyModelManager | InternalModelManager;
17+
modelManager!: AnyPublicModelManager | InternalModelManager;
1818
pagination!: PaginationConfig;
1919

2020
/** Internal method used to create a list. Should not be used by applications. */
2121
static boot<Shape extends RecordShape>(
22-
modelManager: AnyModelManager | InternalModelManager,
22+
modelManager: AnyPublicModelManager | InternalModelManager,
2323
records: GadgetRecord<Shape>[],
2424
pagination: PaginationConfig
2525
) {

packages/api-client-core/src/InternalModelManager.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ export class InternalModelManager {
306306
private readonly capitalizedApiIdentifier: string;
307307

308308
constructor(
309-
private readonly apiIdentifier: string,
309+
readonly apiIdentifier: string,
310310
readonly connection: GadgetConnection,
311311
readonly options?: { pluralApiIdentifier: string; hasAmbiguousIdentifiers?: boolean }
312312
) {
@@ -358,7 +358,7 @@ export class InternalModelManager {
358358
.toPromise();
359359
const assertSuccess = throwOnEmptyData ? assertOperationSuccess : assertNullableOperationSuccess;
360360
const result = assertSuccess(response, ["internal", this.apiIdentifier]);
361-
return hydrateRecord(response, result);
361+
return hydrateRecord(response, result, this);
362362
}
363363

364364
/**
@@ -391,7 +391,7 @@ export class InternalModelManager {
391391
const plan = internalFindManyQuery(this.apiIdentifier, options);
392392
const response = await this.connection.currentClient.query(plan.query, plan.variables).toPromise();
393393
const connection = assertNullableOperationSuccess(response, ["internal", `list${this.capitalizedApiIdentifier}`]);
394-
const records = hydrateConnection(response, connection);
394+
const records = hydrateConnection(response, connection, this);
395395

396396
return GadgetRecordList.boot(this, records, { options, pageInfo: connection.pageInfo });
397397
}
@@ -420,7 +420,7 @@ export class InternalModelManager {
420420
connection = assertOperationSuccess(response, ["internal", `list${this.capitalizedApiIdentifier}`], throwOnEmptyData);
421421
}
422422

423-
const records = hydrateConnection(response, connection);
423+
const records = hydrateConnection(response, connection, this);
424424
const recordList = GadgetRecordList.boot(this, records, { options, pageInfo: connection.pageInfo });
425425
return recordList[0];
426426
}
@@ -458,7 +458,7 @@ export class InternalModelManager {
458458
})
459459
.toPromise();
460460
const result = assertMutationSuccess(response, ["internal", `create${this.capitalizedApiIdentifier}`]);
461-
return hydrateRecord(response, result[this.apiIdentifier]);
461+
return hydrateRecord(response, result[this.apiIdentifier], this);
462462
}
463463

464464
/**
@@ -488,7 +488,7 @@ export class InternalModelManager {
488488
})
489489
.toPromise();
490490
const result = assertMutationSuccess(response, ["internal", `bulkCreate${capitalizedPluralApiIdentifier}`]);
491-
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier]);
491+
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier], this);
492492
}
493493

494494
/**
@@ -513,7 +513,7 @@ export class InternalModelManager {
513513
.toPromise();
514514
const result = assertMutationSuccess(response, ["internal", `update${this.capitalizedApiIdentifier}`]);
515515

516-
return hydrateRecord(response, result[this.apiIdentifier]);
516+
return hydrateRecord(response, result[this.apiIdentifier], this);
517517
}
518518

519519
/**

packages/api-client-core/src/ModelManager.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

packages/api-client-core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./AnyClient.js";
2+
export * from "./AnyModelManager.js";
23
export * from "./ClientOptions.js";
34
export * from "./DataHydrator.js";
45
export * from "./FieldSelection.js";
@@ -9,7 +10,6 @@ export * from "./GadgetRecordList.js";
910
export * from "./GadgetTransaction.js";
1011
export * from "./InMemoryStorage.js";
1112
export * from "./InternalModelManager.js";
12-
export * from "./ModelManager.js";
1313
export * from "./operationBuilders.js";
1414
export * from "./operationRunners.js";
1515
export * from "./support.js";

0 commit comments

Comments
 (0)