Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions sdk/typescript/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -135,13 +135,13 @@ describe("ZyncBaseClient", () => {
const { decode } = await import("@msgpack/msgpack");
const decoded = decode(lastMsg) as Record<string, unknown>;

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);
Expand All @@ -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<string, unknown>;
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();
Expand Down
21 changes: 7 additions & 14 deletions sdk/typescript/src/connection_wire.test.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectionWireCodec> {
const codec = new ConnectionWireCodec();
await codec.applySchemaSync({
Expand Down Expand Up @@ -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"]],
Expand All @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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([]);
Expand Down
102 changes: 90 additions & 12 deletions sdk/typescript/src/connection_wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,74 @@ import type {
StoreUnsubscribe,
} from "./types.js";

const VALID_INBOUND_TYPES = new Set<InboundMessage["type"]>([
"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<OutboundMessage["type"], number>;

// 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<T extends { id: number }> = Omit<T, "id">;

Expand Down Expand Up @@ -84,6 +141,13 @@ export class ConnectionWireCodec {
wireMessage = this.encodeWireMessage(
debugMessage as unknown as Record<string, unknown>,
);
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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
25 changes: 18 additions & 7 deletions sdk/typescript/src/test-helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>),
type: WireMessageType[type as keyof typeof WireMessageType],
};
}
Comment thread
mstdokumaci marked this conversation as resolved.
}
const encoded = encode(wireMsg);
return encoded.buffer.slice(
encoded.byteOffset,
encoded.byteOffset + encoded.byteLength,
Expand Down Expand Up @@ -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 });
}
Expand Down
6 changes: 6 additions & 0 deletions specs/architecture/adrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions specs/implementation/message-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion specs/implementation/query-grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]],
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions specs/implementation/typescript-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading