@shivaedev/effect-contract 0.11.0 → 0.13.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 +28 -0
- package/README.md +31 -8
- 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 +10 -8
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +15 -7
- 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 +6 -6
- package/src/automaticLive.ts +64 -24
- package/src/bind.ts +21 -54
- package/src/contract.ts +36 -15
- 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,12 +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
36
|
readonly application: RpcGroup.RpcGroup<Exclude<TRpcs, { readonly _tag: `${TName}.$changes` }>>;
|
|
34
|
-
readonly declaration: Declared<TName, TQueries, TCommands, TStreams>;
|
|
35
|
-
middleware: <TM extends RpcMiddleware.AnyService>(
|
|
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>;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
type Duplicated<TOperations extends readonly OperationShape[], TSeen extends string = never> = TOperations extends readonly [
|
|
@@ -53,14 +58,15 @@ function assertUnique(operations: readonly OperationShape[]) {
|
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
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);
|
|
57
63
|
if (operation.kind === "command") {
|
|
58
|
-
return Context.
|
|
64
|
+
return Context.add(scope, CommandInvalidation, operation.invalidates);
|
|
59
65
|
}
|
|
60
|
-
return operation.kind === "query" ? Context.
|
|
66
|
+
return operation.kind === "query" ? Context.add(scope, QueryReads, operation.reads) : scope;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
|
-
type AnyDeclared = Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[]>;
|
|
69
|
+
type AnyDeclared = Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[], "session" | undefined>;
|
|
64
70
|
|
|
65
71
|
interface Group {
|
|
66
72
|
middleware: (middleware: RpcMiddleware.AnyService) => Group;
|
|
@@ -86,9 +92,11 @@ export function contract<
|
|
|
86
92
|
const TQueries extends readonly QueryShape[] = readonly [],
|
|
87
93
|
const TCommands extends readonly CommandShape[] = readonly [],
|
|
88
94
|
const TStreams extends readonly StreamShape[] = readonly [],
|
|
95
|
+
const TScope extends "session" | undefined = undefined,
|
|
89
96
|
>(
|
|
90
97
|
name: TName,
|
|
91
98
|
operations: {
|
|
99
|
+
readonly keyScope?: TScope;
|
|
92
100
|
readonly queries?: TQueries & Unique<TQueries>;
|
|
93
101
|
readonly commands?: TCommands & Unique<[...TQueries, ...TCommands]>;
|
|
94
102
|
readonly streams?: TStreams & Unique<[...TQueries, ...TCommands, ...TStreams]>;
|
|
@@ -97,14 +105,20 @@ export function contract<
|
|
|
97
105
|
TName,
|
|
98
106
|
TQueries,
|
|
99
107
|
TCommands,
|
|
100
|
-
OperationRpc<TName, TQueries[number] | TCommands[number] | TStreams[number]> | ReturnType<typeof changesRpc<TName>>,
|
|
101
|
-
TStreams
|
|
108
|
+
OperationRpc<TName, TQueries[number] | TCommands[number] | TStreams[number]> | ReturnType<typeof changesRpc<TName, TQueries>>,
|
|
109
|
+
TStreams,
|
|
110
|
+
TScope
|
|
102
111
|
>;
|
|
103
112
|
export function contract(
|
|
104
113
|
name: string,
|
|
105
|
-
operations: {
|
|
114
|
+
operations: {
|
|
115
|
+
readonly keyScope?: "session";
|
|
116
|
+
readonly queries?: readonly QueryShape[];
|
|
117
|
+
readonly commands?: readonly CommandShape[];
|
|
118
|
+
readonly streams?: readonly StreamShape[];
|
|
119
|
+
},
|
|
106
120
|
): DeclaredGroup {
|
|
107
|
-
const { queries = [], commands = [], streams = [] } = operations;
|
|
121
|
+
const { queries = [], commands = [], streams = [], keyScope } = operations;
|
|
108
122
|
assertUnique([...queries, ...commands, ...streams]);
|
|
109
123
|
const rpcs = [...queries, ...commands, ...streams].map((operation) =>
|
|
110
124
|
Rpc.make(`${name}.${operation.name}`, {
|
|
@@ -112,8 +126,15 @@ export function contract(
|
|
|
112
126
|
payload: operation.payload,
|
|
113
127
|
stream: operation.kind === "stream",
|
|
114
128
|
success: operation.success,
|
|
115
|
-
}).annotateMerge(annotations(operation)),
|
|
129
|
+
}).annotateMerge(annotations(operation, keyScope)),
|
|
116
130
|
);
|
|
117
131
|
const application = RpcGroup.make(...rpcs);
|
|
118
|
-
|
|
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
|
+
});
|
|
119
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
|
}
|