diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05a7d18e..3f210afb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: - 9000:8000 contember-engine: - image: contember/engine:2.1.0-rc.6 + image: contember/engine:2.2.0-alpha.17 env: NODE_ENV: development CONTEMBER_PORT: "4000" @@ -121,7 +121,7 @@ jobs: -e CONTEMBER_PROJECT_NAME=example \ -v ${{ github.workspace }}:/src \ -w /src/packages/example \ - contember/cli:2.1.0-rc.6 \ + contember/cli:2.2.0-alpha.17 \ migrations:execute --yes CONTEMBER_API_URL=http://localhost:1581 CONTEMBER_API_TOKEN=0000000000000000000000000000000000000000 bun run packages/example/seed.ts diff --git a/bun.lock b/bun.lock index 9172d815..b8bb68c2 100644 --- a/bun.lock +++ b/bun.lock @@ -27,16 +27,16 @@ "version": "0.1.59", "dependencies": { "@contember/bindx-client": "workspace:*", - "@contember/graphql-client": "2.1.0-rc.6", + "@contember/graphql-client": "2.2.0-alpha.17", }, }, "packages/bindx-client": { "name": "@contember/bindx-client", "version": "0.1.59", "dependencies": { - "@contember/graphql-builder": "2.1.0-rc.6", - "@contember/graphql-client": "2.1.0-rc.6", - "@contember/schema": "2.1.0-rc.6", + "@contember/graphql-builder": "2.2.0-alpha.17", + "@contember/graphql-client": "2.2.0-alpha.17", + "@contember/schema": "2.2.0-alpha.17", }, }, "packages/bindx-compiler": { @@ -101,8 +101,8 @@ "name": "@contember/bindx-generator", "version": "0.1.59", "dependencies": { - "@contember/schema": "2.1.0-rc.6", - "@contember/schema-utils": "2.1.0-rc.6", + "@contember/schema": "2.2.0-alpha.17", + "@contember/schema-utils": "2.2.0-alpha.17", }, "devDependencies": { "@types/node": "^25.0.3", @@ -114,7 +114,7 @@ "dependencies": { "@contember/bindx": "workspace:*", "@contember/bindx-client": "workspace:*", - "@contember/graphql-client": "2.1.0-rc.6", + "@contember/graphql-client": "2.2.0-alpha.17", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-slot": "^1.2.4", }, @@ -199,10 +199,10 @@ "@contember/bindx-repeater": "workspace:*", "@contember/bindx-ui": "workspace:*", "@contember/bindx-uploader": "workspace:*", - "@contember/client-content": "2.1.0-rc.6", - "@contember/graphql-client": "2.1.0-rc.6", - "@contember/schema": "2.1.0-rc.6", - "@contember/schema-definition": "2.1.0-rc.6", + "@contember/client-content": "2.2.0-alpha.17", + "@contember/graphql-client": "2.2.0-alpha.17", + "@contember/schema": "2.2.0-alpha.17", + "@contember/schema-definition": "2.2.0-alpha.17", "@tailwindcss/vite": "^4.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -296,19 +296,19 @@ "@contember/bindx-uploader": ["@contember/bindx-uploader@workspace:packages/bindx-uploader"], - "@contember/client-content": ["@contember/client-content@2.1.0-rc.6", "", { "dependencies": { "@contember/graphql-builder": "2.1.0-rc.6", "@contember/graphql-client": "2.1.0-rc.6", "@contember/schema": "2.1.0-rc.6" } }, "sha512-pTp74KFmCDx7uwlcpJImqvWdHyxi5mizWHlnxK0yAS+RN1+YKXWfnJ0t4PPF01r4xXC4BgnTV02//3m6eh2mKA=="], + "@contember/client-content": ["@contember/client-content@2.2.0-alpha.17", "", { "dependencies": { "@contember/graphql-builder": "2.2.0-alpha.17", "@contember/graphql-client": "2.2.0-alpha.17", "@contember/schema": "2.2.0-alpha.17" } }, "sha512-g4+ocWVs0pFug/yUl0rHZWSP+uH+0SrdFk6j3ygw2obe5DTS/zdurF1Q9pedZbAWf7KWMitzyN/x9Ak2OPhWYg=="], - "@contember/graphql-builder": ["@contember/graphql-builder@2.1.0-rc.6", "", { "dependencies": { "@contember/schema": "2.1.0-rc.6" } }, "sha512-r6Zi7FRiqaONoVvZ6gRw2MbfNrvV3MenMV58oAEofYIMHpgFC0Y6jZnNIecIzugMRk6uSFqB8AtU21bfO4MsaQ=="], + "@contember/graphql-builder": ["@contember/graphql-builder@2.2.0-alpha.17", "", { "dependencies": { "@contember/schema": "2.2.0-alpha.17" } }, "sha512-diTctHhci4wW+atlhpi2+YBJfK0vr43ex+L137uIbGaqid5PzZs5WyOzfy456QvVPoPjENGr1JVQCJwHkCG3mA=="], - "@contember/graphql-client": ["@contember/graphql-client@2.1.0-rc.6", "", {}, "sha512-oz56L+uGryr1g/FrtyaA1qHz2L5jeqUrVc7CNi3N7Fj4rYNXgdWJxUtpjq9+XJyBPw+qkXvqGp/JctPnEtbYmA=="], + "@contember/graphql-client": ["@contember/graphql-client@2.2.0-alpha.17", "", {}, "sha512-woEXjpWMmA0WLIBxSaWnWiaX1S4bxqkMoncs8MtZzNwAi7GA2lZgHKvu+JqedjVj/ogp71fMdx3pVaXgp2Clsw=="], - "@contember/schema": ["@contember/schema@2.1.0-rc.6", "", {}, "sha512-+Q/ih2UolgJRX/mEjkN14D5hj4QJ6GvwQNT1+78Lc/vz4gLFXjMfmAsYY59FV6pDvMFlnFXJ7HHx/OYpLymAHA=="], + "@contember/schema": ["@contember/schema@2.2.0-alpha.17", "", {}, "sha512-iAzRJXKShpJ0S5xQU0QUAhMLNp4OXdQUU8NwjWg69YpZjivExFHOgeRJyIspIW9+K9i5QTs/jodo4725HXVYHw=="], - "@contember/schema-definition": ["@contember/schema-definition@2.1.0-rc.6", "", { "dependencies": { "@contember/schema": "2.1.0-rc.6", "@contember/schema-utils": "2.1.0-rc.6", "fast-deep-equal": "^3.1.3", "reflect-metadata": "^0.2.2" }, "peerDependencies": { "graphql": "^16.13.1" } }, "sha512-pLuTEqlSRMCYjfRTha/RX/Ac4WYELZWi9+hC2BsKDcnCBFY9UZiBtwyyH6hZY1paF+0noUqc6bwyA2xqTcPd3w=="], + "@contember/schema-definition": ["@contember/schema-definition@2.2.0-alpha.17", "", { "dependencies": { "@contember/schema": "2.2.0-alpha.17", "@contember/schema-utils": "2.2.0-alpha.17", "fast-deep-equal": "^3.1.3", "reflect-metadata": "^0.2.2" }, "peerDependencies": { "graphql": "^16.13.1" } }, "sha512-4YLoYZfNDU3wlOhUt4vixvhYynjLogxkZduuc4LvsJGJm9mNAoOaCyHo+wNLjo03UxK6LkWfoQpYcLGufLLgHQ=="], - "@contember/schema-utils": ["@contember/schema-utils@2.1.0-rc.6", "", { "dependencies": { "@contember/schema": "2.1.0-rc.6", "@contember/typesafe": "2.1.0-rc.6" } }, "sha512-Y8WzXzZxkN3cwtxmvMCawn0VWOjA1VjAuf6K6A6dYH09h6Y82qgUTgbp9kB6o4jrJ182oqCn33LKSm7hKL59iw=="], + "@contember/schema-utils": ["@contember/schema-utils@2.2.0-alpha.17", "", { "dependencies": { "@contember/schema": "2.2.0-alpha.17", "@contember/typesafe": "2.2.0-alpha.17" } }, "sha512-9zlRCxV8Fs/+Ttk6Tu5coXA827YkWaORtnB7hRYvMkyBhRdOsHHYt6wOnhJcpqtmSI1fH1JFxh8lZm3khoUxoA=="], - "@contember/typesafe": ["@contember/typesafe@2.1.0-rc.6", "", {}, "sha512-OLdhP8KfDGuQoctdgc8AFxtcdbk0mGKX6rXGxx6S08qyR9dA98DYM3iosgkq6dWVSzUXRNkoAFOc71mf1aWRgw=="], + "@contember/typesafe": ["@contember/typesafe@2.2.0-alpha.17", "", {}, "sha512-/T4XszWTjWSYaCjl0M2mY6/iWJnkHrJ5If84QOqVpzy0x6ABRYePDiRL65DTPMP0MjoG1GZBjuKq+mVcX+sXFQ=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], diff --git a/docker-compose.yaml b/docker-compose.yaml index 4002efe4..718ebb12 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,6 @@ services: contember-engine: - image: contember/engine:2.1.0-rc.6 + image: contember/engine:2.2.0-alpha.17 environment: NODE_ENV: 'development' @@ -43,7 +43,7 @@ services: condition: service_healthy contember-cli: - image: contember/cli:2.1.0-rc.6 + image: contember/cli:2.2.0-alpha.17 user: '1000:1000' deploy: diff --git a/packages/bindx-client/package.json b/packages/bindx-client/package.json index c9124aec..977ad897 100644 --- a/packages/bindx-client/package.json +++ b/packages/bindx-client/package.json @@ -17,9 +17,9 @@ ], "license": "MIT", "dependencies": { - "@contember/graphql-builder": "2.1.0-rc.6", - "@contember/graphql-client": "2.1.0-rc.6", - "@contember/schema": "2.1.0-rc.6" + "@contember/graphql-builder": "2.2.0-alpha.17", + "@contember/graphql-client": "2.2.0-alpha.17", + "@contember/schema": "2.2.0-alpha.17" }, "repository": { "type": "git", diff --git a/packages/bindx-form/src/types.ts b/packages/bindx-form/src/types.ts index fea57647..1b3e8f45 100644 --- a/packages/bindx-form/src/types.ts +++ b/packages/bindx-form/src/types.ts @@ -68,6 +68,7 @@ export type ColumnType = | 'String' | 'Integer' | 'Double' + | 'Numeric' | 'Date' | 'DateTime' | 'Time' diff --git a/packages/bindx-generator/package.json b/packages/bindx-generator/package.json index 09676147..b9d690c5 100644 --- a/packages/bindx-generator/package.json +++ b/packages/bindx-generator/package.json @@ -17,8 +17,8 @@ ], "license": "MIT", "dependencies": { - "@contember/schema": "2.1.0-rc.6", - "@contember/schema-utils": "2.1.0-rc.6" + "@contember/schema": "2.2.0-alpha.17", + "@contember/schema-utils": "2.2.0-alpha.17" }, "devDependencies": { "@types/node": "^25.0.3" diff --git a/packages/bindx-generator/src/utils.ts b/packages/bindx-generator/src/utils.ts index dfa13fd6..32906876 100644 --- a/packages/bindx-generator/src/utils.ts +++ b/packages/bindx-generator/src/utils.ts @@ -18,6 +18,9 @@ export const columnToTsType = (column: Model.AnyColumn): string => { return 'number' case Model.ColumnType.Double: return 'number' + // An exact decimal travels as a string, so it keeps every digit. + case Model.ColumnType.Numeric: + return 'string' case Model.ColumnType.Bool: return 'boolean' case Model.ColumnType.DateTime: diff --git a/packages/bindx-generator/tests/columnToTsType.test.ts b/packages/bindx-generator/tests/columnToTsType.test.ts new file mode 100644 index 00000000..e562ff57 --- /dev/null +++ b/packages/bindx-generator/tests/columnToTsType.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { columnToTsType } from '../src/index' +import { Model } from '@contember/schema' + +const column = (type: Model.ColumnType, columnType: string, list?: boolean): Model.AnyColumn => ({ + name: 'value', + columnName: 'value', + type, + columnType, + nullable: true, + ...(list === undefined ? {} : { list }), +}) + +describe('columnToTsType', () => { + test('types an exact decimal as a string', () => { + expect(columnToTsType(column(Model.ColumnType.Numeric, 'numeric'))).toBe('string') + }) + + test('types a list of exact decimals as a readonly string array', () => { + expect(columnToTsType(column(Model.ColumnType.Numeric, 'numeric', true))).toBe('readonly string[]') + }) +}) diff --git a/packages/bindx-react/package.json b/packages/bindx-react/package.json index b1a2c9a6..24756a0c 100644 --- a/packages/bindx-react/package.json +++ b/packages/bindx-react/package.json @@ -17,7 +17,7 @@ "dependencies": { "@contember/bindx": "workspace:*", "@contember/bindx-client": "workspace:*", - "@contember/graphql-client": "2.1.0-rc.6", + "@contember/graphql-client": "2.2.0-alpha.17", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-slot": "^1.2.4" }, diff --git a/packages/bindx/package.json b/packages/bindx/package.json index fcf63044..f222f403 100644 --- a/packages/bindx/package.json +++ b/packages/bindx/package.json @@ -18,7 +18,7 @@ "license": "MIT", "dependencies": { "@contember/bindx-client": "workspace:*", - "@contember/graphql-client": "2.1.0-rc.6" + "@contember/graphql-client": "2.2.0-alpha.17" }, "repository": { "type": "git", diff --git a/packages/bindx/src/adapter/ContemberAdapter.ts b/packages/bindx/src/adapter/ContemberAdapter.ts index a24bf172..4467b4df 100644 --- a/packages/bindx/src/adapter/ContemberAdapter.ts +++ b/packages/bindx/src/adapter/ContemberAdapter.ts @@ -27,7 +27,7 @@ import type { SchemaRegistry } from '../schema/SchemaRegistry.js' */ export interface ContemberAdapterOptions { /** GraphQL client instance */ - client: GraphQlClient + client: Pick /** Schema registry for relation target resolution */ schemaRegistry: SchemaRegistry } diff --git a/packages/bindx/src/errors/errorPathResolver.ts b/packages/bindx/src/errors/errorPathResolver.ts index 8617b829..8300c1df 100644 --- a/packages/bindx/src/errors/errorPathResolver.ts +++ b/packages/bindx/src/errors/errorPathResolver.ts @@ -192,6 +192,8 @@ function getErrorCode(errorType?: string): string | undefined { return 'INVALID_DATA' case 'SqlError': return 'SQL_ERROR' + case 'ResourceExhausted': + return 'RESOURCE_EXHAUSTED' default: return undefined } diff --git a/packages/bindx/src/errors/types.ts b/packages/bindx/src/errors/types.ts index fa9ea8c7..c4542130 100644 --- a/packages/bindx/src/errors/types.ts +++ b/packages/bindx/src/errors/types.ts @@ -25,6 +25,7 @@ export const ExecutionErrorTypes: Record NonUniqueWhereInput: 'NonUniqueWhereInput', InvalidDataInput: 'InvalidDataInput', SqlError: 'SqlError', + ResourceExhausted: 'ResourceExhausted', } /** @@ -37,13 +38,14 @@ export function isExecutionErrorType(value: string): value is ExecutionErrorType /** * Error classification for retry logic. */ -export type ErrorCategory = 'validation' | 'constraint' | 'not_found' | 'transient' | 'unknown' +export type ErrorCategory = 'validation' | 'constraint' | 'not_found' | 'resource_limit' | 'transient' | 'unknown' /** * Classifies an execution error type into a category. * - 'validation': Data validation errors (not retryable) * - 'constraint': Database constraint violations (not retryable without data change) * - 'not_found': Entity not found or access denied (not retryable) + * - 'resource_limit': The request exceeded a server resource budget; the same request exceeds it again (not retryable) * - 'transient': Temporary errors that may succeed on retry * - 'unknown': Unclassified errors */ @@ -60,6 +62,8 @@ export function classifyError(errorType?: string): ErrorCategory { return 'validation' case 'NotFoundOrDenied': return 'not_found' + case 'ResourceExhausted': + return 'resource_limit' case 'SqlError': // SQL errors could be transient (deadlock, connection issues) or permanent return 'transient' diff --git a/packages/example/api/package.json b/packages/example/api/package.json index 5dbf82ae..1a6cc0dc 100644 --- a/packages/example/api/package.json +++ b/packages/example/api/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "devDependencies": { - "@contember/schema": "2.1.0-rc.6", - "@contember/schema-definition": "2.1.0-rc.6" + "@contember/schema": "2.2.0-alpha.17", + "@contember/schema-definition": "2.2.0-alpha.17" }, "dependencies": { "graphql": "^16.8.1" diff --git a/packages/example/package.json b/packages/example/package.json index 2cb0d6a8..750af449 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -17,10 +17,10 @@ "@contember/bindx-repeater": "workspace:*", "@contember/bindx-uploader": "workspace:*", "@contember/bindx-react": "workspace:*", - "@contember/client-content": "2.1.0-rc.6", - "@contember/graphql-client": "2.1.0-rc.6", - "@contember/schema": "2.1.0-rc.6", - "@contember/schema-definition": "2.1.0-rc.6", + "@contember/client-content": "2.2.0-alpha.17", + "@contember/graphql-client": "2.2.0-alpha.17", + "@contember/schema": "2.2.0-alpha.17", + "@contember/schema-definition": "2.2.0-alpha.17", "@tailwindcss/vite": "^4.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/tests/unit/errors/errorClassification.test.ts b/tests/unit/errors/errorClassification.test.ts index 47d6d979..4bc0b340 100644 --- a/tests/unit/errors/errorClassification.test.ts +++ b/tests/unit/errors/errorClassification.test.ts @@ -26,6 +26,7 @@ describe('error classification', () => { ['InvalidDataInput', 'validation'], ['NonUniqueWhereInput', 'validation'], ['NotFoundOrDenied', 'not_found'], + ['ResourceExhausted', 'resource_limit'], ['SqlError', 'transient'], ])('should classify %s as %s', (errorType, expectedCategory) => { expect(classifyError(errorType)).toBe(expectedCategory) @@ -41,6 +42,7 @@ describe('error classification', () => { ['constraint', false], ['validation', false], ['not_found', false], + ['resource_limit', false], ['transient', true], ['unknown', true], ])('should return %s for category %s', (category, expectedRetryable) => { @@ -57,6 +59,7 @@ describe('error classification', () => { expect(isExecutionErrorType('NonUniqueWhereInput')).toBe(true) expect(isExecutionErrorType('InvalidDataInput')).toBe(true) expect(isExecutionErrorType('SqlError')).toBe(true) + expect(isExecutionErrorType('ResourceExhausted')).toBe(true) }) test('should return false for invalid error types', () => { @@ -68,7 +71,7 @@ describe('error classification', () => { describe('ExecutionErrorTypes', () => { test('should contain all error types', () => { - expect(Object.keys(ExecutionErrorTypes)).toHaveLength(7) + expect(Object.keys(ExecutionErrorTypes)).toHaveLength(8) expect(ExecutionErrorTypes.NotNullConstraintViolation).toBe('NotNullConstraintViolation') expect(ExecutionErrorTypes.UniqueConstraintViolation).toBe('UniqueConstraintViolation') expect(ExecutionErrorTypes.ForeignKeyConstraintViolation).toBe('ForeignKeyConstraintViolation') @@ -76,6 +79,7 @@ describe('error classification', () => { expect(ExecutionErrorTypes.NonUniqueWhereInput).toBe('NonUniqueWhereInput') expect(ExecutionErrorTypes.InvalidDataInput).toBe('InvalidDataInput') expect(ExecutionErrorTypes.SqlError).toBe('SqlError') + expect(ExecutionErrorTypes.ResourceExhausted).toBe('ResourceExhausted') }) }) diff --git a/tests/unit/errors/errorPathResolver.test.ts b/tests/unit/errors/errorPathResolver.test.ts index 12084498..d1c823fe 100644 --- a/tests/unit/errors/errorPathResolver.test.ts +++ b/tests/unit/errors/errorPathResolver.test.ts @@ -352,6 +352,29 @@ describe('errorPathResolver', () => { }) }) + test('should report an exhausted resource budget as a non-retryable entity-level error', () => { + const store = new SnapshotStore() + + const mutationResult: ContemberMutationResult = { + ok: false, + errorMessage: null, + errors: [{ + paths: [], + message: 'Request memory budget exhausted', + type: 'ResourceExhausted', + }], + validation: { valid: true, errors: [] }, + } + + const resolved = resolveAllErrors(mutationResult, 'Article', 'a-1', createContext(store)) + + expect(resolved).toHaveLength(1) + expect(resolved[0]!.target).toEqual({ entityType: 'Article', entityId: 'a-1', type: 'entity' }) + expect(resolved[0]!.error.code).toBe('RESOURCE_EXHAUSTED') + expect(resolved[0]!.error.category).toBe('resource_limit') + expect(resolved[0]!.error.retryable).toBe(false) + }) + test('should resolve multiple errors to different entities', () => { const store = new SnapshotStore() store.getOrCreateRelation('Article', 'a-1', 'author', { diff --git a/tests/unit/form/numericColumn.test.tsx b/tests/unit/form/numericColumn.test.tsx new file mode 100644 index 00000000..c2bb1dfe --- /dev/null +++ b/tests/unit/form/numericColumn.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { renderHook } from '@testing-library/react' +import { type FormInputHandlerContext, type TypeHandlerMap, useFormInputHandler } from '@contember/bindx-form' + +const ctx: FormInputHandlerContext = { setState: () => {}, currentValue: null, setError: () => {} } + +describe('a Numeric column in a form', () => { + test('keeps every digit of an exact decimal', () => { + const { result } = renderHook(() => useFormInputHandler({ columnType: 'Numeric' })) + + expect(result.current.parseValue('12345678901234567890.10', ctx)).toBe('12345678901234567890.10') + expect(result.current.formatValue('0.10', ctx)).toBe('0.10') + }) + + test('takes a handler of its own', () => { + const handler = { parseValue: (value: string) => value.trim(), formatValue: (value: unknown) => String(value) } + const handlers: TypeHandlerMap = { Numeric: () => handler } + + expect(handlers.Numeric?.()).toBe(handler) + }) +})