@shivaedev/contract 0.0.0 → 0.16.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 +210 -0
- package/LICENSE +21 -0
- package/README.md +510 -1
- package/dist/automaticLive.d.ts +28 -0
- package/dist/automaticLive.d.ts.map +1 -0
- package/dist/automaticLive.js +63 -0
- package/dist/automaticLive.js.map +1 -0
- package/dist/bind.d.ts +51 -0
- package/dist/bind.d.ts.map +1 -0
- package/dist/bind.js +14 -0
- package/dist/bind.js.map +1 -0
- package/dist/contract.d.ts +33 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +44 -0
- package/dist/contract.js.map +1 -0
- 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/liveInvalidation.d.ts +12 -0
- package/dist/internal/liveInvalidation.d.ts.map +1 -0
- package/dist/internal/liveInvalidation.js +14 -0
- package/dist/internal/liveInvalidation.js.map +1 -0
- package/dist/internal/makeHintPolicy.d.ts +4 -0
- package/dist/internal/makeHintPolicy.d.ts.map +1 -0
- package/dist/internal/makeHintPolicy.js +25 -0
- package/dist/internal/makeHintPolicy.js.map +1 -0
- package/dist/internal/queryReactivity.d.ts +5 -0
- package/dist/internal/queryReactivity.d.ts.map +1 -0
- package/dist/internal/queryReactivity.js +96 -0
- package/dist/internal/queryReactivity.js.map +1 -0
- package/dist/keys.d.ts +34 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +25 -0
- package/dist/keys.js.map +1 -0
- package/dist/live.d.ts +39 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +98 -0
- package/dist/live.js.map +1 -0
- package/dist/makeLiveServer.d.ts +20 -0
- package/dist/makeLiveServer.d.ts.map +1 -0
- package/dist/makeLiveServer.js +62 -0
- package/dist/makeLiveServer.js.map +1 -0
- package/dist/operation.d.ts +66 -0
- package/dist/operation.d.ts.map +1 -0
- package/dist/operation.js +29 -0
- package/dist/operation.js.map +1 -0
- package/dist/publicConfig.d.ts +20 -0
- package/dist/publicConfig.d.ts.map +1 -0
- package/dist/publicConfig.js +8 -0
- package/dist/publicConfig.js.map +1 -0
- package/dist/queryCoalesceWindow.d.ts +4 -0
- package/dist/queryCoalesceWindow.d.ts.map +1 -0
- package/dist/queryCoalesceWindow.js +11 -0
- package/dist/queryCoalesceWindow.js.map +1 -0
- package/dist/readPolicy.d.ts +10 -0
- package/dist/readPolicy.d.ts.map +1 -0
- package/dist/readPolicy.js +24 -0
- package/dist/readPolicy.js.map +1 -0
- package/dist/rejection.d.ts +41 -0
- package/dist/rejection.d.ts.map +1 -0
- package/dist/rejection.js +16 -0
- package/dist/rejection.js.map +1 -0
- package/dist/resume.d.ts +17 -0
- package/dist/resume.d.ts.map +1 -0
- package/dist/resume.js +28 -0
- package/dist/resume.js.map +1 -0
- package/dist/serverMetadata.d.ts +42 -0
- package/dist/serverMetadata.d.ts.map +1 -0
- package/dist/serverMetadata.js +27 -0
- package/dist/serverMetadata.js.map +1 -0
- package/package.json +73 -4
- package/src/automaticLive.ts +93 -0
- package/src/bind.ts +103 -0
- package/src/contract.ts +140 -0
- package/src/internal/bindingOwner.ts +24 -0
- package/src/internal/clientOperations.ts +74 -0
- package/src/internal/liveInvalidation.ts +21 -0
- package/src/internal/makeHintPolicy.ts +40 -0
- package/src/internal/queryReactivity.ts +109 -0
- package/src/keys.ts +62 -0
- package/src/live.ts +122 -0
- package/src/makeLiveServer.ts +76 -0
- package/src/operation.ts +145 -0
- package/src/publicConfig.ts +9 -0
- package/src/queryCoalesceWindow.ts +13 -0
- package/src/readPolicy.ts +33 -0
- package/src/rejection.ts +91 -0
- package/src/resume.ts +44 -0
- package/src/serverMetadata.ts +50 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Context, Schema } from "effect";
|
|
2
|
+
import { Rpc } from "effect/rpc";
|
|
3
|
+
import { LiveHint } from "#live.ts";
|
|
4
|
+
export class QueryReads extends Context.Service()("@shivaedev/contract/QueryReads") {
|
|
5
|
+
}
|
|
6
|
+
export class CommandInvalidation extends Context.Service()("@shivaedev/contract/CommandInvalidation") {
|
|
7
|
+
}
|
|
8
|
+
export class KeyScope extends Context.Service()("@shivaedev/contract/KeyScope") {
|
|
9
|
+
}
|
|
10
|
+
export class ChangesReads extends Context.Service()("@shivaedev/contract/ChangesReads") {
|
|
11
|
+
}
|
|
12
|
+
export function queryKeys(queries, requested) {
|
|
13
|
+
return requested.flatMap((reference) => {
|
|
14
|
+
const declared = queries.find((candidate) => candidate.name === reference.query);
|
|
15
|
+
if (declared === undefined) {
|
|
16
|
+
throw new Error(`Unknown query: ${reference.query}`);
|
|
17
|
+
}
|
|
18
|
+
return declared.reads(reference.payload);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function referenceSchemas(queries) {
|
|
22
|
+
return Schema.Array(Schema.Union(queries.map((query) => Schema.Struct({ payload: query.payload, query: Schema.Literal(query.name) }))));
|
|
23
|
+
}
|
|
24
|
+
export function changesRpc(name, queries) {
|
|
25
|
+
return Rpc.make(`${name}.$changes`, { payload: { queries: referenceSchemas(queries) }, stream: true, success: LiveHint }).annotate(ChangesReads, (requested) => queryKeys(queries, requested));
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=serverMetadata.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serverMetadata.js","sourceRoot":"","sources":["../src/serverMetadata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACzC,OAAO,EAAE,GAAG,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAGpC,MAAM,OAAO,UAAW,SAAQ,OAAO,CAAC,OAAO,EAAmC,CAAC,gCAAgC,CAAC;CAAG;AAEvH,MAAM,OAAO,mBAAoB,SAAQ,OAAO,CAAC,OAAO,EAAoD,CAC3G,yCAAyC,CACzC;CAAG;AAEJ,MAAM,OAAO,QAAS,SAAQ,OAAO,CAAC,OAAO,EAAuB,CAAC,8BAA8B,CAAC;CAAG;AAWvG,MAAM,OAAO,YAAa,SAAQ,OAAO,CAAC,OAAO,EAA2E,CAC3H,kCAAkC,CAClC;CAAG;AAEJ,MAAM,UAAU,SAAS,CAAC,OAA8B,EAAE,SAAoC;IAC7F,OAAO,SAAS,CAAC,OAAO,CAAC,CAAC,SAAS,EAAE,EAAE;QACtC,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,KAAK,SAAS,CAAC,KAAK,CAAC,CAAC;QACjF,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC5B,MAAM,IAAI,KAAK,CAAC,kBAAkB,SAAS,CAAC,KAAK,EAAE,CAAC,CAAC;QACtD,CAAC;QACD,OAAO,QAAQ,CAAC,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAC1C,CAAC,CAAC,CAAC;AACJ,CAAC;AAKD,SAAS,gBAAgB,CAAC,OAA8B;IACvD,OAAO,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AACzI,CAAC;AAED,MAAM,UAAU,UAAU,CAA2E,IAAW,EAAE,OAAiB;IAClI,OAAO,GAAG,CAAC,IAAI,CAAC,GAAG,IAAI,WAAW,EAAE,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,gBAAgB,CAAC,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC,QAAQ,CACjI,YAAY,EACZ,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,OAAO,EAAE,SAAS,CAAC,CAC5C,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/contract",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"description": "Typed query and command contracts over native Effect RPC and reactivity",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"effect"
|
|
7
|
+
],
|
|
8
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/contract#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
11
|
+
},
|
|
6
12
|
"repository": {
|
|
7
13
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/ShivaeDev/platform.git"
|
|
14
|
+
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
15
|
+
"directory": "packages/contract"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"type": "module",
|
|
20
|
+
"imports": {
|
|
21
|
+
"#test/*": "./src/test-support/*",
|
|
22
|
+
"#*.ts": {
|
|
23
|
+
"source": "./src/*.ts",
|
|
24
|
+
"types": "./dist/*.d.ts",
|
|
25
|
+
"default": "./dist/*.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./*.ts": {
|
|
30
|
+
"source": "./src/*.ts",
|
|
31
|
+
"types": "./dist/*.d.ts",
|
|
32
|
+
"default": "./dist/*.js"
|
|
33
|
+
},
|
|
34
|
+
"./internal/*": null,
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src",
|
|
40
|
+
"!src/**/*.test.ts",
|
|
41
|
+
"!src/**/*.spec.ts",
|
|
42
|
+
"!src/**/test-support",
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@shivaedev/types": "0.3.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@effect/platform-node": "4.0.5",
|
|
51
|
+
"@effect/vitest": "4.0.5",
|
|
52
|
+
"@shivaedev/quality": "0.41.1",
|
|
53
|
+
"@shivaedev/test": "0.9.0",
|
|
54
|
+
"@shivaedev/test-sql": "0.9.3",
|
|
55
|
+
"@shivaedev/test-story": "0.20.1",
|
|
56
|
+
"@types/node": "24.19.2",
|
|
57
|
+
"effect": "4.0.5",
|
|
58
|
+
"esbuild": "0.28.2",
|
|
59
|
+
"happy-dom": "21.0.3",
|
|
60
|
+
"typescript": "7.0.2",
|
|
61
|
+
"vitest": "5.0.5"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"effect": "4.0.5"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=24"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public",
|
|
71
|
+
"provenance": true
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
|
|
75
|
+
"ready": "pnpm typecheck && pnpm test && pnpm build",
|
|
76
|
+
"test": "vitest run",
|
|
77
|
+
"typecheck": "tsc --noEmit"
|
|
9
78
|
}
|
|
10
79
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type Duration, Effect, Stream, SubscriptionRef } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
4
|
+
import type * as AtomRpc from "effect/reactivity/AtomRpc";
|
|
5
|
+
import type { BindingOwner } from "#internal/bindingOwner.ts";
|
|
6
|
+
import { type Key, readKeys } from "#keys.ts";
|
|
7
|
+
import { type LiveStatus, live } from "#live.ts";
|
|
8
|
+
import { type ResumeSource, type ResumeWindow, resumeSignal } from "#resume.ts";
|
|
9
|
+
import type { changesRpc, QueryReference } from "#serverMetadata.ts";
|
|
10
|
+
|
|
11
|
+
declare const window: ResumeWindow | undefined;
|
|
12
|
+
|
|
13
|
+
export const LiveConnection = Symbol.for("@shivaedev/contract/LiveConnection");
|
|
14
|
+
export interface LiveBound {
|
|
15
|
+
readonly [LiveConnection]: {
|
|
16
|
+
readonly coalesceWindow: Atom.Writable<Duration.Input>;
|
|
17
|
+
readonly connection: Atom.Atom<unknown>;
|
|
18
|
+
readonly status: Atom.Atom<LiveStatus<unknown>>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ChangesClient = AtomRpc.AtomRpcClient<unknown, string, ReturnType<typeof changesRpc>>;
|
|
23
|
+
|
|
24
|
+
interface Tracked {
|
|
25
|
+
readonly keys: readonly Key[];
|
|
26
|
+
readonly reference: QueryReference;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function automaticLive(service: ChangesClient, name: string, owner?: BindingOwner, resume?: ResumeSource) {
|
|
30
|
+
const eager = owner === undefined ? Effect.runSync(SubscriptionRef.make<ReadonlyMap<object, Tracked>>(new Map())) : undefined;
|
|
31
|
+
const known = new Set<string>();
|
|
32
|
+
const tracked = Atom.keepAlive(Atom.make(() => eager ?? Effect.runSync(SubscriptionRef.make<ReadonlyMap<object, Tracked>>(new Map()))));
|
|
33
|
+
function track(registry: AtomRegistry.AtomRegistry | undefined, reference: QueryReference, keys: readonly Key[]) {
|
|
34
|
+
const fresh =
|
|
35
|
+
eager === undefined
|
|
36
|
+
? keys
|
|
37
|
+
: keys.filter((key) => {
|
|
38
|
+
const id = readKeys([key])[0] ?? "";
|
|
39
|
+
if (known.has(id)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
known.add(id);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
if (fresh.length === 0) {
|
|
46
|
+
return () => undefined;
|
|
47
|
+
}
|
|
48
|
+
const token = {};
|
|
49
|
+
const state = eager ?? registry?.get(tracked);
|
|
50
|
+
if (state === undefined) {
|
|
51
|
+
throw new Error("Session query tracking requires its owning registry");
|
|
52
|
+
}
|
|
53
|
+
Effect.runSync(SubscriptionRef.update(state, (current) => new Map([...current, [token, { keys: fresh, reference }]])));
|
|
54
|
+
if (eager !== undefined) {
|
|
55
|
+
return () => undefined;
|
|
56
|
+
}
|
|
57
|
+
return () =>
|
|
58
|
+
Effect.runSync(
|
|
59
|
+
SubscriptionRef.update(state, (current) => {
|
|
60
|
+
const next = new Map(current);
|
|
61
|
+
next.delete(token);
|
|
62
|
+
return next;
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
function subscribe(current: ReadonlyMap<object, Tracked>) {
|
|
67
|
+
if (current.size === 0) {
|
|
68
|
+
return Stream.never;
|
|
69
|
+
}
|
|
70
|
+
const queries = [...current.values()].map(({ reference }) => reference);
|
|
71
|
+
return Stream.unwrap(service.use((client) => Effect.succeed(client(`${name}.$changes`, { queries }))));
|
|
72
|
+
}
|
|
73
|
+
const signal = resumeSignal({ ...(typeof window === "undefined" ? {} : { window }), ...(resume === undefined ? {} : { native: resume }) });
|
|
74
|
+
const subscription = live(service.runtime, {
|
|
75
|
+
keyContext: owner?.context,
|
|
76
|
+
resume: signal,
|
|
77
|
+
resyncKeys: (registry) => [...Effect.runSync(SubscriptionRef.get(registry.get(tracked))).values()].flatMap(({ keys }) => keys),
|
|
78
|
+
stream: Stream.unwrap(
|
|
79
|
+
Effect.map(AtomRegistry.AtomRegistry, (registry) =>
|
|
80
|
+
Stream.zipLatest(SubscriptionRef.changes(registry.get(tracked)), AtomRegistry.toStream(registry, signal)).pipe(
|
|
81
|
+
Stream.switchMap(([current]) => subscribe(current)),
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
),
|
|
85
|
+
});
|
|
86
|
+
const connection = Atom.make((get) => {
|
|
87
|
+
if (owner !== undefined && owner.registry !== get.registry) {
|
|
88
|
+
throw new Error("A session-scoped live connection belongs to its binding registry");
|
|
89
|
+
}
|
|
90
|
+
return get(subscription.connection);
|
|
91
|
+
}).pipe(Atom.setIdleTTL(0));
|
|
92
|
+
return { subscription: { ...subscription, connection }, track };
|
|
93
|
+
}
|
package/src/bind.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Duration, Effect, Stream } from "effect";
|
|
2
|
+
import type { Headers } from "effect/http";
|
|
3
|
+
import type * as AsyncResult from "effect/reactivity/AsyncResult";
|
|
4
|
+
import type * as Atom from "effect/reactivity/Atom";
|
|
5
|
+
import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
6
|
+
import type * as AtomRpc from "effect/reactivity/AtomRpc";
|
|
7
|
+
import type * as Reactivity from "effect/reactivity/Reactivity";
|
|
8
|
+
import type { Rpc } from "effect/rpc";
|
|
9
|
+
import type { RpcClientError } from "effect/rpc/RpcClientError";
|
|
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
|
+
import type { ResumeSource } from "#resume.ts";
|
|
16
|
+
|
|
17
|
+
export type Failure<R extends Rpc.Any> =
|
|
18
|
+
R extends Rpc.Rpc<infer _Tag, infer _Payload, infer _Success, infer _Error, infer TMiddleware, infer _Requires>
|
|
19
|
+
? Rpc.ErrorExit<R> | TMiddleware["error"]["Type"] | RpcClientError
|
|
20
|
+
: never;
|
|
21
|
+
|
|
22
|
+
export type RunFailure<R extends Rpc.Any> =
|
|
23
|
+
R extends Rpc.Rpc<infer _Tag, infer _Payload, infer _Success, infer _Error, infer TMiddleware, infer _Requires>
|
|
24
|
+
? Failure<R> | TMiddleware["~ClientError"]
|
|
25
|
+
: never;
|
|
26
|
+
|
|
27
|
+
export interface QueryOptions {
|
|
28
|
+
readonly headers?: Headers.Input;
|
|
29
|
+
readonly serializationKey?: string;
|
|
30
|
+
readonly timeToLive?: Duration.Input;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface BoundQuery<R extends Rpc.Any, TSelf> {
|
|
34
|
+
readonly query: (payload: Rpc.Payload<R>, options?: QueryOptions) => Atom.Atom<AsyncResult.AsyncResult<Rpc.Success<R>, Failure<R>>>;
|
|
35
|
+
readonly run: (payload: Rpc.Payload<R>) => Effect.Effect<Rpc.Success<R>, RunFailure<R>, TSelf>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BoundCommand<R extends Rpc.Any, TSelf> {
|
|
39
|
+
readonly run: (payload: Rpc.Payload<R>) => Effect.Effect<Rpc.Success<R>, RunFailure<R>, TSelf | Reactivity.Reactivity>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BoundStream<R extends Rpc.Any, TSelf> {
|
|
43
|
+
readonly run: (payload: Rpc.Payload<R>) => Stream.Stream<Rpc.SuccessChunk<R>, RunFailure<R>, TSelf | Rpc.ServicesClient<R>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Bound<
|
|
47
|
+
TName extends string,
|
|
48
|
+
TQueries extends readonly QueryShape[],
|
|
49
|
+
TCommands extends readonly CommandShape[],
|
|
50
|
+
TRpcs extends Rpc.Any,
|
|
51
|
+
TSelf,
|
|
52
|
+
TStreams extends readonly StreamShape[] = readonly [],
|
|
53
|
+
> = {
|
|
54
|
+
readonly [TQuery in TQueries[number] as TQuery["name"]]: BoundQuery<Rpc.ExtractTag<TRpcs, Tag<TName, TQuery["name"]>>, TSelf>;
|
|
55
|
+
} & {
|
|
56
|
+
readonly [TCommand in TCommands[number] as TCommand["name"]]: BoundCommand<Rpc.ExtractTag<TRpcs, Tag<TName, TCommand["name"]>>, TSelf>;
|
|
57
|
+
} & {
|
|
58
|
+
readonly [TStream in TStreams[number] as TStream["name"]]: BoundStream<Rpc.ExtractTag<TRpcs, Tag<TName, TStream["name"]>>, TSelf>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type MatchingClient<TRpcs extends Rpc.Any, TClientRpcs extends Rpc.Any> = [TRpcs] extends [Extract<TClientRpcs, { readonly _tag: TRpcs["_tag"] }>]
|
|
62
|
+
? [Extract<TClientRpcs, { readonly _tag: TRpcs["_tag"] }>] extends [TRpcs]
|
|
63
|
+
? unknown
|
|
64
|
+
: "Client must preserve the contract RPC types"
|
|
65
|
+
: "Client must implement the contract RPCs";
|
|
66
|
+
|
|
67
|
+
interface BindOptions {
|
|
68
|
+
readonly registry?: AtomRegistry.AtomRegistry;
|
|
69
|
+
readonly resume?: ResumeSource;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function bind<
|
|
73
|
+
TName extends string,
|
|
74
|
+
TQueries extends readonly QueryShape[],
|
|
75
|
+
TCommands extends readonly CommandShape[],
|
|
76
|
+
TRpcs extends Rpc.Any,
|
|
77
|
+
TSelf,
|
|
78
|
+
TId extends string,
|
|
79
|
+
TClientRpcs extends Rpc.Any,
|
|
80
|
+
TStreams extends readonly StreamShape[],
|
|
81
|
+
TScope extends "session" | undefined,
|
|
82
|
+
>(
|
|
83
|
+
contract: Contract<TName, TQueries, TCommands, TRpcs, TStreams, TScope>,
|
|
84
|
+
service: AtomRpc.AtomRpcClient<TSelf, TId, TClientRpcs> & MatchingClient<NoInfer<TRpcs>, TClientRpcs>,
|
|
85
|
+
...options: "session" extends TScope ? [options: BindOptions & { readonly registry: AtomRegistry.AtomRegistry }] : [options?: BindOptions]
|
|
86
|
+
): Bound<TName, TQueries, TCommands, TRpcs, TSelf, TStreams> & LiveBound;
|
|
87
|
+
export function bind(
|
|
88
|
+
contract: { readonly declaration: Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[], "session" | undefined> },
|
|
89
|
+
service: AtomRpc.AtomRpcClient<unknown, string, Rpc.Any>,
|
|
90
|
+
options?: BindOptions,
|
|
91
|
+
): unknown {
|
|
92
|
+
const { name, queries, commands, streams, keyScope } = contract.declaration;
|
|
93
|
+
const owner = bindingOwner(keyScope, options?.registry);
|
|
94
|
+
const automatic = automaticLive(service, name, owner, options?.resume);
|
|
95
|
+
return Object.assign(
|
|
96
|
+
Object.fromEntries([
|
|
97
|
+
...queries.map((query) => [query.name, boundQuery(service, `${name}.${query.name}`, query, automatic.track, owner)] as const),
|
|
98
|
+
...commands.map((command) => [command.name, boundCommand(service, `${name}.${command.name}`, command, owner)] as const),
|
|
99
|
+
...streams.map((stream) => [stream.name, boundStream(service, `${name}.${stream.name}`)] as const),
|
|
100
|
+
]),
|
|
101
|
+
{ [LiveConnection]: automatic.subscription },
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Context, type Schema } from "effect";
|
|
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, KeyScope, QueryReads } from "#serverMetadata.ts";
|
|
5
|
+
|
|
6
|
+
export type Tag<TContract extends string, TName extends string> = `${TContract}.${TName}`;
|
|
7
|
+
|
|
8
|
+
export type OperationRpc<TContract extends string, TOperation> = TOperation extends OperationShape
|
|
9
|
+
? TOperation extends StreamShape
|
|
10
|
+
? Rpc.Rpc<Tag<TContract, TOperation["name"]>, TOperation["payload"], RpcSchema.Stream<TOperation["success"], TOperation["error"]>, Schema.Never>
|
|
11
|
+
: Rpc.Rpc<Tag<TContract, TOperation["name"]>, TOperation["payload"], TOperation["success"], TOperation["error"]>
|
|
12
|
+
: never;
|
|
13
|
+
|
|
14
|
+
export interface Declared<
|
|
15
|
+
TName extends string,
|
|
16
|
+
TQueries extends readonly QueryShape[],
|
|
17
|
+
TCommands extends readonly CommandShape[],
|
|
18
|
+
TStreams extends readonly StreamShape[] = readonly [],
|
|
19
|
+
TScope extends "session" | undefined = undefined,
|
|
20
|
+
> {
|
|
21
|
+
readonly commands: TCommands;
|
|
22
|
+
readonly keyScope: TScope;
|
|
23
|
+
readonly name: TName;
|
|
24
|
+
readonly queries: TQueries;
|
|
25
|
+
readonly streams: TStreams;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Contract<
|
|
29
|
+
TName extends string,
|
|
30
|
+
TQueries extends readonly QueryShape[],
|
|
31
|
+
TCommands extends readonly CommandShape[],
|
|
32
|
+
TRpcs extends Rpc.Any = OperationRpc<TName, TQueries[number] | TCommands[number]> | ReturnType<typeof changesRpc<TName, TQueries>>,
|
|
33
|
+
TStreams extends readonly StreamShape[] = readonly [],
|
|
34
|
+
TScope extends "session" | undefined = undefined,
|
|
35
|
+
> extends RpcGroup.RpcGroup<TRpcs> {
|
|
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>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Duplicated<TOperations extends readonly OperationShape[], TSeen extends string = never> = TOperations extends readonly [
|
|
44
|
+
infer THead extends OperationShape,
|
|
45
|
+
...infer TTail extends readonly OperationShape[],
|
|
46
|
+
]
|
|
47
|
+
? (THead["name"] extends TSeen ? THead["name"] : never) | Duplicated<TTail, TSeen | THead["name"]>
|
|
48
|
+
: never;
|
|
49
|
+
|
|
50
|
+
type Unique<TOperations extends readonly OperationShape[]> = [Duplicated<TOperations>] extends [never]
|
|
51
|
+
? unknown
|
|
52
|
+
: `Operation names must be unique; duplicated: ${Duplicated<TOperations>}`;
|
|
53
|
+
|
|
54
|
+
function assertUnique(operations: readonly OperationShape[]) {
|
|
55
|
+
const duplicated = operations.map(({ name }) => name).filter((name, index, names) => names.indexOf(name) !== index);
|
|
56
|
+
if (duplicated.length > 0) {
|
|
57
|
+
throw new Error(`Operation names must be unique; duplicated: ${[...new Set(duplicated)].join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function annotations(operation: QueryShape | CommandShape | StreamShape, keyScope: "session" | undefined) {
|
|
62
|
+
const scope = keyScope === undefined ? Context.empty() : Context.make(KeyScope, keyScope);
|
|
63
|
+
if (operation.kind === "command") {
|
|
64
|
+
return Context.add(scope, CommandInvalidation, operation.invalidates);
|
|
65
|
+
}
|
|
66
|
+
return operation.kind === "query" ? Context.add(scope, QueryReads, operation.reads) : scope;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type AnyDeclared = Declared<string, readonly QueryShape[], readonly CommandShape[], readonly StreamShape[], "session" | undefined>;
|
|
70
|
+
|
|
71
|
+
interface Group {
|
|
72
|
+
middleware: (middleware: RpcMiddleware.AnyService) => Group;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface DeclaredGroup extends Group {
|
|
76
|
+
readonly application: unknown;
|
|
77
|
+
readonly declaration: AnyDeclared;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function withDeclaration(group: Group, application: Group, declaration: AnyDeclared): DeclaredGroup {
|
|
81
|
+
const nativeMiddleware = group.middleware.bind(group);
|
|
82
|
+
return Object.assign(group, {
|
|
83
|
+
application,
|
|
84
|
+
declaration,
|
|
85
|
+
middleware: (middleware: RpcMiddleware.AnyService) =>
|
|
86
|
+
withDeclaration(nativeMiddleware(middleware), application.middleware(middleware), declaration),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function contract<
|
|
91
|
+
const TName extends string,
|
|
92
|
+
const TQueries extends readonly QueryShape[] = readonly [],
|
|
93
|
+
const TCommands extends readonly CommandShape[] = readonly [],
|
|
94
|
+
const TStreams extends readonly StreamShape[] = readonly [],
|
|
95
|
+
const TScope extends "session" | undefined = undefined,
|
|
96
|
+
>(
|
|
97
|
+
name: TName,
|
|
98
|
+
operations: {
|
|
99
|
+
readonly keyScope?: TScope;
|
|
100
|
+
readonly queries?: TQueries & Unique<TQueries>;
|
|
101
|
+
readonly commands?: TCommands & Unique<[...TQueries, ...TCommands]>;
|
|
102
|
+
readonly streams?: TStreams & Unique<[...TQueries, ...TCommands, ...TStreams]>;
|
|
103
|
+
},
|
|
104
|
+
): Contract<
|
|
105
|
+
TName,
|
|
106
|
+
TQueries,
|
|
107
|
+
TCommands,
|
|
108
|
+
OperationRpc<TName, TQueries[number] | TCommands[number] | TStreams[number]> | ReturnType<typeof changesRpc<TName, TQueries>>,
|
|
109
|
+
TStreams,
|
|
110
|
+
TScope
|
|
111
|
+
>;
|
|
112
|
+
export function contract(
|
|
113
|
+
name: string,
|
|
114
|
+
operations: {
|
|
115
|
+
readonly keyScope?: "session";
|
|
116
|
+
readonly queries?: readonly QueryShape[];
|
|
117
|
+
readonly commands?: readonly CommandShape[];
|
|
118
|
+
readonly streams?: readonly StreamShape[];
|
|
119
|
+
},
|
|
120
|
+
): DeclaredGroup {
|
|
121
|
+
const { queries = [], commands = [], streams = [], keyScope } = operations;
|
|
122
|
+
assertUnique([...queries, ...commands, ...streams]);
|
|
123
|
+
const rpcs = [...queries, ...commands, ...streams].map((operation) =>
|
|
124
|
+
Rpc.make(`${name}.${operation.name}`, {
|
|
125
|
+
error: operation.error,
|
|
126
|
+
payload: operation.payload,
|
|
127
|
+
stream: operation.kind === "stream",
|
|
128
|
+
success: operation.success,
|
|
129
|
+
}).annotateMerge(annotations(operation, keyScope)),
|
|
130
|
+
);
|
|
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
|
+
});
|
|
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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Duration } from "effect";
|
|
2
|
+
import type * as Reactivity from "effect/reactivity/Reactivity";
|
|
3
|
+
|
|
4
|
+
interface Batch {
|
|
5
|
+
readonly active: () => boolean;
|
|
6
|
+
readonly seen: Set<() => void>;
|
|
7
|
+
readonly window: Duration.Input | undefined;
|
|
8
|
+
}
|
|
9
|
+
export const liveBatches = new WeakMap<Reactivity.Reactivity, Batch>();
|
|
10
|
+
export function liveKeys(keys: readonly string[]) {
|
|
11
|
+
return keys.map((key) => `@shivaedev/live:${key}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function invalidateLive(reactivity: Reactivity.Reactivity, keys: readonly string[], active: () => boolean, window?: Duration.Input) {
|
|
15
|
+
liveBatches.set(reactivity, { active, seen: new Set(), window });
|
|
16
|
+
try {
|
|
17
|
+
reactivity.invalidateUnsafe(window === undefined ? keys : liveKeys(keys));
|
|
18
|
+
} finally {
|
|
19
|
+
liveBatches.delete(reactivity);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Cache, Data, Effect, Exit, Queue } from "effect";
|
|
2
|
+
import type { Key } from "#keys.ts";
|
|
3
|
+
import { ReadPolicy, ReadPolicyMemoWindow } from "#readPolicy.ts";
|
|
4
|
+
|
|
5
|
+
export const makeHintPolicy = Effect.fnUntraced(function* (capacity: number, retry: (keys: readonly Key[]) => Effect.Effect<void>) {
|
|
6
|
+
const policy = yield* ReadPolicy;
|
|
7
|
+
const window = yield* ReadPolicyMemoWindow;
|
|
8
|
+
const cache = yield* Cache.makeWith(policy, {
|
|
9
|
+
capacity: Math.max(1, capacity),
|
|
10
|
+
timeToLive: (exit) => (Exit.isSuccess(exit) ? window : 0),
|
|
11
|
+
});
|
|
12
|
+
const failed = new Map<string, Key>();
|
|
13
|
+
const wake = yield* Queue.unbounded<void>();
|
|
14
|
+
yield* Queue.take(wake).pipe(
|
|
15
|
+
Effect.andThen(Effect.sleep("1 second")),
|
|
16
|
+
Effect.andThen(
|
|
17
|
+
Effect.suspend(() => {
|
|
18
|
+
const keys = [...failed.values()];
|
|
19
|
+
failed.clear();
|
|
20
|
+
return retry(keys);
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
Effect.forever,
|
|
24
|
+
Effect.forkScoped,
|
|
25
|
+
);
|
|
26
|
+
return (keys: readonly Key[]) =>
|
|
27
|
+
Effect.filter(keys, (key) =>
|
|
28
|
+
Cache.get(cache, new Data.Class<Key>(key)).pipe(
|
|
29
|
+
Effect.catch(() =>
|
|
30
|
+
Effect.gen(function* () {
|
|
31
|
+
if (failed.size === 0) {
|
|
32
|
+
yield* Queue.offer(wake, undefined);
|
|
33
|
+
}
|
|
34
|
+
failed.set(JSON.stringify(key), key);
|
|
35
|
+
return false;
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
});
|