Difficulty
10/10 — Expert. Estimated effort: 5–7 days for a senior engineer.
Context
The current protocol uses a single Zod schema (messageSchema in src/validator.js lines 27–44) with no version field. All clients and the server must share the exact same schema. In a production fleet-tracking deployment with 50,000+ mobile devices, app updates roll out over weeks — old app versions (protocol v1) and new versions (protocol v2) must coexist. The server must accept and correctly process messages from all supported protocol versions, and must encode outbound messages in the version each client understands.
The README.md describes "Pluggable serialization — JSON by default; MessagePack or Protocol Buffers can be substituted" (line 68) but there is no serialization abstraction, no version negotiation, and no schema evolution strategy. Adding a required field (e.g., heading to location_update) breaks old clients. Removing a field breaks new clients that expect it.
Problem statement
Design and implement a protocol versioning and schema evolution system that:
-
Protocol version negotiation: On WebSocket handshake, client sends protocol_version in query string (e.g., ?token=...&protocol_version=2). Server responds with server_protocol_version in the first message (or close frame if unsupported). Supported versions: minProtocolVersion to maxProtocolVersion (configurable).
-
Schema registry: Define schemas as versioned objects, not a single discriminated union. Each version has its own Zod schema for inbound messages and a serializer for outbound messages. Schemas are registered at startup: registry.register(v1Schema, v1Serializer), registry.register(v2Schema, v2Serializer).
-
Forward compatibility (server reads old clients): When a v1 client sends a location_update without the new heading field, the server accepts it, fills in a default (e.g., heading: null), and processes it as a v2 message internally.
-
Backward compatibility (server writes for old clients): When broadcasting to a v1 client, the server serializes the internal v2 message using the v1 serializer, which omits heading and any other v2-only fields.
-
Internal canonical representation: The server operates on a single "latest" internal schema (vN). Inbound messages are up-converted (v1→vN, v2→vN, ...). Outbound messages are down-converted (vN→v1, vN→v2, ...) per client's negotiated version.
-
Schema migration functions: For each version pair (v1→v2, v2→v3, ...), provide pure functions upConvert_v1_to_v2(msg), downConvert_v2_to_v1(msg). These are composed for multi-version jumps.
-
Deprecation and sunset: Track usage per protocol version. Log warning when a version hasn't been seen in 30 days. Configurable deprecateAfterDays and sunsetAfterDays — sunset versions are rejected with close code 4002 "Protocol version deprecated".
-
Serialization abstraction: Extract serialization (JSON, MessagePack, Protobuf) into a Serializer interface. The protocol version selects both the schema and the serializer. This enables the "Pluggable serialization" claim in the README.
Current behavior
src/validator.js: single messageSchema discriminated union, no version field.
src/server.js: no protocol version handling. All messages validated against the single schema.
src/room-manager.js: broadcasts raw JSON strings — no per-client serialization.
- No schema registry, no migration functions, no version tracking.
Required behavior
- New module
src/protocol-registry.js exporting ProtocolRegistry class.
ProtocolRegistry constructor: { minVersion: 1, maxVersion: 3, defaultVersion: 3, deprecateAfterDays: 30, sunsetAfterDays: 90 }.
registry.register(version, { inboundSchema: ZodSchema, outboundSerializer: Serializer, upConvert?: (msg) => msg, downConvert?: (msg) => msg }).
registry.getInboundSchema(version) returns Zod schema for that version.
registry.getOutboundSerializer(version) returns Serializer for that version.
registry.upConvert(version, msg) composes up-conversions from version to maxVersion.
registry.downConvert(version, msg) composes down-conversions from maxVersion to version.
Serializer interface: { serialize(obj): string|Buffer, deserialize(data): obj, contentType: string }.
- Built-in
JsonSerializer, MessagePackSerializer (using msgpackr), ProtobufSerializer (using @protobufjs/aspromise + compiled protos).
src/validator.js refactored to use ProtocolRegistry — validateMessage(raw, clientProtocolVersion) returns { ok, data, version } where data is in canonical (maxVersion) form.
src/room-manager.js broadcast() accepts optional serializerMap: Map<clientId, Serializer> — serializes per-client. Or: broadcast() returns canonical messages, and server.js serializes per-client before ws.send().
- Connection handshake in
server.js: read protocol_version from query, validate against registry, store on ws._protocolVersion.
- Metrics:
protocol_version_usage{version="1"} 1234 counter incremented on each message.
Constraints
- Do not modify
auth.js, rate-limiter.js, conn-rate-limiter.js, logger.js, errors.js.
- Do not modify existing test files. New test files required.
- Add
msgpackr and @protobufjs/aspromise to package.json — only new dependencies allowed.
- Zod schemas for each version must be defined in a single file (
src/protocol-schemas.js) for auditability.
- The canonical internal schema (maxVersion) must be the source of truth — all business logic (room manager, geofence engine, storage) operates on canonical messages.
- Up/down conversion functions must be pure, deterministic, and side-effect-free.
- Adding a new protocol version must not require changes to
server.js, room-manager.js, or geofence-engine.js — only register the new schema/converters.
- Latency overhead: version negotiation + up/down conversion must add <0.5ms per message p99.
- Memory: registry holds all versions simultaneously — <10MB for 10 versions.
Acceptance criteria
Out of scope
- Automatic schema generation from TypeScript types or Protobuf definitions.
- Schema validation for outbound messages (trusted path).
- Client-side SDK implementation.
- Protocol version negotiation via WebSocket subprotocols (Sec-WebSocket-Protocol) — query string is sufficient.
- Encryption/framing changes — only payload serialization changes.
Hints and references
- Schema composition pattern: each version's upConvert only knows how to go to the NEXT version.
upConvert_v1_to_v3 = msg => upConvert_v2_to_v3(upConvert_v1_to_v2(msg)). Store converters as Map<fromVersion, Map<toVersion, fn>> or just adjacent pairs and compose at runtime.
- Zod schema for v1 (current):
const v1LocationPayload = z.object({
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
altitude: z.number().optional(),
accuracy: z.number().min(0).optional(),
speed: z.number().min(0).optional(),
timestamp: z.string().datetime().optional(),
});
v2 adds heading: z.number().min(0).max(360).optional(). v3 adds satellites: z.number().int().min(0).optional().
- Serializer interface:
class JsonSerializer {
contentType = "application/json";
serialize(obj) { return JSON.stringify(obj); }
deserialize(data) { return JSON.parse(data.toString()); }
}
- For Protobuf: define
location.proto with message LocationUpdate { double lat = 1; double lon = 2; ... }, compile with pbjs -t static-module -w es6 -o location_pb.js location.proto, then use generated code in ProtobufSerializer.
- Version tracking:
ws._protocolVersion set in server.js connection handler. registry.trackUsage(version) called on each message.
- Deprecation check: periodic timer (daily) iterates
registry.usageCounts, logs warning if lastSeen < now - deprecateAfterDays.
Difficulty
10/10 — Expert. Estimated effort: 5–7 days for a senior engineer.
Context
The current protocol uses a single Zod schema (
messageSchemainsrc/validator.jslines 27–44) with no version field. All clients and the server must share the exact same schema. In a production fleet-tracking deployment with 50,000+ mobile devices, app updates roll out over weeks — old app versions (protocol v1) and new versions (protocol v2) must coexist. The server must accept and correctly process messages from all supported protocol versions, and must encode outbound messages in the version each client understands.The
README.mddescribes "Pluggable serialization — JSON by default; MessagePack or Protocol Buffers can be substituted" (line 68) but there is no serialization abstraction, no version negotiation, and no schema evolution strategy. Adding a required field (e.g.,headingtolocation_update) breaks old clients. Removing a field breaks new clients that expect it.Problem statement
Design and implement a protocol versioning and schema evolution system that:
Protocol version negotiation: On WebSocket handshake, client sends
protocol_versionin query string (e.g.,?token=...&protocol_version=2). Server responds withserver_protocol_versionin the first message (or close frame if unsupported). Supported versions:minProtocolVersiontomaxProtocolVersion(configurable).Schema registry: Define schemas as versioned objects, not a single discriminated union. Each version has its own Zod schema for inbound messages and a serializer for outbound messages. Schemas are registered at startup:
registry.register(v1Schema, v1Serializer),registry.register(v2Schema, v2Serializer).Forward compatibility (server reads old clients): When a v1 client sends a
location_updatewithout the newheadingfield, the server accepts it, fills in a default (e.g.,heading: null), and processes it as a v2 message internally.Backward compatibility (server writes for old clients): When broadcasting to a v1 client, the server serializes the internal v2 message using the v1 serializer, which omits
headingand any other v2-only fields.Internal canonical representation: The server operates on a single "latest" internal schema (vN). Inbound messages are up-converted (v1→vN, v2→vN, ...). Outbound messages are down-converted (vN→v1, vN→v2, ...) per client's negotiated version.
Schema migration functions: For each version pair (v1→v2, v2→v3, ...), provide pure functions
upConvert_v1_to_v2(msg),downConvert_v2_to_v1(msg). These are composed for multi-version jumps.Deprecation and sunset: Track usage per protocol version. Log warning when a version hasn't been seen in 30 days. Configurable
deprecateAfterDaysandsunsetAfterDays— sunset versions are rejected with close code 4002 "Protocol version deprecated".Serialization abstraction: Extract serialization (JSON, MessagePack, Protobuf) into a
Serializerinterface. The protocol version selects both the schema and the serializer. This enables the "Pluggable serialization" claim in the README.Current behavior
src/validator.js: singlemessageSchemadiscriminated union, no version field.src/server.js: no protocol version handling. All messages validated against the single schema.src/room-manager.js: broadcasts raw JSON strings — no per-client serialization.Required behavior
src/protocol-registry.jsexportingProtocolRegistryclass.ProtocolRegistryconstructor:{ minVersion: 1, maxVersion: 3, defaultVersion: 3, deprecateAfterDays: 30, sunsetAfterDays: 90 }.registry.register(version, { inboundSchema: ZodSchema, outboundSerializer: Serializer, upConvert?: (msg) => msg, downConvert?: (msg) => msg }).registry.getInboundSchema(version)returns Zod schema for that version.registry.getOutboundSerializer(version)returns Serializer for that version.registry.upConvert(version, msg)composes up-conversions fromversiontomaxVersion.registry.downConvert(version, msg)composes down-conversions frommaxVersiontoversion.Serializerinterface:{ serialize(obj): string|Buffer, deserialize(data): obj, contentType: string }.JsonSerializer,MessagePackSerializer(usingmsgpackr),ProtobufSerializer(using@protobufjs/aspromise+ compiled protos).src/validator.jsrefactored to useProtocolRegistry—validateMessage(raw, clientProtocolVersion)returns{ ok, data, version }wheredatais in canonical (maxVersion) form.src/room-manager.jsbroadcast()accepts optionalserializerMap: Map<clientId, Serializer>— serializes per-client. Or:broadcast()returns canonical messages, andserver.jsserializes per-client beforews.send().server.js: readprotocol_versionfrom query, validate against registry, store onws._protocolVersion.protocol_version_usage{version="1"} 1234counter incremented on each message.Constraints
auth.js,rate-limiter.js,conn-rate-limiter.js,logger.js,errors.js.msgpackrand@protobufjs/aspromisetopackage.json— only new dependencies allowed.src/protocol-schemas.js) for auditability.server.js,room-manager.js, orgeofence-engine.js— only register the new schema/converters.Acceptance criteria
ProtocolRegistryregisters v1, v2, v3 schemas with up/down convertersheadingfield) connects withprotocol_version=1, sendslocation_update→ server accepts, up-converts to v3 withheading: nullprotocol_version=3, receives broadcast → server down-converts to v3 (no change), serializes with v3 serializerheading), serializes with v1 serializerheadingbut notaccuracy) connects → up-converts v2→v3 (addsaccuracy: null), down-converts v3→v2 (stripsaccuracy)serialize({a:1})→Buffer,deserialize(buf)→{a:1}.protofiles — provide examplelocation.protoand generated JSprotocol_version_usageincrements per message per versionnpm run lintpassestests/protocol-versioning.test.jswith all above scenarios + fuzz testing of up/down conversion round-tripsOut of scope
Hints and references
upConvert_v1_to_v3 = msg => upConvert_v2_to_v3(upConvert_v1_to_v2(msg)). Store converters asMap<fromVersion, Map<toVersion, fn>>or just adjacent pairs and compose at runtime.heading: z.number().min(0).max(360).optional(). v3 addssatellites: z.number().int().min(0).optional().location.protowithmessage LocationUpdate { double lat = 1; double lon = 2; ... }, compile withpbjs -t static-module -w es6 -o location_pb.js location.proto, then use generated code inProtobufSerializer.ws._protocolVersionset inserver.jsconnection handler.registry.trackUsage(version)called on each message.registry.usageCounts, logs warning iflastSeen < now - deprecateAfterDays.