Skip to content

Commit 736ad9e

Browse files
committed
wip
C
1 parent 75d491f commit 736ad9e

File tree

3 files changed

+185
-173
lines changed

3 files changed

+185
-173
lines changed

README.md

Lines changed: 46 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -36,55 +36,70 @@ Also see the [IETF specification](https://www.ietf.org/archive/id/draft-devault-
3636
## Project Goals
3737

3838
**Goals:**
39+
3940
- **Fast** — Self-contained binary encoding, similar to a tuple structure
4041
- **Simple** — Can be reimplemented in under an hour
4142
- **Portable** — Cross-language support with well-defined standardization
4243

4344
**Non-goals:**
45+
4446
- **Data compactness** — That's what gzip is for
45-
- **Provide an RPC layer** — This is trivial to implement yourself based on your specific requirements
47+
- **RPC layer** — This is trivial to implement yourself based on your specific requirements
4648

4749
## Use Cases
4850

49-
- Defining network protocols
50-
- Storing data at rest that needs to be upgradeable:
51-
- Binary data in databases
52-
- File formats
51+
- **Network protocols** — Allow the server to cleanly evolve the protocol version without breaking old clients
52+
- **Data at rest** — Upgrade your file format without breaking old files
53+
54+
## Overview
55+
56+
VBARE works by declaring a schema file for every possible version of the schema, then writing conversion functions between each version of the schema.
57+
58+
**Versions**
5359

54-
## At a Glance
60+
Each message has an associated version, an unsigned 16 bit integer. Each version of the protocol increases monotonically from 1 (e.g. `v1`, `v2`, `v3`). This version is specified in the file name (e.g. `my-schema/v1.bare`).
5561

56-
- Every message has a version associated with it, either:
57-
- Pre-negotiated (via mechanisms like HTTP request query parameters or handshakes)
58-
- Embedded in the message itself
59-
- Applications provide functions to upgrade between protocol versions
60-
- There are no evolution semantics in the schema itself — simply copy and paste the schema to write a new version
62+
**Schema Evolution & Converters**
6163

62-
## Evolution Philosophy
64+
On your server, you manually define code that will convert between versions for both directions (upgrade for deserialization, downgrade for serialization).
6365

64-
- Declare discrete versions with predefined version indexes
65-
- Manual evolutions simplify application logic by putting complex defaults in your application code
66-
- Stop making big breaking v1 to v2 changes — instead, make much smaller changes with more flexibility
67-
- Reshaping structures is important, not just changing types and names
66+
There are no evolution semantics in the schema itself — simply copy and paste from `v1` to `v2` the schema to write a new version.
6867

69-
## Specification
68+
**Servers vs Clients**
7069

71-
### Versions
70+
Servers need to include converters between all versions.
7271

73-
Each schema version is a monotonically incrementing integer. _[TODO: Specify exact integer type]_
72+
Clients only need to inlucde a single version of the schema since the server is responsible for version conversion no matter what version you connect with.
7473

75-
### Embedded Version
74+
**Embedded vs Negotiated Versions**
7675

77-
Embedded version works by inserting an integer at the beginning of the buffer. This integer is used to define which version of the schema is being used. _[TODO: Specify exact integer type]_
76+
Every message has a version associated with it. This version is either:
77+
78+
- Pre-negotiated (via mechanisms like HTTP request query parameters or handshakes)
79+
- For example, you can extract the version from a request like `POST /v3/users`
80+
- Embedded in the message itself in the first 2 bytes of the message (see below)
81+
82+
**Embedded Binary Format**
83+
84+
Embedded version works by inserting an unsigned 16 bit integer at the beginning of the buffer. This integer is used to define which version of the schema is being used.
7885

7986
The layout looks like this:
8087

8188
```
82-
[TODO: Add layout diagram]
89+
+-------------------+-------------------+
90+
| Schema Version | BARE Payload |
91+
| (uint16, 2B) | (variable N B) |
92+
+-------------------+-------------------+
8393
```
8494

85-
### Pre-negotiated Version
95+
## Philosophy
8696

87-
Often, you specify the protocol version outside of the message itself. For example, when making an HTTP request with the version in the path like `POST /v3/users`, we can extract version 3 from the path. In this case, VBARE does not insert a version into the buffer. For this use case, VBARE simply acts as a step function for upgrading or downgrading version data structures.
97+
The core of why VBARE was designed this way is:
98+
99+
- Manual evolutions simplify application logic by putting all complex evolutions & defaults in a conversion code instead of inside your core applciation logic
100+
- Manual evolution forces you to handle edge cases of migrations & breaking changes at the cost of more verbose migration code
101+
- Stop making big breaking v1 to v2 changes — instead, make much smaller schema changes with more flexibility
102+
- Schema evolution frequently requires more than just renaming properties (like Protobuf, Flatbuffers, Cap'n'proto) — more complicated reshaping & fetching data from remote sources is commonly needed
88103

89104
## Implementations
90105

@@ -108,25 +123,10 @@ _Adding an implementation takes less than an hour — it's really that simple._
108123
- [Persisted state](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/actor-persist)
109124
- [File system driver](https://github.com/rivet-dev/rivetkit/tree/b81d9536ba7ccad4449639dd83a770eb7c353617/packages/rivetkit/schemas/file-system-driver)
110125

111-
## Embedded vs Negotiated Version
112-
113-
_[TODO: Add detailed comparison]_
114-
115126
## Comparison with Other Formats
116127

117128
[Read more](./docs/COMPARISON.md)
118129

119-
## Clients vs Servers
120-
121-
- Only servers need to have the evolution steps
122-
- Clients just send their version
123-
124-
## Downsides
125-
126-
- Extensive migration code required
127-
- The older the version, the more migration steps needed (though these migration steps should be effectively free)
128-
- Migration steps are not portable across languages, but only the server needs the migration steps, so this is usually only implemented once
129-
130130
## FAQ
131131

132132
### Why is copying the entire schema for every version better than using decorators for gradual migrations?
@@ -153,6 +153,13 @@ Yes, but after enough pain and suffering from running production APIs, this is w
153153

154154
Most of the time, structures will match exactly, and most languages can provide a 1:1 migration. The most complicated migration steps will be for deeply nested structures that changed, but even that is relatively straightforward.
155155

156+
### What are the downsides?
157+
158+
- More verbose migration code — but this is usually because VBARE forces you to handle all edge cases
159+
- The older the version, the more migration steps need that need to run to bring it to the latest version — though migration steps are usually negligible in cost
160+
- Migration steps are not portable across languages, but only the server needs the migration steps, so this is usually only implemented once
161+
156162
## License
157163

158164
MIT
165+

typescript/examples/basic/src/migrator.ts

Lines changed: 72 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -4,108 +4,110 @@ import * as V3 from "../dist/v3";
44
import { createVersionedDataHandler, type MigrationFn } from "vbare";
55

66
export function migrateV1TodoToV2(todo: V1.Todo): V2.Todo {
7-
return {
8-
id: BigInt(todo.id) as V2.TodoId,
9-
title: todo.title,
10-
status: todo.done ? V2.TodoStatus.Done : V2.TodoStatus.Open,
11-
createdAt: 0n as V2.u64,
12-
tags: [],
13-
} as V2.Todo;
7+
return {
8+
id: BigInt(todo.id) as V2.TodoId,
9+
title: todo.title,
10+
status: todo.done ? V2.TodoStatus.Done : V2.TodoStatus.Open,
11+
createdAt: 0n as V2.u64,
12+
tags: [],
13+
} as V2.Todo;
1414
}
1515

1616
export function migrateV1ToV2App(app: V1.App): V2.App {
17-
const todos = new Map<V2.TodoId, V2.Todo>();
18-
for (const t of app.todos) {
19-
const migrated = migrateV1TodoToV2(t);
20-
todos.set(migrated.id, migrated);
21-
}
22-
return {
23-
todos,
24-
settings: new Map<string, string>(),
25-
} as V2.App;
17+
const todos = new Map<V2.TodoId, V2.Todo>();
18+
for (const t of app.todos) {
19+
const migrated = migrateV1TodoToV2(t);
20+
todos.set(migrated.id, migrated);
21+
}
22+
return {
23+
todos,
24+
settings: new Map<string, string>(),
25+
} as V2.App;
2626
}
2727

2828
export function migrateV2TodoToV3(todo: V2.Todo): V3.Todo {
29-
// Convert list<string> tags to map<TagId, Tag>
30-
const tags = new Map<V3.TagId, V3.Tag>();
31-
let nextTagId = 1 as V3.TagId; // simple incremental ids
32-
for (const name of todo.tags) {
33-
const id = nextTagId as V3.TagId;
34-
tags.set(id, { id, name, color: null });
35-
nextTagId = ((nextTagId as unknown as number) + 1) as V3.TagId;
36-
}
29+
// Convert list<string> tags to map<TagId, Tag>
30+
const tags = new Map<V3.TagId, V3.Tag>();
31+
let nextTagId = 1 as V3.TagId; // simple incremental ids
32+
for (const name of todo.tags) {
33+
const id = nextTagId as V3.TagId;
34+
tags.set(id, { id, name, color: null });
35+
nextTagId = ((nextTagId as unknown as number) + 1) as V3.TagId;
36+
}
3737

38-
return {
39-
id: todo.id as unknown as V3.TodoId,
40-
status: (todo.status as unknown) as V3.TodoStatus,
41-
createdAt: todo.createdAt as unknown as V3.u64,
42-
priority: V3.Priority.Medium,
43-
assignee: { kind: V3.AssigneeKind.None, userId: null, teamId: null },
44-
detail: { title: todo.title, tags },
45-
history: [],
46-
} as V3.Todo;
38+
return {
39+
id: todo.id as unknown as V3.TodoId,
40+
status: todo.status as unknown as V3.TodoStatus,
41+
createdAt: todo.createdAt as unknown as V3.u64,
42+
priority: V3.Priority.Medium,
43+
assignee: { kind: V3.AssigneeKind.None, userId: null, teamId: null },
44+
detail: { title: todo.title, tags },
45+
history: [],
46+
} as V3.Todo;
4747
}
4848

4949
export function migrateV2ToV3App(app: V2.App): V3.App {
50-
const todos = new Map<V3.TodoId, V3.Todo>();
51-
for (const [id, t] of app.todos) {
52-
const migrated = migrateV2TodoToV3(t);
53-
todos.set(id as unknown as V3.TodoId, migrated);
54-
}
50+
const todos = new Map<V3.TodoId, V3.Todo>();
51+
for (const [id, t] of app.todos) {
52+
const migrated = migrateV2TodoToV3(t);
53+
todos.set(id as unknown as V3.TodoId, migrated);
54+
}
5555

56-
return {
57-
todos,
58-
config: { theme: V3.Theme.System, features: new Map<string, boolean>() },
59-
boards: new Map<V3.BoardId, V3.Board>(),
60-
} as V3.App;
56+
return {
57+
todos,
58+
config: { theme: V3.Theme.System, features: new Map<string, boolean>() },
59+
boards: new Map<V3.BoardId, V3.Board>(),
60+
} as V3.App;
6161
}
6262

6363
// Set up versioned migration handler using the vbare package.
6464
export const CURRENT_VERSION = 3 as const;
6565

6666
// Map migrations as fromVersion -> (data) => nextVersionData
6767
export const migrations = new Map<number, MigrationFn<any, any>>([
68-
[1, (data: V1.App) => migrateV1ToV2App(data)],
69-
[2, (data: V2.App) => migrateV2ToV3App(data)],
68+
[1, (data: V1.App) => migrateV1ToV2App(data)],
69+
[2, (data: V2.App) => migrateV2ToV3App(data)],
7070
]);
7171

7272
// Handlers per starting version that use the actual BARE encode/decode.
7373
// Note: We only rely on deserialize() for migration sequencing; serializeVersion is
7474
// set to the latest version's encoder for completeness.
7575
const APP_FROM_V1 = createVersionedDataHandler<V3.App>({
76-
currentVersion: CURRENT_VERSION,
77-
migrations,
78-
serializeVersion: (data: V3.App) => V3.encodeApp(data),
79-
deserializeVersion: (bytes: Uint8Array) => V1.decodeApp(bytes) as unknown as V3.App,
76+
currentVersion: CURRENT_VERSION,
77+
migrations,
78+
serializeVersion: (data: V3.App) => V3.encodeApp(data),
79+
deserializeVersion: (bytes: Uint8Array) =>
80+
V1.decodeApp(bytes) as unknown as V3.App,
8081
});
8182

8283
const APP_FROM_V2 = createVersionedDataHandler<V3.App>({
83-
currentVersion: CURRENT_VERSION,
84-
migrations,
85-
serializeVersion: (data: V3.App) => V3.encodeApp(data),
86-
deserializeVersion: (bytes: Uint8Array) => V2.decodeApp(bytes) as unknown as V3.App,
84+
currentVersion: CURRENT_VERSION,
85+
migrations,
86+
serializeVersion: (data: V3.App) => V3.encodeApp(data),
87+
deserializeVersion: (bytes: Uint8Array) =>
88+
V2.decodeApp(bytes) as unknown as V3.App,
8789
});
8890

8991
const APP_FROM_V3 = createVersionedDataHandler<V3.App>({
90-
currentVersion: CURRENT_VERSION,
91-
migrations,
92-
serializeVersion: (data: V3.App) => V3.encodeApp(data),
93-
deserializeVersion: (bytes: Uint8Array) => V3.decodeApp(bytes),
92+
currentVersion: CURRENT_VERSION,
93+
migrations,
94+
serializeVersion: (data: V3.App) => V3.encodeApp(data),
95+
deserializeVersion: (bytes: Uint8Array) => V3.decodeApp(bytes),
9496
});
9597

9698
export function migrateToLatest(
97-
app: V1.App | V2.App | V3.App,
98-
fromVersion: 1 | 2 | 3,
99+
app: V1.App | V2.App | V3.App,
100+
fromVersion: 1 | 2 | 3,
99101
): V3.App {
100-
if (fromVersion === 1) {
101-
const bytes = V1.encodeApp(app as V1.App);
102-
return APP_FROM_V1.deserialize(bytes, 1);
103-
}
104-
if (fromVersion === 2) {
105-
const bytes = V2.encodeApp(app as V2.App);
106-
return APP_FROM_V2.deserialize(bytes, 2);
107-
}
108-
// v3 -> v3
109-
const bytes = V3.encodeApp(app as V3.App);
110-
return APP_FROM_V3.deserialize(bytes, 3);
102+
if (fromVersion === 1) {
103+
const bytes = V1.encodeApp(app as V1.App);
104+
return APP_FROM_V1.deserialize(bytes, 1);
105+
}
106+
if (fromVersion === 2) {
107+
const bytes = V2.encodeApp(app as V2.App);
108+
return APP_FROM_V2.deserialize(bytes, 2);
109+
}
110+
// v3 -> v3
111+
const bytes = V3.encodeApp(app as V3.App);
112+
return APP_FROM_V3.deserialize(bytes, 3);
111113
}

0 commit comments

Comments
 (0)