Skip to content

Commit 0b47aa6

Browse files
committed
refactor: migrate to modern stack
1 parent 0b8cadc commit 0b47aa6

File tree

12 files changed

+2348
-1396
lines changed

12 files changed

+2348
-1396
lines changed

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: pnpm/action-setup@v4
16+
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: '20'
20+
cache: 'pnpm'
21+
22+
- name: Install dependencies
23+
run: pnpm install --frozen-lockfile
24+
25+
- name: Build
26+
run: pnpm build
27+
28+
- name: Typecheck
29+
run: pnpm typecheck
30+
31+
- name: Test
32+
run: pnpm test

.github/workflows/preview.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Preview Package
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
push:
7+
branches:
8+
- master
9+
10+
jobs:
11+
preview:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
pull-requests: write
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: pnpm/action-setup@v4
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: '20'
24+
cache: 'pnpm'
25+
26+
- name: Install dependencies
27+
run: pnpm install --frozen-lockfile
28+
29+
- name: Build
30+
run: pnpm build
31+
32+
- name: Publish preview
33+
run: pnpx pkg-pr-new publish

.github/workflows/publish.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Publish to npm
2+
3+
on:
4+
release:
5+
types: [created]
6+
7+
jobs:
8+
publish:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: write
12+
id-token: write
13+
steps:
14+
- uses: actions/checkout@v4
15+
with:
16+
token: ${{ secrets.GITHUB_TOKEN }}
17+
18+
- uses: pnpm/action-setup@v4
19+
20+
- uses: actions/setup-node@v4
21+
with:
22+
node-version: '20'
23+
cache: 'pnpm'
24+
registry-url: 'https://registry.npmjs.org'
25+
26+
- name: Extract version from tag
27+
id: version
28+
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
29+
30+
- name: Update package.json version
31+
run: |
32+
pnpm version ${{ steps.version.outputs.VERSION }} --no-git-tag-version --allow-same-version
33+
34+
- name: Commit version bump
35+
run: |
36+
git config user.name "github-actions[bot]"
37+
git config user.email "github-actions[bot]@users.noreply.github.com"
38+
git add package.json
39+
git commit -m "chore: bump version to ${{ steps.version.outputs.VERSION }}" || echo "No changes to commit"
40+
git push origin HEAD:${{ github.event.release.target_commitish }}
41+
42+
- name: Install dependencies
43+
run: pnpm install --frozen-lockfile
44+
45+
- name: Build
46+
run: pnpm build
47+
48+
- name: Publish to npm
49+
run: pnpm publish --access public --no-git-checks
50+
env:
51+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.travis.yml

Lines changed: 0 additions & 4 deletions
This file was deleted.

