@frockbot/workspace-store 0.0.0 → 0.1.1

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 CHANGED
@@ -1,14 +1,33 @@
1
1
  {
2
2
  "name": "@frockbot/workspace-store",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
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.1"
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
@@ -0,0 +1,3 @@
1
+ export * from "./bucket.js";
2
+ export * from "./keys.js";
3
+ export * from "./store.js";
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
+ }