@shivaedev/contract 0.18.0 → 0.19.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/CHANGELOG.md +33 -0
- package/README.md +168 -178
- package/dist/automaticLive.d.ts +2 -2
- package/dist/automaticLive.d.ts.map +1 -1
- package/dist/automaticLive.js +4 -3
- package/dist/automaticLive.js.map +1 -1
- package/dist/bind.d.ts +11 -2
- package/dist/bind.d.ts.map +1 -1
- package/dist/bind.js +9 -0
- package/dist/bind.js.map +1 -1
- package/dist/clientOptions.d.ts +23 -0
- package/dist/clientOptions.d.ts.map +1 -0
- package/dist/clientOptions.js +2 -0
- package/dist/clientOptions.js.map +1 -0
- package/dist/contract.d.ts +4 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +6 -5
- package/dist/contract.js.map +1 -1
- package/dist/internal/bindingOwner.d.ts +1 -1
- package/dist/internal/bindingOwner.d.ts.map +1 -1
- package/dist/internal/clientOperations.d.ts.map +1 -1
- package/dist/internal/clientOperations.js +28 -7
- package/dist/internal/clientOperations.js.map +1 -1
- package/dist/internal/httpClient.d.ts +21 -0
- package/dist/internal/httpClient.d.ts.map +1 -0
- package/dist/internal/httpClient.js +13 -0
- package/dist/internal/httpClient.js.map +1 -0
- package/dist/internal/operationDeclaration.d.ts +19 -0
- package/dist/internal/operationDeclaration.d.ts.map +1 -0
- package/dist/internal/operationDeclaration.js +20 -0
- package/dist/internal/operationDeclaration.js.map +1 -0
- package/dist/internal/queryHints.d.ts +8 -0
- package/dist/internal/queryHints.d.ts.map +1 -0
- package/dist/internal/queryHints.js +34 -0
- package/dist/internal/queryHints.js.map +1 -0
- package/dist/live.d.ts +7 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +2 -2
- package/dist/live.js.map +1 -1
- package/dist/makeLiveServer.d.ts +27 -1
- package/dist/makeLiveServer.d.ts.map +1 -1
- package/dist/makeLiveServer.js +15 -8
- package/dist/makeLiveServer.js.map +1 -1
- package/dist/operation.d.ts +13 -9
- package/dist/operation.d.ts.map +1 -1
- package/dist/operation.js +5 -19
- package/dist/operation.js.map +1 -1
- package/dist/publicConfig.d.ts +9 -3
- package/dist/publicConfig.d.ts.map +1 -1
- package/dist/publicConfig.js +2 -1
- package/dist/publicConfig.js.map +1 -1
- package/dist/querySubscription.d.ts +15 -0
- package/dist/querySubscription.d.ts.map +1 -0
- package/dist/querySubscription.js +10 -0
- package/dist/querySubscription.js.map +1 -0
- package/dist/readPolicy.d.ts +11 -6
- package/dist/readPolicy.d.ts.map +1 -1
- package/dist/readPolicy.js +16 -19
- package/dist/readPolicy.js.map +1 -1
- package/dist/readPolicyErrors.d.ts +13 -0
- package/dist/readPolicyErrors.d.ts.map +1 -0
- package/dist/readPolicyErrors.js +6 -0
- package/dist/readPolicyErrors.js.map +1 -0
- package/dist/serverMetadata.d.ts +29 -11
- package/dist/serverMetadata.d.ts.map +1 -1
- package/dist/serverMetadata.js +19 -12
- package/dist/serverMetadata.js.map +1 -1
- package/dist/testing.d.ts +13 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -4
- package/dist/testing.js.map +1 -1
- package/package.json +6 -5
- package/src/automaticLive.ts +8 -3
- package/src/bind.ts +45 -3
- package/src/clientOptions.ts +35 -0
- package/src/contract.ts +11 -6
- package/src/internal/bindingOwner.ts +1 -1
- package/src/internal/clientOperations.ts +32 -11
- package/src/internal/httpClient.ts +21 -0
- package/src/internal/operationDeclaration.ts +28 -0
- package/src/internal/queryHints.ts +38 -0
- package/src/live.ts +2 -2
- package/src/makeLiveServer.ts +18 -12
- package/src/operation.ts +27 -37
- package/src/publicConfig.ts +2 -2
- package/src/querySubscription.ts +21 -0
- package/src/readPolicy.ts +23 -23
- package/src/readPolicyErrors.ts +4 -0
- package/src/serverMetadata.ts +35 -19
- package/src/testing.ts +2 -12
- package/dist/internal/makeHintPolicy.d.ts +0 -4
- package/dist/internal/makeHintPolicy.d.ts.map +0 -1
- package/dist/internal/makeHintPolicy.js +0 -25
- package/dist/internal/makeHintPolicy.js.map +0 -1
- package/dist/keys.d.ts +0 -34
- package/dist/keys.d.ts.map +0 -1
- package/dist/keys.js +0 -25
- package/dist/keys.js.map +0 -1
- package/src/internal/makeHintPolicy.ts +0 -40
- package/src/keys.ts +0 -62
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.1 - 2026-10-29
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Automatic live tracking no longer resubscribes, and refetches every tracked query, when a query is read again after the garbage collector reclaimed its atom. A query's tracking id now follows its tag and payload instead of the atom object AtomRpc holds weakly.
|
|
8
|
+
|
|
9
|
+
## 0.19.0 - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `@shivaedev/contract/bind.ts` builds a bound HTTP RPC client and atom runtime from a contract and URL, with an inferred request transform for cookies or bearer tokens.
|
|
14
|
+
- `@shivaedev/react/client/useNow.ts` exports `useNow(interval, runtime?)` to render the runtime's Effect clock and refresh on the requested interval, including with TestClock.
|
|
15
|
+
- Declare collection policies beside module contracts with `ModuleReadPolicies` and combine them with `mergeReadPolicies`. Unclaimed collections are denied. Each policy receives the request's optional viewer.
|
|
16
|
+
- The application host accepts a merged `readPolicy` and checks queries and live hints inside native session middleware, using its resolved identity without another session lookup. Policies can use application services and return `Forbidden` or `AuthUnavailable`.
|
|
17
|
+
- Query authorization and live-hint filtering check keys concurrently so native `Effect.request` resolvers can batch membership checks. Plain Effect policies remain supported.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Live queries accept inputs JSON cannot encode, such as bigint ids.
|
|
22
|
+
- Enforce declared query read policies at the hosted RPC handler boundary, including contracts without identity middleware, whose viewer is anonymous. Query RPC error schemas include `QueryReadDenied` so native clients receive typed refusals before handlers run.
|
|
23
|
+
- Keep collection methods and untyped schema internals from making data parameters appear to carry callable services. Direct service implementations, nested dependency fields and service arrays remain subject to `effect/service-parameters`.
|
|
24
|
+
|
|
25
|
+
### Breaking
|
|
26
|
+
|
|
27
|
+
- Move key imports from `@shivaedev/contract/keys.ts` to `@shivaedev/changes/keys.ts`. Declare bucket fields with `collection(name, idSchema, fields)` and publish affected buckets alongside record keys.
|
|
28
|
+
- Replace collection policy maps, `mergeReadPolicies`, `ReadPolicy` and `ReadPolicyMemoWindow` with each query's `readPolicy(payload, viewer)`. Declare the same query's live scope with `reads(payload, viewer)`. Bound query atoms and subscriptions refuse missing policies with a declaration error; one-off query `run` calls remain available. Denied queries refuse the entire subscription. Server adapters replace `QueryReads` and `ChangesReads` with `QueryDefinition` and `ChangesQueries`. Custom middleware calls `authorizeReads(effect, request, viewer)` and declares `MissingReadPolicy` and `QueryReadDenied` errors.
|
|
29
|
+
- Custom subscription adapters yield `ChangesQueries.resolve(references)`, call `querySubscription(requests, viewer)` and pass the authorized result to `makeLiveServer.subscribe(subscription, context)`. Metadata values expose `QueryDefinition.definition` and `CommandInvalidation.invalidates` instead of bare definitions and callbacks. Yield the Effect returned by `queryKeys(queries, references)` when projecting keys for a custom stream. `resolveChanges` supplies the authorized subscription in hosted applications. Changed hints can carry query positions so bindings can rerun session-scoped reads without deriving session scopes in the browser.
|
|
30
|
+
- Remove `appHost`'s `readPolicy` option and module policy registration. Session and permission middleware apply query policies using the current session viewer. Handle the new typed policy errors in native clients, including `QueryReadDenied` on query RPCs without identity middleware; widen explicit query error annotations accordingly.
|
|
31
|
+
- Replace `sessionCommandChanges` with `commandChanges` for custom session middleware. Command invalidation callbacks can receive the session viewer as their third argument. Announce viewer buckets there; background writers must explicitly publish those bucket keys after commit.
|
|
32
|
+
- Built-in admin queries declare their permission policies alongside their schemas and read scopes. Consumers keep supplying the existing session and permission middleware layers.
|
|
33
|
+
- Local Work Board and Fleet queries declare their existing loopback read access explicitly so their generated live subscriptions remain available.
|
|
34
|
+
- `authorizeReads` now takes `(effect, request, policy, viewer, denied)`, and policies take `(key, viewer)`. Pass `Option.some(resolvedIdentity)` or `Option.none()` before the denial effect. Existing unary policy functions may ignore the new viewer argument. Apps using `appHost` can replace custom read middleware with its `readPolicy` option and merge their modules' policy maps.
|
|
35
|
+
|
|
3
36
|
## 0.18.0 - 2026-10-27
|
|
4
37
|
|
|
5
38
|
### Added
|
package/README.md
CHANGED
|
@@ -7,16 +7,16 @@ Declare a feature's queries, commands, streams, expected failures and refresh de
|
|
|
7
7
|
A feature often repeats its inputs, results and errors on both sides of an RPC boundary, then separately wires which views a write should refresh. Those copies are easy to make disagree. Put the boundary and its dependencies together:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
import { Schema } from "effect";
|
|
10
|
+
import { Effect, Schema } from "effect";
|
|
11
11
|
import { contract } from "@shivaedev/contract/contract.ts";
|
|
12
|
-
import { collection } from "@shivaedev/
|
|
12
|
+
import { collection } from "@shivaedev/changes/keys.ts";
|
|
13
13
|
import { command, query } from "@shivaedev/contract/operation.ts";
|
|
14
14
|
|
|
15
15
|
const notes = collection("notes", Schema.Number);
|
|
16
|
-
const List = query("list", { reads: () => [notes.list], success: Schema.Array(Schema.String) });
|
|
16
|
+
const List = query("list", { readPolicy: () => Effect.succeed(true), reads: () => [notes.list], success: Schema.Array(Schema.String) });
|
|
17
17
|
const Rename = command("rename", {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
payload: { id: Schema.Number, title: Schema.String },
|
|
19
|
+
invalidates: ({ id }) => [notes.item(id)],
|
|
20
20
|
});
|
|
21
21
|
const Notes = contract("notes", { queries: [List], commands: [Rename] });
|
|
22
22
|
```
|
|
@@ -40,47 +40,49 @@ Optional live hints use those same keys. A hint asks for another read; it does n
|
|
|
40
40
|
Keep the shared declaration in a module that both server and client can import:
|
|
41
41
|
|
|
42
42
|
```ts
|
|
43
|
-
import { Schema } from "effect";
|
|
43
|
+
import { Effect, Schema } from "effect";
|
|
44
44
|
import { contract } from "@shivaedev/contract/contract.ts";
|
|
45
|
-
import { collection } from "@shivaedev/
|
|
45
|
+
import { collection } from "@shivaedev/changes/keys.ts";
|
|
46
46
|
import { command, query } from "@shivaedev/contract/operation.ts";
|
|
47
47
|
import { fieldRejection } from "@shivaedev/contract/rejection.ts";
|
|
48
48
|
|
|
49
49
|
export class Note extends Schema.Class<Note>("Note")({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
body: Schema.String,
|
|
51
|
+
id: Schema.Number,
|
|
52
|
+
title: Schema.String,
|
|
53
53
|
}) {}
|
|
54
54
|
export const Draft = Schema.Struct({ body: Schema.String, title: Schema.String });
|
|
55
55
|
export class NoteMissing extends Schema.TaggedError<NoteMissing>()("NoteMissing", { id: Schema.Number }) {}
|
|
56
56
|
export const notes = collection("notes", Note.fields.id);
|
|
57
57
|
|
|
58
58
|
export const Get = query("get", {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
readPolicy: () => Effect.succeed(true),
|
|
60
|
+
payload: { id: Schema.Number },
|
|
61
|
+
success: Note,
|
|
62
|
+
rejections: { NoteMissing },
|
|
63
|
+
reads: ({ id }) => [notes.item(id)],
|
|
63
64
|
});
|
|
64
65
|
export const List = query("list", {
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
readPolicy: () => Effect.succeed(true),
|
|
67
|
+
success: Schema.Array(Note),
|
|
68
|
+
reads: () => [notes.list],
|
|
67
69
|
});
|
|
68
70
|
export const Rename = command("rename", {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
payload: { id: Schema.Number, title: Schema.String },
|
|
72
|
+
success: Note,
|
|
73
|
+
rejections: { NoteMissing, Invalid: fieldRejection(Draft, ["title"]) },
|
|
74
|
+
invalidates: ({ id }) => [notes.item(id)],
|
|
73
75
|
});
|
|
74
76
|
export const Create = command("create", {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
payload: Draft,
|
|
78
|
+
success: Note,
|
|
79
|
+
rejections: { Invalid: fieldRejection(Draft) },
|
|
80
|
+
invalidates: (_draft, note) => [notes.item(note.id)],
|
|
79
81
|
});
|
|
80
82
|
export const Notes = contract("notes", { queries: [Get, List], commands: [Rename, Create] });
|
|
81
83
|
```
|
|
82
84
|
|
|
83
|
-
Save this module as `notes.ts`. The payload can be struct fields, as in `Get`, or a schema, as in `Create`. An omitted payload or success is `Schema.Void`; no declared rejections gives `Schema.Never`. The `reads`
|
|
85
|
+
Save this module as `notes.ts`. The payload can be struct fields, as in `Get`, or a schema, as in `Create`. An omitted payload or success is `Schema.Void`; no declared rejections gives `Schema.Never`. The server resolves `reads` with the decoded payload and session viewer. For local command dependencies, the browser also evaluates it with `Option.none()`; return no viewer keys for that case. Authorized subscription scopes always come from the server. `invalidates` receives the decoded payload, successful result and optional session viewer, so a create call can invalidate the ID the server assigned.
|
|
84
86
|
|
|
85
87
|
The collection's ID schema gives `item` its ID type. Here, `notes.item` takes a number. Query and command callbacks retain their payload and result types, including when the operations are declared inline inside `contract`.
|
|
86
88
|
|
|
@@ -91,14 +93,14 @@ Operation names must differ within a contract, including across queries, command
|
|
|
91
93
|
Import the shared expected failures from `@shivaedev/errors/taxonomy.ts`: `NotFound`, `Unauthorized`, `Forbidden`, `BadRequest`, `Conflict`, `PreconditionFailed`, `TooManyRequests`, `AuthUnavailable`, `InternalServerError` and `ServiceUnavailable`. Each class is a tagged error schema with a string `message`; `BadRequest` and `Conflict` also accept an optional string `field`. Declare only the failures an operation can return:
|
|
92
94
|
|
|
93
95
|
```ts
|
|
94
|
-
import { Schema } from "effect";
|
|
96
|
+
import { Effect, Schema } from "effect";
|
|
95
97
|
import { Rpc } from "effect/rpc";
|
|
96
98
|
import { BadRequest, Conflict } from "@shivaedev/errors/taxonomy.ts";
|
|
97
99
|
|
|
98
100
|
export const Rename = Rpc.make("Rename", {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
error: Schema.Union([BadRequest, Conflict]),
|
|
102
|
+
payload: { name: Schema.String },
|
|
103
|
+
success: Schema.String,
|
|
102
104
|
});
|
|
103
105
|
```
|
|
104
106
|
|
|
@@ -124,28 +126,35 @@ Implement the contract with native `of` and `toLayer`:
|
|
|
124
126
|
import { Effect, Ref, Stream } from "effect";
|
|
125
127
|
import { Create, Get, Note, Notes, Rename } from "./notes.ts";
|
|
126
128
|
|
|
127
|
-
export const NotesHandlers = Notes.toLayer(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}))
|
|
129
|
+
export const NotesHandlers = Notes.toLayer(
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const stored = yield* Ref.make(
|
|
132
|
+
new Map([
|
|
133
|
+
[1, new Note({ body: "", id: 1, title: "One" })],
|
|
134
|
+
[2, new Note({ body: "", id: 2, title: "Two" })],
|
|
135
|
+
]),
|
|
136
|
+
);
|
|
137
|
+
const find = (id: number) =>
|
|
138
|
+
Effect.flatMap(Ref.get(stored), (all) => {
|
|
139
|
+
const note = all.get(id);
|
|
140
|
+
return note === undefined ? Get.reject.NoteMissing({ id }) : Effect.succeed(note);
|
|
141
|
+
});
|
|
142
|
+
const save = (note: Note) => Ref.update(stored, (all) => new Map([...all, [note.id, note]])).pipe(Effect.as(note));
|
|
143
|
+
return Notes.of({
|
|
144
|
+
"notes.$changes": () => Stream.never,
|
|
145
|
+
"notes.get": ({ id }) => find(id),
|
|
146
|
+
"notes.list": () => Effect.map(Ref.get(stored), (all) => [...all.values()]),
|
|
147
|
+
"notes.rename": ({ id, title }) =>
|
|
148
|
+
title.trim() === ""
|
|
149
|
+
? Rename.reject.Invalid({ field: "title", message: "Enter a title" })
|
|
150
|
+
: Effect.flatMap(find(id), (note) => save(new Note({ ...note, title }))),
|
|
151
|
+
"notes.create": (draft) =>
|
|
152
|
+
draft.title === ""
|
|
153
|
+
? Create.reject.Invalid({ field: "title", message: "Enter a title" })
|
|
154
|
+
: Effect.flatMap(Ref.get(stored), (all) => save(new Note({ id: all.size + 1, ...draft }))),
|
|
155
|
+
});
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
149
158
|
```
|
|
150
159
|
|
|
151
160
|
Save this module as `handlers.ts`. An application can put repository and service calls in these handlers. The contract checks handler result and rejection types; it does not implement persistence, authorization or transactions. This in-memory server keeps the reserved change stream open without publishing hints. When a Platform host supplies that handler, build application handlers from `Notes.application` and keep `Notes` as the host and client group.
|
|
@@ -174,7 +183,7 @@ import { Notes } from "./notes.ts";
|
|
|
174
183
|
import { NotesHandlers } from "./handlers.ts";
|
|
175
184
|
|
|
176
185
|
export const NotesRoutes = RpcServer.layerHttp({ group: Notes, path: "/rpc", protocol: "http" }).pipe(
|
|
177
|
-
|
|
186
|
+
Layer.provide([NotesHandlers, RpcSerialization.layerJson]),
|
|
178
187
|
);
|
|
179
188
|
```
|
|
180
189
|
|
|
@@ -189,16 +198,35 @@ import { bind } from "@shivaedev/contract/bind.ts";
|
|
|
189
198
|
import { Notes } from "./notes.ts";
|
|
190
199
|
|
|
191
200
|
export class NotesClient extends AtomRpc.Service<NotesClient>()("app/NotesClient", {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
201
|
+
group: Notes,
|
|
202
|
+
protocol: RpcClient.layerProtocolHttp({ url: "http://127.0.0.1:3000/rpc" }).pipe(
|
|
203
|
+
Layer.provide([FetchHttpClient.layer, RpcSerialization.layerJson]),
|
|
204
|
+
),
|
|
196
205
|
}) {}
|
|
197
206
|
export const api = bind(Notes, NotesClient);
|
|
198
207
|
```
|
|
199
208
|
|
|
200
209
|
Save the client module as `client.ts`. The [native RPC guide](https://github.com/ShivaeDev/platform/blob/main/docs/framework/native-rpc.md) covers hosting; the [reference composition](https://github.com/ShivaeDev/platform/blob/main/docs/framework/composed-example.md) composes handlers, request identity, SQL and a rendered client.
|
|
201
210
|
|
|
211
|
+
### Build an HTTP client in one call
|
|
212
|
+
|
|
213
|
+
Instead of declaring the `AtomRpc.Service` yourself, use `makeClient` once per session to build the HTTP client and bind the contract to it:
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
217
|
+
import { makeClient } from "@shivaedev/contract/bind.ts";
|
|
218
|
+
import { Notes } from "./notes.ts";
|
|
219
|
+
|
|
220
|
+
const { api, runtime } = makeClient(Notes, {
|
|
221
|
+
url: "/rpc",
|
|
222
|
+
transformClient: (client) => HttpClient.mapRequest(client, HttpClientRequest.setHeader("authorization", "Bearer session-token")),
|
|
223
|
+
});
|
|
224
|
+
const note = api.get.query({ id: 1 });
|
|
225
|
+
const rename = runtime.fn(api.rename.run);
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The request transform is optional and its native HTTP client type is inferred. Tests can set cookie and Origin headers through the same option. The default transport uses Fetch and NDJSON; `httpClient` and `serialization` accept native Layers for another HTTP client or codec. `group` can supply a larger native RPC group containing the contract, such as an admin registry. The result is a native client Layer that a host can pass to `ManagedRuntime.make`. It also exposes the native `Client` service and a scoped `client` Effect for callers outside React. Session-scoped contracts require `registry` in the options, just as `bind` does.
|
|
229
|
+
|
|
202
230
|
### 3. Every day: read and change
|
|
203
231
|
|
|
204
232
|
```ts
|
|
@@ -208,8 +236,6 @@ const readOnce = api.get.run({ id: 1 });
|
|
|
208
236
|
const rename = api.rename.run({ id: 1, title: "Uno" });
|
|
209
237
|
```
|
|
210
238
|
|
|
211
|
-
`queryCoalesceWindow(query)` from `@shivaedev/contract/queryCoalesceWindow.ts` returns a writable native atom for that bound query's live changed-hint window. Set it through the query's registry to a Duration, or `undefined` to use the live connection's default. Changing it does not refetch or reconnect; the next window uses the new value. React consumers can set it with `useQuery(query, { coalesceWindow: "2 seconds" })`.
|
|
212
|
-
|
|
213
239
|
`one` and `list` are native query atoms. `readOnce` and `rename` are Effects for individual calls. A query's Effect requires `NotesClient`; a command's Effect also requires native `Reactivity`. Commands expose `run`, and have no `query` member.
|
|
214
240
|
|
|
215
241
|
Mount the atoms and run commands through the service's atom runtime in the same registry:
|
|
@@ -225,9 +251,7 @@ const list = api.list.query();
|
|
|
225
251
|
const releaseOne = registry.mount(one);
|
|
226
252
|
const releaseList = registry.mount(list);
|
|
227
253
|
|
|
228
|
-
const renamed = await Effect.runPromise(
|
|
229
|
-
AtomRegistry.getResult(registry, NotesClient.runtime.atom(api.rename.run({ id: 1, title: "Uno" }))),
|
|
230
|
-
);
|
|
254
|
+
const renamed = await Effect.runPromise(AtomRegistry.getResult(registry, NotesClient.runtime.atom(api.rename.run({ id: 1, title: "Uno" }))));
|
|
231
255
|
|
|
232
256
|
releaseOne();
|
|
233
257
|
releaseList();
|
|
@@ -238,54 +262,36 @@ The successful rename invalidates note 1 and the list. A rejected rename invalid
|
|
|
238
262
|
|
|
239
263
|
The dependency rule is deliberately asymmetric:
|
|
240
264
|
|
|
241
|
-
| Declared key
|
|
242
|
-
|
|
|
243
|
-
| `notes.item(1)` | `"notes:1"` | `"notes:1"` and `"notes"`
|
|
244
|
-
| `notes.list`
|
|
265
|
+
| Declared key | Query reads | Successful command invalidates |
|
|
266
|
+
| --------------- | ----------- | ------------------------------ |
|
|
267
|
+
| `notes.item(1)` | `"notes:1"` | `"notes:1"` and `"notes"` |
|
|
268
|
+
| `notes.list` | `"notes"` | `"notes"` |
|
|
245
269
|
|
|
246
|
-
`readKeys` and `invalidationKeys` produce these native keys and remove duplicates. An item change refreshes that item and its list, preserving other item queries. A list-only invalidation refreshes list queries and leaves item queries alone. These keys match native Reactivity's record-key hashing of `{ notes: [1] }`.
|
|
270
|
+
`readKeys` and `invalidationKeys` from `@shivaedev/changes/keys.ts` produce these native keys and remove duplicates. An item change refreshes that item and its list, preserving other item queries. A list-only invalidation refreshes list queries and leaves item queries alone. These keys match native Reactivity's record-key hashing of `{ notes: [1] }`.
|
|
247
271
|
|
|
248
272
|
Client invalidation follows RPC success. Database hosts from `@shivaedev/server` also publish declared command invalidations automatically after the handler succeeds, excluding keys already announced by repository commits, using `transact` from [`sql`](https://github.com/ShivaeDev/platform/tree/main/packages/sql); commit-bound changes belong to [`changes`](https://github.com/ShivaeDev/platform/tree/main/packages/changes).
|
|
249
273
|
|
|
250
274
|
### Session-owned keys
|
|
251
275
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
const preferences = collection("preferences", Schema.String);
|
|
256
|
-
const Preferences = contract("preferences", {
|
|
257
|
-
queries: [query("self", {
|
|
258
|
-
payload: { ownerId: Schema.String },
|
|
259
|
-
reads: ({ ownerId }) => [preferences.item(ownerId)],
|
|
260
|
-
success: Schema.String,
|
|
261
|
-
})],
|
|
262
|
-
commands: [command("save", {
|
|
263
|
-
payload: { ownerId: Schema.String, title: Schema.String },
|
|
264
|
-
invalidates: ({ ownerId }) => [preferences.item(ownerId)],
|
|
265
|
-
success: Schema.String,
|
|
266
|
-
})],
|
|
267
|
-
}).middleware(Authenticated);
|
|
268
|
-
|
|
269
|
-
const api = bind(Preferences, Client);
|
|
270
|
-
const self = api.self.query({ ownerId: session.userId });
|
|
271
|
-
const save = api.save.run({ ownerId: session.userId, title: "My preferences" });
|
|
272
|
-
```
|
|
273
|
-
|
|
274
|
-
After migration, remove `ownerId` from both payload schemas and every call site. Add `keyScope: "session"` and keep a logical item key for the self-only record:
|
|
276
|
+
A self-only feature, such as a user's preferences, reads and changes only the signed-in account's records. Declare its contract with `keyScope: "session"`. The server chooses the account from request-local identity, so payloads carry no owner field, and reads and invalidations name a logical item key for the record:
|
|
275
277
|
|
|
276
278
|
```ts
|
|
277
279
|
const preferences = collection("preferences", Schema.String);
|
|
278
280
|
const Preferences = contract("preferences", {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
keyScope: "session",
|
|
282
|
+
queries: [
|
|
283
|
+
query("self", {
|
|
284
|
+
reads: () => [preferences.item("self")],
|
|
285
|
+
success: Schema.String,
|
|
286
|
+
}),
|
|
287
|
+
],
|
|
288
|
+
commands: [
|
|
289
|
+
command("save", {
|
|
290
|
+
payload: { title: Schema.String },
|
|
291
|
+
invalidates: () => [preferences.item("self")],
|
|
292
|
+
success: Schema.String,
|
|
293
|
+
}),
|
|
294
|
+
],
|
|
289
295
|
}).middleware(Authenticated);
|
|
290
296
|
|
|
291
297
|
const api = bind(Preferences, Client, { registry });
|
|
@@ -293,30 +299,14 @@ const self = api.self.query(undefined);
|
|
|
293
299
|
const save = api.save.run({ title: "My preferences" });
|
|
294
300
|
```
|
|
295
301
|
|
|
296
|
-
The examples use `Schema` and `Effect` from `effect`, `contract`, `query`, `command
|
|
297
|
-
|
|
298
|
-
```tsx
|
|
299
|
-
import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
|
|
300
|
-
|
|
301
|
-
<SessionBoundary
|
|
302
|
-
session={session}
|
|
303
|
-
identify={(current) => current.generation}
|
|
304
|
-
connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
|
|
305
|
-
recheck={recheck}
|
|
306
|
-
signedOut={<p>Signed out</p>}
|
|
307
|
-
>
|
|
308
|
-
{(api) => <PreferencesScreen api={api} />}
|
|
309
|
-
</SessionBoundary>
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
`makePreferencesClient(token)` creates the application's native AtomRpc service and configures its transport credentials. Existing one-argument `connect` callbacks continue to work. The boundary allocates the registry before calling `connect`; an unchanged generation keeps its binding and state. Account replacement, sign-out and same-account re-entry dispose the old registry, private query tracking, live registrations and pending refreshes. Choose a generation that changes whenever the auth owner starts a new session.
|
|
302
|
+
The examples use `Schema` and `Effect` from `effect`, `contract`, `query`, `command` and `bind` from contract's defining modules, `collection` from `@shivaedev/changes/keys.ts`, and `Authenticated` from `@shivaedev/identity/middleware.ts`. `Client` is the application's native AtomRpc service, and `registry` is the atom registry that owns the session's queries. In React, `SessionBoundary` supplies that registry to `connect`, as [react's session-scoped bindings](https://github.com/ShivaeDev/platform/blob/main/packages/react/README.md#session-scoped-bindings) show.
|
|
313
303
|
|
|
314
|
-
Handlers
|
|
304
|
+
Handlers read the account from request-local `Identity`, imported from `@shivaedev/identity/identity.ts`:
|
|
315
305
|
|
|
316
306
|
```ts
|
|
317
307
|
Preferences.application.toLayer({
|
|
318
|
-
|
|
319
|
-
|
|
308
|
+
"preferences.self": () => Effect.flatMap(Identity, ({ id }) => store.read(id)),
|
|
309
|
+
"preferences.save": ({ title }) => Effect.flatMap(Identity, ({ id }) => store.save(id, title)),
|
|
320
310
|
});
|
|
321
311
|
```
|
|
322
312
|
|
|
@@ -340,9 +330,12 @@ import type { ResumeSource } from "@shivaedev/contract/resume.ts";
|
|
|
340
330
|
import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
|
|
341
331
|
|
|
342
332
|
const resume: ResumeSource = (notify) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
333
|
+
const stopApp = onAppResume(notify);
|
|
334
|
+
const stopNetwork = onNetworkReturn(notify);
|
|
335
|
+
return () => {
|
|
336
|
+
stopApp();
|
|
337
|
+
stopNetwork();
|
|
338
|
+
};
|
|
346
339
|
};
|
|
347
340
|
const api = bind(Notes, NotesClient, { resume });
|
|
348
341
|
```
|
|
@@ -355,14 +348,14 @@ For a direct subscription, configure the server and `NotesClient` with `RpcSeria
|
|
|
355
348
|
import { Effect, Stream } from "effect";
|
|
356
349
|
import { NotesClient } from "./client.ts";
|
|
357
350
|
|
|
358
|
-
const changes = Stream.unwrap(
|
|
359
|
-
|
|
360
|
-
)
|
|
351
|
+
const changes = Stream.unwrap(
|
|
352
|
+
NotesClient.use((client) => Effect.succeed(client("notes.$changes", { queries: [{ query: "get", payload: { id: 1 } }] }))),
|
|
353
|
+
);
|
|
361
354
|
```
|
|
362
355
|
|
|
363
|
-
The contract includes the reserved stream's payload, `LiveHint` elements and middleware failures in its native RPC type. The subscription uses the contract's middleware and needs no separate RPC group or cast. The caller owns stream consumption and interruption. References name declared queries and reuse their payload schemas; payloadless queries use `{ query: "list", payload: undefined }`.
|
|
356
|
+
The contract includes the reserved stream's payload, `LiveHint` elements and middleware failures in its native RPC type. The subscription uses the contract's middleware and needs no separate RPC group or cast. The caller owns stream consumption and interruption. References name declared queries and reuse their payload schemas; payloadless queries use `{ query: "list", payload: undefined }`. Custom subscription handlers yield the Effect from the `ChangesQueries` annotation's `resolve(references)` method, call `querySubscription(requests, viewer)` and pass its authorized result to `makeLiveServer.subscribe(subscription, context)`. Hosted applications use `resolveChanges` from identity to supply that subscription. Session-scoped bindings track mounted queries per registry and drop their references on unmount or disposal. Ordinary bindings track query references when atoms are created.
|
|
364
357
|
|
|
365
|
-
For a custom transport or explicit pause
|
|
358
|
+
For a custom transport or explicit pause and status controls, use the native live APIs.
|
|
366
359
|
|
|
367
360
|
Declare subscriptions with `stream` in the same contract. Its `success` schema describes each element, and its `rejections` become the native stream error schema. `bind` exposes `run(payload)` as a native `Stream`, preserving declared rejections, middleware errors and RPC client errors. Completion and client interruption release the server subscription. The compiler rejects a unary client for a streaming operation.
|
|
368
361
|
|
|
@@ -395,18 +388,18 @@ import { LiveNotes } from "./live-notes.ts";
|
|
|
395
388
|
import { notes } from "./notes.ts";
|
|
396
389
|
|
|
397
390
|
class Client extends AtomRpc.Service<Client>()("app/LiveNotes", {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
391
|
+
group: LiveNotes,
|
|
392
|
+
protocol: RpcClient.layerProtocolHttp({ url: "http://127.0.0.1:3000/rpc" }).pipe(
|
|
393
|
+
Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson]),
|
|
394
|
+
),
|
|
402
395
|
}) {}
|
|
403
396
|
const api = bind(LiveNotes, Client);
|
|
404
397
|
const registry = AtomRegistry.make();
|
|
405
398
|
const updates = live(Client.runtime, {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
399
|
+
stream: api.subscribe.run(undefined),
|
|
400
|
+
resyncKeys: [notes.item(1), notes.item(2), notes.list],
|
|
401
|
+
resume: resumeSignal({ window }),
|
|
402
|
+
retryDelay: "100 millis",
|
|
410
403
|
});
|
|
411
404
|
const releaseQuery = registry.mount(api.get.query({ id: 1 }));
|
|
412
405
|
const releaseConnection = registry.mount(updates.connection);
|
|
@@ -414,14 +407,16 @@ const releaseConnection = registry.mount(updates.connection);
|
|
|
414
407
|
|
|
415
408
|
`LiveNotes` supplies both the declaration for binding and the native RPC group for transport. Stream read keys describe its dependencies; `run` does not mount query atoms or trigger automatic invalidation. Here the caller explicitly reconciles two items and their list. `bind` also accepts a larger native client group that preserves the contract RPC types. The [live updates guide](https://github.com/ShivaeDev/platform/blob/main/docs/framework/live-updates.md) shows server and client composition, and the [HTTP fixture](https://github.com/ShivaeDev/platform/tree/main/packages/contract/src/test-support/live) supplies a complete working server.
|
|
416
409
|
|
|
417
|
-
| Observation
|
|
418
|
-
|
|
|
419
|
-
| `Changed` with keys after the first hint
|
|
420
|
-
| `Resync`
|
|
410
|
+
| Observation | Invalidation |
|
|
411
|
+
| ----------------------------------------- | ------------------------------------------------------ |
|
|
412
|
+
| `Changed` with keys after the first hint | Affected items and their lists. |
|
|
413
|
+
| `Resync` | The exact `resyncKeys` read scope. |
|
|
421
414
|
| First hint on a new or reconnected stream | The full explicit scope, even if the hint is targeted. |
|
|
422
415
|
|
|
423
416
|
Changed hints share a fixed 20 ms window by default. Their keys merge, and each affected bound query refetches once per window. A bound query already fetching keeps its request and refetches once more after it settles if further hints arrive. Set `coalesceWindow` in `live()` to a native Duration, or set `SessionBoundary`'s `liveCoalesceWindow` for automatic session connections. Zero restores immediate per-hint invalidation, including interruption of in-flight reads. Full reconciliation on first hint, reconnect, resume and unpause stays immediate. Registry teardown cancels a pending window.
|
|
424
417
|
|
|
418
|
+
`queryCoalesceWindow(query)` from `@shivaedev/contract/queryCoalesceWindow.ts` returns a writable native atom for that bound query's live changed-hint window. Set it through the query's registry to a Duration, or `undefined` to use the live connection's default. Changing it does not refetch or reconnect; the next window uses the new value. React consumers can set it with `useQuery(query, { coalesceWindow: "2 seconds" })`.
|
|
419
|
+
|
|
425
420
|
`LiveHint` and `Key` are schemas. Decoding rejects unknown hint tags and item keys with an invalid ID type.
|
|
426
421
|
|
|
427
422
|
```ts
|
|
@@ -448,39 +443,33 @@ Call the release functions and dispose the registry when the application's root
|
|
|
448
443
|
|
|
449
444
|
Import `makeLiveServer` from `@shivaedev/contract/makeLiveServer.ts` and create it once with the server's `Reactivity` service. After application authorization, return `hints.stream([orders.item(id)])` from the native streaming RPC handler. Each subscription starts with `Resync`; invalidating its read keys produces `Changed` with those declared keys. `hints.resync` sends `Resync` to every live stream owned by that helper, including streams for otherwise unrelated keys. Independent helpers keep their resync scopes separate even when they share Reactivity. The native stream owns registration and interruption. Keep this server helper separate from the browser's `live` coordinator.
|
|
450
445
|
|
|
451
|
-
|
|
446
|
+
### Query policies, record keys and buckets
|
|
447
|
+
|
|
448
|
+
A query declares its decoded input, result, `readPolicy` and `reads` together. `readPolicy(payload, viewer)` returns an Effect boolean. `viewer` is the session's `Option<{ id: string }>`; the browser does not send the identity. Policies may use browser-safe Effect service tags whose implementations the server supplies.
|
|
452
449
|
|
|
453
450
|
```ts
|
|
454
|
-
import {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
)
|
|
451
|
+
import { Effect, Option, Schema } from "effect";
|
|
452
|
+
import { collection } from "@shivaedev/changes/keys.ts";
|
|
453
|
+
import { query } from "@shivaedev/contract/operation.ts";
|
|
454
|
+
|
|
455
|
+
const notes = collection("notes", Schema.Number, { userId: Schema.String });
|
|
456
|
+
const Mine = query("mine", {
|
|
457
|
+
payload: { title: Schema.String },
|
|
458
|
+
success: Schema.Array(Schema.String),
|
|
459
|
+
readPolicy: (_input, viewer) => Effect.succeed(Option.isSome(viewer)),
|
|
460
|
+
reads: (_input, viewer) => (Option.isSome(viewer) ? [notes.bucket.userId(viewer.value.id)] : []),
|
|
461
|
+
});
|
|
463
462
|
```
|
|
464
463
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
Each changed hint checks its keys against that viewer's policy before delivery and omits denied keys. An empty hint is suppressed. Item and list keys are checked independently: allow a list key when the viewer may read that list, even when a changed item is private to another viewer. The list query still selects its visible rows. Without a supplied `ReadPolicy`, streams keep the default of allowing their subscribed keys; applications own the policy.
|
|
464
|
+
A record key, `notes.item(id)`, names one record. A bucket, `notes.bucket.userId(id)`, names every row with that field value. Declare bucket fields on the collection model with their schemas. A list may add title, state or other filters to its input and handler, but those filters never narrow its bucket. It reruns whenever any row in that bucket changes, including a row excluded by the filter. Writers explicitly announce the record and affected buckets with the same constructors from `@shivaedev/changes/keys.ts`.
|
|
468
465
|
|
|
469
|
-
A
|
|
466
|
+
The same query policy guards running the query and subscribing to it. A subscription names query references, never arbitrary record or bucket keys. The entire subscription is refused with `QueryReadDenied` if any referenced query is denied. Without `readPolicy`, a query has no authorized live subscription: `MissingReadPolicy` tells its author to declare one. Bound query atoms also refuse a missing policy before their first read. Ordinary one-off `run` calls remain available. Reads outside a declared query acquire no automatic subscription.
|
|
470
467
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
```ts
|
|
474
|
-
import { ReadPolicyMemoWindow } from "@shivaedev/contract/readPolicy.ts";
|
|
475
|
-
|
|
476
|
-
authorizeReads(effect, request, canRead, Effect.fail(new Denied())).pipe(
|
|
477
|
-
Effect.provideService(ReadPolicyMemoWindow, "100 millis"),
|
|
478
|
-
);
|
|
479
|
-
```
|
|
468
|
+
The session middleware supplies the viewer and runs `authorizeReads(effect, request, viewer)`. Custom middleware calls that function after resolving its session and declares `MissingReadPolicy` and `QueryReadDenied` in its error schema. Before delivering changed keys, the stream rechecks the owning query's policy without memoization. A client refresh runs the same authorized query that produced its first value; hints carry no row values.
|
|
480
469
|
|
|
481
470
|
### Live hints in stories
|
|
482
471
|
|
|
483
|
-
Use `watchHints` and `heard` from `@shivaedev/contract/testing.ts` in an application's story kit. Pass each viewer's authenticated live stream to `watchHints`; it starts a scoped subscription and waits for its first hint before returning the viewer handle.
|
|
472
|
+
Use `watchHints` and `heard` from `@shivaedev/contract/testing.ts` in an application's story kit. Pass each viewer's authenticated live stream to `watchHints`; it starts a scoped subscription and waits for its first hint before returning the viewer handle.
|
|
484
473
|
|
|
485
474
|
```ts
|
|
486
475
|
const alice = yield* watchHints(aliceChanges);
|
|
@@ -496,30 +485,31 @@ expect(yield* heard(bob, [notes.item(1)], { within: "50 millis" })).toBe(false);
|
|
|
496
485
|
|
|
497
486
|
Import the module that defines the name; the package has no root entry.
|
|
498
487
|
|
|
499
|
-
| Module
|
|
500
|
-
|
|
|
501
|
-
| `operation.ts`
|
|
502
|
-
| `contract.ts`
|
|
503
|
-
| `rejection.ts`
|
|
504
|
-
| `
|
|
505
|
-
| `
|
|
506
|
-
| `live.ts`
|
|
507
|
-
| `
|
|
508
|
-
| `
|
|
509
|
-
| `
|
|
510
|
-
| `
|
|
511
|
-
| `
|
|
488
|
+
| Module | Functions and schemas |
|
|
489
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
490
|
+
| `operation.ts` | `query(name, { payload?, success?, rejections?, readPolicy?, reads })`; `command(name, { payload?, success?, rejections?, invalidates })`; `stream(name, { payload?, success, rejections?, reads })`. |
|
|
491
|
+
| `contract.ts` | `contract(name, { queries?, commands?, streams?, keyScope? })`, returning a native RPC group with `declaration: { name, queries, commands, streams, keyScope }`. |
|
|
492
|
+
| `rejection.ts` | `fieldRejection(struct, fields?)`; lower-level `rejectionSet(specs)`, which constructs the rejection classes, union schema and failure helpers used by operations. |
|
|
493
|
+
| `bind.ts` | `bind(contract, atomRpcService, { registry }?)`, requiring the registry for session-scoped contracts and exposing `{ query, run }` for queries, `{ run }` Effects for commands and `{ run }` native Streams for streams; `makeClient(contract, { url, transformClient?, httpClient?, serialization?, group?, registry? })`, which builds the HTTP client and binds it. |
|
|
494
|
+
| `queryCoalesceWindow.ts` | `queryCoalesceWindow(query)`, a writable atom for a bound query's live changed-hint window. |
|
|
495
|
+
| `live.ts` | `LiveHint` schema; `live(runtime, { stream, resyncKeys, coalesceWindow?, keyContext?, resume?, retryDelay? })`, exposing `connection`, `coalesceWindow`, `paused` and `status`. |
|
|
496
|
+
| `querySubscription.ts` | `querySubscription(requests, viewer)` checks each referenced query and captures its scopes and ongoing authorization. |
|
|
497
|
+
| `makeLiveServer.ts` | `makeLiveServer`, an Effect requiring `Reactivity` that creates scoped server hint streams and a `resync` Effect. |
|
|
498
|
+
| `readPolicy.ts` | `QueryReadPolicy`, `Viewer`, `authorizeReads(effect, request, viewer)`, `CurrentViewer`. |
|
|
499
|
+
| `readPolicyErrors.ts` | `MissingReadPolicy` and `QueryReadDenied`. |
|
|
500
|
+
| `serverMetadata.ts` | `KeyScope`, `QueryDefinition.definition`, `CommandInvalidation.invalidates`, `ChangesQueries.resolve(references)`, the Effect `queryKeys(queries, references)` and `changesRpc(name, queries)` for server adapters. |
|
|
501
|
+
| `resume.ts` | `resumeSignal({ window?, native? })`, returning a native numeric signal atom. |
|
|
502
|
+
| `testing.ts` | `watchHints(stream)` starts a scoped viewer subscription; `heard(viewer, keys, { within? }?)` waits for matching changed keys. |
|
|
512
503
|
|
|
513
504
|
A query atom takes native query options as its optional second argument, for example `api.get.query({ id: 1 }, { timeToLive: "1 minute" })`. A native resume source receives a callback and returns a cleanup function.
|
|
514
505
|
|
|
515
506
|
### Install, setup and limits
|
|
516
507
|
|
|
517
508
|
```sh
|
|
518
|
-
pnpm add @shivaedev/contract effect
|
|
509
|
+
pnpm add @shivaedev/contract effect
|
|
519
510
|
```
|
|
520
511
|
|
|
521
512
|
`effect` is a peer, and the package needs Node.js 24 or newer. Choose the native server host separately; this package supplies no server process or HTTP transport implementation.
|
|
522
|
-
|
|
523
513
|
- Put schema declarations where server and browser can share them. Put service, authorization and transaction implementations on the server. React rendering and editable forms belong to `react` and `form`.
|
|
524
514
|
- Run command Effects with the Reactivity instance that owns the queries to refresh, as in the registry example.
|
|
525
515
|
- Choose a common read key for every query in a full reconciliation scope. A list key does not implicitly cover item queries.
|