solid-objects 0.14.5 → 0.14.7

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +4 -0
  3. package/dist/actor-runtime.d.ts +36 -0
  4. package/dist/actor-runtime.d.ts.map +1 -0
  5. package/dist/actor-runtime.js +2 -0
  6. package/dist/actor-runtime.js.map +1 -0
  7. package/dist/application-database.d.ts.map +1 -1
  8. package/dist/application-database.js +2 -2
  9. package/dist/application-database.js.map +1 -1
  10. package/dist/browser/index.js +1 -1
  11. package/dist/browser/index.js.map +1 -1
  12. package/dist/cloudflare/configuration.d.ts +32 -0
  13. package/dist/cloudflare/configuration.d.ts.map +1 -0
  14. package/dist/cloudflare/configuration.js +46 -0
  15. package/dist/cloudflare/configuration.js.map +1 -0
  16. package/dist/cloudflare/engine.d.ts +43 -0
  17. package/dist/cloudflare/engine.d.ts.map +1 -0
  18. package/dist/cloudflare/engine.js +921 -0
  19. package/dist/cloudflare/engine.js.map +1 -0
  20. package/dist/cloudflare/host.d.ts +10 -0
  21. package/dist/cloudflare/host.d.ts.map +1 -0
  22. package/dist/cloudflare/host.js +44 -0
  23. package/dist/cloudflare/host.js.map +1 -0
  24. package/dist/cloudflare/index.d.ts +9 -0
  25. package/dist/cloudflare/index.d.ts.map +1 -0
  26. package/dist/cloudflare/index.js +8 -0
  27. package/dist/cloudflare/index.js.map +1 -0
  28. package/dist/cloudflare/platform.d.ts +2 -0
  29. package/dist/cloudflare/platform.d.ts.map +1 -0
  30. package/dist/cloudflare/platform.js +4 -0
  31. package/dist/cloudflare/platform.js.map +1 -0
  32. package/dist/cloudflare/protocol.d.ts +58 -0
  33. package/dist/cloudflare/protocol.d.ts.map +1 -0
  34. package/dist/cloudflare/protocol.js +77 -0
  35. package/dist/cloudflare/protocol.js.map +1 -0
  36. package/dist/cloudflare/records.d.ts +71 -0
  37. package/dist/cloudflare/records.d.ts.map +1 -0
  38. package/dist/cloudflare/records.js +2 -0
  39. package/dist/cloudflare/records.js.map +1 -0
  40. package/dist/cloudflare/runtime.d.ts +83 -0
  41. package/dist/cloudflare/runtime.d.ts.map +1 -0
  42. package/dist/cloudflare/runtime.js +321 -0
  43. package/dist/cloudflare/runtime.js.map +1 -0
  44. package/dist/cloudflare/session.d.ts +12 -0
  45. package/dist/cloudflare/session.d.ts.map +1 -0
  46. package/dist/cloudflare/session.js +332 -0
  47. package/dist/cloudflare/session.js.map +1 -0
  48. package/dist/cloudflare/storage.d.ts +25 -0
  49. package/dist/cloudflare/storage.d.ts.map +1 -0
  50. package/dist/cloudflare/storage.js +195 -0
  51. package/dist/cloudflare/storage.js.map +1 -0
  52. package/dist/context.d.ts +5 -4
  53. package/dist/context.d.ts.map +1 -1
  54. package/dist/context.js +3 -0
  55. package/dist/context.js.map +1 -1
  56. package/dist/core.d.ts +8 -0
  57. package/dist/core.d.ts.map +1 -0
  58. package/dist/core.js +4 -0
  59. package/dist/core.js.map +1 -0
  60. package/dist/database/mysql.d.ts +2 -2
  61. package/dist/database/mysql.d.ts.map +1 -1
  62. package/dist/database/mysql.js +4 -1
  63. package/dist/database/mysql.js.map +1 -1
  64. package/dist/database/postgresql.d.ts +2 -2
  65. package/dist/database/postgresql.d.ts.map +1 -1
  66. package/dist/database/postgresql.js +4 -2
  67. package/dist/database/postgresql.js.map +1 -1
  68. package/dist/database/types.d.ts +4 -1
  69. package/dist/database/types.d.ts.map +1 -1
  70. package/dist/default-runtime.d.ts +4 -4
  71. package/dist/default-runtime.d.ts.map +1 -1
  72. package/dist/default-runtime.js.map +1 -1
  73. package/dist/doctor.js +2 -2
  74. package/dist/doctor.js.map +1 -1
  75. package/dist/errors.d.ts +14 -0
  76. package/dist/errors.d.ts.map +1 -1
  77. package/dist/errors.js +9 -0
  78. package/dist/errors.js.map +1 -1
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js.map +1 -1
  82. package/dist/realtime.d.ts +18 -2
  83. package/dist/realtime.d.ts.map +1 -1
  84. package/dist/realtime.js.map +1 -1
  85. package/dist/reference.d.ts +5 -5
  86. package/dist/reference.d.ts.map +1 -1
  87. package/dist/reference.js.map +1 -1
  88. package/dist/repository.d.ts +15 -1
  89. package/dist/repository.d.ts.map +1 -1
  90. package/dist/repository.js +303 -65
  91. package/dist/repository.js.map +1 -1
  92. package/dist/runtime.d.ts.map +1 -1
  93. package/dist/runtime.js +18 -53
  94. package/dist/runtime.js.map +1 -1
  95. package/dist/schema.d.ts.map +1 -1
  96. package/dist/schema.js +29 -5
  97. package/dist/schema.js.map +1 -1
  98. package/dist/turn.d.ts +34 -0
  99. package/dist/turn.d.ts.map +1 -0
  100. package/dist/turn.js +57 -0
  101. package/dist/turn.js.map +1 -0
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/docs/api.md +28 -2
  105. package/docs/architecture.md +5 -3
  106. package/docs/cloudflare.md +155 -0
  107. package/docs/parity.md +28 -9
  108. package/docs/support.md +12 -11
  109. package/examples/cloudflare/README.md +49 -0
  110. package/examples/cloudflare/environment.d.ts +14 -0
  111. package/examples/cloudflare/shopping-cart.ts +65 -0
  112. package/examples/cloudflare/worker.ts +87 -0
  113. package/examples/cloudflare/wrangler.jsonc +15 -0
  114. package/package.json +20 -5
