@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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +191 -2
- package/dist/durable.d.ts +38 -0
- package/dist/durable.js +6 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +19 -0
- package/dist/logs.d.ts +64 -0
- package/dist/logs.js +120 -0
- package/dist/outbox.d.ts +52 -0
- package/dist/outbox.js +173 -0
- package/dist/outcomes.d.ts +13 -0
- package/dist/outcomes.js +62 -0
- package/dist/queues.d.ts +68 -0
- package/dist/queues.js +148 -0
- package/dist/schema.d.ts +44 -0
- package/dist/schema.js +64 -0
- package/dist/subscriptions.d.ts +23 -0
- package/dist/subscriptions.js +121 -0
- package/package.json +59 -3
package/dist/schema.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
}
|