@shivaedev/contract 0.18.0 → 0.19.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 (100) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +57 -36
  3. package/dist/automaticLive.d.ts +2 -2
  4. package/dist/automaticLive.d.ts.map +1 -1
  5. package/dist/automaticLive.js +4 -3
  6. package/dist/automaticLive.js.map +1 -1
  7. package/dist/bind.d.ts +11 -2
  8. package/dist/bind.d.ts.map +1 -1
  9. package/dist/bind.js +9 -0
  10. package/dist/bind.js.map +1 -1
  11. package/dist/clientOptions.d.ts +23 -0
  12. package/dist/clientOptions.d.ts.map +1 -0
  13. package/dist/clientOptions.js +2 -0
  14. package/dist/clientOptions.js.map +1 -0
  15. package/dist/contract.d.ts +4 -2
  16. package/dist/contract.d.ts.map +1 -1
  17. package/dist/contract.js +6 -5
  18. package/dist/contract.js.map +1 -1
  19. package/dist/internal/bindingOwner.d.ts +1 -1
  20. package/dist/internal/bindingOwner.d.ts.map +1 -1
  21. package/dist/internal/clientOperations.d.ts.map +1 -1
  22. package/dist/internal/clientOperations.js +26 -7
  23. package/dist/internal/clientOperations.js.map +1 -1
  24. package/dist/internal/httpClient.d.ts +21 -0
  25. package/dist/internal/httpClient.d.ts.map +1 -0
  26. package/dist/internal/httpClient.js +13 -0
  27. package/dist/internal/httpClient.js.map +1 -0
  28. package/dist/internal/operationDeclaration.d.ts +19 -0
  29. package/dist/internal/operationDeclaration.d.ts.map +1 -0
  30. package/dist/internal/operationDeclaration.js +20 -0
  31. package/dist/internal/operationDeclaration.js.map +1 -0
  32. package/dist/internal/queryHints.d.ts +8 -0
  33. package/dist/internal/queryHints.d.ts.map +1 -0
  34. package/dist/internal/queryHints.js +34 -0
  35. package/dist/internal/queryHints.js.map +1 -0
  36. package/dist/live.d.ts +7 -1
  37. package/dist/live.d.ts.map +1 -1
  38. package/dist/live.js +2 -2
  39. package/dist/live.js.map +1 -1
  40. package/dist/makeLiveServer.d.ts +27 -1
  41. package/dist/makeLiveServer.d.ts.map +1 -1
  42. package/dist/makeLiveServer.js +15 -8
  43. package/dist/makeLiveServer.js.map +1 -1
  44. package/dist/operation.d.ts +13 -9
  45. package/dist/operation.d.ts.map +1 -1
  46. package/dist/operation.js +5 -19
  47. package/dist/operation.js.map +1 -1
  48. package/dist/publicConfig.d.ts +9 -3
  49. package/dist/publicConfig.d.ts.map +1 -1
  50. package/dist/publicConfig.js +2 -1
  51. package/dist/publicConfig.js.map +1 -1
  52. package/dist/querySubscription.d.ts +15 -0
  53. package/dist/querySubscription.d.ts.map +1 -0
  54. package/dist/querySubscription.js +10 -0
  55. package/dist/querySubscription.js.map +1 -0
  56. package/dist/readPolicy.d.ts +11 -6
  57. package/dist/readPolicy.d.ts.map +1 -1
  58. package/dist/readPolicy.js +16 -19
  59. package/dist/readPolicy.js.map +1 -1
  60. package/dist/readPolicyErrors.d.ts +13 -0
  61. package/dist/readPolicyErrors.d.ts.map +1 -0
  62. package/dist/readPolicyErrors.js +6 -0
  63. package/dist/readPolicyErrors.js.map +1 -0
  64. package/dist/serverMetadata.d.ts +29 -11
  65. package/dist/serverMetadata.d.ts.map +1 -1
  66. package/dist/serverMetadata.js +19 -12
  67. package/dist/serverMetadata.js.map +1 -1
  68. package/dist/testing.d.ts +13 -1
  69. package/dist/testing.d.ts.map +1 -1
  70. package/dist/testing.js +1 -4
  71. package/dist/testing.js.map +1 -1
  72. package/package.json +6 -5
  73. package/src/automaticLive.ts +8 -3
  74. package/src/bind.ts +45 -3
  75. package/src/clientOptions.ts +35 -0
  76. package/src/contract.ts +11 -6
  77. package/src/internal/bindingOwner.ts +1 -1
  78. package/src/internal/clientOperations.ts +30 -11
  79. package/src/internal/httpClient.ts +21 -0
  80. package/src/internal/operationDeclaration.ts +28 -0
  81. package/src/internal/queryHints.ts +38 -0
  82. package/src/live.ts +2 -2
  83. package/src/makeLiveServer.ts +18 -12
  84. package/src/operation.ts +27 -37
  85. package/src/publicConfig.ts +2 -2
  86. package/src/querySubscription.ts +21 -0
  87. package/src/readPolicy.ts +23 -23
  88. package/src/readPolicyErrors.ts +4 -0
  89. package/src/serverMetadata.ts +35 -19
  90. package/src/testing.ts +2 -12
  91. package/dist/internal/makeHintPolicy.d.ts +0 -4
  92. package/dist/internal/makeHintPolicy.d.ts.map +0 -1
  93. package/dist/internal/makeHintPolicy.js +0 -25
  94. package/dist/internal/makeHintPolicy.js.map +0 -1
  95. package/dist/keys.d.ts +0 -34
  96. package/dist/keys.d.ts.map +0 -1
  97. package/dist/keys.js +0 -25
  98. package/dist/keys.js.map +0 -1
  99. package/src/internal/makeHintPolicy.ts +0 -40
  100. package/src/keys.ts +0 -62
