effect-zero 0.7.0 → 0.9.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 (46) hide show
  1. package/README.md +86 -77
  2. package/dist/client-transaction.d.ts +0 -1
  3. package/dist/client-transaction.d.ts.map +1 -1
  4. package/dist/client.d.ts +38 -138
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +75 -24
  7. package/dist/client.js.map +1 -1
  8. package/dist/internal/utils.d.ts +0 -7
  9. package/dist/internal/utils.d.ts.map +1 -1
  10. package/dist/internal/utils.js +0 -7
  11. package/dist/internal/utils.js.map +1 -1
  12. package/dist/mutator.d.ts +115 -0
  13. package/dist/mutator.d.ts.map +1 -0
  14. package/dist/mutator.js +67 -0
  15. package/dist/mutator.js.map +1 -0
  16. package/dist/mutator.test.d.ts +2 -0
  17. package/dist/mutator.test.d.ts.map +1 -0
  18. package/dist/{client.test.js → mutator.test.js} +5 -5
  19. package/dist/mutator.test.js.map +1 -0
  20. package/dist/query.d.ts +2 -2
  21. package/dist/result.d.ts +1 -1
  22. package/dist/result.d.ts.map +1 -1
  23. package/dist/result.js +1 -1
  24. package/dist/result.js.map +1 -1
  25. package/dist/server-transaction.d.ts +0 -1
  26. package/dist/server-transaction.d.ts.map +1 -1
  27. package/dist/server.d.ts +7 -3
  28. package/dist/server.d.ts.map +1 -1
  29. package/dist/server.js +30 -15
  30. package/dist/server.js.map +1 -1
  31. package/dist/types.test.js +85 -120
  32. package/dist/types.test.js.map +1 -1
  33. package/package.json +49 -48
  34. package/src/client.ts +159 -56
  35. package/src/internal/utils.ts +0 -8
  36. package/src/mutator.ts +232 -0
  37. package/src/result.ts +2 -2
  38. package/src/server.ts +49 -34
  39. package/dist/client.test.d.ts +0 -2
  40. package/dist/client.test.d.ts.map +0 -1
  41. package/dist/client.test.js.map +0 -1
  42. package/dist/mutators.d.ts +0 -30
  43. package/dist/mutators.d.ts.map +0 -1
  44. package/dist/mutators.js +0 -17
  45. package/dist/mutators.js.map +0 -1
  46. package/src/mutators.ts +0 -80
package/src/client.ts CHANGED
@@ -1,79 +1,182 @@
1
- import { Zero, type ZeroOptions, type Schema as ZeroSchema, type Transaction as ZeroTransaction } from "@rocicorp/zero";
2
- import * as Cause from "effect/Cause";
1
+ import {
2
+ type MutatorResultDetails,
3
+ type MutatorResultErrorDetails,
4
+ type MutatorResultSuccessDetails,
5
+ type ReadonlyJSONValue,
6
+ Zero,
7
+ type ZeroOptions,
8
+ } from "@rocicorp/zero";
3
9
  import * as Data from "effect/Data";
4
10
  import * as Effect from "effect/Effect";
11
+ import * as Exit from "effect/Exit";
12
+ import * as Fiber from "effect/Fiber";
13
+ import * as Fn from "effect/Function";
5
14
  import type { NodeInspectSymbol } from "effect/Inspectable";
15
+ import type * as LogLevel from "effect/LogLevel";
6
16
  import * as Match from "effect/Match";
17
+ import * as Option from "effect/Option";
7
18
  import * as Predicate from "effect/Predicate";
8
19
  import * as Rec from "effect/Record";
9
- import * as Schema from "effect/Schema";
20
+ import * as Scope from "effect/Scope";
21
+ import type * as Tracer from "effect/Tracer";
10
22
  import type * as Unify from "effect/Unify";
11
- import type * as ClientTransaction from "./client-transaction.js";
12
- import { ContextSymbol, SchemaSymbol } from "./client-transaction.js";
13
- import { normalizeArgs } from "./internal/utils.js";
14
- import * as Mutators from "./mutators.js";
23
+ import { type ContextSymbol, SchemaSymbol } from "./client-transaction.js";
24
+ import * as Mutator from "./mutator.js";
15
25
 
16
26
  type _NodeInspectSymbol = NodeInspectSymbol;
17
27
  type _Unify = Unify.typeSymbol | Unify.unifySymbol | Unify.ignoreSymbol;
18
28
 
