diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1468dadf4..b1ec80161 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,12 @@ jobs: token: ${{ secrets.NPM_TOKEN }} package: ./packages/api-client-core/package.json access: public + - name: Publish @gadgetinc/core + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_TOKEN }} + package: ./packages/core/package.json + access: public - name: Publish @gadgetinc/react uses: JS-DevTools/npm-publish@v1 with: diff --git a/jest.config.js b/jest.config.js index 8b51cb28f..283e26b22 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,7 @@ module.exports = { projects: [ "/packages/api-client-core/jest.config.js", + "/packages/core/jest.config.js", "/packages/react/jest.config.js", "/packages/react-shopify-app-bridge/jest.config.js", "/packages/tiny-graphql-query-compiler/jest.config.js", diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js new file mode 100644 index 000000000..fd7f1dc08 --- /dev/null +++ b/packages/core/jest.config.js @@ -0,0 +1,189 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +export default { + displayName: "@gadgetinc/core", + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: process.env.LAYERCI ? "/tmp/jest-cache" : "/../../tmp/cache/jest", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + // coverageDirectory: undefined, + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + extensionsToTreatAsEsm: [".ts", ".tsx"], + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: "/../api/spec/jest.globalsetup.ts", + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: "ts-jest", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + restoreMocks: true, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [""], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + //setupFilesAfterEnv: ["/spec/jest.setup.ts"], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ] + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ["/node_modules/"], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + testRunner: "jest-circus/runner", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + transform: { "^.+\\.(t|j)sx?$": ["@swc/jest"] }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + // transformIgnorePatterns: ["/node_modules/(?!lodash)"], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..708792579 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,45 @@ +{ + "name": "@gadgetinc/core", + "version": "0.1.0", + "files": [ + "README.md", + "dist/**/*" + ], + "license": "MIT", + "repository": "github:gadget-inc/js-clients", + "homepage": "https://github.com/gadget-inc/js-clients/tree/main/packages/core", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + } + }, + "source": "src/index.ts", + "main": "dist/cjs/index.js", + "sideEffects": false, + "scripts": { + "typecheck:main": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "clean": "rimraf dist/ *.tsbuildinfo **/*.tsbuildinfo", + "prebuild": "mkdir -p dist/cjs dist/esm && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", + "build": "pnpm clean && pnpm prebuild && tsc -b tsconfig.cjs.json tsconfig.esm.json", + "prepublishOnly": "pnpm build", + "prerelease": "gitpkg publish" + }, + "dependencies": { + "klona": "^2.0.6" + }, + "peerDependencies": { + "@urql/core": "*", + "graphql": "*", + "graphql-ws": "*" + }, + "devDependencies": { + "type-fest": "^3.13.1", + "conditional-type-checks": "^1.0.6", + "typescript": "5.4.5" + } +} diff --git a/packages/core/spec/GadgetRecord.spec.ts b/packages/core/spec/GadgetRecord.spec.ts new file mode 100644 index 000000000..36a6e70f9 --- /dev/null +++ b/packages/core/spec/GadgetRecord.spec.ts @@ -0,0 +1,763 @@ +import { ChangeTracking, GadgetRecord } from "../src/GadgetRecord.js"; +interface SampleBaseRecord { + id?: string; + state?: any; + stateHistory?: any; + [key: string]: any; +} + +const _expectNoChanges = (record: GadgetRecord, tracking: ChangeTracking, ...properties: string[]) => { + for (const property of properties) { + expect(record.changed(property, tracking)).toEqual(false); + expect(record.changes(property, tracking)).toEqual({ changed: false }); + expect(record.changes(tracking)[property]).toBeUndefined(); + } +}; + +const expectNoChanges = (record: GadgetRecord, ...properties: string[]) => { + return _expectNoChanges(record, ChangeTracking.SinceLoaded, ...properties); +}; + +const expectNoPersistedChanges = (record: GadgetRecord, ...properties: string[]) => { + return _expectNoChanges(record, ChangeTracking.SinceLastPersisted, ...properties); +}; + +const _expectChanges = (record: GadgetRecord, tracking: ChangeTracking, ...properties: string[]) => { + for (const property of properties) { + expect(record.changed(property, tracking)).toEqual(true); + expect(record.changes(property, tracking).changed).toEqual(true); + expect(property in record.changes(tracking)).toEqual(true); + } +}; + +const expectChanges = (record: GadgetRecord, ...properties: string[]) => { + return _expectChanges(record, ChangeTracking.SinceLoaded, ...properties); +}; + +const expectPersistedChanges = (record: GadgetRecord, ...properties: string[]) => { + return _expectChanges(record, ChangeTracking.SinceLastPersisted, ...properties); +}; + +describe("GadgetRecord", () => { + let productBaseRecord: SampleBaseRecord; + beforeAll(() => { + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + }; + }); + + it("should respond toJSON, which returns the inner __gadget.fields properties", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.toJSON()).toEqual({ + ...productBaseRecord, + }); + }); + + it("should proxy properties to the inner __gadget.fields object", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.id).toEqual("123"); + }); + + it("should return undefined for any property that doesn't exist on the __gadget.fields object", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.foo).toBeUndefined(); + }); + + it("should allow you to set properties that were instantiated with the record", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.name).toEqual("A cool product"); + + product.name = "An even cooler product"; + expect(product.name).toEqual("An even cooler product"); + }); + + it("should allow you to set new properties on the __gadget.fields object", () => { + const product = new GadgetRecord(productBaseRecord); + + product.state = "created"; + expect(product.state).toEqual("created"); + }); + + it("should allow you to ask for changes on a specific property", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name"); + + product.name = "A newer name"; + expect(product.changed("name")).toEqual(true); + expect(product.changes("name")).toEqual({ changed: true, current: "A newer name", previous: "A cool product" }); + }); + + it("should only look at the most recent change to a property", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name"); + + product.name = "A newer name"; + expect(product.changed("name")).toEqual(true); + + product.name = "An even newer name"; + expect(product.changes("name")).toEqual({ changed: true, current: "An even newer name", previous: "A cool product" }); + }); + + it("should treat null as a dirty value", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name"); + + product.name = null; + product.newField = null; + expectChanges(product, "name", "newField"); + expect(product.changes("name")).toEqual({ changed: true, current: null, previous: "A cool product" }); + }); + + it("should treat undefined as a dirty value", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name"); + + product.name = undefined; + expectChanges(product, "name"); + expect(product.changes("name")).toEqual({ changed: true, current: undefined, previous: "A cool product" }); + }); + + it("changing a date's timezone shouldn't count as a change, as it is the same moment in time", () => { + const product = new GadgetRecord<{ date: Date }>({ date: new Date("2018-10-16T10:02:34+01:00") }); + expectNoChanges(product, "date"); + + product.date = new Date("2018-10-16T09:02:34.000Z"); + expect(product.changed("date")).toEqual(false); + + product.date = new Date(); + expect(product.changed("date")).toEqual(true); + }); + + it("changing a belongsTo field from a string ID to a _link object shouldn't count as a change", () => { + const product = new GadgetRecord<{ shop: string | { _link: string } }>({ shop: "123" }); + expectNoChanges(product, "shop"); + + product.shop = { _link: "123" }; + expect(product.changed("shop")).toEqual(false); + + product.shop = { _link: "124" }; + expect(product.changed("shop")).toEqual(true); + + product.shop = { _link: "123" }; + expect(product.changed("shop")).toEqual(false); + }); + + it("changing a belongsTo field from a _link object to an id string shouldn't count as a change", () => { + const product = new GadgetRecord<{ shop: string | { _link: string } }>({ shop: { _link: "123" } }); + expectNoChanges(product, "shop"); + + product.shop = "123"; + expect(product.changed("shop")).toEqual(false); + + product.shop = "124"; + expect(product.changed("shop")).toEqual(true); + + product.shop = "123"; + expect(product.changed("shop")).toEqual(false); + }); + + it("should allow you to ask for changes on the entire object", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name", "body", "count"); + + product.name = "A newer name"; + product.body = "A new description"; + product.count = 123; + + expect(product.changed("name")).toEqual(true); + expect(product.changed("body")).toEqual(true); + expect(product.changed("count")).toEqual(true); + expect(product.changed()).toEqual(true); + expect(product.changes()).toEqual({ + name: { current: "A newer name", previous: "A cool product" }, + body: { current: "A new description", previous: "A description of why it's cool" }, + count: { current: 123, previous: undefined }, + }); + }); + + it("should allow you to ask for CURRENT values of changed props on the entire object", () => { + const product = new GadgetRecord(productBaseRecord); + expectNoChanges(product, "name", "body", "count"); + + product.name = "A newer name"; + product.count = 123; + + expect(product.changed("name")).toEqual(true); + expect(product.changed("count")).toEqual(true); + expect(product.changed("body")).toEqual(false); + expect(product.changed()).toEqual(true); + expect(product.toChangedJSON()).toEqual({ + name: "A newer name", + count: 123, + }); + }); + + it("should allow dirty tracking on array fields", () => { + const product = new GadgetRecord({ ...productBaseRecord, anArray: [1, 2, 3] }); + expectNoChanges(product, "anArray"); + + product.anArray.push(4); + expect(product.changes("anArray")).toEqual({ changed: true, current: [1, 2, 3, 4], previous: [1, 2, 3] }); + expect(product.changed("anArray")).toEqual(true); + + product.anArray = [3, 4, 5]; + expect(product.changes("anArray")).toEqual({ changed: true, current: [3, 4, 5], previous: [1, 2, 3] }); + expect(product.changed("anArray")).toEqual(true); + }); + + it("should allow dirty tracking on nested objects", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject }); + expectNoChanges(product, "nestedObject"); + + product.nestedObject.subArray.push(4); + expect(product.changed("nestedObject")).toEqual(true); + expect(product.changes("nestedObject")).toEqual({ + changed: true, + current: { foo: "bar", subArray: [1, 2, 3, 4] }, + previous: { foo: "bar", subArray: [1, 2, 3] }, + }); + }); + + it("should allow an object to become dirty, then return to a non dirty state", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.name = productBaseRecord.name; + product.body = productBaseRecord.body; + product.nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + product.anArray = [1, 2, 3]; + product.newField = undefined; + expectNoChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + }); + + it("should allow dirty changes to be flushed", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.flushChanges(); + expectNoChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + }); + + it("should allow persisted dirty changes to be flushed", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.flushChanges(ChangeTracking.SinceLastPersisted); + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectNoPersistedChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + }); + + it("should allow dirty changes to be reverted", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.revertChanges(); + expectNoChanges(product, "name", "description", "nestedObject", "anArray", "newField"); + + expect(product.name).toEqual(productBaseRecord.name); + expect(product.body).toEqual(productBaseRecord.body); + expect(product.nestedObject).toEqual({ foo: "bar", subArray: [1, 2, 3] }); + expect(product.anArray).toEqual([1, 2, 3]); + expect(product.newField).toBeUndefined(); + }); + + it("should allow dirty changes to be reverted to the persisted state", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + product.flushChanges(ChangeTracking.SinceLastPersisted); + + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectNoPersistedChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.name = "A very dirty name"; + product.body = "A very dirty description"; + product.nestedObject = { different: "thing" }; + product.anArray = [3, 2, 1]; + + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectPersistedChanges(product, "name", "body", "nestedObject", "anArray"); + + expect(product.name).toEqual("A very dirty name"); + expect(product.body).toEqual("A very dirty description"); + expect(product.nestedObject).toEqual({ different: "thing" }); + expect(product.anArray).toEqual([3, 2, 1]); + expect(product.newField).toEqual("foo"); + + product.revertChanges(ChangeTracking.SinceLastPersisted); + + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectNoPersistedChanges(product, "name", "description", "nestedObject", "anArray", "newField"); + + expect(product.name).toEqual("A dirty name"); + expect(product.body).toEqual("An even dirtier description"); + expect(product.nestedObject).toEqual({ foo: "baz", subArray: [1, 2, 3] }); + expect(product.anArray).toEqual([1, 2]); + expect(product.newField).toEqual("foo"); + }); + + test("should allow reverting of ChangeTracking.SinceLoaded changes without affect ChangeTracking.SincePersisted changes", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + expectNoChanges(product, "name", "description", "nestedObject", "anArray"); + + product.name = "A dirty name"; + product.body = "An even dirtier description"; + product.nestedObject.foo = "baz"; + product.anArray.pop(); + product.newField = "foo"; + + expectChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectPersistedChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.flushChanges(ChangeTracking.SinceLoaded); + product.name = "A newer name"; + expectChanges(product, "name"); + expectNoChanges(product, "body", "nestedObject", "anArray", "newField"); + expectPersistedChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + product.revertChanges(ChangeTracking.SinceLoaded); + expectNoChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + expectPersistedChanges(product, "name", "body", "nestedObject", "anArray", "newField"); + + expect(product.name).toEqual("A dirty name"); + expect(product.body).toEqual("An even dirtier description"); + expect(product.nestedObject).toEqual({ foo: "baz", subArray: [1, 2, 3] }); + expect(product.anArray).toEqual([1, 2]); + expect(product.newField).toEqual("foo"); + }); + + test("should allow touching, which marks the record as changed without changing field values", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + product.touch(); + expect(product.changed()).toBe(true); + expect(product.changed(ChangeTracking.SinceLastPersisted)).toBe(true); + expect(product.changed(ChangeTracking.SinceLoaded)).toBe(true); + expect(product.changes()).toEqual({}); + expect(product.toChangedJSON()).toEqual({}); + }); + + test("reverting changes clears touched status", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + product.touch(); + expect(product.changed()).toBe(true); + product.revertChanges(); + expect(product.changed()).toBe(false); + }); + + test("should allow touching and changing fields together", () => { + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + product.name = "A new name"; + expect(product.changed()).toBe(true); + product.touch(); + expect(product.changed()).toBe(true); + product.revertChanges(); + expect(product.changed()).toBe(false); + }); + + test("arrays and objects stored in the instantiated and persisted fields should be independent of each other and active fields", () => { + const nestedObject = { foo: "bar", subArray: [1, 2, 3] }; + const anArray = [1, 2, 3]; + const product = new GadgetRecord({ ...productBaseRecord, nestedObject, anArray }); + + product.revertChanges(); + product.anArray.pop(); + product.nestedObject.foo = "baz"; + expect(product.changed("anArray")).toEqual(true); + expect(product.changed("nestedObject")).toEqual(true); + expect(product.anArray).toEqual([1, 2]); + expect(product.nestedObject.foo).toEqual("baz"); + + product.revertChanges(ChangeTracking.SinceLastPersisted); + product.anArray.pop(); + product.nestedObject.foo = "baz"; + expect(product.changed("anArray", ChangeTracking.SinceLastPersisted)).toEqual(true); + expect(product.changed("nestedObject", ChangeTracking.SinceLastPersisted)).toEqual(true); + expect(product.anArray).toEqual([1, 2]); + expect(product.nestedObject.foo).toEqual("baz"); + + product.flushChanges(); + expect(product.anArray).toEqual([1, 2]); + expect(product.nestedObject.foo).toEqual("baz"); + expect(product.changed("anArray")).toEqual(false); + expect(product.changed("nestedObject")).toEqual(false); + expect(product.changed("anArray", ChangeTracking.SinceLastPersisted)).toEqual(true); + expect(product.changed("nestedObject", ChangeTracking.SinceLastPersisted)).toEqual(true); + }); + + it("should allow fields with names that conflict with the GadgetRecord API to be accessed via getField() and setField()", () => { + const product = new GadgetRecord({ ...productBaseRecord, changed: false }); + + expect(product.getField("changed")).toEqual(false); + product.setField("changed", true); + expect(product.getField("changed")).toEqual(true); + }); + + describe("record changes for current Date values against previous string value", () => { + it("correctly calculates overall record changed when the current value is the same Date as the previous value", () => { + // tests record.changed() with no specific field + const dateStr = "2024-04-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: dateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + + product.publishedAt = new Date(dateStr); + expect(product.changed()).toBe(false); + }); + + it("correctly calculates record changed for a certain field when the current value is a Date and is the same date as the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const dateStr = "2024-04-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: dateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(dateStr); + expect(product.changed("publishedAt")).toBe(false); + }); + + it("correctly calculates record changed for a certain field when the current value is a string and is the same date as the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const dateStr = "2024-04-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: new Date(dateStr), + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = dateStr; + expect(product.changed("publishedAt")).toBe(false); + }); + + it("correctly calculates overall record changed when the current value is a newer Date than the previous value", () => { + // tests record.changed() with no specific field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + + product.publishedAt = new Date(newDateStr); + expect(product.changed()).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is a Date and is a newer date than the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(newDateStr); + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is a string and is a newer date than the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: new Date(oldDateStr), + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = newDateStr; + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates overall record changed when the current value is an older Date than the previous value", () => { + // tests record.changed() with no specific field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: newDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed()).toBe(false); + + product.publishedAt = new Date(oldDateStr); + expect(product.changed()).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is a Date and is an older date than the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: newDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(oldDateStr); + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is a string and is an older date than the previous value", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldDateStr = "2024-04-19T21:03:37.000Z"; + const newDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: new Date(newDateStr), + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = oldDateStr; + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is an invalid date string", () => { + // tests record.changed("publishedAt") ie changes for a certain field + // if the current value is an invalid date string and the previous was a valid date then record changed should be true + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = "invalidDate"; + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current value is an invalid Date", () => { + // tests record.changed("publishedAt") ie changes for a certain field + // if the current value is an invalid Date and the previous was a valid date then record changed should be true + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date("invalid"); + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current and previous values are Dates", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + const newValidDateStr = "2024-11-19T21:03:37.000Z"; + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: new Date(oldValidDateStr), + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(newValidDateStr); + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current date as a string does not have a time and is a different date than previous", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + const newValidDateStr = "2024-11-19"; // new date with no time + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = newValidDateStr; + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current date as a string does not have a time and is same date as previous", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + const newValidDateStr = "2024-04-19"; // same date with no time + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = newValidDateStr; + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current date as a Date does not have a time and is a different date than previous", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + const newValidDateStr = "2024-11-19"; // new date with no time + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(newValidDateStr); + expect(product.changed("publishedAt")).toBe(true); + }); + + it("correctly calculates record changed for a certain field when the current date as a Date does not have a time and is same date as previous", () => { + // tests record.changed("publishedAt") ie changes for a certain field + + const oldValidDateStr = "2024-04-19T21:03:37.000Z"; + const newValidDateStr = "2024-04-19"; // same date with no time + + productBaseRecord = { + id: "123", + name: "A cool product", + body: "A description of why it's cool", + publishedAt: oldValidDateStr, + }; + + const product = new GadgetRecord(productBaseRecord); + expect(product.changed("publishedAt")).toBe(false); + + product.publishedAt = new Date(newValidDateStr); + expect(product.changed("publishedAt")).toBe(true); + }); + }); +}); diff --git a/packages/core/spec/GadgetRecordList.spec.ts b/packages/core/spec/GadgetRecordList.spec.ts new file mode 100644 index 000000000..708e2069f --- /dev/null +++ b/packages/core/spec/GadgetRecordList.spec.ts @@ -0,0 +1,120 @@ +import { jest } from "@jest/globals"; +import { AnyInternalModelManager, GadgetRecordList, InternalFindManyOptions } from "../src/index.js"; + +describe("GadgetRecordList", () => { + const createModelManager = ( + start: number, + expectation?: (startIndex: number, options: InternalFindManyOptions) => void + ): AnyInternalModelManager => { + let startIndex = start; + const modelManager = { + findMany: jest.fn().mockImplementation(async (options?: InternalFindManyOptions) => { + if (!options) { + throw new Error("Expected options to be defined"); + } + + expectation?.(startIndex, options); + const newPageInfo = pages[options.first ? ++startIndex : --startIndex]; + + return GadgetRecordList.boot(modelManager, [], { options, pageInfo: newPageInfo }); + }), + } as unknown as AnyInternalModelManager; + + return modelManager; + }; + + afterEach(() => jest.clearAllMocks()); + + const pages: { startCursor: string; endCursor: string; hasNextPage: boolean; hasPreviousPage: boolean }[] = [ + { + startCursor: "start1", + endCursor: "end1", + hasNextPage: true, + hasPreviousPage: false, + }, + { + startCursor: "start2", + endCursor: "end2", + hasNextPage: true, + hasPreviousPage: true, + }, + { + startCursor: "start3", + endCursor: "end3", + hasNextPage: true, + hasPreviousPage: true, + }, + { + startCursor: "start4", + endCursor: "end4", + hasNextPage: false, + hasPreviousPage: true, + }, + ]; + test("sends correct page info when paging forward", async () => { + const modelManager = createModelManager(0, (startIndex, options) => { + expect(options.after).toBe(pages[startIndex].endCursor); + expect(options.first).toBe(10); + expect(options.last).toBeUndefined(); + }); + let recordList = GadgetRecordList.boot(modelManager, [], { pageInfo: pages[0], options: { first: 10 } }); + + recordList = await recordList.nextPage(); + recordList = await recordList.nextPage(); + await recordList.nextPage(); + }); + + test("sends correct page info when paging backward", async () => { + const modelManager = createModelManager(3, (startIndex, options) => { + expect(options.before).toBe(pages[startIndex].startCursor); + expect(options.last).toBe(10); + expect(options.first).toBeUndefined(); + }); + let recordList = GadgetRecordList.boot(modelManager, [], { pageInfo: pages[3], options: { last: 10 } }); + + recordList = await recordList.previousPage(); + recordList = await recordList.previousPage(); + await recordList.previousPage(); + }); + + test("does not send both first/last when paging forward and backward", async () => { + const modelManager = createModelManager(0, (startIndex, options) => { + if (options.before || options.last) { + expect(options.after).toBeUndefined(); + expect(options.first).toBeUndefined(); + } else { + expect(options.before).toBeUndefined(); + expect(options.last).toBeUndefined(); + } + }); + let recordList = GadgetRecordList.boot(modelManager, [], { pageInfo: pages[0], options: { first: 10 } }); + + recordList = await recordList.nextPage(); + recordList = await recordList.nextPage(); + recordList = await recordList.nextPage(); + recordList = await recordList.previousPage(); + recordList = await recordList.previousPage(); + recordList = await recordList.nextPage(); + recordList = await recordList.previousPage(); + + await recordList.previousPage(); + }); + + test("throws if paging backward is not possible", async () => { + const modelManager = createModelManager(0); + const recordList = GadgetRecordList.boot(modelManager, [], { pageInfo: pages[0], options: { first: 10 } }); + + await expect(recordList.previousPage()).rejects.toThrow( + "Cannot request previous page because there isn't one, should check 'hasPreviousPage' to see if it exists" + ); + }); + + test("throws if paging forward is not possible", async () => { + const modelManager = createModelManager(3); + const recordList = GadgetRecordList.boot(modelManager, [], { pageInfo: pages[3], options: { last: 10 } }); + + await expect(recordList.nextPage()).rejects.toThrow( + "Cannot request next page because there isn't one, should check 'hasNextPage' to see if it exists" + ); + }); +}); diff --git a/packages/core/spec/InvalidRecordError.spec.ts b/packages/core/spec/InvalidRecordError.spec.ts new file mode 100644 index 000000000..cc1bed612 --- /dev/null +++ b/packages/core/spec/InvalidRecordError.spec.ts @@ -0,0 +1,78 @@ +import { InvalidRecordError } from "../src/index.js"; + +describe("InvalidRecordError", () => { + test("it should compute a message with no validation errors", () => { + const error = new InvalidRecordError(null, []); + expect(error.message).toMatchInlineSnapshot(`"GGT_INVALID_RECORD: Record is invalid and can't be saved. ."`); + expect(error.code).toEqual("GGT_INVALID_RECORD"); + expect(error.causedByClient).toBeTruthy(); + }); + + test("it should compute a message with two validation errors", () => { + const error = new InvalidRecordError(null, [ + { + apiIdentifier: "name", + message: "is not unique", + }, + { + apiIdentifier: "title", + message: "is required", + }, + ]); + expect(error.message).toMatchInlineSnapshot( + `"GGT_INVALID_RECORD: Record is invalid and can't be saved. name is not unique, title is required."` + ); + }); + + test("it should compute a message with 4 validation errors", () => { + const error = new InvalidRecordError(null, [ + { + apiIdentifier: "name", + message: "is not unique", + }, + { + apiIdentifier: "title", + message: "is required", + }, + { + apiIdentifier: "body", + message: "is required", + }, + { + apiIdentifier: "body", + message: "must be longer than 15 characters", + }, + ]); + expect(error.message).toMatchInlineSnapshot( + `"GGT_INVALID_RECORD: Record is invalid and can't be saved. name is not unique, title is required, body is required, and 1 more error needs to be corrected."` + ); + }); + + test("it should compute a message with many validation errors", () => { + const error = new InvalidRecordError(null, [ + { + apiIdentifier: "name", + message: "is not unique", + }, + { + apiIdentifier: "title", + message: "is required", + }, + { + apiIdentifier: "body", + message: "is required", + }, + { + apiIdentifier: "body", + message: "must be longer than 15 characters", + }, + { + apiIdentifier: "publishDate", + message: "is required", + }, + ]); + expect(error.message).toMatchInlineSnapshot( + `"GGT_INVALID_RECORD: Record is invalid and can't be saved. name is not unique, title is required, body is required, and 2 more errors need to be corrected."` + ); + }); +}); diff --git a/packages/core/spec/Select-type.spec.ts b/packages/core/spec/Select-type.spec.ts new file mode 100644 index 000000000..0ca1a7e5c --- /dev/null +++ b/packages/core/spec/Select-type.spec.ts @@ -0,0 +1,79 @@ +import type { AssertTrue, IsExact } from "conditional-type-checks"; +import type { DeepFilterNever, Select } from "../src/types.js"; +import type { TestSchema } from "./TestSchema.js"; + +describe("Select<>", () => { + type _SelectingProperties = AssertTrue, { num: number }>>; + + type _ConditionallySelectingProperties = AssertTrue< + IsExact, { num: number }> + >; + + type _SelectingNestedProperties = AssertTrue< + IsExact< + Select, + { num: number; obj: { test: "test"; deep: { property: string } } } + > + >; + + type _SelectingCircularProperties = AssertTrue< + IsExact< + Select< + TestSchema, + { + num: true; + nested: { + bool: true; + nested: { + bool: true; + }; + }; + } + >, + { + num: number; + nested: { + bool: boolean; + nested: { + bool: boolean; + }; + }; + } + > + >; + + type _FilteredNever = AssertTrue< + IsExact, { c: string; d: { e: boolean } }> + >; + + type _optionalNestedPropertySelection = Select; + type _TestSelectingOptionalNestedProperties = AssertTrue< + IsExact<_optionalNestedPropertySelection, { optionalObj: { test: "test" } | null }> + >; + + type _listSelection = Select; + type _TestSelectingLists = AssertTrue>; + + type _optionalListSelection = Select; + type _TestSelectingOptionalLists = AssertTrue< + IsExact<_optionalListSelection, { optionalList: { title: "listy"; stuff: number[] | null }[] | null }> + >; + + type _connectionSelection = Select< + TestSchema, + { someConnection: { pageInfo: { hasNextPage: true }; edges: { node: { id: true; state: true } } } } + >; + type _TestSelectingConnection = AssertTrue< + IsExact< + _connectionSelection, + { + someConnection: { + pageInfo: { hasNextPage: boolean }; + edges: ({ node: { id: string; state: string } | null } | null)[] | null; + }; + } + > + >; + + test("true", () => undefined); +}); diff --git a/packages/core/spec/TestSchema.ts b/packages/core/spec/TestSchema.ts new file mode 100644 index 000000000..8e9a9b3b0 --- /dev/null +++ b/packages/core/spec/TestSchema.ts @@ -0,0 +1,84 @@ +import type { GadgetRecord } from "src/index.js"; +import type { AvailableSelection, DeepFilterNever, DefaultSelection, Select, Selectable } from "../src/types.js"; + +export type NestedThing = { + bool: boolean; + string: string; + nested: NestedThing; +}; + +export type TestSchema = { + num: number; + str: string; + nested: NestedThing; + obj: { + test: "test"; + bool: boolean; + deep: { + property: string; + }; + }; + optionalObj: { + test: "test"; + bool: boolean; + } | null; + list: { + title: "listy"; + stuff: number[] | null; + }[]; + optionalList: + | { + title: "listy"; + stuff: number[] | null; + }[] + | null; + someConnection: { + edges: + | ({ + node: { + id: string; + state: string; + } | null; + } | null)[] + | null; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }; +}; + +export type AvailableTestSchemaSelection = AvailableSelection; + +export const DefaultPostSelection = { + __typename: true, + createdAt: true, + id: true, + updatedAt: true, +} as const; + +export type Post = { + __typename: "Post"; + id: string; + createdAt: Date; + updatedAt: Date; +}; + +export type AvailablePostSelection = { + __typename?: boolean | null | undefined; + id?: boolean | null | undefined; + createdAt?: boolean | null | undefined; + updatedAt?: boolean | null | undefined; +}; + +export type SelectedPostOrDefault> = DeepFilterNever< + Select> +>; + +export interface CreatePostOptions { + select?: AvailablePostSelection; +} + +export type CreatePostResult = SelectedPostOrDefault extends void + ? void + : GadgetRecord>; diff --git a/packages/core/spec/are-the-types-wrong.spec.ts b/packages/core/spec/are-the-types-wrong.spec.ts new file mode 100644 index 000000000..91dd4694d --- /dev/null +++ b/packages/core/spec/are-the-types-wrong.spec.ts @@ -0,0 +1,11 @@ +import execa from "execa"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("package.json types exports", () => { + it("should have the correct types exports", async () => { + await execa("pnpm", ["exec", "attw", "--pack", "."], { cwd: path.resolve(__dirname, "..") }); + }); +}); diff --git a/packages/core/spec/default-selection.spec.ts b/packages/core/spec/default-selection.spec.ts new file mode 100644 index 000000000..795a83a2e --- /dev/null +++ b/packages/core/spec/default-selection.spec.ts @@ -0,0 +1,11 @@ +import type { AssertTrue, IsExact } from "conditional-type-checks"; +import type { DefaultSelection } from "../src/types.js"; +import type { AvailableTestSchemaSelection } from "./TestSchema.js"; + +type _NullDefault = DefaultSelection; +type _TestDefaultsNullToTheDefault = AssertTrue>; + +type _NonNullDefault = DefaultSelection; +type _TestRespectsTruthySelections = AssertTrue>; + +test("true", () => undefined); diff --git a/packages/core/spec/mockActions.ts b/packages/core/spec/mockActions.ts new file mode 100644 index 000000000..5ff203e5a --- /dev/null +++ b/packages/core/spec/mockActions.ts @@ -0,0 +1,205 @@ +import type { ActionFunction, BulkActionFunction, GlobalActionFunction } from "../src/index.js"; + +export const MockWidgetCreateAction = { + type: "action", + isBulk: false, + defaultSelection: { + id: true, + name: true, + }, + operationName: "createWidget", + operationReturnType: "CreateWidget", + modelApiIdentifier: "widget", + operatesWithRecordIdentity: false, + acceptsModelInput: true, + modelSelectionField: "widget", + variables: { + widget: { + type: "CreateWidgetInput", + required: true, + }, + }, + hasReturnType: false, +} as unknown as ActionFunction< + { select?: { id?: boolean; name?: boolean } }, + any, + { id?: boolean; name?: boolean }, + { id: string; name: string }, + { id: true; name: true } +>; + +export const MockWidgetUpdateAction = { + type: "action", + isBulk: false, + defaultSelection: { + id: true, + name: true, + }, + operationName: "updateWidget", + operationReturnType: "UpdateWidget", + modelApiIdentifier: "widget", + operatesWithRecordIdentity: true, + acceptsModelInput: true, + modelSelectionField: "widget", + variables: { + widget: { + type: "UpdateWidgetInput", + required: true, + }, + }, + hasReturnType: false, +} as unknown as ActionFunction< + { select?: { id?: boolean; name?: boolean } }, + any, + { id?: boolean; name?: boolean }, + { id: string; name: string }, + { id: true; name: true } +>; + +export const MockUpsertWidgetAction = { + type: "action", + isBulk: false, + defaultSelection: { + id: true, + name: true, + }, + operationName: "upsertWidget", + operationReturnType: "UpsertWidget", + modelApiIdentifier: "widget", + operatesWithRecordIdentity: false, + acceptsModelInput: true, + modelSelectionField: "widget", + variables: { + on: { required: false, type: "[String!]" }, + widget: { required: true, type: "UpsertWidgetInput" }, + }, + paramOnlyVariables: ["on"], + hasReturnType: { + "... on CreateWidgetResult": { hasReturnType: false }, + "... on UpdateWidgetResult": { hasReturnType: false }, + }, +} as unknown as ActionFunction< + { select?: { id?: boolean; name?: boolean } }, + any, + { id?: boolean; name?: boolean }, + { id: string; name: string }, + { id: true; name: true } +>; + +export const MockBulkCreateWidgetAction = { + type: "action", + operationName: "bulkCreateWidgets", + operationReturnType: "CreateWidget", + namespace: null, + modelApiIdentifier: "widget", + operatesWithRecordIdentity: false, + modelSelectionField: "widgets", + isBulk: true, + defaultSelection: { + id: true, + name: true, + }, + selectionType: {}, + optionsType: {}, + schemaType: null, + variablesType: void 0, + variables: { + inputs: { + required: true, + type: "[BulkCreateWidgetsInput!]", + }, + }, + acceptsModelInput: true, + hasReturnType: false, + singleAction: MockWidgetCreateAction, +} as unknown as BulkActionFunction; + +export const MockBulkUpdateWidgetAction = { + type: "action", + operationName: "bulkUpdateWidgets", + operationReturnType: "UpdateWidget", + namespace: null, + modelApiIdentifier: "widget", + operatesWithRecordIdentity: true, + modelSelectionField: "widgets", + isBulk: true, + defaultSelection: { + id: true, + name: true, + }, + selectionType: {}, + optionsType: {}, + schemaType: null, + variablesType: void 0, + variables: { + inputs: { + required: true, + type: "[BulkUpdateWidgetsInput!]", + }, + }, + acceptsModelInput: true, + hasReturnType: false, + singleAction: MockWidgetUpdateAction, +} as unknown as BulkActionFunction; + +export const MockBulkUpsertWidgetAction = { + type: "action", + operationName: "bulkUpsertWidgets", + namespace: null, + modelApiIdentifier: "widget", + operatesWithRecordIdentity: false, + modelSelectionField: "widgets", + isBulk: true, + defaultSelection: { + id: true, + name: true, + }, + variables: { + inputs: { + required: true, + type: "[BulkUpsertWidgetsInput!]", + }, + }, + acceptsModelInput: true, + hasReturnType: false, + singleAction: MockUpsertWidgetAction, +} as unknown as BulkActionFunction; + +export const MockBulkFlipDownWidgetsAction = { + type: "action", + operationName: "bulkFlipDownWidgets", + operationReturnType: "FlipDownWidget", + namespace: null, + modelApiIdentifier: "widget", + operatesWithRecordIdentity: true, + modelSelectionField: "widgets", + isBulk: true, + defaultSelection: { + id: true, + name: true, + }, + selectionType: {}, + optionsType: {}, + schemaType: null, + variablesType: void 0, + variables: { + ids: { + required: true, + type: "[GadgetID!]", + }, + }, + hasReturnType: false, +} as unknown as BulkActionFunction; + +export const MockGlobalAction = { + type: "globalAction", + isBulk: false, + operationName: "flipAllWidgets", + operationReturnType: "FlipAllWidgets", + variables: { + toState: { + type: "String", + required: true, + }, + }, +} as unknown as GlobalActionFunction; diff --git a/packages/core/spec/support.spec.ts b/packages/core/spec/support.spec.ts new file mode 100644 index 000000000..620d9d44f --- /dev/null +++ b/packages/core/spec/support.spec.ts @@ -0,0 +1,537 @@ +import { GraphQLError } from "@0no-co/graphql.web"; +import { CombinedError } from "@urql/core"; +import { + assertMutationSuccess, + assertNullableOperationSuccess, + assertOperationSuccess, + disambiguateActionVariables, + disambiguateBulkActionVariables, + formatErrorMessages, + GadgetOperationError, + getNonNullableError, + InvalidRecordError, +} from "../src/index.js"; +import { + MockBulkCreateWidgetAction, + MockBulkFlipDownWidgetsAction, + MockBulkUpdateWidgetAction, + MockWidgetCreateAction, + MockWidgetUpdateAction, +} from "./mockActions.js"; + +describe("support utilities", () => { + describe("assertOperationSuccess", () => { + test("returns the result at the datapath if the operation was successful", () => { + expect( + assertOperationSuccess( + { + operation: null as any, + data: { foo: { bar: "baz" } }, + stale: false, + hasNext: false, + }, + ["foo", "bar"] + ) + ).toEqual("baz"); + }); + + test("throws the operation error if there's a network error on the operation", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ networkError: new Error("foobar") }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"[Network] foobar"`); + }); + + test("throws the operation error if with the full network error as a string if there is no error message", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ networkError: new Error() }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrow(`[Network] No message, error:`); + }); + + test("throws an actual error object and not a string so that the user gets a stack message", () => { + try { + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ networkError: new Error("foobar") }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ); + } catch (error: any) { + expect(error).toBeInstanceOf(Error); + } + }); + + test("throws the operation error if there's a error on the operation", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ graphQLErrors: [new Error("foo")] }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"[GraphQL] foo"`); + }); + + test("throws the operation error if there's multiple errors on the operation", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ graphQLErrors: [new Error("foo"), "bar"] }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(` + "[GraphQL] foo + [GraphQL] bar" + `); + }); + + test("throws the operation error if there's a graphql error on the operation", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ graphQLErrors: [new GraphQLError("inner graphql error")] }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"[GraphQL] inner graphql error"`); + }); + + test("throws the operation error if there's no error on the operation but urql still throws a CombinedError", () => { + expect(() => + assertOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ response: { whatever: true } }), + stale: false, + hasNext: false, + }, + + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(`""`); + }); + }); + + describe("assertNullableOperationSuccess", () => { + test("throws the operation error if there's a graphql error on the operation", () => { + expect(() => + assertNullableOperationSuccess( + { + operation: null as any, + data: null, + error: new CombinedError({ graphQLErrors: [new GraphQLError("inner graphql error")] }), + stale: false, + hasNext: false, + }, + ["foo", "bar"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"[GraphQL] inner graphql error"`); + }); + + test("returns null if data is missing", () => { + const result = assertNullableOperationSuccess( + { + operation: null as any, + data: undefined, + error: undefined, + stale: false, + hasNext: false, + }, + ["foo", "bar"] + ); + expect(result).toBeNull(); + }); + }); + + describe("GadgetOperationError", () => { + test("adds the error code to messages that don't have it already", () => { + const error = new GadgetOperationError("some message", "GGT_SOMETHING"); + expect(error.message).toEqual("GGT_SOMETHING: some message"); + }); + test("doesn't add the error code to messages that have it already", () => { + const error = new GadgetOperationError("GGT_SOMETHING: some message", "GGT_SOMETHING"); + expect(error.message).toEqual("GGT_SOMETHING: some message"); + }); + }); + + describe("getNonNullableError", () => { + test("returns an error if data is undefined", () => { + const error = getNonNullableError({ fetching: false, data: undefined }, ["foo"]); + expect(error?.message).toEqual("Internal Error: Gadget API didn't return expected data. Nothing found in response at foo"); + }); + test("returns an error if data is null", () => { + const error = getNonNullableError({ fetching: false, data: { foo: null } }, ["foo"]); + expect(error?.message).toEqual("Record Not Found Error: Gadget API returned no data at foo"); + }); + test("returns void if fetch is in progress", () => { + const error = getNonNullableError({ fetching: true, data: null }, [""]); + expect(error).toBeUndefined(); + }); + test("returns void if data is valid", () => { + const error = getNonNullableError({ fetching: false, data: { foo: { bar: "valid" } } }, ["foo", "bar"]); + expect(error).toBeUndefined(); + }); + }); + + describe("assertMutationSuccess", () => { + test("returns the result at the datapath if the operation was successful", () => { + expect( + assertMutationSuccess( + { + operation: null as any, + data: { createWidget: { success: true, errors: null, widget: { bar: "baz" } } }, + stale: false, + hasNext: false, + }, + ["createWidget"] + ) + ).toEqual({ success: true, errors: null, widget: { bar: "baz" } }); + }); + + test("throws an error if success is false but no errors are returned", () => { + expect(() => + assertMutationSuccess( + { + operation: null as any, + data: { + createWidget: { + success: false, + errors: null, + widget: null, + }, + }, + stale: false, + hasNext: false, + }, + + ["createWidget"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"GGT_UNKNOWN: Gadget API operation not successful."`); + }); + + test("throws the first error from the mutation errors if present", () => { + expect(() => + assertMutationSuccess( + { + operation: null as any, + data: { + createWidget: { + success: false, + errors: [{ code: "GGT_SOMETHING", message: "some message" }], + widget: null, + }, + }, + stale: false, + hasNext: false, + }, + + ["createWidget"] + ) + ).toThrowErrorMatchingInlineSnapshot(`"GGT_SOMETHING: some message"`); + }); + + test("throws a rich error representing a validation error if encountered", () => { + let threw = false; + try { + assertMutationSuccess( + { + operation: null as any, + data: { + createWidget: { + success: false, + errors: [ + { + code: "GGT_INVALID_RECORD", + message: "widget record is invalid and can't be saved. foo is not present, bar is not present.", + model: { apiIdentifier: "widget" }, + validationErrors: [ + { apiIdentifier: "foo", message: "is not present" }, + { apiIdentifier: "bar", message: "is not present" }, + ], + record: { + id: 10, + foo: null, + bar: null, + }, + }, + ], + widget: null, + }, + }, + stale: false, + hasNext: false, + }, + ["createWidget"] + ); + } catch (error: any) { + threw = true; + expect(error).toBeTruthy(); + expect(error.validationErrors).toHaveLength(2); + expect(error.validationErrors[0].apiIdentifier).toEqual("foo"); + expect(error.validationErrors[0].message).toEqual("is not present"); + expect(error.modelApiIdentifier).toEqual("widget"); + expect(error.record.id).toEqual(10); + } + expect(threw).toBeTruthy(); + }); + + test("throws a rich error representing a validation error if encountered where the extra context is missing", () => { + let threw = false; + try { + assertMutationSuccess( + { + operation: null as any, + data: { + createWidget: { + success: false, + errors: [ + { + code: "GGT_INVALID_RECORD", + message: "Record has one invalid error", + validationErrors: [{ apiIdentifier: "foo", message: "is not present" }], + model: null, + record: null, + }, + ], + widget: null, + }, + }, + stale: false, + hasNext: false, + }, + ["createWidget"] + ); + } catch (error: any) { + threw = true; + expect(error).toBeTruthy(); + expect(error.validationErrors).toHaveLength(1); + expect(error.validationErrors[0].apiIdentifier).toEqual("foo"); + expect(error.validationErrors[0].message).toEqual("is not present"); + expect(error.modelApiIdentifier).toBeFalsy(); + expect(error.record).toBeFalsy(); + } + expect(threw).toBeTruthy(); + }); + }); + + describe("disambiguateActionVariables", () => { + test("it should map variables to the fully qualified form when operating with record identity", async () => { + expect(MockWidgetUpdateAction.operatesWithRecordIdentity).toBe(true); + + expect(disambiguateActionVariables(MockWidgetUpdateAction, { id: "123", name: "foobar" })).toEqual({ + id: "123", + widget: { name: "foobar" }, + }); + }); + + test("it should leave the fully qualified as is when operating with record identity", async () => { + expect(MockWidgetUpdateAction.operatesWithRecordIdentity).toBe(true); + + expect(disambiguateActionVariables(MockWidgetUpdateAction, { widget: { id: "123", name: "foobar" } })).toEqual({ + widget: { id: "123", name: "foobar" }, + }); + }); + + test("it should not add params only variables when operating on the flat form with record identity", async () => { + const action = { ...MockWidgetUpdateAction, paramOnlyVariables: ["foo"] }; + + expect(disambiguateActionVariables(action, { id: "123", name: "foobar", foo: "bar" })).toEqual({ + id: "123", + foo: "bar", + widget: { name: "foobar" }, + }); + }); + + test("it should map variables to the fully qualified form when operating without record identity", async () => { + expect(MockWidgetCreateAction.operatesWithRecordIdentity).toBe(false); + + expect(disambiguateActionVariables(MockWidgetCreateAction, { id: "123", name: "foobar" })).toEqual({ + widget: { id: "123", name: "foobar" }, + }); + }); + + test("it should leave the fully qualified as is when operating without record identity", async () => { + expect(MockWidgetCreateAction.operatesWithRecordIdentity).toBe(false); + + expect(disambiguateActionVariables(MockWidgetCreateAction, { widget: { id: "123", name: "foobar" } })).toEqual({ + widget: { id: "123", name: "foobar" }, + }); + }); + + test("it should not add params only variables when operating on the flat form without record identity", async () => { + const action = { ...MockWidgetCreateAction, paramOnlyVariables: ["foo"] }; + + expect(disambiguateActionVariables(action, { id: "123", name: "foobar", foo: "bar" })).toEqual({ + foo: "bar", + widget: { id: "123", name: "foobar" }, + }); + }); + + test("it should not disambiguate if the action does not have model input", async () => { + const action = { ...MockWidgetCreateAction, acceptsModelInput: false }; + + expect(disambiguateActionVariables(action, { id: "123", name: "foobar" })).toEqual({ + id: "123", + name: "foobar", + }); + }); + + test("it should default to extracting ids if the action doesn't have operatesWithRecordIdentity", async () => { + const { operatesWithRecordIdentity: _, ...action } = MockWidgetCreateAction; + + expect(disambiguateActionVariables(action as any, { id: "123", name: "foobar" })).toEqual({ + id: "123", + widget: { name: "foobar" }, + }); + }); + + test("it errors if there are ambiguous identifiers", async () => { + const action = { ...MockWidgetUpdateAction, hasAmbiguousIdentifier: true }; + + expect(() => disambiguateActionVariables(action, { id: "123", name: "foobar" })).toThrowErrorMatchingInlineSnapshot( + `"Invalid arguments found in variables. Did you mean to use ({ widget: { ... } })?"` + ); + }); + }); + + describe("disambiguateBulkActionVariables", () => { + test("it should leave variables objects with ids alone", () => { + expect(disambiguateBulkActionVariables(MockBulkFlipDownWidgetsAction, { ids: ["1", "2", "3"] })).toEqual({ ids: ["1", "2", "3"] }); + }); + + test("it should leave variables objects with fully qualified inputs alone", () => { + expect(disambiguateBulkActionVariables(MockBulkUpdateWidgetAction, { inputs: [{ id: "123", widget: { name: "foobar" } }] })).toEqual({ + inputs: [{ id: "123", widget: { name: "foobar" } }], + }); + }); + + test("it should leave the structure of variables objects with inputs alone, but map each input to the fully qualified form when operating with record identity", () => { + expect(MockBulkUpdateWidgetAction.operatesWithRecordIdentity).toBe(true); + + expect(disambiguateBulkActionVariables(MockBulkUpdateWidgetAction, { inputs: [{ id: "123", name: "foobar" }] })).toEqual({ + inputs: [{ id: "123", widget: { name: "foobar" } }], + }); + }); + + test("it should leave the structure of variables objects with inputs alone, but map each input to the fully qualified form when operating without record identity", () => { + expect(MockBulkCreateWidgetAction.operatesWithRecordIdentity).toBe(false); + + expect(disambiguateBulkActionVariables(MockBulkCreateWidgetAction, { inputs: [{ id: "123", name: "foobar" }] })).toEqual({ + inputs: [{ widget: { id: "123", name: "foobar" } }], + }); + }); + + test("it should normalize ids arrays for actions which accept ids", () => { + expect(disambiguateBulkActionVariables(MockBulkFlipDownWidgetsAction, ["1", "2", "3"])).toEqual({ ids: ["1", "2", "3"] }); + }); + + test("it should normalize input arrays for actions which accept inputs containing shorthand inputs", () => { + expect(disambiguateBulkActionVariables(MockBulkUpdateWidgetAction, [{ id: "123", name: "foobar" }])).toEqual({ + inputs: [{ id: "123", widget: { name: "foobar" } }], + }); + }); + + test("it should normalize input arrays for actions which accept inputs containing fully qualified inputs", () => { + expect(disambiguateBulkActionVariables(MockBulkUpdateWidgetAction, [{ id: "123", widget: { name: "foobar" } }])).toEqual({ + inputs: [{ id: "123", widget: { name: "foobar" } }], + }); + }); + }); + + describe("formatErrorMessages", () => { + test("it should format a validation error when there is a model api identifier", () => { + const error = new InvalidRecordError( + "Record is invalid and can't be saved. foo is not present, bar is not present.", + [ + { apiIdentifier: "foo", message: "is not present" }, + { apiIdentifier: "bar", message: "is not long enough" }, + ], + "widget" + ); + + const result = formatErrorMessages(error); + + expect(result).toEqual({ + widget: { + foo: { message: "is not present" }, + bar: { message: "is not long enough" }, + }, + }); + }); + + test("it should format a validation error when there is no model api identifier", () => { + const error = new InvalidRecordError("Record is invalid and can't be saved. foo is not present, bar is not present.", [ + { apiIdentifier: "foo", message: "is not present" }, + { apiIdentifier: "bar", message: "is not long enough" }, + ]); + + const result = formatErrorMessages(error); + + expect(result).toEqual({ + foo: { message: "is not present" }, + bar: { message: "is not long enough" }, + }); + }); + + test("is should format a standard error", () => { + const error = new Error("Network error: something went wrong"); + + const result = formatErrorMessages(error); + + expect(result).toEqual({ + root: { message: "Network error: something went wrong" }, + }); + }); + + test("is should format a GadgetError with a code", () => { + const error = new GadgetOperationError("GGT_SOMETHING: something went wrong", "GGT_SOMETHING"); + + const result = formatErrorMessages(error); + + expect(result).toEqual({ + root: { message: "something went wrong" }, + }); + }); + }); +}); diff --git a/packages/core/src/AnyClient.ts b/packages/core/src/AnyClient.ts new file mode 100644 index 000000000..178f2732d --- /dev/null +++ b/packages/core/src/AnyClient.ts @@ -0,0 +1,30 @@ +import type { AnyConnection } from "./AnyConnection.js"; +import type { GadgetTransaction } from "./GadgetTransaction.js"; +import type { AnyInternalModelManager } from "./InternalModelManager.js"; + +export const $modelRelationships = Symbol.for("gadget/modelRelationships"); + +export type InternalModelManagerNamespace = { + // internal model managers can be maps of model names to model managers, subnamespaces, or utility functions + [key: string]: AnyInternalModelManager | InternalModelManagerNamespace | ((...args: any[]) => any); +}; + +/** + * An instance of any Gadget app's API client object + */ +export interface AnyClient { + connection: AnyConnection; + query(graphQL: string, variables?: Record): Promise; + mutate(graphQL: string, variables?: Record): Promise; + transaction(callback: (transaction: GadgetTransaction) => Promise): Promise; + internal: InternalModelManagerNamespace; + apiClientCoreVersion?: string; + [$modelRelationships]?: { [modelName: string]: { [apiIdentifier: string]: { type: string; model: string } } }; +} + +/** + * Checks if the given object is an instance of any Gadget app's generated JS client object + */ +export const isGadgetClient = (client: any): client is AnyClient => { + return client && "connection" in client && client.connection && "endpoint" in client.connection; +}; diff --git a/packages/core/src/AnyConnection.ts b/packages/core/src/AnyConnection.ts new file mode 100644 index 000000000..e732b0bc4 --- /dev/null +++ b/packages/core/src/AnyConnection.ts @@ -0,0 +1,56 @@ +import type { Client, ClientOptions } from "@urql/core"; +import type { ClientOptions as SubscriptionClientOptions, createClient as createSubscriptionClient } from "graphql-ws"; +import type { AuthenticationModeOptions, Exchanges } from "./ClientOptions.js"; +import { GadgetTransaction } from "./GadgetTransaction.js"; + +export interface GadgetSubscriptionClientOptions extends Partial { + urlParams?: Record; + connectionAttempts?: number; + connectionGlobalTimeoutMs?: number; +} + +/** + * Represents the current strategy for authenticating with the Gadget platform. + * For individual users in web browsers, we authenticate using a session token stored client side, like a cookie, but with cross domain support. + * For server to server communication, or traceable access from the browser, we use pre shared secrets called API Keys + * And when within the Gadget platform itself, we use a private secret token called an Internal Auth Token. Internal Auth Tokens are managed by Gadget and should never be used by external developers. + **/ +export enum AuthenticationMode { + BrowserSession = "browser-session", + APIKey = "api-key", + Internal = "internal", + InternalAuthToken = "internal-auth-token", + Anonymous = "anonymous", + Custom = "custom", +} + +export interface GadgetConnectionOptions { + endpoint: string; + authenticationMode?: AuthenticationModeOptions; + websocketsEndpoint?: string; + subscriptionClientOptions?: GadgetSubscriptionClientOptions; + websocketImplementation?: typeof globalThis.WebSocket; + fetchImplementation?: typeof globalThis.fetch; + environment?: string; + requestPolicy?: ClientOptions["requestPolicy"]; + applicationId?: string; + baseRouteURL?: string; + exchanges?: Exchanges; + createSubscriptionClient?: typeof createSubscriptionClient; +} + +export type TransactionRun = (transaction: GadgetTransaction) => Promise; + +export interface AnyConnection { + endpoint: string; + authenticationMode: AuthenticationMode; + createSubscriptionClient: typeof createSubscriptionClient; + options: GadgetConnectionOptions; + get currentClient(): Client; + transaction: { + (options: GadgetSubscriptionClientOptions, run: TransactionRun): Promise; + (run: TransactionRun): Promise; + }; + close(): void; + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; +} diff --git a/packages/core/src/ClientOptions.ts b/packages/core/src/ClientOptions.ts new file mode 100644 index 000000000..b2e8825f5 --- /dev/null +++ b/packages/core/src/ClientOptions.ts @@ -0,0 +1,130 @@ +import type { Exchange } from "@urql/core"; +import type { GadgetSubscriptionClientOptions } from "./AnyConnection.js"; + +/** All the options for a Gadget client */ +export interface ClientOptions { + /** + * The HTTP GraphQL endpoint this connection should connect to + **/ + endpoint?: string; + /** + * The authentication strategy for connecting to the upstream API + **/ + authenticationMode?: AuthenticationModeOptions; + /** + * The Websockets GraphQL endpoint this connection should connect to for transactional processing + **/ + websocketsEndpoint?: string; + /** + * Custom options to pass along to the WS clients when creating them + **/ + subscriptionClientOptions?: GadgetSubscriptionClientOptions; + /** + * The `WebSocket` constructor to use for building websockets. Defaults to `globalThis.WebSocket`. + **/ + websocketImplementation?: any; + /** + * The `fetch` function to use for making HTTP requests. Defaults to `globalThis.fetch`. + **/ + fetchImplementation?: typeof fetch; + /** + * Which of the Gadget application's environments this connection should connect to + **/ + environment?: string; + /** + * The ID of the application. Not required -- only used for emitting telemetry + **/ + applicationId?: string; + /** + * The root URL of the app's public HTTP surface. Used for building fully-qualified URLs when `api.fetch` is called with relative paths. + * + * This only needs to be passed if you are overriding the `endpoint` parameter to something that can't be used for building fully-qualified URLs from relative imports. + **/ + baseRouteURL?: string; + /** + * A list of exchanges to merge into the default exchanges used by the client. + */ + exchanges?: Exchanges; +} + +/** Options to configure a specific browser-based authentication mode */ +export interface BrowserSessionAuthenticationModeOptions { + /** + * The initial token to set for browser authentication. + * This is useful when your session is initialized by some external authentication system, like OAuth. + */ + initialToken?: string; + + /** + * Configures how the authentication token is persisted. See `BrowserSessionStorageType`. + */ + storageType?: BrowserSessionStorageType; + /** + * The shop ID to set shop tenant. Useful for fetching shop-specific data. + */ + shopId?: string; +} + +/** + * If using the `browserSession` authentication mode, sets how long the stored authentication information will last for for each user. + */ +export enum BrowserSessionStorageType { + /** + * `Durable` authentications ask the browser to keep the user's authentication information around for as long as it can, like the "Remember Me" button on a lot of webpages. Uses `window.localStorage` to store authentication tokens. + */ + Durable = "Durable", + /** + * `Session` authentications ask the browser to keep the user's authentication information around for a given browser tab, and then remove it when the tab is closed. Useful for high security scenarios where authenticated sessions are sensitive and should be forgotten quickly, or where the user's identity is temporary and only needs to last a short while. Uses `window.sessionStorage` to store authentication tokens. + */ + Session = "session", + /** + * `Temporary` authentications don't ask the browser to keep the user's authentication information around at all, such that refreshing the page will result in the user having no saved authentication state and likely being logged out. Useful for high security scenarios where authenticated sessions are sensitive and should be forgotten quickly. + */ + Temporary = "temporary", +} + +/** Describes how to authenticate an instance of the client with the Gadget platform */ +export interface AuthenticationModeOptions { + // Use an API key to authenticate with Gadget. + // Not strictly required, but without this the client might be useless depending on the app's permissions. + apiKey?: string; + + // Use a web browser's `localStorage` or `sessionStorage` to persist authentication information. + // This allows the browser to have a persistent identity as the user navigates around and logs in and out. + browserSession?: boolean | BrowserSessionAuthenticationModeOptions; + + // Use no authentication at all, and get access only to the data that the Unauthenticated backend role has access to. + anonymous?: true; + + // @deprecated Use internal instead + internalAuthToken?: string; + + // @private Use an internal platform auth token for authentication + // This is used to communicate within Gadget itself and shouldn't be used to connect to Gadget from other systems + internal?: { + authToken: string; + actAsSession?: boolean; + getSessionId?: () => Promise; + }; + + // @private Use a passed custom function for managing authentication. For some fancy integrations that the API client supports, like embedded Shopify apps, we use platform native features to authenticate with the Gadget backend. + custom?: { + processFetch(input: RequestInfo | URL, init: RequestInit): Promise; + processTransactionConnectionParams(params: Record): Promise; + }; +} + +export interface Exchanges { + /** + * Exchanges to add before all other exchanges. + */ + beforeAll?: Exchange[]; + /** + * Exchanges to add before any async exchanges. + */ + beforeAsync?: Exchange[]; + /** + * Exchanges to add after all other exchanges. + */ + afterAll?: Exchange[]; +} diff --git a/packages/core/src/DataHydrator.ts b/packages/core/src/DataHydrator.ts new file mode 100644 index 000000000..c2318953f --- /dev/null +++ b/packages/core/src/DataHydrator.ts @@ -0,0 +1,40 @@ +export const Hydrators = { + DateTime(value: string) { + return new Date(value); + }, +}; + +export type Hydration = keyof typeof Hydrators; + +/** Instructions for a client to turn raw transport types (like strings) into useful client side types (like Dates). Unstable and not intended for developer use. */ +export interface HydrationPlan { + [key: string]: Hydration; +} + +/** + * Utility for declaratively transforming object trees. + * Useful for turning API date strings into real Date objects, etc. + * Declarative so that the operations it peforms can be serialized. + */ +export class DataHydrator { + constructor(readonly plan: HydrationPlan) {} + + apply(source: Record | Record[]) { + if (Array.isArray(source)) { + return source.map((object) => this.hydrateObject(object)); + } else { + return this.hydrateObject(source); + } + } + + private hydrateObject(object: Record) { + const hydrated = { ...object }; + for (const [key, hydrator] of Object.entries(this.plan)) { + const value = hydrated[key]; + if (value != null) { + hydrated[key] = Hydrators[hydrator](value); + } + } + return hydrated; + } +} diff --git a/packages/core/src/ErrorWrapper.ts b/packages/core/src/ErrorWrapper.ts new file mode 100644 index 000000000..62a9aea1d --- /dev/null +++ b/packages/core/src/ErrorWrapper.ts @@ -0,0 +1,145 @@ +import { GraphQLError } from "@0no-co/graphql.web"; +import type { CombinedError } from "@urql/core"; +import { + GadgetError, + InvalidFieldError, + InvalidRecordError, + gadgetErrorFor, + getNonNullableError, + type FetchableResult, +} from "./support.js"; + +/** + * An error returned by any of the Gadget hooks. + * Always has a message, but can be inspected to retrieve more detailed errors from either the network, the raw GraphQL layer, or Gadget specific errors like validation errors. + * Not intended for creating outside of Gadget-owned code. + **/ +export class ErrorWrapper extends Error { + /** @private */ + static forClientSideError(error: Error, response?: any) { + return new ErrorWrapper({ + executionErrors: [error], + response, + }); + } + /** @private */ + static forErrorsResponse(errors: Record[], response?: any) { + return new ErrorWrapper({ + executionErrors: errors.map(gadgetErrorFor), + response, + }); + } + /** @private */ + static forMaybeCombinedError(error?: CombinedError | null) { + if (!error) return undefined; + return new ErrorWrapper({ + networkError: error.networkError, + executionErrors: error.graphQLErrors, + response: error.response, + }); + } + /** @private */ + static errorIfDataAbsent(result: FetchableResult & { error?: CombinedError | null }, dataPath: string[], paused = false) { + const nonNullableError = getNonNullableError(result, dataPath); + let error = ErrorWrapper.forMaybeCombinedError(result.error); + if (!error && nonNullableError && !paused) { + error = ErrorWrapper.forClientSideError(nonNullableError); + } + return error; + } + + /** Error message for this error. Derived from the other errors this wraps. */ + public message: string; + /** + * A list of errors encountered by the backend when processing a request. Populated if the client successfully communicated with the backend, but the backend was unable to process the request and rejected it with an error. + * Includes GraphQL syntax errors, missing or invalid argument errors, data validation errors, or unexpected errors encountered when running backend logic. + **/ + public executionErrors: (GraphQLError | GadgetError)[]; + /** + * An error encountered when trying to communicate with the backend from the client. Includes things like connection timeouts, connection interrupts, or no internet connection errors + **/ + public networkError?: Error; + + /** + * A list of errors encountered by the backend when processing a request. Populated if the client successfully communicated with the backend, but the backend was unable to process the request and rejected it with an error. + * Includes GraphQL syntax errors, missing or invalid argument errors, data validation errors, or unexpected errors encountered when running backend logic. + * + * This property allows this object to match the interface of urql's `CombinedError` object. + * + * @deprecated use `executionErrors` instead for a list of the errors that the GraphQL backend API returned *and* client side errors from unexpected responses. + **/ + public graphQLErrors: GraphQLError[]; + + /** + * The response from the server, if any was retrieved. + */ + public response?: any; + + constructor({ + networkError, + executionErrors, + response, + }: { + networkError?: Error; + executionErrors?: Array | Error>; + validationErrors?: InvalidFieldError[]; + response?: any; + }) { + const normalizedExecutionErrors = (executionErrors || []).map(rehydrateGraphQlError); + const message = generateErrorMessage(networkError, normalizedExecutionErrors); + + super(message); + + this.message = message; + this.executionErrors = normalizedExecutionErrors; + this.graphQLErrors = normalizedExecutionErrors; + this.networkError = networkError; + this.response = response; + } + + /** Class name of this error -- always `ErrorWrapper` */ + get name() { + return "ErrorWrapper"; + } + + toString() { + return this.message; + } + + /** + * A list of errors the backend reported for specific fields being invalid for the records touched by an action. Is a shortcut for accessing the validation errors of a `GadgetInvalidRecordError` if that's what is in the `executionErrors`. + **/ + public get validationErrors(): InvalidFieldError[] | null { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const firstInvalidRecordError = this.executionErrors.find((err) => (err as any).code == "GGT_INVALID_RECORD") as + | InvalidRecordError + | undefined; + + return firstInvalidRecordError?.validationErrors ?? null; + } +} + +const rehydrateGraphQlError = (error: any): GraphQLError => { + if (typeof error === "string") { + return new GraphQLError(error); + } else if (error?.message && !error.code) { + return new GraphQLError(error.message, error.nodes, error.source, error.positions, error.path, error, error.extensions || {}); + } else { + return error; + } +}; + +const generateErrorMessage = (networkErr?: Error, graphQlErrs?: GraphQLError[]) => { + let error = ""; + if (networkErr !== undefined) { + error = `[Network] ${networkErr.message}`; + } else if (graphQlErrs !== undefined) { + graphQlErrs.forEach((err) => { + error += `[GraphQL] ${err.message}\n`; + }); + } else { + error = "Unknown error"; + } + + return error.trim(); +}; diff --git a/packages/core/src/FieldSelection.ts b/packages/core/src/FieldSelection.ts new file mode 100644 index 000000000..b42434c52 --- /dev/null +++ b/packages/core/src/FieldSelection.ts @@ -0,0 +1,7 @@ +/** + * Represents a list of fields selected from a GraphQL API call. Allows nesting, conditional selection. + * Example: `{ id: true, name: false, richText: { markdown: true, html: false } }` + **/ +export interface FieldSelection { + [key: string]: boolean | null | undefined | FieldSelection; +} diff --git a/packages/core/src/GadgetFunctions.ts b/packages/core/src/GadgetFunctions.ts new file mode 100644 index 000000000..2e90c2353 --- /dev/null +++ b/packages/core/src/GadgetFunctions.ts @@ -0,0 +1,277 @@ +import type { GadgetRecord, RecordShape } from "./GadgetRecord.js"; +import type { GadgetRecordList } from "./GadgetRecordList.js"; +import type { LimitToKnownKeys, VariablesOptions } from "./types.js"; + +export type PromiseOrLiveIterator = Promise | AsyncIterable; +export type AsyncRecord = PromiseOrLiveIterator>; +export type AsyncNullableRecord = PromiseOrLiveIterator | null>; +export type AsyncRecordList = PromiseOrLiveIterator>; + +export interface GQLBuilderResult { + query: string; + variables: Record; +} + +export interface FindOneFunction { + (fieldValue: string, options?: LimitToKnownKeys): AsyncRecord; + type: "findOne"; + findByVariableName: string; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface MaybeFindOneFunction { + (fieldValue: string, options?: LimitToKnownKeys): AsyncNullableRecord; + + type: "maybeFindOne"; + findByVariableName: string; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface FindManyFunction { + (options?: LimitToKnownKeys): AsyncRecordList; + + type: "findMany"; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface FindFirstFunction { + (options?: LimitToKnownKeys): AsyncRecord; + + type: "findFirst"; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface MaybeFindFirstFunction { + (options?: LimitToKnownKeys): AsyncNullableRecord; + + type: "maybeFindFirst"; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface ViewFunctionWithoutVariables { + (): Promise; + type: "computedView"; + operationName: string; + gqlFieldName: string; + namespace?: string | string[] | null; + referencedTypenames?: string[]; + resultType: ResultT; + plan(): GQLBuilderResult; +} + +export interface ViewFunctionWithVariables { + (variables: VariablesT): Promise; + type: "computedView"; + operationName: string; + gqlFieldName: string; + namespace?: string | string[] | null; + referencedTypenames?: string[]; + variables: VariablesOptions; + variablesType: VariablesT; + resultType: ResultT; + plan(variables: VariablesOptions): GQLBuilderResult; +} + +export type ViewFunction = ViewFunctionWithoutVariables | ViewFunctionWithVariables; + +export interface ActionWithIdAndVariables { + (id: string, variables: VariablesT, options?: LimitToKnownKeys): + | AsyncRecord + | Promise; +} + +export interface ActionWithNoIdAndVariables { + (variables: VariablesT, options?: LimitToKnownKeys): AsyncRecord; +} + +export interface ActionWithIdAndNoVariables { + (id: string, options?: LimitToKnownKeys): AsyncRecord | Promise; +} + +export interface ActionWithNoIdAndNoVariables { + (options?: LimitToKnownKeys): AsyncRecord; +} + +export interface BulkActionWithIdsAndNoVariables { + (ids: string[], options?: LimitToKnownKeys): AsyncRecord; +} + +export interface BulkActionWithInputs { + (inputs: VariablesT, options?: LimitToKnownKeys): AsyncRecord; +} + +export type HasReturnType = boolean | Record; + +export interface ActionFunctionMetadata { + type: "action"; + operationName: string; + operationReturnType?: string; + namespace: string | string[] | null; + modelApiIdentifier: string; + operatesWithRecordIdentity?: boolean; + modelSelectionField: string; + defaultSelection: DefaultsT; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + variables: VariablesOptions; + variablesType: VariablesT; + isBulk: IsBulk; + hasAmbiguousIdentifier?: boolean; + acceptsModelInput?: boolean; + paramOnlyVariables?: readonly string[]; + hasReturnType?: HasReturnType; + singleActionFunctionName?: string; + singleAction?: IsBulk extends true ? ActionFunctionMetadata : never; + plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; + /** @deprecated */ + hasCreateOrUpdateEffect?: boolean; +} + +export type StubbedActionReason = "MissingApiTrigger"; + +export interface StubbedActionFunctionMetadata { + type: "stubbedAction"; + functionName: string; + operationName?: string; + errorMessage: string; + actionApiIdentifier: string; + modelApiIdentifier?: string; + variables: VariablesOptions; + reason: StubbedActionReason; + dataPath: string; +} + +export type StubbedActionFunction = StubbedActionFunctionMetadata & ActionWithNoIdAndNoVariables; + +export type ActionFunction = ActionFunctionMetadata< + OptionsT, + VariablesT, + SelectionT, + SchemaT, + DefaultsT, + false +> & + ( + | ActionWithIdAndVariables + | ActionWithIdAndNoVariables + | ActionWithNoIdAndVariables + | ActionWithNoIdAndNoVariables + ); + +export type BulkActionFunction = ActionFunctionMetadata< + OptionsT, + VariablesT, + SelectionT, + SchemaT, + DefaultsT, + true +> & + (BulkActionWithIdsAndNoVariables | BulkActionWithInputs); + +export interface GetFunction { + (options?: LimitToKnownKeys): AsyncRecord>; + + type: "get"; + operationName: string; + modelApiIdentifier: string; + defaultSelection: DefaultsT; + namespace?: string | string[] | null; + selectionType: SelectionT; + optionsType: OptionsT; + schemaType: SchemaT | null; + plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; +} + +export interface GlobalActionFunction { + (variables: VariablesT): Promise; + + type: "globalAction"; + operationName: string; + operationReturnType?: string; + namespace: string | string[] | null; + variables: VariablesOptions; + variablesType: VariablesT; + isBulk?: undefined; + plan?: (variables?: VariablesOptions) => GQLBuilderResult; +} + +export type AnyActionFunction = + | ActionFunctionMetadata + | ActionFunctionMetadata + | GlobalActionFunction; +export type AnyBulkActionFunction = ActionFunctionMetadata; + +// This is a function that represents a computed view that doesn't take any input parameters/variables. +// Result is an explicit type parameter defining the shape of the full result. +export type ComputedViewFunctionWithoutVariables = () => Promise; + +// Represents a computed view that doesn't take any input parameters/variables. +// It includes the view function and the view metadata. +export interface ComputedViewWithoutVariables extends ComputedViewFunctionWithoutVariables { + type: "computedView"; + operationName: string; + gqlFieldName: string; + namespace: string | string[] | null; + resultType: Result; + plan(): { + query: string; + variables: Record; + }; +} + +// This is a function that represents a computed view that takes input parameters/variables. +// Result is an explicit type parameter defining the shape of the full result. +// Variables is an explicit type parameter that describes the shape of the variables parameter. +export type ComputedViewFunctionWithVariables = (variables?: Variables) => Promise; + +// Represents a computed view that takes input parameters/variables. +// It includes the view function and the view metadata. +export interface ComputedViewWithVariables extends ComputedViewFunctionWithVariables { + type: "computedView"; + operationName: string; + gqlFieldName: string; + namespace: string | string[] | null; + variables: VariablesOptions; + variablesType: Variables; + resultType: Result; + plan(variables?: Variables): { + query: string; + variables: Record; + }; +} diff --git a/packages/core/src/GadgetRecord.ts b/packages/core/src/GadgetRecord.ts new file mode 100644 index 000000000..7a3cd7a99 --- /dev/null +++ b/packages/core/src/GadgetRecord.ts @@ -0,0 +1,230 @@ +import { klona as cloneDeep } from "klona"; +import type { Jsonify } from "type-fest"; +import { isEqual, toPrimitiveObject } from "./support.js"; + +export enum ChangeTracking { + SinceLoaded, + SinceLastPersisted, +} + +export type RecordShape = Record | null | undefined | void; + +const kFields = Symbol.for("g/fields"); +const kInstantiatedFields = Symbol.for("g/if"); +const kPersistedFields = Symbol.for("g/pf"); +const kFieldKeys = Symbol.for("g/fk"); +const kTouched = Symbol.for("g/t"); + +/** Represents one record returned from a high level Gadget API call */ +export class GadgetRecord_ { + /** Storage of the actual keys and values of this record */ + [kFields]: Record = {}; + /** Storage of the keys and values of this record at the time it was instantiated */ + [kInstantiatedFields]: Record = {}; + /** Storage of the keys and values of this record at the time it was last persisted */ + [kPersistedFields]: Record = {}; + /** Storage of the keys and values of this record at the time it was last persisted */ + [kFieldKeys]: Set; + [kTouched] = false; + + private empty = false; + + constructor(data: Shape) { + this[kInstantiatedFields] = cloneDeep(data) ?? {}; + this[kPersistedFields] = cloneDeep(data) ?? {}; + Object.assign(this[kFields], data); + + if (!data || Object.keys(data).length === 0) { + this.empty = true; + this[kFieldKeys] = new Set(); + } else { + this[kFieldKeys] = new Set(Object.keys(this[kFields])); + } + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + const handler = { + get: (obj: any, prop: string | symbol) => { + if (prop in self || typeof prop == "symbol") { + // if the GadgetRecord responds to the property or function, call that prioritize that + let val = (self as any)[prop]; + if (typeof val == "function") { + val = val.bind(self); + } + return val; + } else if (prop in obj) { + // otherwise proxy it to this [kFields] object + return obj[prop]; + } + }, + set: (obj: Record, prop: string | symbol, value: any) => { + self.trackKey(prop); + obj[prop.toString()] = value; + return true; + }, + }; + + return new Proxy(this[kFields], handler); + } + + /** Makes sure our data keys are all tracked, to avoid repeated runtime object-to-array conversions */ + private trackKey(key: string | symbol) { + const trackingKey = key.toString(); + this[kFieldKeys].add(trackingKey); + } + + /** Helper method to compare values with special handling for Date vs string comparisons in either direction */ + private hasValueChanged(current: any, previous: any): boolean { + if ((current instanceof Date && typeof previous === "string") || (previous instanceof Date && typeof current === "string")) { + const currentDate = current instanceof Date ? current : new Date(current); + const previousDate = previous instanceof Date ? previous : new Date(previous); + + // Check if both dates are valid before comparing + if (!isNaN(currentDate.getTime()) && !isNaN(previousDate.getTime())) { + return currentDate.getTime() !== previousDate.getTime(); + } + return true; // If either can't be converted to a valid date, they're different + } + return !isEqual(current, previous); + } + + /** Returns true if even a single field has changed */ + private hasChanges(tracking = ChangeTracking.SinceLoaded) { + if (this[kTouched]) return true; + const diffFields = tracking == ChangeTracking.SinceLoaded ? this[kInstantiatedFields] : this[kPersistedFields]; + + return [...this[kFieldKeys]].some((key) => this.hasValueChanged(this[kFields][key], diffFields[key])); + } + + /** Checks if the original constructor data was empty or not */ + isEmpty(): boolean { + return this.empty; + } + + /** Returns the value of the field for the given `apiIdentifier`. These properties may also be accessed on this record directly. This method can be used if your model field `apiIdentifier` conflicts with the `GadgetRecord` helper functions. */ + getField(apiIdentifier: string) { + return this[kFields][apiIdentifier]; + } + + /** Sets the value of the field for the given `apiIdentifier`. These properties may also be accessed on this record directly. This method can be used if your model field `apiIdentifier` conflicts with the `GadgetRecord` helper functions. */ + setField(apiIdentifier: string, value: any) { + this.trackKey(apiIdentifier); + return (this[kFields][apiIdentifier] = value); + } + + /** Returns the `current` and `previous` values for any changed fields, keyed by field `apiIdentifier`. */ + changes(): { [prop: string]: { current: any; previous: any } }; + changes(tracking: ChangeTracking): { [prop: string]: { current: any; previous: any } }; + /** Returns the `current` and `previous` values if they have `changed`, otherwise `changed` is `false`. */ + changes(prop: string): { changed: true; current: any; previous: any } | { changed: false }; + changes(prop: string, tracking: ChangeTracking): { changed: true; current: any; previous: any } | { changed: false }; + changes(prop?: string | ChangeTracking, tracking = ChangeTracking.SinceLoaded) { + const trackChangesSince: ChangeTracking = typeof prop == "string" ? tracking : prop || tracking; + const diffFields = trackChangesSince == ChangeTracking.SinceLoaded ? this[kInstantiatedFields] : this[kPersistedFields]; + + if (prop && typeof prop == "string") { + const previous = diffFields[prop]; + const current = this[kFields][prop]; + + const changed = this.hasValueChanged(current, previous); + return changed ? { changed, current, previous } : { changed }; + } else { + const diff = {} as Record; + for (const key of this[kFieldKeys]) { + if (!isEqual(diffFields[key], this[kFields][key])) { + diff[key] = { current: this[kFields][key], previous: diffFields[key] }; + } + } + return diff; + } + } + + /** Returns all current values for fields that have changed */ + toChangedJSON(tracking = ChangeTracking.SinceLoaded): { [prop: string]: any } { + const diffFields = tracking == ChangeTracking.SinceLoaded ? this[kInstantiatedFields] : this[kPersistedFields]; + const current = {} as Record; + + for (const key of this[kFieldKeys]) { + if (!isEqual(diffFields[key], this[kFields][key])) { + current[key] = this[kFields][key]; + } + } + + return current; + } + + /** Returns `true` if any field has changed on this record. */ + changed(): boolean; + changed(tracking: ChangeTracking): boolean; + /** Returns `true` if the specified field has changed on this record. */ + changed(prop: string): boolean; + changed(prop: string, tracking: ChangeTracking): boolean; + changed(prop?: string | ChangeTracking, tracking = ChangeTracking.SinceLoaded) { + if (prop && typeof prop == "string") { + return this.changes(prop, tracking).changed; + } else { + return this.hasChanges(prop === undefined ? tracking : (prop as ChangeTracking)); + } + } + + /** Flushes all `changes` and starts tracking new changes from the current state of the record. */ + flushChanges(tracking = ChangeTracking.SinceLoaded) { + if (tracking == ChangeTracking.SinceLoaded) { + this[kInstantiatedFields] = cloneDeep(this[kFields]); + } else if (tracking == ChangeTracking.SinceLastPersisted) { + this[kPersistedFields] = cloneDeep(this[kFields]); + } + this[kTouched] = false; + } + + /** Reverts all `changes` on the record, and returns to either the values this record were instantiated with, or the values at the time of the last `flushChanges` call. */ + revertChanges(tracking = ChangeTracking.SinceLoaded) { + let persistedKeys: string[]; + + if (tracking == ChangeTracking.SinceLoaded) { + persistedKeys = Object.keys(this[kInstantiatedFields]); + } else { + persistedKeys = Object.keys(this[kPersistedFields]); + } + + for (const key of this[kFieldKeys]) { + if (!persistedKeys.includes(key)) delete this[kFields][key]; + } + + if (tracking == ChangeTracking.SinceLoaded) { + Object.assign(this[kFields], cloneDeep(this[kInstantiatedFields])); + } else { + Object.assign(this[kFields], cloneDeep(this[kPersistedFields])); + } + this[kTouched] = false; + } + + /** Returns a JSON representation of all fields on this record. */ + toJSON(): Jsonify { + return toPrimitiveObject({ ...this[kFields] }); + } + + /** Marks this record as changed so that the next save will save it and adjust any `updatedAt` timestamps */ + touch(): void { + this[kTouched] = true; + } +} + +/** + * The overridden constructor below is TypeScript hijinx to make the generic GadgetRecord class include all the members of the wrapped generic Shape object + * `GadgetRecord`s are generic because they can hold data of an arbitrary shape from the API, but TypeScript doesn't let the the class extend or implement anything without statically known members. The parameter is generic, so it's not statically known. So, we fake TypeScript out and create this pair of constructor and instance types that unions the instance of the class with the shape itself, making dot access of properties on the shape typecheck fine. + */ + +/** One record from the backend of a particular model */ +export type GadgetRecord = GadgetRecord_ & Shape; + +/** + * Instantiates 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 (data: Shape) => GadgetRecord_ & Shape = GadgetRecord_ as any; + +/** + * Legacy export for old generated clients expecting to find the class named this + * @hidden + */ +export const GadgetRecordImplementation = GadgetRecord_; diff --git a/packages/core/src/GadgetRecordList.ts b/packages/core/src/GadgetRecordList.ts new file mode 100644 index 000000000..053d948a6 --- /dev/null +++ b/packages/core/src/GadgetRecordList.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-throw-literal */ +/* eslint-disable @typescript-eslint/require-await */ +import type { Jsonify } from "type-fest"; +import type { GadgetRecord, RecordShape } from "./GadgetRecord.js"; +import type { AnyInternalModelManager } from "./InternalModelManager.js"; +import type { AnyModelManager } from "./ModelManager.js"; +import { GadgetClientError, GadgetOperationError } from "./support.js"; +import type { PaginateOptions } from "./types.js"; + +type PaginationConfig = { + pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean; startCursor: string; endCursor: string }; + options?: PaginateOptions; +}; + +/** Represents a list of objects returned from the API. Facilitates iterating and paginating. */ +export class GadgetRecordList extends Array> { + modelManager!: AnyModelManager | AnyInternalModelManager; + pagination!: PaginationConfig; + + /** Internal method used to create a list. Should not be used by applications. */ + static boot( + modelManager: AnyModelManager | AnyInternalModelManager, + records: GadgetRecord[], + pagination: PaginationConfig + ) { + const list = new GadgetRecordList(); + list.push(...records); + list.modelManager = modelManager; + list.pagination = pagination; + Object.freeze(list); + return list; + } + + static get [Symbol.species]() { + return Array; + } + + firstOrThrow() { + if (!this[0]) { + throw new GadgetOperationError("No records found.", "GGT_RECORD_NOT_FOUND"); + } + return this[0]; + } + + toJSON(): Jsonify[] { + return this.map((record) => record.toJSON()); + } + + get hasNextPage() { + return this.pagination.pageInfo.hasNextPage; + } + + get hasPreviousPage() { + return this.pagination.pageInfo.hasPreviousPage; + } + + get startCursor() { + return this.pagination.pageInfo.startCursor; + } + + get endCursor() { + return this.pagination.pageInfo.endCursor; + } + + async nextPage() { + if (!this.hasNextPage) + throw new GadgetClientError("Cannot request next page because there isn't one, should check 'hasNextPage' to see if it exists"); + // Our current implementation of paging determines paging direction based on if "first" is defined. We can pass both "before" and "after" as options but only after is respected if first is sent. One of "before" or "after" is ignored depending on whether "first" is defined. + const { first, last, before: _before, ...options } = this.pagination.options ?? {}; + const nextPage = this.modelManager.findMany({ + ...options, + after: this.pagination.pageInfo.endCursor, + first: first || last, + }) as Promise>; + return await nextPage; + } + + async previousPage() { + if (!this.hasPreviousPage) + throw new GadgetClientError( + "Cannot request previous page because there isn't one, should check 'hasPreviousPage' to see if it exists" + ); + // Our current implementation of paging determines paging direction based on if "first" is defined. We can pass both "before" and "after" as options but only after is respected if first is sent. One of "before" or "after" is ignored depending on whether "first" is defined. + const { first, last, after: _after, ...options } = this.pagination.options ?? {}; + const prevPage = this.modelManager.findMany({ + ...options, + before: this.pagination.pageInfo.startCursor, + last: last || first, + }) as Promise>; + return await prevPage; + } +} diff --git a/packages/core/src/GadgetTransaction.ts b/packages/core/src/GadgetTransaction.ts new file mode 100644 index 000000000..1e0ffede7 --- /dev/null +++ b/packages/core/src/GadgetTransaction.ts @@ -0,0 +1,52 @@ +import type { Client } from "@urql/core"; +import type { Client as SubscriptionClient } from "graphql-ws"; +import { assertOperationSuccess } from "./support.js"; + +/** Represents the error thrown when a transaction is explicity rolled back, sometimes due to another inner error */ +export class TransactionRolledBack extends Error {} + +/** Represents an open transaction against the Gadget API */ +export class GadgetTransaction { + open = false; + constructor(readonly client: Client, readonly subscriptionClient: SubscriptionClient) {} + + /** Shut down this transaction by closing the connection to the backend. */ + close() { + if (this.open) { + void this.rollback().catch(() => null); + } + void this.subscriptionClient.dispose(); + } + + /** Explicitly roll back this transaction, preventing any of the changes made during it from being committed. */ + async rollback() { + assertOperationSuccess(await this.client.mutation(`mutation RollbackTransaction { internal { rollbackTransaction }}`, {}).toPromise(), [ + "internal", + "rollbackTransaction", + ]); + this.open = false; + throw new TransactionRolledBack("Transaction rolled back."); + } + + /** + * @private + */ + async start() { + assertOperationSuccess(await this.client.mutation(`mutation StartTransaction { internal { startTransaction }}`, {}).toPromise(), [ + "internal", + "startTransaction", + ]); + this.open = true; + } + + /** + * @private + */ + async commit() { + assertOperationSuccess(await this.client.mutation(`mutation CommitTransaction { internal { commitTransaction }}`, {}).toPromise(), [ + "internal", + "commitTransaction", + ]); + this.open = false; + } +} diff --git a/packages/core/src/InMemoryStorage.ts b/packages/core/src/InMemoryStorage.ts new file mode 100644 index 000000000..b719def9b --- /dev/null +++ b/packages/core/src/InMemoryStorage.ts @@ -0,0 +1,19 @@ +export interface BrowserStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +/** + * Implements part of the `window.localStorage` api, but in memory such that the stored values are lost as soon as the JS VM's life ends + **/ +export class InMemoryStorage implements BrowserStorage { + values: Record = {}; + + getItem(key: string) { + return this.values[key] || null; + } + + setItem(key: string, value: string) { + this.values[key] = value; + } +} diff --git a/packages/core/src/InternalModelManager.ts b/packages/core/src/InternalModelManager.ts new file mode 100644 index 000000000..65e102b54 --- /dev/null +++ b/packages/core/src/InternalModelManager.ts @@ -0,0 +1,21 @@ +import { AnyConnection } from "./AnyConnection.js"; +import type { GadgetRecord, RecordShape } from "./GadgetRecord.js"; +import { GadgetRecordList } from "./GadgetRecordList.js"; +import type { AnyFilter, InternalFindListOptions, InternalFindManyOptions, InternalFindOneOptions } from "./types.js"; + +export type RecordData = Record; + +export interface AnyInternalModelManager { + connection: AnyConnection; + findOne: (id: string, options?: InternalFindOneOptions, throwOnEmptyData?: boolean) => Promise>; + maybeFindOne: (id: string, options?: InternalFindOneOptions) => Promise | null>; + findMany: (options?: InternalFindManyOptions) => Promise>; + findFirst: (options?: InternalFindListOptions, throwOnEmptyData?: boolean) => Promise>; + maybeFindFirst: (options?: InternalFindListOptions) => Promise | null>; + create: (record: RecordData) => Promise>; + bulkCreate: (records: RecordData[]) => Promise[]>; + update: (id: string, record: RecordData) => Promise>; + upsert: (record: RecordData & { on?: string[] }) => Promise>; + delete: (id: string) => Promise; + deleteMany: (options?: { search?: string; filter?: AnyFilter }) => Promise; +} diff --git a/packages/core/src/ModelManager.ts b/packages/core/src/ModelManager.ts new file mode 100644 index 000000000..7ffb7a74f --- /dev/null +++ b/packages/core/src/ModelManager.ts @@ -0,0 +1,32 @@ +import type { AnyConnection } from "./AnyConnection.js"; +import type { GadgetRecord } from "./GadgetRecord.js"; +import type { GadgetRecordList } from "./GadgetRecordList.js"; + +export type AnyModelFinderMetadata = { + /** The name of the GraphQL API field that should be called for this operation */ + operationName: string; + /** The model's api identifier */ + modelApiIdentifier: string; + /** What fields to select from the GraphQL API if no explicit selection is passed */ + defaultSelection: Record; + /** A namespace this operation is nested in. Absent for old clients or root-namespaced operations */ + namespace?: string | string[] | null; + /** Type-time only type member used for strong typing of finders */ + selectionType: any; + /** Type-time only type member used for strong typing of finders */ + optionsType: any; + /** Type-time only type member used for strong typing of finders */ + schemaType: any | null; +}; + +/** + * Object representing one model's API in a high level way + * This is a generic interface. Concrete ones are generated by Gadget, */ +export interface AnyModelManager { + connection: AnyConnection; + findOne: ((id: string, options: any) => Promise>) & AnyModelFinderMetadata; + findMany: ((options: any) => Promise>) & AnyModelFinderMetadata; + findFirst: ((options: any) => Promise>) & AnyModelFinderMetadata; + maybeFindFirst(options: any): Promise | null>; + maybeFindOne(id: string, options: any): Promise | null>; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 000000000..c70a0d1ef --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,15 @@ +export * from "./AnyClient.js"; +export * from "./AnyConnection.js"; +export * from "./ClientOptions.js"; +export * from "./DataHydrator.js"; +export * from "./ErrorWrapper.js"; +export * from "./FieldSelection.js"; +export * from "./GadgetFunctions.js"; +export * from "./GadgetRecord.js"; +export * from "./GadgetRecordList.js"; +export * from "./GadgetTransaction.js"; +export * from "./InMemoryStorage.js"; +export * from "./InternalModelManager.js"; +export * from "./ModelManager.js"; +export * from "./support.js"; +export * from "./types.js"; diff --git a/packages/core/src/support.ts b/packages/core/src/support.ts new file mode 100644 index 000000000..c30859592 --- /dev/null +++ b/packages/core/src/support.ts @@ -0,0 +1,746 @@ +import type { OperationResult } from "@urql/core"; +import { type FieldSelection as BuilderFieldSelection } from "tiny-graphql-query-compiler"; +import { DataHydrator } from "./DataHydrator.js"; +import type { ActionFunctionMetadata, AnyActionFunction } from "./GadgetFunctions.js"; +import type { RecordShape } from "./GadgetRecord.js"; +import { GadgetRecord } from "./GadgetRecord.js"; +import type { VariablesOptions } from "./types.js"; + +/** + * Generic type of the state of any record of a Gadget model + **/ +export type AnyState = string | { [key: string]: AnyState }; + +/** + * Error caused by a violated internal expectation that isn't the users fault, but the Gadget platform's. Often the best way to handle is to just retry. + **/ +export class GadgetInternalError extends Error { + code = "GGT_INTERNAL_ERROR"; + name = "InternalError"; + + /** @private */ + statusCode = 500; + /** @private */ + causedByClient = false; +} + +/** + * An error representing misuse or a violation of the assumptions of the Gadget Client. + */ +export class GadgetClientError extends Error { + code = "GGT_CLIENT_ERROR"; + name = "ClientError"; + + /** @private */ + statusCode = 500; + /** @private */ + causedByClient = true; +} + +/** + * A Gadget API error with an `code` and `message` describing the error. Most often these errors are caused by invalid input data or by misconfigured Gadget models. Consult the documentation for the specific `code` to learn more. + **/ +export class GadgetOperationError extends Error { + constructor(incomingMessage: string, readonly code: string) { + super(incomingMessage.startsWith("GGT_") ? incomingMessage : `${code}: ${incomingMessage}`); + } +} + +/** + * Interface representing one message on one invalid field for a `InvalidRecordError` + */ +export interface InvalidFieldError { + /** Which field of a record this error is for */ + apiIdentifier: string; + /** Human facing string representing the error */ + message: string; +} + +/** + * A client error when the Gadget API closes the connection unexpectedly. + */ +export class GadgetUnexpectedCloseError extends Error { + code = "GGT_UNKNOWN"; + name = "UnexpectedCloseError"; + + /** @private */ + statusCode = 500; + /** @private */ + causedByClient = false; + + /** The event that caused the unexpected close */ + readonly event: unknown; + + constructor(event: unknown) { + let message: string; + if (isCloseEvent(event)) { + message = `GraphQL websocket closed unexpectedly by the server with error code ${event.code} and reason "${event.reason}"`; + } else { + message = "GraphQL websocket closed unexpectedly by the server"; + } + + super(message); + this.event = event; + } +} + +/** + * A client error when the client times out waiting for the Gadget API to open websocket connection. + */ +export class GadgetWebsocketConnectionTimeoutError extends Error { + code = "GGT_WEBSOCKET_CONNECTION_TIMEOUT"; + name = "WebsocketConnectionTimeoutError"; + + /** @private */ + statusCode = 500; + /** @private */ + causedByClient = false; +} + +/** + * A Gadget API error when there are more requests sent in the alloted time window then permitted + */ +export class GadgetTooManyRequestsError extends Error { + code = "GGT_TOO_MANY_REQUESTS"; + name = "TooManyRequestsError"; + + /** @private */ + statusCode = 429; + /** @private */ + causedByClient = false; +} + +/** + * A Gadget API error representing a backend validation error on the input data for an action. Thrown when any of the validations configured on a model fail for the given input data. Has a `validationErrors` property describing which fields failed validation, with messages for each. + */ +export class InvalidRecordError extends Error { + code = "GGT_INVALID_RECORD"; + name = "InvalidRecordError"; + + /** @private */ + statusCode = 422; + /** @private */ + causedByClient = true; + /** + * A list of validation errors for each field that failed validation. + */ + readonly validationErrors: InvalidFieldError[]; + /** + * The API identifier of the model for this record which failed to validate + */ + readonly modelApiIdentifier?: string; + /** + * The record that failed to validate, if available + */ + readonly record?: Record; + + constructor(message: string | null, validationErrors: InvalidFieldError[], modelApiIdentifier?: string, record?: Record) { + const firstErrors = validationErrors.slice(0, 3); + const extraErrorMessage = + validationErrors.length > 3 + ? `, and ${validationErrors.length - 3} more error${validationErrors.length > 4 ? "s" : ""} need${ + validationErrors.length > 4 ? "" : "s" + } to be corrected` + : ""; + + super( + message ?? + `GGT_INVALID_RECORD: ${modelApiIdentifier ?? "Record"} is invalid and can't be saved. ${firstErrors + .map(({ apiIdentifier, message }) => `${apiIdentifier} ${message}`) + .join(", ")}${extraErrorMessage}.` + ); + + this.validationErrors = validationErrors; + this.modelApiIdentifier = modelApiIdentifier; + this.record = record; + } +} + +/** + * @deprecated Use `InvalidRecordError` instead, here for backwards compatability + */ +export const GadgetValidationError = InvalidRecordError; + +/** + * A Gadget API error that represents an error from the server. Thrown when the server encounters data that is not unique despite the existence of unique validation on a field. If you receive this error, it is likely that you added a unique validation to a field that has duplicate data. + */ +export class GadgetNonUniqueDataError extends Error { + code = "GGT_NON_UNIQUE_DATA"; + name = "NonUniqueDataError"; + + /** @private */ + statusCode = 417; + /** @private */ + causedByClient = false; +} + +/** + * A Gadget API error that represents an error where the client asked the server for data that doesn't exist server side. + */ +export class GadgetNotFoundError extends Error { + code = "GGT_RECORD_NOT_FOUND"; + name = "RecordNotFoundError"; + + /** @private */ + statusCode = 404; + /** @private */ + causedByClient = false; +} + +/** + * Represents a group of errors that occurred when running a number of operations at once */ +export class GadgetErrorGroup extends Error { + constructor( + /** The list of inner errors that occurred */ + public readonly errors: GadgetError[], + /* Any objects that were successfully processed during the bulk operation (the ones that didn't throw errors) */ + public readonly results: Result[] | undefined + ) { + super(errors.length > 1 ? "Multiple errors occurred" : errors[0].message); + } + + get code(): string { + return `GGT_ERROR_GROUP(${this.errors + .slice(0, 10) + .map((error) => error.code ?? "GGT_UNKNOWN") + .join(",")})`; + } + + name = "ErrorGroup"; + + /** @private */ + get statusCode() { + return Math.max(...this.errors.map((error) => (error as any).statusCode ?? 500)); + } +} + +/** All the errors a Gadget operation can throw */ +export type GadgetError = + | GadgetOperationError + | GadgetInternalError + | InvalidRecordError + | GadgetNonUniqueDataError + | GadgetNotFoundError + | GadgetUnexpectedCloseError + | GadgetWebsocketConnectionTimeoutError + | GadgetErrorGroup; + +export function assert(value: T | undefined | null, message?: string): T { + if (!value) { + throw new Error("assertion error" + (message ? `: ${message}` : "")); + } + return value; +} + +export const get = (object: Record | null | undefined, path: string[]): any => { + const length = path.length; + let index = 0; + while (object != null && index < length) { + object = object[path[index++]]; + } + + return index && index == length ? object : undefined; +}; + +export const isCloseEvent = (event: any): event is CloseEvent => event?.type == "close"; + +/** + * Converts a string to camel case, optionally capitalizing the first character. + * Defaults to capitalizing the first character. + * @param str + * @param capitalizeFirstCharacter + * @returns Camelize string + */ +export const capitalizeIdentifier = (str: string | undefined | null, capitalizeFirstCharacter?: boolean): string => { + if (typeof str !== "string") return ""; + return camelize(str, capitalizeFirstCharacter); +}; + +const capitalizeFirstCharacter = (str: string) => { + const result = str === null || str === undefined ? "" : String(str); + return result.charAt(0).toUpperCase() + result.slice(1); +}; + +export const camelize = (term: string, uppercaseFirstLetter = true) => { + let result = "" + term; + + if (uppercaseFirstLetter) { + result = result.replace(/^[a-z\d]*/, (a) => { + return capitalizeFirstCharacter(a); + }); + } else { + result = result.replace(new RegExp("^(?:(?=\\b|[A-Z_])|\\w)"), (a) => { + return a.toLowerCase(); + }); + } + + result = result.replace(/(?:_|(\/))([a-z\d]*)/gi, (_match, a, b, _idx, _string) => { + a || (a = ""); + return "" + a + capitalizeFirstCharacter(b); + }); + + return result; +}; + +export const namespacedGraphQLTypeName = (modelApiIdentifier: string, givenNamespaces: string | string[] | null | undefined) => { + const namespaces: string[] = Array.isArray(givenNamespaces) ? givenNamespaces : givenNamespaces ? [givenNamespaces] : []; + const segments = [...namespaces, modelApiIdentifier]; + return segments.map((segment) => camelize(segment)).join(""); +}; + +export const sortTypeName = (modelApiIdentifier: string, namespace: string | string[] | null | undefined) => + `${namespacedGraphQLTypeName(modelApiIdentifier, namespace)}Sort`; + +export const filterTypeName = (modelApiIdentifier: string, namespace: string | string[] | null | undefined) => + `${namespacedGraphQLTypeName(modelApiIdentifier, namespace)}Filter`; + +export const getNonUniqueDataError = (modelApiIdentifier: string, fieldName: string, fieldValue: string) => + new GadgetNonUniqueDataError( + `More than one record found for ${modelApiIdentifier}.${fieldName} = ${fieldValue}. Please confirm your unique validation is not reporting an error.` + ); + +export type FetchableResult = Result & { fetching: boolean; stale?: boolean }; + +export const getNonNullableError = (response: FetchableResult, dataPath: string[]) => { + if (response.fetching) { + return; + } + const result = get(response.data, dataPath); + if (result === undefined) { + return new GadgetInternalError( + `Internal Error: Gadget API didn't return expected data. Nothing found in response at ${dataPath.join(".")}` + ); + } else if (result === null) { + return new GadgetNotFoundError(`Record Not Found Error: Gadget API returned no data at ${dataPath.join(".")}`); + } +}; + +export const assertOperationSuccess = (response: OperationResult, dataPath: string[], throwOnEmptyData = false) => { + if (response.error) { + if ("networkError" in response.error && response.error.networkError) { + if (response.error.networkError?.message) { + response.error.message = `[Network] ${response.error.networkError.message}`; + } else { + response.error.message = `[Network] No message, error: string(response.error.networkError) \nstack: ${String( + response.error.networkError.stack + )}}`; + } + } + throw response.error; + } + + const result = get(response.data, dataPath); + const edges = get(result, ["edges"]); + const dataArray = edges ?? result; + if (result === undefined) { + throw new GadgetInternalError( + `Internal Error: Gadget API didn't return expected data. Nothing found in response at ${dataPath.join(".")}` + ); + } else if (result === null || (throwOnEmptyData && Array.isArray(dataArray) && dataArray.length === 0)) { + throw new GadgetNotFoundError(`Record Not Found Error: Gadget API returned no data at ${dataPath.join(".")}`); + } + + return result; +}; + +export const assertNullableOperationSuccess = (response: OperationResult, dataPath: string[]) => { + if (response.error) { + if ("networkError" in response.error && (response.error.networkError as any as Error[])?.length) { + response.error.message = (response.error.networkError as any as Error[]).map((error) => "[Network] " + error.message).join("\n"); + } + throw response.error; + } + + const result = get(response.data, dataPath); + return result ?? null; +}; + +export const gadgetErrorFor = (error: Record) => { + if (error.code == "GGT_INVALID_RECORD") { + return new InvalidRecordError(error.message, error.validationErrors, error.model?.apiIdentifier, error.record); + } else if (error.code == "GGT_UNKNOWN" && error.message.includes("duplicate key value violates unique constraint")) { + return new GadgetNonUniqueDataError(error.message); + } else { + return new GadgetOperationError(error.message, error.code); + } +}; + +export const assertMutationSuccess = (response: OperationResult, dataPath: string[]) => { + const operationResponse = assertOperationSuccess(response, dataPath); + + return assertResponseSuccess(operationResponse); +}; + +export const assertResponseSuccess = (operationResponse: any) => { + if (!operationResponse.success) { + const firstErrorBlob = operationResponse.errors && operationResponse.errors[0]; + if (firstErrorBlob) { + throw gadgetErrorFor(firstErrorBlob); + } else { + throw new GadgetOperationError(`Gadget API operation not successful.`, "GGT_UNKNOWN"); + } + } + + return operationResponse; +}; + +// All of these functions only need the data bit, so narrow the type to make it easier to use these functions +type Result = Pick, "data">; + +export const getHydrator = (response: Result) => { + if (response.data?.gadgetMeta?.hydrations) { + return new DataHydrator(response.data?.gadgetMeta?.hydrations); + } +}; + +export const hydrateRecord = (response: Result, record: any): GadgetRecord => { + const hydrator = getHydrator(response); + if (hydrator) { + record = hydrator.apply(record); + } + return new GadgetRecord(record); +}; + +export const hydrateRecordArray = (response: Result, records: Array) => { + const hydrator = getHydrator(response); + if (hydrator) { + records = hydrator.apply(records) as any; + } + return records?.map((record) => new GadgetRecord(record)); +}; + +export const hydrateConnection = (response: Result, connection: { edges: { node: Node }[] }) => { + const nodes = connection.edges.map((edge) => edge.node); + return hydrateRecordArray(response, nodes); +}; + +const objObjType = "[object Object]"; +const stringObjType = "[object String]"; + +export const toPrimitiveObject = (value: any): any => { + if (value != null && typeof value.toJSON === "function") value = value.toJSON(); + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === "boolean") return value; + if (typeof value === "string") return value; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "object") { + if (Array.isArray(value)) { + const arr = []; + + for (let i = 0; i < value.length; i++) { + const v = value[i]; + arr[i] = v === undefined ? null : toPrimitiveObject(v); + } + + return arr; + } + if (Object.prototype.toString.call(value) === "[object Error]") return {}; + if (Object.prototype.toString.call(value) === objObjType) { + const obj: any = {}; + for (const key of Object.keys(value)) { + const parsed = toPrimitiveObject(value[key]); + // Remove undefined fields + if (parsed !== undefined) obj[key] = parsed; + } + return obj; + } + } +}; + +/** + * Get a string representing an error that is an `Error` object or anything else that might be `throw`n + */ +export const errorMessage = (error: unknown) => { + if (typeof error == "string") { + return error; + } else if (error && (error as any)?.message) { + return (error as any).message; + } else { + return String(error); + } +}; + +// Gadget Storage Test Key that minifies well +const key = "gstk"; + +/** Detect if the window object and window.localStorage or window.sessionStorage objects are functional */ +export const storageAvailable = (type: "localStorage" | "sessionStorage") => { + try { + const storage = window[type]; + storage.setItem(key, key); + storage.removeItem(key); + return true; + } catch (e) { + return false; + } +}; + +// smaller implementation of lodash's isEqual from https://github.com/NickGard/tiny-isequal but made a bit more performant and typesafe +const toString = Object.prototype.toString, + getPrototypeOf = Object.getPrototypeOf, + getOwnProperties = Object.getOwnPropertySymbols + ? (c: any) => (Object.keys(c) as any[]).concat(Object.getOwnPropertySymbols(c)) + : Object.keys; + +const checkEquality = (a: any, b: any, refs: any[]): boolean => { + // trivial case: primitives and referentially equal objects + if (a === b) return true; + + // if both are null/undefined, the above check would have returned true + if (a == null || b == null) return false; + + // check to see if we've seen this reference before; if yes, return true + // eslint-disable-next-line lodash/prefer-includes + if (refs.indexOf(a) > -1 && refs.indexOf(b) > -1) return true; + + const aType = toString.call(a); + const bType = toString.call(b); + + let aElements, bElements, element; + + // save results for circular checks + refs.push(a, b); + + // gadget-specific check for _link equality -- this is a special case for GadgetRecord + if (aType == objObjType && bType == stringObjType && "_link" in a && Object.keys(a).length == 1) { + return a._link === b; + } else if (bType == objObjType && aType == stringObjType && "_link" in b && Object.keys(b).length == 1) { + return b._link === a; + } + + if (aType != bType) return false; // not the same type of objects + + // for non-null objects, check all custom properties + aElements = getOwnProperties(a); + bElements = getOwnProperties(b); + if ( + aElements.length != bElements.length || + aElements.some(function (key) { + return !checkEquality(a[key], b[key], refs); + }) + ) { + return false; + } + + switch (aType.slice(8, -1)) { + case "Symbol": + return a.valueOf() == b.valueOf(); + case "Date": + case "Number": + return +a == +b || (+a != +a && +b != +b); // convert Dates to ms, check for NaN + case "RegExp": + case "Function": + case "String": + case "Boolean": + return "" + a == "" + b; + case "Set": + case "Map": { + aElements = a.entries(); + bElements = b.entries(); + do { + element = aElements.next(); + if (!checkEquality(element.value, bElements.next().value, refs)) { + return false; + } + } while (!element.done); + return true; + } + case "ArrayBuffer": + (a = new Uint8Array(a)), (b = new Uint8Array(b)); // fall through to be handled as an Array + case "DataView": + (a = new Uint8Array(a.buffer)), (b = new Uint8Array(b.buffer)); // fall through to be handled as an Array + case "Float32Array": + case "Float64Array": + case "Int8Array": + case "Int16Array": + case "Int32Array": + case "Uint8Array": + case "Uint16Array": + case "Uint32Array": + case "Uint8ClampedArray": + case "Arguments": + case "Array": + if (a.length != b.length) return false; + for (element = 0; element < a.length; element++) { + if (!(element in a) && !(element in b)) continue; // empty slots are equal + // either one slot is empty but not both OR the elements are not equal + if (element in a != element in b || !checkEquality(a[element], b[element], refs)) return false; + } + return true; + case "Object": + return checkEquality(getPrototypeOf(a), getPrototypeOf(b), refs); + default: + return false; + } +}; + +export const isEqual = (a: any, b: any) => checkEquality(a, b, []); + +/** + * Processes the flexible, convenient JS-land inputs for an action to the fully qualified GraphQL API inputs + * @internal + **/ +export const disambiguateActionVariables = (action: AnyActionFunction, variables: Record | undefined) => { + variables ??= {}; + if (!("hasAmbiguousIdentifier" in action) && !("acceptsModelInput" in action)) return variables; + + if (action.hasAmbiguousIdentifier) { + if ( + Object.keys(variables).some((key) => key !== "id" && !action.paramOnlyVariables?.includes(key) && key !== action.modelApiIdentifier) + ) { + throw Error(`Invalid arguments found in variables. Did you mean to use ({ ${action.modelApiIdentifier}: { ... } })?`); + } + } + + let newVariables: Record; + + // for backwards compatibilty, actions without the operatesWithRecordIdentity metadata should extract the id from the variables + const shouldExtractId = action.operatesWithRecordIdentity ?? true; + + if (action.acceptsModelInput ?? action.hasCreateOrUpdateEffect) { + if ( + action.modelApiIdentifier in variables && + typeof variables[action.modelApiIdentifier] === "object" && + variables[action.modelApiIdentifier] != null + ) { + newVariables = variables; + } else { + newVariables = { + [action.modelApiIdentifier]: {}, + }; + for (const [key, value] of Object.entries(variables)) { + if (action.paramOnlyVariables?.includes(key)) { + newVariables[key] = value; + } else { + if (key == "id" && shouldExtractId) { + newVariables.id = value; + } else { + newVariables[action.modelApiIdentifier][key] = value; + } + } + } + } + } else { + newVariables = variables; + } + + return newVariables; +}; + +/** + * Normalizes incoming params from JS land into the variable format the GraphQL API is expecting + * Some bulk actions take GraphQL variables like `{ids: ["1","2","3"]}`, and others take `{inputs: [{field: "value"}, {field: "value"}]}`. In JS land, we accept the fully qualified variables that look like that, as well as the inner array shorthands. + **/ +export const disambiguateBulkActionVariables = ( + action: ActionFunctionMetadata, + inputs: Record | Record[] = {} +) => { + if (action.variables["ids"]) { + // for actions which accept ids only, normalize the array shorthand into the full GraphQL variables + return Array.isArray(inputs) ? { ids: inputs } : inputs; + } else { + // for actions which accept inputs, normalize each element of the array of inputs, and then normalize arrays into the object form + const inputsArray: Record[] = (Array.isArray(inputs) ? inputs : inputs.inputs) ?? []; + return { + inputs: inputsArray.map((input) => disambiguateActionVariables(action, input)), + }; + } +}; + +/** + * Given a set of variables defined with their types and requiredness and whatnot, return the same options with the values for each variable filled in, suitable for passing to one invocation + */ +export const setVariableOptionValues = (variableOptions: VariablesOptions, values: Record) => { + const result: VariablesOptions = {}; + for (const [key, variable] of Object.entries(variableOptions)) { + result[key] = { ...variable, value: values[key] }; + } + return result; +}; + +export const namespaceDataPath = (dataPath: string[], namespace?: string[] | string | null) => { + if (namespace) { + dataPath.unshift(...(Array.isArray(namespace) ? namespace : [namespace])); + } + return dataPath; +}; + +/** + * Wrap a field selection in a set of namespaces + **/ +export function namespacify(namespace: string[] | string | undefined | null, fields: any) { + if (!namespace) return fields; + if (!Array.isArray(namespace)) { + namespace = [namespace]; + } + if (namespace) { + for (let i = namespace.length - 1; i >= 0; i--) { + fields = { + [namespace[i]]: fields, + }; + } + } + return fields; +} + +export const ErrorsSelection: BuilderFieldSelection = { + errors: { + message: true, + code: true, + "... on InvalidRecordError": { + model: { + apiIdentifier: true, + }, + validationErrors: { + message: true, + apiIdentifier: true, + }, + }, + }, +}; + +/** + * Formats error messages into a structured object. + * + * @param {Error} error - The error object to format. + * @returns {Record} An object containing formatted error messages. + * For InvalidRecordError, it structures validation errors by model and field. + * For other errors, it returns a single root message. + * + * @example + * // For an InvalidRecordError: + * // { + * // modelName: { + * // fieldName: { message: "Error message" } + * // } + * // } + * + * @example + * // For other errors: + * // { + * // root: { message: "Error message" } + * // } + */ +export const formatErrorMessages = (error: Error) => { + const result: Record = {}; + + if ("validationErrors" in error) { + const invalidRecordError = error as InvalidRecordError; + for (const validationError of invalidRecordError.validationErrors) { + if (invalidRecordError.modelApiIdentifier) { + result[invalidRecordError.modelApiIdentifier] ??= {}; + result[invalidRecordError.modelApiIdentifier][validationError.apiIdentifier] = { message: validationError.message }; + } else { + result[validationError.apiIdentifier] = { message: validationError.message }; + } + } + } else { + const codeToReplace = "code" in error ? `${error.code}: ` : ""; + const message = error.message.replace(codeToReplace, ""); + + result.root = { message }; + } + + return result; +}; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 000000000..4911c8c45 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,941 @@ +import type { OperationContext } from "@urql/core"; +import type { VariableOptions } from "tiny-graphql-query-compiler"; +import type { FieldSelection } from "./FieldSelection.js"; +import type { + ActionFunction, + AnyActionFunction, + BulkActionFunction, + GlobalActionFunction, + ViewFunction, + ViewFunctionWithoutVariables, + ViewFunctionWithVariables, +} from "./GadgetFunctions.js"; + +/** + * Allows detecting an any type, this is rather tricky: + * The type constraint 0 extends 1 is not satisfied (0 is not assignable to 1), + * so it should be impossible for 0 extends (1 & T) to be satisfied either, since (1 & T) should be even narrower than 1. + * However, when T is any, it reduces 0 extends (1 & any) to 0 extends any, which is satisfied. + * That's because any is intentionally unsound and acts as both a supertype and subtype of almost every other type. + * source: https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 + */ + +type IfAny = 0 extends 1 & T ? Y : N; + +/** + * Limit the keys in T to only those that also exist in U. AKA Subset or Intersection. + */ +export type LimitToKnownKeys = { + [Key in keyof T]: Key extends keyof U ? T[Key] : never; +}; + +/** + * A set of variable metadatas, keyed by variable name. Is exported by a generated Gadget client. Can have values filled in or not. + * + * @example + * { + * "id": { type: "GadgetID", required: true }, + * "widget": { type: "CreateWidgetInpput" } + * } + */ +export type VariablesOptions = Record; + +/** + * Given an options object from a find method, default the type of the selection to a default if no selection is passed + */ +export type DefaultSelection< + Available extends FieldSelection, + Options extends { select?: Available | null }, + Defaults extends SomeFieldsSelected +> = IfAny>; + +/** + * Take a FieldSelection type and construct a type with all its fields required and selected. + */ +export type AllFieldsSelected = { + [K in keyof Selection]-?: NonNullable extends FieldSelection ? AllFieldsSelected> : true; +}; + +/** + * Take a FieldSelection type and construct a type with its fields set to true + * rather than (boolean | null | undefined) + */ +export type SomeFieldsSelected = { + [K in keyof Selection]?: NonNullable extends FieldSelection ? SomeFieldsSelected> : true; +}; + +/** + * Describes an option set that accepts a selection + */ +export interface Selectable { + /** Select fields other than the defaults of the record to return */ + select?: SelectionType | null; +} + +/** + * Describes the base options that many record finders accept + */ +export interface BaseFindOptions { + /** Select fields other than the defaults of the record to return */ + select?: SelectionType | null; + /** Turn on live query mode, where the query will trigger re-renders when data changes on the backend */ + live?: boolean; +} + +/** + * Get any keys of `Selection` that are not mapped to `never` + */ +export type NonNeverKeys = { + [Key in keyof Selection]: Selection[Key] extends never ? never : Key; +}[keyof Selection]; + +/** + * Filter out any keys in `T` that are mapped to `never`. + */ +export type FilterNever> = NonNeverKeys extends never ? never : { [Key in NonNeverKeys]: T[Key] }; + +/** + * Extract a subset of a schema given a selection + * + * ```typescript + * type Selection = Select< + * { apple: "red", banana: "yellow", orange: "orange" }, + * { apple: true, banana: false } + * >; // { apple: "red" } + * ``` + */ +type InnerSelect = IfAny< + Selection, + never, + Selection extends null | undefined + ? never + : Schema extends (infer T)[] + ? InnerSelect[] + : Schema extends null + ? InnerSelect, Selection> | null + : { + [Key in keyof Selection & keyof Schema]: Selection[Key] extends true + ? Schema[Key] + : Selection[Key] extends FieldSelection + ? InnerSelect + : never; + } +>; + +/** + * Filter out any keys in `T` that are mapped to `never` recursively. Any nested objects that are empty after having never valued keys removed are also removed. + * + * ```typescript + * type Thing = DeepFilterNever< + * { a: { b: never }, c: string } + * >; // { c: string; } + * ``` + */ +export type DeepFilterNever = T extends Record + ? FilterNever<{ + [Key in keyof T]: T[Key] extends Record ? DeepFilterNever : T[Key]; + }> + : T; + +/** + * Extract a subset of a schema given a selection + * + * ```typescript + * type Selection = Select< + * { apple: "red", banana: "yellow", orange: "orange" }, + * { apple: true, banana: false } + * >; // { apple: "red" } + * ``` + */ +export type Select = DeepFilterNever>; + +/** Represents an amount of some currency. Specified as a string so user's aren't tempted to do math on the value. */ +export type CurrencyAmount = string; + +/** Represents a UTC date formatted an ISO-8601 formatted 'full-date' string. */ +export type ISO8601DateString = string; + +export type JSONValue = string | number | boolean | JSONObject | JSONArray; + +/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ +export type JSONObject = { [key: string]: any }; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +interface JSONArray extends Array {} + +/** The ID of a record in Gadget */ +export type GadgetID = string; + +/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */ +export type DateTime = Date; + +/** Represents the state of one record in a Gadget database. Represented as either a string or set of strings nested in objects. */ +export type RecordState = string | { [key: string]: RecordState }; + +/** A field whose value conforms to the standard internet email address format as specified in RFC822: https://www.w3.org/Protocols/rfc822/. */ +export type EmailAddress = string; + +/** A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. */ +export type URLString = string; + +/** The `JSONBlob` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */ +export type JSONBlob = { [key: string]: any } | string | null | any[]; + +/** + * The filters available for filtering an ID type field on the backend + * + * @example + * { id: { equals: "123" } } + * + * @example + * { id: { in: ["123", "456"] } } + **/ +export interface IDFilter { + /** Filter to where the backend value is equal to this ID value */ + equals?: GadgetID | null; + + /** Filter to where the backend value anything other than this ID value */ + notEquals?: GadgetID | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value is within this set of IDs */ + in?: (GadgetID | null)[]; + + /** Filter to where the backend value is not included in this set of IDs */ + notIn?: (GadgetID | null)[]; + + /** Filter to where the backend value is numerically lower than this value */ + lessThan?: GadgetID | null; + + /** Filter to where the backend value is numerically lower or equal to this value */ + lessThanOrEqual?: GadgetID | null; + + /** Filter to where the backend value is numerically greater than this value */ + greaterThan?: GadgetID | null; + + /** Filter to where the backend value is numerically greater than or equal to this value */ + greaterThanOrEqual?: GadgetID | null; +} + +/** + * The filters available for filtering an Number field type that has no decimals on the backend + * + * @example + * { age: { equals: 18 } } + * + * @example + * { age: { in: [18, 19, 20] } } + * + * @example + * { age: { greaterThan: 18 } } + **/ +export interface IntFilter { + /** Filter to where the backend value is equal to this value */ + equals?: number | bigint | null; + + /** Filter to where the backend value is not equal to this value */ + notEquals?: number | bigint | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value is within this set of numbers */ + in?: (number | bigint | null)[]; + + /** Filter to where the backend value is not within this set of numbers */ + notIn?: (number | bigint | null)[]; + + /** Filter to where the backend value is lower than this number */ + lessThan?: number | bigint | null; + + /** Filter to where the backend value is lower than or equal to this number */ + lessThanOrEqual?: number | bigint | null; + + /** Filter to where the backend value is greater than to this number */ + greaterThan?: number | bigint | null; + + /** Filter to where the backend value is greater than or equal to this number */ + greaterThanOrEqual?: number | bigint | null; +} + +/** + * The filters available for filtering an Number field type that has decimals on the backend + * + * @example + * { age: { equals: 18.5 } } + * + * @example + * { age: { in: [18.5, 19.5, 20.5] } } + * + * @example + * { age: { greaterThan: 18.5 } } + * */ +export interface FloatFilter { + /** Filter to where the backend value is equal to this value */ + equals?: number | bigint | null; + + /** Filter to where the backend value is not equal to this value */ + notEquals?: number | bigint | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value is within this set of numbers */ + in?: (number | bigint | null)[]; + + /** Filter to where the backend value is not within this set of numbers */ + notIn?: (number | bigint | null)[]; + + /** Filter to where the backend value is lower than this number */ + lessThan?: number | bigint | null; + + /** Filter to where the backend value is lower than or equal to this number */ + lessThanOrEqual?: number | bigint | null; + + /** Filter to where the backend value is greater than to this number */ + greaterThan?: number | bigint | null; + + /** Filter to where the backend value is greater than or equal to this number */ + greaterThanOrEqual?: number | bigint | null; +} + +/** + * The filters available for filtering a Date type field on the backend that includes the time part of a date time + * + * @example + * { createdAt: { equals: new Date() } } + * + * @example + * { createdAt: { after: "2020-01-01T00:00:00.000Z" } } + * + * @example + * { publishedAt: { isSet: true } } + * + **/ +export interface DateTimeFilter { + /** Filter to where the backend value is equal to this date value. Accepts `Date` objects or ISO8601 formatted date strings */ + equals?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is anything other than this value. Accepts `Date` objects or ISO8601 formatted date strings */ + notEquals?: Date | ISO8601DateString | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: (boolean | null) | null; + + /** Filter to where the backend value is included in this given set of dates. Accepts an array of `Date` objects or ISO8601 formatted date strings */ + in?: (Date | ISO8601DateString | null)[]; + + /** Filter to where the backend value is not included in this given set of dates. Accepts an array of `Date` objects or ISO8601 formatted date strings */ + notIn?: (Date | ISO8601DateString | null)[]; + + /** Filter to where the backend value is numerically lower than this given date value, as in, before this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + lessThan?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically lower or equal to this given date value, as in, before or equal to this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + lessThanOrEqual?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically greater than to this given date value, as in, after this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + greaterThan?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically greater than or equal to to this given date value, as in, after or equal to this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + greaterThanOrEqual?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is before this given date value in time. Accepts a `Date` object or a ISO8601 formatted date string */ + before?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is after this given date value in time. Accepts a `Date` object or a ISO8601 formatted date string */ + after?: Date | ISO8601DateString | null; +} + +/** The filters available for filtering a Date type field on the backend that is just the date part of a timestamp and does not include the time. */ +export interface DateFilter { + /** Filter to where the backend value is equal to this date value. Accepts `Date` objects or ISO8601 formatted date strings */ + equals?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is anything other than this value. Accepts `Date` objects or ISO8601 formatted date strings */ + notEquals?: Date | ISO8601DateString | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: (boolean | null) | null; + + /** Filter to where the backend value is included in this given set of dates. Accepts an array of `Date` objects or ISO8601 formatted date strings */ + in?: (Date | ISO8601DateString | null)[]; + + /** Filter to where the backend value is not included in this given set of dates. Accepts an array of `Date` objects or ISO8601 formatted date strings */ + notIn?: (Date | ISO8601DateString | null)[]; + + /** Filter to where the backend value is numerically lower than this given date value, as in, before this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + lessThan?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically lower or equal to this given date value, as in, before or equal to this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + lessThanOrEqual?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically greater than to this given date value, as in, after this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + greaterThan?: Date | ISO8601DateString | null; + + /** Filter to where the backend value is numerically greater than or equal to to this given date value, as in, after or equal to this given date in time. Accepts a `Date` object or a ISO8601 formatted date string */ + greaterThanOrEqual?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is before this given date value in time. Accepts a `Date` object or a ISO8601 formatted date string */ + before?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is before or the same as this given date value. Accepts a `Date` object or a ISO8601 formatted date string */ + beforeOrOn?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is after this given date value in time. Accepts a `Date` object or a ISO8601 formatted date string */ + after?: Date | ISO8601DateString | null; + + /** Filter to where the backend date value is after or the same as this given date value. Accepts a `Date` object or a ISO8601 formatted date string */ + afterOrOn?: Date | ISO8601DateString | null; +} + +/** The filters available for filtering a JSON type field on the backend */ +export interface JSONFilter { + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value is equal to this value. Does an exact comparison of JSON key-by-key. */ + equals?: JSONBlob | null; + + /** Filter to where the backend value is equal to any of the given values. Accepts a list of JSON objects. */ + in?: (JSONBlob | null)[]; + + /** Filter to where the backend value is not equal to any of the given values. Accepts a list of JSON objects. */ + notIn?: (JSONBlob | null)[]; + + /** Filter to where the backend value is not exactly equal to the given value. Accepts one JSON object. Will filter out any records with exact matches, but will allow records with partial matches through. To do partial testing, use the `matches` operator. */ + notEquals?: JSONBlob | null; + + /** Filter to where the backend value matches the given value on a key by key basis. Accepts one JSON object. Will filter down to only records that have the same value in their backend data as is specified in this value. Backend records can have other keys or values as well. */ + matches?: JSONBlob | null; +} + +/** The filters available for filtering a String type field on the backend */ +export interface StringFilter { + /** Filter to where the backend value is equal to this string */ + equals?: string | null; + + /** Filter to where the backend value is not equal to this string */ + notEquals?: string | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value is exactly equal to any of the given strings */ + in?: (string | null)[]; + + /** Filter to where the backend value is not exactly equal to any of the given strings */ + notIn?: (string | null)[]; + + /** Filter to where the backend value sorts alphanumerically lower than this given string. Sorts using Postgres string sorting rules. */ + lessThan?: string | null; + + /** Filter to where the backend value sorts alphanumerically lower than or equal to this given string. Sorts using Postgres string sorting rules. */ + lessThanOrEqual?: string | null; + + /** Filter to where the backend value sorts alphanumerically greater than this given string. Sorts using Postgres string sorting rules. */ + greaterThan?: string | null; + + /** Filter to where the backend value sorts alphanumerically greater than or equal to this given string. Sorts using Postgres string sorting rules. */ + greaterThanOrEqual?: string | null; + + /** Filter to where the backend string starts with this exact string. */ + startsWith?: string | null; +} + +/** + * Filters available on an Enum type field + * + * @example + * { equals: 'Green' } + * + * @example + * { isSet: true } + * + * @example + * { notEquals: "Red" } + */ +export interface SingleEnumFilter { + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value exactly equal to this given enum value */ + equals?: string | null; + + /** Filter to where the backend value is not exactly equal to this given enum value */ + notEquals?: string | null; + + /** Filter to where the backend value is included in this list of of enum values */ + in?: (string | null)[]; +} + +/** + * Filters available on an Enum type field where the backend can select multiple values + * + * @example + * { equals: ['Green', 'Red'] } + * + * @example + * { contains: 'Green' } + * + * @example + * { isSet: true } + * + * @example + * { notEquals: ["Red"] } + */ +export interface MultiEnumFilter { + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; + + /** Filter to where the backend value exactly equal to this list of given enum values. The backend value must be exactly this list to match. */ + equals?: (string | null)[]; + + /** Filter to where the backend value is not equal to this list of given enum values. Any records with exactly this list will not be returned, but partial matches will be. */ + notEquals?: (string | null)[]; + + /** Filter to where the backend value list of enums includes this given value or list of values. */ + contains?: string | null | (string | null)[]; +} + +/** + * Filters available for filtering boolean type fields on the backend + * + * @example + * { equals: true } + * + * @example + * { notEquals: false } + */ +export interface BooleanFilter { + /** Filter to where the backend value is equal to this boolean. */ + equals?: boolean | null; + + /** Filter to where the backend value is not equal to this boolean. */ + notEquals?: boolean | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; +} + +/** + * Filters available for filtering record state type fields on the backend + * + * @example + * { inState: { created: "installed "} } + * + * @example + * { isSet: false } + */ +export interface StateFilter { + /** Filter to where the backend value is equal to this state string or nested state set. */ + inState?: string | Record | null; + + /** + * Filter to where the backend value is set to any value at all. + * - if true, will exclude records where this field is null. + * - if false, will only return records where this field is null. + **/ + isSet?: boolean | null; +} + +/** The order to sort records by when returning from the backend */ +export type SortOrder = "Ascending" | "Descending"; + +/** How order to sort records by a vector field when returning from the backend */ +export type VectorSortOrder = { + /** Sort by the cosine similarity between the stored vector and this given vector. Defaults the sort order to Ascending, which will return the most similar vectors first. */ + cosineSimilarityTo?: (number | null)[] | null; + /** Sort by the L2 distance between the stored vector and this given vector */ + l2DistanceTo?: (number | null)[] | null; + order?: SortOrder | null; +}; + +/** A sort for one field by a particular order. Can only include one key for one field to be valid. */ +export type FieldSort = { [field: string]: SortOrder | VectorSortOrder | null | undefined }; + +/** + * A sort to return backend records by + * @example + * { name: 'Ascending' } + * + * @example + * [{ name: 'Ascending'}, {id: 'Descending' }] + **/ +export type AnySort = FieldSort | FieldSort[]; + +export type AnyFieldFilter = + | IDFilter + | DateTimeFilter + | DateFilter + | JSONFilter + | StringFilter + | SingleEnumFilter + | MultiEnumFilter + | IntFilter + | FloatFilter + | BooleanFilter + | StateFilter; + +export type FilterElement = + | { + /** A list of filter conditions that all must be matched for the record to be returned */ + AND?: FilterElement[] | null; + /** A list of filter conditions where any one within can be matched for the record to be returned */ + OR?: FilterElement[] | null; + /** A list of filter conditions to invert for matching */ + NOT?: FilterElement[] | null; + } + | Record + | null; + +/** + * A filter for filtering the records returned by the backed. + * Is not specific to any backend model. Look for the backend specific types in the generated API client if you need strong type safety. + * + * @example + * { name: { equals: 'Jane' } } + * + * @example + * { name: { equals: 'Bob' }, age: { greaterThan: 18 } } + * + * @example + * { AND: [{ name: { equals: 'Bob' } }, { age: { greaterThan: 18 } }, { age: { lessThan: 50 } }] } + * + **/ +export type AnyFilter = FilterElement | FilterElement[]; + +/** + * A list of fields to return from the backend + * Is not specific to any backend model. Look for the backend specific types in the generated API client if you need strong type safety. + */ +export interface AnySelection { + [key: string]: boolean | null | undefined | AnySelection; +} + +/** The options a record find operation takes that selects which fields are returned from the backend */ +export type SelectionOptions = { select?: AnySelection }; + +/** The options a record find operation takes that can return many records */ +export interface FindManyOptions extends BaseFindOptions { + /** Sort the returned records by the given criteria */ + sort?: AnySort | null; + /** Only return records which match the given set of filters */ + filter?: AnyFilter | AnyFilter[] | null; + /** Only return records which match this given search string */ + search?: string | null; + + /** + * Return records after the given cursor for pagination. Useful in tandem with the `first` count option for pagination. + **/ + after?: string | null; + /** + * Return this number of records. Useful in tandem with the `after` cursor option for pagination. + **/ + first?: number | null; + /** + * Return records before the given cursor for pagination. Useful in tandem with the `last` count option for pagination. + **/ + before?: string | null; + /** + * Return this number of records. Useful in tandem with the `before` cursor option for pagination. + **/ + last?: number | null; +} + +/** The options a record find operation takes that can return many records */ +export type FindFilteredOptions = { + /** Return only the given fields on the backend record (and related records) */ + select?: AnySelection; + + /** Sort the returned records by the given critera */ + sort?: AnySort | null; + /** Only return records which match the given set of filters */ + filter?: AnyFilter | null; + /** Only return records which match this given search string */ + search?: string | null; +}; + +/** + * A list of fields to select from the internal API + * + * Matches the format of the Public API `select` option, but only allows going one level deep -- no relationships can be selected using the internal API. + * + * Supports passing a list of strings as a shorthand. + * + * @example + * { fieldA: true, fieldB: true, fieldC: false } + * + * @example + * ['fieldA', 'fieldB'] + */ +export type InternalFieldSelection = string[] | { [field: string]: boolean | null | undefined }; + +/** Options for the api functions that return one record on an InternalModelManager */ +export interface InternalFindOneOptions { + /** + * What fields to retrieve from the API for this API call + * __Note__: This selection is different than the top level select option -- it just accepts a list of string fields, and not a nested selection. To use a nested selection, use the top level API. + **/ + select?: InternalFieldSelection; +} + +/** Options for functions that query a list of records on an InternalModelManager */ +export interface InternalFindListOptions { + /** + * A string to search for within all the stringlike fields of the records + * Matches the behavior of the Public API `search` option + **/ + search?: string | null; + /** + * How to sort the returned records + * Matches the format and behavior of the Public API `sort` option + * + * @example + * { + * sort: { publishedAt: "Descending" } + * } + **/ + sort?: AnySort | null; + /** + * Only return records matching this filter + * Matches the format and behavior of the Public API `filter` option + * + * @example + * { + * filter: { published: { equals: true } } + * } + * */ + filter?: AnyFilter | null; + /** + * What fields to retrieve from the API for this API call + * __Note__: This selection is different than the top level select option -- it just accepts a list of string fields, and not a nested selection. To use a nested selection, use the top level API. + **/ + select?: InternalFieldSelection; +} + +/** Options for functions that return a paginated list of records from an InternalModelManager */ +export interface InternalFindManyOptions extends InternalFindListOptions { + /** + * A count of records to return + * Often used in tandem with the `after` option for GraphQL relay-style cursor pagination + * Matches the pagination style and behavior of the Public API + **/ + first?: number | null; + /** + * The `after` cursor from the GraphQL Relay pagination spec + * Matches the pagination style and behavior of the Public API + **/ + after?: string | null; + /** + * A count of records to return + * Often used in tandem with the `before` option for GraphQL relay-style cursor pagination + * Matches the pagination style and behavior of the Public API + **/ + last?: number | null; + /** + * The `before` cursor from the GraphQL Relay pagination spec + * Matches the pagination style and behavior of the Public API + **/ + before?: string | null; +} + +/** The options an internal record mutation takes */ +export type InternalMutationOptions = { + /** + * Which fields to return from the backend + * __Note__: This selection is different than the top level select option -- it just accepts a list of string fields, and not a nested selection. To use a nested selection, use the top level API. + **/ + select?: InternalFieldSelection; +}; + +/** + * @private + */ +export type PaginateOptions = { + after?: string | null; + first?: number | null; + before?: string | null; + last?: number | null; + select?: AnySelection | InternalFieldSelection | null; +}; + +/** + * Convert a schema type into the type that a selection of it must extend + * + * Example Schema: + * + * { + * foo: boolean; + * bar?: string; + * nested?: { + * count: number + * } + * } + * + * Example available selection: + * + * { + * foo?: boolean | null | undefined; + * bar?: boolean | null | undefined; + * nested?: { + * count: boolean | null | undefined + * } + * } + */ +export type AvailableSelection = Schema extends Array + ? AvailableSelection + : Schema extends object + ? { [key in keyof Schema]?: AvailableSelection } + : boolean | null | undefined; + +/** Options for configuring the queue for a background action */ +export interface BackgroundActionQueue { + /** + * The name of the queue to put this background action in. Actions enqueued into the same queue will be grouped together, and at most the `maxConcurrency` number of actions will execute at once. Queue names don't need to be defined ahead of time or explicitly created. + */ + name: string; + + /** + * The maximum number of actions in this queue that can be running at once + */ + maxConcurrency?: number; +} + +/** Options for configuring how a background action will retry on failure */ +export interface BackgroundActionRetryPolicy { + /** + * The maximum number of times to retry the operation if it keeps failing. Default is 10. + */ + retryCount?: number; + /** + * How long to initially delay the first retry. Default is 1000. + */ + initialInterval?: number; + /** + * The maximum amount of time to delay a retry while exponentially backing off. Default is not set, so the retry can backoff indefinitely. + */ + maxInterval?: number; + /** + * Randomizes the delays between attempts by multiplying with a factor between 1 to 2. Default is false. + */ + randomizeInterval?: boolean; + /** + * The exponential backoff factor to use for calculating the retry delay for successive retries. Set this higher to delay longer. Default is 2. + */ + backoffFactor?: number; +} + +/** + * Options for governing how a background action is enqueued + */ +export type EnqueueBackgroundActionOptions = { + /** + * A unique identifier to use for this background action. Must be unique among all other background actions within this environment. If not set, a unique ID will be autogenerated and returned. + **/ + id?: string; + + /** + * How high to place this background action in it's queue. `high` priority actions will be executed before `default` priority actions, and those before `low` priority actions. If not set, the `default` priority will be used. + */ + priority?: "LOW" | "DEFAULT" | "HIGH"; + + /** + * A queue to put this background action in that limits the maximum concurrency of all actions in the queue. If not set, the action will go into the global queue, and won't be concurrency limited. + * + * Pass a string to enqueue this action into a queue with the given name and an assumed max concurrency of `1`. Pass an object to enqueue this action into a queue with the given name and concurrency settings. + */ + queue?: string | BackgroundActionQueue; + + /** + * Configure how many times to retry this action if it fails (and how fast) + * + * Setting `retries` to `1` means the action will be attempted once, and if it fails, retries again once. + * Setting `retries` to `0` means failures won't be retried at all. + * Setting `retries` to an object allows configuring the count as well as the schedule retries will be attempted on, @see BackgroundActionRetryPolicy + * + * @example + * // retry up to 3 more times after failure (4 attempts total) + * retries: 3 + * + * @example + * retries: { retryCount: 5, initialInterval: 1000, maxInterval: 60000, randomizeInterval: true } + * + * @default 10 retry 10 times with exponential backoff + **/ + retries?: number | BackgroundActionRetryPolicy; + + /** + * Define client behavior when a background action is enqueued with the same ID as an existing action. + * - `ignore` will not enqueue the new action, and will return a handle representing the existing action (which will have the same id) + * - `error` will throw the GGT_DUPLICATE_BACKGROUND_ACTION_ID error for the caller to handle + */ + onDuplicateID?: "ignore" | "error"; + + /** + * Schedules the background action to run in the future after this datetime. + * + * @example + * startAt: "2024-03-18T18:14:08.257Z" + * + * @example + * // Start in 60 seconds from now + * startAt: new Date(new Date().getTime() + 60 * 1000) + */ + startAt?: Date | string; +} & Partial; + +export type ActionFunctionOptions = Action extends ActionFunction + ? Options + : Action extends BulkActionFunction + ? Options + : Action extends GlobalActionFunction + ? Record + : never; + +/** Get the result type of executing a view function */ +export type ViewResult> = Awaited< + F extends ViewFunctionWithVariables ? Result : F extends ViewFunctionWithoutVariables ? Result : never +>; diff --git a/packages/core/tsconfig.base.json b/packages/core/tsconfig.base.json new file mode 100644 index 000000000..696f426b0 --- /dev/null +++ b/packages/core/tsconfig.base.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2020", "DOM"], + "baseUrl": "./", + "target": "es2019", + "types": ["jest", "node"], + "importHelpers": true + } +} diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json new file mode 100644 index 000000000..25c2e42ab --- /dev/null +++ b/packages/core/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/cjs", + "module": "CommonJS", + "moduleResolution": "node" + }, + "include": ["./src"] +} diff --git a/packages/core/tsconfig.esm.json b/packages/core/tsconfig.esm.json new file mode 100644 index 000000000..06e3c57bb --- /dev/null +++ b/packages/core/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["./src"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..ec1118065 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["./src", "./spec", "./build"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 134f7199d..c6026240a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,31 @@ importers: specifier: ^7.1.4 version: 7.1.4(@types/node@22.18.1)(tsx@4.9.3) + packages/core: + dependencies: + '@urql/core': + specifier: '*' + version: 4.0.10(graphql@16.11.0) + graphql: + specifier: '*' + version: 16.11.0 + graphql-ws: + specifier: '*' + version: 5.16.0(graphql@16.11.0) + klona: + specifier: ^2.0.6 + version: 2.0.6 + devDependencies: + conditional-type-checks: + specifier: ^1.0.6 + version: 1.0.6 + type-fest: + specifier: ^3.13.1 + version: 3.13.1 + typescript: + specifier: 5.4.5 + version: 5.4.5 + packages/react: dependencies: '@0no-co/graphql.web': @@ -646,6 +671,17 @@ packages: dependencies: graphql: 16.8.1 + /@0no-co/graphql.web@1.2.0(graphql@16.11.0): + resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + dependencies: + graphql: 16.11.0 + dev: false + /@aashutoshrathi/word-wrap@1.2.6: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} @@ -8130,6 +8166,15 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@urql/core@4.0.10(graphql@16.11.0): + resolution: {integrity: sha512-Vs3nOSAnYftqOCg034Ostp/uSqWlQg5ryLIzcOrm8+O43s4M+Ew4GQAuemIH7ZDB8dek6h61zzWI3ujd8FH3NA==} + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.11.0) + wonka: 6.3.2 + transitivePeerDependencies: + - graphql + dev: false + /@urql/core@4.0.10(graphql@16.8.1): resolution: {integrity: sha512-Vs3nOSAnYftqOCg034Ostp/uSqWlQg5ryLIzcOrm8+O43s4M+Ew4GQAuemIH7ZDB8dek6h61zzWI3ujd8FH3NA==} dependencies: @@ -11608,6 +11653,15 @@ packages: dependencies: graphql: 16.8.1 + /graphql-ws@5.16.0(graphql@16.11.0): + resolution: {integrity: sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==} + engines: {node: '>=10'} + peerDependencies: + graphql: '>=0.11 <=16' + dependencies: + graphql: 16.11.0 + dev: false + /graphql-ws@5.16.0(graphql@16.8.1): resolution: {integrity: sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==} engines: {node: '>=10'} @@ -11617,6 +11671,11 @@ packages: graphql: 16.8.1 dev: true + /graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + /graphql@16.8.1: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -16818,7 +16877,6 @@ packages: /type-fest@3.13.1: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} - dev: false /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}