Skip to content

Commit beb52eb

Browse files
committed
SY-3833-2: Add Schematic Action Codec and Dispatch Endpoint
Lays the server-side foundation for granular schematic mutations. Oracle codegen: - Adds the 'action' grammar production to the oracle DSL alongside fields, domains, and field omissions inside struct bodies. - Adds resolution.Action and analyzer.collectAction. resolveTypeRefs now walks action payload field types so cross-references inside action bodies resolve correctly to their qualified names. - New oracle/plugin/go/actions plugin generates a discriminated-union codec (Action envelope, Reduce, ReduceAll, NewXxxAction constructors) for any struct that declares actions. Optional fields use the same pointer-prefix logic as gotypes (skip slice / map / msgpack.EncodedJSON). - Formatter learns to emit action blocks so 'oracle fmt' / 'oracle sync' preserves them. Schematic schema and runtime: - schemas/schematic.oracle declares six actions: SetNodePosition, AddNode, RemoveNode, SetEdge, RemoveEdge, SetProps. Hand-written Handle methods live in core/pkg/service/schematic/actions.go alongside the ScopedAction envelope used for cluster broadcast. - Writer.Dispatch applies a sequence of actions atomically inside a single gorp transaction, rejects snapshots, and notifies actionObserver on success. SetData remains in place; no callers are migrated yet. - Service wires actionObserver to a signals translator that publishes scoped action sequences to sy_schematic_set / sy_schematic_delete when cfg.Signals is non-nil. - API exposes Dispatch as a new freighter endpoint (/api/v1/schematic/dispatch) registered alongside the existing SetData endpoint.
1 parent 8f0862a commit beb52eb

37 files changed

Lines changed: 3388 additions & 1015 deletions

