@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +210 -0
  2. package/LICENSE +21 -0
  3. package/README.md +510 -1
  4. package/dist/automaticLive.d.ts +28 -0
  5. package/dist/automaticLive.d.ts.map +1 -0
  6. package/dist/automaticLive.js +63 -0
  7. package/dist/automaticLive.js.map +1 -0
  8. package/dist/bind.d.ts +51 -0
  9. package/dist/bind.d.ts.map +1 -0
  10. package/dist/bind.js +14 -0
  11. package/dist/bind.js.map +1 -0
  12. package/dist/contract.d.ts +33 -0
  13. package/dist/contract.d.ts.map +1 -0
  14. package/dist/contract.js +44 -0
  15. package/dist/contract.js.map +1 -0
  16. package/dist/internal/bindingOwner.d.ts +8 -0
  17. package/dist/internal/bindingOwner.d.ts.map +1 -0
  18. package/dist/internal/bindingOwner.js +16 -0
  19. package/dist/internal/bindingOwner.js.map +1 -0
  20. package/dist/internal/clientOperations.d.ts +21 -0
  21. package/dist/internal/clientOperations.d.ts.map +1 -0
  22. package/dist/internal/clientOperations.js +36 -0
  23. package/dist/internal/clientOperations.js.map +1 -0
  24. package/dist/internal/liveInvalidation.d.ts +12 -0
  25. package/dist/internal/liveInvalidation.d.ts.map +1 -0
  26. package/dist/internal/liveInvalidation.js +14 -0
  27. package/dist/internal/liveInvalidation.js.map +1 -0
  28. package/dist/internal/makeHintPolicy.d.ts +4 -0
  29. package/dist/internal/makeHintPolicy.d.ts.map +1 -0
  30. package/dist/internal/makeHintPolicy.js +25 -0
  31. package/dist/internal/makeHintPolicy.js.map +1 -0
  32. package/dist/internal/queryReactivity.d.ts +5 -0
  33. package/dist/internal/queryReactivity.d.ts.map +1 -0
  34. package/dist/internal/queryReactivity.js +96 -0
  35. package/dist/internal/queryReactivity.js.map +1 -0
  36. package/dist/keys.d.ts +34 -0
  37. package/dist/keys.d.ts.map +1 -0
  38. package/dist/keys.js +25 -0
  39. package/dist/keys.js.map +1 -0
  40. package/dist/live.d.ts +39 -0
  41. package/dist/live.d.ts.map +1 -0
  42. package/dist/live.js +98 -0
  43. package/dist/live.js.map +1 -0
  44. package/dist/makeLiveServer.d.ts +20 -0
  45. package/dist/makeLiveServer.d.ts.map +1 -0
  46. package/dist/makeLiveServer.js +62 -0
  47. package/dist/makeLiveServer.js.map +1 -0
  48. package/dist/operation.d.ts +66 -0
  49. package/dist/operation.d.ts.map +1 -0
  50. package/dist/operation.js +29 -0
  51. package/dist/operation.js.map +1 -0
  52. package/dist/publicConfig.d.ts +20 -0
  53. package/dist/publicConfig.d.ts.map +1 -0
  54. package/dist/publicConfig.js +8 -0
  55. package/dist/publicConfig.js.map +1 -0
  56. package/dist/queryCoalesceWindow.d.ts +4 -0
  57. package/dist/queryCoalesceWindow.d.ts.map +1 -0
  58. package/dist/queryCoalesceWindow.js +11 -0
  59. package/dist/queryCoalesceWindow.js.map +1 -0
  60. package/dist/readPolicy.d.ts +10 -0
  61. package/dist/readPolicy.d.ts.map +1 -0
  62. package/dist/readPolicy.js +24 -0
  63. package/dist/readPolicy.js.map +1 -0
  64. package/dist/rejection.d.ts +41 -0
  65. package/dist/rejection.d.ts.map +1 -0
  66. package/dist/rejection.js +16 -0
  67. package/dist/rejection.js.map +1 -0
  68. package/dist/resume.d.ts +17 -0
  69. package/dist/resume.d.ts.map +1 -0
  70. package/dist/resume.js +28 -0
  71. package/dist/resume.js.map +1 -0
  72. package/dist/serverMetadata.d.ts +42 -0
  73. package/dist/serverMetadata.d.ts.map +1 -0
  74. package/dist/serverMetadata.js +27 -0
  75. package/dist/serverMetadata.js.map +1 -0
  76. package/package.json +73 -4
  77. package/src/automaticLive.ts +93 -0
  78. package/src/bind.ts +103 -0
  79. package/src/contract.ts +140 -0
  80. package/src/internal/bindingOwner.ts +24 -0
  81. package/src/internal/clientOperations.ts +74 -0
  82. package/src/internal/liveInvalidation.ts +21 -0
  83. package/src/internal/makeHintPolicy.ts +40 -0
  84. package/src/internal/queryReactivity.ts +109 -0
  85. package/src/keys.ts +62 -0
  86. package/src/live.ts +122 -0
  87. package/src/makeLiveServer.ts +76 -0
  88. package/src/operation.ts +145 -0
  89. package/src/publicConfig.ts +9 -0
  90. package/src/queryCoalesceWindow.ts +13 -0
  91. package/src/readPolicy.ts +33 -0
  92. package/src/rejection.ts +91 -0
  93. package/src/resume.ts +44 -0
  94. package/src/serverMetadata.ts +50 -0
