@worker-protocol/cloudflare 0.0.0-stage → 0.7.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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Migrations for the tables a piece keeps in a Durable Object, applied object by object.
3
+ *
4
+ * **Why `CREATE TABLE IF NOT EXISTS` was not enough.** It creates a table and never changes one: a
5
+ * column or an index added in a later release would reach the objects created after it and none of
6
+ * the thousands created before. A Worker with an object per vehicle has every one of those to bring
7
+ * forward, each on its own, the first time it is reached after a deploy.
8
+ *
9
+ * **One journal, `wp_schema`, a row per piece.** Each piece names itself and lists every step its
10
+ * schema has ever taken, in order; the journal records how many an object has applied, and
11
+ * `migrate` applies the rest. Pieces are independent because the mixins are: an object that carries
12
+ * only an outbox migrates only the outbox. A domain may keep its own tables in the same journal
13
+ * under names of its own, so one object has one record of what shape it is in.
14
+ *
15
+ * **A step, once published, is never edited** — an object that already applied it will not apply it
16
+ * again — and a change is a new step at the end. The first step of each piece in this package
17
+ * creates its tables `IF NOT EXISTS`, so that an object created before the journal existed adopts
18
+ * the tables it already has.
19
+ *
20
+ * This is not an ORM and does not want to be one. The tables are a few, the queries are plain, and a
21
+ * query builder imposed by a library would be a version every Worker that installs it has to agree
22
+ * with — a Worker that wants Drizzle or Kysely for its own tables uses them, beside these.
23
+ */
24
+ /** One piece's schema: its name in the journal, and every step it has taken, oldest first. */
25
+ export type Schema = {
26
+ /** Unique within an object. This package's pieces are `worker-protocol.<piece>`. */
27
+ piece: string;
28
+ /** Each step is the statements that take the schema one version forward. */
29
+ steps: string[][];
30
+ };
31
+ /**
32
+ * Brings this object's tables for `schema` up to its latest step, and answers the `sql` to use.
33
+ *
34
+ * Called at the start of every method rather than once in a constructor, because an object that
35
+ * empties itself with `deleteAll()` keeps running in the same instance, and a table created only at
36
+ * construction would be missing on the next call. An object already at the latest step costs one
37
+ * read of the journal.
38
+ *
39
+ * The steps still owed are applied in one transaction with the journal's new version, so a step that
40
+ * fails leaves the object exactly where it was. An object ahead of `schema` — written by a later
41
+ * release than the one now running — is refused rather than read: running older code over a newer
42
+ * shape is a rollback, and a rollback is not a migration.
43
+ */
44
+ export declare function migrate(storage: DurableObjectStorage, schema: Schema): SqlStorage;
package/dist/schema.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Migrations for the tables a piece keeps in a Durable Object, applied object by object.
3
+ *
4
+ * **Why `CREATE TABLE IF NOT EXISTS` was not enough.** It creates a table and never changes one: a
5
+ * column or an index added in a later release would reach the objects created after it and none of
6
+ * the thousands created before. A Worker with an object per vehicle has every one of those to bring
7
+ * forward, each on its own, the first time it is reached after a deploy.
8
+ *
9
+ * **One journal, `wp_schema`, a row per piece.** Each piece names itself and lists every step its
10
+ * schema has ever taken, in order; the journal records how many an object has applied, and
11
+ * `migrate` applies the rest. Pieces are independent because the mixins are: an object that carries
12
+ * only an outbox migrates only the outbox. A domain may keep its own tables in the same journal
13
+ * under names of its own, so one object has one record of what shape it is in.
14
+ *
15
+ * **A step, once published, is never edited** — an object that already applied it will not apply it
16
+ * again — and a change is a new step at the end. The first step of each piece in this package
17
+ * creates its tables `IF NOT EXISTS`, so that an object created before the journal existed adopts
18
+ * the tables it already has.
19
+ *
20
+ * This is not an ORM and does not want to be one. The tables are a few, the queries are plain, and a
21
+ * query builder imposed by a library would be a version every Worker that installs it has to agree
22
+ * with — a Worker that wants Drizzle or Kysely for its own tables uses them, beside these.
23
+ */
24
+ const JOURNAL = `CREATE TABLE IF NOT EXISTS wp_schema (
25
+ piece TEXT PRIMARY KEY,
26
+ version INTEGER NOT NULL
27
+ )`;
28
+ /**
29
+ * Brings this object's tables for `schema` up to its latest step, and answers the `sql` to use.
30
+ *
31
+ * Called at the start of every method rather than once in a constructor, because an object that
32
+ * empties itself with `deleteAll()` keeps running in the same instance, and a table created only at
33
+ * construction would be missing on the next call. An object already at the latest step costs one
34
+ * read of the journal.
35
+ *
36
+ * The steps still owed are applied in one transaction with the journal's new version, so a step that
37
+ * fails leaves the object exactly where it was. An object ahead of `schema` — written by a later
38
+ * release than the one now running — is refused rather than read: running older code over a newer
39
+ * shape is a rollback, and a rollback is not a migration.
40
+ */
41
+ export function migrate(storage, schema) {
42
+ const sql = storage.sql;
43
+ sql.exec(JOURNAL);
44
+ const held = sql
45
+ .exec("SELECT version FROM wp_schema WHERE piece = ?", schema.piece)
46
+ .toArray()[0];
47
+ const at = held?.version ?? 0;
48
+ const latest = schema.steps.length;
49
+ if (at === latest)
50
+ return sql;
51
+ if (at > latest) {
52
+ throw new Error(`${schema.piece} is at version ${at} in this object, and this code knows ${latest}: ` +
53
+ "the object was written by a later release.");
54
+ }
55
+ storage.transactionSync(() => {
56
+ for (const step of schema.steps.slice(at)) {
57
+ for (const statement of step)
58
+ sql.exec(statement);
59
+ }
60
+ sql.exec(`INSERT INTO wp_schema (piece, version) VALUES (?, ?)
61
+ ON CONFLICT (piece) DO UPDATE SET version = excluded.version`, schema.piece, latest);
62
+ });
63
+ return sql;
64
+ }
@@ -0,0 +1,23 @@
1
+ import type { SubscriptionStore } from "@worker-protocol/hono";
2
+ import { type DurableObjectClass, type Mixed, type Rpc } from "./durable.ts";
3
+ /** What `withSubscriptions` adds to a Durable Object. Every subscription crosses as JSON. */
4
+ export interface SubscriptionMethods {
5
+ findSubscription(key: string): string | null;
6
+ ensureSubscription(key: string, candidate: string): {
7
+ record: string;
8
+ created: boolean;
9
+ };
10
+ getSubscription(id: string): string | null;
11
+ subscriptionsOf(caller: string | null): string[];
12
+ subscriptionsFor(type: string): string[];
13
+ updateSubscription(id: string, change: {
14
+ set: string;
15
+ clear: string[];
16
+ }): void;
17
+ removeSubscription(id: string): void;
18
+ }
19
+ export declare function withSubscriptions<B extends DurableObjectClass>(Base: B): Mixed<B, SubscriptionMethods>;
20
+ /** What `durableSubscriptions` needs of a stub: the methods `withSubscriptions` adds, over RPC. */
21
+ export type SubscriptionsRpc = Rpc<SubscriptionMethods>;
22
+ /** `subscriptions.store` for `mount()` and `eventHub()`, over the object with `withSubscriptions`. */
23
+ export declare const durableSubscriptions: (stub: SubscriptionsRpc) => SubscriptionStore;
@@ -0,0 +1,121 @@
1
+ import { first } from "./durable.js";
2
+ import { migrate } from "./schema.js";
3
+ /**
4
+ * SUB-7's store, in the one Durable Object that holds a Worker's subscriptions.
5
+ *
6
+ * **One object, never one per shard.** SUB-7 has two requests for the same thing find one
7
+ * subscription, which only a single consistent store can promise — so a Worker that keeps an object
8
+ * per vehicle keeps its subscriptions in the one object it has for the whole fleet, and puts this
9
+ * mixin there and nowhere else.
10
+ *
11
+ * SQL, because every publish asks for the live subscriptions naming a type (SUB-13) and every list
12
+ * for one caller's (SUB-8), and both are filters. Each method is synchronous between its first
13
+ * statement and its last, so nothing interleaves inside one — which is what `ensureSubscription`
14
+ * needs, as `beginOutcome` does.
15
+ *
16
+ * **A subscription crosses the RPC boundary as JSON.** Its filters nest by definition, and a stub's
17
+ * types are a conditional mapping that exceeds the compiler's instantiation depth on a recursive
18
+ * type. `durableSubscriptions` does the parsing, so a Worker never sees the strings.
19
+ */
20
+ /** `migrate` applies these; a new step goes at the end, and a published one is never edited. */
21
+ const SCHEMA = {
22
+ piece: "worker-protocol.subscriptions",
23
+ steps: [
24
+ // 1. The tables as first released, `IF NOT EXISTS` so an object that already has them adopts them.
25
+ [
26
+ // The subscription itself is `record`. `key`, `caller` and `ended` are copied out of it only
27
+ // because a query filters on them; nothing reads them back.
28
+ `CREATE TABLE IF NOT EXISTS wp_subscription (
29
+ id TEXT PRIMARY KEY,
30
+ key TEXT NOT NULL,
31
+ caller TEXT,
32
+ ended INTEGER NOT NULL,
33
+ record TEXT NOT NULL
34
+ )`,
35
+ "CREATE INDEX IF NOT EXISTS wp_subscription_key ON wp_subscription (key)",
36
+ ],
37
+ ],
38
+ };
39
+ export function withSubscriptions(Base) {
40
+ class WithSubscriptions extends Base {
41
+ findSubscription(key) {
42
+ const sql = migrate(this.ctx.storage, SCHEMA);
43
+ const row = first(sql.exec("SELECT record FROM wp_subscription WHERE key = ? AND ended = 0 LIMIT 1", key));
44
+ return row?.record ?? null;
45
+ }
46
+ /** SUB-7, SUB-15: the live one under `key`, or the candidate. An ended one stays, listed. */
47
+ ensureSubscription(key, candidate) {
48
+ const held = this.findSubscription(key);
49
+ if (held !== null)
50
+ return { record: held, created: false };
51
+ const one = JSON.parse(candidate);
52
+ migrate(this.ctx.storage, SCHEMA).exec("INSERT INTO wp_subscription (id, key, caller, ended, record) VALUES (?, ?, ?, 0, ?)", one.id, key, one.caller, candidate);
53
+ return { record: candidate, created: true };
54
+ }
55
+ getSubscription(id) {
56
+ const sql = migrate(this.ctx.storage, SCHEMA);
57
+ const row = first(sql.exec("SELECT record FROM wp_subscription WHERE id = ?", id));
58
+ return row?.record ?? null;
59
+ }
60
+ /** SUB-8: one caller's, ended ones included. `IS` because a caller may be `null`. */
61
+ subscriptionsOf(caller) {
62
+ const sql = migrate(this.ctx.storage, SCHEMA);
63
+ return sql
64
+ .exec("SELECT record FROM wp_subscription WHERE caller IS ?", caller)
65
+ .toArray()
66
+ .map((row) => row.record);
67
+ }
68
+ /** SUB-13: the live ones naming this type. The filters are the hub's to apply. */
69
+ subscriptionsFor(type) {
70
+ const sql = migrate(this.ctx.storage, SCHEMA);
71
+ return sql
72
+ .exec(`SELECT record FROM wp_subscription
73
+ WHERE ended = 0
74
+ AND EXISTS (SELECT 1 FROM json_each(record, '$.types') WHERE value = ?)`, type)
75
+ .toArray()
76
+ .map((row) => row.record);
77
+ }
78
+ /**
79
+ * A patch, as two lists because JSON cannot say *cleared*: `JSON.stringify` drops a member
80
+ * whose value is `undefined`, which is exactly how `SubscriptionStore.update` names one to
81
+ * clear.
82
+ */
83
+ updateSubscription(id, change) {
84
+ const held = this.getSubscription(id);
85
+ if (held === null)
86
+ return;
87
+ const next = { ...JSON.parse(held), ...JSON.parse(change.set) };
88
+ for (const name of change.clear)
89
+ delete next[name];
90
+ migrate(this.ctx.storage, SCHEMA).exec("UPDATE wp_subscription SET ended = ?, record = ? WHERE id = ?", next.endedAt === undefined ? 0 : 1, JSON.stringify(next), id);
91
+ }
92
+ removeSubscription(id) {
93
+ const sql = migrate(this.ctx.storage, SCHEMA);
94
+ sql.exec("DELETE FROM wp_subscription WHERE id = ?", id);
95
+ }
96
+ }
97
+ return WithSubscriptions;
98
+ }
99
+ const parse = (record) => JSON.parse(record);
100
+ /** `subscriptions.store` for `mount()` and `eventHub()`, over the object with `withSubscriptions`. */
101
+ export const durableSubscriptions = (stub) => {
102
+ const read = (record) => (record === null ? undefined : parse(record));
103
+ return {
104
+ find: async (key) => read(await stub.findSubscription(key)),
105
+ ensure: async (key, candidate) => {
106
+ const { record, created } = await stub.ensureSubscription(key, JSON.stringify(candidate));
107
+ return { subscription: parse(record), created };
108
+ },
109
+ get: async (id) => read(await stub.getSubscription(id)),
110
+ list: async (caller) => (await stub.subscriptionsOf(caller)).map(parse),
111
+ forType: async (type) => (await stub.subscriptionsFor(type)).map(parse),
112
+ // `undefined` names a member to clear, and JSON drops it — so the cleared ones go by name.
113
+ update: (id, patch) => stub.updateSubscription(id, {
114
+ set: JSON.stringify(patch),
115
+ clear: Object.entries(patch)
116
+ .filter(([, value]) => value === undefined)
117
+ .map(([name]) => name),
118
+ }),
119
+ remove: (id) => stub.removeSubscription(id),
120
+ };
121
+ };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@worker-protocol/cloudflare",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.7.0",
4
+ "workerProtocolEdition": "0.5",
5
+ "description": "The protocol's stores in Durable Objects, and the Queues between an outbox and a sink: mixins for ENDP-16, SUB-7, LOG-2 and an outbox, for Workers on Cloudflare",
6
+ "keywords": [
7
+ "worker-protocol",
8
+ "worker",
9
+ "cloudflare",
10
+ "durable-objects",
11
+ "queues",
12
+ "cloudevents",
13
+ "rowing-tech"
14
+ ],
15
+ "license": "Apache-2.0",
16
+ "author": "Rowing Tech, S.A. (https://rowing.tech)",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/rowing-tech/worker-protocol.git",
20
+ "directory": "packages/cloudflare"
21
+ },
22
+ "homepage": "https://github.com/rowing-tech/worker-protocol/tree/main/packages/cloudflare#readme",
23
+ "bugs": "https://github.com/rowing-tech/worker-protocol/issues",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ],
41
+ "dependencies": {
42
+ "@worker-protocol/hono": "0.7.0"
43
+ },
44
+ "peerDependencies": {
45
+ "hono": "^4.13.7",
46
+ "zod": "^4.5.4"
47
+ },
48
+ "devDependencies": {
49
+ "@cloudflare/vitest-pool-workers": "0.22.0",
50
+ "@types/node": "26.5.1",
51
+ "hono": "4.13.7",
52
+ "typescript": "7.0.2",
53
+ "vitest": "4.1.11",
54
+ "wrangler": "4.131.1",
55
+ "zod": "4.5.4"
56
+ },
57
+ "scripts": {
58
+ "typecheck": "wrangler types && tsc -p tsconfig.json",
59
+ "build": "wrangler types --include-env=false runtime.d.ts && tsc -p tsconfig.build.json",
60
+ "test": "vitest run"
61
+ }
6
62
  }