Skip to content

Commit e1d9c03

Browse files
committed
feat(json-crdt): 🎸 implement file encoding and decoding
1 parent ea2693a commit e1d9c03

File tree

5 files changed

+141
-12
lines changed

5 files changed

+141
-12
lines changed

src/json-crdt/file/File.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {Model} from '../model';
22
import {PatchLog} from './PatchLog';
33
import {FileModelEncoding} from './constants';
44
import {Encoder as SidecarEncoder} from '../codec/sidecar/binary/Encoder';
5+
import {Decoder as SidecarDecoder} from '../codec/sidecar/binary/Decoder';
56
import {Encoder as StructuralEncoderCompact} from '../codec/structural/compact/Encoder';
67
import {Encoder as StructuralEncoderVerbose} from '../codec/structural/verbose/Encoder';
78
import {encode as encodeCompact} from '../../json-crdt-patch/codec/compact/encode';
@@ -10,15 +11,55 @@ import {Writer} from '../../util/buffers/Writer';
1011
import {CborEncoder} from '../../json-pack/cbor/CborEncoder';
1112
import {JsonEncoder} from '../../json-pack/json/JsonEncoder';
1213
import {printTree} from '../../util/print/printTree';
14+
import {decodeModel, decodeNdjsonComponents, decodePatch, decodeSeqCborComponents} from './util';
1315
import type * as types from './types';
1416
import type {Printable} from '../../util/print/types';
1517

