@shivaedev/contract 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0 - 2026-10-27
4
+
5
+ ### Added
6
+
7
+ - SQL stories can declare domain read keys that cover written tables with `withChangeCoverage(body, { covers })` and assert committed change keys with `expectPublished(keys)`.
8
+ - Contract stories can observe a viewer's live stream with `watchHints` and use `heard(viewer, keys)` to await allowed hints or report denied keys as unheard on the story clock.
9
+
3
10
  ## 0.17.0 - 2026-10-27
4
11
 
5
12
  ### Added
package/README.md CHANGED
@@ -478,6 +478,20 @@ authorizeReads(effect, request, canRead, Effect.fail(new Denied())).pipe(
478
478
  );
479
479
  ```
480
480
 
481
+ ### Live hints in stories
482
+
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. The contract's viewer story kit exposes these as `listen(viewer, queries)` and `heard(viewer, keys)`.
484
+
485
+ ```ts
486
+ const alice = yield* watchHints(aliceChanges);
487
+ const bob = yield* watchHints(bobChanges);
488
+ yield* renameNote;
489
+ expect(yield* heard(alice, [notes.item(1)])).toBe(true);
490
+ expect(yield* heard(bob, [notes.item(1)], { within: "50 millis" })).toBe(false);
491
+ ```
492
+
493
+ `heard` returns `true` once a delivered `Changed` hint contains all the requested keys, including hints received before the call. Extra keys are allowed; `Resync` does not count. It returns `false` when no matching hint arrives within `within` (one second by default). Waiting uses the story's Effect clock, so a test clock controls expiry. Stream failures stay in the typed error channel. Keep expectations in the spec; the support verbs need no retry loops or hidden assertions.
494
+
481
495
  ### API by module
482
496
 
483
497
  Import the module that defines the name; the package has no root entry.
@@ -494,6 +508,7 @@ Import the module that defines the name; the package has no root entry.
494
508
  | `readPolicy.ts` | `authorizeReads(effect, request, policy, denied)`; `ReadPolicy`, the request-local fallible key policy used by server hint streams; `ReadPolicyMemoWindow`, a `Context.Reference<Duration.Input>` controlling each stream's memo duration. |
495
509
  | `serverMetadata.ts` | `KeyScope`, `QueryReads`, `CommandInvalidation`, `ChangesReads`, `queryKeys(queries, references)` and `changesRpc(name, queries)` for server adapters. |
496
510
  | `resume.ts` | `resumeSignal({ window?, native? })`, returning a native numeric signal atom. |
511
+ | `testing.ts` | `watchHints(stream)` starts a scoped viewer subscription; `heard(viewer, keys, { within? }?)` waits for matching changed keys. |
497
512
 
498
513
  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.
499
514
 
@@ -0,0 +1,39 @@
1
+ import { type Duration, Effect, Stream, SubscriptionRef } from "effect";
2
+ import type { Key } from "./keys.ts";
3
+ import type { LiveHint } from "./live.ts";
4
+ export interface HintViewer<E> {
5
+ readonly finished: Effect.Effect<void, E>;
6
+ readonly hints: SubscriptionRef.SubscriptionRef<readonly LiveHint[]>;
7
+ }
8
+ export declare const watchHints: <E, R>(stream: Stream.Stream<{
9
+ readonly _tag: "Changed";
10
+ readonly keys: readonly ({
11
+ readonly _tag: "List";
12
+ readonly collection: string;
13
+ } | {
14
+ readonly _tag: "Item";
15
+ readonly collection: string;
16
+ readonly id: string | number;
17
+ })[];
18
+ } | {
19
+ readonly _tag: "Resync";
20
+ }, E, R>) => Effect.Effect<{
21
+ finished: Effect.Effect<void, E, never>;
22
+ hints: SubscriptionRef.SubscriptionRef<readonly ({
23
+ readonly _tag: "Changed";
24
+ readonly keys: readonly ({
25
+ readonly _tag: "List";
26
+ readonly collection: string;
27
+ } | {
28
+ readonly _tag: "Item";
29
+ readonly collection: string;
30
+ readonly id: string | number;
31
+ })[];
32
+ } | {
33
+ readonly _tag: "Resync";
34
+ })[]>;
35
+ }, E, R | import("effect/Scope").Scope>;
36
+ export declare const heard: <E>(viewer: HintViewer<E>, keys: readonly Key[], options?: {
37
+ readonly within?: Duration.Input;
38
+ } | undefined) => Effect.Effect<boolean, E, never>;
39
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,EAAS,MAAM,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AACzF,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,MAAM,WAAW,UAAU,CAAC,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC,eAAe,CAAC,SAAS,QAAQ,EAAE,CAAC,CAAC;CACrE;AAED,eAAO,MAAM,UAAU,GAA2C,CAAC,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;uCAYpE,CAAC;AAEH,eAAO,MAAM,KAAK,GAAsC,CAAC;sBAG3B,QAAQ,CAAC,KAAK;kDAU1C,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { Deferred, Effect, Fiber, Stream, SubscriptionRef } from "effect";
2
+ export const watchHints = Effect.fn("Live.watchHints")(function* (stream) {
3
+ const hints = yield* SubscriptionRef.make([]);
4
+ const ready = yield* Deferred.make();
5
+ const fiber = yield* stream.pipe(Stream.runForEach((hint) => SubscriptionRef.update(hints, (received) => [...received, hint]).pipe(Effect.andThen(Deferred.succeed(ready, undefined)))), Effect.forkScoped);
6
+ const finished = Fiber.join(fiber);
7
+ yield* Effect.raceFirst(Deferred.await(ready), finished);
8
+ return { finished, hints };
9
+ });
10
+ export const heard = Effect.fn("Live.heard")(function* (viewer, keys, options = {}) {
11
+ const matching = SubscriptionRef.changes(viewer.hints).pipe(Stream.filter((hints) => hints.some((hint) => namesKeys(hint, keys))), Stream.runHead, Effect.as(true));
12
+ return yield* Effect.raceFirst(matching, Effect.as(viewer.finished, false)).pipe(Effect.timeoutOrElse({ duration: options.within ?? "1 second", orElse: () => Effect.succeed(false) }));
13
+ });
14
+ function namesKeys(hint, keys) {
15
+ return (hint._tag === "Changed"
16
+ && keys.every((key) => hint.keys.some((actual) => actual._tag === key._tag
17
+ && actual.collection === key.collection
18
+ && (key._tag === "List" || (actual._tag === "Item" && actual.id === key.id)))));
19
+ }
20
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,QAAQ,CAAC;AASzF,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,QAAQ,CAAC,EAAQ,MAAqC;IAC5G,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,eAAe,CAAC,IAAI,CAAsB,EAAE,CAAC,CAAC;IACnE,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,QAAQ,CAAC,IAAI,EAAQ,CAAC;IAC3C,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAC/B,MAAM,CAAC,UAAU,CAAC,CAAC,IAAI,EAAE,EAAE,CAC1B,eAAe,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,GAAG,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC,CAAC,CACzH,EACD,MAAM,CAAC,UAAU,CACjB,CAAC;IACF,MAAM,QAAQ,GAAG,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnC,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,CAAC;IACzD,OAAO,EAAE,QAAQ,EAAE,KAAK,EAA0B,CAAC;AACpD,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,QAAQ,CAAC,EACrD,MAAqB,EACrB,IAAoB,EACpB,OAAO,GAAyC,EAAE;IAElD,MAAM,QAAQ,GAAG,eAAe,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAC1D,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,EACrE,MAAM,CAAC,OAAO,EACd,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CACf,CAAC;IACF,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAC/E,MAAM,CAAC,aAAa,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,MAAM,IAAI,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC,CACrG,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,SAAS,SAAS,CAAC,IAAc,EAAE,IAAoB;IACtD,OAAO,CACN,IAAI,CAAC,IAAI,KAAK,SAAS;WACpB,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CACrB,IAAI,CAAC,IAAI,CAAC,IAAI,CACb,CAAC,MAAM,EAAE,EAAE,CACV,MAAM,CAAC,IAAI,KAAK,GAAG,CAAC,IAAI;eACrB,MAAM,CAAC,UAAU,KAAK,GAAG,CAAC,UAAU;eACpC,CAAC,GAAG,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,MAAM,IAAI,MAAM,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,CAAC,CAAC,CAC7E,CACD,CACD,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/contract",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Typed query and command contracts over native Effect RPC and reactivity",
5
5
  "keywords": [
6
6
  "effect"
@@ -51,8 +51,8 @@
51
51
  "@effect/vitest": "4.0.5",
52
52
  "@shivaedev/quality": "0.42.0",
53
53
  "@shivaedev/test": "0.10.0",
54
- "@shivaedev/test-sql": "0.10.0",
55
- "@shivaedev/test-story": "0.21.0",
54
+ "@shivaedev/test-sql": "0.10.1",
55
+ "@shivaedev/test-story": "0.21.1",
56
56
  "@types/node": "24.19.2",
57
57
  "effect": "4.0.5",
58
58
  "esbuild": "0.28.2",
package/src/testing.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { Deferred, type Duration, Effect, Fiber, Stream, SubscriptionRef } from "effect";
2
+ import type { Key } from "./keys.ts";
3
+ import type { LiveHint } from "./live.ts";
4
+
5
+ export interface HintViewer<E> {
6
+ readonly finished: Effect.Effect<void, E>;
7
+ readonly hints: SubscriptionRef.SubscriptionRef<readonly LiveHint[]>;
8
+ }
9
+
10
+ export const watchHints = Effect.fn("Live.watchHints")(function* <E, R>(stream: Stream.Stream<LiveHint, E, R>) {
11
+ const hints = yield* SubscriptionRef.make<readonly LiveHint[]>([]);
12
+ const ready = yield* Deferred.make<void>();
13
+ const fiber = yield* stream.pipe(
14
+ Stream.runForEach((hint) =>
15
+ SubscriptionRef.update(hints, (received) => [...received, hint]).pipe(Effect.andThen(Deferred.succeed(ready, undefined))),
16
+ ),
17
+ Effect.forkScoped,
18
+ );
19
+ const finished = Fiber.join(fiber);
20
+ yield* Effect.raceFirst(Deferred.await(ready), finished);
21
+ return { finished, hints } satisfies HintViewer<E>;
22
+ });
23
+
24
+ export const heard = Effect.fn("Live.heard")(function* <E>(
25
+ viewer: HintViewer<E>,
26
+ keys: readonly Key[],
27
+ options: { readonly within?: Duration.Input } = {},
28
+ ) {
29
+ const matching = SubscriptionRef.changes(viewer.hints).pipe(
30
+ Stream.filter((hints) => hints.some((hint) => namesKeys(hint, keys))),
31
+ Stream.runHead,
32
+ Effect.as(true),
33
+ );
34
+ return yield* Effect.raceFirst(matching, Effect.as(viewer.finished, false)).pipe(
35
+ Effect.timeoutOrElse({ duration: options.within ?? "1 second", orElse: () => Effect.succeed(false) }),
36
+ );
37
+ });
38
+
39
+ function namesKeys(hint: LiveHint, keys: readonly Key[]): boolean {
40
+ return (
41
+ hint._tag === "Changed"
42
+ && keys.every((key) =>
43
+ hint.keys.some(
44
+ (actual) =>
45
+ actual._tag === key._tag
46
+ && actual.collection === key.collection
47
+ && (key._tag === "List" || (actual._tag === "Item" && actual.id === key.id)),
48
+ ),
49
+ )
50
+ );
51
+ }