@shivaedev/contract 0.19.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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.19.0 - 2026-10-28
|
|
4
10
|
|
|
5
11
|
### Added
|
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ import { command, query } from "@shivaedev/contract/operation.ts";
|
|
|
15
15
|
const notes = collection("notes", Schema.Number);
|
|
16
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
|
```
|
|
@@ -35,28 +35,6 @@ A **collection key** names a dependency. Its item key identifies one item; its l
|
|
|
35
35
|
|
|
36
36
|
Optional live hints use those same keys. A hint asks for another read; it does not supply a replacement value, a journal entry or proof that a query is fresh.
|
|
37
37
|
|
|
38
|
-
### Build an HTTP client
|
|
39
|
-
|
|
40
|
-
Use `makeClient` once per session to bind a contract to an HTTP endpoint:
|
|
41
|
-
|
|
42
|
-
```ts
|
|
43
|
-
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
44
|
-
import { makeClient } from "@shivaedev/contract/bind.ts";
|
|
45
|
-
import { Notes } from "./notes.ts";
|
|
46
|
-
|
|
47
|
-
const { api, runtime } = makeClient(Notes, {
|
|
48
|
-
url: "/rpc",
|
|
49
|
-
transformClient: (client) => HttpClient.mapRequest(
|
|
50
|
-
client,
|
|
51
|
-
HttpClientRequest.setHeader("authorization", "Bearer session-token"),
|
|
52
|
-
),
|
|
53
|
-
});
|
|
54
|
-
const note = api.get.query({ id: 1 });
|
|
55
|
-
const rename = runtime.fn(api.rename.run);
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
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.
|
|
59
|
-
|
|
60
38
|
### 1. Once per feature: schemas and operations
|
|
61
39
|
|
|
62
40
|
Keep the shared declaration in a module that both server and client can import:
|
|
@@ -69,37 +47,37 @@ import { command, query } from "@shivaedev/contract/operation.ts";
|
|
|
69
47
|
import { fieldRejection } from "@shivaedev/contract/rejection.ts";
|
|
70
48
|
|
|
71
49
|
export class Note extends Schema.Class<Note>("Note")({
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
50
|
+
body: Schema.String,
|
|
51
|
+
id: Schema.Number,
|
|
52
|
+
title: Schema.String,
|
|
75
53
|
}) {}
|
|
76
54
|
export const Draft = Schema.Struct({ body: Schema.String, title: Schema.String });
|
|
77
55
|
export class NoteMissing extends Schema.TaggedError<NoteMissing>()("NoteMissing", { id: Schema.Number }) {}
|
|
78
56
|
export const notes = collection("notes", Note.fields.id);
|
|
79
57
|
|
|
80
58
|
export const Get = query("get", {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
59
|
+
readPolicy: () => Effect.succeed(true),
|
|
60
|
+
payload: { id: Schema.Number },
|
|
61
|
+
success: Note,
|
|
62
|
+
rejections: { NoteMissing },
|
|
63
|
+
reads: ({ id }) => [notes.item(id)],
|
|
86
64
|
});
|
|
87
65
|
export const List = query("list", {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
66
|
+
readPolicy: () => Effect.succeed(true),
|
|
67
|
+
success: Schema.Array(Note),
|
|
68
|
+
reads: () => [notes.list],
|
|
91
69
|
});
|
|
92
70
|
export const Rename = command("rename", {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
71
|
+
payload: { id: Schema.Number, title: Schema.String },
|
|
72
|
+
success: Note,
|
|
73
|
+
rejections: { NoteMissing, Invalid: fieldRejection(Draft, ["title"]) },
|
|
74
|
+
invalidates: ({ id }) => [notes.item(id)],
|
|
97
75
|
});
|
|
98
76
|
export const Create = command("create", {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
77
|
+
payload: Draft,
|
|
78
|
+
success: Note,
|
|
79
|
+
rejections: { Invalid: fieldRejection(Draft) },
|
|
80
|
+
invalidates: (_draft, note) => [notes.item(note.id)],
|
|
103
81
|
});
|
|
104
82
|
export const Notes = contract("notes", { queries: [Get, List], commands: [Rename, Create] });
|
|
105
83
|
```
|
|
@@ -120,9 +98,9 @@ import { Rpc } from "effect/rpc";
|
|
|
120
98
|
import { BadRequest, Conflict } from "@shivaedev/errors/taxonomy.ts";
|
|
121
99
|
|
|
122
100
|
export const Rename = Rpc.make("Rename", {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
101
|
+
error: Schema.Union([BadRequest, Conflict]),
|
|
102
|
+
payload: { name: Schema.String },
|
|
103
|
+
success: Schema.String,
|
|
126
104
|
});
|
|
127
105
|
```
|
|
128
106
|
|
|
@@ -148,28 +126,35 @@ Implement the contract with native `of` and `toLayer`:
|
|
|
148
126
|
import { Effect, Ref, Stream } from "effect";
|
|
149
127
|
import { Create, Get, Note, Notes, Rename } from "./notes.ts";
|
|
150
128
|
|
|
151
|
-
export const NotesHandlers = Notes.toLayer(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}))
|
|
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
|
+
);
|
|
173
158
|
```
|
|
174
159
|
|
|
175
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.
|
|
@@ -198,7 +183,7 @@ import { Notes } from "./notes.ts";
|
|
|
198
183
|
import { NotesHandlers } from "./handlers.ts";
|
|
199
184
|
|
|
200
185
|
export const NotesRoutes = RpcServer.layerHttp({ group: Notes, path: "/rpc", protocol: "http" }).pipe(
|
|
201
|
-
|
|
186
|
+
Layer.provide([NotesHandlers, RpcSerialization.layerJson]),
|
|
202
187
|
);
|
|
203
188
|
```
|
|
204
189
|
|
|
@@ -213,16 +198,35 @@ import { bind } from "@shivaedev/contract/bind.ts";
|
|
|
213
198
|
import { Notes } from "./notes.ts";
|
|
214
199
|
|
|
215
200
|
export class NotesClient extends AtomRpc.Service<NotesClient>()("app/NotesClient", {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
201
|
+
group: Notes,
|
|
202
|
+
protocol: RpcClient.layerProtocolHttp({ url: "http://127.0.0.1:3000/rpc" }).pipe(
|
|
203
|
+
Layer.provide([FetchHttpClient.layer, RpcSerialization.layerJson]),
|
|
204
|
+
),
|
|
220
205
|
}) {}
|
|
221
206
|
export const api = bind(Notes, NotesClient);
|
|
222
207
|
```
|
|
223
208
|
|
|
224
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.
|
|
225
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
|
+
|
|
226
230
|
### 3. Every day: read and change
|
|
227
231
|
|
|
228
232
|
```ts
|
|
@@ -232,8 +236,6 @@ const readOnce = api.get.run({ id: 1 });
|
|
|
232
236
|
const rename = api.rename.run({ id: 1, title: "Uno" });
|
|
233
237
|
```
|
|
234
238
|
|
|
235
|
-
`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" })`.
|
|
236
|
-
|
|
237
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.
|
|
238
240
|
|
|
239
241
|
Mount the atoms and run commands through the service's atom runtime in the same registry:
|
|
@@ -249,9 +251,7 @@ const list = api.list.query();
|
|
|
249
251
|
const releaseOne = registry.mount(one);
|
|
250
252
|
const releaseList = registry.mount(list);
|
|
251
253
|
|
|
252
|
-
const renamed = await Effect.runPromise(
|
|
253
|
-
AtomRegistry.getResult(registry, NotesClient.runtime.atom(api.rename.run({ id: 1, title: "Uno" }))),
|
|
254
|
-
);
|
|
254
|
+
const renamed = await Effect.runPromise(AtomRegistry.getResult(registry, NotesClient.runtime.atom(api.rename.run({ id: 1, title: "Uno" }))));
|
|
255
255
|
|
|
256
256
|
releaseOne();
|
|
257
257
|
releaseList();
|
|
@@ -262,54 +262,36 @@ The successful rename invalidates note 1 and the list. A rejected rename invalid
|
|
|
262
262
|
|
|
263
263
|
The dependency rule is deliberately asymmetric:
|
|
264
264
|
|
|
265
|
-
| Declared key
|
|
266
|
-
|
|
|
267
|
-
| `notes.item(1)` | `"notes:1"` | `"notes:1"` and `"notes"`
|
|
268
|
-
| `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"` |
|
|
269
269
|
|
|
270
|
-
`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] }`.
|
|
271
271
|
|
|
272
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).
|
|
273
273
|
|
|
274
274
|
### Session-owned keys
|
|
275
275
|
|
|
276
|
-
|
|
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:
|
|
277
277
|
|
|
278
278
|
```ts
|
|
279
279
|
const preferences = collection("preferences", Schema.String);
|
|
280
280
|
const Preferences = contract("preferences", {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const save = api.save.run({ ownerId: session.userId, title: "My preferences" });
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
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:
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
const preferences = collection("preferences", Schema.String);
|
|
302
|
-
const Preferences = contract("preferences", {
|
|
303
|
-
keyScope: "session",
|
|
304
|
-
queries: [query("self", {
|
|
305
|
-
reads: () => [preferences.item("self")],
|
|
306
|
-
success: Schema.String,
|
|
307
|
-
})],
|
|
308
|
-
commands: [command("save", {
|
|
309
|
-
payload: { title: Schema.String },
|
|
310
|
-
invalidates: () => [preferences.item("self")],
|
|
311
|
-
success: Schema.String,
|
|
312
|
-
})],
|
|
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
|
+
],
|
|
313
295
|
}).middleware(Authenticated);
|
|
314
296
|
|
|
315
297
|
const api = bind(Preferences, Client, { registry });
|
|
@@ -317,30 +299,14 @@ const self = api.self.query(undefined);
|
|
|
317
299
|
const save = api.save.run({ title: "My preferences" });
|
|
318
300
|
```
|
|
319
301
|
|
|
320
|
-
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. In React,
|
|
321
|
-
|
|
322
|
-
```tsx
|
|
323
|
-
import { SessionBoundary } from "@shivaedev/react/client/sessionBoundary.ts";
|
|
324
|
-
|
|
325
|
-
<SessionBoundary
|
|
326
|
-
session={session}
|
|
327
|
-
identify={(current) => current.generation}
|
|
328
|
-
connect={(current, registry) => bind(Preferences, makePreferencesClient(current.token), { registry })}
|
|
329
|
-
recheck={recheck}
|
|
330
|
-
signedOut={<p>Signed out</p>}
|
|
331
|
-
>
|
|
332
|
-
{(api) => <PreferencesScreen api={api} />}
|
|
333
|
-
</SessionBoundary>
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
`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.
|
|
337
303
|
|
|
338
|
-
Handlers
|
|
304
|
+
Handlers read the account from request-local `Identity`, imported from `@shivaedev/identity/identity.ts`:
|
|
339
305
|
|
|
340
306
|
```ts
|
|
341
307
|
Preferences.application.toLayer({
|
|
342
|
-
|
|
343
|
-
|
|
308
|
+
"preferences.self": () => Effect.flatMap(Identity, ({ id }) => store.read(id)),
|
|
309
|
+
"preferences.save": ({ title }) => Effect.flatMap(Identity, ({ id }) => store.save(id, title)),
|
|
344
310
|
});
|
|
345
311
|
```
|
|
346
312
|
|
|
@@ -364,9 +330,12 @@ import type { ResumeSource } from "@shivaedev/contract/resume.ts";
|
|
|
364
330
|
import { onAppResume, onNetworkReturn } from "./native-lifecycle.ts";
|
|
365
331
|
|
|
366
332
|
const resume: ResumeSource = (notify) => {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
333
|
+
const stopApp = onAppResume(notify);
|
|
334
|
+
const stopNetwork = onNetworkReturn(notify);
|
|
335
|
+
return () => {
|
|
336
|
+
stopApp();
|
|
337
|
+
stopNetwork();
|
|
338
|
+
};
|
|
370
339
|
};
|
|
371
340
|
const api = bind(Notes, NotesClient, { resume });
|
|
372
341
|
```
|
|
@@ -379,14 +348,14 @@ For a direct subscription, configure the server and `NotesClient` with `RpcSeria
|
|
|
379
348
|
import { Effect, Stream } from "effect";
|
|
380
349
|
import { NotesClient } from "./client.ts";
|
|
381
350
|
|
|
382
|
-
const changes = Stream.unwrap(
|
|
383
|
-
|
|
384
|
-
)
|
|
351
|
+
const changes = Stream.unwrap(
|
|
352
|
+
NotesClient.use((client) => Effect.succeed(client("notes.$changes", { queries: [{ query: "get", payload: { id: 1 } }] }))),
|
|
353
|
+
);
|
|
385
354
|
```
|
|
386
355
|
|
|
387
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.
|
|
388
357
|
|
|
389
|
-
For a custom transport or explicit pause
|
|
358
|
+
For a custom transport or explicit pause and status controls, use the native live APIs.
|
|
390
359
|
|
|
391
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.
|
|
392
361
|
|
|
@@ -419,18 +388,18 @@ import { LiveNotes } from "./live-notes.ts";
|
|
|
419
388
|
import { notes } from "./notes.ts";
|
|
420
389
|
|
|
421
390
|
class Client extends AtomRpc.Service<Client>()("app/LiveNotes", {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
391
|
+
group: LiveNotes,
|
|
392
|
+
protocol: RpcClient.layerProtocolHttp({ url: "http://127.0.0.1:3000/rpc" }).pipe(
|
|
393
|
+
Layer.provide([FetchHttpClient.layer, RpcSerialization.layerNdjson]),
|
|
394
|
+
),
|
|
426
395
|
}) {}
|
|
427
396
|
const api = bind(LiveNotes, Client);
|
|
428
397
|
const registry = AtomRegistry.make();
|
|
429
398
|
const updates = live(Client.runtime, {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
399
|
+
stream: api.subscribe.run(undefined),
|
|
400
|
+
resyncKeys: [notes.item(1), notes.item(2), notes.list],
|
|
401
|
+
resume: resumeSignal({ window }),
|
|
402
|
+
retryDelay: "100 millis",
|
|
434
403
|
});
|
|
435
404
|
const releaseQuery = registry.mount(api.get.query({ id: 1 }));
|
|
436
405
|
const releaseConnection = registry.mount(updates.connection);
|
|
@@ -438,14 +407,16 @@ const releaseConnection = registry.mount(updates.connection);
|
|
|
438
407
|
|
|
439
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.
|
|
440
409
|
|
|
441
|
-
| Observation
|
|
442
|
-
|
|
|
443
|
-
| `Changed` with keys after the first hint
|
|
444
|
-
| `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. |
|
|
445
414
|
| First hint on a new or reconnected stream | The full explicit scope, even if the hint is targeted. |
|
|
446
415
|
|
|
447
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.
|
|
448
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
|
+
|
|
449
420
|
`LiveHint` and `Key` are schemas. Decoding rejects unknown hint tags and item keys with an invalid ID type.
|
|
450
421
|
|
|
451
422
|
```ts
|
|
@@ -483,12 +454,10 @@ import { query } from "@shivaedev/contract/operation.ts";
|
|
|
483
454
|
|
|
484
455
|
const notes = collection("notes", Schema.Number, { userId: Schema.String });
|
|
485
456
|
const Mine = query("mine", {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
? [notes.bucket.userId(viewer.value.id)]
|
|
491
|
-
: [],
|
|
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)] : []),
|
|
492
461
|
});
|
|
493
462
|
```
|
|
494
463
|
|
|
@@ -500,7 +469,7 @@ The session middleware supplies the viewer and runs `authorizeReads(effect, requ
|
|
|
500
469
|
|
|
501
470
|
### Live hints in stories
|
|
502
471
|
|
|
503
|
-
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.
|
|
504
473
|
|
|
505
474
|
```ts
|
|
506
475
|
const alice = yield* watchHints(aliceChanges);
|
|
@@ -516,31 +485,31 @@ expect(yield* heard(bob, [notes.item(1)], { within: "50 millis" })).toBe(false);
|
|
|
516
485
|
|
|
517
486
|
Import the module that defines the name; the package has no root entry.
|
|
518
487
|
|
|
519
|
-
| Module
|
|
520
|
-
|
|
|
521
|
-
| `operation.ts`
|
|
522
|
-
| `contract.ts`
|
|
523
|
-
| `rejection.ts`
|
|
524
|
-
| `bind.ts`
|
|
525
|
-
| `
|
|
526
|
-
| `
|
|
527
|
-
| `
|
|
528
|
-
| `
|
|
529
|
-
| `
|
|
530
|
-
| `
|
|
531
|
-
| `
|
|
532
|
-
| `
|
|
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. |
|
|
533
503
|
|
|
534
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.
|
|
535
505
|
|
|
536
506
|
### Install, setup and limits
|
|
537
507
|
|
|
538
508
|
```sh
|
|
539
|
-
pnpm add @shivaedev/contract effect
|
|
509
|
+
pnpm add @shivaedev/contract effect
|
|
540
510
|
```
|
|
541
511
|
|
|
542
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.
|
|
543
|
-
|
|
544
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`.
|
|
545
514
|
- Run command Effects with the Reactivity instance that owns the queries to refresh, as in the registry example.
|
|
546
515
|
- Choose a common read key for every query in a full reconciliation scope. A list key does not implicitly cover item queries.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clientOperations.d.ts","sourceRoot":"","sources":["../../src/internal/clientOperations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"clientOperations.d.ts","sourceRoot":"","sources":["../../src/internal/clientOperations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAA0B,KAAK,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAE7E,OAAO,KAAK,KAAK,OAAO,MAAM,2BAA2B,CAAC;AAE1D,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEjD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAE9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAGtD,KAAK,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;AAMhH,KAAK,qBAAqB,GAAG,OAAO,CAAC,aAAa,CACjD,OAAO,EACP,MAAM,EACN,GAAG,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,KAAK,CAAC,CACnF,CAAC;AAMF,wBAAgB,UAAU,CACzB,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,UAAU,EACjB,KAAK,EAAE,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC,OAAO,CAAC,EAChD,KAAK,CAAC,EAAE,YAAY;IAInB,KAAK,YAAY,OAAO,YAAW,YAAY;IA6B/C,GAAG,YAAY,OAAO;EAOvB;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,KAAK,CAAC,EAAE,YAAY;IAS1G,GAAG,YAAY,OAAO;EAGvB;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,qBAAqB,EAAE,GAAG,EAAE,MAAM;IAErE,GAAG,YAAY,OAAO;EAEvB"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { Effect, Option, Stream } from "effect";
|
|
1
|
+
import { Effect, MutableHashMap, Option, Stream } from "effect";
|
|
2
2
|
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
3
3
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
4
4
|
import { invalidationKeys, readKeys } from "@shivaedev/changes/keys.ts";
|
|
5
5
|
import { MissingReadPolicy } from "#readPolicyErrors.ts";
|
|
6
6
|
import { queryReactivity } from "./queryReactivity.js";
|
|
7
|
-
|
|
7
|
+
// AtomRpc holds query atoms weakly, so an id per atom object would change after a collection and make live tracking resubscribe.
|
|
8
|
+
const queryIds = MutableHashMap.empty();
|
|
8
9
|
let nextQueryId = 0;
|
|
9
10
|
function run(service, effect, owner) {
|
|
10
11
|
return owner === undefined ? effect : Effect.suspend(() => AtomRegistry.getResult(owner.registry, service.runtime.atom(effect)));
|
|
@@ -17,11 +18,12 @@ export function boundQuery(service, tag, query, track, owner) {
|
|
|
17
18
|
return service.runtime.atom(Effect.fail(new MissingReadPolicy({ message: `Declare a readPolicy on query ${query.name} before running it live or subscribing` })));
|
|
18
19
|
}
|
|
19
20
|
const atom = service.query(tag, payload, options);
|
|
20
|
-
|
|
21
|
+
const identity = { payload, tag };
|
|
22
|
+
let id = Option.getOrUndefined(MutableHashMap.get(queryIds, identity));
|
|
21
23
|
if (id === undefined) {
|
|
22
24
|
id = nextQueryId;
|
|
23
25
|
nextQueryId += 1;
|
|
24
|
-
|
|
26
|
+
MutableHashMap.set(queryIds, identity, id);
|
|
25
27
|
}
|
|
26
28
|
const local = query.reads(payload, Option.none());
|
|
27
29
|
const keys = [...local, { _tag: "List", collection: `@query/${tag}/${id}` }];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"clientOperations.js","sourceRoot":"","sources":["../../src/internal/clientOperations.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAe,MAAM,EAAE,MAAM,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"clientOperations.js","sourceRoot":"","sources":["../../src/internal/clientOperations.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/contract",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Typed query and command contracts over native Effect RPC and reactivity",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@effect/platform-node": "4.0.5",
|
|
52
52
|
"@effect/vitest": "4.0.5",
|
|
53
|
-
"@shivaedev/quality": "0.
|
|
53
|
+
"@shivaedev/quality": "0.44.0",
|
|
54
54
|
"@shivaedev/test": "0.10.0",
|
|
55
55
|
"@shivaedev/test-sql": "0.10.2",
|
|
56
56
|
"@shivaedev/test-story": "0.22.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Effect, Option, type Schema, Stream } from "effect";
|
|
1
|
+
import { Effect, MutableHashMap, Option, type Schema, Stream } from "effect";
|
|
2
2
|
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
3
3
|
import type * as AtomRpc from "effect/reactivity/AtomRpc";
|
|
4
4
|
import * as Reactivity from "effect/reactivity/Reactivity";
|
|
@@ -13,7 +13,8 @@ import { queryReactivity } from "./queryReactivity.ts";
|
|
|
13
13
|
|
|
14
14
|
type ErasedClient = AtomRpc.AtomRpcClient<unknown, string, Rpc.Rpc<string, Schema.Top, Schema.Top, Schema.Top>>;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// AtomRpc holds query atoms weakly, so an id per atom object would change after a collection and make live tracking resubscribe.
|
|
17
|
+
const queryIds = MutableHashMap.empty<{ readonly payload: unknown; readonly tag: string }, number>();
|
|
17
18
|
let nextQueryId = 0;
|
|
18
19
|
|
|
19
20
|
type ErasedStreamingClient = AtomRpc.AtomRpcClient<
|
|
@@ -42,11 +43,12 @@ export function boundQuery(
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
const atom = service.query(tag, payload, options);
|
|
45
|
-
|
|
46
|
+
const identity = { payload, tag };
|
|
47
|
+
let id = Option.getOrUndefined(MutableHashMap.get(queryIds, identity));
|
|
46
48
|
if (id === undefined) {
|
|
47
49
|
id = nextQueryId;
|
|
48
50
|
nextQueryId += 1;
|
|
49
|
-
|
|
51
|
+
MutableHashMap.set(queryIds, identity, id);
|
|
50
52
|
}
|
|
51
53
|
const local = query.reads(payload, Option.none());
|
|
52
54
|
const keys = [...local, { _tag: "List" as const, collection: `@query/${tag}/${id}` }];
|