@@ -0,0 +1,109 @@
1
+ import { Duration, Effect } from "effect";
2
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import * as Atom from "effect/reactivity/Atom";
4
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
5
+ import * as Reactivity from "effect/reactivity/Reactivity";
6
+ import { queryCoalesceWindow } from "#queryCoalesceWindow.ts";
7
+ import { liveBatches, liveKeys } from "./liveInvalidation.ts";
8
+
9
+ export function queryReactivity<R, E>(runtime: Atom.AtomRuntime<R, E>) {
10
+ const service = runtime.atom(Effect.service(Reactivity.Reactivity));
11
+ type Query = Atom.Atom<AsyncResult.AsyncResult<unknown, unknown>>;
12
+ const cached = new WeakMap<Query, Query>();
13
+ return (
14
+ atom: Query,
15
+ keys: readonly string[],
16
+ track: (registry: AtomRegistry.AtomRegistry) => () => void,
17
+ owner?: AtomRegistry.AtomRegistry,
18
+ ): Query => {
19
+ const existing = cached.get(atom);
20
+ if (existing !== undefined) {
21
+ return existing;
22
+ }
23
+ const source = Atom.setIdleTTL(
24
+ Atom.readable((get) => atom.read(get), atom.refresh),
25
+ 0,
26
+ );
27
+ let wrapped = Atom.transform(
28
+ source,
29
+ (get) => {
30
+ if (owner !== undefined && owner !== get.registry) {
31
+ throw new Error("A session-scoped query belongs to its binding registry");
32
+ }
33
+ const context = get(service);
34
+ if (!AsyncResult.isSuccess(context)) {
35
+ return get(source);
36
+ }
37
+ const reactivity = context.value;
38
+ get.addFinalizer(track(get.registry));
39
+ let pending: (() => boolean) | undefined;
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ function clear() {
42
+ clearTimeout(timer);
43
+ timer = undefined;
44
+ pending = undefined;
45
+ }
46
+ function refresh(active: () => boolean) {
47
+ if (!active()) {
48
+ return;
49
+ }
50
+ if (get.once(source).waiting) {
51
+ pending = active;
52
+ } else {
53
+ pending = undefined;
54
+ get.refresh(source);
55
+ }
56
+ }
57
+ function changed() {
58
+ const batch = liveBatches.get(reactivity);
59
+ if (batch === undefined || batch.seen.has(changed)) {
60
+ return;
61
+ }
62
+ batch.seen.add(changed);
63
+ const delay = Duration.toMillis(Duration.fromInputUnsafe(get.once(queryCoalesceWindow(wrapped)) ?? batch.window ?? 0));
64
+ if (delay === 0) {
65
+ clear();
66
+ get.refresh(source);
67
+ } else {
68
+ timer ??= setTimeout(() => {
69
+ timer = undefined;
70
+ refresh(batch.active);
71
+ }, delay);
72
+ }
73
+ }
74
+ get.addFinalizer(clear);
75
+ get.addFinalizer(reactivity.registerUnsafe(liveKeys(keys), changed));
76
+ get.addFinalizer(
77
+ reactivity.registerUnsafe(keys, () => {
78
+ if (!liveBatches.has(reactivity)) {
79
+ clear();
80
+ get.refresh(source);
81
+ }
82
+ }),
83
+ );
84
+ get.subscribe(source, (value) => {
85
+ get.setSelf(value);
86
+ if (pending !== undefined && !value.waiting) {
87
+ const active = pending;
88
+ pending = undefined;
89
+ if (active()) {
90
+ get.refresh(source);
91
+ }
92
+ }
93
+ });
94
+ return get.once(source);
95
+ },
96
+ { initialValueTarget: source },
97
+ );
98
+ if (atom.keepAlive) {
99
+ wrapped = Atom.keepAlive(wrapped);
100
+ } else if (atom.idleTTL !== undefined) {
101
+ wrapped = Atom.setIdleTTL(wrapped, atom.idleTTL);
102
+ }
103
+ if (Atom.isSerializable(atom)) {
104
+ Object.assign(wrapped, { [Atom.SerializableTypeId]: atom[Atom.SerializableTypeId] });
105
+ }
106
+ cached.set(atom, wrapped);
107
+ return wrapped;
108
+ };
109
+ }
package/src/keys.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { Schema } from "effect";
2
+
3
+ export type Identity = string | number;
4
+
5
+ export interface ListKey<TName extends string = string> {
6
+ readonly _tag: "List";
7
+ readonly collection: TName;
8
+ }
9
+
10
+ export interface ItemKey<TName extends string = string> {
11
+ readonly _tag: "Item";
12
+ readonly collection: TName;
13
+ readonly id: Identity;
14
+ }
15
+
16
+ export type Key = ListKey | ItemKey;
17
+
18
+ export const Key = Schema.Union([
19
+ Schema.Struct({ _tag: Schema.Literal("List"), collection: Schema.String }),
20
+ Schema.Struct({ _tag: Schema.Literal("Item"), collection: Schema.String, id: Schema.Union([Schema.String, Schema.Number]) }),
21
+ ]);
22
+
23
+ export interface Collection<TName extends string, TId extends Identity> {
24
+ readonly item: (id: TId) => ItemKey<TName>;
25
+ readonly list: ListKey<TName>;
26
+ readonly name: TName;
27
+ }
28
+
29
+ export function collection<const TName extends string, TId extends Schema.Top & { readonly Type: Identity }>(
30
+ name: TName,
31
+ _id: TId,
32
+ ): Collection<TName, TId["Type"]> {
33
+ return {
34
+ item: (id) => ({ _tag: "Item", collection: name, id }),
35
+ list: { _tag: "List", collection: name },
36
+ name,
37
+ };
38
+ }
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
+
48
+ function own(key: Key): string {
49
+ return key._tag === "List" ? key.collection : `${key.collection}:${key.id}`;
50
+ }
51
+
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
+ );
56
+ }
57
+
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
+ );
62
+ }
package/src/live.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { type Cause, Duration, Effect, Option, Schema, Stream } from "effect";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+ import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
+ import * as Reactivity from "effect/reactivity/Reactivity";
5
+ import { invalidateLive } from "#internal/liveInvalidation.ts";
6
+ import { invalidationKeys, Key, type KeyContext, readKeys } from "#keys.ts";
7
+
8
+ export const LiveHint = Schema.Union([
9
+ Schema.Struct({ _tag: Schema.Literal("Changed"), keys: Schema.Array(Key) }),
10
+ Schema.Struct({ _tag: Schema.Literal("Resync") }),
11
+ ]);
12
+ export type LiveHint = typeof LiveHint.Type;
13
+ export interface LiveStatus<E> {
14
+ readonly connection: "connecting" | "live" | "reconnecting";
15
+ readonly failure: Option.Option<Cause.Cause<E>>;
16
+ readonly needsResync: boolean;
17
+ readonly pending: number;
18
+ }
19
+ export interface LiveOptions<E, R> {
20
+ readonly coalesceWindow?: Duration.Input;
21
+ readonly keyContext?: KeyContext | undefined;
22
+ readonly resume?: Atom.Atom<unknown>;
23
+ readonly resyncKeys: readonly Key[] | ((registry: AtomRegistry.AtomRegistry) => readonly Key[]);
24
+ readonly retryDelay?: Duration.Input;
25
+ readonly stream: Stream.Stream<LiveHint, E, R>;
26
+ }
27
+
28
+ const MAX_PENDING = 256;
29
+
30
+ export function live<R, E, TRuntimeError>(runtime: Atom.AtomRuntime<R, TRuntimeError>, options: LiveOptions<E, R>) {
31
+ const coalesceWindow = Atom.keepAlive(Atom.make<Duration.Input>(options.coalesceWindow ?? "20 millis"));
32
+ const paused = Atom.make(false);
33
+ const status = Atom.make<LiveStatus<E>>({ connection: "connecting", failure: Option.none(), needsResync: true, pending: 0 });
34
+ const connection = runtime
35
+ .atom((get) =>
36
+ Effect.gen(function* () {
37
+ get.mount(status);
38
+ const reactivity = yield* Reactivity.Reactivity;
39
+ function update(value: Partial<LiveStatus<E>>) {
40
+ get.set(status, { ...get.once(status), ...value });
41
+ }
42
+ const pending = new Set<string>();
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ function clear() {
45
+ clearTimeout(timer);
46
+ timer = undefined;
47
+ pending.clear();
48
+ }
49
+ let active = true;
50
+ get.addFinalizer(() => {
51
+ active = false;
52
+ clear();
53
+ });
54
+ function changed(changedKeys: readonly Key[]) {
55
+ const window = get.once(coalesceWindow);
56
+ const keys = invalidationKeys(changedKeys, options.keyContext);
57
+ invalidateLive(reactivity, keys, () => active && !get.once(paused), window);
58
+ const delay = Duration.toMillis(Duration.fromInputUnsafe(window));
59
+ if (delay === 0) {
60
+ invalidateLive(reactivity, keys, () => active && !get.once(paused));
61
+ return;
62
+ }
63
+ for (const key of invalidationKeys(changedKeys, options.keyContext)) {
64
+ pending.add(key);
65
+ }
66
+ timer ??= setTimeout(() => {
67
+ const queued = [...pending];
68
+ clear();
69
+ if (!get.once(paused)) {
70
+ invalidateLive(reactivity, queued, () => active && !get.once(paused));
71
+ }
72
+ }, delay);
73
+ }
74
+ function resync() {
75
+ clear();
76
+ if (get.once(paused)) {
77
+ update({ needsResync: true });
78
+ return;
79
+ }
80
+ const keys = typeof options.resyncKeys === "function" ? options.resyncKeys(get.registry) : options.resyncKeys;
81
+ reactivity.invalidateUnsafe(keys.map((key) => readKeys([key], options.keyContext)[0] ?? ""));
82
+ update({ needsResync: false, pending: 0 });
83
+ }
84
+ // A subscription alone does not build the flag, so its first read would otherwise announce a change.
85
+ get.once(paused);
86
+ get.subscribe(paused, (value) => {
87
+ if (!value) {
88
+ resync();
89
+ }
90
+ });
91
+ if (options.resume !== undefined) {
92
+ // A subscription alone does not initialize a derived signal's dependencies.
93
+ get.once(options.resume);
94
+ get.subscribe(options.resume, resync);
95
+ }
96
+ const attempt = Effect.suspend(() => {
97
+ let isFirst = true;
98
+ function observe(hint: LiveHint) {
99
+ update({ connection: "live", failure: Option.none() });
100
+ if (get.once(paused)) {
101
+ update({ needsResync: true, pending: Math.min(MAX_PENDING, get.once(status).pending + 1) });
102
+ } else if (isFirst || hint._tag === "Resync") {
103
+ resync();
104
+ } else {
105
+ changed(hint.keys);
106
+ }
107
+ isFirst = false;
108
+ }
109
+ return options.stream.pipe(
110
+ Stream.runForEach((hint) => Effect.sync(() => observe(hint))),
111
+ Effect.scoped,
112
+ Effect.catchCause((failure) => Effect.sync(() => update({ failure: Option.some(failure) }))),
113
+ Effect.andThen(Effect.sync(() => update({ connection: "reconnecting", needsResync: true }))),
114
+ Effect.andThen(Effect.sleep(options.retryDelay ?? "1 second")),
115
+ );
116
+ });
117
+ yield* Effect.forever(attempt);
118
+ }),
119
+ )
120
+ .pipe(Atom.setIdleTTL(0));
121
+ return { coalesceWindow, connection, paused, status };
122
+ }
@@ -0,0 +1,76 @@
1
+ import { Effect, Queue, Stream } from "effect";
2
+ import * as Reactivity from "effect/reactivity/Reactivity";
3
+ import { makeHintPolicy } from "#internal/makeHintPolicy.ts";
4
+ import { type Key, type KeyContext, readKeys } from "#keys.ts";
5
+ import type { LiveHint } from "#live.ts";
6
+
7
+ export const makeLiveServer = Effect.gen(function* () {
8
+ const reactivity = yield* Reactivity.Reactivity;
9
+ let openStreams = 0;
10
+ const resyncKey = `@shivaedev/contract/resync/${crypto.randomUUID()}`;
11
+ function stream(keys: readonly Key[], context?: KeyContext) {
12
+ return Stream.unwrap(
13
+ Effect.gen(function* () {
14
+ const queue = yield* Queue.unbounded<LiveHint>();
15
+ const allowedKeys = yield* makeHintPolicy(keys.length, (retryKeys) =>
16
+ Queue.offer(queue, { _tag: "Changed", keys: retryKeys }).pipe(Effect.asVoid),
17
+ );
18
+ const pending = new Map<string, Key>();
19
+ let scheduled = false;
20
+ let resync = false;
21
+ let active = true;
22
+ function flush() {
23
+ if (scheduled) {
24
+ return;
25
+ }
26
+ scheduled = true;
27
+ queueMicrotask(() => {
28
+ scheduled = false;
29
+ if (active) {
30
+ Queue.offerUnsafe(queue, resync ? { _tag: "Resync" } : { _tag: "Changed", keys: [...pending.values()] });
31
+ }
32
+ pending.clear();
33
+ resync = false;
34
+ });
35
+ }
36
+ const cancel = keys.map((key) => {
37
+ const own = readKeys([key], context);
38
+ return reactivity.registerUnsafe(own, () => {
39
+ pending.set(own[0] ?? "", key);
40
+ flush();
41
+ });
42
+ });
43
+ cancel.push(
44
+ reactivity.registerUnsafe([resyncKey], () => {
45
+ resync = true;
46
+ flush();
47
+ }),
48
+ );
49
+ openStreams += 1;
50
+ yield* Effect.addFinalizer(() =>
51
+ Effect.sync(() => {
52
+ active = false;
53
+ openStreams -= 1;
54
+ for (const stop of cancel) {
55
+ stop();
56
+ }
57
+ }),
58
+ );
59
+ yield* Queue.offer(queue, { _tag: "Resync" });
60
+ return Stream.fromQueue(queue).pipe(
61
+ Stream.mapEffect((hint) =>
62
+ hint._tag === "Resync"
63
+ ? Effect.succeed(hint)
64
+ : Effect.map(allowedKeys(hint.keys), (allowed): LiveHint => ({ _tag: "Changed", keys: allowed })),
65
+ ),
66
+ Stream.filter((hint) => hint._tag === "Resync" || hint.keys.length > 0),
67
+ );
68
+ }),
69
+ );
70
+ }
71
+ const resync = Effect.sync(() => {
72
+ reactivity.invalidateUnsafe([resyncKey]);
73
+ return openStreams;
74
+ });
75
+ return { resync, stream };
76
+ });
@@ -0,0 +1,145 @@
1
+ import { Schema } from "effect";
2
+ import type { Bivariant } from "@shivaedev/types/Bivariant.ts";
3
+ import type { Key } from "./keys.ts";
4
+ import { type MatchingTags, type Reject, type RejectionSpecs, type Rejections, type RejectionUnion, rejectionSet } from "./rejection.ts";
5
+
6
+ export type PayloadSchema<TPayload extends Schema.Top | Schema.Struct.Fields> = TPayload extends Schema.Struct.Fields
7
+ ? Schema.Struct<TPayload>
8
+ : TPayload;
9
+
10
+ type NoRejections = Record<never, never>;
11
+
12
+ export interface OperationShape {
13
+ readonly error: Schema.Top;
14
+ readonly kind: "query" | "command" | "stream";
15
+ readonly name: string;
16
+ readonly payload: Schema.Top;
17
+ readonly Rejection: object;
18
+ readonly reject: object;
19
+ readonly rejections: RejectionSpecs;
20
+ readonly success: Schema.Top;
21
+ }
22
+
23
+ export interface QueryShape extends OperationShape {
24
+ readonly kind: "query";
25
+ reads: Bivariant<(payload: unknown) => readonly Key[]>;
26
+ }
27
+
28
+ export interface StreamShape extends OperationShape {
29
+ readonly kind: "stream";
30
+ reads: Bivariant<(payload: unknown) => readonly Key[]>;
31
+ }
32
+
33
+ export interface CommandShape extends OperationShape {
34
+ invalidates: Bivariant<(payload: unknown, result: unknown) => readonly Key[]>;
35
+ readonly kind: "command";
36
+ }
37
+
38
+ interface Operation<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
39
+ extends OperationShape {
40
+ readonly error: RejectionUnion<TSpecs>;
41
+ readonly name: TName;
42
+ readonly payload: TPayload;
43
+ readonly Rejection: Rejections<TSpecs>;
44
+ readonly reject: Reject<TSpecs>;
45
+ readonly rejections: TSpecs;
46
+ readonly success: TSuccess;
47
+ }
48
+
49
+ export interface Query<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
50
+ extends Operation<TName, TPayload, TSuccess, TSpecs> {
51
+ readonly kind: "query";
52
+ reads: (payload: TPayload["Type"]) => readonly Key[];
53
+ }
54
+
55
+ export interface Streaming<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
56
+ extends Operation<TName, TPayload, TSuccess, TSpecs> {
57
+ readonly kind: "stream";
58
+ reads: (payload: TPayload["Type"]) => readonly Key[];
59
+ }
60
+
61
+ export interface Command<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
62
+ extends Operation<TName, TPayload, TSuccess, TSpecs> {
63
+ invalidates: (payload: TPayload["Type"], result: TSuccess["Type"]) => readonly Key[];
64
+ readonly kind: "command";
65
+ }
66
+
67
+ interface Declaration<TPayload extends Schema.Top | Schema.Struct.Fields, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs> {
68
+ readonly payload?: TPayload;
69
+ readonly rejections?: TSpecs & MatchingTags<TSpecs>;
70
+ readonly success?: TSuccess;
71
+ }
72
+
73
+ interface Loose {
74
+ readonly payload?: Schema.Top | Schema.Struct.Fields;
75
+ readonly rejections?: RejectionSpecs;
76
+ readonly success?: Schema.Top;
77
+ }
78
+
79
+ function payloadSchema(payload: Loose["payload"]): Schema.Top {
80
+ if (payload === undefined) {
81
+ return Schema.Void;
82
+ }
83
+ return Schema.isSchema(payload) ? payload : Schema.Struct(payload);
84
+ }
85
+
86
+ function operation<TKind extends OperationShape["kind"]>(kind: TKind, name: string, declaration: Loose) {
87
+ const { payload, success, rejections = {} } = declaration;
88
+ return {
89
+ kind,
90
+ name,
91
+ payload: payloadSchema(payload),
92
+ rejections,
93
+ success: success ?? Schema.Void,
94
+ ...rejectionSet(rejections),
95
+ };
96
+ }
97
+
98
+ export function query<
99
+ const TName extends string,
100
+ TPayload extends Schema.Top | Schema.Struct.Fields = Schema.Void,
101
+ TSuccess extends Schema.Top = Schema.Void,
102
+ const TSpecs extends RejectionSpecs = NoRejections,
103
+ >(
104
+ name: TName,
105
+ declaration: Declaration<TPayload, TSuccess, TSpecs> & {
106
+ readonly reads: (payload: PayloadSchema<TPayload>["Type"]) => readonly Key[];
107
+ },
108
+ ): NoInfer<Query<TName, PayloadSchema<TPayload>, TSuccess, TSpecs>>;
109
+ export function query(name: string, declaration: Loose & { readonly reads: (payload: unknown) => readonly Key[] }): QueryShape {
110
+ return { ...operation("query", name, declaration), reads: declaration.reads };
111
+ }
112
+
113
+ export function command<
114
+ const TName extends string,
115
+ TPayload extends Schema.Top | Schema.Struct.Fields = Schema.Void,
116
+ TSuccess extends Schema.Top = Schema.Void,
117
+ const TSpecs extends RejectionSpecs = NoRejections,
118
+ >(
119
+ name: TName,
120
+ declaration: Declaration<TPayload, TSuccess, TSpecs> & {
121
+ readonly invalidates: (payload: PayloadSchema<TPayload>["Type"], result: TSuccess["Type"]) => readonly Key[];
122
+ },
123
+ ): NoInfer<Command<TName, PayloadSchema<TPayload>, TSuccess, TSpecs>>;
124
+ export function command(
125
+ name: string,
126
+ declaration: Loose & { readonly invalidates: (payload: unknown, result: unknown) => readonly Key[] },
127
+ ): CommandShape {
128
+ return { ...operation("command", name, declaration), invalidates: declaration.invalidates };
129
+ }
130
+
131
+ export function stream<
132
+ const TName extends string,
133
+ TPayload extends Schema.Top | Schema.Struct.Fields = Schema.Void,
134
+ TSuccess extends Schema.Top = Schema.Void,
135
+ const TSpecs extends RejectionSpecs = NoRejections,
136
+ >(
137
+ name: TName,
138
+ declaration: Declaration<TPayload, TSuccess, TSpecs> & {
139
+ readonly success: TSuccess;
140
+ readonly reads: (payload: PayloadSchema<TPayload>["Type"]) => readonly Key[];
141
+ },
142
+ ): NoInfer<Streaming<TName, PayloadSchema<TPayload>, TSuccess, TSpecs>>;
143
+ export function stream(name: string, declaration: Loose & { readonly reads: (payload: unknown) => readonly Key[] }): StreamShape {
144
+ return { ...operation("stream", name, declaration), reads: declaration.reads };
145
+ }
@@ -0,0 +1,9 @@
1
+ import type { Schema } from "effect";
2
+ import { contract } from "#contract.ts";
3
+ import { query } from "#operation.ts";
4
+
5
+ export function publicConfig<T, E>(schema: Schema.Codec<T, E>) {
6
+ return contract("platform.publicConfig", {
7
+ queries: [query("get", { reads: () => [], success: schema })],
8
+ });
9
+ }
@@ -0,0 +1,13 @@
1
+ import type { Duration } from "effect";
2
+ import * as Atom from "effect/reactivity/Atom";
3
+
4
+ const windows = new WeakMap<Atom.Atom<unknown>, Atom.Writable<Duration.Input | undefined>>();
5
+
6
+ export function queryCoalesceWindow<A>(query: Atom.Atom<A>): Atom.Writable<Duration.Input | undefined> {
7
+ let window = windows.get(query);
8
+ if (window === undefined) {
9
+ window = Atom.keepAlive(Atom.make<Duration.Input | undefined>(undefined));
10
+ windows.set(query, window);
11
+ }
12
+ return window;
13
+ }
@@ -0,0 +1,33 @@
1
+ import { Context, type Duration, Effect, Option } from "effect";
2
+ import type { Rpc } from "effect/rpc";
3
+ import type { Key } from "./keys.ts";
4
+ import { QueryReads } from "./serverMetadata.ts";
5
+
6
+ export const ReadPolicy = Context.Reference<(key: Key) => Effect.Effect<boolean, unknown>>("@shivaedev/contract/ReadPolicy", {
7
+ defaultValue: () => () => Effect.succeed(true),
8
+ });
9
+
10
+ export const ReadPolicyMemoWindow = Context.Reference<Duration.Input>("@shivaedev/contract/ReadPolicyMemoWindow", {
11
+ defaultValue: () => "1 second",
12
+ });
13
+
14
+ export const authorizeReads = Effect.fnUntraced(function* <A, E, R, TPolicyServices, TDenied, TPolicyError>(
15
+ effect: Effect.Effect<A, E, R>,
16
+ request: { readonly rpc: Rpc.AnyWithProps; readonly payload: unknown },
17
+ policy: (key: Key) => Effect.Effect<boolean, TPolicyError, TPolicyServices>,
18
+ denied: Effect.Effect<never, TDenied>,
19
+ ) {
20
+ const context = yield* Effect.context<TPolicyServices>();
21
+ function canRead(key: Key) {
22
+ return Effect.provideContext(policy(key), context);
23
+ }
24
+ const reads = Context.getOption(request.rpc.annotations, QueryReads);
25
+ if (Option.isSome(reads)) {
26
+ for (const key of reads.value(request.payload)) {
27
+ if (!(yield* canRead(key))) {
28
+ return yield* denied;
29
+ }
30
+ }
31
+ }
32
+ return yield* Effect.provideService(effect, ReadPolicy, canRead);
33
+ });
@@ -0,0 +1,91 @@
1
+ import { type Cause, Effect, Schema } from "effect";
2
+
3
+ export type RejectionValue<TTag extends string, TFields extends Schema.Struct.Fields> = Schema.TaggedStruct<TTag, TFields>["Type"] &
4
+ Cause.YieldableError;
5
+
6
+ export type RejectionClass<TTag extends string, TFields extends Schema.Struct.Fields> = Schema.Class<
7
+ RejectionValue<TTag, TFields>,
8
+ Schema.TaggedStruct<TTag, TFields>,
9
+ Cause.YieldableError
10
+ >;
11
+
12
+ export type TaggedRejection = Schema.Top & (new (...args: never) => { readonly _tag: string });
13
+
14
+ export interface RejectionSpecs {
15
+ readonly [tag: string]: Schema.Struct.Fields | TaggedRejection;
16
+ }
17
+
18
+ export type Rejections<TSpecs extends RejectionSpecs> = {
19
+ readonly [TTag in keyof TSpecs & string]: TSpecs[TTag] extends TaggedRejection
20
+ ? TSpecs[TTag]
21
+ : TSpecs[TTag] extends Schema.Struct.Fields
22
+ ? RejectionClass<TTag, TSpecs[TTag]>
23
+ : never;
24
+ };
25
+
26
+ export type RejectedBy<TSpecs extends RejectionSpecs> = {
27
+ [TTag in keyof TSpecs & string]: Rejections<TSpecs>[TTag]["Type"];
28
+ }[keyof TSpecs & string];
29
+
30
+ export type Reject<TSpecs extends RejectionSpecs> = {
31
+ readonly [TTag in keyof TSpecs & string]: Rejections<TSpecs>[TTag] extends new (
32
+ ...args: infer TArguments
33
+ ) => infer TValue
34
+ ? (...args: TArguments) => Effect.Effect<never, TValue>
35
+ : never;
36
+ };
37
+
38
+ export type RejectionUnion<TSpecs extends RejectionSpecs> = [keyof TSpecs & string] extends [never]
39
+ ? Schema.Never
40
+ : Schema.Union<readonly Rejections<TSpecs>[keyof TSpecs & string][]>;
41
+
42
+ export type MatchingTags<TSpecs extends RejectionSpecs> = {
43
+ readonly [TTag in keyof TSpecs]: TSpecs[TTag] extends TaggedRejection
44
+ ? TSpecs[TTag]["Type"] extends { readonly _tag: TTag }
45
+ ? TSpecs[TTag]
46
+ : `The rejection class under "${TTag & string}" must have that _tag`
47
+ : TSpecs[TTag];
48
+ };
49
+
50
+ export interface RejectionSet<TSpecs extends RejectionSpecs> {
51
+ readonly error: RejectionUnion<TSpecs>;
52
+ readonly Rejection: Rejections<TSpecs>;
53
+ readonly reject: Reject<TSpecs>;
54
+ }
55
+
56
+ interface LooseRejectionSet {
57
+ readonly error: Schema.Top;
58
+ readonly Rejection: { readonly [tag: string]: TaggedRejection };
59
+ readonly reject: { readonly [tag: string]: (...args: readonly unknown[]) => Effect.Effect<never, unknown> };
60
+ }
61
+
62
+ export function rejectionSet<const TSpecs extends RejectionSpecs>(specs: TSpecs): RejectionSet<TSpecs>;
63
+ export function rejectionSet(specs: RejectionSpecs): LooseRejectionSet {
64
+ const classes = Object.entries(specs).map(([tag, spec]): readonly [string, TaggedRejection] => [
65
+ tag,
66
+ Schema.isSchema(spec) ? spec : Schema.TaggedError<Cause.YieldableError>()(tag, spec),
67
+ ]);
68
+ return {
69
+ error: classes.length === 0 ? Schema.Never : Schema.Union(classes.map(([, schema]) => schema)),
70
+ Rejection: Object.fromEntries(classes),
71
+ reject: Object.fromEntries(
72
+ classes.map(([tag, Rejection]) => [tag, (...args: readonly unknown[]) => Effect.fail(Reflect.construct(Rejection, args))]),
73
+ ),
74
+ };
75
+ }
76
+
77
+ export type FieldRejection<TField extends string> = Readonly<{
78
+ field: Schema.Literals<readonly TField[]>;
79
+ message: Schema.String;
80
+ }>;
81
+
82
+ export function fieldRejection<const TFields extends Schema.Struct.Fields>(struct: {
83
+ readonly fields: TFields;
84
+ }): FieldRejection<keyof TFields & string>;
85
+ export function fieldRejection<const TFields extends Schema.Struct.Fields, const TField extends keyof TFields & string>(
86
+ struct: { readonly fields: TFields },
87
+ fields: readonly TField[],
88
+ ): FieldRejection<TField>;
89
+ export function fieldRejection(struct: { readonly fields: Schema.Struct.Fields }, fields?: readonly string[]): FieldRejection<string> {
90
+ return { field: Schema.Literals(fields ?? Object.keys(struct.fields)), message: Schema.String };
91
+ }