Skip to content

Commit 823516d

Browse files
moosebayclaude
andcommitted
feat: add GraphQL delta support with assertions and response history
This commit implements comprehensive GraphQL delta functionality, matching the HTTP delta system, and fixes critical bugs in delta field updates. ## Key Changes ### Delta System (packages/server/internal/api/rgraphql/) - Add `rgraphql_crud_delta.go` - GraphQL delta CRUD operations - Add `rgraphql_crud_header_delta.go` - GraphQL header delta operations - Add `rgraphql_delta_converter.go` - Delta field conversions - Add `rgraphql_crud_version.go` - Version collection and sync endpoints - Add delta schema `09_graphql_delta.sql` with proper foreign keys ### Assertions (packages/server/internal/api/rgraphql/) - Add `rgraphql_crud_assert.go` - GraphQL assertion CRUD - Add `rgraphql_exec_assert.go` - Assertion execution during requests - Add `rgraphql_exec_assert_test.go` - Assertion execution tests - Add `rgraphql_crud_response_assert.go` - Response assertion tracking - Add frontend assertion UI components ### Response History - Add GraphQL response tracking and history UI - Add response header tracking - Add version sync for real-time updates ### Critical Bug Fix - Fix GraphQL Writer.Update() to check IsDelta flag - Add UpdateGraphQLDelta SQL query for delta field updates - Prevents deltas from incorrectly overwriting parent base fields - Follows HTTP delta pattern exactly ### Frontend (packages/client/src/pages/graphql/) - Add `history.tsx` - GraphQL response history view - Add `request/assert.tsx` - Request assertion editor - Add `request/url.tsx` - URL editor component - Add `response/assert.tsx` - Response assertion viewer - Add delta route handler - Update flow nodes to support GraphQL deltas ### Database Schema - Add graphql_assert table with delta support - Add graphql_response_assert table - Add UpdateGraphQLDelta query - Add migration 01KHEX5H_add_graphql_delta.go ### Configuration - Add .ralph/ and .ralphrc to .gitignore Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5223442 commit 823516d

69 files changed

Lines changed: 8136 additions & 417 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ storybook-static
2727
tsconfig.tsbuildinfo
2828
tsp-output
2929
.bench/
30+
.ralph/
31+
.ralphrc
3032

3133
# Coverage files
3234
*.out

packages/client/src/app/router/route-tree.gen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDot
2121
import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport } from './../../pages/credential/routes/credential/$credentialIdCan/index'
2222
import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/history'
2323
import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport } from './../../pages/http/routes/http/$httpIdCan/delta.$deltaHttpIdCan'
24+
import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRouteImport } from './../../pages/graphql/routes/graphql/$graphqlIdCan/delta.$deltaGraphqlIdCan'
2425

2526
const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute =
2627
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRouteImport.update({
@@ -126,6 +127,15 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoute
126127
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute,
127128
} as any,
128129
)
130+
const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute =
131+
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRouteImport.update(
132+
{
133+
id: '/delta/$deltaGraphqlIdCan',
134+
path: '/delta/$deltaGraphqlIdCan',
135+
getParentRoute: () =>
136+
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute,
137+
} as any,
138+
)
129139