client/ts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@synnaxlabs/freighter": "workspace:^",
3838
"@synnaxlabs/x": "workspace:^",
3939
"async-mutex": "catalog:",
40+
"immer": "catalog:",
4041
"zod": "catalog:"
4142
},
4243
"devDependencies": {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright 2026 Synnax Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License included in the file
4+
// licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with the Business Source
7+
// License, use of this software will be governed by the Apache License, Version 2.0,
8+
// included in the file licenses/APL.txt.
9+
10+
// Code generated by Oracle. DO NOT EDIT.
11+
import { caseconv, control, record, spatial, zod } from "@synnaxlabs/x";
12+
import { produce } from "immer";
13+
import { z } from "zod";
14+
15+
import {
16+
handleAddNode,
17+
handleRemoveEdge,
18+
handleRemoveNode,
19+
handleSetAuthority,
20+
handleSetEdge,
21+
handleSetLegend,
22+
handleSetNodePosition,
23+
handleSetProps,
24+
} from "@/schematic/actions";
25+
import { edgeZ, legendZ, nodeZ, type Schematic } from "@/schematic/types.gen";
26+
27+
/** SetNodePosition moves a node to a new position. */
28+
export const setNodePositionPayloadZ = z.object({
29+
key: z.string(),
30+
position: spatial.xyZ,
31+
});
32+
33+
export type SetNodePositionPayload = z.infer<typeof setNodePositionPayloadZ>;
34+
35+
/**
36+
* AddNode appends a node to the schematic. If props is non-empty it is stored
37+
* under the node's key in the schematic props map.
38+
*/
39+
export const addNodePayloadZ = z.object({
40+
node: nodeZ,
41+
props: caseconv.preserveCase(zod.nullToUndefined(record.unknownZ())),
42+
});
43+
44+
export type AddNodePayload = z.infer<typeof addNodePayloadZ>;
45+
46+
/** RemoveNode removes a node and any props stored under its key. */
47+
export const removeNodePayloadZ = z.object({
48+
key: z.string(),
49+
});
50+
51+
export type RemoveNodePayload = z.infer<typeof removeNodePayloadZ>;
52+
53+
/**
54+
* SetEdge inserts the edge if no edge with the same key exists, otherwise
55+
* replaces the existing edge with the same key.
56+
*/
57+
export const setEdgePayloadZ = z.object({
58+
edge: edgeZ,
59+
});
60+
61+
export type SetEdgePayload = z.infer<typeof setEdgePayloadZ>;
62+
63+
/** RemoveEdge removes the edge with the given key, if present. */
64+
export const removeEdgePayloadZ = z.object({
65+
key: z.string(),
66+
});
67+
68+
export type RemoveEdgePayload = z.infer<typeof removeEdgePayloadZ>;
69+
70+
/** SetProps sets the props entry for the given node or edge key. */
71+
export const setPropsPayloadZ = z.object({
72+
key: z.string(),
73+
props: caseconv.preserveCase(record.nullishToEmpty()),
74+
});
75+
76+
export type SetPropsPayload = z.infer<typeof setPropsPayloadZ>;
77+
78+
/** SetAuthority sets the control authority level for this schematic. */
79+
export const setAuthorityPayloadZ = z.object({
80+
value: control.authorityZ,
81+
});
82+
83+
export type SetAuthorityPayload = z.infer<typeof setAuthorityPayloadZ>;
84+
85+
/** SetLegend replaces the schematic's control-legend overlay configuration. */
86+
export const setLegendPayloadZ = z.object({
87+
legend: legendZ,
88+
});
89+
90+
export type SetLegendPayload = z.infer<typeof setLegendPayloadZ>;
91+
92+
export const ACTION_TYPES = {
93+
set_node_position: "set_node_position",
94+
add_node: "add_node",
95+
remove_node: "remove_node",
96+
set_edge: "set_edge",
97+
remove_edge: "remove_edge",
98+
set_props: "set_props",
99+
set_authority: "set_authority",
100+
set_legend: "set_legend",
101+
} as const;
102+
103+
export const actionZ = z.discriminatedUnion("type", [
104+
z.object({
105+
type: z.literal("set_node_position"),
106+
setNodePosition: setNodePositionPayloadZ,
107+
}),
108+
z.object({ type: z.literal("add_node"), addNode: addNodePayloadZ }),
109+
z.object({ type: z.literal("remove_node"), removeNode: removeNodePayloadZ }),
110+
z.object({ type: z.literal("set_edge"), setEdge: setEdgePayloadZ }),
111+
z.object({ type: z.literal("remove_edge"), removeEdge: removeEdgePayloadZ }),
112+
z.object({ type: z.literal("set_props"), setProps: setPropsPayloadZ }),
113+
z.object({ type: z.literal("set_authority"), setAuthority: setAuthorityPayloadZ }),
114+
z.object({ type: z.literal("set_legend"), setLegend: setLegendPayloadZ }),
115+
]);
116+
117+
export type Action = z.infer<typeof actionZ>;
118+
119+
export const setNodePosition = (payload: SetNodePositionPayload): Action => ({
120+
type: "set_node_position",
121+
setNodePosition: payload,
122+
});
123+
124+
export const addNode = (payload: AddNodePayload): Action => ({
125+
type: "add_node",
126+
addNode: payload,
127+
});
128+
129+
export const removeNode = (payload: RemoveNodePayload): Action => ({
130+
type: "remove_node",
131+
removeNode: payload,
132+
});
133+
134+
export const setEdge = (payload: SetEdgePayload): Action => ({
135+
type: "set_edge",
136+
setEdge: payload,
137+
});
138+
139+
export const removeEdge = (payload: RemoveEdgePayload): Action => ({
140+
type: "remove_edge",
141+
removeEdge: payload,
142+
});
143+
144+
export const setProps = (payload: SetPropsPayload): Action => ({
145+
type: "set_props",
146+
setProps: payload,
147+
});
148+
149+
export const setAuthority = (payload: SetAuthorityPayload): Action => ({
150+
type: "set_authority",
151+
setAuthority: payload,
152+
});
153+
154+
export const setLegend = (payload: SetLegendPayload): Action => ({
155+
type: "set_legend",
156+
setLegend: payload,
157+
});
158+
159+
export const reduce = (state: Schematic, action: Action): Schematic => {
160+
switch (action.type) {
161+
case "set_node_position":
162+
handleSetNodePosition(state, action.setNodePosition);
163+
break;
164+
case "add_node":
165+
handleAddNode(state, action.addNode);
166+
break;
167+
case "remove_node":
168+
handleRemoveNode(state, action.removeNode);
169+
break;
170+
case "set_edge":
171+
handleSetEdge(state, action.setEdge);
172+
break;
173+
case "remove_edge":
174+
handleRemoveEdge(state, action.removeEdge);
175+
break;
176+
case "set_props":
177+
handleSetProps(state, action.setProps);
178+
break;
179+
case "set_authority":
180+
handleSetAuthority(state, action.setAuthority);
181+
break;
182+
case "set_legend":
183+
handleSetLegend(state, action.setLegend);
184+
break;
185+
}
186+
return state;
187+
};
188+
189+
export const reduceAll = (state: Schematic, actions: Action[]): Schematic =>
190+
produce(state, (draft) => actions.forEach((action) => reduce(draft, action)));

client/ts/src/schematic/actions.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2026 Synnax Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License included in the file
4+
// licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with the Business Source
7+
// License, use of this software will be governed by the Apache License, Version 2.0,
8+
// included in the file licenses/APL.txt.
9+
10+
import { z } from "zod";
11+
12+
import {
13+
type AddNodePayload,
14+
actionZ,
15+
type RemoveEdgePayload,
16+
type RemoveNodePayload,
17+
type SetAuthorityPayload,
18+
type SetEdgePayload,
19+
type SetLegendPayload,
20+
type SetNodePositionPayload,
21+
type SetPropsPayload,
22+
} from "@/schematic/actions.gen";
23+
import { keyZ, type Schematic } from "@/schematic/types.gen";
24+
25+
export const scopedActionZ = z.object({
26+
key: keyZ,
27+
sessionKey: z.string(),
28+
actions: actionZ.array(),
29+
});
30+
31+
export interface ScopedAction extends z.infer<typeof scopedActionZ> {}
32+
33+
export const handleSetNodePosition = (
34+
state: Schematic,
35+
payload: SetNodePositionPayload,
36+
): void => {
37+
const node = state.nodes.find((n) => n.key === payload.key);
38+
if (node != null) node.position = payload.position;
39+
};
40+
41+
export const handleAddNode = (state: Schematic, payload: AddNodePayload): void => {
42+
state.nodes.push(payload.node);
43+
if (payload.props != null) state.props[payload.node.key] = payload.props;
44+
};
45+
46+
export const handleRemoveNode = (
47+
state: Schematic,
48+
payload: RemoveNodePayload,
49+
): void => {
50+
const idx = state.nodes.findIndex((n) => n.key === payload.key);
51+
if (idx !== -1) state.nodes.splice(idx, 1);
52+
delete state.props[payload.key];
53+
};
54+
55+
export const handleSetEdge = (state: Schematic, payload: SetEdgePayload): void => {
56+
const idx = state.edges.findIndex((e) => e.key === payload.edge.key);
57+
if (idx !== -1) state.edges[idx] = payload.edge;
58+
else state.edges.push(payload.edge);
59+
};
60+
61+
export const handleRemoveEdge = (
62+
state: Schematic,
63+
payload: RemoveEdgePayload,
64+
): void => {
65+
const idx = state.edges.findIndex((e) => e.key === payload.key);
66+
if (idx !== -1) state.edges.splice(idx, 1);
67+
};
68+
69+
export const handleSetProps = (state: Schematic, payload: SetPropsPayload): void => {
70+
state.props[payload.key] = payload.props;
71+
};
72+
73+
export const handleSetAuthority = (
74+
state: Schematic,
75+
payload: SetAuthorityPayload,
76+
): void => {
77+
state.authority = payload.value;
78+
};
79+
80+
export const handleSetLegend = (state: Schematic, payload: SetLegendPayload): void => {
81+
state.legend = payload.legend;
82+
};

client/ts/src/schematic/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
1111
import { array } from "@synnaxlabs/x";
1212
import { z } from "zod";
1313

14+
import { type Action, actionZ } from "@/schematic/actions.gen";
1415
import { symbol } from "@/schematic/symbol";
1516
import {
1617
type Key,
@@ -29,6 +30,11 @@ const renameReqZ = z.object({ key: keyZ, name: z.string() });
2930
const setDataBodyZ = schematicZ.omit({ key: true, name: true, snapshot: true });
3031
export type SetDataBody = z.input<typeof setDataBodyZ>;
3132
const setDataReqZ = z.object({ key: keyZ, data: setDataBodyZ });
33+
const dispatchReqZ = z.object({
34+
key: keyZ,
35+
session_key: z.string(),
36+
actions: actionZ.array(),
37+
});
3238
const deleteReqZ = z.object({ keys: keyZ.array() });
3339

3440
const copyReqZ = z.object({
@@ -105,6 +111,16 @@ export class Client {
105111
);
106112
}
107113

114+
async dispatch(key: Key, sessionKey: string, actions: Action[]): Promise<void> {
115+
await sendRequired(
116+
this.client,
117+
"/schematic/dispatch",
118+
{ key, session_key: sessionKey, actions },
119+
dispatchReqZ,
120+
emptyResZ,
121+
);
122+
}
123+
108124
async retrieve(args: RetrieveSingleParams): Promise<Schematic>;
109125
async retrieve(args: RetrieveMultipleParams): Promise<Schematic[]>;
110126
async retrieve(

client/ts/src/schematic/external.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// License, use of this software will be governed by the Apache License, Version 2.0,
88
// included in the file licenses/APL.txt.
99

10+
export * from "@/schematic/actions";
11+
export * from "@/schematic/actions.gen";
1012
export * from "@/schematic/client";
1113
export * from "@/schematic/symbol";
1214
export * from "@/schematic/types.gen";

core/pkg/api/grpc/grpc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func NewTransport(channelSvc *distchannel.Service) (api.Transport, []grpc.Bindab
106106
a.SchematicRetrieve = fnoop.UnaryServer[schematic.RetrieveRequest, schematic.RetrieveResponse]{}
107107
a.SchematicRename = fnoop.UnaryServer[schematic.RenameRequest, types.Nil]{}
108108
a.SchematicSetData = fnoop.UnaryServer[schematic.SetDataRequest, types.Nil]{}
109+
a.SchematicDispatch = fnoop.UnaryServer[schematic.DispatchRequest, types.Nil]{}
109110
a.SchematicCopy = fnoop.UnaryServer[schematic.CopyRequest, schematic.CopyResponse]{}
110111

111112
// SCHEMATIC SYMBOL

core/pkg/api/http/http.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func NewTransport(router *fhttp.Router, ch *distchannel.Service) api.Transport {
113113
SchematicDelete: fhttp.UnaryServer[schematic.DeleteRequest, types.Nil](router, "/api/v1/schematic/delete"),
114114
SchematicRename: fhttp.UnaryServer[schematic.RenameRequest, types.Nil](router, "/api/v1/schematic/rename"),
115115
SchematicSetData: fhttp.UnaryServer[schematic.SetDataRequest, types.Nil](router, "/api/v1/schematic/set-data"),
116+
SchematicDispatch: fhttp.UnaryServer[schematic.DispatchRequest, types.Nil](router, "/api/v1/schematic/dispatch"),
116117
SchematicCopy: fhttp.UnaryServer[schematic.CopyRequest, schematic.CopyResponse](router, "/api/v1/schematic/copy"),
117118

118119
// SCHEMATIC SYMBOL

core/pkg/api/layer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ type Transport struct {
110110
SchematicDelete freighter.UnaryServer[schematic.DeleteRequest, types.Nil]
111111
SchematicRename freighter.UnaryServer[schematic.RenameRequest, types.Nil]
112112
SchematicSetData freighter.UnaryServer[schematic.SetDataRequest, types.Nil]
113+
SchematicDispatch freighter.UnaryServer[schematic.DispatchRequest, types.Nil]
113114
SchematicCopy freighter.UnaryServer[schematic.CopyRequest, schematic.CopyResponse]
114115
// SCHEMATIC SYMBOL
115116
SchematicCreateSymbol freighter.UnaryServer[schematic.CreateSymbolRequest, schematic.CreateSymbolResponse]
@@ -292,6 +293,7 @@ func (l *Layer) BindTo(t Transport) {
292293
t.SchematicDelete,
293294
t.SchematicRename,
294295
t.SchematicSetData,
296+
t.SchematicDispatch,
295297
t.SchematicCopy,
296298

297299
// SCHEMATIC SYMBOL
@@ -438,6 +440,7 @@ func (l *Layer) BindTo(t Transport) {
438440
t.SchematicDelete.BindHandler(l.Schematic.Delete)
439441
t.SchematicRename.BindHandler(l.Schematic.Rename)
440442
t.SchematicSetData.BindHandler(l.Schematic.SetData)
443+
t.SchematicDispatch.BindHandler(l.Schematic.Dispatch)
441444
t.SchematicCopy.BindHandler(l.Schematic.Copy)
442445

443446
// SCHEMATIC SYMBOL

core/pkg/api/schematic/schematic.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,31 @@ func (s *Service) SetData(ctx context.Context, req SetDataRequest) (res types.Ni
110110
})
111111
}
112112

113+
// DispatchRequest carries an action sequence to apply to a single schematic.
114+
// SessionKey identifies the originating client so cluster broadcasts can be
115+
// deduplicated against the local optimistic update.
116+
type DispatchRequest struct {
117+
Key uuid.UUID `json:"key" msgpack:"key"`
118+
SessionKey string `json:"session_key" msgpack:"session_key"`
119+
Actions []schematic.Action `json:"actions" msgpack:"actions"`
120+
}
121+
122+
// Dispatch applies the action sequence to the target schematic atomically.
123+
// Subscribers to the schematic action signals receive the sequence after the
124+
// transaction commits.
125+
func (s *Service) Dispatch(ctx context.Context, req DispatchRequest) (res types.Nil, err error) {
126+
if err = s.access.Enforce(ctx, access.Request{
127+
Subject: auth.GetSubject(ctx),
128+
Action: access.ActionUpdate,
129+
Objects: []ontology.ID{schematic.OntologyID(req.Key)},
130+
}); err != nil {
131+
return res, err
132+
}
133+
return res, s.db.WithTx(ctx, func(tx gorp.Tx) error {
134+
return s.internal.NewWriter(tx).Dispatch(ctx, req.Key, req.SessionKey, req.Actions)
135+
})
136+
}
137+
113138
type (
114139
RetrieveRequest struct {
115140
Keys []uuid.UUID `json:"keys" msgpack:"keys"`

0 commit comments

Comments
 (0)