zero-server 0.0.1__py3-none-any.whl

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 (50) hide show
  1. zero_server/__init__.py +36 -0
  2. zero_server/codegen.py +133 -0
  3. zero_server/integrations/__init__.py +1 -0
  4. zero_server/integrations/starlette.py +115 -0
  5. zero_server/mutators.py +87 -0
  6. zero_server/protocol/__init__.py +1 -0
  7. zero_server/protocol/application_error.py +36 -0
  8. zero_server/protocol/ast.py +342 -0
  9. zero_server/protocol/custom_queries.py +51 -0
  10. zero_server/protocol/error.py +119 -0
  11. zero_server/protocol/mutate_server.py +33 -0
  12. zero_server/protocol/mutation.py +127 -0
  13. zero_server/protocol/mutation_id.py +12 -0
  14. zero_server/protocol/push.py +25 -0
  15. zero_server/protocol/query_server.py +29 -0
  16. zero_server/protocol/wire.py +88 -0
  17. zero_server/py.typed +0 -0
  18. zero_server/query/__init__.py +1 -0
  19. zero_server/query/builder.py +338 -0
  20. zero_server/query/expression.py +167 -0
  21. zero_server/query/registry.py +143 -0
  22. zero_server/schema.py +142 -0
  23. zero_server/server/__init__.py +1 -0
  24. zero_server/server/adapters/__init__.py +1 -0
  25. zero_server/server/adapters/asyncpg.py +43 -0
  26. zero_server/server/adapters/psycopg.py +55 -0
  27. zero_server/server/adapters/sqlalchemy.py +49 -0
  28. zero_server/server/crud.py +134 -0
  29. zero_server/server/pg_query_executor.py +70 -0
  30. zero_server/server/pg_types.py +69 -0
  31. zero_server/server/process_mutations.py +412 -0
  32. zero_server/server/process_queries.py +109 -0
  33. zero_server/server/push_processor.py +49 -0
  34. zero_server/server/server_schema.py +165 -0
  35. zero_server/server/transaction.py +70 -0
  36. zero_server/server/zql_database.py +129 -0
  37. zero_server/shared/__init__.py +1 -0
  38. zero_server/shared/asserts.py +19 -0
  39. zero_server/shared/error.py +97 -0
  40. zero_server/shared/js.py +121 -0
  41. zero_server/shared/json.py +27 -0
  42. zero_server/shared/registry.py +64 -0
  43. zero_server/z2s/__init__.py +1 -0
  44. zero_server/z2s/compiler.py +375 -0
  45. zero_server/z2s/sql.py +130 -0
  46. zero_server-0.0.1.dist-info/METADATA +134 -0
  47. zero_server-0.0.1.dist-info/RECORD +50 -0
  48. zero_server-0.0.1.dist-info/WHEEL +4 -0
  49. zero_server-0.0.1.dist-info/entry_points.txt +3 -0
  50. zero_server-0.0.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,69 @@