@@ -1,17 +1,21 @@
1
- import { Effect, type Schema, Stream } from "effect";
1
+ import { Effect, Option, type Schema, Stream } from "effect";
2
2
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
3
  import type * as AtomRpc from "effect/reactivity/AtomRpc";
4
4
  import * as Reactivity from "effect/reactivity/Reactivity";
5
5
  import type { Rpc, RpcSchema } from "effect/rpc";
6
+ import { invalidationKeys, type Key, readKeys } from "@shivaedev/changes/keys.ts";
6
7
  import type { automaticLive } from "#automaticLive.ts";
7
8
  import type { QueryOptions } from "#bind.ts";
8
- import { invalidationKeys, readKeys } from "#keys.ts";
9
9
  import type { CommandShape, QueryShape } from "#operation.ts";
10
+ import { MissingReadPolicy } from "#readPolicyErrors.ts";
10
11
  import type { BindingOwner } from "./bindingOwner.ts";
11
12
  import { queryReactivity } from "./queryReactivity.ts";
12
13
 
13
14
  type ErasedClient = AtomRpc.AtomRpcClient<unknown, string, Rpc.Rpc<string, Schema.Top, Schema.Top, Schema.Top>>;
14
15
 
16
+ const queryIds = new WeakMap<object, number>();
17
+ let nextQueryId = 0;
18
+
15
19
  type ErasedStreamingClient = AtomRpc.AtomRpcClient<
16
20
  unknown,
17
21
  string,
