diff --git a/.gqmrc.json b/.gqmrc.json index 27ebbde..734d96e 100644 --- a/.gqmrc.json +++ b/.gqmrc.json @@ -2,5 +2,7 @@ "modelsPath": "tests/utils/models.ts", "generatedFolderPath": "tests/generated", "graphqlQueriesPath": "tests", - "gqlModule": "../../../src" -} + "gqlModule": "../../../src", + "knexfilePath": "knexfile.ts", + "dateLibrary": "luxon" +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d4e83ff..1413308 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,5 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_HOST_AUTH_METHOD: trust - TZ: 'Europe/Zurich' ports: - '5432:5432' diff --git a/docs/docs/1-tutorial.md b/docs/docs/1-tutorial.md index 0404a18..7bae569 100644 --- a/docs/docs/1-tutorial.md +++ b/docs/docs/1-tutorial.md @@ -95,10 +95,10 @@ const nextConfig = { }; ``` -Install `@smartive/graphql-magic`: +Install `@smartive/graphql-magic` and needed dependencies: ```bash -npm install @smartive/graphql-magic +npm install @smartive/graphql-magic @graphql-codegen/typescript-compatibility ``` Run the gqm cli: @@ -123,7 +123,6 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_HOST_AUTH_METHOD: trust - TZ: 'Europe/Zurich' ports: - '5432:5432' ``` @@ -213,14 +212,14 @@ Now let's implement the `// TODO: get user` part in the `src/graphql/execute.ts` ```ts const session = await getSession(); if (session) { - let dbUser = await db('User').where({ authId: session.user.sid }).first(); + let dbUser = await db('User').where({ authId: session.user.sub }).first(); if (!user) { await db('User').insert({ id: randomUUID(), - authId: session.user.sid, + authId: session.user.sub, username: session.user.nickname }) - dbUser = await db('User').where({ authId: session.user.sid }).first(); + dbUser = await db('User').where({ authId: session.user.sub }).first(); } user = { ...dbUser!, @@ -322,6 +321,7 @@ Let's make a blog out of this app by adding new models in `src/config/models.ts` updatable: true, } ] + } ``` Generate and run the new migrations and generate the new models: diff --git a/docs/docs/6-graphql-server.md b/docs/docs/6-graphql-server.md index 940f5f5..93c4a61 100644 --- a/docs/docs/6-graphql-server.md +++ b/docs/docs/6-graphql-server.md @@ -1,6 +1,6 @@ # Graphql server -## `executeQuery` +## `executeGraphql` `graphql-magic` generates an `execute.ts` file for you, with this structure: @@ -9,7 +9,6 @@ import knexConfig from "@/knexfile"; import { Context, User, execute } from "@smartive/graphql-magic"; import { randomUUID } from "crypto"; import { knex } from 'knex'; -import { DateTime } from "luxon"; import { models } from "../config/models"; export const executeGraphql = async ( @@ -32,7 +31,6 @@ export const executeGraphql = async ( user, models: models, permissions: { ADMIN: true, UNAUTHENTICATED: true }, - now: DateTime.local(), }); await db.destroy(); diff --git a/docs/docs/7-graphql-client.md b/docs/docs/7-graphql-client.md index ce55e78..e56d9fe 100644 --- a/docs/docs/7-graphql-client.md +++ b/docs/docs/7-graphql-client.md @@ -18,7 +18,7 @@ module.exports = { ### Server side -On the server side, and with `next.js` server actions, a graphql api becomes unnecessary, and you can execute query directly using `executeQuery`: +On the server side, and with `next.js` server actions, a graphql api becomes unnecessary, and you can execute queries directly using `executeGraphql`: ```tsx import { GetMeQuery, GetPostsQuery } from "@/generated/client"; diff --git a/docs/docs/8-permissions.md b/docs/docs/8-permissions.md new file mode 100644 index 0000000..12c2424 --- /dev/null +++ b/docs/docs/8-permissions.md @@ -0,0 +1,145 @@ +# Permissions + +Permissions are an object provided to the `execute` function. +The root keys of the objects are the user roles (including the special role `UNAUTHENTICATED` for when the user object is undefined). + +```ts +execute({ + ... + user: { + // ... + role: 'USER' // this is the role that will apply + }, + permissions: { + ADMIN: ... // admin permissions + USER: ... // user permissions + UNAUTHENTICATED: ... // permissions for unauthenticated users + } +}) +``` + +## Grant all permissions + +```ts +ADMIN: true +``` + +## Actions + +- READ +- CREATE +- UPDATE +- DELETE +- RESTORE +- LINK + +Grant all READ permissions on a specific table: + +```ts +User: {} // same as User: { READ: true } +``` + +Grant actions other than READ on a specific table: + +```ts +User: { CREATE: true } +``` + +## Linking + +The LINK permission doesn't give one permission to modify these records, +but to use them as options for foreign keys in _other_ records that one does have the permission to CREATE/UPDATE. + +So, for example, if you want a manager to be able to assign a user to a task, you would model it like this: + +```ts +MANAGER: { + User: { LINK: true } + Task: { UPDATE: true } +} +``` + +## Narrowing the record set + +Use WHERE, which accepts simple table column/value pairs that are then used as sql where filter. + +```ts +GUEST: { + Post: { + WHERE: { published: true } + } +} +``` + +## Derivative permissions + +In the following way you can define permissions that follow the relational structure. + +"If I can read a board (because it is public), then I can follow all threads and their authors, and their replies, which I can like." + +```ts +GUEST: { + Board: { + WHERE: { public: true } + RELATIONS: { + threads: { + RELATIONS: { + LINK: true, + author: {}, + replies: { + CREATE: true, + LINK: true, + likes: { + CREATE: true + } + } + } + } + } + } +} +``` + +## Me + +You can use `me` as a special `User` record set containing just yourself. + +```ts +EMPLOYEE: { + me: { + UPDATE: true, + LINK: true, + RELATIONS: { + tasks: { + UPDATE: true + } + } + } +} +``` + +Note: for ownership patterns (I own what I create, I can update what I own), +one must use implicitly generated relationships such as `createdPosts`, `updatedPosts`, `deletedPosts`: + +```ts +GUEST: { + me: { + LINK: true, + RELATIONS: { + createdPosts: { + // "guests can create a post with the field createdBy === me" + CREATE: true, + UPDATE: true + }, + updatedPosts: { + // this is necessary or it won't be possible to create a post + // "guests can create a post with the field updatedBy === me" + CREATE: true + + // this is *not* necessary because the user can already update posts they created + // UPDATE: true + } + } + } +} +``` diff --git a/knexfile.ts b/knexfile.ts index a3b8b69..8417aff 100644 --- a/knexfile.ts +++ b/knexfile.ts @@ -11,7 +11,7 @@ for (const oid of Object.values(numberOids)) { types.setTypeParser(oid, Number); } -const config = { +const knexConfig = { client: 'postgresql', connection: { host: process.env.DATABASE_HOST, @@ -28,4 +28,4 @@ const config = { }, } as const; -export default config; +export default knexConfig; diff --git a/migrations/20230912185644_setup.ts b/migrations/20230912185644_setup.ts index f69f576..afee938 100644 --- a/migrations/20230912185644_setup.ts +++ b/migrations/20230912185644_setup.ts @@ -3,18 +3,18 @@ import { Knex } from 'knex'; export const up = async (knex: Knex) => { await knex.raw(`CREATE TYPE "someEnum" AS ENUM ('A','B','C')`); - await knex.raw(`CREATE TYPE "role" AS ENUM ('ADMIN','USER')`); - await knex.raw(`CREATE TYPE "reactionType" AS ENUM ('Review','Question','Answer')`); - await knex.schema.createTable('User', (table) => { - table.uuid('id').notNullable().primary(); - table.string('username', undefined).nullable(); + await knex.schema.alterTable('User', (table) => { + table.string('username', undefined); + }); + + await knex.schema.alterTable('User', (table) => { table.enum('role', null as any, { useNative: true, existingType: true, enumName: 'role', - }).nullable(); + }).nullable().alter(); }); await knex.schema.createTable('AnotherObject', (table) => { @@ -101,9 +101,29 @@ export const up = async (knex: Knex) => { table.decimal('rating', undefined, undefined).nullable(); }); + await knex.schema.alterTable('User', (table) => { + table.dropColumn('createdAt'); + table.dropColumn('updatedAt'); + }); + }; export const down = async (knex: Knex) => { + await knex.schema.alterTable('User', (table) => { + table.timestamp('createdAt'); + table.timestamp('updatedAt'); + }); + + await knex('User').update({ + createdAt: 'TODO', + updatedAt: 'TODO', + }); + + await knex.schema.alterTable('User', (table) => { + table.timestamp('createdAt').notNullable().alter(); + table.timestamp('updatedAt').notNullable().alter(); + }); + await knex.schema.dropTable('ReviewRevision'); await knex.schema.dropTable('Review'); @@ -118,10 +138,19 @@ export const down = async (knex: Knex) => { await knex.schema.dropTable('AnotherObject'); - await knex.schema.dropTable('User'); + await knex.schema.alterTable('User', (table) => { + table.enum('role', null as any, { + useNative: true, + existingType: true, + enumName: 'role', + }).notNullable().alter(); + }); + + await knex.schema.alterTable('User', (table) => { + table.dropColumn('username'); + }); await knex.raw('DROP TYPE "reactionType"'); - await knex.raw('DROP TYPE "role"'); await knex.raw('DROP TYPE "someEnum"'); }; diff --git a/package-lock.json b/package-lock.json index 385f43a..c9cc25b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,13 +17,14 @@ "@graphql-codegen/typescript-resolvers": "^4.0.1", "code-block-writer": "^12.0.0", "commander": "^11.0.0", + "dayjs": "^1.11.10", "dotenv": "^16.3.1", "graphql": "^15.8.0", "inflection": "^2.0.1", "knex": "^3.0.1", "knex-schema-inspector": "^3.1.0", "lodash": "^4.17.21", - "luxon": "^3.3.0", + "luxon": "^3.4.4", "pg": "^8.11.3", "simple-git": "^3.21.0", "ts-morph": "^19.0.0", @@ -6066,10 +6067,9 @@ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" }, "node_modules/dayjs": { - "version": "1.11.9", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.9.tgz", - "integrity": "sha512-QvzAURSbQ0pKdIye2txOzNaHmxtUBXerpY0FJsFXUMKbIZeFm5ht1LS/jFsrncjnmtv8HsG0W2g6c0zUjZWmpA==", - "dev": true + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/debounce": { "version": "1.2.1", @@ -9649,9 +9649,9 @@ } }, "node_modules/luxon": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz", - "integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", "engines": { "node": ">=12" } diff --git a/package.json b/package.json index 114a4cf..bb62c5f 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,14 @@ "@graphql-codegen/typescript-resolvers": "^4.0.1", "code-block-writer": "^12.0.0", "commander": "^11.0.0", + "dayjs": "^1.11.10", "dotenv": "^16.3.1", "graphql": "^15.8.0", "inflection": "^2.0.1", "knex": "^3.0.1", "knex-schema-inspector": "^3.1.0", "lodash": "^4.17.21", - "luxon": "^3.3.0", + "luxon": "^3.4.4", "pg": "^8.11.3", "simple-git": "^3.21.0", "ts-morph": "^19.0.0", diff --git a/src/bin/gqm/codegen.ts b/src/bin/gqm/codegen.ts index a8b34c2..fde7196 100644 --- a/src/bin/gqm/codegen.ts +++ b/src/bin/gqm/codegen.ts @@ -1,7 +1,8 @@ import { generate } from '@graphql-codegen/cli'; +import { DATE_CLASS, DATE_CLASS_IMPORT, DateLibrary } from '../../utils/dates'; import { ensureDirectoryExists, getSetting } from './settings'; -export const generateGraphqlApiTypes = async () => { +export const generateGraphqlApiTypes = async (dateLibrary: DateLibrary) => { const generatedFolderPath = await getSetting('generatedFolderPath'); await generate({ overwrite: true, @@ -9,12 +10,12 @@ export const generateGraphqlApiTypes = async () => { documents: undefined, generates: { [`${generatedFolderPath}/api/index.ts`]: { - plugins: ['typescript', 'typescript-resolvers', { add: { content: `import { DateTime } from 'luxon';` } }], + plugins: ['typescript', 'typescript-resolvers', { add: { content: DATE_CLASS_IMPORT[dateLibrary] } }], }, }, config: { scalars: { - DateTime: 'DateTime', + DateTime: DATE_CLASS[dateLibrary], }, }, }); diff --git a/src/bin/gqm/gqm.ts b/src/bin/gqm/gqm.ts index 9852f7a..1c57972 100644 --- a/src/bin/gqm/gqm.ts +++ b/src/bin/gqm/gqm.ts @@ -12,6 +12,7 @@ import { getMigrationDate, printSchemaFromModels, } from '../..'; +import { DateLibrary } from '../../utils/dates'; import { generateGraphqlApiTypes, generateGraphqlClientTypes } from './codegen'; import { parseKnexfile } from './parse-knexfile'; import { parseModels } from './parse-models'; @@ -37,9 +38,10 @@ program const gqlModule = await getSetting('gqlModule'); writeToFile(`${generatedFolderPath}/schema.graphql`, printSchemaFromModels(models)); writeToFile(`${generatedFolderPath}/client/mutations.ts`, generateMutations(models, gqlModule)); - writeToFile(`${generatedFolderPath}/db/index.ts`, generateDBModels(models)); + const dateLibrary = (await getSetting('dateLibrary')) as DateLibrary; + writeToFile(`${generatedFolderPath}/db/index.ts`, generateDBModels(models, dateLibrary)); writeToFile(`${generatedFolderPath}/db/knex.ts`, generateKnexTables(models)); - await generateGraphqlApiTypes(); + await generateGraphqlApiTypes(dateLibrary); await generateGraphqlClientTypes(); }); diff --git a/src/bin/gqm/settings.ts b/src/bin/gqm/settings.ts index 48b602f..013c7b3 100644 --- a/src/bin/gqm/settings.ts +++ b/src/bin/gqm/settings.ts @@ -1,7 +1,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname } from 'path'; +import { DateLibrary } from '../../utils/dates'; import { readLine } from './readline'; -import { EMPTY_MODELS, EXECUTE, GET_ME, GITIGNORE, KNEXFILE } from './templates'; +import { + EMPTY_MODELS, + EXECUTE, + GET_ME, + GITIGNORE, + KNEXFILE, + KNEXFILE_DAYJS_TYPE_PARSERS, + KNEXFILE_LUXON_TYPE_PARSERS, +} from './templates'; const SETTINGS_PATH = '.gqmrc.json'; @@ -52,6 +61,32 @@ const DEFAULTS = { gqlModule: { defaultValue: '@smartive/graphql-magic', }, + dateLibrary: { + question: 'Which date library to use (dayjs|luxon)?', + defaultValue: 'dayjs', + init: async (dateLibrary: DateLibrary) => { + const knexfilePath = await getSetting('knexfilePath'); + switch (dateLibrary) { + case 'luxon': { + const timeZone = await getSetting('timeZone'); + ensureFileContains(knexfilePath, 'luxon', KNEXFILE_LUXON_TYPE_PARSERS(timeZone)); + break; + } + case 'dayjs': + ensureFileContains(knexfilePath, 'dayjs', KNEXFILE_DAYJS_TYPE_PARSERS); + break; + default: + throw new Error('Invalid or unsupported date library.'); + } + }, + }, + timeZone: { + question: 'Which time zone to use?', + defaultValue: 'Europe/Zurich', + init: () => { + // Do nothing + }, + }, }; type Settings = { @@ -61,7 +96,7 @@ type Settings = { const initSetting = async (name: string) => { const { question, defaultValue, init } = DEFAULTS[name]; const value = (await readLine(`${question} (${defaultValue})`)) || defaultValue; - init(value); + await init(value); return value; }; diff --git a/src/bin/gqm/templates.ts b/src/bin/gqm/templates.ts index 5eca188..b7f7d7b 100644 --- a/src/bin/gqm/templates.ts +++ b/src/bin/gqm/templates.ts @@ -17,14 +17,9 @@ const modelDefinitions: ModelDefinitions = [ export const models = new Models(modelDefinitions); `; -export const KNEXFILE = `import { DateTime } from 'luxon'; +export const KNEXFILE = ` import { types } from 'pg'; -const dateOids = { date: 1082, timestamptz: 1184, timestamp: 1114 }; -for (const oid of Object.values(dateOids)) { - types.setTypeParser(oid, (val) => DateTime.fromSQL(val)); -} - const numberOids = { int8: 20, float8: 701, numeric: 1700 }; for (const oid of Object.values(numberOids)) { types.setTypeParser(oid, Number); @@ -50,6 +45,24 @@ const knexConfig = { export default knexConfig; `; +export const KNEXFILE_LUXON_TYPE_PARSERS = (timeZone: string) => ` +import { DateTime } from 'luxon'; + +const dateOids = { date: 1082, timestamptz: 1184, timestamp: 1114 }; +for (const oid of Object.values(dateOids)) { + types.setTypeParser(oid, (val) => DateTime.fromSQL(val, { zone: "${timeZone}" })); +} +`; + +export const KNEXFILE_DAYJS_TYPE_PARSERS = ` +import { dayjs } from 'dayjs'; + +const dateOids = { date: 1082, timestamptz: 1184, timestamp: 1114 }; +for (const oid of Object.values(dateOids)) { + types.setTypeParser(oid, (val) => dayjs(val)); +} +`; + export const GET_ME = `import { gql } from '@smartive/graphql-magic'; export const GET_ME = gql\` @@ -66,7 +79,6 @@ import knexConfig from "@/knexfile"; import { Context, User, execute } from "@smartive/graphql-magic"; import { randomUUID } from "crypto"; import { knex } from 'knex'; -import { DateTime } from "luxon"; import { models } from "../config/models"; export const executeGraphql = async ( @@ -89,7 +101,6 @@ export const executeGraphql = async ( user, models: models, permissions: { ADMIN: true, UNAUTHENTICATED: true }, - now: DateTime.local(), }); await db.destroy(); diff --git a/src/context.ts b/src/context.ts index 9a214fa..6f85bb5 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,18 +1,19 @@ import { DocumentNode, GraphQLResolveInfo } from 'graphql'; import { IncomingMessage } from 'http'; import { Knex } from 'knex'; -import { DateTime } from 'luxon'; import { Models } from './models/models'; import { Entity, MutationHook } from './models/mutation-hook'; import { Permissions } from './permissions/generate'; import { AliasGenerator } from './resolvers/utils'; +import { AnyDateType } from './utils'; // Minimal user structure required by graphql-magic export type User = { id: string; role: string }; -export type Context = { +export type Context = { req: IncomingMessage; - now: DateTime; + now: DateType; + timeZone?: string; knex: Knex; document: DocumentNode; locale: string; @@ -20,8 +21,11 @@ export type Context = { user?: User; models: Models; permissions: Permissions; - mutationHook?: MutationHook; + mutationHook?: MutationHook; handleUploads?: (data: Entity) => Promise; }; -export type FullContext = Context & { info: GraphQLResolveInfo; aliases: AliasGenerator }; +export type FullContext = Context & { + info: GraphQLResolveInfo; + aliases: AliasGenerator; +}; diff --git a/src/db/generate.ts b/src/db/generate.ts index 88ba652..16449ce 100644 --- a/src/db/generate.ts +++ b/src/db/generate.ts @@ -1,6 +1,7 @@ import CodeBlockWriter from 'code-block-writer'; import { EntityField, get, getColumnName, isCustomField, isInTable, isRootModel, not } from '..'; import { Models } from '../models/models'; +import { DATE_CLASS, DATE_CLASS_IMPORT, DateLibrary } from '../utils/dates'; const PRIMITIVE_TYPES = { ID: 'string', @@ -9,18 +10,17 @@ const PRIMITIVE_TYPES = { Int: 'number', Float: 'number', String: 'string', - DateTime: 'DateTime | string', }; const OPTIONAL_SEED_FIELDS = ['createdAt', 'createdById', 'updatedAt', 'updatedById', 'deletedAt', 'deletedById']; -export const generateDBModels = (models: Models) => { +export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => { const writer: CodeBlockWriter = new CodeBlockWriter['default']({ useSingleQuote: true, indentNumberOfSpaces: 2, }); - writer.write(`import { DateTime } from 'luxon';`).blankLine(); + writer.write(DATE_CLASS_IMPORT[dateLibrary]).blankLine(); for (const enm of models.enums) { writer.write(`export type ${enm.name} = ${enm.values.map((v) => `'${v}'`).join(' | ')};`).blankLine(); @@ -36,7 +36,9 @@ export const generateDBModels = (models: Models) => { .write(`export type ${model.name} = `) .inlineBlock(() => { for (const field of fields.filter(not(isCustomField))) { - writer.write(`'${getColumnName(field)}': ${getFieldType(field)}${field.nonNull ? '' : ' | null'};`).newLine(); + writer + .write(`'${getColumnName(field)}': ${getFieldType(field, dateLibrary)}${field.nonNull ? '' : ' | null'};`) + .newLine(); } }) .blankLine(); @@ -48,7 +50,9 @@ export const generateDBModels = (models: Models) => { writer .write( `'${getColumnName(field)}'${field.nonNull && field.defaultValue === undefined ? '' : '?'}: ${getFieldType( - field + field, + dateLibrary, + true )}${field.list ? ' | string' : ''}${field.nonNull ? '' : ' | null'};` ) .newLine(); @@ -62,7 +66,7 @@ export const generateDBModels = (models: Models) => { for (const field of fields.filter(not(isCustomField)).filter(isInTable)) { writer .write( - `'${getColumnName(field)}'?: ${getFieldType(field)}${field.list ? ' | string' : ''}${ + `'${getColumnName(field)}'?: ${getFieldType(field, dateLibrary, true)}${field.list ? ' | string' : ''}${ field.nonNull ? '' : ' | null' };` ) @@ -84,7 +88,7 @@ export const generateDBModels = (models: Models) => { .write( `'${getColumnName(field)}'${ field.nonNull && field.defaultValue === undefined && !OPTIONAL_SEED_FIELDS.includes(fieldName) ? '' : '?' - }: ${field.kind === 'enum' ? (field.list ? 'string[]' : 'string') : getFieldType(field)}${ + }: ${field.kind === 'enum' ? (field.list ? 'string[]' : 'string') : getFieldType(field, dateLibrary, true)}${ field.list ? ' | string' : '' }${field.nonNull ? '' : ' | null'};` ) @@ -104,7 +108,7 @@ export const generateDBModels = (models: Models) => { return writer.toString(); }; -const getFieldType = (field: EntityField) => { +const getFieldType = (field: EntityField, dateLibrary: DateLibrary, input?: boolean) => { const kind = field.kind; switch (kind) { case 'json': @@ -119,6 +123,9 @@ const getFieldType = (field: EntityField) => { throw new Error(`Custom fields are not in the db.`); case 'primitive': case undefined: + if (field.type === 'DateTime') { + return (input ? `(${DATE_CLASS[dateLibrary]} | string)` : DATE_CLASS[dateLibrary]) + (field.list ? '[]' : ''); + } return get(PRIMITIVE_TYPES, field.type) + (field.list ? '[]' : ''); default: { const exhaustiveCheck: never = kind; diff --git a/src/index.ts b/src/index.ts index 080e7c2..6727d45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from './models'; export * from './permissions'; export * from './resolvers'; export * from './schema'; +export * from './utils'; export * from './context'; export * from './errors'; export * from './values'; diff --git a/src/migrations/generate.ts b/src/migrations/generate.ts index c513485..e6849ea 100644 --- a/src/migrations/generate.ts +++ b/src/migrations/generate.ts @@ -8,6 +8,7 @@ import { EntityField, EntityModel, EnumModel, Models } from '../models/models'; import { and, get, + isCreatableModel, isInherited, isUpdatableField, isUpdatableModel, @@ -147,9 +148,7 @@ export class MigrationGenerator { .filter( ({ name, ...field }) => field.kind !== 'custom' && - !this.columns[model.name].some( - (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) - ) + !this.getColumn(model.name, field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ), up, down @@ -157,7 +156,7 @@ export class MigrationGenerator { // Update fields const existingFields = model.fields.filter(({ name, kind, nonNull }) => { - const col = this.columns[model.name].find((col) => col.name === (kind === 'relation' ? `${name}Id` : name)); + const col = this.getColumn(model.name, kind === 'relation' ? `${name}Id` : name); if (!col) { return false; } @@ -216,9 +215,7 @@ export class MigrationGenerator { .filter( ({ name, ...field }) => field.kind !== 'custom' && - !this.columns[revisionTable].some( - (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) - ) + !this.getColumn(revisionTable, field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ); this.createRevisionFields(model, missingRevisionFields, up, down); @@ -229,9 +226,7 @@ export class MigrationGenerator { field.kind !== 'custom' && !updatable && !(field.kind === 'relation' && field.foreignKey === 'id') && - this.columns[revisionTable].some( - (col) => col.name === (field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) - ) + this.getColumn(revisionTable, field.kind === 'relation' ? field.foreignKey || `${name}Id` : name) ); this.createRevisionFields(model, revisionFieldsToRemove, down, up); } @@ -241,12 +236,31 @@ export class MigrationGenerator { for (const model of models.entities) { if (tables.includes(model.name)) { - this.createFields( - model, - model.fields.filter(({ name, deleted }) => deleted && this.columns[model.name].some((col) => col.name === name)), - down, - up - ); + const fieldsToDelete = model.fields.filter(({ name, deleted }) => deleted && this.getColumn(model.name, name)); + + if (!isCreatableModel(model)) { + if (this.getColumn(model.name, 'createdAt')) { + fieldsToDelete.push({ name: 'createdAt', type: 'DateTime', nonNull: true }); + } + + if (this.getColumn(model.name, 'createdBy')) { + fieldsToDelete.push({ name: 'createdBy', kind: 'relation', type: 'User', nonNull: true }); + } + } + + if (!isUpdatableModel(model)) { + if (this.getColumn(model.name, 'updatedAt')) { + fieldsToDelete.push({ name: 'updatedAt', type: 'DateTime', nonNull: true }); + } + + if (this.getColumn(model.name, 'updatedBy')) { + fieldsToDelete.push({ name: 'updatedBy', kind: 'relation', type: 'User', nonNull: true }); + } + } + + if (fieldsToDelete.length) { + this.createFields(model, fieldsToDelete, down, up); + } if (isUpdatableModel(model)) { this.createRevisionFields( @@ -664,6 +678,10 @@ export class MigrationGenerator { } } } + + private getColumn(tableName: string, columnName: string) { + return this.columns[tableName].find((col) => col.name === columnName); + } } export const getMigrationDate = () => { diff --git a/src/models/mutation-hook.ts b/src/models/mutation-hook.ts index c74a03d..2137a19 100644 --- a/src/models/mutation-hook.ts +++ b/src/models/mutation-hook.ts @@ -1,17 +1,14 @@ -import { DateTime } from 'luxon'; -import { Context } from '..'; +import { AnyDateType, Context } from '..'; import { EntityModel } from './models'; -export type Entity = Record & { createdAt?: DateTime; deletedAt?: DateTime }; - -export type FullEntity = Entity & { id: string }; +export type Entity = Record; export type Action = 'create' | 'update' | 'delete' | 'restore'; -export type MutationHook = ( +export type MutationHook = ( model: EntityModel, action: Action, when: 'before' | 'after', - data: { prev: Entity; input: Entity; normalizedInput: Entity; next: FullEntity }, - ctx: Context + data: { prev: Entity; input: Entity; normalizedInput: Entity; next: Entity }, + ctx: Context ) => Promise; diff --git a/src/models/utils.ts b/src/models/utils.ts index d7bb060..47a1e32 100644 --- a/src/models/utils.ts +++ b/src/models/utils.ts @@ -58,8 +58,12 @@ export const isInputModel = (model: Model): model is InputModel => model instanc export const isInterfaceModel = (model: Model): model is InterfaceModel => model instanceof InterfaceModel; +export const isCreatableModel = (model: EntityModel) => model.creatable && model.fields.some(isCreatableField); + export const isUpdatableModel = (model: EntityModel) => model.updatable && model.fields.some(isUpdatableField); +export const isCreatableField = (field: EntityField) => !field.inherited && !!field.creatable; + export const isUpdatableField = (field: EntityField) => !field.inherited && !!field.updatable; export const modelNeedsTable = (model: EntityModel) => model.fields.some((field) => !field.inherited); @@ -169,3 +173,23 @@ export const retry = async (cb: () => Promise, condition: (e: any) => bool } } }; + +type Typeof = { + string: string; + number: number; + bigint: bigint; + boolean: boolean; + symbol: symbol; + undefined: undefined; + object: object; + // eslint-disable-next-line @typescript-eslint/ban-types + function: Function; +}; + +export const as = (value: unknown, type: T): Typeof[T] => { + if (typeof value !== type) { + throw new Error(`No string`); + } + + return value as Typeof[T]; +}; diff --git a/src/permissions/check.ts b/src/permissions/check.ts index 70f80ac..726f20f 100644 --- a/src/permissions/check.ts +++ b/src/permissions/check.ts @@ -4,7 +4,6 @@ import { NotFoundError, PermissionError } from '../errors'; import { EntityModel } from '../models/models'; import { get, isRelation } from '../models/utils'; import { AliasGenerator, hash, ors } from '../resolvers/utils'; -import { BasicValue } from '../values'; import { PermissionAction, PermissionLink, PermissionStack } from './generate'; export const getRole = (ctx: Pick) => ctx.user?.role ?? 'UNAUTHENTICATED'; @@ -89,7 +88,7 @@ export const applyPermissions = ( export const getEntityToMutate = async ( ctx: Pick, model: EntityModel, - where: Record, + where: Record, action: 'UPDATE' | 'DELETE' | 'RESTORE' ) => { const query = ctx @@ -132,7 +131,7 @@ export const getEntityToMutate = async ( export const checkCanWrite = async ( ctx: Pick, model: EntityModel, - data: Record, + data: Record, action: 'CREATE' | 'UPDATE' ) => { const permissionStack = getPermissionStack(ctx, model.name, action); diff --git a/src/resolvers/mutations.ts b/src/resolvers/mutations.ts index 76552bc..05354e7 100644 --- a/src/resolvers/mutations.ts +++ b/src/resolvers/mutations.ts @@ -1,12 +1,12 @@ import { GraphQLResolveInfo } from 'graphql'; -import { DateTime } from 'luxon'; import { v4 as uuid } from 'uuid'; import { Context, FullContext } from '../context'; import { ForbiddenError, GraphQLError } from '../errors'; import { EntityField, EntityModel } from '../models/models'; -import { Entity, FullEntity } from '../models/mutation-hook'; +import { Entity } from '../models/mutation-hook'; import { get, isPrimitive, it, typeToField } from '../models/utils'; import { applyPermissions, checkCanWrite, getEntityToMutate } from '../permissions/check'; +import { anyDateToLuxon } from '../utils'; import { resolve } from './resolver'; import { AliasGenerator } from './utils'; @@ -151,7 +151,7 @@ const del = async (model: EntityModel, { where, dryRun }: { where: any; dryRun: const mutations: Callbacks = []; const afterHooks: Callbacks = []; - const deleteCascade = async (currentModel: EntityModel, entity: FullEntity) => { + const deleteCascade = async (currentModel: EntityModel, entity: Entity) => { if (entity.deleted) { return; } @@ -266,8 +266,12 @@ const restore = async (model: EntityModel, { where }: { where: any }, ctx: FullC const mutations: Callbacks = []; const afterHooks: Callbacks = []; - const restoreCascade = async (currentModel: EntityModel, relatedEntity: FullEntity) => { - if (!relatedEntity.deleted || !relatedEntity.deletedAt || !relatedEntity.deletedAt.equals(entity.deletedAt)) { + const restoreCascade = async (currentModel: EntityModel, relatedEntity: Entity) => { + if ( + !relatedEntity.deleted || + !relatedEntity.deletedAt || + anyDateToLuxon(relatedEntity.deletedAt, ctx.timeZone).equals(anyDateToLuxon(entity.deletedAt, ctx.timeZone)) + ) { return; } @@ -365,7 +369,7 @@ const sanitize = (ctx: FullContext, model: EntityModel, data: Entity) => { } if (isEndOfDay(field) && data[key]) { - data[key] = (data[key] as DateTime).endOf('day'); + data[key] = anyDateToLuxon(data[key], ctx.timeZone); continue; } diff --git a/src/schema/utils.ts b/src/schema/utils.ts index 2c195f8..93072f5 100644 --- a/src/schema/utils.ts +++ b/src/schema/utils.ts @@ -1,3 +1,4 @@ +import { Dayjs } from 'dayjs'; import { ArgumentNode, DefinitionNode, @@ -222,7 +223,13 @@ export const value = (val: Value = null): ValueNode => kind: 'StringValue', value: val.toString(), } - : { + : val instanceof Dayjs + ? { + kind: 'StringValue', + value: val.toISOString(), + } + : typeof val === 'object' + ? { kind: 'ObjectValue', fields: Object.keys(val).map( (nme): ObjectFieldNode => ({ @@ -231,4 +238,9 @@ export const value = (val: Value = null): ValueNode => value: value(val[nme]), }) ), - }; + } + : doThrow(`Unsupported value ${val}`); + +const doThrow = (message: string) => { + throw new Error(message); +}; diff --git a/src/utils/dates.ts b/src/utils/dates.ts new file mode 100644 index 0000000..bc254c7 --- /dev/null +++ b/src/utils/dates.ts @@ -0,0 +1,48 @@ +import { Dayjs, isDayjs } from 'dayjs'; +import { DateTime } from 'luxon'; + +export type DateLibrary = 'luxon' | 'dayjs'; + +export const DATE_CLASS: { [key in DateLibrary]: string } = { + luxon: 'DateTime', + dayjs: 'Dayjs', +}; + +export const DATE_CLASS_IMPORT = { + luxon: `import { DateTime } from 'luxon';`, + dayjs: `import { Dayjs } from 'dayjs';`, +}; + +export type AnyDateType = DateTime | Dayjs | Date | string; + +export const anyDateToLuxon = (date: unknown, zone: string | undefined, fallbackToNow = false) => { + if (!date) { + if (fallbackToNow) { + return DateTime.local({ zone }); + } else { + return undefined; + } + } + + if (DateTime.isDateTime(date)) { + return date.setZone(zone); + } + + if (isDayjs(date)) { + return DateTime.fromISO(date.toISOString(), { zone }); + } + + if (date instanceof Date) { + return DateTime.fromJSDate(date, { zone }); + } + + if (typeof date === 'string' && date) { + return DateTime.fromISO(date, { zone }); + } + + if (typeof date === 'number') { + return DateTime.fromMillis(date, { zone }); + } + + throw new Error(`Unsupported date format: ${date} (${date.constructor.name})`); +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..24f77ca --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +// created from 'create-ts-index' + +export * from './dates'; diff --git a/src/values.ts b/src/values.ts index a5d519b..41c58cf 100644 --- a/src/values.ts +++ b/src/values.ts @@ -1,8 +1,4 @@ -import { DateTime } from 'luxon'; - -export type BasicValue = undefined | null | boolean | string | number | DateTime; - -export type Value = any; // BasicValue | Symbol | Symbol[] | Record | Value[]; +export type Value = unknown; export type Values = { name: string; diff --git a/tests/generated/client/index.ts b/tests/generated/client/index.ts index 5d36240..ac2b20a 100644 --- a/tests/generated/client/index.ts +++ b/tests/generated/client/index.ts @@ -857,7 +857,7 @@ export type SomeQueryQuery = { manyObjects: Array<{ __typename: 'SomeObject', id export type ReverseFiltersQueryQueryVariables = Exact<{ [key: string]: never; }>; -export type ReverseFiltersQueryQuery = { all: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, withFloat0: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, withFloat0_5: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, noneFloat0: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, noneFloat0_5: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }> }; +export type ReverseFiltersQueryQuery = { all: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, withFloat0: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, withFloat0_5: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, noneFloat0: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, noneFloat0_5: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }>, noneFloat2: Array<{ __typename: 'AnotherObject', id: string, manyObjects: Array<{ __typename: 'SomeObject', float: number }> }> }; export type DeleteAnotherObjectMutationMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -1061,6 +1061,8 @@ export namespace ReverseFiltersQuery { export type ___manyObjects = NonNullable<(NonNullable)[number]>['manyObjects']>)[number]>; export type noneFloat0_5 = NonNullable<(NonNullable)[number]>; export type ____manyObjects = NonNullable<(NonNullable)[number]>['manyObjects']>)[number]>; + export type noneFloat2 = NonNullable<(NonNullable)[number]>; + export type _____manyObjects = NonNullable<(NonNullable)[number]>['manyObjects']>)[number]>; } export namespace DeleteAnotherObjectMutation { diff --git a/tests/generated/db/index.ts b/tests/generated/db/index.ts index 9e29622..1ad40d1 100644 --- a/tests/generated/db/index.ts +++ b/tests/generated/db/index.ts @@ -35,7 +35,7 @@ export type AnotherObject = { 'name': string | null; 'myselfId': string | null; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; } @@ -44,7 +44,7 @@ export type AnotherObjectInitializer = { 'name'?: string | null; 'myselfId'?: string | null; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -53,7 +53,7 @@ export type AnotherObjectMutator = { 'name'?: string | null; 'myselfId'?: string | null; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -62,7 +62,7 @@ export type AnotherObjectSeed = { 'name'?: string | null; 'myselfId'?: string | null; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -73,12 +73,12 @@ export type SomeObject = { 'float': number; 'list': SomeEnum[]; 'xyz': number; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; } @@ -89,12 +89,12 @@ export type SomeObjectInitializer = { 'float': number; 'list': SomeEnum[] | string; 'xyz': number; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -105,12 +105,12 @@ export type SomeObjectMutator = { 'float'?: number; 'list'?: SomeEnum[] | string; 'xyz'?: number; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -121,12 +121,12 @@ export type SomeObjectSeed = { 'float': number; 'list': string[] | string; 'xyz': number; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -135,12 +135,12 @@ export type Reaction = { 'type': ReactionType; 'parentId': string | null; 'content': string | null; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; } @@ -149,12 +149,12 @@ export type ReactionInitializer = { 'type': ReactionType; 'parentId'?: string | null; 'content'?: string | null; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -163,12 +163,12 @@ export type ReactionMutator = { 'type'?: ReactionType; 'parentId'?: string | null; 'content'?: string | null; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -177,12 +177,12 @@ export type Review = { 'type': ReactionType; 'parentId': string | null; 'content': string | null; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; 'rating': number | null; } @@ -201,12 +201,12 @@ export type ReviewSeed = { 'id': string; 'parentId'?: string | null; 'content'?: string | null; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; 'rating'?: number | null; } @@ -216,12 +216,12 @@ export type Question = { 'type': ReactionType; 'parentId': string | null; 'content': string | null; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; } @@ -237,12 +237,12 @@ export type QuestionSeed = { 'id': string; 'parentId'?: string | null; 'content'?: string | null; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } @@ -251,12 +251,12 @@ export type Answer = { 'type': ReactionType; 'parentId': string | null; 'content': string | null; - 'createdAt': DateTime | string; + 'createdAt': DateTime; 'createdById': string; - 'updatedAt': DateTime | string; + 'updatedAt': DateTime; 'updatedById': string; 'deleted': boolean; - 'deletedAt': DateTime | string | null; + 'deletedAt': DateTime | null; 'deletedById': string | null; } @@ -272,12 +272,12 @@ export type AnswerSeed = { 'id': string; 'parentId'?: string | null; 'content'?: string | null; - 'createdAt'?: DateTime | string; + 'createdAt'?: DateTime; 'createdById'?: string; - 'updatedAt'?: DateTime | string; + 'updatedAt'?: DateTime; 'updatedById'?: string; 'deleted'?: boolean; - 'deletedAt'?: DateTime | string | null; + 'deletedAt'?: DateTime | null; 'deletedById'?: string | null; } diff --git a/tests/utils/database/seed.ts b/tests/utils/database/seed.ts index 08750ec..3ab1e78 100644 --- a/tests/utils/database/seed.ts +++ b/tests/utils/database/seed.ts @@ -1,6 +1,5 @@ import { Knex } from 'knex'; import { pick } from 'lodash'; -import { DateTime } from 'luxon'; import { getColumnName, isInTable, modelNeedsTable } from '../../../src'; import { SeedData } from '../../generated/db'; import { models } from '../models'; @@ -78,19 +77,18 @@ export const seed: SeedData = { ], }; -export const setupSeed = async (knex: Knex) => { - const now = DateTime.now(); +export const setupSeed = async (knex: Knex, now: string) => { for (const [table, entities] of Object.entries(seed)) { const model = models.getModel(table, 'entity'); const mappedEntities = entities.map((entity, i) => ({ ...entity, ...(model.parent && { type: model.name }), ...(model.creatable && { - createdAt: now.plus({ second: i }), + createdAt: addSeconds(now, i), createdById: ADMIN_ID, }), ...(model.updatable && { - updatedAt: now.plus({ second: i }), + updatedAt: addSeconds(now, i), updatedById: ADMIN_ID, }), })); @@ -112,3 +110,9 @@ export const setupSeed = async (knex: Knex) => { } } }; + +const addSeconds = (dateString: string, seconds: number) => { + const date = new Date(dateString); + date.setSeconds(date.getSeconds() + seconds); + return date.toISOString(); +}; diff --git a/tests/utils/server.ts b/tests/utils/server.ts index 7f3bd91..9ac616e 100644 --- a/tests/utils/server.ts +++ b/tests/utils/server.ts @@ -2,7 +2,6 @@ import { TypedQueryDocumentNode } from 'graphql'; import graphqlRequest, { RequestDocument, Variables } from 'graphql-request'; import { RequestListener, createServer } from 'http'; import { Knex } from 'knex'; -import { DateTime } from 'luxon'; import { up } from '../../migrations/20230912185644_setup'; import { execute } from '../../src'; import { getKnex } from './database/knex'; @@ -44,7 +43,8 @@ export const withServer = async ( try { await up(knex); - await setupSeed(knex); + const now = '2020-01-01T00:00:00.000Z'; + await setupSeed(knex, now); handler = async (req, res) => { const user = await knex('User').where({ id: ADMIN_ID }).first(); @@ -66,7 +66,7 @@ export const withServer = async ( user, models, permissions, - now: DateTime.fromISO('2020-01-01T00:00:00.000Z'), + now, body, });