1618
export class File implements Printable {
19+
public static unserialize(components: types.FileReadSequence): File {
20+
const [view, metadata, model, history, ...frontier] = components;
21+
const modelFormat = metadata[1];
22+
let decodedModel: Model<any> | null = null;
23+
if (model && modelFormat !== FileModelEncoding.None) {
24+
const isSidecar = modelFormat === FileModelEncoding.SidecarBinary;
25+
if (isSidecar) {
26+
const decoder = new SidecarDecoder();
27+
if (!(model instanceof Uint8Array)) throw new Error('NOT_BLOB');
28+
decodedModel = decoder.decode(view, model);
29+
} else {
30+
decodedModel = decodeModel(model);
31+
}
32+
}
33+
let log: PatchLog | null = null;
34+
if (history) {
35+
const [start, patches] = history;
36+
if (start) {
37+
const startModel = decodeModel(start);
38+
log = new PatchLog(startModel);
39+
for (const patch of patches) log.push(decodePatch(patch));
40+
}
41+
}
42+
if (!log) throw new Error('NO_HISTORY');
43+
if (!decodedModel) decodedModel = log.replayToEnd();
44+
const file = new File(decodedModel, log);
45+
return file;
46+
}
47+
48+
public static fromNdjson(blob: Uint8Array): File {
49+
const components = decodeNdjsonComponents(blob);
50+
return File.unserialize(components as types.FileReadSequence);
51+
}
52+
53+
public static fromSeqCbor(blob: Uint8Array): File {
54+
const components = decodeSeqCborComponents(blob);
55+
return File.unserialize(components as types.FileReadSequence);
56+
}
57+
1758
public static fromModel(model: Model<any>): File {
1859
return new File(model, PatchLog.fromModel(model));
1960
}
2061

21-
constructor(public readonly model: Model, public readonly history: PatchLog) {}
62+
constructor(public readonly model: Model, public readonly log: PatchLog) {}
2263

2364
public serialize(params: types.FileSerializeParams = {}): types.FileWriteSequence {
2465
const view = this.model.view();
@@ -48,33 +89,41 @@ export class File implements Printable {
4889
model = new StructuralEncoderVerbose().encode(this.model);
4990
break;
5091
}
92+
case 'none': {
93+
metadata[1] = FileModelEncoding.None;
94+
model = null;
95+
break;
96+
}
5197
default:
5298
throw new Error(`Invalid model format: ${modelFormat}`);
5399
}
54100
const history: types.FileWriteSequenceHistory = [null, []];
55101
const patchFormat = params.history ?? 'binary';
56102
switch (patchFormat) {
57103
case 'binary': {
58-
history[0] = this.history.start.toBinary();
59-
this.history.patches.forEach(({v}) => {
104+
history[0] = this.log.start.toBinary();
105+
this.log.patches.forEach(({v}) => {
60106
history[1].push(v.toBinary());
61107
});
62108
break;
63109
}
64110
case 'compact': {
65-
history[0] = new StructuralEncoderCompact().encode(this.history.start);
66-
this.history.patches.forEach(({v}) => {
111+
history[0] = new StructuralEncoderCompact().encode(this.log.start);
112+
this.log.patches.forEach(({v}) => {
67113
history[1].push(encodeCompact(v));
68114
});
69115
break;
70116
}
71117
case 'verbose': {
72-
history[0] = new StructuralEncoderVerbose().encode(this.history.start);
73-
this.history.patches.forEach(({v}) => {
118+
history[0] = new StructuralEncoderVerbose().encode(this.log.start);
119+
this.log.patches.forEach(({v}) => {
74120
history[1].push(encodeVerbose(v));
75121
});
76122
break;
77123
}
124+
case 'none': {
125+
break;
126+
}
78127
default:
79128
throw new Error(`Invalid history format: ${patchFormat}`);
80129
}
@@ -104,6 +153,6 @@ export class File implements Printable {
104153
// ---------------------------------------------------------------- Printable
105154

106155
public toString(tab?: string) {
107-
return `file` + printTree(tab, [(tab) => this.model.toString(tab), () => '', (tab) => this.history.toString(tab)]);
156+
return `file` + printTree(tab, [(tab) => this.model.toString(tab), () => '', (tab) => this.log.toString(tab)]);
108157
}
109158
}

src/json-crdt/file/PatchLog.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ export class PatchLog implements Printable {
2525
this.patches.set(id, patch);
2626
}
2727

28+
public replayToEnd(): Model {
29+
const model = this.start.clone();
30+
this.patches.forEach(({v}) => model.applyPatch(v));
31+
return model;
32+
}
33+
2834
// ---------------------------------------------------------------- Printable
2935

3036
public toString(tab?: string) {

src/json-crdt/file/__tests__/File.spec.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ test('can create File from new model', () => {
1818
}),
1919
);
2020
const file = File.fromModel(model);
21-
expect(file.history.start.view()).toBe(undefined);
21+
expect(file.log.start.view()).toBe(undefined);
2222
expect(file.model.view()).toEqual({
2323
foo: 'bar',
2424
});
25-
expect(file.history.start.clock.sid).toBe(file.model.clock.sid);
25+
expect(file.log.start.clock.sid).toBe(file.model.clock.sid);
2626
});
2727

2828
test.todo('patches are flushed and stored in memory');
@@ -46,4 +46,26 @@ describe('.toBinary()', () => {
4646
expect(view).toEqual({foo: 'bar'});
4747
});
4848
});
49+
50+
describe('can decode from blob', () => {
51+
test('.ndjson', () => {
52+
const {file} = setup({foo: 'bar'});
53+
const blob = file.toBinary({format: 'ndjson', model: 'compact', history: 'compact'});
54+
const file2 = File.fromNdjson(blob);
55+
expect(file2.model.view()).toEqual({foo: 'bar'});
56+
expect(file2.model !== file.model).toBe(true);
57+
expect(file.log.start.view()).toEqual(undefined);
58+
expect(file.log.replayToEnd().view()).toEqual({foo: 'bar'});
59+
});
60+
61+
test('.seq.cbor', () => {
62+
const {file} = setup({foo: 'bar'});
63+
const blob = file.toBinary({format: 'seq.cbor', model: 'binary', history: 'binary'});
64+
const file2 = File.fromSeqCbor(blob);
65+
expect(file2.model.view()).toEqual({foo: 'bar'});
66+
expect(file2.model !== file.model).toBe(true);
67+
expect(file.log.start.view()).toEqual(undefined);
68+
expect(file.log.replayToEnd().view()).toEqual({foo: 'bar'});
69+
});
70+
});
4971
});

src/json-crdt/file/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export type FileReadSequence = [...FileWriteSequence, ...frontier: Array<Uint8Ar
1515

1616
export interface FileSerializeParams {
1717
noView?: boolean;
18-
model?: 'sidecar' | 'binary' | 'compact' | 'verbose';
19-
history?: 'binary' | 'compact' | 'verbose';
18+
model?: 'sidecar' | 'binary' | 'compact' | 'verbose' | 'none';
19+
history?: 'binary' | 'compact' | 'verbose' | 'none';
2020
}
2121

2222
export interface FileEncodingParams extends FileSerializeParams {

src/json-crdt/file/util.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {JsonDecoder} from '../../json-pack/json/JsonDecoder';
2+
import {CborDecoder} from '../../json-pack/cbor/CborDecoder';
3+
import {Model} from '../model';
4+
import {Decoder as StructuralDecoderCompact} from '../codec/structural/compact/Decoder';
5+
import {Decoder as StructuralDecoderVerbose} from '../codec/structural/verbose/Decoder';
6+
import {decode as decodeCompact} from '../../json-crdt-patch/codec/compact/decode';
7+
import {decode as decodeVerbose} from '../../json-crdt-patch/codec/verbose/decode';
8+
import {Patch} from '../../json-crdt-patch';
9+
import type {JsonCrdtCompactDocument} from '../codec/structural/compact';
10+
import type {JsonCrdtVerboseDocument} from '../codec/structural/verbose';
11+
import type {CompactCodecPatch} from '../../json-crdt-patch/codec/compact';
12+
import type {JsonCodecPatch} from '../../json-crdt-patch/codec/verbose';
13+
14+
export const decodeNdjsonComponents = (blob: Uint8Array): unknown[] => {
15+
const decoder = new JsonDecoder();
16+
const reader = decoder.reader;
17+
reader.reset(blob);
18+
const components: unknown[] = [];
19+
while (reader.x < blob.length) {
20+
components.push(decoder.readAny());
21+
const nl = reader.u8();
22+
if (nl !== '\n'.charCodeAt(0)) throw new Error('NDJSON_UNEXPECTED_NEWLINE');
23+
}
24+
return components;
25+
};
26+
27+
export const decodeSeqCborComponents = (blob: Uint8Array): unknown[] => {
28+
const decoder = new CborDecoder();
29+
const reader = decoder.reader;
30+
reader.reset(blob);
31+
const components: unknown[] = [];
32+
while (reader.x < blob.length) components.push(decoder.val());
33+
return components;
34+
};
35+
36+
export const decodeModel = (serialized: unknown): Model => {
37+
if (!serialized) throw new Error('NO_MODEL');
38+
if (serialized instanceof Uint8Array) return Model.fromBinary(serialized);
39+
if (Array.isArray(serialized))
40+
return new StructuralDecoderCompact().decode(<JsonCrdtCompactDocument>serialized);
41+
if (typeof serialized === 'object')
42+
return new StructuralDecoderVerbose().decode(<JsonCrdtVerboseDocument>serialized);
43+
throw new Error('UNKNOWN_MODEL');
44+
};
45+
46+
export const decodePatch = (serialized: unknown): Patch => {
47+
if (!serialized) throw new Error('NO_MODEL');
48+
if (serialized instanceof Uint8Array) return Patch.fromBinary(serialized);
49+
if (Array.isArray(serialized)) return decodeCompact(<CompactCodecPatch>serialized);
50+
if (typeof serialized === 'object') return decodeVerbose(<JsonCodecPatch>serialized);
51+
throw new Error('UNKNOWN_MODEL');
52+
};

0 commit comments

Comments
 (0)