effect-zero 0.0.0-alpha.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +187 -0
- package/dist/client.d.ts +47 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +61 -0
- package/dist/client.js.map +1 -0
- package/dist/mutators.d.ts +35 -0
- package/dist/mutators.d.ts.map +1 -0
- package/dist/mutators.js +26 -0
- package/dist/mutators.js.map +1 -0
- package/dist/server.d.ts +188 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +235 -0
- package/dist/server.js.map +1 -0
- package/dist/snapshot.d.ts +10 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +67 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/types.d.ts +174 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +151 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -0
- package/package.json +64 -15
- package/src/client.ts +130 -0
- package/src/mutators.ts +82 -0
- package/src/server.ts +377 -0
- package/src/snapshot.ts +91 -0
- package/src/types.ts +201 -0
- package/src/utils.ts +4 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import type { Database, TransactionProviderHooks, TransactionProviderInput } from "@rocicorp/zero/pg";
|
|
2
|
+
import * as Cause from "effect/Cause";
|
|
3
|
+
import * as Chunk from "effect/Chunk";
|
|
4
|
+
import * as Context from "effect/Context";
|
|
5
|
+
import * as Data from "effect/Data";
|
|
6
|
+
import * as Deferred from "effect/Deferred";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
8
|
+
import * as Exit from "effect/Exit";
|
|
9
|
+
import * as Fn from "effect/Function";
|
|
10
|
+
import * as Match from "effect/Match";
|
|
11
|
+
import * as Option from "effect/Option";
|
|
12
|
+
import type * as ParseResult from "effect/ParseResult";
|
|
13
|
+
import * as Predicate from "effect/Predicate";
|
|
14
|
+
import * as Rec from "effect/Record";
|
|
15
|
+
import * as Runtime from "effect/Runtime";
|
|
16
|
+
import * as Schema from "effect/Schema";
|
|
17
|
+
import * as Stream from "effect/Stream";
|
|
18
|
+
import * as Str from "effect/String";
|
|
19
|
+
import * as SynchronizedRef from "effect/SynchronizedRef";
|
|
20
|
+
import { type AnyMutators, type ExtractMutatorsRequirements, MutatorArgsSchemaSym } from "./mutators.js";
|
|
21
|
+
import {
|
|
22
|
+
type ZeroAppError,
|
|
23
|
+
type ZeroError,
|
|
24
|
+
type ZeroMutation,
|
|
25
|
+
ZeroMutationResponse,
|
|
26
|
+
type ZeroMutationResult,
|
|
27
|
+
type ZeroPushBody,
|
|
28
|
+
type ZeroPushParams,
|
|
29
|
+
type ZeroPushResponse,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
import { prefixId } from "./utils.js";
|
|
32
|
+
|
|
33
|
+
// Updated to: https://github.com/rocicorp/mono/blob/3082c9fa061891067b4bd7dc9fe74f798270d8d7/packages/zero-server/src/push-processor.ts
|
|
34
|
+
|
|
35
|
+
export interface ZeroServerTransactionContext {
|
|
36
|
+
readonly _tag: unique symbol;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// NOTE(zero): Using the "Lifting the Generic" technique.
|
|
40
|
+
// NOTE(zero): Could choose to not accept `Database<T>` as a param and instead create some `ZeroDatabase` service inside the
|
|
41
|
+
// `make` function, which various other functions require. However, this means that the user will have to manually
|
|
42
|
+
// specify the `T` type parameter which seemed a bit clunky.
|
|
43
|
+
export const makeServer = <T, I = never>(options: { database: Database<T>; clientTransaction?: Context.Tag<I, T> }) => {
|
|
44
|
+
const ZeroServerTransactionContext = Context.Tag(prefixId("ZeroServerTransactionContext"))<
|
|
45
|
+
ZeroServerTransactionContext,
|
|
46
|
+
{ transaction: T; transactionHooks: TransactionProviderHooks }
|
|
47
|
+
>();
|
|
48
|
+
|
|
49
|
+
// TODO: Maybe prefix this / suffix this with a tag passed in to `make`?
|
|
50
|
+
const execute = Effect.fn(function* <A, E, R>(effect: Effect.Effect<A, E, R>) {
|
|
51
|
+
const runtime =
|
|
52
|
+
// TODO: Is there a cleaner way to write this?
|
|
53
|
+
yield* Effect.runtime<Exclude<Exclude<R | ZeroTransactionInput, ZeroServerTransactionContext>, I>>();
|
|
54
|
+
const result = yield* Deferred.make<A, E | Effect.Effect.Error<typeof checkAndIncrementLastMutationID>>();
|
|
55
|
+
|
|
56
|
+
const transactionInput = yield* ZeroTransactionInput;
|
|
57
|
+
yield* Effect.tryPromise({
|
|
58
|
+
try: (signal) =>
|
|
59
|
+
options.database.transaction(async (transaction, transactionHooks) => {
|
|
60
|
+
const exit = await Effect.zipRight(checkAndIncrementLastMutationID, effect).pipe(
|
|
61
|
+
Effect.provideService(ZeroServerTransactionContext, { transaction, transactionHooks }),
|
|
62
|
+
options.clientTransaction
|
|
63
|
+
? Effect.provideService(options.clientTransaction, transaction)
|
|
64
|
+
: // TODO: Is there a cleaner way to write this?
|
|
65
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>) => effect as Effect.Effect<A, E, Exclude<R, I>>,
|
|
66
|
+
(effect) => Runtime.runPromiseExit(runtime, effect, { signal }),
|
|
67
|
+
);
|
|
68
|
+
Deferred.unsafeDone(result, exit);
|
|
69
|
+
return Exit.getOrElse(exit, () => {
|
|
70
|
+
// This error's purpose is to differentiate between "external" errors
|
|
71
|
+
// that originate from the user-defined mutator code and "internal" errors
|
|
72
|
+
// that originate from our own code and the Zero API.
|
|
73
|
+
// Both types are caught in the "catch" block below, but at this point we only need to handle
|
|
74
|
+
// the "internal" errors wrapping them in a `ZeroDatabaseError`, because "external" errors
|
|
75
|
+
// are already covered by passing the Exit result to the Deferred, which is why
|
|
76
|
+
// we have the ZeroTransactionUserError silenced below in the pipe.
|
|
77
|
+
throw new ZeroTransactionUserError();
|
|
78
|
+
});
|
|
79
|
+
}, transactionInput),
|
|
80
|
+
catch: (error) => {
|
|
81
|
+
if (ZeroTransactionUserError.is(error)) {
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
// This is for errors that occur when calling `database.transaction` despite the provided `effect` succeeding.
|
|
85
|
+
// This can be caused by e.g. the database connection timing out or other database-related issues.
|
|
86
|
+
return new ZeroDatabaseError({ cause: Cause.fail(error) });
|
|
87
|
+
},
|
|
88
|
+
}).pipe(Effect.catchTag("ZeroTransactionUserError", () => Effect.void));
|
|
89
|
+
|
|
90
|
+
return yield* result;
|
|
91
|
+
}, ZeroServerMutationContext.guard);
|
|
92
|
+
|
|
93
|
+
const use = <A>(fn: (transaction: T, options: { readonly signal: AbortSignal }) => PromiseLike<A>) =>
|
|
94
|
+
Effect.flatMap(ZeroServerTransactionContext, (ctx) =>
|
|
95
|
+
Effect.tryPromise({
|
|
96
|
+
try: (signal) => fn(ctx.transaction, { signal }),
|
|
97
|
+
catch: Fn.identity,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const checkAndIncrementLastMutationID = Effect.gen(function* () {
|
|
102
|
+
const { transactionHooks } = yield* ZeroServerTransactionContext;
|
|
103
|
+
const { clientID, mutationID: receivedMutationID } = yield* ZeroTransactionInput;
|
|
104
|
+
|
|
105
|
+
const { lastMutationID } = yield* Effect.tryPromise({
|
|
106
|
+
try: () => transactionHooks.updateClientMutationID(),
|
|
107
|
+
catch: (error) => new UpdateClientMutationIDError({ cause: Cause.fail(error) }),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (receivedMutationID < lastMutationID) {
|
|
111
|
+
return yield* new MutationAlreadyProcessedError({
|
|
112
|
+
clientID,
|
|
113
|
+
received: receivedMutationID,
|
|
114
|
+
actual: lastMutationID,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (receivedMutationID > lastMutationID) {
|
|
118
|
+
return yield* new OutOfOrderMutationError({
|
|
119
|
+
clientID,
|
|
120
|
+
receivedMutationID,
|
|
121
|
+
lastMutationID,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const processPush = Effect.fn(function* <T extends AnyMutators>(
|
|
127
|
+
mutators: T,
|
|
128
|
+
params: ZeroPushParams,
|
|
129
|
+
request: ZeroPushBody,
|
|
130
|
+
) {
|
|
131
|
+
if (request.pushVersion !== 1) {
|
|
132
|
+
return { error: "unsupportedPushVersion" as const };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const responses = yield* Stream.fromIterable(request.mutations).pipe(
|
|
136
|
+
Stream.mapEffect(
|
|
137
|
+
Effect.fn(function* (mutation) {
|
|
138
|
+
if (mutation.type !== "custom") {
|
|
139
|
+
return yield* new CustomMutationExpectedError({});
|
|
140
|
+
}
|
|
141
|
+
return yield* processMutation<ExtractMutatorsRequirements<T>>(mutators, mutation).pipe(
|
|
142
|
+
Effect.map((result) =>
|
|
143
|
+
ZeroMutationResponse.make({ id: { id: mutation.id, clientID: mutation.clientID }, result }),
|
|
144
|
+
),
|
|
145
|
+
Effect.provideService(ZeroTransactionInput, {
|
|
146
|
+
clientID: mutation.clientID,
|
|
147
|
+
mutationID: mutation.id,
|
|
148
|
+
clientGroupID: request.clientGroupID,
|
|
149
|
+
upstreamSchema: params.schema,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
// We only stop processing if the mutation is out of order.
|
|
155
|
+
// If the mutation has already been processed or if it returns an application error,
|
|
156
|
+
// we continue processing the next mutation.
|
|
157
|
+
Stream.takeUntil(({ result }) => Predicate.hasProperty(result, "error") && result.error === "oooMutation"),
|
|
158
|
+
Stream.runCollect,
|
|
159
|
+
Effect.annotateLogs({ module: prefixId("ZeroServer"), method: "processPush" }),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return { mutations: Chunk.toArray(responses) } satisfies ZeroPushResponse;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const processMutation = Effect.fn(
|
|
166
|
+
function* <R>(mutators: AnyMutators<R>, mutation: ZeroMutation) {
|
|
167
|
+
// Support both "namespace|name" and "namespace.name" formats, and single-segment names.
|
|
168
|
+
const [namespace, name] = mutation.name.includes("|")
|
|
169
|
+
? Str.split(mutation.name, "|")
|
|
170
|
+
: Str.split(mutation.name, ".");
|
|
171
|
+
|
|
172
|
+
const mutator = yield* Fn.pipe(
|
|
173
|
+
mutators,
|
|
174
|
+
Rec.get<string>(namespace),
|
|
175
|
+
Option.flatMap((mutator) =>
|
|
176
|
+
Match.value([mutator, name]).pipe(
|
|
177
|
+
Match.when([Predicate.isRecord, Predicate.isString], ([mutator, name]) => Rec.get<string>(name)(mutator)),
|
|
178
|
+
Match.when([Predicate.isFunction, Predicate.isUndefined], ([mutator]) => Option.some(mutator)),
|
|
179
|
+
Match.orElse(() => Option.none()),
|
|
180
|
+
),
|
|
181
|
+
),
|
|
182
|
+
Effect.catchTag("NoSuchElementException", () => new MutatorNotFoundError({ mutationName: mutation.name })),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const args = yield* Schema.decode(mutator[MutatorArgsSchemaSym])(mutation.args[0]).pipe(
|
|
186
|
+
Effect.catchTag("ParseError", (e) => new ZeroServerArgsParseError({ cause: Cause.fail(e) })),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return yield* mutator(args).pipe(
|
|
190
|
+
// Case #2 "One transaction then fail"
|
|
191
|
+
// If the transaction was executed successfully, swallow the error and just log it, otherwise re-throw it
|
|
192
|
+
Effect.catchAllCause(
|
|
193
|
+
Effect.fn(function* (e) {
|
|
194
|
+
const wasTransactionExecuted = yield* ZeroServerMutationContext.wasTransactionExecuted;
|
|
195
|
+
if (wasTransactionExecuted) {
|
|
196
|
+
return yield* Effect.logError("Error occurred after transaction execution completed", e);
|
|
197
|
+
}
|
|
198
|
+
return yield* Effect.failCause(e);
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
// Case #4 "Zero transactions then succeed"
|
|
202
|
+
// Check that the transaction was executed during the mutation
|
|
203
|
+
Effect.tap(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const wasTransactionExecuted = yield* ZeroServerMutationContext.wasTransactionExecuted;
|
|
206
|
+
if (!wasTransactionExecuted) {
|
|
207
|
+
return yield* new NoTransactionError();
|
|
208
|
+
}
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
Effect.as<ZeroMutationResult>({}),
|
|
212
|
+
Effect.catchIf(OutOfOrderMutationError.is, (e) =>
|
|
213
|
+
Effect.logError(e.message).pipe(Effect.as({ error: "oooMutation", details: e.message } satisfies ZeroError)),
|
|
214
|
+
),
|
|
215
|
+
Effect.catchIf(MutationAlreadyProcessedError.is, (e) =>
|
|
216
|
+
Effect.logWarning(e.message).pipe(
|
|
217
|
+
Effect.as({ error: "alreadyProcessed", details: e.message } satisfies ZeroError),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
// Case #5 "Zero transactions then fail" / #6 "Fail before transaction"
|
|
221
|
+
// Catches all errors that are produced before the transaction is executed
|
|
222
|
+
Effect.catchAllCause((cause) => new ZeroMutationUserError({ cause })),
|
|
223
|
+
);
|
|
224
|
+
},
|
|
225
|
+
Effect.catchAll((e) => processMutationError(e)),
|
|
226
|
+
Effect.provide(ZeroServerMutationContext.Default),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const processMutationError = Effect.fn(function* (e: unknown) {
|
|
230
|
+
const { clientID, mutationID } = yield* ZeroTransactionInput;
|
|
231
|
+
|
|
232
|
+
yield* Effect.logError(`Unexpected error processing mutation ${mutationID} for client ${clientID}`, Cause.fail(e));
|
|
233
|
+
|
|
234
|
+
const errorMessage = Match.value(e).pipe(
|
|
235
|
+
Match.when(ZeroMutationUserError.is, (e) => e.message),
|
|
236
|
+
Match.orElse(() => "Internal error"),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const appError = {
|
|
240
|
+
error: "app",
|
|
241
|
+
details: errorMessage,
|
|
242
|
+
} satisfies ZeroAppError;
|
|
243
|
+
|
|
244
|
+
yield* execute(
|
|
245
|
+
Effect.flatMap(ZeroServerTransactionContext, ({ transactionHooks }) => {
|
|
246
|
+
return Effect.tryPromise({
|
|
247
|
+
try: () => transactionHooks.writeMutationResult({ id: { id: mutationID, clientID }, result: appError }),
|
|
248
|
+
catch: (error) => new WriteMutationResultError({ cause: Cause.fail(error) }),
|
|
249
|
+
});
|
|
250
|
+
}),
|
|
251
|
+
).pipe(Effect.catchAllCause(Effect.logError));
|
|
252
|
+
|
|
253
|
+
yield* Effect.logWarning(`Mutation ${mutationID} for client ${clientID} was retried after an error: ${e}`);
|
|
254
|
+
|
|
255
|
+
return appError;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
Transaction: Object.assign(ZeroServerTransactionContext, { execute, use }),
|
|
260
|
+
processPush,
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
class ZeroTransactionInput extends Context.Tag(prefixId("ZeroTransactionInput"))<
|
|
265
|
+
ZeroTransactionInput,
|
|
266
|
+
TransactionProviderInput
|
|
267
|
+
>() {}
|
|
268
|
+
|
|
269
|
+
class ZeroServerMutationContext extends Effect.Service<ZeroServerMutationContext>()(
|
|
270
|
+
prefixId("ZeroServerMutationContext"),
|
|
271
|
+
{
|
|
272
|
+
effect: Effect.gen(function* () {
|
|
273
|
+
return { wasTransactionExecuted: yield* SynchronizedRef.make(false) };
|
|
274
|
+
}),
|
|
275
|
+
},
|
|
276
|
+
) {
|
|
277
|
+
static wasTransactionExecuted = Effect.flatMap(ZeroServerMutationContext, (ctx) => ctx.wasTransactionExecuted);
|
|
278
|
+
|
|
279
|
+
// Ensures that only one transaction is executed at a time and checks that another transaction wasn't already executed.
|
|
280
|
+
static guard = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
281
|
+
Effect.flatMap(this, ({ wasTransactionExecuted }) =>
|
|
282
|
+
SynchronizedRef.modifyEffect(
|
|
283
|
+
wasTransactionExecuted,
|
|
284
|
+
Effect.fn(function* (wasTransactionExecuted) {
|
|
285
|
+
if (wasTransactionExecuted) {
|
|
286
|
+
return yield* new MultipleTransactionsError();
|
|
287
|
+
}
|
|
288
|
+
const result = yield* effect;
|
|
289
|
+
return [result, true];
|
|
290
|
+
}),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
class ZeroDatabaseError extends Data.TaggedError("ZeroDatabaseError")<{
|
|
296
|
+
cause: Cause.Cause<unknown>;
|
|
297
|
+
}> {}
|
|
298
|
+
|
|
299
|
+
class UpdateClientMutationIDError extends Data.TaggedError("UpdateClientMutationIDError")<{
|
|
300
|
+
readonly cause: Cause.Cause<unknown>;
|
|
301
|
+
}> {}
|
|
302
|
+
|
|
303
|
+
class MutatorNotFoundError extends Data.TaggedError("MutatorNotFoundError")<{
|
|
304
|
+
mutationName: string;
|
|
305
|
+
}> {
|
|
306
|
+
override message = `Mutator not found for mutation ${this.mutationName}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
class WriteMutationResultError extends Data.TaggedError("WriteMutationResultError")<{
|
|
310
|
+
readonly cause: Cause.Cause<unknown>;
|
|
311
|
+
}> {}
|
|
312
|
+
class CustomMutationExpectedError extends Data.TaggedError("CustomMutationExpectedError")<object> {}
|
|
313
|
+
|
|
314
|
+
export class MultipleTransactionsError extends Data.TaggedError("MultipleTransactionsError") {
|
|
315
|
+
override message = "Multiple transactions detected in a mutation, only one transaction is allowed.";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class NoTransactionError extends Data.TaggedError("NoTransactionError") {
|
|
319
|
+
override message = "No transaction detected in a mutation, a transaction is required.";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const OutOfOrderMutationErrorTypeId = Symbol.for(prefixId("OutOfOrderMutationError"));
|
|
323
|
+
export class OutOfOrderMutationError extends Data.TaggedError("OutOfOrderMutationError")<{
|
|
324
|
+
readonly clientID: string;
|
|
325
|
+
readonly receivedMutationID: number;
|
|
326
|
+
readonly lastMutationID: number | bigint;
|
|
327
|
+
}> {
|
|
328
|
+
readonly [OutOfOrderMutationErrorTypeId] = OutOfOrderMutationErrorTypeId;
|
|
329
|
+
override get message() {
|
|
330
|
+
return `Client ${this.clientID} sent mutation ID ${this.receivedMutationID} but expected ${this.lastMutationID}`;
|
|
331
|
+
}
|
|
332
|
+
static is(e: unknown): e is OutOfOrderMutationError {
|
|
333
|
+
return Predicate.hasProperty(e, OutOfOrderMutationErrorTypeId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const MutationAlreadyProcessedErrorTypeId = Symbol.for(prefixId("MutationAlreadyProcessedError"));
|
|
338
|
+
export class MutationAlreadyProcessedError extends Data.TaggedError("MutationAlreadyProcessedError")<{
|
|
339
|
+
readonly clientID: string;
|
|
340
|
+
readonly received: number;
|
|
341
|
+
readonly actual: number | bigint;
|
|
342
|
+
}> {
|
|
343
|
+
readonly [MutationAlreadyProcessedErrorTypeId] = MutationAlreadyProcessedErrorTypeId;
|
|
344
|
+
override get message() {
|
|
345
|
+
return `Ignoring mutation from ${this.clientID} with ID ${this.received} as it was already processed. Expected: ${this.actual}`;
|
|
346
|
+
}
|
|
347
|
+
static is(e: unknown): e is MutationAlreadyProcessedError {
|
|
348
|
+
return Predicate.hasProperty(e, MutationAlreadyProcessedErrorTypeId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ZeroTransactionUserErrorTypeId = Symbol.for(prefixId("ZeroTransactionUserError"));
|
|
353
|
+
export class ZeroTransactionUserError extends Data.TaggedError("ZeroTransactionUserError") {
|
|
354
|
+
readonly [ZeroTransactionUserErrorTypeId] = ZeroTransactionUserErrorTypeId;
|
|
355
|
+
static is(e: unknown): e is ZeroTransactionUserError {
|
|
356
|
+
return Predicate.hasProperty(e, ZeroTransactionUserErrorTypeId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const ZeroMutationUserErrorTypeId = Symbol.for(prefixId("ZeroMutationUserError"));
|
|
361
|
+
export class ZeroMutationUserError extends Data.TaggedError("ZeroMutationUserError")<{
|
|
362
|
+
cause: Cause.Cause<unknown>;
|
|
363
|
+
}> {
|
|
364
|
+
readonly [ZeroMutationUserErrorTypeId] = ZeroMutationUserErrorTypeId;
|
|
365
|
+
static is(e: unknown): e is ZeroMutationUserError {
|
|
366
|
+
return Predicate.hasProperty(e, ZeroMutationUserErrorTypeId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
override get message() {
|
|
370
|
+
const err = Cause.squash(this.cause);
|
|
371
|
+
return err instanceof Error ? err.message : "exception was not of type `Error`";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
class ZeroServerArgsParseError extends Data.TaggedError("ZeroServerArgsParseError")<{
|
|
376
|
+
cause: Cause.Cause<ParseResult.ParseError>;
|
|
377
|
+
}> {}
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// TODO(zero): clean this up
|
|
2
|
+
|
|
3
|
+
// Code borrowed from:
|
|
4
|
+
// https://github.com/rocicorp/mono/blob/30f209f2946b4cf2cd2dee459849351498f11308/packages/zero-react/src/use-query.tsx
|
|
5
|
+
// https://github.com/rocicorp/mono/blob/30f209f2946b4cf2cd2dee459849351498f11308/packages/shared/src/deep-clone.ts#L4
|
|
6
|
+
|
|
7
|
+
import type { JSONValue, ReadonlyJSONValue } from "@rocicorp/zero";
|
|
8
|
+
import type { HumanReadable, QueryResult } from "@rocicorp/zero/react";
|
|
9
|
+
|
|
10
|
+
const emptyArray: unknown[] = [];
|
|
11
|
+
|
|
12
|
+
const resultTypeUnknown = { type: "unknown" } as const;
|
|
13
|
+
const resultTypeComplete = { type: "complete" } as const;
|
|
14
|
+
|
|
15
|
+
const emptySnapshotSingularUnknown = [undefined, resultTypeUnknown] as const;
|
|
16
|
+
const emptySnapshotSingularComplete = [undefined, resultTypeComplete] as const;
|
|
17
|
+
const emptySnapshotPluralUnknown = [emptyArray, resultTypeUnknown] as const;
|
|
18
|
+
const emptySnapshotPluralComplete = [emptyArray, resultTypeComplete] as const;
|
|
19
|
+
|
|
20
|
+
export function getDefaultSnapshot<TReturn>(singular: boolean): QueryResult<TReturn> {
|
|
21
|
+
return (singular ? emptySnapshotSingularUnknown : emptySnapshotPluralUnknown) as QueryResult<TReturn>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns a new snapshot or one of the empty predefined ones. Returning the
|
|
26
|
+
* predefined ones is important to prevent unnecessary re-renders in React.
|
|
27
|
+
*/
|
|
28
|
+
export function getSnapshot<TReturn>(
|
|
29
|
+
singular: boolean,
|
|
30
|
+
data: HumanReadable<TReturn>,
|
|
31
|
+
resultType: string,
|
|
32
|
+
): QueryResult<TReturn> {
|
|
33
|
+
if (singular && data === undefined) {
|
|
34
|
+
return (resultType === "complete"
|
|
35
|
+
? emptySnapshotSingularComplete
|
|
36
|
+
: emptySnapshotSingularUnknown) as unknown as QueryResult<TReturn>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!singular && (data as unknown[]).length === 0) {
|
|
40
|
+
return (
|
|
41
|
+
resultType === "complete" ? emptySnapshotPluralComplete : emptySnapshotPluralUnknown
|
|
42
|
+
) as QueryResult<TReturn>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [data, resultType === "complete" ? resultTypeComplete : resultTypeUnknown];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deepClone(value: ReadonlyJSONValue): JSONValue {
|
|
49
|
+
const seen: Array<ReadonlyJSONValue> = [];
|
|
50
|
+
return internalDeepClone(value, seen);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function internalDeepClone(value: ReadonlyJSONValue, seen: Array<ReadonlyJSONValue>): JSONValue {
|
|
54
|
+
switch (typeof value) {
|
|
55
|
+
case "boolean":
|
|
56
|
+
case "number":
|
|
57
|
+
case "string":
|
|
58
|
+
case "undefined":
|
|
59
|
+
return value;
|
|
60
|
+
case "object": {
|
|
61
|
+
if (value === null) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (seen.includes(value)) {
|
|
65
|
+
throw new Error("Cyclic object");
|
|
66
|
+
}
|
|
67
|
+
seen.push(value);
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
const rv = value.map((v) => internalDeepClone(v, seen));
|
|
70
|
+
seen.pop();
|
|
71
|
+
return rv;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const obj: JSONValue = {};
|
|
75
|
+
|
|
76
|
+
for (const k in value) {
|
|
77
|
+
if (Object.hasOwn(value, k)) {
|
|
78
|
+
const v = (value as Record<string, ReadonlyJSONValue>)[k];
|
|
79
|
+
if (v !== undefined) {
|
|
80
|
+
obj[k] = internalDeepClone(v, seen);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
seen.pop();
|
|
85
|
+
return obj;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
default:
|
|
89
|
+
throw new Error(`Invalid type: ${typeof value}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
|
|
3
|
+
// The code below was converted to Effect Schema from:
|
|
4
|
+
// https://github.com/rocicorp/mono/blob/30f209f2946b4cf2cd2dee459849351498f11308/packages/zero-protocol/src/push.ts
|
|
5
|
+
|
|
6
|
+
/** TODO: Defined as unknown for now */
|
|
7
|
+
const JsonSchema = Schema.Unknown;
|
|
8
|
+
|
|
9
|
+
const ZeroPrimaryKey = Schema.Array(Schema.String);
|
|
10
|
+
|
|
11
|
+
const ZeroPrimaryKeyValue = Schema.Union(Schema.String, Schema.Number, Schema.Boolean);
|
|
12
|
+
|
|
13
|
+
const ZeroPrimaryKeyValueRecord = Schema.Record({ key: Schema.String, value: ZeroPrimaryKeyValue });
|
|
14
|
+
|
|
15
|
+
const ZeroValue = Schema.Union(JsonSchema, Schema.Undefined);
|
|
16
|
+
const ZeroRow = Schema.Record({ key: Schema.String, value: ZeroValue });
|
|
17
|
+
|
|
18
|
+
const CRUD_MUTATION_NAME = "_zero_crud";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Inserts if entity with id does not already exist.
|
|
22
|
+
*/
|
|
23
|
+
const ZeroInsertOp = Schema.Struct({
|
|
24
|
+
op: Schema.Literal("insert"),
|
|
25
|
+
tableName: Schema.String,
|
|
26
|
+
primaryKey: ZeroPrimaryKey,
|
|
27
|
+
value: ZeroRow,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Upsert semantics. Inserts if entity with id does not already exist,
|
|
32
|
+
* otherwise updates existing entity with id.
|
|
33
|
+
*/
|
|
34
|
+
const ZeroUpsertOp = Schema.Struct({
|
|
35
|
+
op: Schema.Literal("upsert"),
|
|
36
|
+
tableName: Schema.String,
|
|
37
|
+
primaryKey: ZeroPrimaryKey,
|
|
38
|
+
value: ZeroRow,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Updates if entity with id exists, otherwise does nothing.
|
|
43
|
+
*/
|
|
44
|
+
const ZeroUpdateOp = Schema.Struct({
|
|
45
|
+
op: Schema.Literal("update"),
|
|
46
|
+
tableName: Schema.String,
|
|
47
|
+
primaryKey: ZeroPrimaryKey,
|
|
48
|
+
// Partial value with at least the primary key fields
|
|
49
|
+
value: ZeroRow,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Deletes entity with id if it exists, otherwise does nothing.
|
|
54
|
+
*/
|
|
55
|
+
const ZeroDeleteOp = Schema.Struct({
|
|
56
|
+
op: Schema.Literal("delete"),
|
|
57
|
+
tableName: Schema.String,
|
|
58
|
+
primaryKey: ZeroPrimaryKey,
|
|
59
|
+
// Partial value representing the primary key
|
|
60
|
+
value: ZeroPrimaryKeyValueRecord,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const ZeroCrudOp = Schema.Union(ZeroInsertOp, ZeroUpsertOp, ZeroUpdateOp, ZeroDeleteOp);
|
|
64
|
+
|
|
65
|
+
const ZeroCrudArg = Schema.Struct({
|
|
66
|
+
ops: Schema.Array(ZeroCrudOp),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const ZeroCrudArgs = Schema.Tuple(ZeroCrudArg);
|
|
70
|
+
|
|
71
|
+
const ZeroCrudMutation = Schema.Struct({
|
|
72
|
+
type: Schema.Literal("crud"),
|
|
73
|
+
id: Schema.Number,
|
|
74
|
+
clientID: Schema.String,
|
|
75
|
+
name: Schema.Literal(CRUD_MUTATION_NAME),
|
|
76
|
+
args: ZeroCrudArgs,
|
|
77
|
+
timestamp: Schema.Number,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const ZeroCustomMutation = Schema.Struct({
|
|
81
|
+
type: Schema.Literal("custom"),
|
|
82
|
+
id: Schema.Number,
|
|
83
|
+
clientID: Schema.String,
|
|
84
|
+
name: Schema.String,
|
|
85
|
+
args: Schema.Array(JsonSchema),
|
|
86
|
+
timestamp: Schema.Number,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const ZeroMutation = Schema.Union(ZeroCrudMutation, ZeroCustomMutation);
|
|
90
|
+
export type ZeroMutation = typeof ZeroMutation.Type;
|
|
91
|
+
|
|
92
|
+
export const ZeroPushBody = Schema.Struct({
|
|
93
|
+
clientGroupID: Schema.String,
|
|
94
|
+
mutations: Schema.Array(ZeroMutation),
|
|
95
|
+
pushVersion: Schema.Number,
|
|
96
|
+
// For legacy (CRUD) mutations, the schema is tied to the client group /
|
|
97
|
+
// sync connection. For custom mutations, schema versioning is delegated
|
|
98
|
+
// to the custom protocol / api-server.
|
|
99
|
+
schemaVersion: Schema.optional(Schema.Number),
|
|
100
|
+
timestamp: Schema.Number,
|
|
101
|
+
requestID: Schema.String,
|
|
102
|
+
});
|
|
103
|
+
export type ZeroPushBody = typeof ZeroPushBody.Type;
|
|
104
|
+
|
|
105
|
+
// biome-ignore lint/correctness/noUnusedVariables: borrowed code
|
|
106
|
+
const ZeroPushMessage = Schema.Tuple(Schema.Literal("push"), ZeroPushBody);
|
|
107
|
+
|
|
108
|
+
const ZeroMutationId = Schema.Struct({
|
|
109
|
+
id: Schema.Number,
|
|
110
|
+
clientID: Schema.String,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export const ZeroAppError = Schema.Struct({
|
|
114
|
+
error: Schema.Literal("app"),
|
|
115
|
+
// The user can return any additional data here
|
|
116
|
+
details: Schema.optional(JsonSchema),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export type ZeroAppError = typeof ZeroAppError.Type;
|
|
120
|
+
|
|
121
|
+
export const ZeroError = Schema.Struct({
|
|
122
|
+
error: Schema.Union(Schema.Literal("oooMutation"), Schema.Literal("alreadyProcessed")),
|
|
123
|
+
details: Schema.optional(JsonSchema),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export type ZeroError = typeof ZeroError.Type;
|
|
127
|
+
|
|
128
|
+
const ZeroMutationOk = Schema.Struct({
|
|
129
|
+
// The user can return any additional data here
|
|
130
|
+
data: Schema.optional(JsonSchema),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const ZeroMutationError = Schema.Union(ZeroAppError, ZeroError);
|
|
134
|
+
|
|
135
|
+
// We flip the original order here as otherwise values of type ZeroMutationError would get parsed as ZeroMutationOk with empty `data`
|
|
136
|
+
export const ZeroMutationResult = Schema.Union(ZeroMutationError, ZeroMutationOk);
|
|
137
|
+
export type ZeroMutationResult = typeof ZeroMutationResult.Type;
|
|
138
|
+
|
|
139
|
+
export const ZeroMutationResponse = Schema.Struct({
|
|
140
|
+
id: ZeroMutationId,
|
|
141
|
+
result: ZeroMutationResult,
|
|
142
|
+
});
|
|
143
|
+
export type ZeroMutationResponse = typeof ZeroMutationResponse.Type;
|
|
144
|
+
|
|
145
|
+
const ZeroPushOk = Schema.Struct({
|
|
146
|
+
mutations: Schema.Array(ZeroMutationResponse),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const ZeroUnsupportedPushVersion = Schema.Struct({
|
|
150
|
+
error: Schema.Literal("unsupportedPushVersion"),
|
|
151
|
+
// optional for backwards compatibility
|
|
152
|
+
// This field is included so the client knows which mutations
|
|
153
|
+
// were not processed by the server.
|
|
154
|
+
mutationIDs: Schema.optional(Schema.Array(ZeroMutationId)),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const ZeroUnsupportedSchemaVersion = Schema.Struct({
|
|
158
|
+
error: Schema.Literal("unsupportedSchemaVersion"),
|
|
159
|
+
// optional for backwards compatibility
|
|
160
|
+
// This field is included so the client knows which mutations
|
|
161
|
+
// were not processed by the server.
|
|
162
|
+
mutationIDs: Schema.optional(Schema.Array(ZeroMutationId)),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const ZeroHttpError = Schema.Struct({
|
|
166
|
+
error: Schema.Literal("http"),
|
|
167
|
+
status: Schema.Number,
|
|
168
|
+
details: Schema.String,
|
|
169
|
+
mutationIDs: Schema.optional(Schema.Array(ZeroMutationId)),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const ZeroPusherError = Schema.Struct({
|
|
173
|
+
error: Schema.Literal("zeroPusher"),
|
|
174
|
+
details: Schema.String,
|
|
175
|
+
mutationIDs: Schema.optional(Schema.Array(ZeroMutationId)),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const ZeroPushError = Schema.Union(
|
|
179
|
+
ZeroUnsupportedPushVersion,
|
|
180
|
+
ZeroUnsupportedSchemaVersion,
|
|
181
|
+
ZeroHttpError,
|
|
182
|
+
ZeroPusherError,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
export const ZeroPushResponse = Schema.Union(ZeroPushOk, ZeroPushError);
|
|
186
|
+
export type ZeroPushResponse = typeof ZeroPushResponse.Type;
|
|
187
|
+
|
|
188
|
+
// biome-ignore lint/correctness/noUnusedVariables: borrowed code
|
|
189
|
+
const ZeroPushResponseMessage = Schema.Tuple(Schema.Literal("pushResponse"), ZeroPushResponse);
|
|
190
|
+
|
|
191
|
+
// biome-ignore lint/correctness/noUnusedVariables: borrowed code
|
|
192
|
+
const ZeroAckMutationResponsesMessage = Schema.Tuple(Schema.Literal("ackMutationResponses"), ZeroMutationId);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The schema for the querystring parameters of the custom push endpoint.
|
|
196
|
+
*/
|
|
197
|
+
export const ZeroPushParams = Schema.Struct({
|
|
198
|
+
schema: Schema.String,
|
|
199
|
+
appID: Schema.String,
|
|
200
|
+
});
|
|
201
|
+
export type ZeroPushParams = typeof ZeroPushParams.Type;
|
package/src/utils.ts
ADDED