19
- export const make = Effect.fn(function* <TSchema extends ZeroSchema, TMutators extends Mutators.AnyMutators>(
20
- transaction: ClientTransaction.Context<TSchema>,
21
- mutators: TMutators,
22
- options: Omit<ZeroOptions<TSchema, UnwrapMutators<TSchema, TMutators>>, "schema" | "mutators">,
29
+ export type Options<TRegistry extends Mutator.AnyRegistry> = Omit<
30
+ ZeroOptions<Mutator.ZeroSchemaOf<TRegistry>>,
31
+ "schema" | "mutators" | "context"
32
+ >;
33
+
34
+ /**
35
+ * Makes a Zero client that runs the registry's mutators with the services in scope here.
36
+ * Run mutations with {@link mutate}.
37
+ */
38
+ export const make = Effect.fn(function* <TRegistry extends Mutator.AnyRegistry>(
39
+ mutators: TRegistry,
40
+ options: Options<TRegistry>,
23
41
  ) {
24
- const ctx =
42
+ const context =
25
43
  yield* Effect.context<
26
- Exclude<Mutators.ExtractMutatorsRequirements<TMutators>, (typeof transaction)[typeof ContextSymbol]["Identifier"]>
44
+ Exclude<Mutator.Requirements<TRegistry>, Mutator.Transaction<TRegistry>[typeof ContextSymbol]["Identifier"]>
27
45
  >();
28
46
 
29
- function unwrapMutator<E>(mutator: Mutators.AnyMutator<Mutators.ExtractMutatorsRequirements<TMutators>, E>) {
30
- return (tx: ZeroTransaction<TSchema>, args: unknown, _ctx: unknown) =>
31
- Schema.decodeUnknownEffect(mutator[Mutators.MutatorSchemaSymbol])(normalizeArgs(args)).pipe(
32
- Effect.catchTag("SchemaError", (e) => Effect.fail(new ClientArgsParseError({ cause: Cause.fail(e) }))),
33
- Effect.flatMap(mutator),
34
- Effect.provideService(transaction[ContextSymbol], tx),
35
- Effect.runPromiseWith(ctx),
36
- );
37
- }
38
-
39
- const unwrappedMutators = Rec.map(mutators, (v) =>
40
- Match.value(v).pipe(Match.when(Predicate.isFunction, unwrapMutator), Match.orElse(Rec.map(unwrapMutator))),
41
- ) as ZeroOptions<TSchema, UnwrapMutators<TSchema, TMutators>>["mutators"];
42
-
43
47
  return yield* Effect.acquireRelease(
44
- Effect.sync(() => {
45
- return new Zero({
46
- ...options,
47
- schema: transaction[SchemaSymbol],
48
- mutators: unwrappedMutators,
49
- });
50
- }),
48
+ Effect.sync(
49
+ () =>
50
+ new Zero<Mutator.ZeroSchemaOf<TRegistry>>({
51
+ // Zero's own logs (at its `logLevel`, "warn" by default) go through Effect's logger, unless `options` sets
52
+ // a `logSink`.
53
+ logSink: {
54
+ log: (level, logContext, ...args) =>
55
+ Match.value(level).pipe(
56
+ Match.withReturnType<LogLevel.Severity>(),
57
+ Match.when("debug", () => "Debug"),
58
+ Match.when("info", () => "Info"),
59
+ Match.when("warn", () => "Warn"),
60
+ Match.when("error", () => "Error"),
61
+ Match.exhaustive,
62
+ (severity) => Effect.logWithLevel(severity)(...args),
63
+ Effect.annotateLogs({ ...logContext, logger: "zero" }),
64
+ Effect.runForkWith(context),
65
+ ),
66
+ },
67
+ ...options,
68
+ schema: mutators[Mutator.RegistryTypeId].transaction[SchemaSymbol],
69
+ mutators,
70
+ // The mutators run with these services.
71
+ context,
72
+ }),
73
+ ),
51
74
  (zero) => Effect.promise(() => zero.close()),
52
75
  );
53
76
  });
54
77
 
55
- type UnwrapMutator<TSchema extends ZeroSchema, TMutators extends Mutators.AnyMutator> = Parameters<TMutators> extends []
56
- ? (transaction: ZeroTransaction<TSchema>) => Promise<void>
57
- : (
58
- transaction: ZeroTransaction<TSchema>,
59
- args: Schema.Codec.Encoded<TMutators[typeof Mutators.MutatorSchemaSymbol]>,
60
- ) => Promise<void>;
78
+ /** A mutation sent with {@link mutate}. Its client and server outcomes settle independently. */
79
+ export interface Mutation {
80
+ /** Completes once the mutation is applied locally; fails if Zero or the client mutator rejected it. */
81
+ readonly client: Effect.Effect<void, MutationError>;
82
+ /** Completes once the server applied the mutation; fails if it was rejected anywhere. */
83
+ readonly server: Effect.Effect<void, MutationError>;
84
+ }
85
+
86
+ /**
87
+ * Sends a mutation with `zero.mutate(request)` and traces it. Awaiting `client` or `server` is optional; both
88
+ * outcomes are always recorded on the `zero.mutation` span, as the `zero.mutation.client` and
89
+ * `zero.mutation.server` child spans.
90
+ */
91
+ export const mutate = Effect.fn(function* <Type>(
92
+ // biome-ignore lint/suspicious/noExplicitAny: any Zero instance made with `make`
93
+ zero: Zero<any, any, any>,
94
+ request: Mutator.MutateRequest<Type>,
95
+ ) {
96
+ const mutator = request.mutator.mutatorName;
97
+ const connection = zero.connection.state.current;
98
+ // The span outlives this call: it ends when this scope closes, once both outcomes are known.
99
+ const scope = yield* Scope.make();
100
+ const span = yield* Effect.makeSpanScoped("zero.mutation", {
101
+ attributes: {
102
+ "zero.mutator": mutator,
103
+ "zero.clientID": zero.clientID,
104
+ "zero.connection.state": connection.name,
105
+ ...Rec.getSomes({
106
+ "zero.connection.reason": Option.fromNullishOr("reason" in connection ? connection.reason : undefined).pipe(
107
+ Option.map((reason) => (Predicate.isString(reason) ? reason : reason.type)),
108
+ ),
109
+ }),
110
+ },
111
+ }).pipe(Scope.provide(scope));
112
+
113
+ const result = yield* Effect.sync(() => zero.mutate(request)).pipe(
114
+ Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
115
+ );
116
+
117
+ const client = yield* Effect.promise(() => result.client).pipe(
118
+ Effect.filterOrElse(isApplied, (details) => new MutationError({ mutator, origin: "client", ...details.error })),
119
+ Effect.asVoid,
120
+ traceOutcome({ origin: "client", parent: span, mutator }),
121
+ Effect.forkDetach,
122
+ );
123
+ const server = yield* Effect.promise(() => result.server).pipe(
124
+ Effect.filterOrElse(
125
+ isApplied,
126
+ Effect.fnUntraced(function* (details) {
127
+ // A write the client rejected is never pushed; Zero reports the client's error for the server too.
128
+ const origin = Exit.isFailure(yield* Fiber.await(client)) ? "client" : "server";
129
+ return yield* new MutationError({ mutator, origin, ...details.error });
130
+ }),
131
+ ),
132
+ Effect.asVoid,
133
+ traceOutcome({ origin: "server", parent: span, mutator }),
134
+ Effect.forkDetach,
135
+ );
136
+
137
+ yield* Effect.gen(function* () {
138
+ span.attribute("zero.clientGroupID", yield* Effect.promise(() => zero.clientGroupID));
139
+ const clientExit = yield* Fiber.await(client);
140
+ const serverExit = yield* Fiber.await(server);
141
+ yield* Scope.close(scope, Exit.isFailure(clientExit) ? clientExit : serverExit);
142
+ }).pipe(Effect.forkDetach);
143
+
144
+ // Joining a fiber never re-runs it, so either outcome can be awaited any number of times.
145
+ return { client: Fiber.join(client), server: Fiber.join(server) };
146
+ });
147
+
148
+ const isApplied = (details: MutatorResultDetails): details is MutatorResultSuccessDetails => details.type === "success";
61
149
 
62
- type UnwrapMutators<TSchema extends ZeroSchema, TMutators extends Mutators.AnyMutators> = {
63
- [A in keyof TMutators]: TMutators[A] extends Mutators.AnyMutator
64
- ? UnwrapMutator<TSchema, TMutators[A]>
65
- : {
66
- [B in keyof TMutators[A]]: TMutators[A][B] extends Mutators.AnyMutator
67
- ? UnwrapMutator<TSchema, TMutators[A][B]>
68
- : never;
69
- };
70
- } & {};
150
+ /** Runs a mutation's outcome in its `zero.mutation.client` or `zero.mutation.server` span. */
151
+ const traceOutcome: {
152
+ (options: TraceOutcomeOptions): (self: Effect.Effect<void, MutationError>) => Effect.Effect<void, MutationError>;
153
+ (self: Effect.Effect<void, MutationError>, options: TraceOutcomeOptions): Effect.Effect<void, MutationError>;
154
+ } = Fn.dual(2, (self: Effect.Effect<void, MutationError>, { origin, parent, mutator }: TraceOutcomeOptions) =>
155
+ self.pipe(
156
+ Effect.tapError((error) =>
157
+ Effect.annotateCurrentSpan({
158
+ "zero.mutation.error.type": error.type,
159
+ "zero.mutation.error.origin": error.origin,
160
+ }),
161
+ ),
162
+ Effect.withSpan(`zero.mutation.${origin}`, { parent, attributes: { "zero.mutator": mutator } }),
163
+ ),
164
+ );
71
165
 
72
- export class ClientArgsParseError extends Data.TaggedError("ClientArgsParseError")<{
73
- readonly cause: Cause.Cause<unknown>;
74
- }> {
75
- override get message() {
76
- const error = Cause.squash(this.cause);
77
- return Predicate.isError(error) ? error.message : "Internal error";
78
- }
166
+ interface TraceOutcomeOptions {
167
+ readonly origin: "client" | "server";
168
+ readonly parent: Tracer.Span;
169
+ readonly mutator: string;
79
170
  }
171
+
172
+ /**
173
+ * Zero rejected a mutation, with Zero's own error details: `type` is `"zero"` when Zero itself refused it
174
+ * (e.g. offline), and `"app"` when a mutator failed. `origin` is where it failed.
175
+ */
176
+ export class MutationError extends Data.TaggedError("MutationError")<{
177
+ readonly mutator: string;
178
+ readonly origin: "client" | "server";
179
+ readonly type: MutatorResultErrorDetails["error"]["type"];
180
+ readonly message: string;
181
+ readonly details?: ReadonlyJSONValue | undefined;
182
+ }> {}
@@ -2,11 +2,3 @@
2
2
  * Prefix a string with the package name.
3
3
  */
4
4
  export const prefixId = <S extends string>(name: S) => `effect-zero/${name}` as const;
5
-
6
- /**
7
- * Zero unconditionally serializes arg-less mutator calls as `null` on the wire
8
- * (https://github.com/rocicorp/mono/blob/3082c9fa061891067b4bd7dc9fe74f798270d8d7/packages/zero-client/src/client/custom.ts).
9
- * v3 `Schema.Void` happened to accept any input; v4 `Schema.Void` is strict
10
- * `=== undefined`, so we renormalize before decoding.
11
- */
12
- export const normalizeArgs = (args: unknown): unknown => (args === null ? undefined : args);
package/src/mutator.ts ADDED
@@ -0,0 +1,232 @@
1
+ import type {
2
+ AnyMutatorRegistry,
3
+ MutateRequest as ZeroMutateRequest,
4
+ Schema as ZeroSchema,
5
+ Transaction as ZeroTransaction,
6
+ } from "@rocicorp/zero";
7
+ import * as Cause from "effect/Cause";
8
+ import * as Context from "effect/Context";
9
+ import * as Data from "effect/Data";
10
+ import * as Effect from "effect/Effect";
11
+ import type { NodeInspectSymbol } from "effect/Inspectable";
12
+ import * as Predicate from "effect/Predicate";
13
+ import * as Rec from "effect/Record";
14
+ import * as Schema from "effect/Schema";
15
+ import type * as Unify from "effect/Unify";
16
+ import type * as ClientTransaction from "./client-transaction.js";
17
+ import { ContextSymbol } from "./client-transaction.js";
18
+ import { prefixId } from "./internal/utils.js";
19
+
20
+ type _NodeInspectSymbol = NodeInspectSymbol;
21
+ type _Unify = Unify.typeSymbol | Unify.unifySymbol | Unify.ignoreSymbol;
22
+
23
+ export const RegistryTypeId = Symbol.for(prefixId("MutatorRegistry"));
24
+
25
+ /**
26
+ * A mutator that isn't part of a registry yet, made with {@link make}. Like `defineMutator`, it gets its name
27
+ * from its path in the {@link registry}.
28
+ */
29
+ export interface Definition<Type, Encoded, E, R> {
30
+ readonly payload: Schema.Codec<Type, Encoded, R>;
31
+ readonly mutator: (args: Type) => Effect.Effect<unknown, E, R>;
32
+ }
33
+
34
+ // biome-ignore lint/suspicious/noExplicitAny: upper bound to allow everything
35
+ export type AnyDefinition = Definition<any, any, any, any>;
36
+
37
+ export type Definitions = { readonly [key: string]: AnyDefinition | Definitions };
38
+
39
+ /**
40
+ * A registered mutator. Calling it encodes `args` and returns a request for `ZfxClient.mutate(zero, request)`, or
41
+ * for Zero's own `zero.mutate(request)`. It is also a Zero `Mutator`, so the registry can be passed to Zero.
42
+ */
43
+ export interface Mutator<Type, Encoded, E, R> extends Definition<Type, Encoded, E, R> {
44
+ (args: Type): MutateRequest<Type>;
45
+ readonly mutatorName: string;
46
+ /** Runs the mutator for Zero, which calls it with the transaction and the `context` from `ZfxClient.make`. */
47
+ readonly fn: (options: {
48
+ readonly args: unknown;
49
+ readonly ctx: unknown;
50
+ readonly tx: ZeroTransaction<ZeroSchema>;
51
+ }) => Promise<void>;
52
+ readonly "~": "Mutator";
53
+ }
54
+
55
+ // biome-ignore lint/suspicious/noExplicitAny: upper bound to allow everything
56
+ export type AnyMutator = Mutator<any, any, any, any>;
57
+
58
+ /** A request to run a mutator, for `ZfxClient.mutate` or Zero's own `zero.mutate`. */
59
+ // biome-ignore lint/suspicious/noExplicitAny: compatible with any Zero instance's `zero.mutate`
60
+ export type MutateRequest<Type> = ZeroMutateRequest<any, any, any, any> & {
61
+ // biome-ignore lint/suspicious/noExplicitAny: the encoded args, errors and services don't matter to the caller
62
+ readonly mutator: Mutator<Type, any, any, any>;
63
+ };
64
+
65
+ type Tree<D> = {
66
+ readonly [K in keyof D]: D[K] extends Definition<infer Type, infer Encoded, infer E, infer R>
67
+ ? Mutator<Type, Encoded, E, R>
68
+ : Tree<D[K]>;
69
+ };
70
+
71
+ /** A tree of registered mutators, made with {@link registry}. Pass it to `ZfxClient.make` and the server. */
72
+ export type Registry<D extends Definitions, TTransaction extends AnyClientTransaction> = Tree<D> & {
73
+ readonly "~": AnyMutatorRegistry["~"];
74
+ readonly [RegistryTypeId]: { readonly transaction: TTransaction };
75
+ };
76
+
77
+ // biome-ignore lint/suspicious/noExplicitAny: upper bound to allow everything
78
+ export type AnyRegistry = Registry<any, AnyClientTransaction>;
79
+
80
+ // biome-ignore lint/suspicious/noExplicitAny: any client transaction
81
+ type AnyClientTransaction = ClientTransaction.Context<any>;
82
+
83
+ type Keys<T> = Exclude<Extract<keyof T, string>, "~">;
84
+
85
+ /** The services a registry's mutators require. */
86
+ // biome-ignore lint/suspicious/noExplicitAny: matches any mutator
87
+ export type Requirements<T> = T extends Mutator<any, any, any, infer R>
88
+ ? R
89
+ : { [K in Keys<T>]: Requirements<T[K]> }[Keys<T>];
90
+
91
+ /** The client transaction a registry's mutators run in. */
92
+ export type Transaction<T extends AnyRegistry> = T[typeof RegistryTypeId]["transaction"];
93
+
94
+ /** The Zero schema a registry's mutators run against. */
95
+ export type ZeroSchemaOf<T extends AnyRegistry> = Transaction<T> extends ClientTransaction.Context<infer S> ? S : never;
96
+
97
+ /**
98
+ * Defines a mutator. Its `payload` codec decodes the args on both the client and the server; it must encode without
99
+ * services, since calling a registered mutator encodes its args synchronously.
100
+ */
101
+ export const make = <Type, Encoded, RD = never, A = unknown, E = never, R = never>(options: {
102
+ readonly payload: Schema.Codec<Type, Encoded, RD, never>;
103
+ readonly mutator: (args: NoInfer<Type>) => Effect.Effect<A, E, R>;
104
+ }): Definition<Type, Encoded, E, R | RD> => options;
105
+
106
+ /**
107
+ * Registers mutators under their path, like Zero's `defineMutators`: `{ todo: { create } }` registers
108
+ * `"todo.create"`. The result is also a Zero mutator registry, so it can be passed to `new Zero({ mutators })`.
109
+ */
110
+ export const registry = <TTransaction extends AnyClientTransaction, const D extends Definitions>(
111
+ transaction: TTransaction,
112
+ definitions: D,
113
+ ): Registry<D, TTransaction> => {
114
+ const register = (definitions: Definitions, path: ReadonlyArray<string>): Record<string, unknown> =>
115
+ Rec.map(definitions, (value, key) =>
116
+ isDefinition(value) ? makeMutator(transaction, [...path, key].join("."), value) : register(value, [...path, key]),
117
+ );
118
+
119
+ // The registry's shape is only known from `D`.
120
+ return Object.assign(register(definitions, []), {
121
+ "~": "MutatorRegistry",
122
+ [RegistryTypeId]: { transaction },
123
+ }) as unknown as Registry<D, TTransaction>;
124
+ };
125
+
126
+ const isDefinition = (u: unknown): u is AnyDefinition =>
127
+ Predicate.hasProperty(u, "payload") && Schema.isSchema(u.payload);
128
+
129
+ const makeMutator = <Type, Encoded, E, R>(
130
+ transaction: AnyClientTransaction,
131
+ name: string,
132
+ definition: Definition<Type, Encoded, E, R>,
133
+ ): Mutator<Type, Encoded, E, R> => {
134
+ const run = (args: unknown, tx: ZeroTransaction<ZeroSchema>) =>
135
+ Schema.decodeUnknownEffect(definition.payload)(args).pipe(
136
+ Effect.catchTag("SchemaError", (error) => new ClientArgsParseError({ cause: Cause.fail(error) })),
137
+ Effect.flatMap(definition.mutator),
138
+ Effect.withSpan("zero.mutator", {
139
+ attributes: {
140
+ "zero.mutator": name,
141
+ "zero.clientID": tx.clientID,
142
+ "zero.mutationID": tx.mutationID,
143
+ "zero.mutation.reason": tx.reason,
144
+ },
145
+ }),
146
+ Effect.provideService(transaction[ContextSymbol], tx),
147
+ Effect.asVoid,
148
+ );
149
+
150
+ const mutator: Mutator<Type, Encoded, E, R> = Object.assign(
151
+ (args: Type) =>
152
+ // A Zero `MutateRequest`, whose brand only exists at the type level.
153
+ ({ mutator, args: Schema.encodeSync(definition.payload)(args), "~": "MutateRequest" }) as MutateRequest<Type>,
154
+ {
155
+ ...definition,
156
+ mutatorName: name,
157
+ "~": "Mutator" as const,
158
+ fn: ({
159
+ args,
160
+ ctx,
161
+ tx,
162
+ }: {
163
+ readonly args: unknown;
164
+ readonly ctx: unknown;
165
+ readonly tx: ZeroTransaction<ZeroSchema>;
166
+ }) =>
167
+ Context.isContext(ctx)
168
+ ? // `ZfxClient.make` passes the services it captured, which cover every mutator's requirements.
169
+ run(args, tx).pipe(Effect.runPromiseWith(ctx as Context.Context<R>))
170
+ : Promise.reject(new MutatorContextNotFound({ mutator: name })),
171
+ },
172
+ );
173
+ return mutator;
174
+ };
175
+
176
+ type Overrides<T> = {
177
+ readonly [K in Keys<T>]?: T[K] extends Mutator<infer Type, infer _Encoded, infer _E, infer _R>
178
+ ? // biome-ignore lint/suspicious/noExplicitAny: any server implementation
179
+ (args: Type) => Effect.Effect<unknown, any, any>
180
+ : Overrides<T[K]>;
181
+ };
182
+
183
+ /** The server's view of a registry, made with {@link server}. */
184
+ export interface ServerMutators<TRegistry extends AnyRegistry, O> {
185
+ readonly registry: TRegistry;
186
+ readonly overrides: O;
187
+ }
188
+
189
+ export type AnyServerMutators = ServerMutators<AnyRegistry, unknown>;
190
+
191
+ /**
192
+ * The registry's mutators as the server runs them, for `ZfxServer.handleMutate`. `overrides` replaces some mutators'
193
+ * bodies, like Zero's `defineMutators(base, overrides)`; every other mutator runs its shared body in a server
194
+ * transaction, so pass `{}` if nothing differs. An override runs outside the transaction, so it can do work before
195
+ * and after `serverTransaction.execute`, e.g. `registry.todo.create.mutator(args).pipe(serverTransaction.execute)`.
196
+ */
197
+ export const server = <TRegistry extends AnyRegistry, const O extends Overrides<TRegistry>>(
198
+ registry: TRegistry,
199
+ overrides: O,
200
+ ): ServerMutators<TRegistry, O> => ({ registry, overrides });
201
+
202
+ type ServerTreeRequirements<T, O, ClientTransactionId> = {
203
+ [K in Keys<T>]: T[K] extends AnyMutator
204
+ ? K extends keyof O
205
+ ? O[K] extends (args: never) => Effect.Effect<infer _A, infer _E, infer R>
206
+ ? R
207
+ : never
208
+ : Exclude<Requirements<T[K]>, ClientTransactionId>
209
+ : ServerTreeRequirements<T[K], K extends keyof O ? O[K] : unknown, ClientTransactionId>;
210
+ }[Keys<T>];
211
+
212
+ /**
213
+ * The services the server needs to run these mutators. Mutators that aren't overridden run in a server transaction,
214
+ * which provides their client transaction.
215
+ */
216
+ export type ServerRequirements<T extends AnyServerMutators> = T extends ServerMutators<infer TRegistry, infer O>
217
+ ? ServerTreeRequirements<TRegistry, O, Transaction<TRegistry>[typeof ContextSymbol]["Identifier"]>
218
+ : never;
219
+
220
+ export class ClientArgsParseError extends Data.TaggedError("ClientArgsParseError")<{
221
+ readonly cause: Cause.Cause<unknown>;
222
+ }> {
223
+ override get message() {
224
+ const error = Cause.squash(this.cause);
225
+ return Predicate.isError(error) && !Schema.isSchemaError(error) ? error.message : "Internal error";
226
+ }
227
+ }
228
+
229
+ /** A mutator ran on a Zero instance that wasn't made with `ZfxClient.make`. */
230
+ export class MutatorContextNotFound extends Data.TaggedError("MutatorContextNotFound")<{
231
+ readonly mutator: string;
232
+ }> {}
package/src/result.ts CHANGED
@@ -5,14 +5,14 @@ import * as Fn from "effect/Function";
5
5
  import * as Match from "effect/Match";
6
6
  import * as Option from "effect/Option";
7
7
  import * as Predicate from "effect/Predicate";
8
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
8
9
  import * as Struct from "effect/Struct";
9
- import { AsyncResult } from "effect/unstable/reactivity";
10
10
  import type { QueryResult } from "./internal/query-result.js";
11
11
 
12
12
  /*
13
13
  ### Introduction
14
14
 
15
- When using `effect/unstable/reactivity` (atoms) with effect-zero, you will often have atoms which contain a type in the form of
15
+ When using `effect/reactivity` (atoms) with effect-zero, you will often have atoms which contain a type in the form of
16
16
 
17
17
  `AsyncResult<QueryResult<A>, E>`
18
18
 
package/src/server.ts CHANGED
@@ -10,10 +10,8 @@ import * as Data from "effect/Data";
10
10
  import * as Deferred from "effect/Deferred";
11
11
  import * as Effect from "effect/Effect";
12
12
  import * as Exit from "effect/Exit";
13
- import * as Fn from "effect/Function";
14
13
  import type { NodeInspectSymbol } from "effect/Inspectable";
15
14
  import * as Layer from "effect/Layer";
16
- import * as Match from "effect/Match";
17
15
  import * as Option from "effect/Option";
18
16
  import * as Predicate from "effect/Predicate";
19
17
  import * as Rec from "effect/Record";
@@ -22,8 +20,7 @@ import * as Str from "effect/String";
22
20
  import type * as Unify from "effect/Unify";
23
21
  import { TransactCallbackNotInvokedError, TransactInternalError } from "./internal/server.js";
24
22
  import * as ServerSynchronizationContext from "./internal/server-synchronization-context.js";
25
- import { normalizeArgs } from "./internal/utils.js";
26
- import * as Mutators from "./mutators.js";
23
+ import type * as Mutator from "./mutator.js";
27
24
  import { type MakeQueryResult, QueryNameSymbol, RunQuerySymbol } from "./query.js";
28
25
  import type * as ServerTransaction from "./server-transaction.js";
29
26
  import { DatabaseSymbol, ServerTransactionCallbackSymbol } from "./server-transaction.js";
@@ -44,10 +41,14 @@ type _Unify = Unify.typeSymbol | Unify.unifySymbol | Unify.ignoreSymbol;
44
41
  type MutateUserId = Extract<Awaited<ReturnType<typeof handleMutateRequest>>, { kind: "MutateResponse" }>["userID"];
45
42
  type QueryUserId = Extract<Awaited<ReturnType<typeof handleQueryRequest>>, { kind: "QueryResponse" }>["userID"];
46
43
 
44
+ /**
45
+ * Handles Zero's push endpoint, running each mutation's server implementation: its override from
46
+ * `ZfxMutator.server`, or else its shared body in a server transaction.
47
+ */
47
48
  export const handleMutate = Effect.fn(function* <
48
49
  TSchema extends ZeroSchema,
49
50
  TTransaction,
50
- TMutators extends Mutators.AnyMutators,
51
+ TMutators extends Mutator.AnyServerMutators,
51
52
  >({
52
53
  transaction,
53
54
  mutators,
@@ -64,7 +65,7 @@ export const handleMutate = Effect.fn(function* <
64
65
  const ctx =
65
66
  yield* Effect.context<
66
67
  Exclude<
67
- Mutators.ExtractMutatorsRequirements<TMutators>,
68
+ Mutator.ServerRequirements<TMutators>,
68
69
  | (typeof transaction)[typeof ServerTransactionCallbackSymbol]["Identifier"]
69
70
  | ServerSynchronizationContext.ServerSynchronizationContext
70
71
  >
@@ -91,28 +92,31 @@ export const handleMutate = Effect.fn(function* <
91
92
  Effect.onExit((exit) => Deferred.done(exitDeferred, exit)),
92
93
  Effect.catchCause((cause) => Effect.die(makeApplicationError(cause))),
93
94
  Effect.asVoid,
94
- Effect.provide(innerCtx),
95
- (effect) => Effect.runPromise(effect, { signal }),
95
+ (effect) => Effect.runPromiseWith(innerCtx)(effect, { signal }),
96
96
  ),
97
97
  ),
98
98
  catch: (error) => new TransactInternalError({ cause: Cause.fail(error) }),
99
99
  }).pipe(Effect.flatMap((value) => Deferred.succeed(response, value)));
100
100
 
101
101
  return yield* Deferred.poll(exitDeferred).pipe(
102
- Effect.flatMap(Effect.fromOption),
103
- Effect.catchTag("NoSuchElementError", () => Effect.fail(new TransactCallbackNotInvokedError())),
102
+ Effect.flatMap(Effect.fromOption(() => new TransactCallbackNotInvokedError())),
104
103
  Effect.flatten,
105
104
  );
106
105
  }, ServerSynchronizationContext.guard);
107
106
 
108
- const exit: Exit.Exit<unknown, unknown> = yield* runMutation<
109
- Mutators.ExtractMutatorsRequirements<TMutators>
110
- >(mutators, mutation).pipe(
107
+ const exit: Exit.Exit<unknown, unknown> = yield* runMutation(mutators, transaction, mutation).pipe(
111
108
  ServerSynchronizationContext.finalize,
112
109
  Effect.provide([
113
110
  Layer.succeed(transaction[ServerTransactionCallbackSymbol], { transact }),
114
111
  ServerSynchronizationContext.layer,
115
112
  ]),
113
+ Effect.withSpan("zero.mutation.handle", {
114
+ attributes: {
115
+ "zero.mutator": mutation.name,
116
+ "zero.clientID": mutation.clientID,
117
+ "zero.mutationID": mutation.id,
118
+ },
119
+ }),
116
120
  Effect.exit,
117
121
  );
118
122
 
@@ -142,29 +146,41 @@ export const handleMutate = Effect.fn(function* <
142
146
  });
143
147
  });