1
+ """Postgres column types as Zero sees them.
2
+
3
+ Ported from rocicorp/mono `packages/zero-cache/src/types/pg-data-type.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ from zero_server.schema import ValueType
10
+
11
+ NUMERIC: Final = frozenset(
12
+ {
13
+ "smallint", "integer", "int", "int2", "int4", "int8", "bigint", "smallserial", "serial", "serial2",
14
+ "serial4", "serial8", "bigserial", "decimal", "numeric", "real", "double precision", "float", "float4",
15
+ "float8",
16
+ }
17
+ ) # fmt: skip
18
+ NATIVE_STRING: Final = frozenset({"bpchar", "character", "character varying", "text", "varchar"})
19
+ TEXT_REPRESENTED: Final = frozenset(
20
+ {
21
+ "cidr", "ean13", "inet", "isbn", "isbn13", "ismn", "ismn13", "issn", "issn13", "macaddr", "macaddr8",
22
+ "pg_lsn", "upc", "uuid",
23
+ }
24
+ ) # fmt: skip
25
+ DATE_TIME: Final = frozenset(
26
+ {
27
+ "date", "time", "timetz", "time with time zone", "time without time zone", "timestamp", "timestamptz",
28
+ "timestamp with time zone", "timestamp without time zone",
29
+ }
30
+ ) # fmt: skip
31
+
32
+
33
+ def _lookup(pg_type: str) -> str:
34
+ """Lowercased, with any arguments (the `(32)` of `char(32)`) removed."""
35
+ lowered = pg_type.lower()
36
+ start = lowered.find("(")
37
+ return lowered if start == -1 else lowered[:start]
38
+
39
+
40
+ def is_number(pg_type: str) -> bool:
41
+ return _lookup(pg_type) in NUMERIC
42
+
43
+
44
+ def is_native_string(pg_type: str) -> bool:
45
+ return _lookup(pg_type) in NATIVE_STRING
46
+
47
+
48
+ def is_text_represented(pg_type: str) -> bool:
49
+ return _lookup(pg_type) in TEXT_REPRESENTED
50
+
51
+
52
+ def is_string(pg_type: str) -> bool:
53
+ return is_native_string(pg_type) or is_text_represented(pg_type)
54
+
55
+
56
+ def zql_value_type(pg_type: str, is_enum: bool, is_array: bool) -> ValueType | None:
57
+ """The Zero type a Postgres column must be declared as, or `None` if Zero does not support it."""
58
+ if is_array:
59
+ return "json"
60
+ key = _lookup(pg_type)
61
+ if key in NUMERIC or key in DATE_TIME:
62
+ return "number"
63
+ if key in NATIVE_STRING or key in TEXT_REPRESENTED:
64
+ return "string"
65
+ if key in ("bool", "boolean"):
66
+ return "boolean"
67
+ if key in ("json", "jsonb"):
68
+ return "json"
69
+ return "string" if is_enum else None
@@ -0,0 +1,412 @@
1
+ """The mutate endpoint: exactly-once processing of a push from zero-cache.
2
+
3
+ Ported from rocicorp/mono `packages/zero-server/src/process-mutations.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482), with `MutationAlreadyProcessedError` from
5
+ `packages/zero-cache/src/services/mutagen/error.ts`. Apache-2.0, Copyright Rocicorp.
6
+
7
+ Each mutation runs inside the same database transaction that bumps its client's `lastMutationID`.
8
+ The bump comes first and the comparison second, so a duplicate or a gap is found after the bump and
9
+ undone by rolling that transaction back. A mutation whose mutator fails is retried without the
10
+ mutator: the second transaction takes the ID and records the error in the `mutations` table, which is
11
+ where the client's pending mutation learns it failed.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ from collections.abc import Awaitable, Callable, Mapping
17
+ from dataclasses import dataclass
18
+ from typing import Any, Literal, Protocol
19
+
20
+ from pydantic import TypeAdapter, ValidationError
21
+
22
+ from zero_server.protocol.application_error import ApplicationError, wrap_with_application_error
23
+ from zero_server.protocol.error import ErrorReason, ServerPushFailed
24
+ from zero_server.protocol.mutate_server import MutateParams, MutateSuccess
25
+ from zero_server.protocol.mutation import (
26
+ CLEANUP_RESULTS_MUTATION_NAME,
27
+ CleanupResultsArg,
28
+ CleanupResultsBulkArg,
29
+ CustomMutation,
30
+ MutationAppError,
31
+ MutationOk,
32
+ MutationResponse,
33
+ MutationZeroError,
34
+ )
35
+ from zero_server.protocol.mutation_id import MutationID
36
+ from zero_server.protocol.push import PushBody
37
+ from zero_server.protocol.wire import parse_wire
38
+ from zero_server.shared.asserts import check
39
+ from zero_server.shared.error import get_error_details, get_error_message
40
+ from zero_server.shared.js import js_number, js_truthy
41
+ from zero_server.shared.json import JSONValue
42
+
43
+ logger = logging.getLogger("zero_server.mutate")
44
+
45
+ _cleanup_results_arg: TypeAdapter[CleanupResultsArg] = TypeAdapter(CleanupResultsArg)
46
+
47
+
48
+ class TransactionProviderHooks(Protocol):
49
+ """The three statements against zero-cache's bookkeeping tables, run in the mutation's transaction."""
50
+
51
+ async def update_client_mutation_id(self) -> int: ...
52
+
53
+ async def write_mutation_result(self, result: MutationResponse) -> None: ...
54
+
55
+ async def delete_mutation_results(self, args: CleanupResultsArg) -> None: ...
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class TransactionProviderInput:
60
+ upstream_schema: str
61
+ client_group_id: str
62
+ client_id: str
63
+ mutation_id: int | float
64
+
65
+
66
+ class Database[T](Protocol):
67
+ """A database `handle_mutate_request` can run transactions against.
68
+
69
+ `transaction` opens one transaction, calls `callback` inside it with the app's transaction object and
70
+ the bookkeeping hooks, and commits when `callback` returns.
71
+ """
72
+
73
+ async def transaction[R](
74
+ self,
75
+ callback: Callable[[T, TransactionProviderHooks], Awaitable[R]],
76
+ transaction_input: TransactionProviderInput | None = None,
77
+ ) -> R: ...
78
+
79
+
80
+ type TransactFnCallback[T] = Callable[[T, str, JSONValue | None], Awaitable[None]]
81
+ type TransactFn[T] = Callable[[TransactFnCallback[T]], Awaitable[MutationResponse]]
82
+ type MutateRequestHandler[T] = Callable[[TransactFn[T], CustomMutation], Awaitable[MutationResponse]]
83
+ """Runs once per custom mutation.
84
+
85
+ Call `transact` to run the mutation's database work. Raising before `transact` records an application
86
+ error for the mutation; raising after it only reaches the log, because the mutation has committed.
87
+ """
88
+
89
+ type MutateResponse = MutateSuccess | ServerPushFailed
90
+
91
+ type _DatabaseTransactionPhase = Literal["open", "execute"]
92
+ type _MutationPhase = Literal["preTransaction", "transactionPending", "postCommit"]
93
+
94
+
95
+ class OutOfOrderMutation(Exception):
96
+ error_name = "Error"
97
+
98
+ def __init__(self, client_id: str, received_mutation_id: float, last_mutation_id: float) -> None:
99
+ super().__init__(
100
+ f"Client {client_id} sent mutation ID {js_number(received_mutation_id)} "
101
+ f"but expected {js_number(last_mutation_id)}"
102
+ )
103
+
104
+
105
+ class MutationAlreadyProcessedError(Exception):
106
+ error_name = "Error"
107
+
108
+ def __init__(self, client_id: str, received: float, actual: float) -> None:
109
+ super().__init__(
110
+ f"Ignoring mutation from {client_id} with ID {js_number(received)} as it was already processed. "
111
+ f"Expected: {js_number(actual)}"
112
+ )
113
+ check(
114
+ received < actual,
115
+ f"MutationAlreadyProcessedError: received ({js_number(received)}) must be < actual ({js_number(actual)})",
116
+ )
117
+
118
+
119
+ class DatabaseTransactionError(Exception):
120
+ def __init__(self, phase: _DatabaseTransactionPhase, cause: BaseException) -> None:
121
+ message = (
122
+ f"Failed to open database transaction: {get_error_message(cause)}"
123
+ if phase == "open"
124
+ else f"Database transaction failed after opening: {get_error_message(cause)}"
125
+ )
126
+ super().__init__(message)
127
+ self.__cause__ = cause
128
+
129
+
130
+ _PASSES_THROUGH = (DatabaseTransactionError, OutOfOrderMutation, MutationAlreadyProcessedError, ApplicationError)
131
+
132
+
133
+ async def _application_error_wrapper[R](fn: Callable[[], Awaitable[R]]) -> R:
134
+ try:
135
+ return await fn()
136
+ except _PASSES_THROUGH:
137
+ raise
138
+ except Exception as error:
139
+ raise wrap_with_application_error(error) from error
140
+
141
+
142
+ def _refuse_constant(name: str) -> None:
143
+ raise ValueError(f"{name} is not valid JSON")
144
+
145
+
146
+ def _parse_failure(
147
+ subject: str, error: BaseException, mutation_ids: list[MutationID], *, syntax: bool = False
148
+ ) -> ServerPushFailed:
149
+ return ServerPushFailed(
150
+ reason=ErrorReason.PARSE,
151
+ message=f"Failed to parse {subject}: {get_error_message(error)}",
152
+ mutationIDs=mutation_ids,
153
+ # Upstream's parsers throw a `TypeError`, and `JSON.parse` a `SyntaxError`.
154
+ details={"name": "SyntaxError" if syntax else "TypeError"},
155
+ )
156
+
157
+
158
+ async def handle_mutate_request[T](
159
+ *,
160
+ db_provider: Database[T],
161
+ handler: MutateRequestHandler[T],
162
+ query: Mapping[str, str],
163
+ body: object,
164
+ user_id: str | None,
165
+ ) -> MutateResponse:
166
+ """Process one `/mutate` request from zero-cache.
167
+
168
+ `query` is the request's query string and `body` its JSON, either raw bytes or already parsed.
169
+ `user_id` is the authenticated user, `None` when logged out; reporting it back is what makes
170
+ zero-cache treat the connection's user as server-validated.
171
+ """
172
+ return await handle_mutate(db_provider, handler, query, body, user_id, report_user_id=True)
173
+
174
+
175
+ async def handle_mutate[T](
176
+ db_provider: Database[T],
177
+ handler: MutateRequestHandler[T],
178
+ query: Mapping[str, str],
179
+ body: object,
180
+ user_id: str | None,
181
+ *,
182
+ report_user_id: bool,
183
+ ) -> MutateResponse:
184
+ """`handle_mutate_request`, and the legacy form that reports no user ID at all."""
185
+ json_body: Any = body
186
+ if isinstance(body, bytes):
187
+ try:
188
+ json_body = json.loads(body, parse_constant=_refuse_constant)
189
+ except ValueError as error:
190
+ logger.error("Failed to parse push body", exc_info=error)
191
+ return _parse_failure("push body", error, [], syntax=True)
192
+
193
+ mutation_ids: list[MutationID] = []
194
+ try:
195
+ push_body = PushBody.from_json(json_body)
196
+ mutation_ids = [MutationID(id=m.id, clientID=m.client_id) for m in push_body.mutations]
197
+ except ValidationError as error:
198
+ logger.error("Failed to parse push body", exc_info=error)
199
+ return _parse_failure("push body", error, mutation_ids)
200
+
201
+ try:
202
+ params = MutateParams.from_json(dict(query))
203
+ except ValidationError as error:
204
+ logger.error("Failed to parse push query parameters", exc_info=error)
205
+ return _parse_failure("push query parameters", error, mutation_ids)
206
+
207
+ if push_body.push_version != 1:
208
+ return ServerPushFailed(
209
+ reason=ErrorReason.UNSUPPORTED_PUSH_VERSION,
210
+ mutationIDs=mutation_ids,
211
+ message=f"Unsupported push version: {js_number(push_body.push_version)}",
212
+ )
213
+
214
+ responses: list[MutationResponse] = []
215
+ processed_count = 0
216
+
217
+ try:
218
+ transactor = _Transactor(db_provider, push_body, params)
219
+
220
+ for m in push_body.mutations:
221
+ if m.type == "custom" and m.name == CLEANUP_RESULTS_MUTATION_NAME:
222
+ try:
223
+ await _process_cleanup_results_mutation(db_provider, m, params)
224
+ except Exception as error:
225
+ # A failed cleanup costs disk, not correctness, so it does not fail the push.
226
+ logger.warning("Failed to process cleanup mutation for client %s", m.client_id, exc_info=error)
227
+ processed_count += 1
228
+ continue
229
+
230
+ check(m.type == "custom", "Expected custom mutation")
231
+ assert isinstance(m, CustomMutation)
232
+ response = await _process_one(transactor, handler, m)
233
+ responses.append(response)
234
+ processed_count += 1
235
+
236
+ if report_user_id:
237
+ return MutateSuccess(mutations=responses, userID=user_id)
238
+ return MutateSuccess(mutations=responses)
239
+ except Exception as error:
240
+ logger.error("Failed to process push request", exc_info=error)
241
+ details = get_error_details(error)
242
+ return ServerPushFailed(
243
+ reason=(
244
+ ErrorReason.OUT_OF_ORDER_MUTATION
245
+ if isinstance(error, OutOfOrderMutation)
246
+ else ErrorReason.DATABASE
247
+ if isinstance(error, DatabaseTransactionError)
248
+ else ErrorReason.INTERNAL
249
+ ),
250
+ message=get_error_message(error),
251
+ # Only the mutations not yet processed.
252
+ mutationIDs=mutation_ids[processed_count:],
253
+ # Upstream drops falsy details, `0` and `""` included.
254
+ details=details if js_truthy(details) else None,
255
+ )
256
+
257
+
258
+ async def _process_one[T](
259
+ transactor: "_Transactor[T]", handler: MutateRequestHandler[T], m: CustomMutation
260
+ ) -> MutationResponse:
261
+ phase: _MutationPhase = "preTransaction"
262
+
263
+ async def transact(callback: TransactFnCallback[T]) -> MutationResponse:
264
+ nonlocal phase
265
+ phase = "transactionPending"
266
+ result = await transactor.transact(m, callback)
267
+ phase = "postCommit"
268
+ return result
269
+
270
+ try:
271
+ return await _application_error_wrapper(lambda: handler(transact, m))
272
+ except ApplicationError as error:
273
+ if phase == "preTransaction":
274
+ await transactor.persist_pre_transaction_failure(m, error)
275
+ elif phase == "postCommit": # pragma: no branch - `transact` never raises an ApplicationError
276
+ logger.error(
277
+ "Post-commit mutation handler failed for mutation %s for client %s", m.id, m.client_id, exc_info=error
278
+ )
279
+ logger.warning("Application error processing mutation %s for client %s", m.id, m.client_id, exc_info=error)
280
+ return _app_error_response(m, error)
281
+
282
+
283
+ class _Transactor[T]:
284
+ def __init__(self, db_provider: Database[T], push: PushBody, params: MutateParams) -> None:
285
+ self._db = db_provider
286
+ self._push = push
287
+ self._params = params
288
+
289
+ async def transact(self, mutation: CustomMutation, callback: TransactFnCallback[T]) -> MutationResponse:
290
+ app_error: ApplicationError | None = None
291
+ while True:
292
+ try:
293
+ result = await self._transact_impl(mutation, callback, app_error)
294
+ if app_error is not None:
295
+ logger.warning(
296
+ "Mutation %s for client %s was retried after an error",
297
+ mutation.id,
298
+ mutation.client_id,
299
+ exc_info=app_error,
300
+ )
301
+ return _app_error_response(mutation, app_error)
302
+ return result
303
+ except OutOfOrderMutation as error:
304
+ logger.error("%s", error)
305
+ raise
306
+ except MutationAlreadyProcessedError as error:
307
+ logger.warning("%s", error)
308
+ return MutationResponse(
309
+ id=MutationID(clientID=mutation.client_id, id=mutation.id),
310
+ result=MutationZeroError(error="alreadyProcessed", details=str(error)),
311
+ )
312
+ except Exception as error:
313
+ if app_error is not None:
314
+ # The retry, which runs no mutator, failed too: nothing to skip to.
315
+ logger.error(
316
+ "Retry also failed for mutation %s for client %s",
317
+ mutation.id,
318
+ mutation.client_id,
319
+ exc_info=error,
320
+ )
321
+ raise
322
+ original: BaseException = error
323
+ if isinstance(error, DatabaseTransactionError) and error.__cause__ is not None:
324
+ original = error.__cause__
325
+ app_error = wrap_with_application_error(original)
326
+ logger.warning(
327
+ "Error processing mutation %s for client %s, retrying without mutator",
328
+ mutation.id,
329
+ mutation.client_id,
330
+ exc_info=app_error,
331
+ )
332
+
333
+ async def persist_pre_transaction_failure(self, mutation: CustomMutation, app_error: ApplicationError) -> None:
334
+ """The handler raised before calling `transact`. The ID still advances and the failure is recorded."""
335
+
336
+ await self._transact_impl(mutation, None, app_error)
337
+
338
+ async def _transact_impl(
339
+ self, mutation: CustomMutation, callback: TransactFnCallback[T] | None, app_error: ApplicationError | None
340
+ ) -> MutationResponse:
341
+ """Bump the ID, then run `callback`, or with `app_error` set, record that error in its place."""
342
+ phase: _DatabaseTransactionPhase = "open"
343
+
344
+ async def run(tx: T, hooks: TransactionProviderHooks) -> MutationResponse:
345
+ nonlocal phase
346
+ phase = "execute"
347
+ await self._check_and_increment_last_mutation_id(hooks, mutation.client_id, mutation.id)
348
+ if app_error is None:
349
+ assert callback is not None
350
+ logger.debug("Executing mutator '%s' (id=%s)", mutation.name, mutation.id)
351
+ await callback(tx, mutation.name, mutation.args[0] if mutation.args else None)
352
+ else:
353
+ await hooks.write_mutation_result(_app_error_response(mutation, app_error))
354
+ return MutationResponse(id=MutationID(clientID=mutation.client_id, id=mutation.id), result=MutationOk())
355
+
356
+ try:
357
+ return await self._db.transaction(
358
+ run,
359
+ TransactionProviderInput(
360
+ upstream_schema=self._params.schema_,
361
+ client_group_id=self._push.client_group_id,
362
+ client_id=mutation.client_id,
363
+ mutation_id=mutation.id,
364
+ ),
365
+ )
366
+ except (ApplicationError, OutOfOrderMutation, MutationAlreadyProcessedError):
367
+ raise
368
+ except Exception as error:
369
+ raise DatabaseTransactionError(phase, error) from error
370
+
371
+ async def _check_and_increment_last_mutation_id(
372
+ self, hooks: TransactionProviderHooks, client_id: str, received: float
373
+ ) -> None:
374
+ last_mutation_id = await hooks.update_client_mutation_id()
375
+ if received < last_mutation_id:
376
+ raise MutationAlreadyProcessedError(client_id, received, last_mutation_id)
377
+ if received > last_mutation_id:
378
+ raise OutOfOrderMutation(client_id, received, last_mutation_id)
379
+
380
+
381
+ def _app_error_response(m: CustomMutation, error: ApplicationError) -> MutationResponse:
382
+ return MutationResponse(
383
+ id=MutationID(clientID=m.client_id, id=m.id),
384
+ result=MutationAppError(message=error.message, details=error.details if js_truthy(error.details) else None),
385
+ )
386
+
387
+
388
+ async def _process_cleanup_results_mutation[T](
389
+ db_provider: Database[T], mutation: CustomMutation, params: MutateParams
390
+ ) -> None:
391
+ """Deletes acknowledged results. An internal mutation, so it never touches `lastMutationID`."""
392
+ try:
393
+ args = parse_wire(_cleanup_results_arg, mutation.args[0] if mutation.args else None)
394
+ except ValidationError as error:
395
+ logger.warning("Cleanup mutation has invalid args", exc_info=error)
396
+ return
397
+
398
+ client_id = args.client_ids[0] if isinstance(args, CleanupResultsBulkArg) else args.client_id
399
+
400
+ async def delete(_tx: T, hooks: TransactionProviderHooks) -> None:
401
+ await hooks.delete_mutation_results(args)
402
+
403
+ # Only the upstream schema matters to the delete; the rest fills the interface.
404
+ await db_provider.transaction(
405
+ delete,
406
+ TransactionProviderInput(
407
+ upstream_schema=params.schema_,
408
+ client_group_id=args.client_group_id,
409
+ client_id=client_id,
410
+ mutation_id=0,
411
+ ),
412
+ )
@@ -0,0 +1,109 @@
1
+ """The query endpoint: turn each named query zero-cache forwards into its AST, in server names.
2
+
3
+ Ported from rocicorp/mono `packages/zero-server/src/queries/process-queries.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+ """
6
+
7
+ import inspect
8
+ import json
9
+ import logging
10
+ from collections.abc import Awaitable, Callable
11
+ from typing import Any
12
+
13
+ from pydantic import TypeAdapter, ValidationError
14
+
15
+ from zero_server.protocol.ast import map_ast
16
+ from zero_server.protocol.custom_queries import (
17
+ AppErroredQuery,
18
+ ParseErroredQuery,
19
+ TransformedQuery,
20
+ TransformRequestMessage,
21
+ )
22
+ from zero_server.protocol.error import ErrorReason, ServerTransformFailed
23
+ from zero_server.protocol.query_server import QuerySuccess
24
+ from zero_server.protocol.wire import parse_wire
25
+ from zero_server.query.builder import Query
26
+ from zero_server.query.registry import QueryParseError
27
+ from zero_server.schema import Schema, client_to_server
28
+ from zero_server.shared.asserts import check
29
+ from zero_server.shared.error import get_error_details, get_error_message
30
+ from zero_server.shared.js import js_truthy
31
+ from zero_server.shared.json import JSONValue
32
+
33
+ logger = logging.getLogger("zero_server.query")
34
+
35
+ type QueryRequestHandler = Callable[[str, JSONValue | None], Query | Awaitable[Query]]
36
+ """Turns a query's name and its single argument into a `Query`."""
37
+
38
+ type QueryResponse = QuerySuccess | ServerTransformFailed
39
+
40
+ _transform_request: TypeAdapter[Any] = TypeAdapter(TransformRequestMessage)
41
+
42
+
43
+ async def handle_query_request(
44
+ *,
45
+ handler: QueryRequestHandler,
46
+ schema: Schema,
47
+ body: object,
48
+ user_id: str | None,
49
+ ) -> QueryResponse:
50
+ """Process one `/query` request from zero-cache.
51
+
52
+ `body` is the request's JSON, raw bytes or already parsed. `user_id` is the authenticated user,
53
+ `None` when logged out; reporting it is what makes zero-cache trust the connection's user.
54
+ """
55
+ query_ids: list[str] = []
56
+ try:
57
+ json_body: Any = json.loads(body) if isinstance(body, bytes) else body
58
+ parsed = parse_wire(_transform_request, json_body)
59
+ query_ids = [r.id for r in parsed[1]]
60
+ except (ValueError, ValidationError) as error:
61
+ logger.error("Failed to parse query request", exc_info=error)
62
+ return ServerTransformFailed(
63
+ reason=ErrorReason.PARSE,
64
+ message=f"Failed to parse query request: {get_error_message(error)}",
65
+ queryIDs=query_ids,
66
+ details={"name": "TypeError" if isinstance(error, ValidationError) else "SyntaxError"},
67
+ )
68
+
69
+ try:
70
+ mapper = client_to_server(schema.tables)
71
+ responses: list[Any] = []
72
+ for request in parsed[1]:
73
+ try:
74
+ result = request_query = handler(request.name, request.args[0] if request.args else None)
75
+ if inspect.isawaitable(request_query):
76
+ result = await request_query
77
+ except Exception as error:
78
+ details = get_error_details(error)
79
+ errored = ParseErroredQuery if isinstance(error, QueryParseError) else AppErroredQuery
80
+ responses.append(
81
+ errored(
82
+ id=request.id,
83
+ name=request.name,
84
+ message=get_error_message(error),
85
+ details=details if js_truthy(details) else None,
86
+ )
87
+ )
88
+ continue
89
+ check(
90
+ isinstance(result, Query),
91
+ "Query does not implement QueryInternals. This likely means there are two copies of Zero in your "
92
+ "runtime.",
93
+ )
94
+ assert isinstance(result, Query)
95
+ try:
96
+ ast = map_ast(result.ast, mapper)
97
+ except Exception as error:
98
+ logger.error("Failed to map AST", exc_info=error)
99
+ raise
100
+ responses.append(TransformedQuery(id=request.id, name=request.name, ast=ast))
101
+ return QuerySuccess(queries=responses, userID=user_id)
102
+ except Exception as error:
103
+ details = get_error_details(error)
104
+ return ServerTransformFailed(
105
+ reason=ErrorReason.INTERNAL,
106
+ message=get_error_message(error),
107
+ queryIDs=query_ids,
108
+ details=details if js_truthy(details) else None,
109
+ )
@@ -0,0 +1,49 @@
1
+ """`PushProcessor`: the older entry point to the mutate endpoint, dispatching to a mutator registry.
2
+
3
+ Ported from rocicorp/mono `packages/zero-server/src/push-processor.ts` at zero/v1.10.0
4
+ (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
5
+
6
+ Unlike `handle_mutate_request`, it reports no user ID to zero-cache.
7
+ """
8
+
9
+ import re
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ from zero_server.mutators import Mutator
14
+ from zero_server.protocol.mutation import CustomMutation, MutationResponse
15
+ from zero_server.server.process_mutations import Database, MutateResponse, TransactFn, handle_mutate
16
+ from zero_server.shared.asserts import check
17
+ from zero_server.shared.json import JSONValue
18
+ from zero_server.shared.registry import Registry, value_at_path
19
+
20
+ # Older mutators were named with `|`, newer ones with `.`.
21
+ SEPARATOR = re.compile(r"[.|]")
22
+
23
+
24
+ class PushProcessor[T]:
25
+ def __init__(self, db_provider: Database[T], context: Any = None) -> None:
26
+ self._db = db_provider
27
+ self._context = context
28
+
29
+ async def process(
30
+ self, mutators: Registry | Mapping[str, Any], query: Mapping[str, str], body: object
31
+ ) -> MutateResponse:
32
+ """Process a push. A mutator is a `Mutator` from `define_mutators`, or a plain `async (tx, args)`."""
33
+ check(body is not None, "body is required when using query params directly")
34
+
35
+ async def handler(transact: TransactFn[T], _mutation: CustomMutation) -> MutationResponse:
36
+ async def dispatch(tx: T, name: str, args: JSONValue | None) -> None:
37
+ await self._dispatch(mutators, tx, name, args)
38
+
39
+ return await transact(dispatch)
40
+
41
+ return await handle_mutate(self._db, handler, query, body, None, report_user_id=False)
42
+
43
+ async def _dispatch(self, mutators: Mapping[str, Any], tx: T, key: str, args: JSONValue | None) -> None:
44
+ mutator = value_at_path(mutators, key, SEPARATOR)
45
+ check(callable(mutator) and not isinstance(mutator, Mapping), f"could not find mutator {key}")
46
+ if isinstance(mutator, Mutator):
47
+ await mutator.fn(args=args, ctx=self._context, tx=tx)
48
+ else:
49
+ await mutator(tx, args)