130140
export interface FileRoutesByFullPath {
131141
'/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute
@@ -139,6 +149,7 @@ export interface FileRoutesByFullPath {
139149
'/workspace/$workspaceIdCan/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute
140150
'/workspace/$workspaceIdCan/graphql/$graphqlIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute
141151
'/workspace/$workspaceIdCan/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute
152+
'/workspace/$workspaceIdCan/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute
142153
'/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute
143154
}
144155
export interface FileRoutesByTo {
@@ -149,6 +160,7 @@ export interface FileRoutesByTo {
149160
'/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute
150161
'/workspace/$workspaceIdCan/graphql/$graphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute
151162
'/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute
163+
'/workspace/$workspaceIdCan/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute
152164
'/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute
153165
}
154166
export interface FileRoutesById {
@@ -164,6 +176,7 @@ export interface FileRoutesById {
164176
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute
165177
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute
166178
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute
179+
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute
167180
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute
168181
}
169182
export interface FileRouteTypes {
@@ -180,6 +193,7 @@ export interface FileRouteTypes {
180193
| '/workspace/$workspaceIdCan/flow/$flowIdCan/'
181194
| '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/'
182195
| '/workspace/$workspaceIdCan/http/$httpIdCan/'
196+
| '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan'
183197
| '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan'
184198
fileRoutesByTo: FileRoutesByTo
185199
to:
@@ -190,6 +204,7 @@ export interface FileRouteTypes {
190204
| '/workspace/$workspaceIdCan/flow/$flowIdCan'
191205
| '/workspace/$workspaceIdCan/graphql/$graphqlIdCan'
192206
| '/workspace/$workspaceIdCan/http/$httpIdCan'
207+
| '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan'
193208
| '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan'
194209
id:
195210
| '__root__'
@@ -204,6 +219,7 @@ export interface FileRouteTypes {
204219
| '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/'
205220
| '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/'
206221
| '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/'
222+
| '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan'
207223
| '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan'
208224
fileRoutesById: FileRoutesById
209225
}
@@ -298,6 +314,13 @@ declare module '@tanstack/react-router' {
298314
preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport
299315
parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute
300316
}
317+
'/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan': {
318+
id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(graphql)/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan'
319+
path: '/delta/$deltaGraphqlIdCan'
320+
fullPath: '/workspace/$workspaceIdCan/graphql/$graphqlIdCan/delta/$deltaGraphqlIdCan'
321+
preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRouteImport
322+
parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRoute
323+
}
301324
}
302325
}
303326

@@ -321,12 +344,15 @@ const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoute
321344

322345
interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren {
323346
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute
347+
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute
324348
}
325349

326350
const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteChildren =
327351
{
328352
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute:
329353
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanIndexRoute,
354+
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute:
355+
dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanDeltaDotdeltaGraphqlIdCanRoute,
330356
}
331357

332358
const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotGraphqlRoutesGraphqlGraphqlIdCanRouteRouteWithChildren =

packages/client/src/pages/flow/nodes/graphql.tsx

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@ import { eq, useLiveQuery } from '@tanstack/react-db';
33
import { useRouter } from '@tanstack/react-router';
44
import * as XF from '@xyflow/react';
55
import { Ulid } from 'id128';
6+
import { use } from 'react';
67
import { FiExternalLink } from 'react-icons/fi';
78
import { NodeGraphQLSchema } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb';
89
import {
910
NodeExecutionCollectionSchema,
1011
NodeGraphQLCollectionSchema,
1112
} from '@the-dev-tools/spec/tanstack-db/v1/api/flow';
12-
import { GraphQLCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l';
13+
import { GraphQLCollectionSchema, GraphQLDeltaCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/graph_q_l';
1314
import { ButtonAsLink } from '@the-dev-tools/ui/button';
1415
import { SendRequestIcon } from '@the-dev-tools/ui/icons';
1516
import { tw } from '@the-dev-tools/ui/tailwind-literal';
16-
import { GraphQLRequestPanel, GraphQLResponsePanel } from '~/pages/graphql/@x/flow';
17+
import { useDeltaState } from '~/features/delta';
18+
import { ReferenceContext } from '~/features/expression';
19+
import { GraphQLRequestPanel, GraphQLResponseInfo, GraphQLResponsePanel } from '~/pages/graphql/@x/flow';
1720
import { useApiCollection } from '~/shared/api';
1821
import { pick } from '~/shared/lib';
1922
import { routes } from '~/shared/routes';
23+
import { FlowContext } from '../context';
2024
import { Handle } from '../handle';
2125
import { NodeSettingsBody, NodeSettingsOutputProps, NodeSettingsProps, SimpleNode } from '../node';
2226

@@ -25,27 +29,25 @@ export const GraphQLNode = ({ id, selected }: XF.NodeProps) => {
2529

2630
const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema);
2731

28-
const { graphqlId } =
32+
const { deltaGraphqlId, graphqlId } =
2933
useLiveQuery(
3034
(_) =>
3135
_.from({ item: nodeGraphQLCollection })
3236
.where((_) => eq(_.item.nodeId, nodeId))
33-
.select((_) => pick(_.item, 'graphqlId'))
37+
.select((_) => pick(_.item, 'graphqlId', 'deltaGraphqlId'))
3438
.findOne(),
3539
[nodeGraphQLCollection, nodeId],
3640
).data ?? create(NodeGraphQLSchema);
3741

38-
const graphqlCollection = useApiCollection(GraphQLCollectionSchema);
42+
const deltaOptions = {
43+
deltaId: deltaGraphqlId,
44+
deltaSchema: GraphQLDeltaCollectionSchema,
45+
isDelta: deltaGraphqlId !== undefined,
46+
originId: graphqlId,
47+
originSchema: GraphQLCollectionSchema,
48+
};
3949

40-
const { name, url } =
41-
useLiveQuery(
42-
(_) =>
43-
_.from({ item: graphqlCollection })
44-
.where((_) => eq(_.item.graphqlId, graphqlId))
45-
.select((_) => pick(_.item, 'name', 'url'))
46-
.findOne(),
47-
[graphqlCollection, graphqlId],
48-
).data ?? {};
50+
const [name] = useDeltaState({ ...deltaOptions, valueKey: 'name' });
4951

5052
return (
5153
<SimpleNode
@@ -63,7 +65,7 @@ export const GraphQLNode = ({ id, selected }: XF.NodeProps) => {
6365
>
6466
<div className={tw`min-w-0 flex-1`}>
6567
<div className={tw`truncate text-xs font-medium tracking-tight text-teal-600`}>GQL</div>
66-
<div className={tw`truncate text-xs tracking-tight text-on-neutral-low`}>{name ?? url}</div>
68+
<div className={tw`truncate text-xs tracking-tight text-on-neutral-low`}>{name}</div>
6769
</div>
6870
</SimpleNode>
6971
);
@@ -72,16 +74,19 @@ export const GraphQLNode = ({ id, selected }: XF.NodeProps) => {
7274
export const GraphQLSettings = ({ nodeId }: NodeSettingsProps) => {
7375
const router = useRouter();
7476

77+
const { isReadOnly = false } = use(FlowContext);
78+
79+
const { workspaceId } = routes.dashboard.workspace.route.useLoaderData();
7580
const { workspaceIdCan } = routes.dashboard.workspace.route.useParams();
7681

7782
const nodeGraphQLCollection = useApiCollection(NodeGraphQLCollectionSchema);
7883

79-
const { graphqlId } =
84+
const { deltaGraphqlId, graphqlId } =
8085
useLiveQuery(
8186
(_) =>
8287
_.from({ item: nodeGraphQLCollection })
8388
.where((_) => eq(_.item.nodeId, nodeId))
84-
.select((_) => pick(_.item, 'graphqlId'))
89+
.select((_) => pick(_.item, 'graphqlId', 'deltaGraphqlId'))
8590
.findOne(),
8691
[nodeGraphQLCollection, nodeId],
8792
).data ?? create(NodeGraphQLSchema);
@@ -93,20 +98,39 @@ export const GraphQLSettings = ({ nodeId }: NodeSettingsProps) => {
9398
settingsHeader={
9499
<ButtonAsLink
95100
className={tw`-my-4 shrink-0 px-2`}
96-
params={{
97-
graphqlIdCan: Ulid.construct(graphqlId).toCanonical(),
98-
workspaceIdCan,
99-
}}
100-
to={router.routesById[routes.dashboard.workspace.graphql.route.id].fullPath}
101101
variant='ghost'
102+
{...(deltaGraphqlId
103+
? {
104+
params: {
105+
deltaGraphqlIdCan: Ulid.construct(deltaGraphqlId).toCanonical(),
106+
graphqlIdCan: Ulid.construct(graphqlId).toCanonical(),
107+
workspaceIdCan,
108+
},
109+
to: router.routesById[routes.dashboard.workspace.graphql.delta.id].fullPath,
110+
}
111+
: {
112+
params: {
113+
graphqlIdCan: Ulid.construct(graphqlId).toCanonical(),
114+
workspaceIdCan,
115+
},
116+
to: router.routesById[routes.dashboard.workspace.graphql.route.id].fullPath,
117+
})}
102118
>
103119
<FiExternalLink className={tw`size-4 text-on-neutral-low`} />
104120
Open GraphQL
105121
</ButtonAsLink>
106122
}
107123
title='GraphQL request'
108124
>
109-
<GraphQLRequestPanel graphqlId={graphqlId} />
125+
<ReferenceContext
126+
value={{ flowNodeId: nodeId, graphqlId, workspaceId, ...(deltaGraphqlId && { deltaGraphqlId }) }}
127+
>
128+
<GraphQLRequestPanel
129+
deltaGraphqlId={deltaGraphqlId}
130+
graphqlId={graphqlId}
131+
isReadOnly={isReadOnly}
132+
/>
133+
</ReferenceContext>
110134
</NodeSettingsBody>
111135
);
112136
};
@@ -128,7 +152,8 @@ const Output = ({ nodeExecutionId }: NodeSettingsOutputProps) => {
128152

129153
return (
130154
<div className={tw`flex h-full flex-col`}>
131-
<GraphQLResponsePanel graphqlResponseId={graphqlResponseId} />
155+
<GraphQLResponseInfo className={tw`-m-2`} graphqlResponseId={graphqlResponseId} />
156+
<GraphQLResponsePanel className={tw`flex-1`} graphqlResponseId={graphqlResponseId} />
132157
</div>
133158
);
134159
};
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { GraphQLRequestPanel } from '../request/panel';
2-
export { GraphQLResponsePanel } from '../response';
2+
export { GraphQLResponseInfo, GraphQLResponsePanel } from '../response';

0 commit comments

Comments
 (0)