Skip to content

Commit

Permalink
Give records a reference to the model manager which loaded them
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
airhorns committed Sep 28, 2023
1 parent ddc7ba1 commit 5b44478
Show file tree
Hide file tree
Showing 22 changed files with 183 additions and 101 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"lint:eslint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint --quiet --ext ts,tsx packages scripts",
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" prettier --write --check \"(packages|scripts)/**/*.{js,ts,tsx}\" && eslint --ext ts,tsx --fix packages scripts",
"typecheck": "pnpm -r --no-bail run --if-present typecheck",
"build": "pnpm -r --no-bail run --if-present build",
"build": "pnpm -r run --if-present build",
"prerelease": "pnpm -r --no-bail run --if-present prerelease",
"watch": "run-p --print-label watch:*",
"watch:client": "pnpm --filter=@gadgetinc/api-client-core watch",
Expand Down
15 changes: 15 additions & 0 deletions packages/api-client-core/spec/GadgetRecord.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AnyPublicModelManager } from "../src/AnyModelManager.js";
import { ChangeTracking, GadgetRecord } from "../src/GadgetRecord.js";
interface SampleBaseRecord {
id?: string;
Expand Down Expand Up @@ -38,6 +39,8 @@ const expectPersistedChanges = (record: GadgetRecord<SampleBaseRecord>, ...prope
return _expectChanges(record, ChangeTracking.SinceLastPersisted, ...properties);
};

const mockModelManager: AnyPublicModelManager = {} as any;

describe("GadgetRecord", () => {
let productBaseRecord: SampleBaseRecord;
beforeAll(() => {
Expand All @@ -48,6 +51,18 @@ describe("GadgetRecord", () => {
};
});

it("can be constructed with a base record and no model manager for backwards compatibility", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
expect(product.id).toEqual("123");
expect(product.name).toEqual("A cool product");
expect(product.modelManager).toEqual(null);
});

it("can be constructed with a base record and a model manager", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord, mockModelManager);
expect(product.modelManager).toEqual(mockModelManager);
});

it("should respond toJSON, which returns the inner __gadget.fields properties", () => {
const product = new GadgetRecord<SampleBaseRecord>(productBaseRecord);
expect(product.toJSON()).toEqual({
Expand Down
36 changes: 11 additions & 25 deletions packages/api-client-core/spec/operationRunners.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import nock from "nock";
import type { GadgetErrorGroup } from "../src/index.js";
import type { AnyPublicModelManager, GadgetErrorGroup } from "../src/index.js";
import { GadgetConnection, actionRunner } from "../src/index.js";
import { mockUrqlClient } from "./mockUrqlClient.js";

Expand All @@ -8,17 +8,17 @@ nock.disableNetConnect();
// eslint-disable-next-line jest/no-export
describe("operationRunners", () => {
let connection: GadgetConnection;
let manager: AnyPublicModelManager;
beforeEach(() => {
connection = new GadgetConnection({ endpoint: "https://someapp.gadget.app" });
jest.spyOn(connection, "currentClient", "get").mockReturnValue(mockUrqlClient as any);
manager = { connection } as AnyPublicModelManager;
});

describe("actionRunner", () => {
test("can run a single create action", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"createWidget",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -58,9 +58,7 @@ describe("operationRunners", () => {

test("can run a single update action", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"updateWidget",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -105,9 +103,7 @@ describe("operationRunners", () => {

test("can throw the error returned by the server for a single action", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"updateWidget",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -152,9 +148,7 @@ describe("operationRunners", () => {

test("can run a bulk action by ids", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"bulkFlipWidgets",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -202,9 +196,7 @@ describe("operationRunners", () => {

test("can run a bulk action with params", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"bulkCreateWidgets",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -252,9 +244,7 @@ describe("operationRunners", () => {

test("throws a nice error when a bulk action returns errors", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"bulkCreateWidgets",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -292,9 +282,7 @@ describe("operationRunners", () => {

test("throws a nice error when a bulk action returns errors and data", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"bulkCreateWidgets",
{ id: true, name: true },
"widget",
Expand Down Expand Up @@ -339,9 +327,7 @@ describe("operationRunners", () => {

test("returns undefined when bulk action does not have a result", async () => {
const promise = actionRunner<{ id: string; name: string }>(
{
connection,
},
manager,
"bulkDeleteWidgets",
{ id: true, name: true },
"widget",
Expand Down
31 changes: 31 additions & 0 deletions packages/api-client-core/src/AnyModelManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { GadgetConnection } from "./GadgetConnection";
import type { GadgetRecord } from "./GadgetRecord";
import type { GadgetRecordList } from "./GadgetRecordList";
import type { InternalModelManager } from "./InternalModelManager";

/**
* The manager class for a given model that uses the Public API, like `api.post` or `api.user`
**/
export interface AnyPublicModelManager {
connection: GadgetConnection;
apiIdentifier?: string;
findOne(id: string, options: any): Promise<GadgetRecord<any>>;
maybeFindOne(id: string, options: any): Promise<GadgetRecord<any> | null>;
findMany(options: any): Promise<GadgetRecordList<any>>;
findFirst(options: any): Promise<GadgetRecord<any>>;
maybeFindFirst(options: any): Promise<GadgetRecord<any> | null>;
}

/**
* The manager class for a given single model that uses the Public API, like `api.session`
**/
export interface AnyPublicSingletonModelManager {
connection: GadgetConnection;
apiIdentifier?: string;
get(): Promise<GadgetRecord<any>>;
}

/**
* Any model manager, either public or internal
*/
export type AnyModelManager = AnyPublicModelManager | AnyPublicSingletonModelManager | InternalModelManager;
8 changes: 8 additions & 0 deletions packages/api-client-core/src/GadgetFunctions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AnyPublicModelManager, AnyPublicSingletonModelManager } from "./AnyModelManager.js";
import type { GadgetRecord, RecordShape } from "./GadgetRecord.js";
import type { GadgetRecordList } from "./GadgetRecordList.js";
import type { LimitToKnownKeys, VariablesOptions } from "./types.js";
Expand All @@ -17,6 +18,7 @@ export interface FindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -30,6 +32,7 @@ export interface MaybeFindOneFunction<OptionsT, SelectionT, SchemaT, DefaultsT>
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -42,6 +45,7 @@ export interface FindManyFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -54,6 +58,7 @@ export interface FindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
Expand All @@ -66,6 +71,7 @@ export interface MaybeFindFirstFunction<OptionsT, SelectionT, SchemaT, DefaultsT
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicModelManager;
}

interface ActionWithIdAndVariables<OptionsT, VariablesT> {
Expand Down Expand Up @@ -113,6 +119,7 @@ interface ActionFunctionMetadata<OptionsT, VariablesT, SelectionT, SchemaT, Defa
acceptsModelInput?: boolean;
paramOnlyVariables?: readonly string[];
hasReturnType?: boolean;
modelManager?: AnyPublicModelManager;
}

export type ActionFunction<OptionsT, VariablesT, SelectionT, SchemaT, DefaultsT> = ActionFunctionMetadata<
Expand Down Expand Up @@ -150,6 +157,7 @@ export interface GetFunction<OptionsT, SelectionT, SchemaT, DefaultsT> {
selectionType: SelectionT;
optionsType: OptionsT;
schemaType: SchemaT | null;
modelManager?: AnyPublicSingletonModelManager;
}

export interface GlobalActionFunction<VariablesT> {
Expand Down
9 changes: 6 additions & 3 deletions packages/api-client-core/src/GadgetRecord.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { klona as cloneDeep } from "klona";
import type { Jsonify } from "type-fest";
import type { AnyModelManager } from "./AnyModelManager.js";
import { isEqual, toPrimitiveObject } from "./support.js";

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

private empty = false;

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

/** 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. */
export const GadgetRecord: new <Shape extends RecordShape>(data: Shape) => GadgetRecordImplementation<Shape> & Shape =
GadgetRecordImplementation as any;
export const GadgetRecord: new <Shape extends RecordShape>(
data: Shape,
modelManager?: AnyModelManager
) => GadgetRecordImplementation<Shape> & Shape = GadgetRecordImplementation as any;
export type GadgetRecord<Shape extends RecordShape> = GadgetRecordImplementation<Shape> & Shape;
6 changes: 3 additions & 3 deletions packages/api-client-core/src/GadgetRecordList.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/* eslint-disable no-throw-literal */
/* eslint-disable @typescript-eslint/require-await */
import type { Jsonify } from "type-fest";
import type { AnyPublicModelManager } from "./AnyModelManager.js";
import type { GadgetRecord, RecordShape } from "./GadgetRecord.js";
import type { InternalModelManager } from "./InternalModelManager.js";
import type { AnyModelManager } from "./ModelManager.js";
import type { PaginationOptions } from "./operationBuilders.js";
import { GadgetClientError, GadgetOperationError } from "./support.js";

Expand All @@ -14,12 +14,12 @@ type PaginationConfig = {

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

/** Internal method used to create a list. Should not be used by applications. */
static boot<Shape extends RecordShape>(
modelManager: AnyModelManager | InternalModelManager,
modelManager: AnyPublicModelManager | InternalModelManager,
records: GadgetRecord<Shape>[],
pagination: PaginationConfig
) {
Expand Down
14 changes: 7 additions & 7 deletions packages/api-client-core/src/InternalModelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ export class InternalModelManager {
private readonly capitalizedApiIdentifier: string;

constructor(
private readonly apiIdentifier: string,
readonly apiIdentifier: string,
readonly connection: GadgetConnection,
readonly options?: { pluralApiIdentifier: string; hasAmbiguousIdentifiers?: boolean }
) {
Expand Down Expand Up @@ -358,7 +358,7 @@ export class InternalModelManager {
.toPromise();
const assertSuccess = throwOnEmptyData ? assertOperationSuccess : assertNullableOperationSuccess;
const result = assertSuccess(response, ["internal", this.apiIdentifier]);
return hydrateRecord(response, result);
return hydrateRecord(response, result, this);
}

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

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

const records = hydrateConnection(response, connection);
const records = hydrateConnection(response, connection, this);
const recordList = GadgetRecordList.boot(this, records, { options, pageInfo: connection.pageInfo });
return recordList[0];
}
Expand Down Expand Up @@ -458,7 +458,7 @@ export class InternalModelManager {
})
.toPromise();
const result = assertMutationSuccess(response, ["internal", `create${this.capitalizedApiIdentifier}`]);
return hydrateRecord(response, result[this.apiIdentifier]);
return hydrateRecord(response, result[this.apiIdentifier], this);
}

/**
Expand Down Expand Up @@ -488,7 +488,7 @@ export class InternalModelManager {
})
.toPromise();
const result = assertMutationSuccess(response, ["internal", `bulkCreate${capitalizedPluralApiIdentifier}`]);
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier]);
return hydrateRecordArray(response, result[this.options.pluralApiIdentifier], this);
}

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

return hydrateRecord(response, result[this.apiIdentifier]);
return hydrateRecord(response, result[this.apiIdentifier], this);
}

/**
Expand Down
15 changes: 0 additions & 15 deletions packages/api-client-core/src/ModelManager.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/api-client-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./AnyClient.js";
export * from "./AnyModelManager.js";
export * from "./ClientOptions.js";
export * from "./DataHydrator.js";
export * from "./FieldSelection.js";
Expand All @@ -9,7 +10,6 @@ export * from "./GadgetRecordList.js";
export * from "./GadgetTransaction.js";
export * from "./InMemoryStorage.js";
export * from "./InternalModelManager.js";
export * from "./ModelManager.js";
export * from "./operationBuilders.js";
export * from "./operationRunners.js";
export * from "./support.js";
Expand Down
Loading

0 comments on commit 5b44478

Please sign in to comment.