@@ -0,0 +1,155 @@
1
+ # Cloudflare Durable Objects
2
+
3
+ Import actor definitions from `solid-objects/core` to share them between Node
4
+ and Workers. On Cloudflare, import `createRuntime`, `durableObjects`,
5
+ `createDurableObjectsHost`, and `createDurableObjectsSessionHost` from
6
+ `solid-objects/cloudflare`.
7
+
8
+ The [runnable example](../examples/cloudflare/README.md) includes both Durable
9
+ Object classes, Wrangler bindings, migrations, and a public shopping cart.
10
+
11
+ ## Configure the hosts
12
+
13
+ ```typescript
14
+ import { createRuntime, durableObjects, withRuntime } from "solid-objects/cloudflare"
15
+
16
+ const runtime = createRuntime({
17
+ backend: durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }),
18
+ })
19
+
20
+ const counter = runtime.ref(Counter, "counter-1")
21
+ const count = await counter.with({ authorizationContext: subject }).increment()
22
+ await withRuntime(
23
+ runtime,
24
+ () => Counter.ref("counter-1").with({ authorizationContext: subject }).count,
25
+ )
26
+ ```
27
+
28
+ Define the actor host once with `createDurableObjectsHost({ actors, configure })`.
29
+ `configure(env)` returns a `CloudflareConfiguration` with the same backend
30
+ bindings, authorization policies, and an `effects` map of named handlers. A
31
+ handler receives the existing effect arguments and `EffectContext` with its
32
+ stable delivery ID. Definitions and configuration stay inside the deployment;
33
+ they are not transmitted through RPC.
34
+
35
+ Use `runtime.ref()` or request-scoped `withRuntime()` in Worker handlers.
36
+ `Actor.ref()` within actor operations and lifecycle callbacks retains its
37
+ runtime across awaits. The backend does not set a global default runtime.
38
+
39
+ The host supplies storage and scheduling, so there is no `install()` or
40
+ `run()` step. Configure both exported classes using `new_sqlite_classes` and
41
+ enable `nodejs_compat` for async context propagation. Library schema migrations
42
+ run synchronously on activation and use a separate migration ledger.
43
+
44
+ ## Guarantees and recovery
45
+
46
+ An actor type plus its string-normalized ID routes to one Durable Object.
47
+ Operations and getters enter its ordered durable mailbox. Snapshots read one
48
+ committed state image without entering the mailbox. A retryable head blocks
49
+ later turns, and permanent failures pause that actor until operator recovery.
50
+
51
+ State, results, effects, outbound messages, reminders, and the next alarm commit
52
+ together. Actor code executes outside that short transaction. Destruction and
53
+ restart generations fence stale commits. State migrations and read-only
54
+ projection checks use the same implementation as the SQL runtime.
55
+
56
+ Delivery is at least once. An effect can reach its external service before the
57
+ acknowledgement is stored. Deduplicate using `EffectContext.id`. `sendTo()` stores
58
+ an outbound intent in the source object and deduplicates acceptance in the
59
+ destination. This is eventual delivery, not a cross-object transaction.
60
+ Actors cannot synchronously call or wait on other actors.
61
+
62
+ One alarm covers the next due mailbox turn, retry, reminder, outbox delivery,
63
+ subscription expiry, or retention deadline. Persistent retry state supplements
64
+ Cloudflare's finite automatic alarm retries. Idle objects have no recurring
65
+ polling loop. Completed records default to 30 days of retention; dead letters
66
+ remain available for inspection. Idempotency receipts expire with their messages,
67
+ so callers must not rely on deduplication after the retention window.
68
+
69
+ An invocation timeout after acceptance raises `SyncTimeout` with a recoverable
70
+ `messageReference`. If RPC fails before an acceptance response arrives,
71
+ `EnqueueOutcomeUnknown.details` contains `actorType`, `actorId`, and `requestId`:
72
+
73
+ ```typescript
74
+ const message = await runtime.lookupMessage({
75
+ ...error.details,
76
+ authorizationContext: subject,
77
+ })
78
+ if (message) await message.wait({ authorizationContext: subject })
79
+ ```
80
+
81
+ A missing lookup is not proof that an outstanding enqueue cannot still arrive.
82
+ Look up again, or retry the application operation with its original explicit
83
+ idempotency key. Lookup requires query authorization for `__lookupMessage__`;
84
+ an existing message also requires authorization for its original operation.
85
+
86
+ Use `runtime.actorAdministration({ actorType, actorId, authorizationContext })`
87
+ for `deadLetters()`, `retryDeadLetter(id)`, `reminders()`, and
88
+ `resumeReminder({ name, runAt })`. Inspection is bounded to 1,000 records per
89
+ call. These operations call `authorizeAdministration` with resource
90
+ `actor:` followed by the canonical JSON identity tuple.
91
+
92
+ ## Realtime sessions
93
+
94
+ The application authenticates its HTTP upgrade before calling
95
+ `runtime.openWebSocket({ sessionId, expiresAt })`. Forward the returned response
96
+ to the browser. `sessionId` must be an opaque application session reference,
97
+ not a bearer credential or serialized user object. Never forward client-supplied
98
+ `X-Solid-Session-*` headers to the session namespace.
99
+
100
+ The session host's `resolveAuthorizationContext({ sessionId, environment })`
101
+ loads current authorization data and returns JSON, or `null` when access has
102
+ expired or been revoked. It runs when opening the connection, subscribing, and
103
+ delivering events. Actor policies still authorize subscriptions and each named
104
+ personalized payload. Authentication remains application-owned.
105
+
106
+ One hibernating WebSocket multiplexes up to 100 actors by default. Configure
107
+ `maxSubscriptions` on the session host to change that limit. The browser client,
108
+ version-1 envelopes, value versus invalidation projections, personalized
109
+ payloads, and revision fences use the existing protocol.
110
+
111
+ The handshake uses Fetch because WebSocket upgrades cannot travel through
112
+ ordinary RPC. Actor-to-session events use RPC. Durable registrations survive
113
+ hibernation; disconnect and expiry trigger registration cleanup. Reconnects
114
+ replay current committed projections, not every event missed while offline.
115
+
116
+ ## Capability boundaries
117
+
118
+ | Surface | Cloudflare backend |
119
+ | ------------------------------------------------------------------ | -------------------------------------- |
120
+ | Actor operations, getters, snapshots, state migrations | Supported |
121
+ | Durable sends, results, retries, rejection, destruction | Supported |
122
+ | Effects, reminders, cross-actor `sendTo()` | Supported |
123
+ | Browser subscriptions and personalized payloads | Supported through session hosts |
124
+ | Actor-scoped dead letters and reminder administration | Supported |
125
+ | `commitAction`, shared application SQL transactions | Unsupported |
126
+ | Global repository, reconciliation, process controls, SQL dashboard | Unsupported |
127
+ | Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions |
128
+ | SQL-to-Durable-Objects data migration | Not provided |
129
+
130
+ Unsupported runtime facilities raise `UnsupportedCapability`; staging a commit
131
+ action permanently fails that turn before any state or intent commits.
132
+ Application and effect code must run within Workers' APIs and execution limits.
133
+ Arbitrary JavaScript is cooperative; caller timeout does not preempt it.
134
+ Eviction may discard private fields, and does not guarantee `onDeactivate()`.
135
+
136
+ State defaults to a 1 MiB limit. Encoded records, including indexed copies of
137
+ fields, must also fit Cloudflare's 2 MB SQLite row limit. Oversized records raise
138
+ `PayloadTooLarge`; increasing a configured payload limit cannot bypass the
139
+ [platform limits](https://developers.cloudflare.com/durable-objects/platform/limits/).
140
+
141
+ Cloudflare hosting is JavaScript-specific. It does not change Ruby's SQL backend
142
+ or the guarantees of the existing SQL drivers.
143
+
144
+ ## Validate and release
145
+
146
+ `pnpm run test:cloudflare` runs the Workers integration suite. `pnpm run check`
147
+ type-checks the separate Workers target alongside Node; `pnpm run check:cloudflare`
148
+ checks the example bundle and its imports. Keep backend schema changes additive.
149
+ Once an actor persists a newer state version, rollback to older actor code may
150
+ be unsafe; test that rollback against the stored version before deploying it.
151
+
152
+ Before calling this backend stable, run a deployed soak in a disposable
153
+ namespace covering alarms without traffic, eviction/restart recovery,
154
+ hibernation, and slow external effects. Local Workers tests do not establish
155
+ Cloudflare production failover behavior.
package/docs/parity.md CHANGED
@@ -40,6 +40,10 @@ such boundary between a gem and its dependents.
40
40
 
41
41
  ## Runtime and correctness
42
42
 
43
+ SQL waiters read results and status from one statement. Ruby already checks
44
+ completion and returns the result from the same loaded message; no Ruby change
45
+ is needed for the JavaScript stale-result race fix.
46
+
43
47
  | Capability | Status | TypeScript shape or remaining work |
44
48
  | ------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
49
  | Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
@@ -80,15 +84,15 @@ such boundary between a gem and its dependents.
80
84
 
81
85
  ## Databases and wake-up
82
86
 
83
- | Capability | Status | TypeScript shape or remaining work |
84
- | ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85
- | SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
86
- | PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
87
- | MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
88
- | Durable polling fallback | Native | Every role progresses without a notification service. |
89
- | In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
90
- | PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
91
- | Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
87
+ | Capability | Status | TypeScript shape or remaining work |
88
+ | ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
89
+ | SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
90
+ | PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
91
+ | MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
92
+ | Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with `FOR UPDATE SKIP LOCKED`. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. |
93
+ | In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
94
+ | PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
95
+ | Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
92
96
 
93
97
  Every wake-up adapter above is opt-in. Neither runtime selects one
94
98
  automatically. An application that configures nothing keeps polling. Each
@@ -180,6 +184,21 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it
180
184
  found (the optional `arguments` default) is fixed and pinned by the shared
181
185
  fixture.
182
186
 
187
+ ## JavaScript-only: Cloudflare hosting
188
+
189
+ The experimental `solid-objects/cloudflare` backend hosts each actor identity
190
+ in a SQLite-backed Durable Object. Portable actor definitions, turn evaluation,
191
+ state migrations, and authorization are shared with the SQL runtime. Alarms
192
+ drive durable mailbox/outbox recovery, and session Durable Objects host
193
+ hibernating browser subscriptions.
194
+
195
+ Ruby hosting on Cloudflare is **Not applicable**. This backend introduces no
196
+ change to Ruby's SQL behavior or roadmap. Its JavaScript support is **Partial**
197
+ until deployed failover and soak validation completes. Shared SQL transactions,
198
+ commit actions, fleet administration, reconciliation, and the SQL dashboard
199
+ are explicit unsupported capabilities. The [backend matrix](cloudflare.md#capability-boundaries)
200
+ records these boundaries separately from SQL-runtime parity.
201
+
183
202
  ## Rails-specific surfaces
184
203
 
185
204
  Rails generators, Active Record models/controllers, Turbo rendering, and
package/docs/support.md CHANGED
@@ -2,17 +2,18 @@
2
2
 
3
3
  ## Runtime support
4
4
 
5
- | Component | Supported or tested range |
6
- | --------------- | ----------------------------------------------------------- |
7
- | Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
8
- | TypeScript | 5.9 or newer for TypeScript applications |
9
- | SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
10
- | PostgreSQL | 14 or newer; CI runs 14 and 18 |
11
- | MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
12
- | Redis wake-up | Optional; CI runs Redis 7 |
13
- | Browser client | Chromium through Playwright |
14
- | SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional |
15
- | Browser runtime | Chromium through Playwright; OPFS for persistent storage |
5
+ | Component | Supported or tested range |
6
+ | ------------------ | ------------------------------------------------------------------ |
7
+ | Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
8
+ | TypeScript | 5.9 or newer for TypeScript applications |
9
+ | SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
10
+ | PostgreSQL | 14 or newer; CI runs 14 and 18 |
11
+ | MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
12
+ | Redis wake-up | Optional; CI runs Redis 7 |
13
+ | Browser client | Chromium through Playwright |
14
+ | SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional |
15
+ | Browser runtime | Chromium through Playwright; OPFS for persistent storage |
16
+ | Cloudflare runtime | Experimental; Workers integration tests and Wrangler bundle checks |
16
17
 
17
18
  The package is ESM-only. PostgreSQL, MySQL, Redis, and SQLite WASM require
18
19
  their optional peer dependency. The Node SQLite adapter has no driver
@@ -0,0 +1,49 @@
1
+ # Cloudflare shopping cart
2
+
3
+ This is an intentionally public shopping cart. Anyone can add or remove items
4
+ from `ShoppingCart("demo-cart")`; destruction and administration remain denied.
5
+ Replace the demo session resolver with your application's session lookup before
6
+ using this example for private data.
7
+
8
+ The example code is here:
9
+
10
+ - [`shopping-cart.ts`](./shopping-cart.ts) — actor state, cart operations, and realtime observables
11
+ - [`worker.ts`](./worker.ts) — Worker routes, authorization, and Durable Object hosts
12
+ - [`wrangler.jsonc`](./wrangler.jsonc) — SQLite-backed Durable Object bindings and migrations
13
+ - [`environment.d.ts`](./environment.d.ts) — generated Wrangler binding types
14
+
15
+ From the repository root:
16
+
17
+ ```sh
18
+ pnpm install
19
+ pnpm run build
20
+ pnpm run dev:cloudflare
21
+ ```
22
+
23
+ In another terminal:
24
+
25
+ ```sh
26
+ curl http://localhost:8787/cart
27
+ curl -X POST http://localhost:8787/cart/items \
28
+ -H 'content-type: application/json' \
29
+ --data '{"sku":"book","name":"Solid Objects book","priceCents":2500,"quantity":1}'
30
+ curl -X POST http://localhost:8787/cart/remove \
31
+ -H 'content-type: application/json' \
32
+ --data '{"sku":"book"}'
33
+ curl -X POST http://localhost:8787/cart/clear-later
34
+ ```
35
+
36
+ The last call schedules the cart to clear five seconds later. Stop the local
37
+ server and start it again to verify persistence. Local state lives in
38
+ Wrangler's `.wrangler` directory. Each actor identity has its own SQLite-backed
39
+ Durable Object; browser connections use the separate `Sessions` class.
40
+
41
+ Connect the existing `SolidObjectsBrowserClient` to `/events` and subscribe to
42
+ `{ actorType: "ShoppingCart", actorId: "demo-cart" }`. The connection expires
43
+ after one hour. Reconnect and resubscribe to obtain the current committed
44
+ projection with `itemCount` and `totalCents`.
45
+
46
+ `pnpm run check:cloudflare` validates the production bundle without uploading it.
47
+ To deploy this example to your own account, run `fnox exec -- pnpm run deploy:cloudflare`.
48
+ Wrangler creates the two SQLite-backed namespaces using the configuration's
49
+ class migrations. No D1 database or external broker is required.
@@ -0,0 +1,14 @@
1
+ /* eslint-disable */
2
+ // Generated by Wrangler by running `wrangler types --config=examples/cloudflare/wrangler.jsonc --include-runtime=false examples/cloudflare/environment.d.ts` (hash: 30c3823992a5154da09c838c96a8f2fb)
3
+ interface __BaseEnv_Env {
4
+ ACTORS: DurableObjectNamespace<import("./worker").Actors>
5
+ SESSIONS: DurableObjectNamespace<import("./worker").Sessions>
6
+ }
7
+ declare namespace Cloudflare {
8
+ interface GlobalProps {
9
+ mainModule: typeof import("./worker")
10
+ durableNamespaces: "Actors" | "Sessions"
11
+ }
12
+ interface Env extends __BaseEnv_Env {}
13
+ }
14
+ interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,65 @@
1
+ import { Actor, broadcastValue } from "solid-objects/core"
2
+
3
+ interface CartItem {
4
+ name: string
5
+ priceCents: number
6
+ quantity: number
7
+ }
8
+
9
+ export class ShoppingCart extends Actor {
10
+ static override readonly actorType = "ShoppingCart"
11
+ items: Record<string, CartItem> = {}
12
+
13
+ addItem(input: { sku: string; name: string; priceCents: number; quantity?: number }): number {
14
+ if (
15
+ !input.sku ||
16
+ !input.name ||
17
+ !Number.isSafeInteger(input.priceCents) ||
18
+ input.priceCents < 0
19
+ )
20
+ throw new TypeError("sku, name, and a non-negative integer priceCents are required")
21
+ const quantity = input.quantity ?? 1
22
+ if (!Number.isSafeInteger(quantity) || quantity <= 0)
23
+ throw new TypeError("quantity must be a positive safe integer")
24
+ const existing = this.items[input.sku]
25
+ const combinedQuantity = (existing?.quantity ?? 0) + quantity
26
+ if (!Number.isSafeInteger(combinedQuantity))
27
+ throw new TypeError("combined quantity must be a positive safe integer")
28
+ this.items[input.sku] = {
29
+ name: input.name,
30
+ priceCents: input.priceCents,
31
+ quantity: combinedQuantity,
32
+ }
33
+ return this.totalCents
34
+ }
35
+
36
+ removeItem(input: { sku: string }): void {
37
+ delete this.items[input.sku]
38
+ }
39
+
40
+ clear(): void {
41
+ this.items = {}
42
+ }
43
+
44
+ clearLater(): void {
45
+ this.schedule({ at: new Date(Date.now() + 5_000), key: "clear" }).clear!()
46
+ }
47
+
48
+ get itemCount(): number {
49
+ return Object.values(this.items).reduce((total, item) => total + item.quantity, 0)
50
+ }
51
+
52
+ get totalCents(): number {
53
+ return Object.values(this.items).reduce(
54
+ (total, item) => total + item.priceCents * item.quantity,
55
+ 0,
56
+ )
57
+ }
58
+
59
+ override observables() {
60
+ return {
61
+ itemCount: broadcastValue(this.itemCount),
62
+ totalCents: broadcastValue(this.totalCents),
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ createDurableObjectsHost,
3
+ createDurableObjectsSessionHost,
4
+ createRuntime,
5
+ durableObjects,
6
+ type CloudflareConfiguration,
7
+ } from "solid-objects/cloudflare"
8
+ import { ShoppingCart } from "./shopping-cart.js"
9
+
10
+ function backend(environment: Env) {
11
+ return durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS })
12
+ }
13
+
14
+ function publicCart(input: {
15
+ actorType: string
16
+ actorId: string
17
+ authorizationContext: unknown
18
+ }): boolean {
19
+ return (
20
+ input.actorType === "ShoppingCart" &&
21
+ input.actorId === "demo-cart" &&
22
+ input.authorizationContext === "public-demo"
23
+ )
24
+ }
25
+
26
+ export class Actors extends createDurableObjectsHost<Env>({
27
+ actors: [ShoppingCart],
28
+ configure: (environment): CloudflareConfiguration => ({
29
+ backend: backend(environment),
30
+ authorizeMessage: publicCart,
31
+ authorizeQuery: publicCart,
32
+ authorizeSubscription: publicCart,
33
+ }),
34
+ }) {}
35
+
36
+ export class Sessions extends createDurableObjectsSessionHost<Env>({
37
+ backend,
38
+ resolveAuthorizationContext: ({ sessionId }) =>
39
+ sessionId === "demo-cart" ? "public-demo" : null,
40
+ }) {}
41
+
42
+ export default {
43
+ async fetch(request: Request, environment: Env): Promise<Response> {
44
+ const url = new URL(request.url)
45
+ const runtime = createRuntime({ backend: backend(environment) })
46
+ const cartReference = runtime.ref(ShoppingCart, "demo-cart")
47
+ const cart = cartReference.with({ authorizationContext: "public-demo" })
48
+ const isWebSocketRequest =
49
+ url.pathname === "/events" && request.headers.get("Upgrade") === "websocket"
50
+ const origin = request.headers.get("Origin")
51
+ if (isWebSocketRequest && origin !== null && origin !== url.origin)
52
+ return new Response("Forbidden", { status: 403 })
53
+ if (isWebSocketRequest)
54
+ return runtime.openWebSocket({
55
+ sessionId: "demo-cart",
56
+ expiresAt: new Date(Date.now() + 3_600_000),
57
+ })
58
+ if (request.method === "GET" && url.pathname === "/cart")
59
+ return Response.json(
60
+ await runtime.snapshot(cartReference, { authorizationContext: "public-demo" }),
61
+ )
62
+ if (request.method === "POST" && url.pathname === "/cart/items") {
63
+ const item = (await request.json()) as {
64
+ sku: string
65
+ name: string
66
+ priceCents: number
67
+ quantity?: number
68
+ }
69
+ return Response.json({ totalCents: await cart.addItem(item) })
70
+ }
71
+ if (request.method === "POST" && url.pathname === "/cart/remove") {
72
+ const item = (await request.json()) as { sku: string }
73
+ await cart.removeItem(item)
74
+ return new Response(null, { status: 204 })
75
+ }
76
+ if (request.method === "POST" && url.pathname === "/cart/clear-later") {
77
+ await cart.clearLater()
78
+ return new Response(null, { status: 202 })
79
+ }
80
+ return new Response(
81
+ "GET /cart; POST /cart/items; POST /cart/remove; POST /cart/clear-later; WebSocket /events",
82
+ {
83
+ status: url.pathname === "/" ? 200 : 404,
84
+ },
85
+ )
86
+ },
87
+ } satisfies ExportedHandler<Env>
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "../../node_modules/wrangler/config-schema.json",
3
+ "name": "solid-objects-shopping-cart",
4
+ "main": "worker.ts",
5
+ "compatibility_date": "2026-09-04",
6
+ "compatibility_flags": ["nodejs_compat"],
7
+ "durable_objects": {
8
+ "bindings": [
9
+ { "name": "ACTORS", "class_name": "Actors" },
10
+ { "name": "SESSIONS", "class_name": "Sessions" },
11
+ ],
12
+ },
13
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Actors", "Sessions"] }],
14
+ "observability": { "enabled": true },
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid-objects",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "Race-free realtime state per application identity, backed by your SQL database",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,6 +38,14 @@
38
38
  "MIT-LICENSE"
39
39
  ],
40
40
  "exports": {
41
+ "./core": {
42
+ "types": "./dist/core.d.ts",
43
+ "import": "./dist/core.js"
44
+ },
45
+ "./cloudflare": {
46
+ "types": "./dist/cloudflare/index.d.ts",
47
+ "import": "./dist/cloudflare/index.js"
48
+ },
41
49
  ".": {
42
50
  "types": "./dist/index.d.ts",
43
51
  "import": "./dist/index.js"
@@ -92,8 +100,12 @@
92
100
  }
93
101
  },
94
102
  "scripts": {
95
- "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs",
96
- "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit",
103
+ "test:cloudflare": "vitest run --config vitest.cloudflare.config.ts",
104
+ "dev:cloudflare": "wrangler dev --config examples/cloudflare/wrangler.jsonc",
105
+ "deploy:cloudflare": "wrangler deploy --config examples/cloudflare/wrangler.jsonc",
106
+ "check:cloudflare": "node scripts/check-cloudflare-imports.mjs && tsc -p tsconfig.cloudflare-example.json --noEmit && wrangler deploy --dry-run --config examples/cloudflare/wrangler.jsonc",
107
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.cloudflare-build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs",
108
+ "check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit && tsc -p tsconfig.cloudflare.json --noEmit",
97
109
  "check:browser-imports": "node scripts/check-browser-imports.mjs",
98
110
  "check:documentation": "node scripts/check-documentation.mjs",
99
111
  "check:parameters": "node scripts/check-parameter-style.mjs",
@@ -117,6 +129,8 @@
117
129
  "prepack": "pnpm run build"
118
130
  },
119
131
  "devDependencies": {
132
+ "@cloudflare/vitest-plugin": "^1.1.4",
133
+ "@cloudflare/workers-types": "^5.20260904.1",
120
134
  "@playwright/test": "^1.62.1",
121
135
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
122
136
  "@types/node": "^24.0.0",
@@ -130,14 +144,15 @@
130
144
  "signal-polyfill": "^0.2.2",
131
145
  "typescript": "^5.9.0",
132
146
  "vitest": "^4.1.10",
147
+ "wrangler": "^4.129.0",
133
148
  "ws": "^8.21.3"
134
149
  },
135
150
  "peerDependencies": {
136
151
  "@sqlite.org/sqlite-wasm": ">=3.50.0-build1",
137
- "signal-polyfill": ">=0.2.2",
138
152
  "mysql2": "^3.23.3",
139
153
  "pg": "^8.23.0",
140
- "redis": "^6.2.1"
154
+ "redis": "^6.2.1",
155
+ "signal-polyfill": ">=0.2.2"
141
156
  },
142
157
  "peerDependenciesMeta": {
143
158
  "@sqlite.org/sqlite-wasm": {