lib/index.test.ts

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { describe, it, expect } from "vitest";
2+
import { TupleItem } from "./index.js";
3+
import * as tuple from "./index.js";
4+
5+
// Helper functions
6+
function hexToBytes(hex: string): Uint8Array {
7+
const bytes = new Uint8Array(hex.length / 2);
8+
for (let i = 0; i < bytes.length; i++) {
9+
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
10+
}
11+
return bytes;
12+
}
13+
14+
function asciiToBytes(str: string): Uint8Array {
15+
const bytes = new Uint8Array(str.length);
16+
for (let i = 0; i < str.length; i++) {
17+
bytes[i] = str.charCodeAt(i) & 0xff;
18+
}
19+
return bytes;
20+
}
21+
22+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
23+
if (a.length !== b.length) return false;
24+
for (let i = 0; i < a.length; i++) {
25+
if (a[i] !== b[i]) return false;
26+
}
27+
return true;
28+
}
29+
30+
const floatBytes = (x: number): Uint8Array => {
31+
const result = new Uint8Array(4);
32+
const view = new DataView(result.buffer);
33+
view.setFloat32(0, x, false);
34+
return result;
35+
};
36+
37+
describe("tuple", () => {
38+
const assertRoundTripBytes = (orig: Uint8Array, strict: boolean = false) => {
39+
const val = tuple.unpack(orig, strict)[0] as TupleItem;
40+
const packed = tuple.pack([val]);
41+
expect(bytesEqual(packed, orig)).toBe(true);
42+
};
43+
44+
const assertEncodesAs = (
45+
value: TupleItem,
46+
data: Uint8Array | string | number[]
47+
) => {
48+
const encoded = tuple.pack([value]);
49+
let bytes: Uint8Array;
50+
if (data instanceof Uint8Array) {
51+
bytes = data;
52+
} else if (typeof data === "string") {
53+
bytes = asciiToBytes(data);
54+
} else {
55+
bytes = new Uint8Array(data);
56+
}
57+
expect(bytesEqual(encoded, bytes)).toBe(true);
58+
59+
// Check that numbered int -> bigint has no effect on encoded output.
60+
if (typeof value === "number" && Number.isInteger(value)) {
61+
const encoded2 = tuple.pack([BigInt(value)]);
62+
expect(bytesEqual(encoded2, bytes)).toBe(true);
63+
}
64+
65+
const decoded = tuple.unpack(encoded);
66+
// Handle NaN case
67+
if (typeof value === "number" && isNaN(value as number)) {
68+
expect(isNaN(decoded[0] as number)).toBe(true);
69+
} else {
70+
expect(decoded).toEqual([value]);
71+
}
72+
};
73+
74+
describe("roundtrips expected values", () => {
75+
const assertRoundTrip = (val: TupleItem, strict: boolean = false) =>
76+
it(typeof val === "bigint" ? `${val}n` : JSON.stringify(val), () => {
77+
const packed = tuple.pack([val]);
78+
if (!Array.isArray(val)) {
79+
const packedRaw = tuple.pack(val);
80+
expect(bytesEqual(packed, packedRaw)).toBe(true);
81+
}
82+
83+
const unpacked = tuple.unpack(packed, strict)[0];
84+
expect(unpacked).toEqual(val);
85+
86+
// Check that numbered int -> bigint has no effect on encoded output.
87+
if (typeof val === "number" && Number.isSafeInteger(val)) {
88+
const packed2 = tuple.pack([BigInt(val)]);
89+
expect(bytesEqual(packed2, packed)).toBe(true);
90+
}
91+
});
92+
93+
const data = ["hi", null, "\u{1F47E}", 321, 0, -100];
94+
assertRoundTrip(data);
95+
96+
assertRoundTrip(0.75);
97+
assertRoundTrip(BigInt("12341234123412341234"));
98+
assertRoundTrip(BigInt("-12341234123412341234"));
99+
// This is a 230-byte number.
100+
assertRoundTrip(
101+
BigInt(
102+
"-25590177972831273370257770873989184378968622566250081269954042898433662370031719667203350964691861270342483438771650473254888465056727193237251538125410415669915254450681177531095961100324827856201637570233530243472179104778438087224216101130117197440338289639344554123323256917100155477022101041486896307697849188764471017736123757252639610971416446462793453318430443692386602432742205109597722547740645132051736817382305630442267915356539282325495631800602625500347541220888685499019574296692446821077084703673346449833537043377925515781584311410015431"
103+
)
104+
);
105+
106+
assertRoundTrip(Number.MAX_SAFE_INTEGER);
107+
assertRoundTrip(Number.MAX_SAFE_INTEGER + 1); // Encoded as a double.
108+
assertRoundTrip(BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)); // Encoded as an integer
109+
110+
assertRoundTrip(-Number.MAX_SAFE_INTEGER);
111+
assertRoundTrip(-Number.MAX_SAFE_INTEGER - 1);
112+
assertRoundTrip(-BigInt(Number.MAX_SAFE_INTEGER) - BigInt(1));
113+
114+
assertRoundTrip(
115+
{ type: "float", value: 0.5, rawEncoding: floatBytes(0.5) },
116+
true
117+
);
118+
});
119+
120+
it("handles negative bigint numbers correctly", () => {
121+
// regression
122+
const bytes = asciiToBytes(
123+
"\x0b\x19\xac\x9b\xcdf;\xe9\x08\xa9\xfc\x17\x06hE^4}" +
124+
"\xc7\xbc\xfe\xa9\x9997\x90\xdb\xdf\tD\xc4\xd1\xfc\xdc\x934\xf5\xa8\xe23>E" +
125+
"\x94\xf8\xd2)\x1fz\x94QO\xf0\x01\xe6pza,\x81\x05us=\xd6\xc7\xa3\xaa\xd1R" +
126+
"\xf8j\xdd\xb0\xaa\x03lW\xb7\xd3\xf5\x84\x7ff\xbb\xb31\xc8\xcf\xb7gpg\x18" +
127+
"\x11\xa7\x9b6\xaa\xd7\xe3\x82\x8dM\xf7\xf3\xda\xe8\xac\xeb\xb8\xfd\xce\xae" +
128+
"\xf2[Z\r?\xd7@\x03\xf8c\xdb\xd6q\xac\xfe\xfe\xcb\xfa\x17\xde\x08\xb9\xe5K" +
129+
"\x81\xad\xdf\xe7\xd9\x10\x12\xb0L\xa1\x15c\x0e\xc3\xda\xd2;\xbc.\xcdo\xc9" +
130+
"1/-\xf6\xf1\xfajW\xd1\xa5\xaa\xbf\xda\xfel\xde\x84\xe9~\xc5\xe7\xe7}\xe1" +
131+
"\x96\xc3\x91\xaf\x8eQQz\xd4\xff\x94i\xc3\xe8\xc0\xb3\xa0\"\xd6\xe26\xee\x0b" +
132+
'\x05&\x9d\x95\x19)q}\x02$4\xa8\xf61\x07i\x89\xa3&\x82\x89\xaf=\x17C8'
133+
);
134+
135+
const expected = BigInt(
136+
"-25590177972831273370257770873989184378968622566250081" +
137+
"26995404289843366237003171966720335096469186127034248343877165047325488846" +
138+
"50567271932372515381254104156699152544506811775310959611003248278562016375" +
139+
"70233530243472179104778438087224216101130117197440338289639344554123323256" +
140+
"91710015547702210104148689630769784918876447101773612375725263961097141644" +
141+
"64627934533184304436923866024327422051095977225477406451320517368173823056" +
142+
"30442267915356539282325495631800602625500347541220888685499019574296692446" +
143+
"821077084703673346449833537043377925515781584311410015431"
144+
);
145+
146+
assertEncodesAs(expected, bytes);
147+
});
148+
149+
it("handles null and undefined as expected", () => {
150+
expect(bytesEqual(tuple.pack(null), new Uint8Array([0]))).toBe(true);
151+
expect(bytesEqual(tuple.pack(undefined), new Uint8Array([]))).toBe(true);
152+
expect(bytesEqual(tuple.pack([]), new Uint8Array([]))).toBe(true);
153+
154+
expect(() => {
155+
tuple.pack([undefined as unknown as TupleItem]);
156+
}).toThrow();
157+
});
158+
159+
it("implements bigint encoding in a way that matches the java bindings", () => {
160+
// These are ported from here:
161+
// https://github.com/apple/foundationdb/blob/becc01923a30c1bc2ba158b293dbb38de7585c72/bindings/java/src/test/com/apple/foundationdb/test/TupleTest.java#L109-L122
162+
assertEncodesAs(BigInt("0x7fffffffffffffff"), [
163+
0x1c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
164+
]);
165+
assertEncodesAs(BigInt("0x8000000000000000"), [
166+
0x1c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
167+
]);
168+
assertEncodesAs(BigInt("0xffffffffffffffff"), [
169+
0x1c, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
170+
]);
171+
assertEncodesAs(BigInt("0x10000000000000000"), [
172+
0x1d, 0x09, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
173+
]);
174+
assertEncodesAs(-0xffffffff, [0x10, 0x00, 0x00, 0x00, 0x00]);
175+
assertEncodesAs(-BigInt("0x7ffffffffffffffe"), [
176+
0x0c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
177+
]);
178+
assertEncodesAs(-BigInt("0x7fffffffffffffff"), [
179+
0x0c, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
180+
]);
181+
assertEncodesAs(-BigInt("0x8000000000000000"), [
182+
0x0c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
183+
]);
184+
assertEncodesAs(-BigInt("0x8000000000000001"), [
185+
0x0c, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff - 1,
186+
]);
187+
assertEncodesAs(-BigInt("0xffffffffffffffff"), [
188+
0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
189+
]);
190+
});
191+
192+
it("throws when asked to encode a bigint larger than 255 bytes", () => {
193+
tuple.pack(BigInt(256) ** BigInt(254)); // should be ok
194+
195+
// What about 255? That currently throws and I'm not sure if that behaviour is correct or not. TODO.
196+
197+
expect(() => {
198+
tuple.pack(BigInt(256) ** BigInt(256));
199+
}).toThrow();
200+
});
201+
202+
it("preserves encoding of values in strict mode", () => {
203+
// There's a few ways NaN is encoded.
204+
assertRoundTripBytes(hexToBytes("210007ffffffffffff"), true); // double
205+
assertRoundTripBytes(hexToBytes("21fff8000000000000"), true);
206+
assertRoundTripBytes(hexToBytes("20ffc00000"), true); // TODO:
207+
assertRoundTripBytes(hexToBytes("20003fffff"), true);
208+
// Do any other nan encodings exist?
209+
210+
// Also any regular integers should be preserved.
211+
assertRoundTripBytes(hexToBytes("2080000000"), true);
212+
assertRoundTripBytes(hexToBytes("218000000000000000"), true);
213+
});
214+
215+
it("preserves encoding of exotic numbers", () => {
216+
// I'm sure there's lots more I'm missing here.
217+
assertRoundTripBytes(hexToBytes("217fffffffffffffff"), true); // This is -0.
218+
});
219+
220+
it("stalls on invalid input", () => {
221+
tuple.unpack(
222+
tuple.unpack(
223+
asciiToBytes(
224+
"\x01\x01tester_output\x00\xff\x01workspace\x01\x00"
225+
)
226+
)[0] as Uint8Array
227+
);
228+
});
229+
230+
describe("Conformance tests", () => {
231+
// These are from the examples here:
232+
// https://github.com/apple/foundationdb/blob/master/design/tuple.md
233+
234+
const testConformance = (
235+
name: string,
236+
value: TupleItem,
237+
bytes: Uint8Array | string
238+
) => {
239+
it(name, () => assertEncodesAs(value, bytes));
240+
};
241+
242+
testConformance("null", null, "\x00");
243+
testConformance("false", false, "\x26");
244+
testConformance("true", true, "\x27");
245+
testConformance(
246+
"bytes",
247+
asciiToBytes("foo\x00bar"),
248+
"\x01foo\x00\xffbar\x00"
249+
);
250+
testConformance("string", "F\u00d4O\u0000bar", "\x02F\xc3\x94O\x00\xffbar\x00");
251+
// TODO: Nested tuple
252+
testConformance(
253+
"nested tuples",
254+
[asciiToBytes("foo\x00bar"), null, []],
255+
"\x05\x01foo\x00\xffbar\x00\x00\xff\x05\x00\x00"
256+
);
257+
testConformance("zero", 0, "\x14"); // zero
258+
testConformance("integer", -5551212, "\x11\xabK\x93"); // integer
259+
// testConformance(-42.
260+
// testConformance('nan float', NaN, hexToBytes('0007ffffffffffff')
261+
testConformance("nan double", NaN, hexToBytes("21fff8000000000000"));
262+
testConformance(
263+
"bound version stamp",
264+
{ type: "versionstamp", value: new Uint8Array(12).fill(0xe3) },
265+
hexToBytes("33e3e3e3e3e3e3e3e3e3e3e3e3")
266+
);
267+
// TODO: unbound versionstamps
268+
});
269+
});

0 commit comments

Comments
 (0)