@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/package.json
CHANGED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/workspace-store",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./bucket": "./src/bucket.ts",
|
|
9
|
+
"./keys": "./src/keys.ts",
|
|
10
|
+
"./testing": "./src/testing.ts",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "bun test src",
|
|
15
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@frockbot/kernel-contracts": "0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/bun": "1.4.0",
|
|
22
|
+
"@types/node": "26.2.0",
|
|
23
|
+
"typescript": "^7.0.2"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
6
28
|
"repository": {
|
|
7
29
|
"type": "git",
|
|
8
30
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
31
|
"directory": "packages/workspace-store"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
32
|
}
|
|
14
33
|
}
|
package/src/bucket.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The object store this package writes through, declared structurally.
|
|
2
|
+
//
|
|
3
|
+
// It is deliberately not `R2Bucket`. "Electron, Cloudflare, provider SDK, and
|
|
4
|
+
// Computer implementation types remain inside their adapters" — so the store
|
|
5
|
+
// consumes the four operations and the two conditional headers it actually
|
|
6
|
+
// needs, an adapter in the Cloudflare app supplies them over R2, and a Bun
|
|
7
|
+
// test supplies them over a Map. The two behave the same because this file,
|
|
8
|
+
// not R2, is the contract.
|
|
9
|
+
//
|
|
10
|
+
// Conditional semantics are the whole point of the interface. ADR 0013 names
|
|
11
|
+
// "object storage conditional writes (`If-Match` on the object's ETag)" as the
|
|
12
|
+
// mechanism by which a write that has not seen the current generation loses
|
|
13
|
+
// rather than overwrites, so `put` must be able to fail a precondition and say
|
|
14
|
+
// so as a value.
|
|
15
|
+
|
|
16
|
+
/** An object's metadata, without its bytes. */
|
|
17
|
+
export interface ObjectHeadV1 {
|
|
18
|
+
key: string;
|
|
19
|
+
/** The entity tag an `If-Match` write is conditioned on. */
|
|
20
|
+
etag: string;
|
|
21
|
+
size: number;
|
|
22
|
+
/** When the store accepted these bytes; orders an unrecorded object. */
|
|
23
|
+
uploaded: Date;
|
|
24
|
+
/** Small opaque strings stored beside the bytes. */
|
|
25
|
+
customMetadata?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An object with its bytes. */
|
|
29
|
+
export interface ObjectBodyV1 extends ObjectHeadV1 {
|
|
30
|
+
bytes(): Promise<Uint8Array>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The two conditions this store uses, and no others.
|
|
35
|
+
*
|
|
36
|
+
* `etagMatches` is `If-Match`: replace exactly the bytes the writer has seen.
|
|
37
|
+
* `etagDoesNotMatch: "*"` is `If-None-Match: *`: create only if absent, which
|
|
38
|
+
* is what `expectedGenerationId: null` asserts.
|
|
39
|
+
*/
|
|
40
|
+
export interface ObjectConditionsV1 {
|
|
41
|
+
etagMatches?: string;
|
|
42
|
+
etagDoesNotMatch?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ObjectPutOptionsV1 {
|
|
46
|
+
onlyIf?: ObjectConditionsV1;
|
|
47
|
+
customMetadata?: Record<string, string>;
|
|
48
|
+
contentType?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ObjectListRequestV1 {
|
|
52
|
+
prefix?: string;
|
|
53
|
+
cursor?: string;
|
|
54
|
+
limit?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ObjectListPageV1 {
|
|
58
|
+
objects: ObjectHeadV1[];
|
|
59
|
+
truncated: boolean;
|
|
60
|
+
cursor?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The minimal object store. Every method may reject; the store above turns a
|
|
65
|
+
* rejection into the declared `unavailable` variant rather than letting it
|
|
66
|
+
* escape, because object storage being briefly unreachable is an ordinary
|
|
67
|
+
* answer, not a failed Turn.
|
|
68
|
+
*/
|
|
69
|
+
export interface ObjectBucketV1 {
|
|
70
|
+
get(key: string): Promise<ObjectBodyV1 | null>;
|
|
71
|
+
head(key: string): Promise<ObjectHeadV1 | null>;
|
|
72
|
+
/** Answers `null` when a precondition in `options.onlyIf` failed. */
|
|
73
|
+
put(
|
|
74
|
+
key: string,
|
|
75
|
+
bytes: Uint8Array,
|
|
76
|
+
options?: ObjectPutOptionsV1,
|
|
77
|
+
): Promise<ObjectHeadV1 | null>;
|
|
78
|
+
delete(key: string): Promise<void>;
|
|
79
|
+
list(request: ObjectListRequestV1): Promise<ObjectListPageV1>;
|
|
80
|
+
}
|
package/src/index.ts
ADDED
package/src/keys.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Where a durable-root file lives in object storage, and where its losing
|
|
2
|
+
// writes are preserved.
|
|
3
|
+
//
|
|
4
|
+
// One scheme, in one place, because three things must agree on it: this
|
|
5
|
+
// package, the Computer-side sync agent of ADR 0013, and anything that reads
|
|
6
|
+
// the bucket to rebuild an index.
|
|
7
|
+
//
|
|
8
|
+
// file workspace/<workspaceRootKeyV1(root)>/<relative>
|
|
9
|
+
// conflict workspace/<workspaceRootKeyV1(root)>/<relative>.conflict/<generationId>
|
|
10
|
+
//
|
|
11
|
+
// The conflict key is a *prefix* of nothing a file can occupy, and that is
|
|
12
|
+
// enforced rather than assumed: `normalizeWorkspaceRelativePathV1` refuses any
|
|
13
|
+
// segment ending in `.conflict`, so `notes.conflict/a.md` is not a path a
|
|
14
|
+
// caller can present. Listing a root therefore skips any key containing
|
|
15
|
+
// `/<name>.conflict/`, and a preserved losing write is durable, addressable,
|
|
16
|
+
// and never mistaken for the file it lost to.
|
|
17
|
+
import {
|
|
18
|
+
WORKSPACE_CONFLICT_SEGMENT_SUFFIX,
|
|
19
|
+
workspaceRootKeyV1,
|
|
20
|
+
type WorkspaceRootV1,
|
|
21
|
+
} from "@frockbot/kernel-contracts";
|
|
22
|
+
|
|
23
|
+
/** Every durable-root object lives under this prefix. */
|
|
24
|
+
export const WORKSPACE_OBJECT_PREFIX = "workspace";
|
|
25
|
+
/** The segment marking a preserved losing write; a path may not end in it. */
|
|
26
|
+
export const WORKSPACE_CONFLICT_SUFFIX = WORKSPACE_CONFLICT_SEGMENT_SUFFIX;
|
|
27
|
+
|
|
28
|
+
/** The prefix every object of one durable root shares. */
|
|
29
|
+
export function workspaceObjectPrefixV1(root: WorkspaceRootV1): string {
|
|
30
|
+
return `${WORKSPACE_OBJECT_PREFIX}/${workspaceRootKeyV1(root)}/`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The object key holding one file's current bytes. */
|
|
34
|
+
export function workspaceObjectKeyV1(
|
|
35
|
+
root: WorkspaceRootV1,
|
|
36
|
+
path: string,
|
|
37
|
+
): string {
|
|
38
|
+
return `${workspaceObjectPrefixV1(root)}${path}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The object key preserving one losing write. */
|
|
42
|
+
export function workspaceConflictKeyV1(
|
|
43
|
+
root: WorkspaceRootV1,
|
|
44
|
+
path: string,
|
|
45
|
+
generationId: string,
|
|
46
|
+
): string {
|
|
47
|
+
return `${workspaceObjectKeyV1(root, path)}${WORKSPACE_CONFLICT_SUFFIX}/${generationId}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True for a key that preserves a losing write rather than holding a file. */
|
|
51
|
+
export function isWorkspaceConflictKeyV1(key: string): boolean {
|
|
52
|
+
return key.includes(`${WORKSPACE_CONFLICT_SUFFIX}/`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The relative path a file key names inside its root, if it names one. */
|
|
56
|
+
export function workspaceRelativeFromKeyV1(
|
|
57
|
+
root: WorkspaceRootV1,
|
|
58
|
+
key: string,
|
|
59
|
+
): string | undefined {
|
|
60
|
+
const prefix = workspaceObjectPrefixV1(root);
|
|
61
|
+
if (!key.startsWith(prefix)) return undefined;
|
|
62
|
+
const relative = key.slice(prefix.length);
|
|
63
|
+
return relative.length > 0 ? relative : undefined;
|
|
64
|
+
}
|