Skip to content

Commit 28ad93d

Browse files
committed
feat: decorator support
1 parent f7de583 commit 28ad93d

9 files changed

+283
-63
lines changed

biome.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
"linter": {
2121
"enabled": true,
2222
"rules": {
23-
"recommended": true
23+
"recommended": true,
24+
"suspicious": {
25+
"noExplicitAny": "off"
26+
}
2427
}
2528
},
2629
"javascript": {

src/decorator.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { DurableObject } from "cloudflare:workers";
2+
import { type SnapshotPolicy, state } from "./index.js";
3+
import { unreachable } from "./util.js";
4+
5+
type FieldDecoratorFactoryReturn<T> = (
6+
value: T,
7+
metadata: { kind: string; name: string },
8+
) => FieldDecoratorReturn<T>;
9+
10+
type FieldDecoratorReturn<T> = (this: DurableObject, initialValue: T) => T;
11+
12+
type DiffableArgs =
13+
| []
14+
| [DiffableOpts]
15+
| [name?: string]
16+
| [_: any, { kind: string; name: string }];
17+
18+
type DiffableOpts = {
19+
name?: string;
20+
snapshotPolicy?: SnapshotPolicy;
21+
};
22+
23+
export function diffable(
24+
_: any,
25+
{ kind, name }: { kind: string; name: string },
26+
): FieldDecoratorReturn<any>;
27+
export function diffable(name?: string): FieldDecoratorFactoryReturn<any>;
28+
export function diffable(
29+
options: DiffableOpts,
30+
): FieldDecoratorFactoryReturn<any>;
31+
export function diffable():
32+
| FieldDecoratorReturn<any>
33+
| FieldDecoratorFactoryReturn<any> {
34+
// biome-ignore lint/style/noArguments: <explanation>
35+
const args = arguments as unknown as DiffableArgs;
36+
const fn =
37+
(opts: DiffableOpts = {}) =>
38+
(_: any, { kind, name: fieldName }: { kind: string; name: string }) => {
39+
if (kind === "field") {
40+
return function (this: DurableObject, initialValue: any) {
41+
const fieldNameNonPrivate = fieldName.replace(/^#/, "");
42+
return state(
43+
this.ctx,
44+
opts.name ?? fieldNameNonPrivate,
45+
initialValue,
46+
{
47+
snapshotPolicy: opts.snapshotPolicy ?? { changes: 10 },
48+
},
49+
);
50+
};
51+
}
52+
53+
throw new Error("Only fields can be persistable");
54+
};
55+
56+
if (args.length === 0) {
57+
return fn();
58+
}
59+
60+
if (args.length === 1) {
61+
const [optsOrName] = args;
62+
63+
if (typeof optsOrName === "object") {
64+
return fn(optsOrName);
65+
}
66+
67+
return fn(optsOrName ? { name: optsOrName } : {});
68+
}
69+
70+
const [value, metadata] = args;
71+
return fn()(value, metadata ?? unreachable());
72+
}

src/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { recursivelyObservable } from "./observable.js";
22
import { SqliteState } from "./sqlite.js";
33
import { unreachable } from "./util.js";
44

5+
export * from "./decorator.js";
6+
57
export type SnapshotPolicy = "never" | "every-change" | { changes: number };
68

79
export type StateOptions = {
8-
snapshotPolicy: SnapshotPolicy;
10+
snapshotPolicy?: SnapshotPolicy;
911
};
1012

1113
export function state<T extends object>(
@@ -21,7 +23,7 @@ export function state<T extends object>(
2123
return recursivelyObservable(data, {
2224
onUpdate(changes, data) {
2325
state.appendChanges(changes);
24-
maybeSnapshot(data, state, options.snapshotPolicy);
26+
maybeSnapshot(data, state, options.snapshotPolicy ?? { changes: 10 });
2527
},
2628
});
2729
}

src/util.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
export function unreachable(): never {
2-
throw new Error("unreachable");
3-
}
2+
throw new Error("unreachable");
3+
}

tests/decorator.test.ts

+142
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { diffable } from "../src/index.js";
2+
import { describe, expect } from "vitest";
3+
import { durableIt, snapshots } from "./helper.js";
4+
import { DurableObject } from "cloudflare:workers";
5+
import { env } from "cloudflare:test";
6+
7+
describe("state tracking with decorators", () => {
8+
durableIt("should track with no args", (objectState) => {
9+
class TestObject extends DurableObject {
10+
@diffable
11+
state = { count: 0 };
12+
}
13+
14+
const obj = new TestObject(objectState, env);
15+
for (let i = 0; i < 20; i++) {
16+
obj.state.count++;
17+
}
18+
19+
const snapshowRows = snapshots(objectState.storage.sql);
20+
expect(snapshowRows).toMatchInlineSnapshot(`
21+
[
22+
{
23+
"changes_id": 10,
24+
"id": 1,
25+
"state": "state",
26+
"value": {
27+
"count": 10,
28+
},
29+
},
30+
{
31+
"changes_id": 20,
32+
"id": 2,
33+
"state": "state",
34+
"value": {
35+
"count": 20,
36+
},
37+
},
38+
]
39+
`);
40+
});
41+
42+
durableIt("should track with empty args", (objectState) => {
43+
class TestObject extends DurableObject {
44+
@diffable()
45+
state = { count: 0 };
46+
}
47+
48+
const obj = new TestObject(objectState, env);
49+
for (let i = 0; i < 20; i++) {
50+
obj.state.count++;
51+
}
52+
53+
const snapshowRows = snapshots(objectState.storage.sql);
54+
expect(snapshowRows).toMatchInlineSnapshot(`
55+
[
56+
{
57+
"changes_id": 10,
58+
"id": 1,
59+
"state": "state",
60+
"value": {
61+
"count": 10,
62+
},
63+
},
64+
{
65+
"changes_id": 20,
66+
"id": 2,
67+
"state": "state",
68+
"value": {
69+
"count": 20,
70+
},
71+
},
72+
]
73+
`);
74+
});
75+
76+
durableIt("should track with named args", (objectState) => {
77+
class TestObject extends DurableObject {
78+
@diffable("foo")
79+
state = { count: 0 };
80+
}
81+
82+
const obj = new TestObject(objectState, env);
83+
for (let i = 0; i < 20; i++) {
84+
obj.state.count++;
85+
}
86+
87+
const snapshowRows = snapshots(objectState.storage.sql);
88+
expect(snapshowRows).toMatchInlineSnapshot(`
89+
[
90+
{
91+
"changes_id": 10,
92+
"id": 1,
93+
"state": "foo",
94+
"value": {
95+
"count": 10,
96+
},
97+
},
98+
{
99+
"changes_id": 20,
100+
"id": 2,
101+
"state": "foo",
102+
"value": {
103+
"count": 20,
104+
},
105+
},
106+
]
107+
`);
108+
});
109+
110+
durableIt("should track with options", (objectState) => {
111+
class TestObject extends DurableObject {
112+
@diffable({ name: "foo", snapshotPolicy: "every-change" })
113+
state = { count: 0 };
114+
}
115+
116+
const obj = new TestObject(objectState, env);
117+
obj.state.count++;
118+
obj.state.count++;
119+
120+
const snapshowRows = snapshots(objectState.storage.sql);
121+
expect(snapshowRows).toMatchInlineSnapshot(`
122+
[
123+
{
124+
"changes_id": 1,
125+
"id": 1,
126+
"state": "foo",
127+
"value": {
128+
"count": 1,
129+
},
130+
},
131+
{
132+
"changes_id": 2,
133+
"id": 2,
134+
"state": "foo",
135+
"value": {
136+
"count": 2,
137+
},
138+
},
139+
]
140+
`);
141+
});
142+
});

tests/global.d.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
declare module "cloudflare:test" {
2-
interface ProvidedEnv {
3-
test: DurableObjectNamespace;
4-
}
5-
}
2+
interface ProvidedEnv {
3+
test: DurableObjectNamespace;
4+
}
5+
}

tests/helper.ts

+37-10
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,43 @@
11
import { env, runInDurableObject } from "cloudflare:test";
22
import { it } from "vitest";
33

4-
export async function runInTestDurableObject(fn: (objectState: DurableObjectState) => void) {
5-
const id = env.test.idFromName("test");
6-
const stub = env.test.get(id);
7-
await runInDurableObject(stub, async (_, objectState) => {
8-
fn(objectState);
9-
});
4+
export async function runInTestDurableObject(
5+
fn: (objectState: DurableObjectState) => void,
6+
) {
7+
const id = env.test.idFromName("test");
8+
const stub = env.test.get(id);
9+
await runInDurableObject(stub, async (_, objectState) => {
10+
fn(objectState);
11+
});
12+
}
13+
14+
export function durableIt(
15+
name: string,
16+
fn: (objectState: DurableObjectState) => void,
17+
) {
18+
it(name, async () => {
19+
await runInTestDurableObject(fn);
20+
});
1021
}
1122

12-
export function durableIt(name: string, fn: (objectState: DurableObjectState) => void) {
13-
it(name, async () => {
14-
await runInTestDurableObject(fn);
23+
type SnapshotRow = {
24+
id: number;
25+
state: string;
26+
value: string;
27+
changes_id: number;
28+
created_at?: string;
29+
};
30+
31+
export function snapshots(sql: SqlStorage) {
32+
return sql
33+
.exec<SnapshotRow>("SELECT * FROM snapshots")
34+
.toArray()
35+
.map((row) => {
36+
// biome-ignore lint/performance/noDelete: <explanation>
37+
delete row.created_at;
38+
return {
39+
...row,
40+
value: JSON.parse(row.value),
41+
};
1542
});
16-
}
43+
}

0 commit comments

Comments
 (0)