144
148
 
145
- const runMutation = Effect.fn(function* <R>(mutators: Mutators.AnyMutators<R>, mutation: Mutation) {
146
- // Support both "namespace|name" and "namespace.name" formats, and single-segment names.
147
- const [namespace, name] = mutation.name.includes("|") ? Str.split(mutation.name, "|") : Str.split(mutation.name, ".");
148
-
149
- const mutator = yield* Fn.pipe(
150
- mutators,
151
- Rec.get<string>(namespace),
152
- Option.flatMap((mutator) =>
153
- Match.value([mutator, name]).pipe(
154
- Match.when([Predicate.isObject, Predicate.isString], ([mutator, name]) => Rec.get<string>(name)(mutator)),
155
- Match.when([Predicate.isFunction, Predicate.isUndefined], ([mutator]) => Option.some(mutator)),
156
- Match.orElse(() => Option.none()),
157
- ),
149
+ /**
150
+ * Runs a mutation: its override if it has one, or else its shared body in a server transaction. Like upstream, names
151
+ * are split on "." or "|" (the separator of Zero's legacy custom mutators), at any depth. Their services aren't tracked
152
+ * from here on: `handleMutate` already requires every implementation's.
153
+ */
154
+ const runMutation = Effect.fn(function* <TSchema extends ZeroSchema, TTransaction>(
155
+ mutators: Mutator.AnyServerMutators,
156
+ transaction: ServerTransaction.Context<TSchema, TTransaction>,
157
+ mutation: Mutation,
158
+ ) {
159
+ const { registry, overrides } = Rec.map(mutators, (root) =>
160
+ Arr.reduce(Str.split(mutation.name, /[.|]/), Option.some(root), (tree, key) =>
161
+ // Only an object has keys to walk into; the checks below make sure the walk ended at a mutator.
162
+ tree.pipe(Option.filter(Predicate.isObject), Option.flatMap(Rec.get(key))),
158
163
  ),
159
- Effect.fromOption,
160
- Effect.catchTag("NoSuchElementError", () => Effect.fail(new MutatorNotFoundError({ name: mutation.name }))),
164
+ );
165
+ const mutator = yield* registry.pipe(
166
+ Option.filter(
167
+ (u): u is Mutator.Mutator<unknown, unknown, unknown, never> =>
168
+ Predicate.isFunction(u) && Predicate.hasProperty(u, "mutatorName"),
169
+ ),
170
+ Effect.fromOption(() => new MutatorNotFoundError({ name: mutation.name })),
161
171
  );
162
172
 
163
- const args = yield* Schema.decodeUnknownEffect(mutator[Mutators.MutatorSchemaSymbol])(
164
- normalizeArgs(mutation.args[0]),
165
- ).pipe(Effect.catchTag("SchemaError", (e) => Effect.fail(new ServerArgsParseError({ cause: Cause.fail(e) }))));
173
+ const args = yield* Schema.decodeUnknownEffect(mutator.payload)(mutation.args[0]).pipe(
174
+ Effect.catchTag("SchemaError", (e) => Effect.fail(new ServerArgsParseError({ cause: Cause.fail(e) }))),
175
+ );
166
176
 
167
- return yield* mutator(args);
177
+ return yield* overrides.pipe(
178
+ Option.filter((u): u is (args: unknown) => Effect.Effect<unknown, unknown> => Predicate.isFunction(u)),
179
+ Option.match({
180
+ onNone: () => transaction.execute(mutator.mutator(args)),
181
+ onSome: (run) => run(args),
182
+ }),
183
+ );
168
184
  });
169
185
 
170
186
  export const makeApplicationError = (cause: Cause.Cause<unknown>) => {
@@ -204,8 +220,7 @@ export const handleQuery = Effect.fn(function* <E, R1, R2>({
204
220
  handleQueryRequest({
205
221
  handler: (name, args) =>
206
222
  Arr.findFirst(queries, (q) => q[QueryNameSymbol] === name).pipe(
207
- Effect.fromOption,
208
- Effect.catchTag("NoSuchElementError", () => Effect.fail(new QueryNotFound({ name }))),
223
+ Effect.fromOption(() => new QueryNotFound({ name })),
209
224
  Effect.flatMap((query) => query[RunQuerySymbol]({ _tag: "Encoded", args })),
210
225
  Effect.runSyncWith(ctx),
211
226
  ),
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=client.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../src/client.test.ts"],"names":[],"mappings":""}