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.
- zero_server/__init__.py +36 -0
- zero_server/codegen.py +133 -0
- zero_server/integrations/__init__.py +1 -0
- zero_server/integrations/starlette.py +115 -0
- zero_server/mutators.py +87 -0
- zero_server/protocol/__init__.py +1 -0
- zero_server/protocol/application_error.py +36 -0
- zero_server/protocol/ast.py +342 -0
- zero_server/protocol/custom_queries.py +51 -0
- zero_server/protocol/error.py +119 -0
- zero_server/protocol/mutate_server.py +33 -0
- zero_server/protocol/mutation.py +127 -0
- zero_server/protocol/mutation_id.py +12 -0
- zero_server/protocol/push.py +25 -0
- zero_server/protocol/query_server.py +29 -0
- zero_server/protocol/wire.py +88 -0
- zero_server/py.typed +0 -0
- zero_server/query/__init__.py +1 -0
- zero_server/query/builder.py +338 -0
- zero_server/query/expression.py +167 -0
- zero_server/query/registry.py +143 -0
- zero_server/schema.py +142 -0
- zero_server/server/__init__.py +1 -0
- zero_server/server/adapters/__init__.py +1 -0
- zero_server/server/adapters/asyncpg.py +43 -0
- zero_server/server/adapters/psycopg.py +55 -0
- zero_server/server/adapters/sqlalchemy.py +49 -0
- zero_server/server/crud.py +134 -0
- zero_server/server/pg_query_executor.py +70 -0
- zero_server/server/pg_types.py +69 -0
- zero_server/server/process_mutations.py +412 -0
- zero_server/server/process_queries.py +109 -0
- zero_server/server/push_processor.py +49 -0
- zero_server/server/server_schema.py +165 -0
- zero_server/server/transaction.py +70 -0
- zero_server/server/zql_database.py +129 -0
- zero_server/shared/__init__.py +1 -0
- zero_server/shared/asserts.py +19 -0
- zero_server/shared/error.py +97 -0
- zero_server/shared/js.py +121 -0
- zero_server/shared/json.py +27 -0
- zero_server/shared/registry.py +64 -0
- zero_server/z2s/__init__.py +1 -0
- zero_server/z2s/compiler.py +375 -0
- zero_server/z2s/sql.py +130 -0
- zero_server-0.0.1.dist-info/METADATA +134 -0
- zero_server-0.0.1.dist-info/RECORD +50 -0
- zero_server-0.0.1.dist-info/WHEEL +4 -0
- zero_server-0.0.1.dist-info/entry_points.txt +3 -0
- 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)
|