fix(httpapi): align sync seq validation
Reject negative and fractional sync sequence values in Effect HttpApi schemas so replay/history validation matches the legacy Hono routes.
This commit is contained in:
@@ -9,15 +9,16 @@ import { not } from "drizzle-orm"
|
|||||||
import { or } from "drizzle-orm"
|
import { or } from "drizzle-orm"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { EventTable } from "@/sync/event.sql"
|
import { EventTable } from "@/sync/event.sql"
|
||||||
|
import { NonNegativeInt } from "@/util/schema"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
const root = "/sync"
|
const root = "/sync"
|
||||||
const ReplayEvent = Schema.Struct({
|
const ReplayEvent = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
aggregateID: Schema.String,
|
aggregateID: Schema.String,
|
||||||
seq: Schema.Number,
|
seq: NonNegativeInt,
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
data: Schema.Record(Schema.String, Schema.Unknown),
|
data: Schema.Record(Schema.String, Schema.Unknown),
|
||||||
}).annotate({ identifier: "SyncReplayEvent" })
|
}).annotate({ identifier: "SyncReplayEvent" })
|
||||||
@@ -28,7 +29,7 @@ const ReplayPayload = Schema.Struct({
|
|||||||
const ReplayResponse = Schema.Struct({
|
const ReplayResponse = Schema.Struct({
|
||||||
sessionID: Schema.String,
|
sessionID: Schema.String,
|
||||||
}).annotate({ identifier: "SyncReplayResponse" })
|
}).annotate({ identifier: "SyncReplayResponse" })
|
||||||
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
|
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
|
||||||
const HistoryEvent = Schema.Struct({
|
const HistoryEvent = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
aggregate_id: Schema.String,
|
aggregate_id: Schema.String,
|
||||||
@@ -59,6 +60,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
HttpApiEndpoint.post("replay", SyncPaths.replay, {
|
||||||
payload: ReplayPayload,
|
payload: ReplayPayload,
|
||||||
success: ReplayResponse,
|
success: ReplayResponse,
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "sync.replay",
|
identifier: "sync.replay",
|
||||||
@@ -69,6 +71,7 @@ export const SyncApi = HttpApi.make("sync")
|
|||||||
HttpApiEndpoint.post("history", SyncPaths.history, {
|
HttpApiEndpoint.post("history", SyncPaths.history, {
|
||||||
payload: HistoryPayload,
|
payload: HistoryPayload,
|
||||||
success: Schema.Array(HistoryEvent),
|
success: Schema.Array(HistoryEvent),
|
||||||
|
error: HttpApiError.BadRequest,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "sync.history.list",
|
identifier: "sync.history.list",
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
|||||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||||
|
|
||||||
function app() {
|
function app(httpapi = true) {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
|
||||||
return InstanceRoutes(websocket)
|
return InstanceRoutes(websocket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,4 +81,48 @@ describe("sync HttpApi", () => {
|
|||||||
expect(replayed.status).toBe(200)
|
expect(replayed.status).toBe(200)
|
||||||
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("matches legacy seq validation", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||||
|
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
path: SyncPaths.history,
|
||||||
|
body: { aggregate: -1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: SyncPaths.history,
|
||||||
|
body: { aggregate: 1.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: SyncPaths.replay,
|
||||||
|
body: {
|
||||||
|
directory: tmp.path,
|
||||||
|
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: SyncPaths.replay,
|
||||||
|
body: {
|
||||||
|
directory: tmp.path,
|
||||||
|
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
const legacy = await app(false).request(item.path, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(item.body),
|
||||||
|
})
|
||||||
|
const httpapi = await app(true).request(item.path, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(item.body),
|
||||||
|
})
|
||||||
|
expect(httpapi.status).toBe(legacy.status)
|
||||||
|
expect(httpapi.status).toBe(400)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user