diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index f7dfe00c..7bb0df07 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { encode } from "@msgpack/msgpack"; import { createClient, ZyncBaseClient } from "./client"; import { + encodeToBuffer, installMockFetchTicket, installMockWs, MockWebSocket, @@ -135,13 +135,13 @@ describe("ZyncBaseClient", () => { const { decode } = await import("@msgpack/msgpack"); const decoded = decode(lastMsg) as Record; - const errorResponse = encode({ + const errorResponse = encodeToBuffer({ type: "error", id: decoded.id, code: "SCHEMA_VALIDATION_FAILED", message: "oops", }); - mockWs.triggerMessage(errorResponse as unknown as ArrayBuffer); + mockWs.triggerMessage(errorResponse); await setPromise; expect(errors.length).toBeGreaterThan(0); @@ -166,12 +166,10 @@ describe("ZyncBaseClient", () => { const lastMsg = mockWs.sentMessages[mockWs.sentMessages.length - 1]; const { decode } = await import("@msgpack/msgpack"); const decoded = decode(lastMsg) as Record; - expect(decoded.type).toBe("AuthRefresh"); + expect(decoded.type).toBe(0x04); expect(decoded.token).toBe("new-jwt-token"); - mockWs.triggerMessage( - encode({ type: "ok", id: decoded.id }) as unknown as ArrayBuffer, - ); + mockWs.triggerMessage(encodeToBuffer({ type: "ok", id: decoded.id })); await refreshPromise; client.disconnect(); diff --git a/sdk/typescript/src/connection_wire.test.ts b/sdk/typescript/src/connection_wire.test.ts index 480097d3..2909909a 100644 --- a/sdk/typescript/src/connection_wire.test.ts +++ b/sdk/typescript/src/connection_wire.test.ts @@ -1,16 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { decode, encode } from "@msgpack/msgpack"; +import { decode } from "@msgpack/msgpack"; import { ConnectionWireCodec } from "./connection_wire.js"; +import { encodeToBuffer } from "./test-helpers.js"; import type { StoreDelta } from "./types.js"; -function bufferOf(value: unknown): ArrayBuffer { - const bytes = encode(value); - return bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; -} - async function makeCodec(): Promise { const codec = new ConnectionWireCodec(); await codec.applySchemaSync({ @@ -40,7 +33,7 @@ describe("ConnectionWireCodec", () => { responseTableIndex: 0, }); expect(decode(encoded.bytes)).toEqual({ - type: "StoreQuery", + type: 0x14, id: 7, table_index: 0, conditions: [[1, 0, "Ada"]], @@ -51,7 +44,7 @@ describe("ConnectionWireCodec", () => { test("decodes schema-aware deltas", async () => { const codec = await makeCodec(); const msg = codec.decode( - bufferOf({ + encodeToBuffer({ type: "StoreDelta", subId: 1, ops: [ @@ -82,8 +75,8 @@ describe("ConnectionWireCodec", () => { test("decodeMulti decodes all concatenated messages in a frame", async () => { const codec = await makeCodec(); - const ok = bufferOf({ type: "ok", id: 7 }); - const delta = bufferOf({ + const ok = encodeToBuffer({ type: "ok", id: 7 }); + const delta = encodeToBuffer({ type: "StoreDelta", subId: 1, ops: [ @@ -119,7 +112,7 @@ describe("ConnectionWireCodec", () => { test("decodeMulti handles single-message, empty, and invalid frames", async () => { const codec = await makeCodec(); - const single = codec.decodeMulti(bufferOf({ type: "ok", id: 1 })); + const single = codec.decodeMulti(encodeToBuffer({ type: "ok", id: 1 })); expect(single).toHaveLength(1); expect(single[0]).toEqual({ type: "ok", id: 1 }); expect(codec.decodeMulti(new ArrayBuffer(0))).toEqual([]); diff --git a/sdk/typescript/src/connection_wire.ts b/sdk/typescript/src/connection_wire.ts index dae741d0..3eb86905 100644 --- a/sdk/typescript/src/connection_wire.ts +++ b/sdk/typescript/src/connection_wire.ts @@ -30,17 +30,74 @@ import type { StoreUnsubscribe, } from "./types.js"; -const VALID_INBOUND_TYPES = new Set([ - "ok", - "error", - "StoreDelta", - "SchemaSync", - "Connected", - "WriteCommitted", - "WriteError", - "PresenceBroadcast", - "SharedStateBroadcast", -]); +/** + * Canonical registry of every top-level wire message type ID (wire-protocol.md). + * All IDs stay <= 0x7f so MessagePack encodes them as a one-byte positive + * fixint. Never reuse or renumber an assigned ID. + */ +export const WireMessageType = { + ok: 0x00, + error: 0x01, + Connected: 0x02, + SchemaSync: 0x03, + AuthRefresh: 0x04, + ServerDisconnect: 0x05, + StoreSetNamespace: 0x10, + StoreSet: 0x11, + StoreRemove: 0x12, + StoreBatch: 0x13, + StoreQuery: 0x14, + StoreSubscribe: 0x15, + StoreUnsubscribe: 0x16, + StoreLoadMore: 0x17, + StoreDelta: 0x18, + WriteCommitted: 0x19, + WriteError: 0x1a, + PresenceSetNamespace: 0x20, + PresenceSet: 0x21, + PresenceSetShared: 0x22, + PresenceSubscribe: 0x23, + PresenceUnsubscribe: 0x24, + PresenceSubscribeShared: 0x25, + PresenceUnsubscribeShared: 0x26, + PresenceRemove: 0x27, + PresenceBroadcast: 0x28, + SharedStateBroadcast: 0x29, +} as const; + +const OUTBOUND_WIRE_TYPES = { + AuthRefresh: WireMessageType.AuthRefresh, + StoreSet: WireMessageType.StoreSet, + StoreRemove: WireMessageType.StoreRemove, + StoreBatch: WireMessageType.StoreBatch, + StoreSetNamespace: WireMessageType.StoreSetNamespace, + StoreQuery: WireMessageType.StoreQuery, + StoreSubscribe: WireMessageType.StoreSubscribe, + StoreUnsubscribe: WireMessageType.StoreUnsubscribe, + StoreLoadMore: WireMessageType.StoreLoadMore, + PresenceSetNamespace: WireMessageType.PresenceSetNamespace, + PresenceSet: WireMessageType.PresenceSet, + PresenceSetShared: WireMessageType.PresenceSetShared, + PresenceSubscribe: WireMessageType.PresenceSubscribe, + PresenceUnsubscribe: WireMessageType.PresenceUnsubscribe, + PresenceSubscribeShared: WireMessageType.PresenceSubscribeShared, + PresenceUnsubscribeShared: WireMessageType.PresenceUnsubscribeShared, + PresenceRemove: WireMessageType.PresenceRemove, +} satisfies Record; + +// ServerDisconnect (0x05) is intentionally absent: it is known but unsupported, +// so it decodes to null rather than being surfaced to consumers. +const INBOUND_WIRE_TYPES: { [id: number]: InboundMessage["type"] } = { + [WireMessageType.ok]: "ok", + [WireMessageType.error]: "error", + [WireMessageType.Connected]: "Connected", + [WireMessageType.SchemaSync]: "SchemaSync", + [WireMessageType.StoreDelta]: "StoreDelta", + [WireMessageType.WriteCommitted]: "WriteCommitted", + [WireMessageType.WriteError]: "WriteError", + [WireMessageType.PresenceBroadcast]: "PresenceBroadcast", + [WireMessageType.SharedStateBroadcast]: "SharedStateBroadcast", +} as const; type WithoutId = Omit; @@ -84,6 +141,13 @@ export class ConnectionWireCodec { wireMessage = this.encodeWireMessage( debugMessage as unknown as Record, ); + const wireType = OUTBOUND_WIRE_TYPES[debugMessage.type]; + if (wireType === undefined) { + throw new Error( + `Unregistered outbound message type: ${debugMessage.type}`, + ); + } + wireMessage.type = wireType; } catch (err) { throw this.mapSchemaEncodingError(err); } @@ -133,8 +197,10 @@ export class ConnectionWireCodec { decodeMessage(raw: unknown): InboundMessage | null { if (!raw || typeof raw !== "object" || !("type" in raw)) return null; + const name = this.inboundTypeName(raw); + if (!name) return null; const msg = raw as InboundMessage; - if (!VALID_INBOUND_TYPES.has(msg.type)) return null; + msg.type = name; switch (msg.type) { case "StoreDelta": if (!Array.isArray(msg.ops)) return null; @@ -149,6 +215,18 @@ export class ConnectionWireCodec { return msg; } + private inboundTypeName(raw: object): InboundMessage["type"] | null { + const rawType = (raw as { type?: unknown }).type; + if ( + typeof rawType !== "number" || + !Number.isInteger(rawType) || + rawType < 0 + ) { + return null; + } + return INBOUND_WIRE_TYPES[rawType] ?? null; + } + decodeOkResponse( ok: OkResponse, context?: PendingRequestContext, diff --git a/sdk/typescript/src/test-helpers.ts b/sdk/typescript/src/test-helpers.ts index 11e97ab6..877b6f4a 100644 --- a/sdk/typescript/src/test-helpers.ts +++ b/sdk/typescript/src/test-helpers.ts @@ -1,10 +1,25 @@ import { encode } from "@msgpack/msgpack"; import { ConnectionManager } from "./connection"; +import { WireMessageType } from "./connection_wire"; import type { AuthConfig, ClientOptions } from "./types"; -/** Encode a message to an exact-size ArrayBuffer. */ +/** + * Encode a message to an exact-size ArrayBuffer. Logical message type names + * (e.g. "ok", "StoreDelta") are translated to their numeric wire IDs so test + * helpers can keep speaking the SDK's logical vocabulary. + */ export function encodeToBuffer(msg: unknown): ArrayBuffer { - const encoded = encode(msg); + let wireMsg = msg; + if (msg && typeof msg === "object" && !Array.isArray(msg)) { + const type = (msg as { type?: unknown }).type; + if (typeof type === "string" && Object.hasOwn(WireMessageType, type)) { + wireMsg = { + ...(msg as Record), + type: WireMessageType[type as keyof typeof WireMessageType], + }; + } + } + const encoded = encode(wireMsg); return encoded.buffer.slice( encoded.byteOffset, encoded.byteOffset + encoded.byteLength, @@ -83,11 +98,7 @@ export class AutoMockWebSocket { } private _autoRespondOk(id: number): void { - const encoded = encode({ type: "ok", id }) as Uint8Array; - const buf = encoded.buffer.slice( - encoded.byteOffset, - encoded.byteOffset + encoded.byteLength, - ) as ArrayBuffer; + const buf = encodeToBuffer({ type: "ok", id }); if (this.onmessage) { this.onmessage({ data: buf }); } diff --git a/specs/architecture/adrs.md b/specs/architecture/adrs.md index d7e3a386..15d4f4ce 100644 --- a/specs/architecture/adrs.md +++ b/specs/architecture/adrs.md @@ -242,6 +242,7 @@ WebSocket per-message deflate compression is disabled. MessagePack is already co - Smaller wire payloads than JSON without pre-compiled stubs. - Stack-overflow attacks via deeply nested payloads are impossible by construction. - No compression overhead on the critical path. +- Message type discriminators are one-byte positive fixint IDs (`0x00`–`0x29`) from the canonical registry in `src/wire/message_type.zig` / `sdk/typescript/src/connection_wire.ts`, replacing the legacy top-level `type` strings. Direction is enforced by routing, not by the numeric value; see ADR-009. **Principles**: P-PPF, P-SBD @@ -282,6 +283,11 @@ The SDK is responsible for translating developer-facing syntax into compact wire | `{ age: { gte: 18 } }` (query condition) | `[field_index, op_code, 18]` (positional tuple) | | `{ created_at: 'desc' }` (sort) | `[field_index, 1]` (positional tuple, desc flag) | | `'address.city'` (nested field) | field index for `address__city` (flattened with `__`) | +| `{ type: "StoreSet" }` (message) | `type: 0x11` — one-byte positive fixint message ID | + +### Message Type IDs + +Message type discriminators are fixed numeric IDs (`0x00`–`0x29`), a single byte each on the wire. The registry is a shared contract — the Zig enum (`src/wire/message_type.zig`) and the SDK registry (`sdk/typescript/src/connection_wire.ts`) encode the same table, and routing is an exhaustive switch: server-only or unknown IDs sent by a client return `INVALID_MESSAGE_TYPE`, and legacy string types fail with `INVALID_MESSAGE_FORMAT`. The SDK maps IDs back to logical type names on decode, so the wire change is invisible above the codec boundary. Nested field paths are flattened in the SDK using the `__` separator convention before being mapped to integer indices. The server sees only flat field names. diff --git a/specs/implementation/message-handler.md b/specs/implementation/message-handler.md index 03cec068..323949af 100644 --- a/specs/implementation/message-handler.md +++ b/specs/implementation/message-handler.md @@ -35,8 +35,8 @@ 1. Apply per-connection message rate limiting from `Config.SecurityConfig`. 2. Decode `wire.Envelope` from the raw MessagePack frame. 3. Acquire a request arena from `MemoryStrategy`. -4. Classify the message type with `classifyMsgType`. -5. Route to the store, auth, or presence handler. +4. Convert the envelope `type` ID to `MessageType` via `std.enums.fromInt`; unknown or server-only IDs return `error.UnknownMessageType` → `INVALID_MESSAGE_TYPE`. +5. Route to the store, auth, or presence handler with an exhaustive `switch` over `MessageType` (adding a registry member is a compile-time routing decision). 6. Send an immediate response when the route completes synchronously. 7. Return `null` for asynchronous read, subscribe, load-more, presence, or scope-resolution paths; the relevant worker later sends the response if the connection state is still current. 8. Convert route failures through `wire.getWireError` and send a canonical error response. diff --git a/specs/implementation/query-grammar.md b/specs/implementation/query-grammar.md index b0c47cb7..5fa411d0 100644 --- a/specs/implementation/query-grammar.md +++ b/specs/implementation/query-grammar.md @@ -65,7 +65,7 @@ Queries use compact integer tuples on the wire to minimize message size. ```json { - "type": "StoreQuery", + "type": 20, "id": 6, "table_index": 0, "conditions": [[3, 4, 18], [4, 0, "active"]], @@ -76,6 +76,8 @@ Queries use compact integer tuples on the wire to minimize message size. } ``` +`type` is the numeric wire ID for `StoreQuery` from the [Message Type Registry](./wire-protocol.md#message-type-registry). + --- ## Flattening & Storage Invariants diff --git a/specs/implementation/typescript-sdk.md b/specs/implementation/typescript-sdk.md index 6bc2ffdf..20a5a430 100644 --- a/specs/implementation/typescript-sdk.md +++ b/specs/implementation/typescript-sdk.md @@ -53,7 +53,7 @@ The TypeScript SDK owns the browser/application API surface, connection lifecycl 1. Public store/presence method validates SDK inputs and builds a semantic command. 2. `ConnectionManager` ensures the connection and required namespace scope are ready. -3. `ConnectionWireCodec` encodes the command using current schema dictionaries. +3. `ConnectionWireCodec` encodes the command using current schema dictionaries and replaces the logical `type` name with its numeric wire ID from the shared registry (`WireMessageType`); `decodeMessage` maps numeric IDs back to the logical `InboundMessage` type names, so the rest of the SDK never sees numeric wire IDs. 4. `PendingRequests` records the request id, timeout, and optional committed write id. 5. Server response resolves/rejects the immediate request. 6. For committed writes, `WriteCommitted` or `WriteError` resolves/rejects the tracked write. @@ -74,7 +74,7 @@ The TypeScript SDK owns the browser/application API surface, connection lifecycl ## Maintenance Rules -- A wire message change must update `connection_wire.ts`, server `src/wire/*`, [Wire Protocol](./wire-protocol.md), and relevant tests together. +- A wire message change must update `connection_wire.ts` (registry + encode/decode), server `src/wire/*`, [Wire Protocol](./wire-protocol.md), and relevant tests together. - A public API type change must update `types.ts` and `index.ts` exports together. - SDK distribution changes must keep `sdk/typescript/src/index.ts`, emitted declarations, and the CLI build entry aligned. - Store and presence APIs stay separate; do not reintroduce presence through store paths. diff --git a/specs/implementation/wire-protocol.md b/specs/implementation/wire-protocol.md index 92fc9e43..abe2190e 100644 --- a/specs/implementation/wire-protocol.md +++ b/specs/implementation/wire-protocol.md @@ -44,9 +44,9 @@ This document is the canonical implementation contract for ZyncBase's WebSocket | Transport | WebSocket. | | Frame type | Binary MessagePack frames. | | Compression | Disabled. | -| Request envelope | MessagePack map with `type: string` and `id: u64`. | -| Response envelope | MessagePack map with `type: "ok"` or `type: "error"` and matching `id`. | -| Push envelope | MessagePack map with a push `type`; no request `id`. | +| Request envelope | MessagePack map with `type: u8` (numeric message type ID, one-byte positive fixint) and `id: u64`. | +| Response envelope | MessagePack map with `type: 0x00` (`ok`) or `type: 0x01` (`error`) and matching `id`. | +| Push envelope | MessagePack map with a numeric push `type` ID; no request `id`. | | Unknown fields | Ignored by decoders unless the owning message requires a stricter shape. | | Document ids | SDK strings at the API boundary; 16-byte binary ids where the wire format carries typed document ids. | | Field/table routing | Integer ids from `SchemaSync`; see [Schema Grammar](./schema-grammar.md). | @@ -82,6 +82,52 @@ All values that represent integer-keyed field maps (store documents, presence da - Ordering: pairs are unordered; server/SDK must not assume sorted-by-index. - Empty `[]`: valid; means no fields. +## Message Type Registry + +Every top-level message carries a fixed numeric `type` ID. The registry is the +single source of truth shared by the Zig enum (`src/wire/message_type.zig`), +the SDK registry (`sdk/typescript/src/connection_wire.ts`), and this spec. +All IDs are ≤ `0x7f` so MessagePack encodes them as a one-byte positive +fixint. **Never reuse or renumber an assigned ID.** + +| ID | Name | Direction | Purpose | +|----|------|-----------|---------| +| `0x00` | `ok` | S→C | Successful correlated response. | +| `0x01` | `error` | S→C | Failed correlated or uncorrelated response. | +| `0x02` | `Connected` | S→C | Connection/session bootstrap. | +| `0x03` | `SchemaSync` | S→C | Schema dictionary bootstrap. | +| `0x04` | `AuthRefresh` | C→S | Refresh connection authentication. | +| `0x05` | `ServerDisconnect` | S→C | Structured disconnect reason. | +| `0x10` | `StoreSetNamespace` | C→S | Establish store scope. | +| `0x11` | `StoreSet` | C→S | Set a document or field. | +| `0x12` | `StoreRemove` | C→S | Remove a document or field. | +| `0x13` | `StoreBatch` | C→S | Apply a write batch. | +| `0x14` | `StoreQuery` | C→S | Execute a one-shot query. | +| `0x15` | `StoreSubscribe` | C→S | Start a live query. | +| `0x16` | `StoreUnsubscribe` | C→S | Stop a live query. | +| `0x17` | `StoreLoadMore` | C→S | Page an active query. | +| `0x18` | `StoreDelta` | S→C | Push committed subscription changes. | +| `0x19` | `WriteCommitted` | S→C | Confirm a tracked write. | +| `0x1a` | `WriteError` | S→C | Fail a tracked write. | +| `0x20` | `PresenceSetNamespace` | C→S | Establish presence scope. | +| `0x21` | `PresenceSet` | C→S | Update user presence. | +| `0x22` | `PresenceSetShared` | C→S | Update shared presence state. | +| `0x23` | `PresenceSubscribe` | C→S | Subscribe to user presence. | +| `0x24` | `PresenceUnsubscribe` | C→S | Unsubscribe from user presence. | +| `0x25` | `PresenceSubscribeShared` | C→S | Subscribe to shared state. | +| `0x26` | `PresenceUnsubscribeShared` | C→S | Unsubscribe from shared state. | +| `0x27` | `PresenceRemove` | C→S | Remove user presence. | +| `0x28` | `PresenceBroadcast` | S→C | Push user presence changes. | +| `0x29` | `SharedStateBroadcast` | S→C | Push shared state changes. | + +**Direction rules:** server-only IDs (`0x00`–`0x03`, `0x05`, `0x18`–`0x1a`, +`0x28`–`0x29`) received as client requests are rejected with +`INVALID_MESSAGE_TYPE`. `0x04` (`AuthRefresh`) is a client request; unknown or +unassigned IDs are rejected the same way. +Legacy string `type` values fail envelope decoding with +`INVALID_MESSAGE_FORMAT`. The map key remains the string `"type"`; only the +value is numeric. + ## Client Messages All client messages include `type` and `id`. The fields below are additional message-specific fields. @@ -164,7 +210,7 @@ Public error codes and retry categories are owned by [Error Taxonomy](./error-ta ## Extensibility - Additive fields are allowed when older decoders can safely ignore them. -- New message types must be added to `MessageHandler.classifyMsgType`, `src/wire/decode.zig`, SDK wire code, and this file in the same change. +- New message types must be added to `MessageType` (`src/wire/message_type.zig`), the message routing switch in `src/message_handler.zig`, the SDK registry in `sdk/typescript/src/connection_wire.ts`, this file, and the [Message Type Registry](#message-type-registry) in the same change. - New public errors must be added to `src/wire/errors.zig`, `sdk/typescript/src/errors.ts`, and [Error Taxonomy](./error-taxonomy.md). - Breaking wire changes are acceptable during the current green-field stage, but the docs and SDK must move in the same commit. diff --git a/src/app_test_helpers.zig b/src/app_test_helpers.zig index 12f669cc..76c1b573 100644 --- a/src/app_test_helpers.zig +++ b/src/app_test_helpers.zig @@ -28,6 +28,7 @@ const StorageEngine = @import("storage_engine.zig").StorageEngine; const StoreService = @import("store_service.zig").StoreService; const SubscriptionEngine = @import("subscription/engine.zig").SubscriptionEngine; const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; +const MessageType = @import("wire/message_type.zig").MessageType; const Allocator = std.mem.Allocator; const Session = authentication_session.Session; @@ -146,17 +147,18 @@ pub fn encodePayloadToBytes(allocator: Allocator, payload: msgpack.Payload) ![]c /// Parse a response and extract the "type" and "code" fields. /// Caller is responsible for freeing the fields in the returned struct. -pub fn parseResponse(allocator: std.mem.Allocator, response: []const u8) !struct { resp_type: []const u8, code: ?[]const u8 } { +pub fn parseResponse(allocator: std.mem.Allocator, response: []const u8) !struct { resp_type: MessageType, code: ?[]const u8 } { var reader: std.Io.Reader = .fixed(response); const parsed = try msgpack_utils.decode(allocator, &reader); defer parsed.free(allocator); const resp_type_val_opt = try msgpack.getMapValue(parsed, "type"); const resp_type_val = resp_type_val_opt orelse return error.MissingType; + const resp_type = std.enums.fromInt(MessageType, resp_type_val.uint) orelse return error.InvalidMessageType; const resp_code_val = try msgpack.getMapValue(parsed, "code"); return .{ - .resp_type = try allocator.dupe(u8, resp_type_val.str.value()), + .resp_type = resp_type, .code = if (resp_code_val) |v| try allocator.dupe(u8, v.str.value()) else null, }; } diff --git a/src/logging_test.zig b/src/logging_test.zig index 620bd22a..e9ad3a6c 100644 --- a/src/logging_test.zig +++ b/src/logging_test.zig @@ -202,7 +202,7 @@ test "logging: error details" { const writer = &buf.writer; try writer.writeByte(0x81); // fixmap(1) try msgpack_helpers.writeMsgPackStr(writer, "type"); - try msgpack_helpers.writeMsgPackStr(writer, "StoreSet"); + try writer.writeByte(0x11); // StoreSet numeric ID const incomplete_msg = buf.written(); diff --git a/src/message_handler.zig b/src/message_handler.zig index c415065b..0ab47c16 100644 --- a/src/message_handler.zig +++ b/src/message_handler.zig @@ -10,6 +10,7 @@ const typed_doc_id = @import("typed/doc_id.zig"); const wire_decode = @import("wire/decode.zig"); const wire_encode = @import("wire/encode.zig"); const wire_errors = @import("wire/errors.zig"); +const wire_message_type = @import("wire/message_type.zig"); const JwtValidator = @import("authentication/jwt_validator.zig").JwtValidator; const SecurityConfig = @import("config/state.zig").Config.SecurityConfig; const ViolationTracker = @import("connection/violations.zig").ConnectionViolationTracker; @@ -175,8 +176,40 @@ pub const MessageHandler = struct { envelope: wire_decode.Envelope, message: []const u8, ) !?[]const u8 { - const msg_type = classifyMsgType(envelope.type) orelse return error.UnknownMessageType; - return handler_table[@intFromEnum(msg_type)](self, arena_allocator, conn, envelope.id, message); + const msg_type = std.enums.fromInt(wire_message_type.MessageType, envelope.type) orelse return error.UnknownMessageType; + return switch (msg_type) { + // Client request cases — exhaustive dispatch keeps newly added + // enum members a compile-time routing decision. + .store_set_namespace => try wrap(&MessageHandler.handleStoreSetNamespace)(self, arena_allocator, conn, envelope.id, message), + .store_set => try wrap(&MessageHandler.handleStoreSet)(self, arena_allocator, conn, envelope.id, message), + .store_remove => try wrap(&MessageHandler.handleStoreRemove)(self, arena_allocator, conn, envelope.id, message), + .store_batch => try wrap(&MessageHandler.handleStoreBatch)(self, arena_allocator, conn, envelope.id, message), + .store_query => try wrap(&MessageHandler.handleStoreQuery)(self, arena_allocator, conn, envelope.id, message), + .store_subscribe => try wrap(&MessageHandler.handleStoreSubscribe)(self, arena_allocator, conn, envelope.id, message), + .store_unsubscribe => try wrap(&MessageHandler.handleStoreUnsubscribe)(self, arena_allocator, conn, envelope.id, message), + .store_load_more => try wrap(&MessageHandler.handleStoreLoadMore)(self, arena_allocator, conn, envelope.id, message), + .auth_refresh => try wrap(&MessageHandler.handleAuthRefresh)(self, arena_allocator, conn, envelope.id, message), + .presence_set_namespace => try wrap(&MessageHandler.handlePresenceSetNamespace)(self, arena_allocator, conn, envelope.id, message), + .presence_set => try wrap(&MessageHandler.handlePresenceSet)(self, arena_allocator, conn, envelope.id, message), + .presence_set_shared => try wrap(&MessageHandler.handlePresenceSetShared)(self, arena_allocator, conn, envelope.id, message), + .presence_subscribe => try wrap(&MessageHandler.handlePresenceSubscribe)(self, arena_allocator, conn, envelope.id, message), + .presence_unsubscribe => try wrap(&MessageHandler.handlePresenceUnsubscribe)(self, arena_allocator, conn, envelope.id, message), + .presence_subscribe_shared => try wrap(&MessageHandler.handlePresenceSubscribeShared)(self, arena_allocator, conn, envelope.id, message), + .presence_unsubscribe_shared => try wrap(&MessageHandler.handlePresenceUnsubscribeShared)(self, arena_allocator, conn, envelope.id, message), + .presence_remove => try wrap(&MessageHandler.handlePresenceRemove)(self, arena_allocator, conn, envelope.id, message), + // Server response/push types must never be received as client requests. + .ok, + .@"error", + .connected, + .schema_sync, + .server_disconnect, + .store_delta, + .write_committed, + .write_error, + .presence_broadcast, + .shared_state_broadcast, + => error.UnknownMessageType, + }; } pub fn teardownSession(self: *MessageHandler, conn: *Connection) void { @@ -779,51 +812,6 @@ pub const MessageHandler = struct { } }; -const MsgType = enum { - store_set_namespace, - store_set, - store_subscribe, - store_unsubscribe, - store_query, - store_load_more, - store_remove, - store_batch, - auth_refresh, - presence_set_namespace, - presence_set, - presence_set_shared, - presence_subscribe, - presence_unsubscribe, - presence_subscribe_shared, - presence_unsubscribe_shared, - presence_remove, -}; - -const msg_type_map = std.StaticStringMap(MsgType).initComptime(.{ - .{ "StoreSetNamespace", .store_set_namespace }, - .{ "StoreSet", .store_set }, - .{ "StoreSubscribe", .store_subscribe }, - .{ "StoreUnsubscribe", .store_unsubscribe }, - .{ "StoreQuery", .store_query }, - .{ "StoreLoadMore", .store_load_more }, - .{ "StoreRemove", .store_remove }, - .{ "StoreBatch", .store_batch }, - .{ "AuthRefresh", .auth_refresh }, - .{ "PresenceSetNamespace", .presence_set_namespace }, - .{ "PresenceSet", .presence_set }, - .{ "PresenceSetShared", .presence_set_shared }, - .{ "PresenceSubscribe", .presence_subscribe }, - .{ "PresenceUnsubscribe", .presence_unsubscribe }, - .{ "PresenceSubscribeShared", .presence_subscribe_shared }, - .{ "PresenceUnsubscribeShared", .presence_unsubscribe_shared }, - .{ "PresenceRemove", .presence_remove }, -}); - -fn classifyMsgType(t: []const u8) ?MsgType { - if (t.len < 8) return null; - return msg_type_map.get(t); -} - const HandlerFn = *const fn (*MessageHandler, std.mem.Allocator, *Connection, u64, []const u8) anyerror!?[]const u8; fn wrap(comptime f: anytype) HandlerFn { // zwanzig-disable-line: unused-parameter @@ -834,29 +822,6 @@ fn wrap(comptime f: anytype) HandlerFn { // zwanzig-disable-line: unused-paramet }.call; } -const handler_table = [_]HandlerFn{ - wrap(&MessageHandler.handleStoreSetNamespace), - wrap(&MessageHandler.handleStoreSet), - wrap(&MessageHandler.handleStoreSubscribe), - wrap(&MessageHandler.handleStoreUnsubscribe), - wrap(&MessageHandler.handleStoreQuery), - wrap(&MessageHandler.handleStoreLoadMore), - wrap(&MessageHandler.handleStoreRemove), - wrap(&MessageHandler.handleStoreBatch), - wrap(&MessageHandler.handleAuthRefresh), - wrap(&MessageHandler.handlePresenceSetNamespace), - wrap(&MessageHandler.handlePresenceSet), - wrap(&MessageHandler.handlePresenceSetShared), - wrap(&MessageHandler.handlePresenceSubscribe), - wrap(&MessageHandler.handlePresenceUnsubscribe), - wrap(&MessageHandler.handlePresenceSubscribeShared), - wrap(&MessageHandler.handlePresenceUnsubscribeShared), - wrap(&MessageHandler.handlePresenceRemove), -}; -comptime { - std.debug.assert(handler_table.len == @typeInfo(MsgType).@"enum".fields.len); -} - fn isSecurityError(err: anyerror) bool { return switch (err) { error.MaxDepthExceeded, diff --git a/src/message_handler_test.zig b/src/message_handler_test.zig index ff9e2885..2b2cede1 100644 --- a/src/message_handler_test.zig +++ b/src/message_handler_test.zig @@ -8,6 +8,7 @@ const sth = @import("storage_engine_test_helpers.zig"); const store_helpers = @import("store_test_helpers.zig"); const typed_doc_id = @import("typed/doc_id.zig"); const WebSocket = @import("uwebsockets_wrapper.zig").WebSocket; +const MessageType = @import("wire/message_type.zig").MessageType; const testing = std.testing; const AppTestContext = helpers.AppTestContext; @@ -114,10 +115,10 @@ test "MessageHandler: store operations require ready scope" { const response = try routeWithArena(&app.handler, allocator, conn, message); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("error", result.resp_type); + try testing.expectEqual(MessageType.@"error", result.resp_type); try testing.expectEqualStrings("SESSION_NOT_READY", result.code.?); } @@ -154,10 +155,10 @@ test "MessageHandler: StoreSet document with auth predicate persists and is read const response = try routeWithArena(&app.handler, allocator, conn, message); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("ok", result.resp_type); + try testing.expectEqual(MessageType.ok, result.resp_type); // Flush writes to ensure persistence try app.storage_engine.flushPendingWrites(); @@ -208,10 +209,10 @@ test "MessageHandler: StoreSet routes and maps StoreService errors" { const response = try routeWithArena(&app.handler, allocator, conn, message); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("ok", result.resp_type); + try testing.expectEqual(MessageType.ok, result.resp_type); } { @@ -233,10 +234,10 @@ test "MessageHandler: StoreSet routes and maps StoreService errors" { const response = try routeWithArena(&app.handler, allocator, conn, message); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("error", result.resp_type); + try testing.expectEqual(MessageType.@"error", result.resp_type); try testing.expectEqualStrings("INVALID_ARRAY_ELEMENT", result.code.?); } } @@ -262,7 +263,7 @@ test "MessageHandler: StoreSet with confirm=accepted and writeId returns INVALID try writer.writeByte(0x86); // fixmap(6) try msgpack.writeMsgPackStr(writer, "type"); - try msgpack.writeMsgPackStr(writer, "StoreSet"); + try writer.writeByte(@intFromEnum(MessageType.store_set)); try msgpack.writeMsgPackStr(writer, "id"); try writer.writeByte(0xcf); try writer.writeInt(u64, 1, .big); @@ -286,10 +287,10 @@ test "MessageHandler: StoreSet with confirm=accepted and writeId returns INVALID const response = try routeWithArena(&app.handler, allocator, conn, message); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("error", result.resp_type); + try testing.expectEqual(MessageType.@"error", result.resp_type); try testing.expectEqualStrings("INVALID_MESSAGE", result.code.?); } @@ -302,7 +303,7 @@ fn createStoreSetNamespaceMessageBytes(allocator: std.mem.Allocator, id: u64, na try writer.writeByte(0x83); // fixmap(3) try msgpack.writeMsgPackStr(writer, "type"); - try msgpack.writeMsgPackStr(writer, "StoreSetNamespace"); + try writer.writeByte(@intFromEnum(MessageType.store_set_namespace)); try msgpack.writeMsgPackStr(writer, "id"); try writer.writeByte(0xcf); // uint64 try writer.writeInt(u64, id, .big); @@ -319,7 +320,7 @@ fn createPresenceSetNamespaceMessageBytes(allocator: std.mem.Allocator, id: u64, try writer.writeByte(0x83); // fixmap(3) try msgpack.writeMsgPackStr(writer, "type"); - try msgpack.writeMsgPackStr(writer, "PresenceSetNamespace"); + try writer.writeByte(@intFromEnum(MessageType.presence_set_namespace)); try msgpack.writeMsgPackStr(writer, "id"); try writer.writeByte(0xcf); // uint64 try writer.writeInt(u64, id, .big); @@ -365,10 +366,10 @@ test "NamespaceSwitch: initial store namespace setup succeeds with users.namespa const response = try routeWithArena(&app.handler, allocator, conn, msg); defer allocator.free(response); const result = try parseResponse(allocator, response); - defer allocator.free(result.resp_type); + defer if (result.code) |code| allocator.free(code); - try testing.expectEqualStrings("ok", result.resp_type); + try testing.expectEqual(MessageType.ok, result.resp_type); } test "NamespaceSwitch: namespaced=true enforces lock across both scopes" { @@ -408,9 +409,9 @@ test "NamespaceSwitch: namespaced=true enforces lock across both scopes" { const resp = try routeWithArena(&app.handler, allocator, conn, msg); defer allocator.free(resp); const r = try parseResponse(allocator, resp); - defer allocator.free(r.resp_type); + defer if (r.code) |c| allocator.free(c); - try testing.expectEqualStrings("ok", r.resp_type); + try testing.expectEqual(MessageType.ok, r.resp_type); } // Same-scope store switch rejected @@ -420,9 +421,9 @@ test "NamespaceSwitch: namespaced=true enforces lock across both scopes" { const resp = try routeWithArena(&app.handler, allocator, conn, msg); defer allocator.free(resp); const r = try parseResponse(allocator, resp); - defer allocator.free(r.resp_type); + defer if (r.code) |c| allocator.free(c); - try testing.expectEqualStrings("error", r.resp_type); + try testing.expectEqual(MessageType.@"error", r.resp_type); try testing.expectEqualStrings("NAMESPACE_SWITCH_REJECTED", r.code.?); } @@ -433,9 +434,9 @@ test "NamespaceSwitch: namespaced=true enforces lock across both scopes" { const resp = try routeWithArena(&app.handler, allocator, conn, msg); defer allocator.free(resp); const r = try parseResponse(allocator, resp); - defer allocator.free(r.resp_type); + defer if (r.code) |c| allocator.free(c); - try testing.expectEqualStrings("error", r.resp_type); + try testing.expectEqual(MessageType.@"error", r.resp_type); try testing.expectEqualStrings("NAMESPACE_SWITCH_REJECTED", r.code.?); } @@ -446,9 +447,9 @@ test "NamespaceSwitch: namespaced=true enforces lock across both scopes" { const resp = try routeWithArena(&app.handler, allocator, conn, msg); defer allocator.free(resp); const r = try parseResponse(allocator, resp); - defer allocator.free(r.resp_type); + defer if (r.code) |c| allocator.free(c); - try testing.expectEqualStrings("ok", r.resp_type); + try testing.expectEqual(MessageType.ok, r.resp_type); } } @@ -478,18 +479,18 @@ test "NamespaceSwitch: namespaced=false allows any switch" { const resp1 = try routeWithArena(&app.handler, allocator, conn, msg1); defer allocator.free(resp1); const result1 = try parseResponse(allocator, resp1); - defer allocator.free(result1.resp_type); + defer if (result1.code) |code| allocator.free(code); - try testing.expectEqualStrings("ok", result1.resp_type); + try testing.expectEqual(MessageType.ok, result1.resp_type); const msg2 = try createStoreSetNamespaceMessageBytes(allocator, 2, "public"); defer allocator.free(msg2); const resp2 = try routeWithArena(&app.handler, allocator, conn, msg2); defer allocator.free(resp2); const result2 = try parseResponse(allocator, resp2); - defer allocator.free(result2.resp_type); + defer if (result2.code) |code| allocator.free(code); - try testing.expectEqualStrings("ok", result2.resp_type); + try testing.expectEqual(MessageType.ok, result2.resp_type); } const table_defs = [_]helpers.TableDef{ @@ -509,13 +510,13 @@ fn decodeResponse(allocator: std.mem.Allocator, response: []const u8) !msgpack_h return try msgpack_helpers.decode(allocator, &reader); } -fn expectResponseType(allocator: std.mem.Allocator, response: []const u8, expected: []const u8) !void { +fn expectResponseType(allocator: std.mem.Allocator, response: []const u8, expected: MessageType) !void { const parsed = try decodeResponse(allocator, response); defer parsed.free(allocator); const value = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; - try testing.expect(value == .str); - try testing.expectEqualStrings(expected, value.str.value()); + try testing.expect(value == .uint); + try testing.expectEqual(expected, std.enums.fromInt(MessageType, value.uint) orelse return error.TestExpectedError); } fn expectResponseId(allocator: std.mem.Allocator, response: []const u8, expected: u64) !void { @@ -532,8 +533,8 @@ fn expectErrorCode(allocator: std.mem.Allocator, response: []const u8, expected: defer parsed.free(allocator); const resp_type = (try msgpack_helpers.getMapValue(parsed, "type")) orelse return error.TestExpectedError; - try testing.expect(resp_type == .str); - try testing.expectEqualStrings("error", resp_type.str.value()); + try testing.expect(resp_type == .uint); + try testing.expectEqual(MessageType.@"error", std.enums.fromInt(MessageType, resp_type.uint) orelse return error.TestExpectedError); const code = (try msgpack_helpers.getMapValue(parsed, "code")) orelse return error.TestExpectedError; try testing.expect(code == .str); @@ -561,7 +562,7 @@ test "message: representative frames route at protocol boundary" { const response = try routeBytes(&app, sc.conn, allocator, message); defer allocator.free(response); - try expectResponseType(allocator, response, "ok"); + try expectResponseType(allocator, response, MessageType.ok); try expectResponseId(allocator, response, 11); } @@ -575,13 +576,13 @@ test "message: representative frames route at protocol boundary" { } { - const message = try store_helpers.createCustomMessage(allocator, 13, "UnknownType", 1, table.index, &.{}); + const message = try store_helpers.createCustomMessage(allocator, 13, 0x7e, 1, table.index, &.{}); defer allocator.free(message); const response = try routeBytes(&app, sc.conn, allocator, message); defer allocator.free(response); - try expectErrorCode(allocator, response, "INTERNAL_ERROR"); + try expectErrorCode(allocator, response, "INVALID_MESSAGE_TYPE"); try expectResponseId(allocator, response, 13); } } @@ -619,11 +620,12 @@ test "message: response id is preserved across routed requests" { } { - const message = try store_helpers.createCustomMessage(allocator, 303, "InvalidType", 1, table.index, &.{}); + const message = try store_helpers.createCustomMessage(allocator, 303, 0x7f, 1, table.index, &.{}); defer allocator.free(message); const response = try routeBytes(&app, sc.conn, allocator, message); defer allocator.free(response); + try expectErrorCode(allocator, response, "INVALID_MESSAGE_TYPE"); try expectResponseId(allocator, response, 303); } } @@ -667,7 +669,7 @@ test "message: repeated routed requests release per-message allocations" { const response = try routeBytes(&app, sc.conn, allocator, message); defer allocator.free(response); - try expectResponseType(allocator, response, "ok"); + try expectResponseType(allocator, response, MessageType.ok); try expectResponseId(allocator, response, msg_id); } } @@ -723,7 +725,7 @@ test "message: concurrent routed requests release response allocations" { const response = try routeBytes(ctx.app, sc.conn, thread_allocator, message); defer thread_allocator.free(response); - try expectResponseType(thread_allocator, response, "ok"); + try expectResponseType(thread_allocator, response, MessageType.ok); try expectResponseId(thread_allocator, response, msg_id); } } diff --git a/src/message_handler_verification_test.zig b/src/message_handler_verification_test.zig index c2cd59c5..6f3842ba 100644 --- a/src/message_handler_verification_test.zig +++ b/src/message_handler_verification_test.zig @@ -3,6 +3,7 @@ const std = @import("std"); const helpers = @import("app_test_helpers.zig"); const msgpack = @import("msgpack_test_helpers.zig"); const store_helpers = @import("store_test_helpers.zig"); +const MessageType = @import("wire/message_type.zig").MessageType; const testing = std.testing; const AppTestContext = helpers.AppTestContext; @@ -71,7 +72,8 @@ test "Verification: StoreQuery routes to query response" { defer decoded.free(allocator); const msg_type = (try msgpack.getMapValue(decoded, "type")) orelse return error.TestExpectedError; - try testing.expectEqualStrings("ok", msg_type.str.value()); + try testing.expect(msg_type == .uint); + try testing.expectEqual(MessageType.ok, std.enums.fromInt(MessageType, msg_type.uint) orelse return error.TestExpectedError); const value = (try msgpack.getMapValue(decoded, "value")) orelse return error.TestExpectedError; try testing.expect(value == .arr); @@ -94,7 +96,7 @@ test "Verification: Error handling for invalid messages" { { var parsed = msgpack.Payload.mapPayload(allocator); defer parsed.free(allocator); - try parsed.mapPut("type", try msgpack.Payload.strToPayload("StoreSet", allocator)); + try parsed.mapPut("type", msgpack.Payload.uintToPayload(@intFromEnum(MessageType.store_set))); const sc = try app.setupMockConnection(); defer sc.deinit(); @@ -112,9 +114,10 @@ test "Verification: Error handling for invalid messages" { } { + // Unassigned numeric ID routes to INVALID_MESSAGE_TYPE. var parsed = msgpack.Payload.mapPayload(allocator); defer parsed.free(allocator); - try parsed.mapPut("type", try msgpack.Payload.strToPayload("UnknownType", allocator)); + try parsed.mapPut("type", msgpack.Payload.uintToPayload(0x7e)); try parsed.mapPut("id", msgpack.Payload.uintToPayload(1)); const sc = try app.setupMockConnection(); @@ -125,11 +128,46 @@ test "Verification: Error handling for invalid messages" { const response = try routeWithArena(&app.handler, allocator, sc.conn, bytes); defer allocator.free(response); const decoded = try helpers.parseResponse(allocator, response); - defer allocator.free(decoded.resp_type); defer if (decoded.code) |code| allocator.free(code); - try testing.expectEqualStrings("error", decoded.resp_type); - try testing.expectEqualStrings("INTERNAL_ERROR", decoded.code.?); + try testing.expectEqual(MessageType.@"error", decoded.resp_type); + try testing.expectEqualStrings("INVALID_MESSAGE_TYPE", decoded.code.?); + } + + { + // Server-only ID received as a client request routes to INVALID_MESSAGE_TYPE. + var parsed = msgpack.Payload.mapPayload(allocator); + defer parsed.free(allocator); + try parsed.mapPut("type", msgpack.Payload.uintToPayload(@intFromEnum(MessageType.store_delta))); + try parsed.mapPut("id", msgpack.Payload.uintToPayload(1)); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + + const bytes = try encodePayloadToBytes(allocator, parsed); + defer allocator.free(bytes); + const response = try routeWithArena(&app.handler, allocator, sc.conn, bytes); + defer allocator.free(response); + const decoded = try helpers.parseResponse(allocator, response); + defer if (decoded.code) |code| allocator.free(code); + + try testing.expectEqual(MessageType.@"error", decoded.resp_type); + try testing.expectEqualStrings("INVALID_MESSAGE_TYPE", decoded.code.?); + } + + { + // Legacy string request type fails envelope decoding with INVALID_MESSAGE_FORMAT. + var parsed = msgpack.Payload.mapPayload(allocator); + defer parsed.free(allocator); + try parsed.mapPut("type", try msgpack.Payload.strToPayload("StoreSet", allocator)); + try parsed.mapPut("id", msgpack.Payload.uintToPayload(1)); + + const sc = try app.setupMockConnection(); + defer sc.deinit(); + + const bytes = try encodePayloadToBytes(allocator, parsed); + defer allocator.free(bytes); + try testing.expectError(error.InvalidMessageFormat, routeWithArena(&app.handler, allocator, sc.conn, bytes)); } } @@ -169,7 +207,8 @@ test "Verification: StoreLoadMore uses subscription state and returns requested defer subscribe_decoded.free(allocator); const subscribe_type = (try msgpack.getMapValue(subscribe_decoded, "type")) orelse return error.TestExpectedError; - try testing.expectEqualStrings("ok", subscribe_type.str.value()); + try testing.expect(subscribe_type == .uint); + try testing.expectEqual(MessageType.ok, std.enums.fromInt(MessageType, subscribe_type.uint) orelse return error.TestExpectedError); const sub_id_payload = (try msgpack.getMapValue(subscribe_decoded, "subId")) orelse return error.TestExpectedError; try testing.expect(sub_id_payload == .uint); @@ -187,7 +226,7 @@ test "Verification: StoreLoadMore uses subscription state and returns requested var load_more = msgpack.Payload.mapPayload(allocator); defer load_more.free(allocator); - try load_more.mapPut("type", try msgpack.Payload.strToPayload("StoreLoadMore", allocator)); + try load_more.mapPut("type", msgpack.Payload.uintToPayload(@intFromEnum(MessageType.store_load_more))); try load_more.mapPut("id", msgpack.Payload.uintToPayload(78)); try load_more.mapPut("subId", msgpack.Payload.uintToPayload(sub_id)); try load_more.mapPut("nextCursor", try msgpack.Payload.strToPayload(next_cursor, allocator)); @@ -202,7 +241,8 @@ test "Verification: StoreLoadMore uses subscription state and returns requested defer load_decoded.free(allocator); const load_type = (try msgpack.getMapValue(load_decoded, "type")) orelse return error.TestExpectedError; - try testing.expectEqualStrings("ok", load_type.str.value()); + try testing.expect(load_type == .uint); + try testing.expectEqual(MessageType.ok, std.enums.fromInt(MessageType, load_type.uint) orelse return error.TestExpectedError); const load_sub_id = (try msgpack.getMapValue(load_decoded, "subId")) orelse return error.TestExpectedError; try testing.expectEqual(sub_id, load_sub_id.uint); diff --git a/src/msgpack_test_helpers.zig b/src/msgpack_test_helpers.zig index 157d82a2..270c91cd 100644 --- a/src/msgpack_test_helpers.zig +++ b/src/msgpack_test_helpers.zig @@ -15,7 +15,7 @@ const TableMetadata = schema_types.Table; pub fn createMessage( allocator: std.mem.Allocator, id: u64, - msg_type: []const u8, + msg_type: u8, namespace: ?[]const u8, table_index: ?usize, path_suffix: []const []const u8, @@ -29,9 +29,9 @@ pub fn createMessage( if (namespace == null) num_elements -= 1; try writer.writeByte(0x80 | num_elements); // fixmap with N elements - // "type" key + // "type" key (numeric message ID) try writeMsgPackStr(writer, "type"); - try writeMsgPackStr(writer, msg_type); + try msgpack_utils.encode(msgpack_utils.Payload.uintToPayload(msg_type), writer); // "id" key try writeMsgPackStr(writer, "id"); diff --git a/src/storage_engine_test.zig b/src/storage_engine_test.zig index 83a60700..331080bf 100644 --- a/src/storage_engine_test.zig +++ b/src/storage_engine_test.zig @@ -12,11 +12,17 @@ const typed_doc_id = @import("typed/doc_id.zig"); const tth = @import("typed/test_helpers.zig"); const typed = @import("typed/types.zig"); const DDLGenerator = @import("sql/ddl.zig").DDLGenerator; +const MessageType = @import("wire/message_type.zig").MessageType; const testing = std.testing; const SendQueueEntry = send_queue_mod.Entry; const StorageEngine = sth.StorageEngine; +// Wire marker for the "type" key + numeric message ID, e.g. type:"WriteError" +fn typeMarker(comptime t: MessageType) [6]u8 { + return .{ 0xa4, 't', 'y', 'p', 'e', @intFromEnum(t) }; +} + const DirectWriterContext = struct { allocator: std.mem.Allocator, engine: storage_mod.StorageEngine, @@ -615,7 +621,7 @@ test "StorageEngine: confirmed upsert with rejecting guard returns PermissionDen try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqual(conn_id, entries[0].conn_id); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteError") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_error)) != null); try testing.expect(std.mem.indexOf(u8, entries[0].data, "PERMISSION_DENIED") != null); } @@ -677,11 +683,11 @@ test "StorageEngine: mixed flush batch commits passing op and rejects guarded op try testing.expectEqual(@as(usize, 2), entries.len); for (entries) |e| { if (e.conn_id == conn_reject) { - try testing.expect(std.mem.indexOf(u8, e.data, "WriteError") != null); + try testing.expect(std.mem.indexOf(u8, e.data, &typeMarker(.write_error)) != null); try testing.expect(std.mem.indexOf(u8, e.data, "PERMISSION_DENIED") != null); } else { try testing.expectEqual(conn_ok, e.conn_id); - try testing.expect(std.mem.indexOf(u8, e.data, "WriteError") == null); + try testing.expect(std.mem.indexOf(u8, e.data, &typeMarker(.write_error)) == null); } } @@ -785,7 +791,7 @@ test "StorageEngine: confirmed delete with rejecting guard returns PermissionDen } try testing.expectEqual(@as(usize, 1), entries.len); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteError") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_error)) != null); try testing.expect(std.mem.indexOf(u8, entries[0].data, "PERMISSION_DENIED") != null); const record = try sth.readDoc(allocator, &ctx.engine, table_meta.index, doc_id, namespace_id); @@ -825,7 +831,7 @@ test "StorageEngine: confirmed delete of non-existent row succeeds" { } try testing.expectEqual(@as(usize, 1), entries.len); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteCommitted") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_committed)) != null); } test "StorageEngine: confirmed update with guard on non-existent row succeeds" { @@ -863,7 +869,7 @@ test "StorageEngine: confirmed update with guard on non-existent row succeeds" { try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqual(conn_id, entries[0].conn_id); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteCommitted") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_committed)) != null); } test "StorageEngine: confirmed upsert with guard on non-existent row succeeds" { @@ -904,7 +910,7 @@ test "StorageEngine: confirmed upsert with guard on non-existent row succeeds" { try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqual(conn_id, entries[0].conn_id); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteCommitted") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_committed)) != null); const record = try sth.readDoc(allocator, &ctx.engine, table_meta.index, doc_id, namespace_id); defer if (record) |r| r.deinit(allocator); @@ -956,7 +962,7 @@ test "StorageEngine: confirmed update with rejecting guard on existing row retur try testing.expectEqual(@as(usize, 1), entries.len); try testing.expectEqual(conn_id, entries[0].conn_id); - try testing.expect(std.mem.indexOf(u8, entries[0].data, "WriteError") != null); + try testing.expect(std.mem.indexOf(u8, entries[0].data, &typeMarker(.write_error)) != null); try testing.expect(std.mem.indexOf(u8, entries[0].data, "PERMISSION_DENIED") != null); const record = try sth.readDoc(allocator, &ctx.engine, table_meta.index, doc_id, namespace_id); diff --git a/src/store_test_helpers.zig b/src/store_test_helpers.zig index b936a9c1..4b4068da 100644 --- a/src/store_test_helpers.zig +++ b/src/store_test_helpers.zig @@ -4,6 +4,7 @@ const msgpack_test_helpers = @import("msgpack_test_helpers.zig"); const msgpack_utils = @import("msgpack_utils.zig"); const schema_types = @import("schema/types.zig"); const typed_doc_id = @import("typed/doc_id.zig"); +const MessageType = @import("wire/message_type.zig").MessageType; pub fn createStoreSetMessageWithPayload( allocator: std.mem.Allocator, @@ -20,7 +21,7 @@ pub fn createStoreSetMessageWithPayload( try writer.writeByte(0x84); // fixmap(4) - removed namespace try msgpack_utils.writeMsgPackStr(writer, "type"); - try msgpack_utils.writeMsgPackStr(writer, "StoreSet"); + try msgpack_utils.encode(msgpack_utils.Payload.uintToPayload(@intFromEnum(MessageType.store_set)), writer); try msgpack_utils.writeMsgPackStr(writer, "id"); try writer.writeByte(0xcf); // uint64 @@ -54,8 +55,7 @@ pub fn createStoreQueryMessageWithFilterKey( defer p.free(allocator); { - const k_val = try msgpack_utils.Payload.strToPayload("StoreQuery", allocator); - errdefer k_val.free(allocator); + const k_val = msgpack_utils.Payload.uintToPayload(@intFromEnum(MessageType.store_query)); try p.mapPut("type", k_val); } try p.mapPut("id", msgpack_utils.Payload.uintToPayload(id)); @@ -99,8 +99,7 @@ pub fn createStoreSubscribeMessage( _ = _subscription_id; { - const k_val = try msgpack_utils.Payload.strToPayload("StoreSubscribe", allocator); - errdefer k_val.free(allocator); + const k_val = msgpack_utils.Payload.uintToPayload(@intFromEnum(MessageType.store_subscribe)); try p.mapPut("type", k_val); } try p.mapPut("id", msgpack_utils.Payload.uintToPayload(id)); @@ -129,7 +128,7 @@ pub fn createStoreSubscribeMessage( pub fn createCustomMessage( allocator: std.mem.Allocator, id: u64, - msg_type: []const u8, + msg_type: u8, namespace_id: i64, table_index: ?usize, path_suffix: []const []const u8, @@ -148,7 +147,7 @@ pub fn createInvalidStoreSetMessageMissingId( const writer = &buf.writer; try writer.writeByte(0x81); // fixmap(1) - removed namespace try msgpack_utils.writeMsgPackStr(writer, "type"); - try msgpack_utils.writeMsgPackStr(writer, "StoreSet"); + try msgpack_utils.encode(msgpack_utils.Payload.uintToPayload(@intFromEnum(MessageType.store_set)), writer); return buf.toOwnedSlice(); } diff --git a/src/wire/decode.zig b/src/wire/decode.zig index da2cf041..1b4ed9b4 100644 --- a/src/wire/decode.zig +++ b/src/wire/decode.zig @@ -6,7 +6,7 @@ const msgpack_skip = @import("msgpack_skip.zig"); const Payload = msgpack.Payload; pub const Envelope = struct { - type: []const u8, + type: u64, id: u64, }; @@ -271,7 +271,7 @@ fn extractMap( // === Fast Envelope Extractor === const envelope_table = [_]Field{ - .{ .key = "type", .kind = .str, .field = "type", .required = true }, + .{ .key = "type", .kind = .u64, .field = "type", .required = true }, .{ .key = "id", .kind = .u64, .field = "id", .required = true }, }; diff --git a/src/wire/decode_test.zig b/src/wire/decode_test.zig index 6bc42a83..51260285 100644 --- a/src/wire/decode_test.zig +++ b/src/wire/decode_test.zig @@ -3,6 +3,7 @@ const std = @import("std"); const msgpack = @import("../msgpack_utils.zig"); const decode = @import("decode.zig"); const helpers = @import("test_helpers.zig"); +const MessageType = @import("message_type.zig").MessageType; const testing = std.testing; const encodePayload = helpers.encodePayload; @@ -16,13 +17,13 @@ test "extractEnvelopeFast: valid envelope" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreSet", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x11)); try map.mapPut("id", msgpack.Payload.uintToPayload(42)); const bytes = try encodePayload(allocator, map); defer allocator.free(bytes); const result = try decode.extractEnvelopeFast(bytes); - try testing.expectEqualStrings("StoreSet", result.type); + try testing.expectEqual(@as(u64, 0x11), result.type); try testing.expectEqual(@as(u64, 42), result.id); } @@ -43,7 +44,7 @@ test "extractEnvelopeFast: missing id" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreSet", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x11)); const bytes = try encodePayload(allocator, map); defer allocator.free(bytes); @@ -63,8 +64,7 @@ test "extractEnvelopeFast: wrong type for field" { const writer = &buf.writer; try writeFixMapHeader(writer, 2); try writeFixStr(writer, "type"); - try writer.writeByte(0xcf); - try writer.writeInt(u64, 999, .big); // type as uint64 instead of string + try writeFixStr(writer, "StoreSet"); // type as string instead of numeric ID try writeFixStr(writer, "id"); try writer.writeByte(0x01); // positive fixint 1 @@ -76,14 +76,14 @@ test "extractEnvelopeFast: extra fields (lenient)" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreSet", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x11)); try map.mapPut("id", msgpack.Payload.uintToPayload(99)); try map.mapPut("extra", msgpack.Payload.uintToPayload(123)); // unknown field const bytes = try encodePayload(allocator, map); defer allocator.free(bytes); const result = try decode.extractEnvelopeFast(bytes); - try testing.expectEqualStrings("StoreSet", result.type); + try testing.expectEqual(@as(u64, 0x11), result.type); try testing.expectEqual(@as(u64, 99), result.id); } @@ -92,7 +92,7 @@ test "extractStoreSetNamespaceFast: valid" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreSetNamespace", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x10)); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); try map.mapPut("namespace", try msgpack.Payload.strToPayload("my-ns", allocator)); const bytes = try encodePayload(allocator, map); @@ -107,7 +107,7 @@ test "extractStoreSetNamespaceFast: missing namespace" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreSetNamespace", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x10)); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); const bytes = try encodePayload(allocator, map); defer allocator.free(bytes); @@ -120,7 +120,7 @@ test "extractStoreUnsubscribeFast: valid" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreUnsubscribe", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(@intFromEnum(MessageType.store_unsubscribe))); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); try map.mapPut("subId", msgpack.Payload.uintToPayload(12345)); const bytes = try encodePayload(allocator, map); @@ -135,7 +135,7 @@ test "extractStoreUnsubscribeFast: missing subId" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreUnsubscribe", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(@intFromEnum(MessageType.store_unsubscribe))); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); const bytes = try encodePayload(allocator, map); defer allocator.free(bytes); @@ -148,7 +148,7 @@ test "extractStoreLoadMoreFast: valid" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreLoadMore", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x17)); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); try map.mapPut("subId", msgpack.Payload.uintToPayload(99)); try map.mapPut("nextCursor", try msgpack.Payload.strToPayload("abc123", allocator)); @@ -165,7 +165,7 @@ test "extractStoreLoadMoreFast: missing subId" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreLoadMore", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x17)); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); try map.mapPut("nextCursor", try msgpack.Payload.strToPayload("abc123", allocator)); const bytes = try encodePayload(allocator, map); @@ -179,7 +179,7 @@ test "extractStoreLoadMoreFast: missing nextCursor" { var map = msgpack.Payload.mapPayload(allocator); defer map.free(allocator); - try map.mapPut("type", try msgpack.Payload.strToPayload("StoreLoadMore", allocator)); + try map.mapPut("type", msgpack.Payload.uintToPayload(0x17)); try map.mapPut("id", msgpack.Payload.uintToPayload(1)); try map.mapPut("subId", msgpack.Payload.uintToPayload(99)); const bytes = try encodePayload(allocator, map); @@ -209,14 +209,14 @@ test "extractEnvelopeFast: duplicate key, last wins" { const writer = &buf.writer; try writeFixMapHeader(writer, 3); try writeFixStr(writer, "type"); - try writeFixStr(writer, "first"); + try writer.writeByte(0x01); // positive fixint 1 — first value try writeFixStr(writer, "id"); try writer.writeByte(0x01); // positive fixint 1 try writeFixStr(writer, "type"); - try writeFixStr(writer, "second"); // duplicate — last write should win + try writer.writeByte(0x02); // positive fixint 2 — duplicate, last write should win const result = try decode.extractEnvelopeFast(buf.written()); - try testing.expectEqualStrings("second", result.type); + try testing.expectEqual(@as(u64, 2), result.type); } test "extractPresenceSetFast: duplicate data key, last wins" { diff --git a/src/wire/encode.zig b/src/wire/encode.zig index 3c816516..452bb844 100644 --- a/src/wire/encode.zig +++ b/src/wire/encode.zig @@ -9,6 +9,7 @@ const typed = @import("../typed/types.zig"); const PresenceRecord = @import("../presence/record.zig").PresenceRecord; const comptimeEncodeKey = @import("comptime.zig").comptimeEncodeKey; const WireError = @import("errors.zig").WireError; +const MessageType = @import("message_type.zig").MessageType; const Allocator = std.mem.Allocator; const PresenceManager = presence_mgr.PresenceManager; @@ -46,20 +47,22 @@ const Keys = struct { pub const joined_at = comptimeEncodeKey("joinedAt"); }; +// Top-level message names encode as one-byte positive fixints; nested +// operation/event/phase values stay strings. const Values = struct { - pub const ok = comptimeEncodeKey("ok"); - pub const @"error" = comptimeEncodeKey("error"); - pub const connected = comptimeEncodeKey("Connected"); - pub const schema_sync = comptimeEncodeKey("SchemaSync"); - pub const store_delta = comptimeEncodeKey("StoreDelta"); + pub const ok = &[_]u8{@intFromEnum(MessageType.ok)}; + pub const @"error" = &[_]u8{@intFromEnum(MessageType.@"error")}; + pub const connected = &[_]u8{@intFromEnum(MessageType.connected)}; + pub const schema_sync = &[_]u8{@intFromEnum(MessageType.schema_sync)}; + pub const store_delta = &[_]u8{@intFromEnum(MessageType.store_delta)}; pub const op_remove = comptimeEncodeKey("remove"); pub const op_set = comptimeEncodeKey("set"); - pub const write_committed = comptimeEncodeKey("WriteCommitted"); - pub const presence_broadcast = comptimeEncodeKey("PresenceBroadcast"); - pub const shared_state_broadcast = comptimeEncodeKey("SharedStateBroadcast"); - pub const write_error = comptimeEncodeKey("WriteError"); + pub const write_committed = &[_]u8{@intFromEnum(MessageType.write_committed)}; + pub const presence_broadcast = &[_]u8{@intFromEnum(MessageType.presence_broadcast)}; + pub const shared_state_broadcast = &[_]u8{@intFromEnum(MessageType.shared_state_broadcast)}; + pub const write_error = &[_]u8{@intFromEnum(MessageType.write_error)}; pub const phase_write = comptimeEncodeKey("write"); - pub const server_disconnect = comptimeEncodeKey("ServerDisconnect"); + pub const server_disconnect = &[_]u8{@intFromEnum(MessageType.server_disconnect)}; pub const event_join = comptimeEncodeKey("join"); pub const event_update = comptimeEncodeKey("update"); pub const event_leave = comptimeEncodeKey("leave"); diff --git a/src/wire/encode_test.zig b/src/wire/encode_test.zig index fe27e908..d520574f 100644 --- a/src/wire/encode_test.zig +++ b/src/wire/encode_test.zig @@ -10,12 +10,47 @@ const tth = @import("../typed/test_helpers.zig"); const typed = @import("../typed/types.zig"); const wire_encode = @import("encode.zig"); const wire_errors = @import("errors.zig"); +const msgpack_skip = @import("msgpack_skip.zig"); const helpers = @import("test_helpers.zig"); const PendingUserUpdate = @import("../presence/manager.zig").PresenceManager.PendingUserUpdate; +const MessageType = @import("message_type.zig").MessageType; const testing = std.testing; const makeDeltaTestRecord = helpers.makeDeltaTestRecord; +fn expectType(bytes: []const u8, parsed: msgpack.Payload, expected: MessageType) !void { + // Walk the top-level map structurally so only the "type" key at the top + // level is located; nested payloads or truncated matches elsewhere in the + // buffer cannot be mistaken for it. The value must be a one-byte positive + // fixint (0x00-0x7f), never a uint8/16/32/64 marker (0xcc-0xcf). + if (bytes.len == 0) return error.Truncated; + if (bytes[0] < 0x80 or bytes[0] > 0x8f) return error.NotFixMap; + const map_size = bytes[0] & 0x0f; + var idx: usize = 1; + for (0..map_size) |_| { + if (idx >= bytes.len) return error.Truncated; + const key_marker = bytes[idx]; + if (key_marker < 0xa0 or key_marker > 0xbf) return error.MalformedKey; + const key_len: usize = key_marker & 0x1f; + idx += 1; + if (idx + key_len > bytes.len) return error.Truncated; + const key = bytes[idx .. idx + key_len]; + idx += key_len; + if (std.mem.eql(u8, key, "type")) { + if (idx >= bytes.len) return error.Truncated; + const type_byte = bytes[idx]; + try testing.expect(type_byte < 0x80); + + const type_val = (try parsed.mapGet("type")) orelse return error.MissingType; + try testing.expect(type_val == .uint); + try testing.expectEqual(expected, std.enums.fromInt(MessageType, type_val.uint) orelse return error.TestExpectedError); + return; + } + try msgpack_skip.skipValue(bytes, &idx); + } + return error.MissingType; +} + test "encodeSuccess: produces valid MsgPack" { const allocator = std.heap.smp_allocator; const response = try wire_encode.encodeSuccess(allocator, 12345); @@ -26,8 +61,7 @@ test "encodeSuccess: produces valid MsgPack" { defer parsed.free(allocator); try testing.expect(parsed == .map); - const type_val = (try parsed.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("ok", type_val.str.value()); + try expectType(response, parsed, .ok); const id_val = (try parsed.mapGet("id")) orelse return error.MissingId; try testing.expectEqual(@as(u64, 12345), id_val.uint); } @@ -43,8 +77,7 @@ test "encodeError: produces valid MsgPack" { defer parsed.free(allocator); try testing.expect(parsed == .map); - const type_val = (try parsed.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("error", type_val.str.value()); + try expectType(response, parsed, .@"error"); const id_val = (try parsed.mapGet("id")) orelse return error.MissingId; try testing.expectEqual(@as(u64, 999), id_val.uint); const code_val = (try parsed.mapGet("code")) orelse return error.MissingCode; @@ -90,8 +123,7 @@ test "encodeQuery: includes subscription pagination fields" { const parsed = try msgpack.decode(allocator, &reader); defer parsed.free(allocator); - const type_val = (try parsed.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("ok", type_val.str.value()); + try expectType(response, parsed, .ok); const id_val = (try parsed.mapGet("id")) orelse return error.MissingId; try testing.expectEqual(@as(u64, 44), id_val.uint); const sub_id_val = (try parsed.mapGet("subId")) orelse return error.MissingSubId; @@ -193,8 +225,7 @@ test "encodeWriteCommitted: produces valid MsgPack with type and writeId" { const p = try msgpack.decode(allocator, &reader); defer p.free(allocator); - const type_val = (try p.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("WriteCommitted", type_val.str.value()); + try expectType(msg, p, .write_committed); const wid_val = (try p.mapGet("writeId")) orelse return error.MissingWriteId; try testing.expectEqualStrings("0102030405060708090a0b0c0d0e0f10", wid_val.str.value()); } @@ -210,8 +241,7 @@ test "encodeWriteError: 5-field map with phase=write, no batchIndex" { const p = try msgpack.decode(allocator, &reader); defer p.free(allocator); - const type_val = (try p.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("WriteError", type_val.str.value()); + try expectType(msg, p, .write_error); const phase_val = (try p.mapGet("phase")) orelse return error.MissingPhase; try testing.expectEqualStrings("write", phase_val.str.value()); try testing.expect((try p.mapGet("batchIndex")) == null); @@ -251,8 +281,7 @@ test "store_delta_header: decodes to StoreDelta type" { defer p.free(allocator); try testing.expect(p == .map); - const type_val = (try p.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("StoreDelta", type_val.str.value()); + try expectType(buf.written(), p, .store_delta); const sub_id_val = (try p.mapGet("subId")) orelse return error.MissingSubId; try testing.expectEqual(@as(u64, 42), sub_id_val.uint); } @@ -309,8 +338,7 @@ test "encodePresenceBroadcast - update event round-trips with correct map size" defer decoded.free(allocator); try testing.expect(decoded == .map); - const type_val = (try decoded.mapGet("type")) orelse return error.MissingType; - try testing.expectEqualStrings("PresenceBroadcast", type_val.str.value()); + try expectType(bytes, decoded, .presence_broadcast); const sub_id_val = (try decoded.mapGet("subId")) orelse return error.MissingSubId; try testing.expectEqual(@as(u64, 42), sub_id_val.uint); const users_val = (try decoded.mapGet("users")) orelse return error.MissingUsers; diff --git a/src/wire/errors.zig b/src/wire/errors.zig index ca88e8b4..0f70c174 100644 --- a/src/wire/errors.zig +++ b/src/wire/errors.zig @@ -28,6 +28,7 @@ const wire_error_map = std.StaticStringMap(WireError).initComptime(.{ .{ "InvalidFieldName", wireError("INVALID_FIELD_NAME", "Field name contains forbidden characters") }, .{ "InvalidMessageFormat", wireError("INVALID_MESSAGE_FORMAT", "Malformed MessagePack frame") }, .{ "InvalidMessageType", wireError("INVALID_MESSAGE_TYPE", "Only binary MessagePack frames are supported") }, + .{ "UnknownMessageType", wireError("INVALID_MESSAGE_TYPE", "Unknown or invalid message type") }, .{ "MissingRequiredFields", wireError("INVALID_MESSAGE", "Request missing required fields") }, .{ "MissingSubscriptionId", wireError("INVALID_MESSAGE_FORMAT", "Request missing subscription ID") }, .{ "InvalidPayload", wireError("INVALID_MESSAGE", "Invalid payload structure") }, diff --git a/src/wire/message_type.zig b/src/wire/message_type.zig new file mode 100644 index 00000000..49436723 --- /dev/null +++ b/src/wire/message_type.zig @@ -0,0 +1,47 @@ +/// Canonical registry of every top-level wire message type ID (wire-protocol.md). +/// Direction is documented but not part of the encoding; routing uses explicit +/// enum cases, never arithmetic on the high nibble. Never reuse or renumber an +/// assigned ID. All values stay <= 0x7f so MessagePack encodes them as a +/// one-byte positive fixint. +pub const MessageType = enum(u8) { + // Protocol and session (0x0x) + ok = 0x00, // S->C successful correlated response + @"error" = 0x01, // S->C failed correlated or uncorrelated response + connected = 0x02, // S->C connection/session bootstrap + schema_sync = 0x03, // S->C schema dictionary bootstrap + auth_refresh = 0x04, // C->S refresh connection authentication + server_disconnect = 0x05, // S->C structured disconnect reason + + // Store (0x1x) + store_set_namespace = 0x10, // C->S establish store scope + store_set = 0x11, // C->S set a document or field + store_remove = 0x12, // C->S remove a document or field + store_batch = 0x13, // C->S apply a write batch + store_query = 0x14, // C->S execute a one-shot query + store_subscribe = 0x15, // C->S start a live query + store_unsubscribe = 0x16, // C->S stop a live query + store_load_more = 0x17, // C->S page an active query + store_delta = 0x18, // S->C push committed subscription changes + write_committed = 0x19, // S->C confirm a tracked write + write_error = 0x1a, // S->C fail a tracked write + + // Presence (0x2x) + presence_set_namespace = 0x20, // C->S establish presence scope + presence_set = 0x21, // C->S update user presence + presence_set_shared = 0x22, // C->S update shared presence state + presence_subscribe = 0x23, // C->S subscribe to user presence + presence_unsubscribe = 0x24, // C->S unsubscribe from user presence + presence_subscribe_shared = 0x25, // C->S subscribe to shared state + presence_unsubscribe_shared = 0x26, // C->S unsubscribe from shared state + presence_remove = 0x27, // C->S remove user presence + presence_broadcast = 0x28, // S->C push user presence changes + shared_state_broadcast = 0x29, // S->C push shared state changes +}; + +comptime { + for (@typeInfo(MessageType).@"enum".fields) |f| { + if (f.value > 0x7f) { + @compileError("MessageType '" ++ f.name ++ "' exceeds 0x7f and would lose one-byte positive fixint encoding"); + } + } +}