@shivaedev/effect-contract 0.10.0 → 0.12.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +33 -10
  3. package/dist/automaticLive.d.ts +9 -7
  4. package/dist/automaticLive.d.ts.map +1 -1
  5. package/dist/automaticLive.js +52 -19
  6. package/dist/automaticLive.js.map +1 -1
  7. package/dist/bind.d.ts +11 -7
  8. package/dist/bind.d.ts.map +1 -1
  9. package/dist/bind.js +9 -36
  10. package/dist/bind.js.map +1 -1
  11. package/dist/contract.d.ts +13 -8
  12. package/dist/contract.d.ts.map +1 -1
  13. package/dist/contract.js +19 -9
  14. package/dist/contract.js.map +1 -1
  15. package/dist/internal/bindingOwner.d.ts +8 -0
  16. package/dist/internal/bindingOwner.d.ts.map +1 -0
  17. package/dist/internal/bindingOwner.js +16 -0
  18. package/dist/internal/bindingOwner.js.map +1 -0
  19. package/dist/internal/clientOperations.d.ts +21 -0
  20. package/dist/internal/clientOperations.d.ts.map +1 -0
  21. package/dist/internal/clientOperations.js +36 -0
  22. package/dist/internal/clientOperations.js.map +1 -0
  23. package/dist/internal/queryReactivity.d.ts +2 -1
  24. package/dist/internal/queryReactivity.d.ts.map +1 -1
  25. package/dist/internal/queryReactivity.js +5 -1
  26. package/dist/internal/queryReactivity.js.map +1 -1
  27. package/dist/keys.d.ts +5 -2
  28. package/dist/keys.d.ts.map +1 -1
  29. package/dist/keys.js +7 -4
  30. package/dist/keys.js.map +1 -1
  31. package/dist/live.d.ts +4 -2
  32. package/dist/live.d.ts.map +1 -1
  33. package/dist/live.js +5 -4
  34. package/dist/live.js.map +1 -1
  35. package/dist/makeLiveServer.d.ts +2 -2
  36. package/dist/makeLiveServer.d.ts.map +1 -1
  37. package/dist/makeLiveServer.js +3 -3
  38. package/dist/makeLiveServer.js.map +1 -1
  39. package/dist/operation.d.ts +1 -1
  40. package/dist/serverMetadata.d.ts +21 -12
  41. package/dist/serverMetadata.d.ts.map +1 -1
  42. package/dist/serverMetadata.js +19 -4
  43. package/dist/serverMetadata.js.map +1 -1
  44. package/package.json +12 -12
  45. package/src/automaticLive.ts +64 -24
  46. package/src/bind.ts +21 -54
  47. package/src/contract.ts +43 -17
  48. package/src/internal/bindingOwner.ts +24 -0
  49. package/src/internal/clientOperations.ts +74 -0
  50. package/src/internal/queryReactivity.ts +11 -1
  51. package/src/keys.ts +16 -4
  52. package/src/live.ts +8 -5
  53. package/src/makeLiveServer.ts +4 -4
  54. package/src/operation.ts +1 -1
  55. package/src/serverMetadata.ts +40 -5
package/src/bind.ts CHANGED
@@ -1,17 +1,17 @@
1
- import type { Duration, Schema } from "effect";
2
- import { Effect, Stream } from "effect";
1
+ import type { Duration, Effect, Stream } from "effect";
3
2
  import type { Headers } from "effect/http";
4
3
  import type * as AsyncResult from "effect/reactivity/AsyncResult";
5
4
  import type * as Atom from "effect/reactivity/Atom";
5
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
6
6
  import type * as AtomRpc from "effect/reactivity/AtomRpc";
7
- import * as Reactivity from "effect/reactivity/Reactivity";
8
- import type { Rpc, RpcSchema } from "effect/rpc";
7
+ import type * as Reactivity from "effect/reactivity/Reactivity";
8
+ import type { Rpc } from "effect/rpc";
9
9
  import type { RpcClientError } from "effect/rpc/RpcClientError";
10
- import { queryReactivity } from "#internal/queryReactivity.ts";
11
- import { automaticLive, type LiveBound, LiveConnection } from "./automaticLive.ts";
12
- import type { Contract, Declared, Tag } from "./contract.ts";
13
- import { invalidationKeys, type Key, readKeys } from "./keys.ts";
14
- import type { CommandShape, QueryShape, StreamShape } from "./operation.ts";
10
+ import { automaticLive, type LiveBound, LiveConnection } from "#automaticLive.ts";
11
+ import type { Contract, Declared, Tag } from "#contract.ts";
12
+ import { bindingOwner } from "#internal/bindingOwner.ts";
13
+ import { boundCommand, boundQuery, boundStream } from "#internal/clientOperations.ts";
14
+ import type { CommandShape, QueryShape, StreamShape } from "#operation.ts";
15
15
 
16
16
  export type Failure<R extends Rpc.Any> =
17
17
  R extends Rpc.Rpc<infer _Tag, infer _Payload, infer _Success, infer _Error, infer TMiddleware, infer _Requires>
@@ -57,51 +57,12 @@ export type Bound<
57
57
  readonly [TStream in TStreams[number] as TStream["name"]]: BoundStream<Rpc.ExtractTag<TRpcs, Tag<TName, TStream["name"]>>, TSelf>;
58
58
  };
59
59
 
60
- type ErasedClient = AtomRpc.AtomRpcClient<unknown, string, Rpc.Rpc<string, Schema.Top, Schema.Top, Schema.Top>>;
61
-
62
- type ErasedStreamingClient = AtomRpc.AtomRpcClient<
63
- unknown,
64
- string,
65
- Rpc.Rpc<string, Schema.Top, RpcSchema.Stream<Schema.Top, Schema.Top>, Schema.Never>
66
- >;
67
-
68
60
  type MatchingClient<TRpcs extends Rpc.Any, TClientRpcs extends Rpc.Any> = [TRpcs] extends [Extract<TClientRpcs, { readonly _tag: TRpcs["_tag"] }>]
69
61
  ? [Extract<TClientRpcs, { readonly _tag: TRpcs["_tag"] }>] extends [TRpcs]
70
62
  ? unknown
71
63
  : "Client must preserve the contract RPC types"
72
64
  : "Client must implement the contract RPCs";
73
65
 
74
- const invalidateAfter = Effect.fn("EffectContract.invalidateAfter")(function* (keys: readonly Key[]) {
75
- yield* Reactivity.invalidate(invalidationKeys(keys));
76
- });
77
-
78
- function boundQuery(service: ErasedClient, tag: string, query: QueryShape, track: (keys: readonly Key[]) => void) {
79
- const reactive = queryReactivity(service.runtime);
80
- function reads(payload: unknown) {
81
- return readKeys(query.reads(payload));
82
- }
83
- return {
84
- query: (payload: unknown, options: QueryOptions = {}) => {
85
- track(query.reads(payload));
86
- return reactive(service.query(tag, payload, options), reads(payload));
87
- },
88
- run: (payload: unknown) => service.use((client) => client(tag, payload)),
89
- };
90
- }
91
-
92
- function boundCommand(service: ErasedClient, tag: string, command: CommandShape) {
93
- return {
94
- run: (payload: unknown) =>
95
- service.use((client) => client(tag, payload)).pipe(Effect.tap((result) => invalidateAfter(command.invalidates(payload, result)))),
96
- };
97
- }
98
-
99
- function boundStream(service: ErasedStreamingClient, tag: string) {
100
- return {
101
- run: (payload: unknown) => Stream.unwrap(service.use((client) => Effect.succeed(client(tag, payload)))),
102
- };
103
- }
104
-
105
66
  export function bind<
106
67
  TName extends string,
107
68
  TQueries extends readonly QueryShape[],
@@ -111,20 +72,26 @@ export function bind<
111
72
  TId extends string,
112
73
  TClientRpcs extends Rpc.Any,
113
74
  TStreams extends readonly StreamShape[],
75
+ TScope extends "session" | undefined,
114
76
  >(
115
- contract: Contract<TName, TQueries, TCommands, TRpcs, TStreams>,
77
+ contract: Contract<TName, TQueries, TCommands, TRpcs, TStreams, TScope>,
116
78
  service: AtomRpc.AtomRpcClient<TSelf, TId, TClientRpcs> & MatchingClient<NoInfer<TRpcs>, TClientRpcs>,
79
+ ...options: "session" extends TScope
80
+ ? [options: { readonly registry: AtomRegistry.AtomRegistry }]
81
+ : [options?: { readonly registry: AtomRegistry.AtomRegistry }]
117
82
  ): Bound<TName, TQueries, TCommands, TRpcs, TSelf, TStreams> & LiveBound;
118
83
  export function bind(
119
- contract: { readonly declaration: Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[]> },
84
+ contract: { readonly declaration: Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[], "session" | undefined> },
120
85
  service: AtomRpc.AtomRpcClient<unknown, string, Rpc.Any>,
86
+ options?: { readonly registry: AtomRegistry.AtomRegistry },
121
87
  ): unknown {
122
- const { name, queries, commands, streams } = contract.declaration;
123
- const automatic = automaticLive(service, name);
88
+ const { name, queries, commands, streams, keyScope } = contract.declaration;
89
+ const owner = bindingOwner(keyScope, options?.registry);
90
+ const automatic = automaticLive(service, name, owner);
124
91
  return Object.assign(
125
92
  Object.fromEntries([
126
- ...queries.map((query) => [query.name, boundQuery(service, `${name}.${query.name}`, query, automatic.track)] as const),
127
- ...commands.map((command) => [command.name, boundCommand(service, `${name}.${command.name}`, command)] as const),
93
+ ...queries.map((query) => [query.name, boundQuery(service, `${name}.${query.name}`, query, automatic.track, owner)] as const),
94
+ ...commands.map((command) => [command.name, boundCommand(service, `${name}.${command.name}`, command, owner)] as const),
128
95
  ...streams.map((stream) => [stream.name, boundStream(service, `${name}.${stream.name}`)] as const),
129
96
  ]),
130
97
  { [LiveConnection]: automatic.subscription },
package/src/contract.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Context, type Schema } from "effect";
2
2
  import { Rpc, RpcGroup, type RpcMiddleware, type RpcSchema } from "effect/rpc";
3
- import type { CommandShape, OperationShape, QueryShape, StreamShape } from "./operation.ts";
4
- import { CommandInvalidation, changesRpc, QueryReads } from "./serverMetadata.ts";
3
+ import type { CommandShape, OperationShape, QueryShape, StreamShape } from "#operation.ts";
4
+ import { CommandInvalidation, changesRpc, KeyScope, QueryReads } from "#serverMetadata.ts";
5
5
 
6
6
  export type Tag<TContract extends string, TName extends string> = `${TContract}.${TName}`;
7
7
 
@@ -16,8 +16,10 @@ export interface Declared<
16
16
  TQueries extends readonly QueryShape[],
17
17
  TCommands extends readonly CommandShape[],
18
18
  TStreams extends readonly StreamShape[] = readonly [],
19
+ TScope extends "session" | undefined = undefined,
19
20
  > {
20
21
  readonly commands: TCommands;
22
+ readonly keyScope: TScope;
21
23
  readonly name: TName;
22
24
  readonly queries: TQueries;
23
25
  readonly streams: TStreams;
@@ -27,11 +29,15 @@ export interface Contract<
27
29
  TName extends string,
28
30
  TQueries extends readonly QueryShape[],
29
31
  TCommands extends readonly CommandShape[],
30
- TRpcs extends Rpc.Any = OperationRpc<TName, TQueries[number] | TCommands[number]> | ReturnType<typeof changesRpc<TName>>,
32
+ TRpcs extends Rpc.Any = OperationRpc<TName, TQueries[number] | TCommands[number]> | ReturnType<typeof changesRpc<TName, TQueries>>,
31
33
  TStreams extends readonly StreamShape[] = readonly [],
34
+ TScope extends "session" | undefined = undefined,
32
35
  > extends RpcGroup.RpcGroup<TRpcs> {
33
- readonly declaration: Declared<TName, TQueries, TCommands, TStreams>;
34
- middleware: <TM extends RpcMiddleware.AnyService>(middleware: TM) => Contract<TName, TQueries, TCommands, Rpc.AddMiddleware<TRpcs, TM>, TStreams>;
36
+ readonly application: RpcGroup.RpcGroup<Exclude<TRpcs, { readonly _tag: `${TName}.$changes` }>>;
37
+ readonly declaration: Declared<TName, TQueries, TCommands, TStreams, TScope>;
38
+ middleware: <TM extends RpcMiddleware.AnyService>(
39
+ middleware: TM,
40
+ ) => Contract<TName, TQueries, TCommands, Rpc.AddMiddleware<TRpcs, TM>, TStreams, TScope>;
35
41
  }
36
42
 
37
43
  type Duplicated<TOperations extends readonly OperationShape[], TSeen extends string = never> = TOperations extends readonly [
@@ -52,28 +58,32 @@ function assertUnique(operations: readonly OperationShape[]) {
52
58
  }
53
59
  }
54
60
 
55
- function annotations(operation: QueryShape | CommandShape | StreamShape) {
61
+ function annotations(operation: QueryShape | CommandShape | StreamShape, keyScope: "session" | undefined) {
62
+ const scope = keyScope === undefined ? Context.empty() : Context.make(KeyScope, keyScope);
56
63
  if (operation.kind === "command") {
57
- return Context.make(CommandInvalidation, operation.invalidates);
64
+ return Context.add(scope, CommandInvalidation, operation.invalidates);
58
65
  }
59
- return operation.kind === "query" ? Context.make(QueryReads, operation.reads) : Context.empty();
66
+ return operation.kind === "query" ? Context.add(scope, QueryReads, operation.reads) : scope;
60
67
  }
61
68
 
62
- type AnyDeclared = Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[]>;
69
+ type AnyDeclared = Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[], "session" | undefined>;
63
70
 
64
71
  interface Group {
65
72
  middleware: (middleware: RpcMiddleware.AnyService) => Group;
66
73
  }
67
74
 
68
75
  interface DeclaredGroup extends Group {
76
+ readonly application: unknown;
69
77
  readonly declaration: AnyDeclared;
70
78
  }
71
79
 
72
- function withDeclaration(group: Group, declaration: AnyDeclared): DeclaredGroup {
80
+ function withDeclaration(group: Group, application: Group, declaration: AnyDeclared): DeclaredGroup {
73
81
  const nativeMiddleware = group.middleware.bind(group);
74
82
  return Object.assign(group, {
83
+ application,
75
84
  declaration,
76
- middleware: (middleware: RpcMiddleware.AnyService) => withDeclaration(nativeMiddleware(middleware), declaration),
85
+ middleware: (middleware: RpcMiddleware.AnyService) =>
86
+ withDeclaration(nativeMiddleware(middleware), application.middleware(middleware), declaration),
77
87
  });
78
88
  }
79
89
 
@@ -82,9 +92,11 @@ export function contract<
82
92
  const TQueries extends readonly QueryShape[] = readonly [],
83
93
  const TCommands extends readonly CommandShape[] = readonly [],
84
94
  const TStreams extends readonly StreamShape[] = readonly [],
95
+ const TScope extends "session" | undefined = undefined,
85
96
  >(
86
97
  name: TName,
87
98
  operations: {
99
+ readonly keyScope?: TScope;
88
100
  readonly queries?: TQueries & Unique<TQueries>;
89
101
  readonly commands?: TCommands & Unique<[...TQueries, ...TCommands]>;
90
102
  readonly streams?: TStreams & Unique<[...TQueries, ...TCommands, ...TStreams]>;
@@ -93,14 +105,20 @@ export function contract<
93
105
  TName,
94
106
  TQueries,
95
107
  TCommands,
96
- OperationRpc<TName, TQueries[number] | TCommands[number] | TStreams[number]> | ReturnType<typeof changesRpc<TName>>,
97
- TStreams
108
+ OperationRpc<TName, TQueries[number] | TCommands[number] | TStreams[number]> | ReturnType<typeof changesRpc<TName, TQueries>>,
109
+ TStreams,
110
+ TScope
98
111
  >;
99
112
  export function contract(
100
113
  name: string,
101
- operations: { readonly queries?: readonly QueryShape[]; readonly commands?: readonly CommandShape[]; readonly streams?: readonly StreamShape[] },
114
+ operations: {
115
+ readonly keyScope?: "session";
116
+ readonly queries?: readonly QueryShape[];
117
+ readonly commands?: readonly CommandShape[];
118
+ readonly streams?: readonly StreamShape[];
119
+ },
102
120
  ): DeclaredGroup {
103
- const { queries = [], commands = [], streams = [] } = operations;
121
+ const { queries = [], commands = [], streams = [], keyScope } = operations;
104
122
  assertUnique([...queries, ...commands, ...streams]);
105
123
  const rpcs = [...queries, ...commands, ...streams].map((operation) =>
106
124
  Rpc.make(`${name}.${operation.name}`, {
@@ -108,7 +126,15 @@ export function contract(
108
126
  payload: operation.payload,
109
127
  stream: operation.kind === "stream",
110
128
  success: operation.success,
111
- }).annotateMerge(annotations(operation)),
129
+ }).annotateMerge(annotations(operation, keyScope)),
112
130
  );
113
- return withDeclaration(RpcGroup.make(...rpcs, changesRpc(name)), { commands, name, queries, streams });
131
+ const application = RpcGroup.make(...rpcs);
132
+ const changes = changesRpc(name, queries);
133
+ return withDeclaration(application.add(keyScope === undefined ? changes : changes.annotate(KeyScope, keyScope)), application, {
134
+ commands,
135
+ keyScope,
136
+ name,
137
+ queries,
138
+ streams,
139
+ });
114
140
  }
@@ -0,0 +1,24 @@
1
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
2
+ import type { KeyContext } from "#keys.ts";
3
+
4
+ export interface BindingOwner {
5
+ readonly context: KeyContext;
6
+ readonly registry: AtomRegistry.AtomRegistry;
7
+ }
8
+
9
+ const contexts = new WeakMap<AtomRegistry.AtomRegistry, KeyContext>();
10
+
11
+ export function bindingOwner(scope: "session" | undefined, registry?: AtomRegistry.AtomRegistry): BindingOwner | undefined {
12
+ if (scope === undefined) {
13
+ return undefined;
14
+ }
15
+ if (registry === undefined) {
16
+ throw new Error("A session-scoped contract requires its owning registry");
17
+ }
18
+ let context = contexts.get(registry);
19
+ if (context === undefined) {
20
+ context = { scope: crypto.randomUUID() };
21
+ contexts.set(registry, context);
22
+ }
23
+ return { context, registry };
24
+ }
@@ -0,0 +1,74 @@
1
+ import { Effect, type Schema, Stream } from "effect";
2
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+ import type * as AtomRpc from "effect/reactivity/AtomRpc";
4
+ import * as Reactivity from "effect/reactivity/Reactivity";
5
+ import type { Rpc, RpcSchema } from "effect/rpc";
6
+ import type { automaticLive } from "#automaticLive.ts";
7
+ import type { QueryOptions } from "#bind.ts";
8
+ import { invalidationKeys, readKeys } from "#keys.ts";
9
+ import type { CommandShape, QueryShape } from "#operation.ts";
10
+ import type { BindingOwner } from "./bindingOwner.ts";
11
+ import { queryReactivity } from "./queryReactivity.ts";
12
+
13
+ type ErasedClient = AtomRpc.AtomRpcClient<unknown, string, Rpc.Rpc<string, Schema.Top, Schema.Top, Schema.Top>>;
14
+
15
+ type ErasedStreamingClient = AtomRpc.AtomRpcClient<
16
+ unknown,
17
+ string,
18
+ Rpc.Rpc<string, Schema.Top, RpcSchema.Stream<Schema.Top, Schema.Top>, Schema.Never>
19
+ >;
20
+
21
+ function run<A, E>(service: ErasedClient, effect: Effect.Effect<A, E, unknown>, owner?: BindingOwner) {
22
+ return owner === undefined ? effect : Effect.suspend(() => AtomRegistry.getResult(owner.registry, service.runtime.atom(effect)));
23
+ }
24
+
25
+ export function boundQuery(
26
+ service: ErasedClient,
27
+ tag: string,
28
+ query: QueryShape,
29
+ track: ReturnType<typeof automaticLive>["track"],
30
+ owner?: BindingOwner,
31
+ ) {
32
+ const reactive = queryReactivity(service.runtime);
33
+ return {
34
+ query: (payload: unknown, options: QueryOptions = {}) => {
35
+ const atom = service.query(tag, payload, options);
36
+ const keys = query.reads(payload);
37
+ const reference = { payload, query: query.name };
38
+ if (owner === undefined) {
39
+ track(undefined, reference, keys);
40
+ }
41
+ return reactive(
42
+ atom,
43
+ readKeys(keys, owner?.context),
44
+ (registry) => (owner === undefined ? () => undefined : track(registry, reference, keys)),
45
+ owner?.registry,
46
+ );
47
+ },
48
+ run: (payload: unknown) =>
49
+ run(
50
+ service,
51
+ service.use((client) => client(tag, payload)),
52
+ owner,
53
+ ),
54
+ };
55
+ }
56
+
57
+ export function boundCommand(service: ErasedClient, tag: string, command: CommandShape, owner?: BindingOwner) {
58
+ return {
59
+ run: (payload: unknown) =>
60
+ run(
61
+ service,
62
+ service
63
+ .use((client) => client(tag, payload))
64
+ .pipe(Effect.tap((result) => Reactivity.invalidate(invalidationKeys(command.invalidates(payload, result), owner?.context)))),
65
+ owner,
66
+ ),
67
+ };
68
+ }
69
+
70
+ export function boundStream(service: ErasedStreamingClient, tag: string) {
71
+ return {
72
+ run: (payload: unknown) => Stream.unwrap(service.use((client) => Effect.succeed(client(tag, payload)))),
73
+ };
74
+ }
@@ -1,6 +1,7 @@
1
1
  import { Duration, Effect } from "effect";
2
2
  import * as AsyncResult from "effect/reactivity/AsyncResult";
3
3
  import * as Atom from "effect/reactivity/Atom";
4
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
5
  import * as Reactivity from "effect/reactivity/Reactivity";
5
6
  import { queryCoalesceWindow } from "#queryCoalesceWindow.ts";
6
7
  import { liveBatches, liveKeys } from "./liveInvalidation.ts";
@@ -9,7 +10,12 @@ export function queryReactivity<R, E>(runtime: Atom.AtomRuntime<R, E>) {
9
10
  const service = runtime.atom(Effect.service(Reactivity.Reactivity));
10
11
  type Query = Atom.Atom<AsyncResult.AsyncResult<unknown, unknown>>;
11
12
  const cached = new WeakMap<Query, Query>();
12
- return (atom: Query, keys: readonly string[]): Query => {
13
+ return (
14
+ atom: Query,
15
+ keys: readonly string[],
16
+ track: (registry: AtomRegistry.AtomRegistry) => () => void,
17
+ owner?: AtomRegistry.AtomRegistry,
18
+ ): Query => {
13
19
  const existing = cached.get(atom);
14
20
  if (existing !== undefined) {
15
21
  return existing;
@@ -21,11 +27,15 @@ export function queryReactivity<R, E>(runtime: Atom.AtomRuntime<R, E>) {
21
27
  let wrapped = Atom.transform(
22
28
  source,
23
29
  (get) => {
30
+ if (owner !== undefined && owner !== get.registry) {
31
+ throw new Error("A session-scoped query belongs to its binding registry");
32
+ }
24
33
  const context = get(service);
25
34
  if (!AsyncResult.isSuccess(context)) {
26
35
  return get(source);
27
36
  }
28
37
  const reactivity = context.value;
38
+ get.addFinalizer(track(get.registry));
29
39
  let pending: (() => boolean) | undefined;
30
40
  let timer: ReturnType<typeof setTimeout> | undefined;
31
41
  function clear() {
package/src/keys.ts CHANGED
@@ -37,14 +37,26 @@ export function collection<const TName extends string, TId extends Schema.Top &
37
37
  };
38
38
  }
39
39
 
40
+ export interface KeyContext {
41
+ readonly scope: string;
42
+ }
43
+
44
+ function contextual(key: string, context?: KeyContext): string {
45
+ return context === undefined ? key : `${JSON.stringify(["session", context.scope])}:${key}`;
46
+ }
47
+
40
48
  function own(key: Key): string {
41
49
  return key._tag === "List" ? key.collection : `${key.collection}:${key.id}`;
42
50
  }
43
51
 
44
- export function readKeys(keys: readonly Key[]): readonly string[] {
45
- return [...new Set(keys.map(own))];
52
+ export function readKeys(keys: readonly Key[], context?: KeyContext): readonly string[] {
53
+ return [...new Set(keys.flatMap((key) => (key._tag === "Item" ? [own(key), `${key.collection}:*`] : [own(key)])))].map((key) =>
54
+ contextual(key, context),
55
+ );
46
56
  }
47
57
 
48
- export function invalidationKeys(keys: readonly Key[]): readonly string[] {
49
- return [...new Set(keys.flatMap((key) => (key._tag === "List" ? [key.collection] : [own(key), key.collection])))];
58
+ export function invalidationKeys(keys: readonly Key[], context?: KeyContext): readonly string[] {
59
+ return [...new Set(keys.flatMap((key) => (key._tag === "List" ? [key.collection] : [own(key), key.collection])))].map((key) =>
60
+ contextual(key, context),
61
+ );
50
62
  }
package/src/live.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type Cause, Duration, Effect, Option, Schema, Stream } from "effect";
2
2
  import * as Atom from "effect/reactivity/Atom";
3
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
4
  import * as Reactivity from "effect/reactivity/Reactivity";
4
5
  import { invalidateLive } from "#internal/liveInvalidation.ts";
5
- import { invalidationKeys, Key, readKeys } from "./keys.ts";
6
+ import { invalidationKeys, Key, type KeyContext, readKeys } from "#keys.ts";
6
7
 
7
8
  export const LiveHint = Schema.Union([
8
9
  Schema.Struct({ _tag: Schema.Literal("Changed"), keys: Schema.Array(Key) }),
@@ -17,8 +18,9 @@ export interface LiveStatus<E> {
17
18
  }
18
19
  export interface LiveOptions<E, R> {
19
20
  readonly coalesceWindow?: Duration.Input;
21
+ readonly keyContext?: KeyContext | undefined;
20
22
  readonly resume?: Atom.Atom<unknown>;
21
- readonly resyncKeys: readonly Key[] | (() => readonly Key[]);
23
+ readonly resyncKeys: readonly Key[] | ((registry: AtomRegistry.AtomRegistry) => readonly Key[]);
22
24
  readonly retryDelay?: Duration.Input;
23
25
  readonly stream: Stream.Stream<LiveHint, E, R>;
24
26
  }
@@ -51,14 +53,14 @@ export function live<R, E, TRuntimeError>(runtime: Atom.AtomRuntime<R, TRuntimeE
51
53
  });
52
54
  function changed(changedKeys: readonly Key[]) {
53
55
  const window = get.once(coalesceWindow);
54
- const keys = invalidationKeys(changedKeys);
56
+ const keys = invalidationKeys(changedKeys, options.keyContext);
55
57
  invalidateLive(reactivity, keys, () => active && !get.once(paused), window);
56
58
  const delay = Duration.toMillis(Duration.fromInputUnsafe(window));
57
59
  if (delay === 0) {
58
60
  invalidateLive(reactivity, keys, () => active && !get.once(paused));
59
61
  return;
60
62
  }
61
- for (const key of invalidationKeys(changedKeys)) {
63
+ for (const key of invalidationKeys(changedKeys, options.keyContext)) {
62
64
  pending.add(key);
63
65
  }
64
66
  timer ??= setTimeout(() => {
@@ -75,7 +77,8 @@ export function live<R, E, TRuntimeError>(runtime: Atom.AtomRuntime<R, TRuntimeE
75
77
  update({ needsResync: true });
76
78
  return;
77
79
  }
78
- reactivity.invalidateUnsafe(readKeys(typeof options.resyncKeys === "function" ? options.resyncKeys() : options.resyncKeys));
80
+ const keys = typeof options.resyncKeys === "function" ? options.resyncKeys(get.registry) : options.resyncKeys;
81
+ reactivity.invalidateUnsafe(keys.map((key) => readKeys([key], options.keyContext)[0] ?? ""));
79
82
  update({ needsResync: false, pending: 0 });
80
83
  }
81
84
  // A subscription alone does not build the flag, so its first read would otherwise announce a change.
@@ -1,13 +1,13 @@
1
1
  import { Effect, Queue, Stream } from "effect";
2
2
  import * as Reactivity from "effect/reactivity/Reactivity";
3
3
  import { makeHintPolicy } from "#internal/makeHintPolicy.ts";
4
- import { type Key, readKeys } from "./keys.ts";
5
- import type { LiveHint } from "./live.ts";
4
+ import { type Key, type KeyContext, readKeys } from "#keys.ts";
5
+ import type { LiveHint } from "#live.ts";
6
6
 
7
7
  export const makeLiveServer = Effect.gen(function* () {
8
8
  const reactivity = yield* Reactivity.Reactivity;
9
9
  const resyncKey = `@shivaedev/effect-contract/resync/${crypto.randomUUID()}`;
10
- function stream(keys: readonly Key[]) {
10
+ function stream(keys: readonly Key[], context?: KeyContext) {
11
11
  return Stream.unwrap(
12
12
  Effect.gen(function* () {
13
13
  const queue = yield* Queue.unbounded<LiveHint>();
@@ -33,7 +33,7 @@ export const makeLiveServer = Effect.gen(function* () {
33
33
  });
34
34
  }
35
35
  const cancel = keys.map((key) => {
36
- const own = readKeys([key]);
36
+ const own = readKeys([key], context);
37
37
  return reactivity.registerUnsafe(own, () => {
38
38
  pending.set(own[0] ?? "", key);
39
39
  flush();
package/src/operation.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Schema } from "effect";
2
- import type { Bivariant } from "@shivaedev/types/bivariant.ts";
2
+ import type { Bivariant } from "@shivaedev/types/Bivariant.ts";
3
3
  import type { Key } from "./keys.ts";
4
4
  import { type MatchingTags, type Reject, type RejectionSpecs, type Rejections, type RejectionUnion, rejectionSet } from "./rejection.ts";
5
5
 
@@ -1,8 +1,8 @@
1
1
  import { Context, Schema } from "effect";
2
2
  import { Rpc } from "effect/rpc";
3
- import { Key } from "./keys.ts";
4
- import { LiveHint } from "./live.ts";
5
- import type { CommandShape, QueryShape } from "./operation.ts";
3
+ import type { Key } from "#keys.ts";
4
+ import { LiveHint } from "#live.ts";
5
+ import type { CommandShape, QueryShape } from "#operation.ts";
6
6
 
7
7
  export class QueryReads extends Context.Service<QueryReads, QueryShape["reads"]>()("@shivaedev/effect-contract/QueryReads") {}
8
8
 
@@ -10,6 +10,41 @@ export class CommandInvalidation extends Context.Service<CommandInvalidation, Co
10
10
  "@shivaedev/effect-contract/CommandInvalidation",
11
11
  ) {}
12
12
 
13
- export function changesRpc<const TName extends string>(name: TName) {
14
- return Rpc.make(`${name}.$changes`, { payload: { keys: Schema.Array(Key) }, stream: true, success: LiveHint });
13
+ export class KeyScope extends Context.Service<KeyScope, "session">()("@shivaedev/effect-contract/KeyScope") {}
14
+
15
+ export interface QueryReference {
16
+ readonly payload: unknown;
17
+ readonly query: string;
18
+ }
19
+
20
+ type ReferenceSchema<TQuery extends QueryShape> = TQuery extends QueryShape
21
+ ? Schema.Struct<{ query: Schema.Literal<TQuery["name"]>; payload: TQuery["payload"] }>
22
+ : never;
23
+
24
+ export class ChangesReads extends Context.Service<ChangesReads, (references: readonly QueryReference[]) => readonly Key[]>()(
25
+ "@shivaedev/effect-contract/ChangesReads",
26
+ ) {}
27
+
28
+ export function queryKeys(queries: readonly QueryShape[], requested: readonly QueryReference[]): readonly Key[] {
29
+ return requested.flatMap((reference) => {
30
+ const declared = queries.find((candidate) => candidate.name === reference.query);
31
+ if (declared === undefined) {
32
+ throw new Error(`Unknown query: ${reference.query}`);
33
+ }
34
+ return declared.reads(reference.payload);
35
+ });
36
+ }
37
+
38
+ function referenceSchemas<const TQueries extends readonly QueryShape[]>(
39
+ queries: TQueries,
40
+ ): Schema.$Array<Schema.Union<readonly ReferenceSchema<TQueries[number]>[]>>;
41
+ function referenceSchemas(queries: readonly QueryShape[]) {
42
+ return Schema.Array(Schema.Union(queries.map((query) => Schema.Struct({ payload: query.payload, query: Schema.Literal(query.name) }))));
43
+ }
44
+
45
+ export function changesRpc<const TName extends string, const TQueries extends readonly QueryShape[]>(name: TName, queries: TQueries) {
46
+ return Rpc.make(`${name}.$changes`, { payload: { queries: referenceSchemas(queries) }, stream: true, success: LiveHint }).annotate(
47
+ ChangesReads,
48
+ (requested) => queryKeys(queries, requested),
49
+ );
15
50
  }