@@ -32,15 +36,29 @@ export function boundQuery(
32
36
  const reactive = queryReactivity(service.runtime);
33
37
  return {
34
38
  query: (payload: unknown, options: QueryOptions = {}) => {
39
+ if (query.readPolicy === undefined) {
40
+ return service.runtime.atom(
41
+ Effect.fail(new MissingReadPolicy({ message: `Declare a readPolicy on query ${query.name} before running it live or subscribing` })),
42
+ );
43
+ }
35
44
  const atom = service.query(tag, payload, options);
36
- const keys = query.reads(payload);
45
+ let id = queryIds.get(atom);
46
+ if (id === undefined) {
47
+ id = nextQueryId;
48
+ nextQueryId += 1;
49
+ queryIds.set(atom, id);
50
+ }
51
+ const local = query.reads(payload, Option.none());
52
+ const keys = [...local, { _tag: "List" as const, collection: `@query/${tag}/${id}` }];
53
+ const dependencies =
54
+ local.length === 0 ? [...keys, { _tag: "List" as const, collection: `@query/${tag.slice(0, tag.lastIndexOf("."))}` }] : keys;
37
55
  const reference = { payload, query: query.name };
38
56
  if (owner === undefined) {
39
57
  track(undefined, reference, keys);
40
58
  }
41
59
  return reactive(
42
60
  atom,
43
- readKeys(keys, owner?.context),
61
+ readKeys(dependencies, owner?.context),
44
62
  (registry) => (owner === undefined ? () => undefined : track(registry, reference, keys)),
45
63
  owner?.registry,
46
64
  );
@@ -55,15 +73,16 @@ export function boundQuery(
55
73
  }
56
74
 
57
75
  export function boundCommand(service: ErasedClient, tag: string, command: CommandShape, owner?: BindingOwner) {
76
+ function invalidate(payload: unknown, result: unknown) {
77
+ const keys: readonly Key[] = [
78
+ ...command.invalidates(payload, result),
79
+ { _tag: "List", collection: `@query/${tag.slice(0, tag.lastIndexOf("."))}` },
80
+ ];
81
+ return Reactivity.invalidate(invalidationKeys(keys, owner?.context));
82
+ }
58
83
  return {
59
84
  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
- ),
85
+ run(service, service.use((client) => client(tag, payload)).pipe(Effect.tap((result) => invalidate(payload, result))), owner),
67
86
  };
68
87
  }
69
88
 
@@ -0,0 +1,21 @@
1
+ import { type Context, Effect, Layer } from "effect";
2
+ import { FetchHttpClient } from "effect/http";
3
+ import * as AtomRpc from "effect/reactivity/AtomRpc";
4
+ import { type Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/rpc";
5
+ import type { ClientOptions } from "#clientOptions.ts";
6
+
7
+ export interface HttpGroup {
8
+ readonly annotations: Context.Context<never>;
9
+ readonly requests: ReadonlyMap<string, Rpc.Any>;
10
+ }
11
+
12
+ export function httpClient(name: string, source: HttpGroup, options: ClientOptions) {
13
+ const group = RpcGroup.make(...source.requests.values()).annotateMerge(source.annotations);
14
+ const protocol = RpcClient.layerProtocolHttp(options).pipe(
15
+ Layer.provide([options.httpClient ?? FetchHttpClient.layer, options.serialization ?? RpcSerialization.layerNdjson]),
16
+ );
17
+ const Client = AtomRpc.Service<unknown>()(`${name}/Client`, { group, protocol });
18
+ const client = RpcClient.make(group).pipe(Effect.provide(protocol));
19
+ const layer = Layer.effect(Client, RpcClient.make(group, { flatten: true })).pipe(Layer.provide(protocol));
20
+ return Object.assign(layer, { Client, client, runtime: Client.runtime });
21
+ }
@@ -0,0 +1,28 @@
1
+ import { Schema } from "effect";
2
+ import type { OperationShape } from "#operation.ts";
3
+ import { type RejectionSpecs, rejectionSet } from "#rejection.ts";
4
+
5
+ export interface Loose {
6
+ readonly payload?: Schema.Top | Schema.Struct.Fields;
7
+ readonly rejections?: RejectionSpecs;
8
+ readonly success?: Schema.Top;
9
+ }
10
+
11
+ function payloadSchema(payload: Loose["payload"]): Schema.Top {
12
+ if (payload === undefined) {
13
+ return Schema.Void;
14
+ }
15
+ return Schema.isSchema(payload) ? payload : Schema.Struct(payload);
16
+ }
17
+
18
+ export function operation<TKind extends OperationShape["kind"]>(kind: TKind, name: string, declaration: Loose) {
19
+ const { payload, success, rejections = {} } = declaration;
20
+ return {
21
+ kind,
22
+ name,
23
+ payload: payloadSchema(payload),
24
+ rejections,
25
+ success: success ?? Schema.Void,
26
+ ...rejectionSet(rejections),
27
+ };
28
+ }
@@ -0,0 +1,38 @@
1
+ import { invalidationKeys, type Key, readKeys } from "@shivaedev/changes/keys.ts";
2
+ import type { LiveHint } from "#live.ts";
3
+
4
+ export function localQueryKeys(keys: readonly Key[]) {
5
+ return keys.filter((key) => key.collection.startsWith("@query/"));
6
+ }
7
+
8
+ export function queryHint(hint: LiveHint, tracked: readonly { readonly keys: readonly Key[] }[]): LiveHint {
9
+ if (hint._tag === "Resync") {
10
+ return hint;
11
+ }
12
+ const invalidated = invalidationKeys(hint.keys);
13
+ return {
14
+ _tag: "Changed",
15
+ keys: [
16
+ ...hint.keys,
17
+ ...tracked.flatMap(({ keys }, index) => {
18
+ const local = keys.filter((key) => !key.collection.startsWith("@query/"));
19
+ const affected =
20
+ hint.queryIndexes === undefined
21
+ ? local.length === 0 || hint.keys.some((key) => key._tag === "Bucket") || readKeys(local).some((key) => invalidated.includes(key))
22
+ : hint.queryIndexes.includes(index);
23
+ return affected ? localQueryKeys(keys) : [];
24
+ }),
25
+ ],
26
+ };
27
+ }
28
+
29
+ export function changedHint(keys: readonly Key[], scopes?: readonly (readonly Key[])[]): LiveHint {
30
+ const changed = new Set(keys.map((key) => JSON.stringify(key)));
31
+ return {
32
+ _tag: "Changed",
33
+ keys,
34
+ ...(scopes === undefined
35
+ ? {}
36
+ : { queryIndexes: scopes.flatMap((scope, index) => (scope.some((key) => changed.has(JSON.stringify(key))) ? [index] : [])) }),
37
+ };
38
+ }
package/src/live.ts CHANGED
@@ -2,11 +2,11 @@ import { type Cause, Duration, Effect, Option, Schema, Stream } from "effect";
2
2
  import * as Atom from "effect/reactivity/Atom";
3
3
  import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
4
4
  import * as Reactivity from "effect/reactivity/Reactivity";
5
+ import { invalidationKeys, Key, type KeyContext, readKeys } from "@shivaedev/changes/keys.ts";
5
6
  import { invalidateLive } from "#internal/liveInvalidation.ts";
6
- import { invalidationKeys, Key, type KeyContext, readKeys } from "#keys.ts";
7
7
 
8
8
  export const LiveHint = Schema.Union([
9
- Schema.Struct({ _tag: Schema.Literal("Changed"), keys: Schema.Array(Key) }),
9
+ Schema.Struct({ _tag: Schema.Literal("Changed"), keys: Schema.Array(Key), queryIndexes: Schema.optional(Schema.Array(Schema.Number)) }),
10
10
  Schema.Struct({ _tag: Schema.Literal("Resync") }),
11
11
  ]);
12
12
  export type LiveHint = typeof LiveHint.Type;
@@ -1,20 +1,18 @@
1
1
  import { Effect, Queue, Stream } from "effect";
2
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";
3
+ import { type Key, type KeyContext, readKeys } from "@shivaedev/changes/keys.ts";
4
+ import { changedHint } from "#internal/queryHints.ts";
5
5
  import type { LiveHint } from "#live.ts";
6
+ import type { QuerySubscription } from "#querySubscription.ts";
6
7
 
7
8
  export const makeLiveServer = Effect.gen(function* () {
8
9
  const reactivity = yield* Reactivity.Reactivity;
9
10
  let openStreams = 0;
10
11
  const resyncKey = `@shivaedev/contract/resync/${crypto.randomUUID()}`;
11
- function stream(keys: readonly Key[], context?: KeyContext) {
12
+ function open(keys: readonly Key[], context?: KeyContext, subscription?: QuerySubscription) {
12
13
  return Stream.unwrap(
13
14
  Effect.gen(function* () {
14
15
  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
16
  const pending = new Map<string, Key>();
19
17
  let scheduled = false;
20
18
  let resync = false;
@@ -57,12 +55,16 @@ export const makeLiveServer = Effect.gen(function* () {
57
55
  }),
58
56
  );
59
57
  yield* Queue.offer(queue, { _tag: "Resync" });
58
+ function authorizeHint(hint: LiveHint) {
59
+ if (hint._tag === "Resync") {
60
+ return Effect.succeed(hint);
61
+ }
62
+ return Effect.map(subscription?.authorized ?? Effect.succeed(true), (allowed) =>
63
+ changedHint(allowed ? hint.keys : [], subscription?.scopes),
64
+ );
65
+ }
60
66
  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
- ),
67
+ Stream.mapEffect(authorizeHint),
66
68
  Stream.filter((hint) => hint._tag === "Resync" || hint.keys.length > 0),
67
69
  );
68
70
  }),
@@ -72,5 +74,9 @@ export const makeLiveServer = Effect.gen(function* () {
72
74
  reactivity.invalidateUnsafe([resyncKey]);
73
75
  return openStreams;
74
76
  });
75
- return { resync, stream };
77
+ return {
78
+ resync,
79
+ stream: (keys: readonly Key[], context?: KeyContext) => open(keys, context),
80
+ subscribe: (subscription: QuerySubscription, context?: KeyContext) => open(subscription.keys, context, subscription),
81
+ };
76
82
  });
package/src/operation.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { Schema } from "effect";
1
+ import type { Option, Schema } from "effect";
2
+ import type { Key } from "@shivaedev/changes/keys.ts";
2
3
  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";
4
+ import { type Loose, operation } from "#internal/operationDeclaration.ts";
5
+ import type { QueryReadPolicy, Viewer } from "#readPolicy.ts";
6
+ import type { MatchingTags, Reject, RejectionSpecs, Rejections, RejectionUnion } from "./rejection.ts";
5
7
 
6
8
  export type PayloadSchema<TPayload extends Schema.Top | Schema.Struct.Fields> = TPayload extends Schema.Struct.Fields
7
9
  ? Schema.Struct<TPayload>
@@ -22,7 +24,8 @@ export interface OperationShape {
22
24
 
23
25
  export interface QueryShape extends OperationShape {
24
26
  readonly kind: "query";
25
- reads: Bivariant<(payload: unknown) => readonly Key[]>;
27
+ readonly readPolicy?: Bivariant<QueryReadPolicy<unknown>>;
28
+ reads: Bivariant<(payload: unknown, viewer: Option.Option<Viewer>) => readonly Key[]>;
26
29
  }
27
30
 
28
31
  export interface StreamShape extends OperationShape {
@@ -31,7 +34,7 @@ export interface StreamShape extends OperationShape {
31
34
  }
32
35
 
33
36
  export interface CommandShape extends OperationShape {
34
- invalidates: Bivariant<(payload: unknown, result: unknown) => readonly Key[]>;
37
+ invalidates: Bivariant<(payload: unknown, result: unknown, viewer?: Option.Option<Viewer>) => readonly Key[]>;
35
38
  readonly kind: "command";
36
39
  }
37
40
 
@@ -49,7 +52,8 @@ interface Operation<TName extends string, TPayload extends Schema.Top, TSuccess
49
52
  export interface Query<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
50
53
  extends Operation<TName, TPayload, TSuccess, TSpecs> {
51
54
  readonly kind: "query";
52
- reads: (payload: TPayload["Type"]) => readonly Key[];
55
+ readonly readPolicy?: QueryReadPolicy<TPayload["Type"]>;
56
+ reads: (payload: TPayload["Type"], viewer: Option.Option<Viewer>) => readonly Key[];
53
57
  }
54
58
 
55
59
  export interface Streaming<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
@@ -60,7 +64,7 @@ export interface Streaming<TName extends string, TPayload extends Schema.Top, TS
60
64
 
61
65
  export interface Command<TName extends string, TPayload extends Schema.Top, TSuccess extends Schema.Top, TSpecs extends RejectionSpecs>
62
66
  extends Operation<TName, TPayload, TSuccess, TSpecs> {
63
- invalidates: (payload: TPayload["Type"], result: TSuccess["Type"]) => readonly Key[];
67
+ invalidates: (payload: TPayload["Type"], result: TSuccess["Type"], viewer?: Option.Option<Viewer>) => readonly Key[];
64
68
  readonly kind: "command";
65
69
  }
66
70
 
@@ -70,31 +74,6 @@ interface Declaration<TPayload extends Schema.Top | Schema.Struct.Fields, TSucce
70
74
  readonly success?: TSuccess;
71
75
  }
72
76
 
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
77
  export function query<
99
78
  const TName extends string,
100
79
  TPayload extends Schema.Top | Schema.Struct.Fields = Schema.Void,
@@ -103,11 +82,22 @@ export function query<
103
82
  >(
104
83
  name: TName,
105
84
  declaration: Declaration<TPayload, TSuccess, TSpecs> & {
106
- readonly reads: (payload: PayloadSchema<TPayload>["Type"]) => readonly Key[];
85
+ readonly reads: (payload: PayloadSchema<TPayload>["Type"], viewer: Option.Option<Viewer>) => readonly Key[];
86
+ readonly readPolicy?: QueryReadPolicy<PayloadSchema<TPayload>["Type"]>;
107
87
  },
108
88
  ): 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 };
89
+ export function query(
90
+ name: string,
91
+ declaration: Loose & {
92
+ readonly reads: (payload: unknown, viewer: Option.Option<Viewer>) => readonly Key[];
93
+ readonly readPolicy?: QueryReadPolicy<unknown>;
94
+ },
95
+ ): QueryShape {
96
+ return {
97
+ ...operation("query", name, declaration),
98
+ reads: declaration.reads,
99
+ ...(declaration.readPolicy === undefined ? {} : { readPolicy: declaration.readPolicy }),
100
+ };
111
101
  }
112
102
 
113
103
  export function command<
@@ -118,12 +108,12 @@ export function command<
118
108
  >(
119
109
  name: TName,
120
110
  declaration: Declaration<TPayload, TSuccess, TSpecs> & {
121
- readonly invalidates: (payload: PayloadSchema<TPayload>["Type"], result: TSuccess["Type"]) => readonly Key[];
111
+ readonly invalidates: (payload: PayloadSchema<TPayload>["Type"], result: TSuccess["Type"], viewer?: Option.Option<Viewer>) => readonly Key[];
122
112
  },
123
113
  ): NoInfer<Command<TName, PayloadSchema<TPayload>, TSuccess, TSpecs>>;
124
114
  export function command(
125
115
  name: string,
126
- declaration: Loose & { readonly invalidates: (payload: unknown, result: unknown) => readonly Key[] },
116
+ declaration: Loose & { readonly invalidates: (payload: unknown, result: unknown, viewer?: Option.Option<Viewer>) => readonly Key[] },
127
117
  ): CommandShape {
128
118
  return { ...operation("command", name, declaration), invalidates: declaration.invalidates };
129
119
  }
@@ -1,9 +1,9 @@
1
- import type { Schema } from "effect";
1
+ import { Effect, type Schema } from "effect";
2
2
  import { contract } from "#contract.ts";
3
3
  import { query } from "#operation.ts";
4
4
 
5
5
  export function publicConfig<T, E>(schema: Schema.Codec<T, E>) {
6
6
  return contract("platform.publicConfig", {
7
- queries: [query("get", { reads: () => [], success: schema })],
7
+ queries: [query("get", { readPolicy: () => Effect.succeed(true), reads: () => [], success: schema })],
8
8
  });
9
9
  }
@@ -0,0 +1,21 @@
1
+ import { Context, Effect, type Option } from "effect";
2
+ import type { Key } from "@shivaedev/changes/keys.ts";
3
+ import { checkQuery, type Viewer } from "#readPolicy.ts";
4
+ import type { QueryRequest } from "#serverMetadata.ts";
5
+
6
+ export interface QuerySubscription {
7
+ readonly authorized: Effect.Effect<boolean>;
8
+ readonly keys: readonly Key[];
9
+ readonly scopes: readonly (readonly Key[])[];
10
+ }
11
+
12
+ export const querySubscription = Effect.fnUntraced(function* (queries: readonly QueryRequest[], viewer: Option.Option<Viewer>) {
13
+ yield* Effect.forEach(queries, ({ query, payload }) => checkQuery(query, payload, viewer));
14
+ const context = Context.makeUnsafe<unknown>((yield* Effect.context<never>()).mapUnsafe);
15
+ const scopes = queries.map(({ query, payload }) => query.reads(payload, viewer));
16
+ const authorized = Effect.map(
17
+ Effect.forEach(queries, ({ query, payload }) => Effect.provideContext(query.readPolicy?.(payload, viewer) ?? Effect.succeed(false), context)),
18
+ (permissions) => permissions.every(Boolean),
19
+ );
20
+ return { authorized, keys: scopes.flat(), scopes } satisfies QuerySubscription;
21
+ });
package/src/readPolicy.ts CHANGED
@@ -1,33 +1,33 @@
1
- import { Context, type Duration, Effect, Option } from "effect";
1
+ import { Context, Effect, Option } from "effect";
2
2
  import type { Rpc } from "effect/rpc";
3
- import type { Key } from "./keys.ts";
4
- import { QueryReads } from "./serverMetadata.ts";
3
+ import type { QueryShape } from "#operation.ts";
4
+ import { MissingReadPolicy, QueryReadDenied } from "#readPolicyErrors.ts";
5
+ import { QueryDefinition } from "#serverMetadata.ts";
5
6
 
6
- export const ReadPolicy = Context.Reference<(key: Key) => Effect.Effect<boolean, unknown>>("@shivaedev/contract/ReadPolicy", {
7
- defaultValue: () => () => Effect.succeed(true),
8
- });
7
+ export interface Viewer {
8
+ readonly id: string;
9
+ }
10
+ export type QueryReadPolicy<TPayload> = (payload: TPayload, viewer: Option.Option<Viewer>) => Effect.Effect<boolean, never, unknown>;
9
11
 
10
- export const ReadPolicyMemoWindow = Context.Reference<Duration.Input>("@shivaedev/contract/ReadPolicyMemoWindow", {
11
- defaultValue: () => "1 second",
12
+ export const CurrentViewer = Context.Reference<Option.Option<Viewer>>("@shivaedev/contract/CurrentViewer", { defaultValue: Option.none });
13
+ export const checkQuery = Effect.fnUntraced(function* (query: QueryShape, payload: unknown, viewer: Option.Option<Viewer>) {
14
+ if (query.readPolicy === undefined) {
15
+ return yield* new MissingReadPolicy({ message: `Declare a readPolicy on query ${query.name} before running it live or subscribing` });
16
+ }
17
+ const context = Context.makeUnsafe<unknown>((yield* Effect.context<never>()).mapUnsafe);
18
+ if (!(yield* Effect.provideContext(query.readPolicy(payload, viewer), context))) {
19
+ return yield* new QueryReadDenied({ message: `Read not allowed for query ${query.name}` });
20
+ }
12
21
  });
13
22
 
14
- export const authorizeReads = Effect.fnUntraced(function* <A, E, R, TPolicyServices, TDenied, TPolicyError>(
23
+ export const authorizeReads = Effect.fnUntraced(function* <A, E, R>(
15
24
  effect: Effect.Effect<A, E, R>,
16
25
  request: { readonly rpc: Rpc.AnyWithProps; readonly payload: unknown },
17
- policy: (key: Key) => Effect.Effect<boolean, TPolicyError, TPolicyServices>,
18
- denied: Effect.Effect<never, TDenied>,
26
+ viewer: Option.Option<Viewer>,
19
27
  ) {
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
- }
28
+ const definition = Context.getOption(request.rpc.annotations, QueryDefinition);
29
+ if (Option.isSome(definition) && definition.value.definition.readPolicy !== undefined) {
30
+ yield* checkQuery(definition.value.definition, request.payload, viewer);
31
31
  }
32
- return yield* Effect.provideService(effect, ReadPolicy, canRead);
32
+ return yield* Effect.provideService(effect, CurrentViewer, viewer);
33
33
  });
@@ -0,0 +1,4 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class MissingReadPolicy extends Schema.TaggedError<MissingReadPolicy>()("MissingReadPolicy", { message: Schema.String }) {}
4
+ export class QueryReadDenied extends Schema.TaggedError<QueryReadDenied>()("QueryReadDenied", { message: Schema.String }) {}
@@ -1,12 +1,24 @@
1
- import { Context, Schema } from "effect";
1
+ import { Context, Effect, Option, Schema } from "effect";
2
2
  import { Rpc } from "effect/rpc";
3
- import type { Key } from "#keys.ts";
3
+ import type { Key } from "@shivaedev/changes/keys.ts";
4
4
  import { LiveHint } from "#live.ts";
5
5
  import type { CommandShape, QueryShape } from "#operation.ts";
6
+ import type { Viewer } from "#readPolicy.ts";
7
+ import { MissingReadPolicy, QueryReadDenied } from "#readPolicyErrors.ts";
6
8
 
7
- export class QueryReads extends Context.Service<QueryReads, QueryShape["reads"]>()("@shivaedev/contract/QueryReads") {}
9
+ export class QueryDefinition extends Context.Service<QueryDefinition, { readonly definition: QueryShape }>()("@shivaedev/contract/QueryDefinition") {}
10
+ export interface QueryRequest {
11
+ readonly payload: unknown;
12
+ readonly query: QueryShape;
13
+ }
14
+ export class ChangesQueries extends Context.Service<
15
+ ChangesQueries,
16
+ {
17
+ readonly resolve: (references: readonly QueryReference[]) => Effect.Effect<readonly QueryRequest[]>;
18
+ }
19
+ >()("@shivaedev/contract/ChangesQueries") {}
8
20
 
9
- export class CommandInvalidation extends Context.Service<CommandInvalidation, CommandShape["invalidates"]>()(
21
+ export class CommandInvalidation extends Context.Service<CommandInvalidation, { readonly invalidates: CommandShape["invalidates"] }>()(
10
22
  "@shivaedev/contract/CommandInvalidation",
11
23
  ) {}
12
24
 
@@ -21,20 +33,22 @@ type ReferenceSchema<TQuery extends QueryShape> = TQuery extends QueryShape
21
33
  ? Schema.Struct<{ query: Schema.Literal<TQuery["name"]>; payload: TQuery["payload"] }>
22
34
  : never;
23
35
 
24
- export class ChangesReads extends Context.Service<ChangesReads, (references: readonly QueryReference[]) => readonly Key[]>()(
25
- "@shivaedev/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);
36
+ function resolveQueries(queries: readonly QueryShape[], requested: readonly QueryReference[]) {
37
+ return Effect.forEach(requested, (reference) => {
38
+ const query = queries.find((candidate) => candidate.name === reference.query);
39
+ return query === undefined ? Effect.die(new Error(`Unknown query: ${reference.query}`)) : Effect.succeed({ payload: reference.payload, query });
35
40
  });
36
41
  }
37
42
 
43
+ export const queryKeys = Effect.fnUntraced(function* (
44
+ queries: readonly QueryShape[],
45
+ requested: readonly QueryReference[],
46
+ viewer: Option.Option<Viewer> = Option.none(),
47
+ ): Effect.fn.Return<readonly Key[]> {
48
+ const resolved = yield* resolveQueries(queries, requested);
49
+ return resolved.flatMap(({ query, payload }) => query.reads(payload, viewer));
50
+ });
51
+
38
52
  function referenceSchemas<const TQueries extends readonly QueryShape[]>(
39
53
  queries: TQueries,
40
54
  ): Schema.$Array<Schema.Union<readonly ReferenceSchema<TQueries[number]>[]>>;
@@ -43,8 +57,10 @@ function referenceSchemas(queries: readonly QueryShape[]) {
43
57
  }
44
58
 
45
59
  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
- );
60
+ return Rpc.make(`${name}.$changes`, {
61
+ error: Schema.Union([MissingReadPolicy, QueryReadDenied]),
62
+ payload: { queries: referenceSchemas(queries) },
63
+ stream: true,
64
+ success: LiveHint,
65
+ }).annotate(ChangesQueries, { resolve: (references) => resolveQueries(queries, references) });
50
66
  }
package/src/testing.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Deferred, type Duration, Effect, Fiber, Stream, SubscriptionRef } from "effect";
2
- import type { Key } from "./keys.ts";
2
+ import type { Key } from "@shivaedev/changes/keys.ts";
3
3
  import type { LiveHint } from "./live.ts";
4
4
 
5
5
  export interface HintViewer<E> {
@@ -37,15 +37,5 @@ export const heard = Effect.fn("Live.heard")(function* <E>(
37
37
  });
38
38
 
39
39
  function namesKeys(hint: LiveHint, keys: readonly Key[]): boolean {
40
- return (
41
- hint._tag === "Changed"
42
- && keys.every((key) =>
43
- hint.keys.some(
44
- (actual) =>
45
- actual._tag === key._tag
46
- && actual.collection === key.collection
47
- && (key._tag === "List" || (actual._tag === "Item" && actual.id === key.id)),
48
- ),
49
- )
50
- );
40
+ return hint._tag === "Changed" && keys.every((key) => hint.keys.some((actual) => JSON.stringify(actual) === JSON.stringify(key)));
51
41
  }
@@ -1,4 +0,0 @@
1
- import { Effect } from "effect";
2
- import type { Key } from "#keys.ts";
3
- export declare const makeHintPolicy: (capacity: number, retry: (keys: readonly Key[]) => Effect.Effect<void>) => Effect.Effect<(keys: readonly Key[]) => Effect.Effect<Key[], never, never>, never, import("effect/Scope").Scope>;
4
- //# sourceMappingURL=makeHintPolicy.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"makeHintPolicy.d.ts","sourceRoot":"","sources":["../../src/internal/makeHintPolicy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,MAAM,EAAe,MAAM,QAAQ,CAAC;AAC1D,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAGpC,eAAO,MAAM,cAAc,mCAAgE,SAAS,GAAG,EAAE,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,0BAqBlH,SAAS,GAAG,EAAE,6EAc3B,CAAC"}
@@ -1,25 +0,0 @@
1
- import { Cache, Data, Effect, Exit, Queue } from "effect";
2
- import { ReadPolicy, ReadPolicyMemoWindow } from "#readPolicy.ts";
3
- export const makeHintPolicy = Effect.fnUntraced(function* (capacity, retry) {
4
- const policy = yield* ReadPolicy;
5
- const window = yield* ReadPolicyMemoWindow;
6
- const cache = yield* Cache.makeWith(policy, {
7
- capacity: Math.max(1, capacity),
8
- timeToLive: (exit) => (Exit.isSuccess(exit) ? window : 0),
9
- });
10
- const failed = new Map();
11
- const wake = yield* Queue.unbounded();
12
- yield* Queue.take(wake).pipe(Effect.andThen(Effect.sleep("1 second")), Effect.andThen(Effect.suspend(() => {
13
- const keys = [...failed.values()];
14
- failed.clear();
15
- return retry(keys);
16
- })), Effect.forever, Effect.forkScoped);
17
- return (keys) => Effect.filter(keys, (key) => Cache.get(cache, new Data.Class(key)).pipe(Effect.catch(() => Effect.gen(function* () {
18
- if (failed.size === 0) {
19
- yield* Queue.offer(wake, undefined);
20
- }
21
- failed.set(JSON.stringify(key), key);
22
- return false;
23
- }))));
24
- });
25
- //# sourceMappingURL=makeHintPolicy.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"makeHintPolicy.js","sourceRoot":"","sources":["../../src/internal/makeHintPolicy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAE1D,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAElE,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,QAAgB,EAAE,KAAoD;IAChI,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC;IACjC,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,oBAAoB,CAAC;IAC3C,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE;QAC3C,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,CAAC;QAC/B,UAAU,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;KACzD,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,IAAI,GAAG,EAAe,CAAC;IACtC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,SAAS,EAAQ,CAAC;IAC5C,KAAK,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CAC3B,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,EACxC,MAAM,CAAC,OAAO,CACb,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;QACnB,MAAM,IAAI,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QAClC,MAAM,CAAC,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC;IACpB,CAAC,CAAC,CACF,EACD,MAAM,CAAC,OAAO,EACd,MAAM,CAAC,UAAU,CACjB,CAAC;IACF,OAAO,CAAC,IAAoB,EAAE,EAAE,CAC/B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,CAC3B,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,IAAI,CAAC,KAAK,CAAM,GAAG,CAAC,CAAC,CAAC,IAAI,CAC9C,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CACjB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACvB,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QACrC,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QACrC,OAAO,KAAK,CAAC;IACd,CAAC,CAAC,CACF,CACD,CACD,CAAC;AACJ,CAAC,CAAC,CAAC"}
package/dist/keys.d.ts DELETED
@@ -1,34 +0,0 @@
1
- import { Schema } from "effect";
2
- export type Identity = string | number;
3
- export interface ListKey<TName extends string = string> {
4
- readonly _tag: "List";
5
- readonly collection: TName;
6
- }
7
- export interface ItemKey<TName extends string = string> {
8
- readonly _tag: "Item";
9
- readonly collection: TName;
10
- readonly id: Identity;
11
- }
12
- export type Key = ListKey | ItemKey;
13
- export declare const Key: Schema.Union<readonly [Schema.Struct<{
14
- readonly _tag: Schema.Literal<"List">;
15
- readonly collection: Schema.String;
16
- }>, Schema.Struct<{
17
- readonly _tag: Schema.Literal<"Item">;
18
- readonly collection: Schema.String;
19
- readonly id: Schema.Union<readonly [Schema.String, Schema.Number]>;
20
- }>]>;
21
- export interface Collection<TName extends string, TId extends Identity> {
22
- readonly item: (id: TId) => ItemKey<TName>;
23
- readonly list: ListKey<TName>;
24
- readonly name: TName;
25
- }
26
- export declare function collection<const TName extends string, TId extends Schema.Top & {
27
- readonly Type: Identity;
28
- }>(name: TName, _id: TId): Collection<TName, TId["Type"]>;
29
- export interface KeyContext {
30
- readonly scope: string;
31
- }
32
- export declare function readKeys(keys: readonly Key[], context?: KeyContext): readonly string[];
33
- export declare function invalidationKeys(keys: readonly Key[], context?: KeyContext): readonly string[];
34
- //# sourceMappingURL=keys.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEvC,MAAM,WAAW,OAAO,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAC;CAC3B;AAED,MAAM,WAAW,OAAO,CAAC,KAAK,SAAS,MAAM,GAAG,MAAM;IACrD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAC;IAC3B,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC;CACtB;AAED,MAAM,MAAM,GAAG,GAAG,OAAO,GAAG,OAAO,CAAC;AAEpC,eAAO,MAAM,GAAG;;;;;;;IAGd,CAAC;AAEH,MAAM,WAAW,UAAU,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,QAAQ;IACrE,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,GAAG,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;IAC3C,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;CACrB;AAED,wBAAgB,UAAU,CAAC,KAAK,CAAC,KAAK,SAAS,MAAM,EAAE,GAAG,SAAS,MAAM,CAAC,GAAG,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,EAC1G,IAAI,EAAE,KAAK,EACX,GAAG,EAAE,GAAG,GACN,UAAU,CAAC,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAMhC;AAED,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAUD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,EAAE,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAItF;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,EAAE,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,SAAS,MAAM,EAAE,CAI9F"}