@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.
- package/CHANGELOG.md +27 -0
- package/README.md +33 -10
- package/dist/automaticLive.d.ts +9 -7
- package/dist/automaticLive.d.ts.map +1 -1
- package/dist/automaticLive.js +52 -19
- package/dist/automaticLive.js.map +1 -1
- package/dist/bind.d.ts +11 -7
- package/dist/bind.d.ts.map +1 -1
- package/dist/bind.js +9 -36
- package/dist/bind.js.map +1 -1
- package/dist/contract.d.ts +13 -8
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +19 -9
- package/dist/contract.js.map +1 -1
- package/dist/internal/bindingOwner.d.ts +8 -0
- package/dist/internal/bindingOwner.d.ts.map +1 -0
- package/dist/internal/bindingOwner.js +16 -0
- package/dist/internal/bindingOwner.js.map +1 -0
- package/dist/internal/clientOperations.d.ts +21 -0
- package/dist/internal/clientOperations.d.ts.map +1 -0
- package/dist/internal/clientOperations.js +36 -0
- package/dist/internal/clientOperations.js.map +1 -0
- package/dist/internal/queryReactivity.d.ts +2 -1
- package/dist/internal/queryReactivity.d.ts.map +1 -1
- package/dist/internal/queryReactivity.js +5 -1
- package/dist/internal/queryReactivity.js.map +1 -1
- package/dist/keys.d.ts +5 -2
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +7 -4
- package/dist/keys.js.map +1 -1
- package/dist/live.d.ts +4 -2
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +5 -4
- package/dist/live.js.map +1 -1
- package/dist/makeLiveServer.d.ts +2 -2
- package/dist/makeLiveServer.d.ts.map +1 -1
- package/dist/makeLiveServer.js +3 -3
- package/dist/makeLiveServer.js.map +1 -1
- package/dist/operation.d.ts +1 -1
- package/dist/serverMetadata.d.ts +21 -12
- package/dist/serverMetadata.d.ts.map +1 -1
- package/dist/serverMetadata.js +19 -4
- package/dist/serverMetadata.js.map +1 -1
- package/package.json +12 -12
- package/src/automaticLive.ts +64 -24
- package/src/bind.ts +21 -54
- package/src/contract.ts +43 -17
- package/src/internal/bindingOwner.ts +24 -0
- package/src/internal/clientOperations.ts +74 -0
- package/src/internal/queryReactivity.ts +11 -1
- package/src/keys.ts +16 -4
- package/src/live.ts +8 -5
- package/src/makeLiveServer.ts +4 -4
- package/src/operation.ts +1 -1
- package/src/serverMetadata.ts +40 -5
package/src/bind.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { Duration,
|
|
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
|
|
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 {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import type { CommandShape, QueryShape, StreamShape } from "
|
|
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
|
|
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 "
|
|
4
|
-
import { CommandInvalidation, changesRpc, QueryReads } from "
|
|
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
|
|
34
|
-
|
|
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.
|
|
64
|
+
return Context.add(scope, CommandInvalidation, operation.invalidates);
|
|
58
65
|
}
|
|
59
|
-
return operation.kind === "query" ? Context.
|
|
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) =>
|
|
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: {
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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 "
|
|
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
|
-
|
|
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.
|
package/src/makeLiveServer.ts
CHANGED
|
@@ -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 "
|
|
5
|
-
import type { LiveHint } from "
|
|
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/
|
|
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
|
|
package/src/serverMetadata.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Context, Schema } from "effect";
|
|
2
2
|
import { Rpc } from "effect/rpc";
|
|
3
|
-
import { Key } from "
|
|
4
|
-
import { LiveHint } from "
|
|
5
|
-
import type { CommandShape, QueryShape } from "
|
|
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
|
|
14
|
-
|
|
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
|
}
|