@frockbot/workspace-store 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +25 -6
- package/src/bucket.ts +80 -0
- package/src/index.ts +3 -0
- package/src/keys.ts +64 -0
- package/src/store.test.ts +984 -0
- package/src/store.ts +929 -0
- package/src/testing.ts +180 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/testing.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// In-memory doubles for the two seams the store consumes.
|
|
2
|
+
//
|
|
3
|
+
// They exist so the store's behaviour can be proven without workerd — and so
|
|
4
|
+
// the Memory Package and the Computer-side sync can test against the same
|
|
5
|
+
// semantics the deployed R2 and Durable Object give them. They are fixtures,
|
|
6
|
+
// not a second implementation: the conditional-write rules and the generation
|
|
7
|
+
// ledger they model are the ones the store depends on, so a divergence here
|
|
8
|
+
// would be a bug in the double, not a difference in policy.
|
|
9
|
+
import {
|
|
10
|
+
workspaceRootKeyV1,
|
|
11
|
+
type WorkspaceGenerationRecordV1,
|
|
12
|
+
type WorkspaceGenerationsV1,
|
|
13
|
+
type WorkspaceRootV1,
|
|
14
|
+
} from "@frockbot/kernel-contracts";
|
|
15
|
+
import type {
|
|
16
|
+
ObjectBodyV1,
|
|
17
|
+
ObjectBucketV1,
|
|
18
|
+
ObjectHeadV1,
|
|
19
|
+
ObjectListPageV1,
|
|
20
|
+
ObjectListRequestV1,
|
|
21
|
+
ObjectPutOptionsV1,
|
|
22
|
+
} from "./bucket.js";
|
|
23
|
+
|
|
24
|
+
interface StoredObject {
|
|
25
|
+
bytes: Uint8Array;
|
|
26
|
+
etag: string;
|
|
27
|
+
uploaded: Date;
|
|
28
|
+
customMetadata?: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InMemoryObjectBucketV1 extends ObjectBucketV1 {
|
|
32
|
+
/** Every key currently held, in sorted order. */
|
|
33
|
+
keys(): string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An object store with R2's conditional-write semantics: `etagMatches` is
|
|
38
|
+
* `If-Match`, and `etagDoesNotMatch: "*"` is `If-None-Match: *`. A failed
|
|
39
|
+
* precondition answers `null` rather than throwing, exactly as R2 does.
|
|
40
|
+
*/
|
|
41
|
+
export function createInMemoryObjectBucketV1(
|
|
42
|
+
clock: () => Date = () => new Date(),
|
|
43
|
+
): InMemoryObjectBucketV1 {
|
|
44
|
+
const objects = new Map<string, StoredObject>();
|
|
45
|
+
let etagCounter = 0;
|
|
46
|
+
|
|
47
|
+
const head = (key: string): ObjectHeadV1 | null => {
|
|
48
|
+
const stored = objects.get(key);
|
|
49
|
+
if (!stored) return null;
|
|
50
|
+
return {
|
|
51
|
+
key,
|
|
52
|
+
etag: stored.etag,
|
|
53
|
+
size: stored.bytes.byteLength,
|
|
54
|
+
uploaded: stored.uploaded,
|
|
55
|
+
...(stored.customMetadata
|
|
56
|
+
? { customMetadata: stored.customMetadata }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
keys: () => [...objects.keys()].sort(),
|
|
63
|
+
head: (key) => Promise.resolve(head(key)),
|
|
64
|
+
get: (key) => {
|
|
65
|
+
const stored = objects.get(key);
|
|
66
|
+
const meta = head(key);
|
|
67
|
+
if (!stored || !meta) return Promise.resolve(null);
|
|
68
|
+
const body: ObjectBodyV1 = {
|
|
69
|
+
...meta,
|
|
70
|
+
bytes: () => Promise.resolve(stored.bytes),
|
|
71
|
+
};
|
|
72
|
+
return Promise.resolve(body);
|
|
73
|
+
},
|
|
74
|
+
put: (key: string, bytes: Uint8Array, options?: ObjectPutOptionsV1) => {
|
|
75
|
+
const existing = objects.get(key);
|
|
76
|
+
const onlyIf = options?.onlyIf;
|
|
77
|
+
if (onlyIf?.etagDoesNotMatch === "*" && existing) {
|
|
78
|
+
return Promise.resolve(null);
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
onlyIf?.etagDoesNotMatch !== undefined &&
|
|
82
|
+
onlyIf.etagDoesNotMatch !== "*" &&
|
|
83
|
+
existing?.etag === onlyIf.etagDoesNotMatch
|
|
84
|
+
) {
|
|
85
|
+
return Promise.resolve(null);
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
onlyIf?.etagMatches !== undefined &&
|
|
89
|
+
existing?.etag !== onlyIf.etagMatches
|
|
90
|
+
) {
|
|
91
|
+
return Promise.resolve(null);
|
|
92
|
+
}
|
|
93
|
+
etagCounter += 1;
|
|
94
|
+
const stored: StoredObject = {
|
|
95
|
+
bytes: new Uint8Array(bytes),
|
|
96
|
+
etag: `etag-${etagCounter}`,
|
|
97
|
+
uploaded: clock(),
|
|
98
|
+
...(options?.customMetadata
|
|
99
|
+
? { customMetadata: { ...options.customMetadata } }
|
|
100
|
+
: {}),
|
|
101
|
+
};
|
|
102
|
+
objects.set(key, stored);
|
|
103
|
+
return Promise.resolve(head(key));
|
|
104
|
+
},
|
|
105
|
+
delete: (key) => {
|
|
106
|
+
objects.delete(key);
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
},
|
|
109
|
+
list: (request: ObjectListRequestV1) => {
|
|
110
|
+
const prefix = request.prefix ?? "";
|
|
111
|
+
const matching = [...objects.keys()]
|
|
112
|
+
.filter((key) => key.startsWith(prefix))
|
|
113
|
+
.sort();
|
|
114
|
+
const start = request.cursor ? Number(request.cursor) : 0;
|
|
115
|
+
const limit = request.limit ?? 1000;
|
|
116
|
+
const window = matching.slice(start, start + limit);
|
|
117
|
+
const truncated = start + window.length < matching.length;
|
|
118
|
+
const page: ObjectListPageV1 = {
|
|
119
|
+
objects: window.flatMap((key) => {
|
|
120
|
+
const meta = head(key);
|
|
121
|
+
return meta ? [meta] : [];
|
|
122
|
+
}),
|
|
123
|
+
truncated,
|
|
124
|
+
...(truncated ? { cursor: String(start + window.length) } : {}),
|
|
125
|
+
};
|
|
126
|
+
return Promise.resolve(page);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface InMemoryWorkspaceGenerationsV1 extends WorkspaceGenerationsV1 {
|
|
132
|
+
/** Every tombstone recorded, for asserting that a delete left evidence. */
|
|
133
|
+
tombstones(): WorkspaceGenerationRecordV1[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The generation ledger a Durable Object keeps, modelled in memory. */
|
|
137
|
+
export function createInMemoryWorkspaceGenerationsV1(
|
|
138
|
+
clock: () => Date = () => new Date(),
|
|
139
|
+
): InMemoryWorkspaceGenerationsV1 {
|
|
140
|
+
const current = new Map<string, WorkspaceGenerationRecordV1>();
|
|
141
|
+
const conflicts = new Map<string, WorkspaceGenerationRecordV1[]>();
|
|
142
|
+
let counter = 0;
|
|
143
|
+
let last = "";
|
|
144
|
+
|
|
145
|
+
const key = (root: WorkspaceRootV1, path: string): string =>
|
|
146
|
+
`${workspaceRootKeyV1(root)}:${path}`;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
mint: (at: Date) => {
|
|
150
|
+
counter += 1;
|
|
151
|
+
let minted = `${at.getTime().toString().padStart(15, "0")}-${counter
|
|
152
|
+
.toString()
|
|
153
|
+
.padStart(6, "0")}`;
|
|
154
|
+
// Sortable *and* monotonic: a clock that does not advance still yields
|
|
155
|
+
// an increasing id, because generation order is what ordering means.
|
|
156
|
+
if (minted <= last)
|
|
157
|
+
minted = `${last}-${counter.toString().padStart(6, "0")}`;
|
|
158
|
+
last = minted;
|
|
159
|
+
return Promise.resolve(minted);
|
|
160
|
+
},
|
|
161
|
+
current: (root, path) => Promise.resolve(current.get(key(root, path))),
|
|
162
|
+
record: (entry) => {
|
|
163
|
+
current.set(key(entry.root, entry.path), entry);
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
},
|
|
166
|
+
tombstone: (entry) => {
|
|
167
|
+
current.set(key(entry.root, entry.path), { ...entry, deleted: true });
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
},
|
|
170
|
+
conflict: (entry) => {
|
|
171
|
+
const at = key(entry.root, entry.path);
|
|
172
|
+
conflicts.set(at, [...(conflicts.get(at) ?? []), entry]);
|
|
173
|
+
return Promise.resolve();
|
|
174
|
+
},
|
|
175
|
+
conflicts: (root, path) =>
|
|
176
|
+
Promise.resolve([...(conflicts.get(key(root, path)) ?? [])]),
|
|
177
|
+
tombstones: () =>
|
|
178
|
+
[...current.values()].filter((entry) => entry.deleted === true),
|
|
179
|
+
};
|
|
180
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED